ivr menu slop pass 1

This commit is contained in:
legop3 2026-06-14 04:38:37 -04:00
parent e4576231ba
commit 8a3023e275
5 changed files with 561 additions and 6 deletions

View file

@ -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`

View file

@ -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<DashMap<Snowflake, Arc<Notify>>>,
sip_calls: Arc<DashMap<CallId, SipCallInfo>>,
dtmf_waiters: Arc<DashMap<CallId, mpsc::UnboundedSender<char>>>,
/// 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<DashMap<CallId, FaxSessionEntry>>,
@ -129,6 +134,7 @@ pub struct BridgeCoordinator {
pending_bridges: Arc<DashSet<Snowflake>>,
bridge_ready_notifiers: Arc<DashMap<Snowflake, Arc<Notify>>>,
sip_calls: Arc<DashMap<CallId, SipCallInfo>>,
dtmf_waiters: Arc<DashMap<CallId, mpsc::UnboundedSender<char>>>,
/// 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<DashMap<CallId, FaxSessionEntry>>,
@ -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<dyn Backend>,
bridges: Arc<DashMap<Snowflake, ChannelBridge>>,
pending_bridges: Arc<DashSet<Snowflake>>,
bridge_ready_notifiers: Arc<DashMap<Snowflake, Arc<Notify>>>,
sip_calls: Arc<DashMap<CallId, SipCallInfo>>,
dtmf_waiters: Arc<DashMap<CallId, mpsc::UnboundedSender<char>>>,
discord_event_tx: Sender<DiscordEvent>,
sip_cmd_tx: Sender<SipCommand>,
sound_manager: Arc<SoundManager>,
shared_discord: Arc<SharedDiscordClient>,
health_check_notify: Arc<Notify>,
}
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<SipCommand>,
) {
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<Snowflake> = 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,

View file

@ -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<String>,
}
/// Static IVR menu route.
#[derive(Debug, Clone)]
pub struct MenuRoute {
pub id: String,
pub prompt: Option<String>,
pub invalid_prompt: Option<String>,
pub timeout_seconds: u64,
pub max_attempts: u8,
pub options: std::collections::HashMap<char, MenuOptionRoute>,
}
/// 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)

View file

@ -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<String>,
}
#[derive(Deserialize, Clone)]
struct MenuConfig {
extension: String,
prompt: Option<String>,
invalid_prompt: Option<String>,
#[serde(default = "default_menu_timeout_seconds")]
timeout_seconds: u64,
#[serde(default = "default_menu_max_attempts")]
max_attempts: u8,
options: HashMap<String, MenuOptionTarget>,
}
#[derive(Deserialize)]
struct Dialplan {
#[serde(default)]
extensions: HashMap<String, ExtensionTarget>,
#[serde(default)]
menus: HashMap<String, MenuConfig>,
}
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<String, ExtensionTarget>,
menus: HashMap<String, MenuConfig>,
outbound_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>>>,
hangup_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<HangupCallRequest>>>,
}
@ -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");

View file

@ -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({