mirror of
https://github.com/coral/sipcord-bridge.git
synced 2026-06-29 09:23:14 -06:00
slop hangup
This commit is contained in:
parent
14fecbb3e1
commit
e4576231ba
|
|
@ -7,7 +7,6 @@ RTP_PUBLIC_IP=192.168.0.100
|
||||||
# DISCORD_OUTBOUND_SIP_HOST=192.168.0.25
|
# DISCORD_OUTBOUND_SIP_HOST=192.168.0.25
|
||||||
# DISCORD_OUTBOUND_SIP_PORT=5060
|
# DISCORD_OUTBOUND_SIP_PORT=5060
|
||||||
# DISCORD_OUTBOUND_SIP_TRANSPORT=udp
|
# DISCORD_OUTBOUND_SIP_TRANSPORT=udp
|
||||||
# DISCORD_OUTBOUND_EXTENSION_PREFIX=
|
|
||||||
# DATA_DIR=/var/lib/sipcord
|
# DATA_DIR=/var/lib/sipcord
|
||||||
# CONFIG_PATH=./config.toml
|
# CONFIG_PATH=./config.toml
|
||||||
# SOUNDS_DIR=./wav
|
# SOUNDS_DIR=./wav
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -73,7 +73,6 @@ RTP_PUBLIC_IP=192.168.0.100
|
||||||
DISCORD_OUTBOUND_SIP_HOST=192.168.0.25
|
DISCORD_OUTBOUND_SIP_HOST=192.168.0.25
|
||||||
DISCORD_OUTBOUND_SIP_PORT=5060
|
DISCORD_OUTBOUND_SIP_PORT=5060
|
||||||
DISCORD_OUTBOUND_SIP_TRANSPORT=udp
|
DISCORD_OUTBOUND_SIP_TRANSPORT=udp
|
||||||
DISCORD_OUTBOUND_EXTENSION_PREFIX=
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Set both IPs to the address other SIP devices use to reach the bridge. For
|
Set both IPs to the address other SIP devices use to reach the bridge. For
|
||||||
|
|
@ -86,12 +85,6 @@ Set `DISCORD_OUTBOUND_SIP_HOST` to the PBX or SIP server that should receive
|
||||||
Discord-originated extension calls. For a FreePBX box at `192.168.0.25`, that
|
Discord-originated extension calls. For a FreePBX box at `192.168.0.25`, that
|
||||||
means `DISCORD_OUTBOUND_SIP_HOST=192.168.0.25`.
|
means `DISCORD_OUTBOUND_SIP_HOST=192.168.0.25`.
|
||||||
|
|
||||||
Discord-originated calls dial the requested extension directly by default. The
|
|
||||||
bridge also attaches common auto-answer headers (`Call-Info:
|
|
||||||
<uri>;answer-after=0` and `Alert-Info: <http://127.0.0.1>;info=Ring Answer`)
|
|
||||||
to Discord-originated outbound calls, and appends `intercom=true` to the SIP
|
|
||||||
URI, so auto-answer phones can behave more like FreePBX intercom targets.
|
|
||||||
|
|
||||||
Create a `docker-compose.yml`:
|
Create a `docker-compose.yml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|
@ -200,13 +193,14 @@ to the bridge host address, not the FreePBX address and not `0.0.0.0`.
|
||||||
|
|
||||||
### 4c. Discord -> extension calling
|
### 4c. Discord -> extension calling
|
||||||
|
|
||||||
If `DISCORD_OUTBOUND_SIP_HOST` is set, the bot registers a `/call` slash command
|
If `DISCORD_OUTBOUND_SIP_HOST` is set, the bot registers `/call` and `/hangup`
|
||||||
in each guild it is connected to.
|
slash commands in each guild it is connected to.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/call extension:1101
|
/call extension:1101
|
||||||
|
/hangup
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
@ -215,17 +209,17 @@ Behavior:
|
||||||
- The bridge dials the requested extension through the configured PBX target.
|
- The bridge dials the requested extension through the configured PBX target.
|
||||||
- It dials the requested extension directly, for example
|
- It dials the requested extension directly, for example
|
||||||
`sip:1101@192.168.0.25:5060;transport=udp`.
|
`sip:1101@192.168.0.25:5060;transport=udp`.
|
||||||
- Discord-originated outbound calls also include auto-answer headers and append
|
|
||||||
`intercom=true` to the SIP URI so phones configured for that behavior can
|
|
||||||
answer immediately.
|
|
||||||
- When the SIP side answers, the phone call is connected to the Discord voice
|
- When the SIP side answers, the phone call is connected to the Discord voice
|
||||||
channel where the command was run.
|
channel where the command was run.
|
||||||
|
- `/hangup` ends active SIP calls connected to the voice channel where the
|
||||||
|
command was run.
|
||||||
|
|
||||||
Current scope:
|
Current scope:
|
||||||
- `/call` is implemented for the static self-host backend.
|
- `/call` is implemented for the static self-host backend.
|
||||||
- It dials a configured PBX/SIP host by extension.
|
- It dials a configured PBX/SIP host by extension.
|
||||||
- It does not yet include a Discord `/hangup` command or rich status updates
|
- `/hangup` ends calls already connected to the current Discord voice channel.
|
||||||
back into Discord after the initial slash command reply.
|
- Rich status updates back into Discord after the initial slash command reply
|
||||||
|
are not implemented yet.
|
||||||
|
|
||||||
### 4d. Build from source
|
### 4d. Build from source
|
||||||
|
|
||||||
|
|
@ -263,7 +257,6 @@ Dial `1000` (or whatever you put in `dialplan.toml`) and you should hear the bot
|
||||||
| `DISCORD_OUTBOUND_SIP_HOST` | *(disabled if unset)* | PBX/SIP host used by Discord `/call` |
|
| `DISCORD_OUTBOUND_SIP_HOST` | *(disabled if unset)* | PBX/SIP host used by Discord `/call` |
|
||||||
| `DISCORD_OUTBOUND_SIP_PORT` | `5060` | Port for Discord-originated outbound SIP calls |
|
| `DISCORD_OUTBOUND_SIP_PORT` | `5060` | Port for Discord-originated outbound SIP calls |
|
||||||
| `DISCORD_OUTBOUND_SIP_TRANSPORT` | `udp` | Transport for Discord-originated outbound SIP calls: `udp`, `tcp`, or `tls` |
|
| `DISCORD_OUTBOUND_SIP_TRANSPORT` | `udp` | Transport for Discord-originated outbound SIP calls: `udp`, `tcp`, or `tls` |
|
||||||
| `DISCORD_OUTBOUND_EXTENSION_PREFIX` | `""` | Optional prefix prepended before the requested extension for Discord-originated calls |
|
|
||||||
| `CONFIG_PATH` | `./config.toml` | Path to config.toml |
|
| `CONFIG_PATH` | `./config.toml` | Path to config.toml |
|
||||||
| `DIALPLAN_PATH` | `./dialplan.toml` | Path to dialplan.toml |
|
| `DIALPLAN_PATH` | `./dialplan.toml` | Path to dialplan.toml |
|
||||||
| `SOUNDS_DIR` | `./wav` | Path to sound files directory |
|
| `SOUNDS_DIR` | `./wav` | Path to sound files directory |
|
||||||
|
|
|
||||||
|
|
@ -553,7 +553,6 @@ impl BridgeCoordinator {
|
||||||
tracking_id: req.call_id.clone(),
|
tracking_id: req.call_id.clone(),
|
||||||
sip_uri,
|
sip_uri,
|
||||||
caller_display_name: Some(req.caller_username.clone()),
|
caller_display_name: Some(req.caller_username.clone()),
|
||||||
auto_answer: true,
|
|
||||||
fork_total,
|
fork_total,
|
||||||
});
|
});
|
||||||
outbound_backend.report_call_status(&req.call_id, "ringing");
|
outbound_backend.report_call_status(&req.call_id, "ringing");
|
||||||
|
|
@ -586,7 +585,6 @@ impl BridgeCoordinator {
|
||||||
tracking_id: req.call_id.clone(),
|
tracking_id: req.call_id.clone(),
|
||||||
sip_uri,
|
sip_uri,
|
||||||
caller_display_name: Some(req.caller_username.clone()),
|
caller_display_name: Some(req.caller_username.clone()),
|
||||||
auto_answer: false,
|
|
||||||
fork_total,
|
fork_total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -595,6 +593,50 @@ impl BridgeCoordinator {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle hangup requests from the backend (Discord /hangup)
|
||||||
|
let hangup_backend = self.backend.clone();
|
||||||
|
let hangup_bridges = self.bridges.clone();
|
||||||
|
let hangup_sip_cmd_tx = self.sip_cmd_tx.clone();
|
||||||
|
|
||||||
|
let hangup_handle = tokio::spawn(async move {
|
||||||
|
while let Some(req) = hangup_backend.next_hangup_request().await {
|
||||||
|
let channel_id = match req.channel_id.parse::<Snowflake>() {
|
||||||
|
Ok(channel_id) => channel_id,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"Invalid /hangup channel id {} for request {}: {}",
|
||||||
|
req.channel_id, req.request_id, e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let call_ids: Vec<CallId> = hangup_bridges
|
||||||
|
.get(&channel_id)
|
||||||
|
.map(|bridge| bridge.sip_calls.iter().copied().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if call_ids.is_empty() {
|
||||||
|
info!(
|
||||||
|
"No active SIP calls to hang up for Discord channel {} (requested by {})",
|
||||||
|
channel_id, req.requested_by
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Hanging up {} active SIP call(s) for Discord channel {} (requested by {})",
|
||||||
|
call_ids.len(),
|
||||||
|
channel_id,
|
||||||
|
req.requested_by
|
||||||
|
);
|
||||||
|
|
||||||
|
for call_id in call_ids {
|
||||||
|
let _ = hangup_sip_cmd_tx.send(SipCommand::Hangup { call_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle Discord events
|
// Handle Discord events
|
||||||
let discord_event_rx = self.discord_event_rx.clone();
|
let discord_event_rx = self.discord_event_rx.clone();
|
||||||
|
|
||||||
|
|
@ -962,6 +1004,7 @@ impl BridgeCoordinator {
|
||||||
_ = discord_handle => { info!("Discord event handler finished"); }
|
_ = discord_handle => { info!("Discord event handler finished"); }
|
||||||
_ = health_check_handle => { info!("Health check handler finished"); }
|
_ = health_check_handle => { info!("Health check handler finished"); }
|
||||||
_ = outbound_handle => { info!("Outbound call handler finished"); }
|
_ = outbound_handle => { info!("Outbound call handler finished"); }
|
||||||
|
_ = hangup_handle => { info!("Hangup request handler finished"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,6 @@ fn default_discord_outbound_sip_port() -> u16 {
|
||||||
fn default_discord_outbound_sip_transport() -> String {
|
fn default_discord_outbound_sip_transport() -> String {
|
||||||
"udp".to_string()
|
"udp".to_string()
|
||||||
}
|
}
|
||||||
fn default_discord_outbound_extension_prefix() -> String {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All environment variables consumed by the bridge, deserialized once at startup.
|
/// All environment variables consumed by the bridge, deserialized once at startup.
|
||||||
#[derive(Debug, Clone, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
|
@ -120,8 +117,6 @@ pub struct EnvConfig {
|
||||||
pub discord_outbound_sip_port: u16,
|
pub discord_outbound_sip_port: u16,
|
||||||
#[serde(default = "default_discord_outbound_sip_transport")]
|
#[serde(default = "default_discord_outbound_sip_transport")]
|
||||||
pub discord_outbound_sip_transport: String,
|
pub discord_outbound_sip_transport: String,
|
||||||
#[serde(default = "default_discord_outbound_extension_prefix")]
|
|
||||||
pub discord_outbound_extension_prefix: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -147,28 +142,18 @@ pub struct DiscordOutboundSipConfig {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub transport: OutboundSipTransport,
|
pub transport: OutboundSipTransport,
|
||||||
pub extension_prefix: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DiscordOutboundSipConfig {
|
impl DiscordOutboundSipConfig {
|
||||||
pub fn build_sip_uri(&self, extension: &str) -> String {
|
pub fn build_sip_uri(&self, extension: &str) -> String {
|
||||||
let dialed_extension = format!("{}{}", self.extension_prefix, extension);
|
|
||||||
match self.transport {
|
match self.transport {
|
||||||
OutboundSipTransport::Udp => {
|
OutboundSipTransport::Udp => {
|
||||||
format!(
|
format!("sip:{}@{}:{};transport=udp", extension, self.host, self.port)
|
||||||
"sip:{}@{}:{};transport=udp",
|
|
||||||
dialed_extension, self.host, self.port
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
OutboundSipTransport::Tcp => {
|
OutboundSipTransport::Tcp => {
|
||||||
format!(
|
format!("sip:{}@{}:{};transport=tcp", extension, self.host, self.port)
|
||||||
"sip:{}@{}:{};transport=tcp",
|
|
||||||
dialed_extension, self.host, self.port
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OutboundSipTransport::Tls => {
|
|
||||||
format!("sips:{}@{}:{}", dialed_extension, self.host, self.port)
|
|
||||||
}
|
}
|
||||||
|
OutboundSipTransport::Tls => format!("sips:{}@{}:{}", extension, self.host, self.port),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -245,7 +230,6 @@ impl EnvConfig {
|
||||||
host,
|
host,
|
||||||
port: self.discord_outbound_sip_port,
|
port: self.discord_outbound_sip_port,
|
||||||
transport,
|
transport,
|
||||||
extension_prefix: self.discord_outbound_extension_prefix.clone(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -546,7 +530,6 @@ mod tests {
|
||||||
discord_outbound_sip_host: None,
|
discord_outbound_sip_host: None,
|
||||||
discord_outbound_sip_port: 5060,
|
discord_outbound_sip_port: 5060,
|
||||||
discord_outbound_sip_transport: "udp".to_string(),
|
discord_outbound_sip_transport: "udp".to_string(),
|
||||||
discord_outbound_extension_prefix: String::new(),
|
|
||||||
};
|
};
|
||||||
assert_eq!(env.resolved_data_dir(), ".");
|
assert_eq!(env.resolved_data_dir(), ".");
|
||||||
}
|
}
|
||||||
|
|
@ -574,7 +557,6 @@ mod tests {
|
||||||
discord_outbound_sip_host: None,
|
discord_outbound_sip_host: None,
|
||||||
discord_outbound_sip_port: 5060,
|
discord_outbound_sip_port: 5060,
|
||||||
discord_outbound_sip_transport: "udp".to_string(),
|
discord_outbound_sip_transport: "udp".to_string(),
|
||||||
discord_outbound_extension_prefix: String::new(),
|
|
||||||
};
|
};
|
||||||
assert_eq!(env.resolved_data_dir(), "/tmp");
|
assert_eq!(env.resolved_data_dir(), "/tmp");
|
||||||
}
|
}
|
||||||
|
|
@ -602,7 +584,6 @@ mod tests {
|
||||||
discord_outbound_sip_host: None,
|
discord_outbound_sip_host: None,
|
||||||
discord_outbound_sip_port: 5060,
|
discord_outbound_sip_port: 5060,
|
||||||
discord_outbound_sip_transport: "udp".to_string(),
|
discord_outbound_sip_transport: "udp".to_string(),
|
||||||
discord_outbound_extension_prefix: String::new(),
|
|
||||||
};
|
};
|
||||||
let tls = env.to_tls_config();
|
let tls = env.to_tls_config();
|
||||||
assert_eq!(tls.cert_dir, PathBuf::from("/data/certs"));
|
assert_eq!(tls.cert_dir, PathBuf::from("/data/certs"));
|
||||||
|
|
@ -646,7 +627,6 @@ mod tests {
|
||||||
discord_outbound_sip_host: Some("192.168.0.25".to_string()),
|
discord_outbound_sip_host: Some("192.168.0.25".to_string()),
|
||||||
discord_outbound_sip_port: 5060,
|
discord_outbound_sip_port: 5060,
|
||||||
discord_outbound_sip_transport: "udp".to_string(),
|
discord_outbound_sip_transport: "udp".to_string(),
|
||||||
discord_outbound_extension_prefix: String::new(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let outbound = env.discord_outbound_sip_config().unwrap();
|
let outbound = env.discord_outbound_sip_config().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,12 @@ async fn run_static_router() -> Result<(), BridgeError> {
|
||||||
// Load dialplan
|
// Load dialplan
|
||||||
let dialplan_path = PathBuf::from(&EnvConfig::global().dialplan_path);
|
let dialplan_path = PathBuf::from(&EnvConfig::global().dialplan_path);
|
||||||
let (outbound_request_tx, outbound_request_rx) = tokio::sync::mpsc::unbounded_channel();
|
let (outbound_request_tx, outbound_request_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let (hangup_request_tx, hangup_request_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let backend = Arc::new(StaticBackend::load(
|
let backend = Arc::new(StaticBackend::load(
|
||||||
&dialplan_path,
|
&dialplan_path,
|
||||||
bot_token.clone(),
|
bot_token.clone(),
|
||||||
outbound_request_rx,
|
outbound_request_rx,
|
||||||
|
hangup_request_rx,
|
||||||
)?);
|
)?);
|
||||||
|
|
||||||
// Create SIP transport (no TLS for static router)
|
// Create SIP transport (no TLS for static router)
|
||||||
|
|
@ -87,6 +89,7 @@ async fn run_static_router() -> Result<(), BridgeError> {
|
||||||
.map(|sip| DiscordOutboundCallConfig {
|
.map(|sip| DiscordOutboundCallConfig {
|
||||||
sip,
|
sip,
|
||||||
request_tx: outbound_request_tx,
|
request_tx: outbound_request_tx,
|
||||||
|
hangup_tx: hangup_request_tx,
|
||||||
bot_token: bot_token.clone(),
|
bot_token: bot_token.clone(),
|
||||||
});
|
});
|
||||||
let shared_discord = SharedDiscordClient::new(&bot_token, outbound_call_config).await?;
|
let shared_discord = SharedDiscordClient::new(&bot_token, outbound_call_config).await?;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,16 @@ pub struct OutboundCallRequest {
|
||||||
pub created_at: std::time::Instant,
|
pub created_at: std::time::Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hangup request from the backend (e.g., Discord /hangup command)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HangupCallRequest {
|
||||||
|
pub request_id: String,
|
||||||
|
pub guild_id: String,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub requested_by: String,
|
||||||
|
pub created_at: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of routing an incoming SIP call
|
/// Result of routing an incoming SIP call
|
||||||
pub enum RouteDecision {
|
pub enum RouteDecision {
|
||||||
/// Connect to this Discord voice channel
|
/// Connect to this Discord voice channel
|
||||||
|
|
@ -104,4 +114,7 @@ pub trait Backend: Send + Sync {
|
||||||
|
|
||||||
/// Get the next outbound call request (None if backend doesn't support outbound)
|
/// Get the next outbound call request (None if backend doesn't support outbound)
|
||||||
async fn next_outbound_request(&self) -> Option<OutboundCallRequest>;
|
async fn next_outbound_request(&self) -> Option<OutboundCallRequest>;
|
||||||
|
|
||||||
|
/// Get the next hangup request (None if backend doesn't support hangup control)
|
||||||
|
async fn next_hangup_request(&self) -> Option<HangupCallRequest>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ use tokio::sync::Mutex;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::config::ConfigError;
|
use crate::config::ConfigError;
|
||||||
use crate::routing::{Backend, CallError, CallStartedInfo, OutboundCallRequest, RouteDecision};
|
use crate::routing::{
|
||||||
|
Backend, CallError, CallStartedInfo, HangupCallRequest, OutboundCallRequest, RouteDecision,
|
||||||
|
};
|
||||||
use crate::services::snowflake::Snowflake;
|
use crate::services::snowflake::Snowflake;
|
||||||
use crate::transport::sip::DigestAuthParams;
|
use crate::transport::sip::DigestAuthParams;
|
||||||
|
|
||||||
|
|
@ -46,6 +48,7 @@ pub struct StaticBackend {
|
||||||
bot_token: String,
|
bot_token: String,
|
||||||
extensions: HashMap<String, ExtensionTarget>,
|
extensions: HashMap<String, ExtensionTarget>,
|
||||||
outbound_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>>>,
|
outbound_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>>>,
|
||||||
|
hangup_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<HangupCallRequest>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StaticBackend {
|
impl StaticBackend {
|
||||||
|
|
@ -54,6 +57,7 @@ impl StaticBackend {
|
||||||
path: &Path,
|
path: &Path,
|
||||||
bot_token: String,
|
bot_token: String,
|
||||||
outbound_rx: tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>,
|
outbound_rx: tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>,
|
||||||
|
hangup_rx: tokio::sync::mpsc::UnboundedReceiver<HangupCallRequest>,
|
||||||
) -> Result<Self, ConfigError> {
|
) -> Result<Self, ConfigError> {
|
||||||
let content = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
|
let content = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
|
|
@ -80,6 +84,7 @@ impl StaticBackend {
|
||||||
bot_token,
|
bot_token,
|
||||||
extensions: dialplan.extensions,
|
extensions: dialplan.extensions,
|
||||||
outbound_rx: Arc::new(Mutex::new(outbound_rx)),
|
outbound_rx: Arc::new(Mutex::new(outbound_rx)),
|
||||||
|
hangup_rx: Arc::new(Mutex::new(hangup_rx)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +130,10 @@ impl Backend for StaticBackend {
|
||||||
async fn next_outbound_request(&self) -> Option<OutboundCallRequest> {
|
async fn next_outbound_request(&self) -> Option<OutboundCallRequest> {
|
||||||
self.outbound_rx.lock().await.recv().await
|
self.outbound_rx.lock().await.recv().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn next_hangup_request(&self) -> Option<HangupCallRequest> {
|
||||||
|
self.hangup_rx.lock().await.recv().await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -144,7 +153,9 @@ mod tests {
|
||||||
std::fs::write(&path, toml_content).unwrap();
|
std::fs::write(&path, toml_content).unwrap();
|
||||||
|
|
||||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let backend = StaticBackend::load(&path, "test_token".to_string(), rx).unwrap();
|
let (_hangup_tx, hangup_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let backend =
|
||||||
|
StaticBackend::load(&path, "test_token".to_string(), rx, hangup_rx).unwrap();
|
||||||
assert_eq!(backend.extensions.len(), 2);
|
assert_eq!(backend.extensions.len(), 2);
|
||||||
assert!(backend.extensions.contains_key("1000"));
|
assert!(backend.extensions.contains_key("1000"));
|
||||||
assert!(backend.extensions.contains_key("2000"));
|
assert!(backend.extensions.contains_key("2000"));
|
||||||
|
|
@ -162,7 +173,8 @@ mod tests {
|
||||||
std::fs::write(&path, toml_content).unwrap();
|
std::fs::write(&path, toml_content).unwrap();
|
||||||
|
|
||||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let backend = StaticBackend::load(&path, "tok".to_string(), rx).unwrap();
|
let (_hangup_tx, hangup_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let backend = StaticBackend::load(&path, "tok".to_string(), rx, hangup_rx).unwrap();
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -192,7 +204,8 @@ mod tests {
|
||||||
std::fs::write(&path, toml_content).unwrap();
|
std::fs::write(&path, toml_content).unwrap();
|
||||||
|
|
||||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let backend = StaticBackend::load(&path, "tok".to_string(), rx).unwrap();
|
let (_hangup_tx, hangup_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let backend = StaticBackend::load(&path, "tok".to_string(), rx, hangup_rx).unwrap();
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -218,7 +231,8 @@ mod tests {
|
||||||
std::fs::write(&path, "this is not valid toml [[[").unwrap();
|
std::fs::write(&path, "this is not valid toml [[[").unwrap();
|
||||||
|
|
||||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let result = StaticBackend::load(&path, "tok".to_string(), rx);
|
let (_hangup_tx, hangup_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let result = StaticBackend::load(&path, "tok".to_string(), rx, hangup_rx);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ mod voice;
|
||||||
|
|
||||||
use crate::audio::simd;
|
use crate::audio::simd;
|
||||||
use crate::config::DiscordOutboundSipConfig;
|
use crate::config::DiscordOutboundSipConfig;
|
||||||
use crate::routing::OutboundCallRequest;
|
use crate::routing::{HangupCallRequest, OutboundCallRequest};
|
||||||
use crate::services::snowflake::Snowflake;
|
use crate::services::snowflake::Snowflake;
|
||||||
use audioadapter::Adapter;
|
use audioadapter::Adapter;
|
||||||
use audioadapter_buffers::direct::SequentialSliceOfVecs;
|
use audioadapter_buffers::direct::SequentialSliceOfVecs;
|
||||||
|
|
@ -506,6 +506,7 @@ pub struct SharedDiscordClient {
|
||||||
pub struct DiscordOutboundCallConfig {
|
pub struct DiscordOutboundCallConfig {
|
||||||
pub sip: DiscordOutboundSipConfig,
|
pub sip: DiscordOutboundSipConfig,
|
||||||
pub request_tx: tokio::sync::mpsc::UnboundedSender<OutboundCallRequest>,
|
pub request_tx: tokio::sync::mpsc::UnboundedSender<OutboundCallRequest>,
|
||||||
|
pub hangup_tx: tokio::sync::mpsc::UnboundedSender<HangupCallRequest>,
|
||||||
pub bot_token: String,
|
pub bot_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -605,9 +606,9 @@ impl EventHandler for SharedClientEventHandler {
|
||||||
|
|
||||||
if self.outbound_call_config.is_some() {
|
if self.outbound_call_config.is_some() {
|
||||||
for guild_status in &data_about_bot.guilds {
|
for guild_status in &data_about_bot.guilds {
|
||||||
if let Err(e) = register_call_command(ctx, guild_status.id).await {
|
if let Err(e) = register_call_commands(ctx, guild_status.id).await {
|
||||||
error!(
|
error!(
|
||||||
"Failed to register /call command for guild {}: {}",
|
"Failed to register call commands for guild {}: {}",
|
||||||
guild_status.id, e
|
guild_status.id, e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -617,9 +618,12 @@ impl EventHandler for SharedClientEventHandler {
|
||||||
FullEvent::InteractionCreate { interaction, .. } => {
|
FullEvent::InteractionCreate { interaction, .. } => {
|
||||||
if let Some(ref cfg) = self.outbound_call_config
|
if let Some(ref cfg) = self.outbound_call_config
|
||||||
&& let Interaction::Command(command) = interaction
|
&& let Interaction::Command(command) = interaction
|
||||||
&& command.data.name == "call"
|
|
||||||
{
|
{
|
||||||
handle_call_command(ctx, command, cfg).await;
|
match command.data.name.as_str() {
|
||||||
|
"call" => handle_call_command(ctx, command, cfg).await,
|
||||||
|
"hangup" => handle_hangup_command(ctx, command, cfg).await,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -627,8 +631,8 @@ impl EventHandler for SharedClientEventHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn register_call_command(ctx: &Context, guild_id: GuildId) -> Result<(), serenity::Error> {
|
async fn register_call_commands(ctx: &Context, guild_id: GuildId) -> Result<(), serenity::Error> {
|
||||||
let command = CreateCommand::new("call")
|
let call_command = CreateCommand::new("call")
|
||||||
.description("Call a SIP/PBX extension from your current voice channel")
|
.description("Call a SIP/PBX extension from your current voice channel")
|
||||||
.add_option(
|
.add_option(
|
||||||
CreateCommandOption::new(
|
CreateCommandOption::new(
|
||||||
|
|
@ -639,8 +643,12 @@ async fn register_call_command(ctx: &Context, guild_id: GuildId) -> Result<(), s
|
||||||
.required(true),
|
.required(true),
|
||||||
);
|
);
|
||||||
|
|
||||||
guild_id.create_command(&ctx.http, command).await?;
|
let hangup_command = CreateCommand::new("hangup")
|
||||||
info!("Registered /call command for guild {}", guild_id);
|
.description("Hang up active SIP calls in your current voice channel");
|
||||||
|
|
||||||
|
guild_id.create_command(&ctx.http, call_command).await?;
|
||||||
|
guild_id.create_command(&ctx.http, hangup_command).await?;
|
||||||
|
info!("Registered /call and /hangup commands for guild {}", guild_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -653,7 +661,10 @@ async fn handle_call_command(
|
||||||
Ok(req) => {
|
Ok(req) => {
|
||||||
let extension = req.discord_username.clone();
|
let extension = req.discord_username.clone();
|
||||||
match cfg.request_tx.send(req) {
|
match cfg.request_tx.send(req) {
|
||||||
Ok(()) => format!("Dialing extension `{}` from your current voice channel.", extension),
|
Ok(()) => format!(
|
||||||
|
"Dialing extension `{}` from your current voice channel.",
|
||||||
|
extension
|
||||||
|
),
|
||||||
Err(_) => "Outbound call queue is unavailable right now.".to_string(),
|
Err(_) => "Outbound call queue is unavailable right now.".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -675,6 +686,34 @@ async fn handle_call_command(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_hangup_command(
|
||||||
|
ctx: &Context,
|
||||||
|
command: &CommandInteraction,
|
||||||
|
cfg: &DiscordOutboundCallConfig,
|
||||||
|
) {
|
||||||
|
let response = match build_hangup_request(ctx, command) {
|
||||||
|
Ok(req) => match cfg.hangup_tx.send(req) {
|
||||||
|
Ok(()) => "Hanging up active calls in your current voice channel.".to_string(),
|
||||||
|
Err(_) => "Hangup queue is unavailable right now.".to_string(),
|
||||||
|
},
|
||||||
|
Err(msg) => msg,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = command
|
||||||
|
.create_response(
|
||||||
|
&ctx.http,
|
||||||
|
CreateInteractionResponse::Message(
|
||||||
|
CreateInteractionResponseMessage::new()
|
||||||
|
.content(response)
|
||||||
|
.ephemeral(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("Failed to respond to /hangup interaction: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_outbound_request(
|
fn build_outbound_request(
|
||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
command: &CommandInteraction,
|
command: &CommandInteraction,
|
||||||
|
|
@ -697,18 +736,7 @@ fn build_outbound_request(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let guild_id = command
|
let (guild_id, voice_channel_id) = current_voice_channel(ctx, command, "/call")?;
|
||||||
.guild_id
|
|
||||||
.ok_or_else(|| "This command only works inside a server.".to_string())?;
|
|
||||||
let guild = ctx
|
|
||||||
.cache
|
|
||||||
.guild(guild_id)
|
|
||||||
.ok_or_else(|| "Guild is not available in cache yet. Try again in a moment.".to_string())?;
|
|
||||||
let voice_channel_id = guild
|
|
||||||
.voice_states
|
|
||||||
.get(&command.user.id)
|
|
||||||
.and_then(|state| state.channel_id)
|
|
||||||
.ok_or_else(|| "Join a voice channel first, then run `/call` there.".to_string())?;
|
|
||||||
|
|
||||||
let caller_username = command
|
let caller_username = command
|
||||||
.member
|
.member
|
||||||
|
|
@ -729,6 +757,49 @@ fn build_outbound_request(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_hangup_request(
|
||||||
|
ctx: &Context,
|
||||||
|
command: &CommandInteraction,
|
||||||
|
) -> Result<HangupCallRequest, String> {
|
||||||
|
let (guild_id, voice_channel_id) = current_voice_channel(ctx, command, "/hangup")?;
|
||||||
|
let requested_by = command
|
||||||
|
.member
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|member| member.nick.clone())
|
||||||
|
.or_else(|| command.user.global_name.clone())
|
||||||
|
.unwrap_or_else(|| command.user.name.clone())
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(HangupCallRequest {
|
||||||
|
request_id: format!("hangup-{}", command.id),
|
||||||
|
guild_id: guild_id.get().to_string(),
|
||||||
|
channel_id: voice_channel_id.get().to_string(),
|
||||||
|
requested_by,
|
||||||
|
created_at: std::time::Instant::now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_voice_channel(
|
||||||
|
ctx: &Context,
|
||||||
|
command: &CommandInteraction,
|
||||||
|
command_name: &str,
|
||||||
|
) -> Result<(GuildId, ChannelId), String> {
|
||||||
|
let guild_id = command
|
||||||
|
.guild_id
|
||||||
|
.ok_or_else(|| "This command only works inside a server.".to_string())?;
|
||||||
|
let guild = ctx
|
||||||
|
.cache
|
||||||
|
.guild(guild_id)
|
||||||
|
.ok_or_else(|| "Guild is not available in cache yet. Try again in a moment.".to_string())?;
|
||||||
|
let voice_channel_id = guild
|
||||||
|
.voice_states
|
||||||
|
.get(&command.user.id)
|
||||||
|
.and_then(|state| state.channel_id)
|
||||||
|
.ok_or_else(|| format!("Join a voice channel first, then run `{command_name}` there."))?;
|
||||||
|
|
||||||
|
Ok((guild_id, voice_channel_id))
|
||||||
|
}
|
||||||
|
|
||||||
fn is_safe_extension(extension: &str) -> bool {
|
fn is_safe_extension(extension: &str) -> bool {
|
||||||
!extension.is_empty()
|
!extension.is_empty()
|
||||||
&& extension.len() <= 64
|
&& extension.len() <= 64
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,6 @@ pub enum SipCommand {
|
||||||
tracking_id: String,
|
tracking_id: String,
|
||||||
sip_uri: String,
|
sip_uri: String,
|
||||||
caller_display_name: Option<String>,
|
caller_display_name: Option<String>,
|
||||||
auto_answer: bool,
|
|
||||||
/// Total number of fork legs for this tracking_id (for multi-contact forking)
|
/// Total number of fork legs for this tracking_id (for multi-contact forking)
|
||||||
fork_total: usize,
|
fork_total: usize,
|
||||||
},
|
},
|
||||||
|
|
@ -408,14 +407,13 @@ fn process_sip_command(cmd: SipCommand, calls: &Arc<DashMap<CallId, CallState>>)
|
||||||
tracking_id,
|
tracking_id,
|
||||||
sip_uri,
|
sip_uri,
|
||||||
caller_display_name,
|
caller_display_name,
|
||||||
auto_answer,
|
|
||||||
fork_total,
|
fork_total,
|
||||||
} => {
|
} => {
|
||||||
info!(
|
info!(
|
||||||
"Making outbound call: tracking_id={}, uri={}, caller={:?}, auto_answer={}, fork={}/{}",
|
"Making outbound call: tracking_id={}, uri={}, caller={:?}, fork={}/{}",
|
||||||
tracking_id, sip_uri, caller_display_name, auto_answer, fork_total, fork_total
|
tracking_id, sip_uri, caller_display_name, fork_total, fork_total
|
||||||
);
|
);
|
||||||
match make_outbound_call(&sip_uri, caller_display_name.as_deref(), auto_answer) {
|
match make_outbound_call(&sip_uri, caller_display_name.as_deref()) {
|
||||||
Ok(call_id) => {
|
Ok(call_id) => {
|
||||||
// Store tracking_id -> call_id mapping
|
// Store tracking_id -> call_id mapping
|
||||||
let outbound_calls = OUTBOUND_CALL_TRACKING.get_or_init(DashMap::new);
|
let outbound_calls = OUTBOUND_CALL_TRACKING.get_or_init(DashMap::new);
|
||||||
|
|
@ -526,24 +524,10 @@ pub fn remove_outbound_tracking(call_id: CallId) -> Option<String> {
|
||||||
fn make_outbound_call(
|
fn make_outbound_call(
|
||||||
sip_uri: &str,
|
sip_uri: &str,
|
||||||
caller_display_name: Option<&str>,
|
caller_display_name: Option<&str>,
|
||||||
auto_answer: bool,
|
|
||||||
) -> Result<CallId, SipCallError> {
|
) -> Result<CallId, SipCallError> {
|
||||||
unsafe {
|
unsafe {
|
||||||
use self::ffi::pj_str::make_string_hdr;
|
|
||||||
let outbound_uri = if auto_answer {
|
|
||||||
if sip_uri.contains("intercom=true") {
|
|
||||||
sip_uri.to_string()
|
|
||||||
} else if let Some((base, params)) = sip_uri.split_once(';') {
|
|
||||||
format!("{base};intercom=true;{params}")
|
|
||||||
} else {
|
|
||||||
format!("{sip_uri};intercom=true")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sip_uri.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let uri =
|
let uri =
|
||||||
std::ffi::CString::new(outbound_uri).map_err(|source| SipCallError::InvalidString {
|
std::ffi::CString::new(sip_uri).map_err(|source| SipCallError::InvalidString {
|
||||||
field: "sip_uri",
|
field: "sip_uri",
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -564,28 +548,6 @@ fn make_outbound_call(
|
||||||
::pjsua::pjsua_msg_data_init(msg_data.as_mut_ptr());
|
::pjsua::pjsua_msg_data_init(msg_data.as_mut_ptr());
|
||||||
let msg_data_ptr = msg_data.assume_init_mut();
|
let msg_data_ptr = msg_data.assume_init_mut();
|
||||||
|
|
||||||
if auto_answer {
|
|
||||||
let pool = ::pjsua::pjsua_pool_create(c"outbound_call".as_ptr(), 512, 512);
|
|
||||||
if pool.is_null() {
|
|
||||||
return Err(SipCallError::MakeCall(-1));
|
|
||||||
}
|
|
||||||
|
|
||||||
let call_info = make_string_hdr(pool, c"Call-Info", "<uri>;answer-after=0")
|
|
||||||
.map_err(|_| SipCallError::MakeCall(-1))?;
|
|
||||||
::pjsua::pj_list_insert_before(
|
|
||||||
&mut msg_data_ptr.hdr_list as *mut _ as *mut ::pjsua::pj_list_type,
|
|
||||||
call_info as *mut ::pjsua::pj_list_type,
|
|
||||||
);
|
|
||||||
|
|
||||||
let alert_info =
|
|
||||||
make_string_hdr(pool, c"Alert-Info", "<http://127.0.0.1>;info=Ring Answer")
|
|
||||||
.map_err(|_| SipCallError::MakeCall(-1))?;
|
|
||||||
::pjsua::pj_list_insert_before(
|
|
||||||
&mut msg_data_ptr.hdr_list as *mut _ as *mut ::pjsua::pj_list_type,
|
|
||||||
alert_info as *mut ::pjsua::pj_list_type,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the From URI with display name: "name" <sip:sipcord@host>
|
// Build the From URI with display name: "name" <sip:sipcord@host>
|
||||||
// The local_uri field overrides the From header in the outgoing INVITE
|
// The local_uri field overrides the From header in the outgoing INVITE
|
||||||
let from_uri_cstring;
|
let from_uri_cstring;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue