diff --git a/.env.example b/.env.example index e05b4e9..0d57008 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ SIP_PUBLIC_HOST=192.168.0.100 RTP_PUBLIC_IP=192.168.0.100 # 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 # CONFIG_PATH=./config.toml # SOUNDS_DIR=./wav diff --git a/README.md b/README.md index fd30186..2ee06f4 100644 --- a/README.md +++ b/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 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 -static Discord command for placing outbound calls to SIP phones. +extension digits to Discord voice channels, and can also place outbound calls +from Discord into a PBX extension when outbound SIP target settings are enabled. ### Prerequisites @@ -70,6 +70,9 @@ Create a `.env` file: DISCORD_BOT_TOKEN=your_bot_token_here SIP_PUBLIC_HOST=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 @@ -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 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`: ```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 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. @@ -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_END` | `15000` | End of RTP port range | | `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 | | `DIALPLAN_PATH` | `./dialplan.toml` | Path to dialplan.toml | | `SOUNDS_DIR` | `./wav` | Path to sound files directory | diff --git a/sipcord-bridge/src/call/mod.rs b/sipcord-bridge/src/call/mod.rs index 6dd794a..1194ef8 100644 --- a/sipcord-bridge/src/call/mod.rs +++ b/sipcord-bridge/src/call/mod.rs @@ -522,8 +522,15 @@ impl BridgeCoordinator { req.call_id, req.discord_username ); - // Look up the user's SIP contact from the registrar - let contacts = if let Some(ref registrar) = outbound_registrar { + // Either dial the explicitly configured SIP URI, or look up + // 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) } else { Vec::new() @@ -549,6 +556,16 @@ impl BridgeCoordinator { // Ring ALL registered contacts simultaneously 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") // The contact_uri has the correct SIP username/extension; source_addr is the NAT'd public address let user_part = contact_uri diff --git a/sipcord-bridge/src/config.rs b/sipcord-bridge/src/config.rs index 6ae2fc3..d178593 100644 --- a/sipcord-bridge/src/config.rs +++ b/sipcord-bridge/src/config.rs @@ -65,6 +65,12 @@ fn default_tls_refresh() -> u64 { fn default_dialplan_path() -> 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. #[derive(Debug, Clone, serde::Deserialize)] @@ -106,6 +112,50 @@ pub struct EnvConfig { pub discord_bot_token: Option, #[serde(default = "default_dialplan_path")] pub dialplan_path: String, + pub discord_outbound_sip_host: Option, + #[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 { + 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 { @@ -172,6 +222,17 @@ impl EnvConfig { 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 { + 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: /// if the default `/var/lib/sipcord` doesn't exist on disk, fall back to `.`. pub fn resolved_data_dir(&self) -> String { @@ -466,6 +527,9 @@ mod tests { tls_refresh_interval: 3600, discord_bot_token: None, 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(), "."); } @@ -490,6 +554,9 @@ mod tests { tls_refresh_interval: 3600, discord_bot_token: None, 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"); } @@ -514,6 +581,9 @@ mod tests { tls_refresh_interval: 3600, discord_bot_token: None, 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(); 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")); } + #[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] fn test_app_config_load_valid_toml() { let toml_content = r#" diff --git a/sipcord-bridge/src/main.rs b/sipcord-bridge/src/main.rs index 60ed9ec..06e0b83 100644 --- a/sipcord-bridge/src/main.rs +++ b/sipcord-bridge/src/main.rs @@ -15,7 +15,7 @@ use sipcord_bridge::BridgeError; use sipcord_bridge::call::BridgeCoordinator; use sipcord_bridge::config::{APP_CONFIG, AppConfig, ConfigError, EnvConfig, SipConfig}; 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; #[tokio::main] @@ -58,7 +58,12 @@ async fn run_static_router() -> Result<(), BridgeError> { // Load dialplan 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) let sip_transport = SipTransport::new(sip_config.clone(), None); @@ -77,7 +82,14 @@ async fn run_static_router() -> Result<(), BridgeError> { }); // 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"); let bridge = BridgeCoordinator::new( diff --git a/sipcord-bridge/src/routing/mod.rs b/sipcord-bridge/src/routing/mod.rs index 208730c..0ddbaf6 100644 --- a/sipcord-bridge/src/routing/mod.rs +++ b/sipcord-bridge/src/routing/mod.rs @@ -13,6 +13,7 @@ pub struct OutboundCallRequest { pub channel_id: String, pub bot_token: String, pub caller_username: String, + pub sip_uri: Option, pub created_at: std::time::Instant, } diff --git a/sipcord-bridge/src/routing/static_router.rs b/sipcord-bridge/src/routing/static_router.rs index 7d0fb18..5b8a4dc 100644 --- a/sipcord-bridge/src/routing/static_router.rs +++ b/sipcord-bridge/src/routing/static_router.rs @@ -14,9 +14,11 @@ use std::collections::HashMap; use std::path::Path; +use std::sync::Arc; use async_trait::async_trait; use serde::Deserialize; +use tokio::sync::Mutex; use tracing::info; use crate::config::ConfigError; @@ -39,15 +41,20 @@ struct Dialplan { /// /// 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. -/// Outbound calls are not supported. +/// Outbound calls can also be queued by the self-host Discord `/call` command. pub struct StaticBackend { bot_token: String, extensions: HashMap, + outbound_rx: Arc>>, } impl StaticBackend { /// Load the dialplan from a TOML file. `bot_token` comes from the environment. - pub fn load(path: &Path, bot_token: String) -> Result { + pub fn load( + path: &Path, + bot_token: String, + outbound_rx: tokio::sync::mpsc::UnboundedReceiver, + ) -> Result { let content = std::fs::read_to_string(path).map_err(|source| ConfigError::Read { path: path.to_path_buf(), source, @@ -72,6 +79,7 @@ impl StaticBackend { Ok(Self { bot_token, 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) {} async fn next_outbound_request(&self) -> Option { - // Static router doesn't support outbound calls — block forever - std::future::pending().await + self.outbound_rx.lock().await.recv().await } } @@ -136,7 +143,8 @@ mod tests { let path = dir.join("test_dialplan.toml"); 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!(backend.extensions.contains_key("1000")); assert!(backend.extensions.contains_key("2000")); @@ -153,7 +161,8 @@ mod tests { let path = dir.join("test_route.toml"); 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() .build() @@ -182,7 +191,8 @@ mod tests { let path = dir.join("test_route_unknown.toml"); 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() .build() @@ -207,7 +217,8 @@ mod tests { let path = dir.join("test_bad.toml"); 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()); } } diff --git a/sipcord-bridge/src/transport/discord/mod.rs b/sipcord-bridge/src/transport/discord/mod.rs index 6e9deb9..4973256 100644 --- a/sipcord-bridge/src/transport/discord/mod.rs +++ b/sipcord-bridge/src/transport/discord/mod.rs @@ -1,6 +1,8 @@ mod voice; use crate::audio::simd; +use crate::config::DiscordOutboundSipConfig; +use crate::routing::OutboundCallRequest; use crate::services::snowflake::Snowflake; use audioadapter::Adapter; use audioadapter_buffers::direct::SequentialSliceOfVecs; @@ -12,7 +14,11 @@ use rubato::{ Async, FixedAsync, Resampler, SincInterpolationParameters, SincInterpolationType, 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::secrets::Token; use songbird::driver::DecodeMode; @@ -45,6 +51,9 @@ pub enum DiscordError { attempts: u32, last_error: String, }, + + #[error("failed to register Discord slash command: {0}")] + CommandRegistration(String), } // Direct audio path: SIP audio thread → Discord @@ -493,13 +502,23 @@ pub struct SharedDiscordClient { _client_handle: tokio::task::JoinHandle<()>, } +#[derive(Clone)] +pub struct DiscordOutboundCallConfig { + pub sip: DiscordOutboundSipConfig, + pub request_tx: tokio::sync::mpsc::UnboundedSender, + pub bot_token: String, +} + impl SharedDiscordClient { /// Create the shared Discord client. Call once at bridge startup. /// /// This opens a single gateway WebSocket connection that stays alive for /// the bridge's lifetime. The returned Songbird manager is used by all /// voice connections to join/leave channels. - pub async fn new(bot_token: &str) -> Result, DiscordError> { + pub async fn new( + bot_token: &str, + outbound_call_config: Option, + ) -> Result, DiscordError> { info!("Creating shared Discord client (single gateway connection)"); let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES; @@ -515,7 +534,10 @@ impl SharedDiscordClient { .map_err(|e| DiscordError::InvalidToken(format!("{e}")))?; 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()) .await?; @@ -565,20 +587,173 @@ impl SharedDiscordClient { /// Serenity event handler for the shared client struct SharedClientEventHandler { ready_tx: Arc>>>, + outbound_call_config: Option, } #[async_trait] impl EventHandler for SharedClientEventHandler { - async fn dispatch(&self, _ctx: &Context, event: &FullEvent) { - if let FullEvent::Ready { data_about_bot, .. } = event { - info!( - "Shared Discord bot connected as {} (ID: {})", - data_about_bot.user.name, data_about_bot.user.id - ); - if let Some(tx) = self.ready_tx.lock().await.take() { - let _ = tx.send(data_about_bot.user.id.get()); + async fn dispatch(&self, ctx: &Context, event: &FullEvent) { + match event { + FullEvent::Ready { data_about_bot, .. } => { + info!( + "Shared Discord bot connected as {} (ID: {})", + data_about_bot.user.name, data_about_bot.user.id + ); + if let Some(tx) = self.ready_tx.lock().await.take() { + 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 { + 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/../../")); } }