From 8a3023e275bbea7a1334e4f055903b7c308ba36a Mon Sep 17 00:00:00 2001 From: legop3 Date: Sun, 14 Jun 2026 04:38:37 -0400 Subject: [PATCH] ivr menu slop pass 1 --- README.md | 35 ++ sipcord-bridge/src/call/mod.rs | 378 +++++++++++++++++++- sipcord-bridge/src/routing/mod.rs | 21 ++ sipcord-bridge/src/routing/static_router.rs | 127 ++++++- sipcord-bridge/src/transport/sip/mod.rs | 6 +- 5 files changed, 561 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 21f5f5f..a5b157a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,17 @@ This means you have to build the call routing backend yourself. I am including a [extensions] 1000 = { guild = "123456789012345620", channel = "987654321012345620" } 2000 = { guild = "123456789012345620", channel = "111222333444555620" } + +[menus.main] +extension = "8000" +prompt = "main_menu" +invalid_prompt = "invalid" +timeout_seconds = 10 +max_attempts = 3 + +[menus.main.options] +1 = { guild = "123456789012345620", channel = "987654321012345620", label = "Lobby" } +2 = { guild = "123456789012345620", channel = "111222333444555620", label = "Workshop" } ``` but if you want more fancy routing you have to build it. You can easily use sipcord-bridge as a library and provide your own routers by implementing the `Backend` trait. @@ -56,6 +67,26 @@ Create a `dialplan.toml` mapping extensions to Discord channels: Each extension is what you'll dial from your SIP phone. Pick any numbers you like. +You can also add a simple phone menu. A caller dials the menu extension, hears +the prompt, presses a digit, and Sipcord joins the selected Discord voice +channel: + +```toml +[menus.main] +extension = "8000" +prompt = "main_menu" +invalid_prompt = "invalid" +timeout_seconds = 10 +max_attempts = 3 + +[menus.main.options] +1 = { guild = "123456789012345678", channel = "987654321012345678", label = "Lobby" } +2 = { guild = "123456789012345678", channel = "111222333444555666", label = "Workshop" } +``` + +`prompt` and `invalid_prompt` are optional sound names from `config.toml`. +They must be preloaded 16kHz mono audio files. Press `#` to repeat the menu. + ### 4a. Run with Docker (recommended) Create a directory for your deployment: @@ -191,6 +222,10 @@ 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`. +For a menu extension, route the menu number the same way. For example, dialing +`88000` can strip the `8` prefix and send `8000` to Sipcord, where +`[menus.main] extension = "8000"` answers and collects DTMF. + ### 4c. Discord -> extension calling If `DISCORD_OUTBOUND_SIP_HOST` is set, the bot registers `/call` and `/hangup` diff --git a/sipcord-bridge/src/call/mod.rs b/sipcord-bridge/src/call/mod.rs index 4a81489..a9aeb09 100644 --- a/sipcord-bridge/src/call/mod.rs +++ b/sipcord-bridge/src/call/mod.rs @@ -16,7 +16,10 @@ use crate::fax::session::FaxSession; use crate::fax::spandsp::FaxT38Receiver; -use crate::routing::{Backend, CallError, CallStartedInfo, OutboundCallRequest, RouteDecision}; +use crate::routing::{ + Backend, CallError, CallStartedInfo, MenuOptionRoute, MenuRoute, OutboundCallRequest, + RouteDecision, +}; use crate::services::snowflake::Snowflake; use crate::services::sound::{SoundManager, create_sound_manager}; use crate::transport::discord::{ @@ -36,6 +39,7 @@ use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; +use tokio::sync::mpsc; use tokio::sync::Notify; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, trace, warn}; @@ -109,6 +113,7 @@ struct BridgeContext { /// Notify waiters when a pending bridge completes (or fails) bridge_ready_notifiers: Arc>>, sip_calls: Arc>, + dtmf_waiters: Arc>>, /// Active fax sessions keyed by SIP call ID. /// Each entry holds the session and a cancellation token for the T.38 processing task. fax_sessions: Arc>, @@ -129,6 +134,7 @@ pub struct BridgeCoordinator { pending_bridges: Arc>, bridge_ready_notifiers: Arc>>, sip_calls: Arc>, + dtmf_waiters: Arc>>, /// Active fax sessions keyed by SIP call ID. /// Each entry holds the session and a cancellation token for the T.38 processing task. fax_sessions: Arc>, @@ -162,6 +168,7 @@ impl BridgeCoordinator { pending_bridges: Arc::new(DashSet::new()), bridge_ready_notifiers: Arc::new(DashMap::new()), sip_calls: Arc::new(DashMap::new()), + dtmf_waiters: Arc::new(DashMap::new()), fax_sessions: Arc::new(DashMap::new()), outbound_requests: Arc::new(DashMap::new()), discord_event_tx, @@ -186,6 +193,7 @@ impl BridgeCoordinator { pending_bridges: self.pending_bridges.clone(), bridge_ready_notifiers: self.bridge_ready_notifiers.clone(), sip_calls: self.sip_calls.clone(), + dtmf_waiters: self.dtmf_waiters.clone(), fax_sessions: self.fax_sessions.clone(), discord_event_tx: self.discord_event_tx.clone(), sip_cmd_tx: self.sip_cmd_tx.clone(), @@ -268,6 +276,7 @@ impl BridgeCoordinator { } SipEvent::CallEnded { call_id } => { + ctx.dtmf_waiters.remove(&call_id); unregister_call_channel(call_id); stop_loop(call_id); @@ -360,6 +369,17 @@ impl BridgeCoordinator { } } + SipEvent::Dtmf { call_id, digit } => { + if let Some(waiter) = ctx.dtmf_waiters.get(&call_id) { + let _ = waiter.send(digit); + } else { + debug!( + "Ignoring DTMF {} on call {} (no active waiter)", + digit, call_id + ); + } + } + SipEvent::CallTimeout { call_id, rx_count } => { warn!( "Call {} timed out due to RTP inactivity (rx_count={}), forcing hangup", @@ -1025,6 +1045,7 @@ async fn handle_incoming_call( pending_bridges, bridge_ready_notifiers, sip_calls, + dtmf_waiters, fax_sessions, discord_event_tx, sip_cmd_tx, @@ -1035,9 +1056,13 @@ async fn handle_incoming_call( // Route the call via the backend FIRST to determine call type let decision = backend.route_call(&digest_auth, &extension).await; - // For non-fax calls: send 183 Session Progress and play connecting sound - let is_fax = matches!(decision, RouteDecision::ConnectFax { .. }); - if !is_fax { + // For normal voice calls: send 183 Session Progress and play connecting sound. + // Menu calls are answered immediately so the caller can hear prompts and send DTMF. + let use_connecting_audio = !matches!( + decision, + RouteDecision::ConnectFax { .. } | RouteDecision::Menu { .. } + ); + if use_connecting_audio { let _ = sip_cmd_tx.send(SipCommand::Send183 { call_id }); tokio::time::sleep(Duration::from_millis(100)).await; @@ -1152,6 +1177,28 @@ async fn handle_incoming_call( // Discord embeds are only relevant for voice calls. Fax has its own notifications. } + RouteDecision::Menu { menu } => { + handle_menu_call( + MenuCallContext { + backend, + bridges, + pending_bridges, + bridge_ready_notifiers, + sip_calls, + dtmf_waiters, + discord_event_tx, + sip_cmd_tx, + sound_manager, + shared_discord, + health_check_notify, + }, + call_id, + extension, + menu, + ) + .await; + } + RouteDecision::Connect { channel_id, guild_id, @@ -1401,6 +1448,328 @@ async fn handle_incoming_call( } } +struct MenuCallContext { + backend: Arc, + bridges: Arc>, + pending_bridges: Arc>, + bridge_ready_notifiers: Arc>>, + sip_calls: Arc>, + dtmf_waiters: Arc>>, + discord_event_tx: Sender, + sip_cmd_tx: Sender, + sound_manager: Arc, + shared_discord: Arc, + health_check_notify: Arc, +} + +async fn handle_menu_call( + ctx: MenuCallContext, + call_id: CallId, + extension: String, + menu: MenuRoute, +) { + info!( + "Starting menu {} for call {} on extension {}", + menu.id, call_id, extension + ); + + let _ = ctx.sip_cmd_tx.send(SipCommand::Answer { call_id }); + tokio::time::sleep(Duration::from_millis(200)).await; + + let (dtmf_tx, mut dtmf_rx) = mpsc::unbounded_channel(); + ctx.dtmf_waiters.insert(call_id, dtmf_tx); + + let max_attempts = menu.max_attempts.max(1); + let mut attempts = 0u8; + let selected = loop { + if !ctx.sip_calls.contains_key(&call_id) { + ctx.dtmf_waiters.remove(&call_id); + return; + } + + if let Some(prompt) = menu.prompt.as_deref() { + play_named_prompt(call_id, prompt, &ctx.sound_manager, &ctx.sip_cmd_tx).await; + } + + let digit = match tokio::time::timeout( + Duration::from_secs(menu.timeout_seconds.max(1)), + dtmf_rx.recv(), + ) + .await + { + Ok(Some(digit)) => digit, + Ok(None) => { + warn!("Menu {} DTMF channel closed for call {}", menu.id, call_id); + ctx.dtmf_waiters.remove(&call_id); + let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id }); + return; + } + Err(_) => { + attempts = attempts.saturating_add(1); + warn!( + "Menu {} timed out waiting for DTMF on call {} ({}/{})", + menu.id, call_id, attempts, max_attempts + ); + if attempts >= max_attempts { + ctx.dtmf_waiters.remove(&call_id); + let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id }); + return; + } + if let Some(invalid_prompt) = menu.invalid_prompt.as_deref() { + play_named_prompt(call_id, invalid_prompt, &ctx.sound_manager, &ctx.sip_cmd_tx) + .await; + } + continue; + } + }; + + if digit == '#' { + info!("Repeating menu {} for call {}", menu.id, call_id); + continue; + } + + if let Some(option) = menu.options.get(&digit) { + break option.clone(); + } + + attempts = attempts.saturating_add(1); + warn!( + "Invalid menu digit {} for menu {} on call {} ({}/{})", + digit, menu.id, call_id, attempts, max_attempts + ); + if attempts >= max_attempts { + ctx.dtmf_waiters.remove(&call_id); + let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id }); + return; + } + if let Some(invalid_prompt) = menu.invalid_prompt.as_deref() { + play_named_prompt(call_id, invalid_prompt, &ctx.sound_manager, &ctx.sip_cmd_tx).await; + } + }; + + ctx.dtmf_waiters.remove(&call_id); + connect_menu_selection(ctx, call_id, extension, selected).await; +} + +async fn play_named_prompt( + call_id: CallId, + sound_name: &str, + sound_manager: &SoundManager, + sip_cmd_tx: &Sender, +) { + if let Some(sound) = sound_manager.get_preloaded(sound_name) { + info!("Playing menu prompt '{}' for call {}", sound_name, call_id); + let _ = sip_cmd_tx.send(SipCommand::PlayDirectToCall { + call_id, + samples: (*sound.samples).clone(), + }); + tokio::time::sleep(Duration::from_millis(sound.duration_ms + 100)).await; + } else { + warn!("Menu prompt sound '{}' is not preloaded or does not exist", sound_name); + } +} + +async fn connect_menu_selection( + ctx: MenuCallContext, + call_id: CallId, + extension: String, + selected: MenuOptionRoute, +) { + let channel_id = selected.channel_id; + let guild_id = selected.guild_id; + let user_id = "menu".to_string(); + let bot_token = ctx.backend.bot_token().to_string(); + + info!( + "Menu call {} selected channel {} ({})", + call_id, + channel_id, + selected.label.as_deref().unwrap_or("unlabeled") + ); + + let mut conflicting_channel: Option = None; + for entry in ctx.bridges.iter() { + let existing_channel_id = *entry.key(); + let existing_bridge = entry.value(); + + if existing_bridge.guild_id == guild_id && existing_channel_id != channel_id { + conflicting_channel = Some(existing_channel_id); + break; + } + } + + if let Some(existing_channel_id) = conflicting_channel { + warn!( + "Guild {} already has active bridge to channel {} (menu call {} tried to join channel {})", + guild_id, existing_channel_id, call_id, channel_id + ); + play_error_and_hangup( + call_id, + CallError::ServerBusy, + &ctx.sound_manager, + &ctx.sip_cmd_tx, + ) + .await; + ctx.sip_calls.remove(&call_id); + return; + } + + let bridge_exists = ctx.bridges.contains_key(&channel_id); + let bridge_pending = ctx.pending_bridges.contains(&channel_id); + + if bridge_pending && !bridge_exists { + info!( + "Menu call {} waiting for pending bridge for channel {}", + call_id, channel_id + ); + let notify = ctx + .bridge_ready_notifiers + .entry(channel_id) + .or_insert_with(|| Arc::new(Notify::new())) + .clone(); + + let wait_result = tokio::time::timeout(Duration::from_secs(15), async { + loop { + if ctx.bridges.contains_key(&channel_id) + || !ctx.pending_bridges.contains(&channel_id) + { + return true; + } + if !ctx.sip_calls.contains_key(&call_id) { + return false; + } + notify.notified().await; + } + }) + .await; + + if !matches!(wait_result, Ok(true)) { + play_error_and_hangup( + call_id, + CallError::Unknown, + &ctx.sound_manager, + &ctx.sip_cmd_tx, + ) + .await; + ctx.sip_calls.remove(&call_id); + return; + } + } + + if ctx.bridges.contains_key(&channel_id) { + if !ctx.sip_calls.contains_key(&call_id) { + warn!("Menu call {} ended during routing", call_id); + return; + } + + if let Some(mut call) = ctx.sip_calls.get_mut(&call_id) { + call.channel_id = Some(channel_id); + call._user_id = Some(user_id.clone()); + call._guild_id = Some(guild_id); + } + + if let Some(mut bridge) = ctx.bridges.get_mut(&channel_id) { + bridge.sip_calls.insert(call_id); + bridge.last_call_time = Instant::now(); + } + + register_call_channel(call_id, channel_id); + let backend = ctx.backend.clone(); + let info = CallStartedInfo { + sip_call_id: call_id.to_string(), + user_id, + guild_id: guild_id.to_string(), + channel_id: channel_id.to_string(), + extension, + }; + tokio::spawn(async move { + backend.on_call_started(&info).await; + }); + play_discord_join(call_id, &ctx.sound_manager, &ctx.sip_cmd_tx).await; + return; + } + + if !ctx.sip_calls.contains_key(&call_id) { + warn!("Menu call {} ended before creating bridge", call_id); + return; + } + + ctx.pending_bridges.insert(channel_id); + let bridge_id = format!("bridge_{}", channel_id); + match DiscordVoiceConnection::connect( + bridge_id, + &ctx.shared_discord, + guild_id, + channel_id, + ctx.discord_event_tx.clone(), + ctx.health_check_notify.clone(), + ) + .await + { + Ok(connection) => { + if !ctx.sip_calls.contains_key(&call_id) { + connection.disconnect().await; + ctx.pending_bridges.remove(&channel_id); + notify_bridge_ready(&ctx.bridge_ready_notifiers, channel_id); + return; + } + + setup_channel_ring_buffers(channel_id); + let mut sip_calls_set = HashSet::new(); + sip_calls_set.insert(call_id); + ctx.bridges.insert( + channel_id, + ChannelBridge { + guild_id, + discord_connection: connection, + sip_calls: sip_calls_set, + bot_token, + last_call_time: Instant::now(), + created_at: Instant::now(), + reconnect_attempts: 0, + last_reconnect_at: None, + }, + ); + + ctx.pending_bridges.remove(&channel_id); + notify_bridge_ready(&ctx.bridge_ready_notifiers, channel_id); + + if let Some(mut call) = ctx.sip_calls.get_mut(&call_id) { + call.channel_id = Some(channel_id); + call._user_id = Some(user_id.clone()); + call._guild_id = Some(guild_id); + } + + register_call_channel(call_id, channel_id); + let backend = ctx.backend.clone(); + let info = CallStartedInfo { + sip_call_id: call_id.to_string(), + user_id, + guild_id: guild_id.to_string(), + channel_id: channel_id.to_string(), + extension, + }; + tokio::spawn(async move { + backend.on_call_started(&info).await; + }); + play_discord_join(call_id, &ctx.sound_manager, &ctx.sip_cmd_tx).await; + } + Err(e) => { + ctx.pending_bridges.remove(&channel_id); + notify_bridge_ready(&ctx.bridge_ready_notifiers, channel_id); + error!("Failed to connect menu call {} to Discord: {}", call_id, e); + play_error_and_hangup( + call_id, + CallError::Unknown, + &ctx.sound_manager, + &ctx.sip_cmd_tx, + ) + .await; + ctx.sip_calls.remove(&call_id); + } + } +} + /// Handle an outbound call that was answered (phone picked up) /// /// This mirrors handle_incoming_call but skips authentication (already done by the DO) @@ -1417,6 +1786,7 @@ async fn handle_outbound_call_answered( pending_bridges, bridge_ready_notifiers, sip_calls, + dtmf_waiters: _, fax_sessions: _, discord_event_tx, sip_cmd_tx, diff --git a/sipcord-bridge/src/routing/mod.rs b/sipcord-bridge/src/routing/mod.rs index 4bda564..bb02d9b 100644 --- a/sipcord-bridge/src/routing/mod.rs +++ b/sipcord-bridge/src/routing/mod.rs @@ -27,6 +27,25 @@ pub struct HangupCallRequest { pub created_at: std::time::Instant, } +/// A single static IVR menu option. +#[derive(Debug, Clone)] +pub struct MenuOptionRoute { + pub guild_id: Snowflake, + pub channel_id: Snowflake, + pub label: Option, +} + +/// Static IVR menu route. +#[derive(Debug, Clone)] +pub struct MenuRoute { + pub id: String, + pub prompt: Option, + pub invalid_prompt: Option, + pub timeout_seconds: u64, + pub max_attempts: u8, + pub options: std::collections::HashMap, +} + /// Result of routing an incoming SIP call pub enum RouteDecision { /// Connect to this Discord voice channel @@ -43,6 +62,8 @@ pub enum RouteDecision { user_id: String, bot_token: String, }, + /// Answer the call and collect DTMF before selecting a Discord voice channel + Menu { menu: MenuRoute }, /// Redirect to another bridge server Redirect { domain: String, extension: String }, /// Reject with invalid credentials (no error sound, just hangup) diff --git a/sipcord-bridge/src/routing/static_router.rs b/sipcord-bridge/src/routing/static_router.rs index 70a7065..706e47d 100644 --- a/sipcord-bridge/src/routing/static_router.rs +++ b/sipcord-bridge/src/routing/static_router.rs @@ -23,7 +23,8 @@ use tracing::info; use crate::config::ConfigError; use crate::routing::{ - Backend, CallError, CallStartedInfo, HangupCallRequest, OutboundCallRequest, RouteDecision, + Backend, CallError, CallStartedInfo, HangupCallRequest, MenuOptionRoute, MenuRoute, + OutboundCallRequest, RouteDecision, }; use crate::services::snowflake::Snowflake; use crate::transport::sip::DigestAuthParams; @@ -34,9 +35,39 @@ struct ExtensionTarget { channel: Snowflake, } +#[derive(Deserialize, Clone)] +struct MenuOptionTarget { + guild: Snowflake, + channel: Snowflake, + label: Option, +} + +#[derive(Deserialize, Clone)] +struct MenuConfig { + extension: String, + prompt: Option, + invalid_prompt: Option, + #[serde(default = "default_menu_timeout_seconds")] + timeout_seconds: u64, + #[serde(default = "default_menu_max_attempts")] + max_attempts: u8, + options: HashMap, +} + #[derive(Deserialize)] struct Dialplan { + #[serde(default)] extensions: HashMap, + #[serde(default)] + menus: HashMap, +} + +fn default_menu_timeout_seconds() -> u64 { + 10 +} + +fn default_menu_max_attempts() -> u8 { + 3 } /// Static file-based routing backend. @@ -47,6 +78,7 @@ struct Dialplan { pub struct StaticBackend { bot_token: String, extensions: HashMap, + menus: HashMap, outbound_rx: Arc>>, hangup_rx: Arc>>, } @@ -79,10 +111,22 @@ impl StaticBackend { ext, target.guild, target.channel ); } + if !dialplan.menus.is_empty() { + info!("Loaded {} static menu(s)", dialplan.menus.len()); + for (id, menu) in &dialplan.menus { + info!( + " menu {} on ext {} ({} options)", + id, + menu.extension, + menu.options.len() + ); + } + } Ok(Self { bot_token, extensions: dialplan.extensions, + menus: dialplan.menus, outbound_rx: Arc::new(Mutex::new(outbound_rx)), hangup_rx: Arc::new(Mutex::new(hangup_rx)), }) @@ -96,6 +140,39 @@ impl Backend for StaticBackend { } async fn route_call(&self, _digest_auth: &DigestAuthParams, extension: &str) -> RouteDecision { + if let Some((id, menu)) = self + .menus + .iter() + .find(|(_, menu)| menu.extension == extension) + { + let options = menu + .options + .iter() + .filter_map(|(digit, target)| { + let digit = digit.chars().next()?; + Some(( + digit, + MenuOptionRoute { + guild_id: target.guild, + channel_id: target.channel, + label: target.label.clone(), + }, + )) + }) + .collect(); + + return RouteDecision::Menu { + menu: MenuRoute { + id: id.clone(), + prompt: menu.prompt.clone(), + invalid_prompt: menu.invalid_prompt.clone(), + timeout_seconds: menu.timeout_seconds, + max_attempts: menu.max_attempts, + options, + }, + }; + } + match self.extensions.get(extension) { Some(target) => RouteDecision::Connect { channel_id: target.channel, @@ -223,6 +300,54 @@ mod tests { }); } + #[test] + fn test_route_menu_extension() { + let toml_content = r#" +[menus.main] +extension = "8000" +prompt = "main_menu" +invalid_prompt = "invalid" +timeout_seconds = 7 +max_attempts = 2 + +[menus.main.options] +1 = { guild = 111, channel = 222, label = "Lobby" } +2 = { guild = 333, channel = 444, label = "Workshop" } +"#; + let dir = std::env::temp_dir().join("sipcord_test_dialplan"); + std::fs::create_dir_all(&dir).ok(); + let path = dir.join("test_route_menu.toml"); + std::fs::write(&path, toml_content).unwrap(); + + let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); + 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() + .build() + .unwrap(); + rt.block_on(async { + let decision = backend + .route_call(&DigestAuthParams::default(), "8000") + .await; + match decision { + RouteDecision::Menu { menu } => { + assert_eq!(menu.id, "main"); + assert_eq!(menu.prompt.as_deref(), Some("main_menu")); + assert_eq!(menu.invalid_prompt.as_deref(), Some("invalid")); + assert_eq!(menu.timeout_seconds, 7); + assert_eq!(menu.max_attempts, 2); + assert_eq!(menu.options.get(&'1').unwrap().channel_id, Snowflake::new(222)); + assert_eq!( + menu.options.get(&'2').unwrap().label.as_deref(), + Some("Workshop") + ); + } + _ => panic!("Expected Menu"), + } + }); + } + #[test] fn test_load_malformed_toml() { let dir = std::env::temp_dir().join("sipcord_test_dialplan"); diff --git a/sipcord-bridge/src/transport/sip/mod.rs b/sipcord-bridge/src/transport/sip/mod.rs index 77bbc17..afe5b28 100644 --- a/sipcord-bridge/src/transport/sip/mod.rs +++ b/sipcord-bridge/src/transport/sip/mod.rs @@ -49,6 +49,8 @@ pub enum SipEvent { }, /// Call ended CallEnded { call_id: CallId }, + /// DTMF digit received on a call + Dtmf { call_id: CallId, digit: char }, /// Call timed out due to RTP inactivity (no audio received for extended period) /// rx_count is the total RTP packets received before timeout (0 = never got any audio) CallTimeout { call_id: CallId, rx_count: u64 }, @@ -276,11 +278,13 @@ fn run_pjsua_loop( } }), on_dtmf: Box::new({ + let event_tx = event_tx.clone(); move |call_id, digit| { debug!( - "DTMF {} on call {} (ignored - using dialed number)", + "DTMF {} on call {}", digit, call_id ); + let _ = event_tx.send(SipEvent::Dtmf { call_id, digit }); } }), on_call_ended: Box::new({