diff --git a/Dockerfile b/Dockerfile index c560d11..f724df3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -115,6 +115,8 @@ RUN apt-get update && apt-get install -y \ libopus0 \ libtiff6 \ libjpeg62-turbo \ + espeak-ng \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/README.md b/README.md index a5b157a..ca3197d 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,8 @@ This means you have to build the call routing backend yourself. I am including a [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. @@ -67,25 +61,20 @@ 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: +You can also add a dynamic phone menu. A caller dials the menu extension, +Sipcord reads the available Discord servers, the caller picks one with DTMF, +then Sipcord reads that server's voice channels and joins the selected 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. +The menu uses `espeak-ng` for local text-to-speech. Press `#` to repeat the +current menu page, `9` for the next page when available, and `*` for the +previous page when available. ### 4a. Run with Docker (recommended) diff --git a/sipcord-bridge/src/call/mod.rs b/sipcord-bridge/src/call/mod.rs index a9aeb09..0e505b1 100644 --- a/sipcord-bridge/src/call/mod.rs +++ b/sipcord-bridge/src/call/mod.rs @@ -17,8 +17,7 @@ use crate::fax::session::FaxSession; use crate::fax::spandsp::FaxT38Receiver; use crate::routing::{ - Backend, CallError, CallStartedInfo, MenuOptionRoute, MenuRoute, OutboundCallRequest, - RouteDecision, + Backend, CallError, CallStartedInfo, MenuRoute, OutboundCallRequest, RouteDecision, }; use crate::services::snowflake::Snowflake; use crate::services::sound::{SoundManager, create_sound_manager}; @@ -39,6 +38,7 @@ use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; +use tokio::process::Command; use tokio::sync::mpsc; use tokio::sync::Notify; use tokio_util::sync::CancellationToken; @@ -1462,6 +1462,32 @@ struct MenuCallContext { health_check_notify: Arc, } +#[derive(Debug, Clone, serde::Deserialize)] +struct DiscordRestGuild { + id: String, + name: String, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct DiscordRestChannel { + id: String, + name: String, + #[serde(rename = "type")] + kind: u8, +} + +#[derive(Debug, Clone)] +struct DynamicGuildOption { + guild_id: Snowflake, + name: String, +} + +#[derive(Debug, Clone)] +struct DynamicChannelOption { + channel_id: Snowflake, + name: String, +} + async fn handle_menu_call( ctx: MenuCallContext, call_id: CallId, @@ -1480,103 +1506,405 @@ async fn handle_menu_call( 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 { + let guilds = match fetch_discord_guilds(ctx.backend.bot_token()).await { + Ok(guilds) if !guilds.is_empty() => guilds, + Ok(_) => { + warn!("Dynamic menu {} has no Discord guilds to offer", menu.id); + let _ = + play_tts_prompt(call_id, "No Discord servers are available.", &ctx.sip_cmd_tx) + .await; 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; + Err(e) => { + error!("Failed to load Discord guilds for menu {}: {}", menu.id, e); + let _ = + play_tts_prompt(call_id, "I could not load Discord servers.", &ctx.sip_cmd_tx) + .await; + ctx.dtmf_waiters.remove(&call_id); + let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id }); + return; } }; + let guild = + match select_guild_from_menu(call_id, &menu, &guilds, max_attempts, &mut dtmf_rx, &ctx) + .await + { + Some(guild) => guild, + None => return, + }; + + let channels = match fetch_discord_voice_channels(ctx.backend.bot_token(), guild.guild_id).await + { + Ok(channels) if !channels.is_empty() => channels, + Ok(_) => { + let text = format!("{} has no voice channels available.", guild.name); + let _ = play_tts_prompt(call_id, &text, &ctx.sip_cmd_tx).await; + ctx.dtmf_waiters.remove(&call_id); + let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id }); + return; + } + Err(e) => { + error!( + "Failed to load Discord channels for guild {}: {}", + guild.guild_id, e + ); + let _ = + play_tts_prompt(call_id, "I could not load voice channels.", &ctx.sip_cmd_tx) + .await; + ctx.dtmf_waiters.remove(&call_id); + let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id }); + return; + } + }; + + let selected = match select_channel_from_menu( + call_id, + &menu, + &guild, + &channels, + max_attempts, + &mut dtmf_rx, + &ctx, + ) + .await + { + Some(channel) => channel, + None => return, + }; + ctx.dtmf_waiters.remove(&call_id); - connect_menu_selection(ctx, call_id, extension, selected).await; + connect_menu_selection(ctx, call_id, extension, guild, selected).await; } -async fn play_named_prompt( +async fn select_guild_from_menu( 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); + menu: &MenuRoute, + guilds: &[DynamicGuildOption], + max_attempts: u8, + dtmf_rx: &mut mpsc::UnboundedReceiver, + ctx: &MenuCallContext, +) -> Option { + let mut page = 0usize; + let mut attempts = 0u8; + loop { + let page_items = page_slice(guilds, page); + let prompt = build_option_prompt( + "Select a Discord server.", + page_items, + |guild| guild.name.as_str(), + page, + guilds.len(), + ); + if let Err(e) = play_tts_prompt(call_id, &prompt, &ctx.sip_cmd_tx).await { + error!("Failed to play guild menu TTS for call {}: {}", call_id, e); + ctx.dtmf_waiters.remove(&call_id); + let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id }); + return None; + } + + let digit = wait_for_menu_digit(call_id, menu, dtmf_rx, ctx).await?; + match digit { + '#' => continue, + '9' if has_next_page(guilds.len(), page) => { + page += 1; + continue; + } + '*' if page > 0 => { + page -= 1; + continue; + } + '1'..='8' => { + let idx = page * 8 + digit.to_digit(10).unwrap_or(0) as usize - 1; + if let Some(guild) = guilds.get(idx) { + return Some(guild.clone()); + } + } + _ => {} + } + + attempts = attempts.saturating_add(1); + if attempts >= max_attempts { + let _ = + play_tts_prompt(call_id, "Too many invalid selections. Goodbye.", &ctx.sip_cmd_tx) + .await; + ctx.dtmf_waiters.remove(&call_id); + let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id }); + return None; + } + let _ = play_tts_prompt(call_id, "Invalid selection.", &ctx.sip_cmd_tx).await; } } +async fn select_channel_from_menu( + call_id: CallId, + menu: &MenuRoute, + guild: &DynamicGuildOption, + channels: &[DynamicChannelOption], + max_attempts: u8, + dtmf_rx: &mut mpsc::UnboundedReceiver, + ctx: &MenuCallContext, +) -> Option { + let mut page = 0usize; + let mut attempts = 0u8; + loop { + let page_items = page_slice(channels, page); + let intro = format!("Select a voice channel in {}.", guild.name); + let prompt = build_option_prompt( + &intro, + page_items, + |channel| channel.name.as_str(), + page, + channels.len(), + ); + if let Err(e) = play_tts_prompt(call_id, &prompt, &ctx.sip_cmd_tx).await { + error!("Failed to play channel menu TTS for call {}: {}", call_id, e); + ctx.dtmf_waiters.remove(&call_id); + let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id }); + return None; + } + + let digit = wait_for_menu_digit(call_id, menu, dtmf_rx, ctx).await?; + match digit { + '#' => continue, + '*' if page > 0 => { + page -= 1; + continue; + } + '9' if has_next_page(channels.len(), page) => { + page += 1; + continue; + } + '1'..='8' => { + let idx = page * 8 + digit.to_digit(10).unwrap_or(0) as usize - 1; + if let Some(channel) = channels.get(idx) { + return Some(channel.clone()); + } + } + _ => {} + } + + attempts = attempts.saturating_add(1); + if attempts >= max_attempts { + let _ = + play_tts_prompt(call_id, "Too many invalid selections. Goodbye.", &ctx.sip_cmd_tx) + .await; + ctx.dtmf_waiters.remove(&call_id); + let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id }); + return None; + } + let _ = play_tts_prompt(call_id, "Invalid selection.", &ctx.sip_cmd_tx).await; + } +} + +fn page_slice(items: &[T], page: usize) -> &[T] { + let start = page.saturating_mul(8); + let end = (start + 8).min(items.len()); + if start >= items.len() { + &[] + } else { + &items[start..end] + } +} + +fn has_next_page(total: usize, page: usize) -> bool { + (page + 1) * 8 < total +} + +fn build_option_prompt( + intro: &str, + items: &[T], + label: impl Fn(&T) -> &str, + page: usize, + total: usize, +) -> String { + let mut prompt = String::from(intro); + for (idx, item) in items.iter().enumerate() { + prompt.push_str(&format!(" Press {} for {}.", idx + 1, label(item))); + } + if has_next_page(total, page) { + prompt.push_str(" Press 9 for more."); + } + if page > 0 { + prompt.push_str(" Press star for previous."); + } + prompt.push_str(" Press pound to repeat."); + prompt +} + +async fn wait_for_menu_digit( + call_id: CallId, + menu: &MenuRoute, + dtmf_rx: &mut mpsc::UnboundedReceiver, + ctx: &MenuCallContext, +) -> Option { + if !ctx.sip_calls.contains_key(&call_id) { + ctx.dtmf_waiters.remove(&call_id); + return None; + } + + match tokio::time::timeout( + Duration::from_secs(menu.timeout_seconds.max(1)), + dtmf_rx.recv(), + ) + .await + { + Ok(Some(digit)) => Some(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 }); + None + } + Err(_) => { + warn!("Menu {} timed out waiting for DTMF on call {}", menu.id, call_id); + let _ = play_tts_prompt(call_id, "No selection received.", &ctx.sip_cmd_tx).await; + Some('\0') + } + } +} + +async fn fetch_discord_guilds( + bot_token: &str, +) -> Result, Box> { + let client = reqwest::Client::new(); + let guilds: Vec = client + .get("https://discord.com/api/v10/users/@me/guilds?limit=200") + .header("Authorization", format!("Bot {}", bot_token)) + .send() + .await? + .error_for_status()? + .json() + .await?; + + let mut guilds: Vec = guilds + .into_iter() + .filter_map(|guild| { + let guild_id = guild.id.parse::().ok()?; + Some(DynamicGuildOption { + guild_id, + name: guild.name, + }) + }) + .collect(); + guilds.sort_by(|a, b| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase())); + Ok(guilds) +} + +async fn fetch_discord_voice_channels( + bot_token: &str, + guild_id: Snowflake, +) -> Result, Box> { + let client = reqwest::Client::new(); + let url = format!("https://discord.com/api/v10/guilds/{}/channels", guild_id); + let channels: Vec = client + .get(url) + .header("Authorization", format!("Bot {}", bot_token)) + .send() + .await? + .error_for_status()? + .json() + .await?; + + let mut channels: Vec = channels + .into_iter() + .filter(|channel| channel.kind == 2) + .filter_map(|channel| { + let channel_id = channel.id.parse::().ok()?; + Some(DynamicChannelOption { + channel_id, + name: channel.name, + }) + }) + .collect(); + channels.sort_by(|a, b| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase())); + Ok(channels) +} + +async fn play_tts_prompt( + call_id: CallId, + text: &str, + sip_cmd_tx: &Sender, +) -> Result<(), Box> { + let samples = synthesize_tts_samples(call_id, text).await?; + let duration_ms = (samples.len() as u64 * 1000) / CONF_SAMPLE_RATE as u64; + let _ = sip_cmd_tx.send(SipCommand::PlayDirectToCall { call_id, samples }); + tokio::time::sleep(Duration::from_millis(duration_ms + 100)).await; + Ok(()) +} + +async fn synthesize_tts_samples( + call_id: CallId, + text: &str, +) -> Result, Box> { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos(); + let raw_path = std::env::temp_dir().join(format!("sipcord-tts-{}-{}-raw.wav", call_id, stamp)); + let out_path = std::env::temp_dir().join(format!("sipcord-tts-{}-{}.wav", call_id, stamp)); + + let espeak_status = Command::new("espeak-ng") + .arg("-w") + .arg(&raw_path) + .arg(text) + .status() + .await?; + if !espeak_status.success() { + let _ = tokio::fs::remove_file(&raw_path).await; + return Err(format!("espeak-ng exited with status {}", espeak_status).into()); + } + + let ffmpeg_status = Command::new("ffmpeg") + .arg("-y") + .arg("-loglevel") + .arg("error") + .arg("-i") + .arg(&raw_path) + .arg("-ac") + .arg("1") + .arg("-ar") + .arg(CONF_SAMPLE_RATE.to_string()) + .arg("-sample_fmt") + .arg("s16") + .arg(&out_path) + .status() + .await?; + if !ffmpeg_status.success() { + let _ = tokio::fs::remove_file(&raw_path).await; + return Err(format!("ffmpeg exited with status {}", ffmpeg_status).into()); + } + + let _ = tokio::fs::remove_file(&raw_path).await; + let data = match tokio::fs::read(&out_path).await { + Ok(data) => data, + Err(e) => { + let _ = tokio::fs::remove_file(&out_path).await; + return Err(e.into()); + } + }; + let _ = tokio::fs::remove_file(&out_path).await; + let (samples, rate) = crate::audio::wav::parse_wav(&data)?; + if rate != CONF_SAMPLE_RATE { + return Err(format!( + "TTS WAV has sample rate {}, expected {}", + rate, CONF_SAMPLE_RATE + ) + .into()); + } + Ok(samples) +} + async fn connect_menu_selection( ctx: MenuCallContext, call_id: CallId, extension: String, - selected: MenuOptionRoute, + guild: DynamicGuildOption, + selected: DynamicChannelOption, ) { let channel_id = selected.channel_id; - let guild_id = selected.guild_id; + let guild_id = guild.guild_id; let user_id = "menu".to_string(); let bot_token = ctx.backend.bot_token().to_string(); @@ -1584,7 +1912,7 @@ async fn connect_menu_selection( "Menu call {} selected channel {} ({})", call_id, channel_id, - selected.label.as_deref().unwrap_or("unlabeled") + selected.name ); let mut conflicting_channel: Option = None; diff --git a/sipcord-bridge/src/routing/mod.rs b/sipcord-bridge/src/routing/mod.rs index bb02d9b..375c2d1 100644 --- a/sipcord-bridge/src/routing/mod.rs +++ b/sipcord-bridge/src/routing/mod.rs @@ -27,23 +27,12 @@ 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. +/// Dynamic 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 diff --git a/sipcord-bridge/src/routing/static_router.rs b/sipcord-bridge/src/routing/static_router.rs index 706e47d..0d6654f 100644 --- a/sipcord-bridge/src/routing/static_router.rs +++ b/sipcord-bridge/src/routing/static_router.rs @@ -1,7 +1,8 @@ //! Static dialplan router — routes calls based on a TOML file. //! //! This is the open-source-friendly backend that doesn't require the SIPcord API. -//! It reads a `dialplan.toml` file mapping extensions to Discord voice channels. +//! It reads a `dialplan.toml` file mapping extensions to Discord voice channels +//! and optional dynamic menu extensions that browse the bot's Discord guilds. //! //! Required env var: `DISCORD_BOT_TOKEN` //! @@ -10,6 +11,9 @@ //! [extensions] //! 1000 = { guild = "123456789012345678", channel = "987654321012345678" } //! 2000 = { guild = "123456789012345678", channel = "111222333444555666" } +//! +//! [menus.main] +//! extension = "8000" //! ``` use std::collections::HashMap; @@ -23,8 +27,8 @@ use tracing::info; use crate::config::ConfigError; use crate::routing::{ - Backend, CallError, CallStartedInfo, HangupCallRequest, MenuOptionRoute, MenuRoute, - OutboundCallRequest, RouteDecision, + Backend, CallError, CallStartedInfo, HangupCallRequest, MenuRoute, OutboundCallRequest, + RouteDecision, }; use crate::services::snowflake::Snowflake; use crate::transport::sip::DigestAuthParams; @@ -35,23 +39,13 @@ 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)] @@ -112,14 +106,9 @@ impl StaticBackend { ); } if !dialplan.menus.is_empty() { - info!("Loaded {} static menu(s)", dialplan.menus.len()); + info!("Loaded {} dynamic menu(s)", dialplan.menus.len()); for (id, menu) in &dialplan.menus { - info!( - " menu {} on ext {} ({} options)", - id, - menu.extension, - menu.options.len() - ); + info!(" dynamic menu {} on ext {}", id, menu.extension); } } @@ -145,30 +134,11 @@ impl Backend for StaticBackend { .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, }, }; } @@ -305,14 +275,8 @@ mod tests { 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(); @@ -333,15 +297,8 @@ max_attempts = 2 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"), }