mirror of
https://github.com/coral/sipcord-bridge.git
synced 2026-06-29 17:32:52 -06:00
slopcoding feature: calling from discord commands
This commit is contained in:
parent
d021861858
commit
b18b876cb3
|
|
@ -4,6 +4,9 @@ SIP_PUBLIC_HOST=192.168.0.100
|
||||||
RTP_PUBLIC_IP=192.168.0.100
|
RTP_PUBLIC_IP=192.168.0.100
|
||||||
|
|
||||||
# Optional (defaults shown)
|
# Optional (defaults shown)
|
||||||
|
# DISCORD_OUTBOUND_SIP_HOST=192.168.0.25
|
||||||
|
# DISCORD_OUTBOUND_SIP_PORT=5060
|
||||||
|
# DISCORD_OUTBOUND_SIP_TRANSPORT=udp
|
||||||
# DATA_DIR=/var/lib/sipcord
|
# DATA_DIR=/var/lib/sipcord
|
||||||
# CONFIG_PATH=./config.toml
|
# CONFIG_PATH=./config.toml
|
||||||
# SOUNDS_DIR=./wav
|
# SOUNDS_DIR=./wav
|
||||||
|
|
|
||||||
41
README.md
41
README.md
|
|
@ -23,8 +23,8 @@ This was written a mix between myself and claude, sure, some of it's big slop bu
|
||||||
## Self-host setup notes
|
## Self-host setup notes
|
||||||
|
|
||||||
These notes cover the static-router Docker setup. The bridge maps inbound SIP
|
These notes cover the static-router Docker setup. The bridge maps inbound SIP
|
||||||
extension digits to Discord voice channels; it does not currently expose a
|
extension digits to Discord voice channels, and can also place outbound calls
|
||||||
static Discord command for placing outbound calls to SIP phones.
|
from Discord into a PBX extension when outbound SIP target settings are enabled.
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
|
|
@ -70,6 +70,9 @@ Create a `.env` file:
|
||||||
DISCORD_BOT_TOKEN=your_bot_token_here
|
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||||
SIP_PUBLIC_HOST=192.168.0.100
|
SIP_PUBLIC_HOST=192.168.0.100
|
||||||
RTP_PUBLIC_IP=192.168.0.100
|
RTP_PUBLIC_IP=192.168.0.100
|
||||||
|
DISCORD_OUTBOUND_SIP_HOST=192.168.0.25
|
||||||
|
DISCORD_OUTBOUND_SIP_PORT=5060
|
||||||
|
DISCORD_OUTBOUND_SIP_TRANSPORT=udp
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -78,6 +81,10 @@ example, if FreePBX is `192.168.0.25` and this container runs on an OMV host at
|
||||||
advertised in SIP Contact/SDP headers, and callers must be able to route back to
|
advertised in SIP Contact/SDP headers, and callers must be able to route back to
|
||||||
it.
|
it.
|
||||||
|
|
||||||
|
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
|
||||||
|
means `DISCORD_OUTBOUND_SIP_HOST=192.168.0.25`.
|
||||||
|
|
||||||
Create a `docker-compose.yml`:
|
Create a `docker-compose.yml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|
@ -184,7 +191,32 @@ challenge, a second INVITE with auth, a `200 OK`, and an `ACK`. If the call ends
|
||||||
after about 32 seconds, check that `SIP_PUBLIC_HOST` and `RTP_PUBLIC_IP` are set
|
after about 32 seconds, check that `SIP_PUBLIC_HOST` and `RTP_PUBLIC_IP` are set
|
||||||
to the bridge host address, not the FreePBX address and not `0.0.0.0`.
|
to the bridge host address, not the FreePBX address and not `0.0.0.0`.
|
||||||
|
|
||||||
### 4c. Build from source
|
### 4c. Discord -> extension calling
|
||||||
|
|
||||||
|
If `DISCORD_OUTBOUND_SIP_HOST` is set, the bot registers a `/call` slash command
|
||||||
|
in each guild it is connected to.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/call extension:1101
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- The user running `/call` must already be in a Discord voice channel.
|
||||||
|
- The bot uses that voice channel as the bridge destination.
|
||||||
|
- The bridge dials the requested extension through the configured PBX target, for
|
||||||
|
example `sip:1101@192.168.0.25:5060;transport=udp`.
|
||||||
|
- When the SIP side answers, the phone call is connected to the Discord voice
|
||||||
|
channel where the command was run.
|
||||||
|
|
||||||
|
Current scope:
|
||||||
|
- `/call` is implemented for the static self-host backend.
|
||||||
|
- It dials a configured PBX/SIP host by extension.
|
||||||
|
- It does not yet include a Discord `/hangup` command or rich status updates
|
||||||
|
back into Discord after the initial slash command reply.
|
||||||
|
|
||||||
|
### 4d. Build from source
|
||||||
|
|
||||||
Requires Rust nightly (for `portable_simd`) and system dependencies for pjproject (OpenSSL, Opus, libtiff, etc). See the `Dockerfile` for the full list.
|
Requires Rust nightly (for `portable_simd`) and system dependencies for pjproject (OpenSSL, Opus, libtiff, etc). See the `Dockerfile` for the full list.
|
||||||
|
|
||||||
|
|
@ -217,6 +249,9 @@ Dial `1000` (or whatever you put in `dialplan.toml`) and you should hear the bot
|
||||||
| `RTP_PORT_START` | `10000` | Start of RTP port range |
|
| `RTP_PORT_START` | `10000` | Start of RTP port range |
|
||||||
| `RTP_PORT_END` | `15000` | End of RTP port range |
|
| `RTP_PORT_END` | `15000` | End of RTP port range |
|
||||||
| `RTP_PUBLIC_IP` | *(local address if unset)* | Routable IP advertised in SDP for RTP media |
|
| `RTP_PUBLIC_IP` | *(local address if unset)* | Routable IP advertised in SDP for RTP media |
|
||||||
|
| `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_TRANSPORT` | `udp` | Transport for Discord-originated outbound SIP calls: `udp`, `tcp`, or `tls` |
|
||||||
| `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 |
|
||||||
|
|
|
||||||
|
|
@ -522,8 +522,15 @@ impl BridgeCoordinator {
|
||||||
req.call_id, req.discord_username
|
req.call_id, req.discord_username
|
||||||
);
|
);
|
||||||
|
|
||||||
// Look up the user's SIP contact from the registrar
|
// Either dial the explicitly configured SIP URI, or look up
|
||||||
let contacts = if let Some(ref registrar) = outbound_registrar {
|
// registered contacts for the Discord username.
|
||||||
|
let contacts = if let Some(sip_uri) = req.sip_uri.clone() {
|
||||||
|
vec![(
|
||||||
|
sip_uri,
|
||||||
|
String::new(),
|
||||||
|
crate::services::registrar::SipTransport::Udp,
|
||||||
|
)]
|
||||||
|
} else if let Some(ref registrar) = outbound_registrar {
|
||||||
registrar.get_contacts_for_discord_user(&req.discord_username)
|
registrar.get_contacts_for_discord_user(&req.discord_username)
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|
@ -549,6 +556,16 @@ impl BridgeCoordinator {
|
||||||
|
|
||||||
// Ring ALL registered contacts simultaneously
|
// Ring ALL registered contacts simultaneously
|
||||||
for (contact_uri, source_addr, transport) in &contacts {
|
for (contact_uri, source_addr, transport) in &contacts {
|
||||||
|
if req.sip_uri.is_some() {
|
||||||
|
let _ = outbound_sip_cmd_tx.send(SipCommand::MakeOutboundCall {
|
||||||
|
tracking_id: req.call_id.clone(),
|
||||||
|
sip_uri: contact_uri.clone(),
|
||||||
|
caller_display_name: Some(req.caller_username.clone()),
|
||||||
|
fork_total,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract the user part from the Contact URI (e.g., "sip:3001@10.0.1.151:5060" -> "3001")
|
// Extract the user part from the Contact URI (e.g., "sip:3001@10.0.1.151:5060" -> "3001")
|
||||||
// The contact_uri has the correct SIP username/extension; source_addr is the NAT'd public address
|
// The contact_uri has the correct SIP username/extension; source_addr is the NAT'd public address
|
||||||
let user_part = contact_uri
|
let user_part = contact_uri
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,12 @@ fn default_tls_refresh() -> u64 {
|
||||||
fn default_dialplan_path() -> String {
|
fn default_dialplan_path() -> String {
|
||||||
"./dialplan.toml".to_string()
|
"./dialplan.toml".to_string()
|
||||||
}
|
}
|
||||||
|
fn default_discord_outbound_sip_port() -> u16 {
|
||||||
|
5060
|
||||||
|
}
|
||||||
|
fn default_discord_outbound_sip_transport() -> String {
|
||||||
|
"udp".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// 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)]
|
||||||
|
|
@ -106,6 +112,50 @@ pub struct EnvConfig {
|
||||||
pub discord_bot_token: Option<String>,
|
pub discord_bot_token: Option<String>,
|
||||||
#[serde(default = "default_dialplan_path")]
|
#[serde(default = "default_dialplan_path")]
|
||||||
pub dialplan_path: String,
|
pub dialplan_path: String,
|
||||||
|
pub discord_outbound_sip_host: Option<String>,
|
||||||
|
#[serde(default = "default_discord_outbound_sip_port")]
|
||||||
|
pub discord_outbound_sip_port: u16,
|
||||||
|
#[serde(default = "default_discord_outbound_sip_transport")]
|
||||||
|
pub discord_outbound_sip_transport: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OutboundSipTransport {
|
||||||
|
Udp,
|
||||||
|
Tcp,
|
||||||
|
Tls,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutboundSipTransport {
|
||||||
|
fn parse(raw: &str) -> Option<Self> {
|
||||||
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"udp" => Some(Self::Udp),
|
||||||
|
"tcp" => Some(Self::Tcp),
|
||||||
|
"tls" | "sips" => Some(Self::Tls),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DiscordOutboundSipConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub transport: OutboundSipTransport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscordOutboundSipConfig {
|
||||||
|
pub fn build_sip_uri(&self, extension: &str) -> String {
|
||||||
|
match self.transport {
|
||||||
|
OutboundSipTransport::Udp => {
|
||||||
|
format!("sip:{}@{}:{};transport=udp", extension, self.host, self.port)
|
||||||
|
}
|
||||||
|
OutboundSipTransport::Tcp => {
|
||||||
|
format!("sip:{}@{}:{};transport=tcp", extension, self.host, self.port)
|
||||||
|
}
|
||||||
|
OutboundSipTransport::Tls => format!("sips:{}@{}:{}", extension, self.host, self.port),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EnvConfig {
|
impl EnvConfig {
|
||||||
|
|
@ -172,6 +222,17 @@ impl EnvConfig {
|
||||||
self.sip_public_host.as_deref().unwrap_or("0.0.0.0")
|
self.sip_public_host.as_deref().unwrap_or("0.0.0.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build outbound Discord->SIP call config when enabled.
|
||||||
|
pub fn discord_outbound_sip_config(&self) -> Option<DiscordOutboundSipConfig> {
|
||||||
|
let host = self.discord_outbound_sip_host.clone()?;
|
||||||
|
let transport = OutboundSipTransport::parse(&self.discord_outbound_sip_transport)?;
|
||||||
|
Some(DiscordOutboundSipConfig {
|
||||||
|
host,
|
||||||
|
port: self.discord_outbound_sip_port,
|
||||||
|
transport,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the resolved DATA_DIR path, applying the smart fallback:
|
/// Return the resolved DATA_DIR path, applying the smart fallback:
|
||||||
/// if the default `/var/lib/sipcord` doesn't exist on disk, fall back to `.`.
|
/// if the default `/var/lib/sipcord` doesn't exist on disk, fall back to `.`.
|
||||||
pub fn resolved_data_dir(&self) -> String {
|
pub fn resolved_data_dir(&self) -> String {
|
||||||
|
|
@ -466,6 +527,9 @@ mod tests {
|
||||||
tls_refresh_interval: 3600,
|
tls_refresh_interval: 3600,
|
||||||
discord_bot_token: None,
|
discord_bot_token: None,
|
||||||
dialplan_path: "./dialplan.toml".to_string(),
|
dialplan_path: "./dialplan.toml".to_string(),
|
||||||
|
discord_outbound_sip_host: None,
|
||||||
|
discord_outbound_sip_port: 5060,
|
||||||
|
discord_outbound_sip_transport: "udp".to_string(),
|
||||||
};
|
};
|
||||||
assert_eq!(env.resolved_data_dir(), ".");
|
assert_eq!(env.resolved_data_dir(), ".");
|
||||||
}
|
}
|
||||||
|
|
@ -490,6 +554,9 @@ mod tests {
|
||||||
tls_refresh_interval: 3600,
|
tls_refresh_interval: 3600,
|
||||||
discord_bot_token: None,
|
discord_bot_token: None,
|
||||||
dialplan_path: "./dialplan.toml".to_string(),
|
dialplan_path: "./dialplan.toml".to_string(),
|
||||||
|
discord_outbound_sip_host: None,
|
||||||
|
discord_outbound_sip_port: 5060,
|
||||||
|
discord_outbound_sip_transport: "udp".to_string(),
|
||||||
};
|
};
|
||||||
assert_eq!(env.resolved_data_dir(), "/tmp");
|
assert_eq!(env.resolved_data_dir(), "/tmp");
|
||||||
}
|
}
|
||||||
|
|
@ -514,6 +581,9 @@ mod tests {
|
||||||
tls_refresh_interval: 3600,
|
tls_refresh_interval: 3600,
|
||||||
discord_bot_token: None,
|
discord_bot_token: None,
|
||||||
dialplan_path: "./dialplan.toml".to_string(),
|
dialplan_path: "./dialplan.toml".to_string(),
|
||||||
|
discord_outbound_sip_host: None,
|
||||||
|
discord_outbound_sip_port: 5060,
|
||||||
|
discord_outbound_sip_transport: "udp".to_string(),
|
||||||
};
|
};
|
||||||
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"));
|
||||||
|
|
@ -534,6 +604,38 @@ mod tests {
|
||||||
assert_eq!(tls.key_path(), PathBuf::from("/etc/ssl/sipcord/bridge.key"));
|
assert_eq!(tls.key_path(), PathBuf::from("/etc/ssl/sipcord/bridge.key"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discord_outbound_sip_config_uri() {
|
||||||
|
let env = EnvConfig {
|
||||||
|
data_dir: "/data".to_string(),
|
||||||
|
config_path: "./config.toml".to_string(),
|
||||||
|
bridge_id: "br_test".to_string(),
|
||||||
|
sounds_dir: "./wav".to_string(),
|
||||||
|
dev_mode: false,
|
||||||
|
sip_public_host: None,
|
||||||
|
sip_port: 5060,
|
||||||
|
rtp_port_start: 10000,
|
||||||
|
rtp_port_end: 15000,
|
||||||
|
rtp_public_ip: None,
|
||||||
|
sip_local_host: None,
|
||||||
|
sip_local_cidr: None,
|
||||||
|
tls_cert_dir: None,
|
||||||
|
tls_port: 5061,
|
||||||
|
tls_refresh_interval: 3600,
|
||||||
|
discord_bot_token: None,
|
||||||
|
dialplan_path: "./dialplan.toml".to_string(),
|
||||||
|
discord_outbound_sip_host: Some("192.168.0.25".to_string()),
|
||||||
|
discord_outbound_sip_port: 5060,
|
||||||
|
discord_outbound_sip_transport: "udp".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let outbound = env.discord_outbound_sip_config().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
outbound.build_sip_uri("1101"),
|
||||||
|
"sip:1101@192.168.0.25:5060;transport=udp"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_app_config_load_valid_toml() {
|
fn test_app_config_load_valid_toml() {
|
||||||
let toml_content = r#"
|
let toml_content = r#"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use sipcord_bridge::BridgeError;
|
||||||
use sipcord_bridge::call::BridgeCoordinator;
|
use sipcord_bridge::call::BridgeCoordinator;
|
||||||
use sipcord_bridge::config::{APP_CONFIG, AppConfig, ConfigError, EnvConfig, SipConfig};
|
use sipcord_bridge::config::{APP_CONFIG, AppConfig, ConfigError, EnvConfig, SipConfig};
|
||||||
use sipcord_bridge::routing::static_router::StaticBackend;
|
use sipcord_bridge::routing::static_router::StaticBackend;
|
||||||
use sipcord_bridge::transport::discord::SharedDiscordClient;
|
use sipcord_bridge::transport::discord::{DiscordOutboundCallConfig, SharedDiscordClient};
|
||||||
use sipcord_bridge::transport::sip::SipTransport;
|
use sipcord_bridge::transport::sip::SipTransport;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -58,7 +58,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 backend = Arc::new(StaticBackend::load(&dialplan_path, bot_token.clone())?);
|
let (outbound_request_tx, outbound_request_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let backend = Arc::new(StaticBackend::load(
|
||||||
|
&dialplan_path,
|
||||||
|
bot_token.clone(),
|
||||||
|
outbound_request_rx,
|
||||||
|
)?);
|
||||||
|
|
||||||
// Create SIP transport (no TLS for static router)
|
// Create SIP transport (no TLS for static router)
|
||||||
let sip_transport = SipTransport::new(sip_config.clone(), None);
|
let sip_transport = SipTransport::new(sip_config.clone(), None);
|
||||||
|
|
@ -77,7 +82,14 @@ async fn run_static_router() -> Result<(), BridgeError> {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create shared Discord client
|
// Create shared Discord client
|
||||||
let shared_discord = SharedDiscordClient::new(&bot_token).await?;
|
let outbound_call_config = EnvConfig::global()
|
||||||
|
.discord_outbound_sip_config()
|
||||||
|
.map(|sip| DiscordOutboundCallConfig {
|
||||||
|
sip,
|
||||||
|
request_tx: outbound_request_tx,
|
||||||
|
bot_token: bot_token.clone(),
|
||||||
|
});
|
||||||
|
let shared_discord = SharedDiscordClient::new(&bot_token, outbound_call_config).await?;
|
||||||
info!("Shared Discord client initialized");
|
info!("Shared Discord client initialized");
|
||||||
|
|
||||||
let bridge = BridgeCoordinator::new(
|
let bridge = BridgeCoordinator::new(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ pub struct OutboundCallRequest {
|
||||||
pub channel_id: String,
|
pub channel_id: String,
|
||||||
pub bot_token: String,
|
pub bot_token: String,
|
||||||
pub caller_username: String,
|
pub caller_username: String,
|
||||||
|
pub sip_uri: Option<String>,
|
||||||
pub created_at: std::time::Instant,
|
pub created_at: std::time::Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,11 @@
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::config::ConfigError;
|
use crate::config::ConfigError;
|
||||||
|
|
@ -39,15 +41,20 @@ struct Dialplan {
|
||||||
///
|
///
|
||||||
/// Routes calls by looking up the dialed extension in a TOML dialplan file.
|
/// Routes calls by looking up the dialed extension in a TOML dialplan file.
|
||||||
/// No authentication is performed — any caller dialing a known extension is connected.
|
/// No authentication is performed — any caller dialing a known extension is connected.
|
||||||
/// Outbound calls are not supported.
|
/// Outbound calls can also be queued by the self-host Discord `/call` command.
|
||||||
pub struct StaticBackend {
|
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>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StaticBackend {
|
impl StaticBackend {
|
||||||
/// Load the dialplan from a TOML file. `bot_token` comes from the environment.
|
/// Load the dialplan from a TOML file. `bot_token` comes from the environment.
|
||||||
pub fn load(path: &Path, bot_token: String) -> Result<Self, ConfigError> {
|
pub fn load(
|
||||||
|
path: &Path,
|
||||||
|
bot_token: String,
|
||||||
|
outbound_rx: tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>,
|
||||||
|
) -> 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(),
|
||||||
source,
|
source,
|
||||||
|
|
@ -72,6 +79,7 @@ impl StaticBackend {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
bot_token,
|
bot_token,
|
||||||
extensions: dialplan.extensions,
|
extensions: dialplan.extensions,
|
||||||
|
outbound_rx: Arc::new(Mutex::new(outbound_rx)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,8 +123,7 @@ impl Backend for StaticBackend {
|
||||||
fn report_call_status(&self, _call_id: &str, _status: &str) {}
|
fn report_call_status(&self, _call_id: &str, _status: &str) {}
|
||||||
|
|
||||||
async fn next_outbound_request(&self) -> Option<OutboundCallRequest> {
|
async fn next_outbound_request(&self) -> Option<OutboundCallRequest> {
|
||||||
// Static router doesn't support outbound calls — block forever
|
self.outbound_rx.lock().await.recv().await
|
||||||
std::future::pending().await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,7 +143,8 @@ mod tests {
|
||||||
let path = dir.join("test_dialplan.toml");
|
let path = dir.join("test_dialplan.toml");
|
||||||
std::fs::write(&path, toml_content).unwrap();
|
std::fs::write(&path, toml_content).unwrap();
|
||||||
|
|
||||||
let backend = StaticBackend::load(&path, "test_token".to_string()).unwrap();
|
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let backend = StaticBackend::load(&path, "test_token".to_string(), 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"));
|
||||||
|
|
@ -153,7 +161,8 @@ mod tests {
|
||||||
let path = dir.join("test_route.toml");
|
let path = dir.join("test_route.toml");
|
||||||
std::fs::write(&path, toml_content).unwrap();
|
std::fs::write(&path, toml_content).unwrap();
|
||||||
|
|
||||||
let backend = StaticBackend::load(&path, "tok".to_string()).unwrap();
|
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let backend = StaticBackend::load(&path, "tok".to_string(), rx).unwrap();
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -182,7 +191,8 @@ mod tests {
|
||||||
let path = dir.join("test_route_unknown.toml");
|
let path = dir.join("test_route_unknown.toml");
|
||||||
std::fs::write(&path, toml_content).unwrap();
|
std::fs::write(&path, toml_content).unwrap();
|
||||||
|
|
||||||
let backend = StaticBackend::load(&path, "tok".to_string()).unwrap();
|
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let backend = StaticBackend::load(&path, "tok".to_string(), rx).unwrap();
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -207,7 +217,8 @@ mod tests {
|
||||||
let path = dir.join("test_bad.toml");
|
let path = dir.join("test_bad.toml");
|
||||||
std::fs::write(&path, "this is not valid toml [[[").unwrap();
|
std::fs::write(&path, "this is not valid toml [[[").unwrap();
|
||||||
|
|
||||||
let result = StaticBackend::load(&path, "tok".to_string());
|
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let result = StaticBackend::load(&path, "tok".to_string(), rx);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
mod voice;
|
mod voice;
|
||||||
|
|
||||||
use crate::audio::simd;
|
use crate::audio::simd;
|
||||||
|
use crate::config::DiscordOutboundSipConfig;
|
||||||
|
use crate::routing::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;
|
||||||
|
|
@ -12,7 +14,11 @@ use rubato::{
|
||||||
Async, FixedAsync, Resampler, SincInterpolationParameters, SincInterpolationType,
|
Async, FixedAsync, Resampler, SincInterpolationParameters, SincInterpolationType,
|
||||||
WindowFunction,
|
WindowFunction,
|
||||||
};
|
};
|
||||||
use serenity::all::{ChannelId, Client, Context, EventHandler, FullEvent, GatewayIntents, GuildId};
|
use serenity::all::{
|
||||||
|
ChannelId, Client, Command, CommandInteraction, CommandOptionType, Context, CreateCommand,
|
||||||
|
CreateCommandOption, CreateInteractionResponse, CreateInteractionResponseMessage, EventHandler,
|
||||||
|
FullEvent, GatewayIntents, GuildId, Interaction,
|
||||||
|
};
|
||||||
use serenity::async_trait;
|
use serenity::async_trait;
|
||||||
use serenity::secrets::Token;
|
use serenity::secrets::Token;
|
||||||
use songbird::driver::DecodeMode;
|
use songbird::driver::DecodeMode;
|
||||||
|
|
@ -45,6 +51,9 @@ pub enum DiscordError {
|
||||||
attempts: u32,
|
attempts: u32,
|
||||||
last_error: String,
|
last_error: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[error("failed to register Discord slash command: {0}")]
|
||||||
|
CommandRegistration(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct audio path: SIP audio thread → Discord
|
// Direct audio path: SIP audio thread → Discord
|
||||||
|
|
@ -493,13 +502,23 @@ pub struct SharedDiscordClient {
|
||||||
_client_handle: tokio::task::JoinHandle<()>,
|
_client_handle: tokio::task::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DiscordOutboundCallConfig {
|
||||||
|
pub sip: DiscordOutboundSipConfig,
|
||||||
|
pub request_tx: tokio::sync::mpsc::UnboundedSender<OutboundCallRequest>,
|
||||||
|
pub bot_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl SharedDiscordClient {
|
impl SharedDiscordClient {
|
||||||
/// Create the shared Discord client. Call once at bridge startup.
|
/// Create the shared Discord client. Call once at bridge startup.
|
||||||
///
|
///
|
||||||
/// This opens a single gateway WebSocket connection that stays alive for
|
/// This opens a single gateway WebSocket connection that stays alive for
|
||||||
/// the bridge's lifetime. The returned Songbird manager is used by all
|
/// the bridge's lifetime. The returned Songbird manager is used by all
|
||||||
/// voice connections to join/leave channels.
|
/// voice connections to join/leave channels.
|
||||||
pub async fn new(bot_token: &str) -> Result<Arc<Self>, DiscordError> {
|
pub async fn new(
|
||||||
|
bot_token: &str,
|
||||||
|
outbound_call_config: Option<DiscordOutboundCallConfig>,
|
||||||
|
) -> Result<Arc<Self>, DiscordError> {
|
||||||
info!("Creating shared Discord client (single gateway connection)");
|
info!("Creating shared Discord client (single gateway connection)");
|
||||||
|
|
||||||
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES;
|
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES;
|
||||||
|
|
@ -515,7 +534,10 @@ impl SharedDiscordClient {
|
||||||
.map_err(|e| DiscordError::InvalidToken(format!("{e}")))?;
|
.map_err(|e| DiscordError::InvalidToken(format!("{e}")))?;
|
||||||
|
|
||||||
let mut client = Client::builder(token, intents)
|
let mut client = Client::builder(token, intents)
|
||||||
.event_handler(Arc::new(SharedClientEventHandler { ready_tx }))
|
.event_handler(Arc::new(SharedClientEventHandler {
|
||||||
|
ready_tx,
|
||||||
|
outbound_call_config,
|
||||||
|
}))
|
||||||
.voice_manager(songbird.clone())
|
.voice_manager(songbird.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -565,12 +587,14 @@ impl SharedDiscordClient {
|
||||||
/// Serenity event handler for the shared client
|
/// Serenity event handler for the shared client
|
||||||
struct SharedClientEventHandler {
|
struct SharedClientEventHandler {
|
||||||
ready_tx: Arc<tokio::sync::Mutex<Option<oneshot::Sender<u64>>>>,
|
ready_tx: Arc<tokio::sync::Mutex<Option<oneshot::Sender<u64>>>>,
|
||||||
|
outbound_call_config: Option<DiscordOutboundCallConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl EventHandler for SharedClientEventHandler {
|
impl EventHandler for SharedClientEventHandler {
|
||||||
async fn dispatch(&self, _ctx: &Context, event: &FullEvent) {
|
async fn dispatch(&self, ctx: &Context, event: &FullEvent) {
|
||||||
if let FullEvent::Ready { data_about_bot, .. } = event {
|
match event {
|
||||||
|
FullEvent::Ready { data_about_bot, .. } => {
|
||||||
info!(
|
info!(
|
||||||
"Shared Discord bot connected as {} (ID: {})",
|
"Shared Discord bot connected as {} (ID: {})",
|
||||||
data_about_bot.user.name, data_about_bot.user.id
|
data_about_bot.user.name, data_about_bot.user.id
|
||||||
|
|
@ -578,9 +602,160 @@ impl EventHandler for SharedClientEventHandler {
|
||||||
if let Some(tx) = self.ready_tx.lock().await.take() {
|
if let Some(tx) = self.ready_tx.lock().await.take() {
|
||||||
let _ = tx.send(data_about_bot.user.id.get());
|
let _ = tx.send(data_about_bot.user.id.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.outbound_call_config.is_some() {
|
||||||
|
for guild_status in &data_about_bot.guilds {
|
||||||
|
if let Err(e) = register_call_command(ctx, guild_status.id).await {
|
||||||
|
error!(
|
||||||
|
"Failed to register /call command for guild {}: {}",
|
||||||
|
guild_status.id, e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
FullEvent::InteractionCreate { interaction } => {
|
||||||
|
if let Some(ref cfg) = self.outbound_call_config
|
||||||
|
&& let Interaction::Command(command) = interaction
|
||||||
|
&& command.data.name == "call"
|
||||||
|
{
|
||||||
|
handle_call_command(ctx, command, cfg).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register_call_command(ctx: &Context, guild_id: GuildId) -> Result<(), serenity::Error> {
|
||||||
|
let command = CreateCommand::new("call")
|
||||||
|
.description("Call a SIP/PBX extension from your current voice channel")
|
||||||
|
.add_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::String,
|
||||||
|
"extension",
|
||||||
|
"The extension to dial",
|
||||||
|
)
|
||||||
|
.required(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
Command::create_guild_command(&ctx.http, guild_id, command).await?;
|
||||||
|
info!("Registered /call command for guild {}", guild_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_call_command(
|
||||||
|
ctx: &Context,
|
||||||
|
command: &CommandInteraction,
|
||||||
|
cfg: &DiscordOutboundCallConfig,
|
||||||
|
) {
|
||||||
|
let response = match build_outbound_request(ctx, command, cfg) {
|
||||||
|
Ok(req) => {
|
||||||
|
let extension = req.discord_username.clone();
|
||||||
|
match cfg.request_tx.send(req) {
|
||||||
|
Ok(()) => format!("Dialing extension `{}` from your current voice channel.", extension),
|
||||||
|
Err(_) => "Outbound call queue is unavailable right now.".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(msg) => msg,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = command
|
||||||
|
.create_response(
|
||||||
|
ctx,
|
||||||
|
CreateInteractionResponse::Message(
|
||||||
|
CreateInteractionResponseMessage::new()
|
||||||
|
.content(response)
|
||||||
|
.ephemeral(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("Failed to respond to /call interaction: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_outbound_request(
|
||||||
|
ctx: &Context,
|
||||||
|
command: &CommandInteraction,
|
||||||
|
cfg: &DiscordOutboundCallConfig,
|
||||||
|
) -> Result<OutboundCallRequest, String> {
|
||||||
|
let extension = command
|
||||||
|
.data
|
||||||
|
.options
|
||||||
|
.iter()
|
||||||
|
.find(|opt| opt.name == "extension")
|
||||||
|
.and_then(|opt| opt.value.as_str())
|
||||||
|
.ok_or_else(|| "Missing extension.".to_string())?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if !is_safe_extension(&extension) {
|
||||||
|
return Err(
|
||||||
|
"Extension contains unsupported characters. Use digits or simple SIP-safe extension text."
|
||||||
|
.to_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(|| "Join a voice channel first, then run `/call` there.".to_string())?;
|
||||||
|
|
||||||
|
let caller_username = command
|
||||||
|
.member
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|member| member.nick.clone())
|
||||||
|
.or_else(|| command.user.global_name.clone())
|
||||||
|
.unwrap_or_else(|| command.user.name.clone());
|
||||||
|
|
||||||
|
Ok(OutboundCallRequest {
|
||||||
|
call_id: format!("discord-{}-{}", command.id, extension),
|
||||||
|
discord_username: extension.clone(),
|
||||||
|
guild_id: guild_id.get().to_string(),
|
||||||
|
channel_id: voice_channel_id.get().to_string(),
|
||||||
|
bot_token: cfg.bot_token.clone(),
|
||||||
|
caller_username,
|
||||||
|
sip_uri: Some(cfg.sip.build_sip_uri(&extension)),
|
||||||
|
created_at: std::time::Instant::now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_safe_extension(extension: &str) -> bool {
|
||||||
|
!extension.is_empty()
|
||||||
|
&& extension.len() <= 64
|
||||||
|
&& extension.chars().all(|ch| {
|
||||||
|
ch.is_ascii_alphanumeric() || matches!(ch, '*' | '#' | '+' | '-' | '_' | '.')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod outbound_command_tests {
|
||||||
|
use super::is_safe_extension;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn safe_extensions_are_accepted() {
|
||||||
|
assert!(is_safe_extension("1101"));
|
||||||
|
assert!(is_safe_extension("*98"));
|
||||||
|
assert!(is_safe_extension("queue-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsafe_extensions_are_rejected() {
|
||||||
|
assert!(!is_safe_extension(""));
|
||||||
|
assert!(!is_safe_extension("1101@pbx"));
|
||||||
|
assert!(!is_safe_extension("11 01"));
|
||||||
|
assert!(!is_safe_extension("1101/../../"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Inner state for Discord voice connection
|
/// Inner state for Discord voice connection
|
||||||
struct DiscordVoiceConnectionInner {
|
struct DiscordVoiceConnectionInner {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue