dynamic guild and channel menu with espeak tts slopcoded

This commit is contained in:
legop3 2026-06-14 05:00:53 -04:00
parent 8a3023e275
commit fb8bd8e738
5 changed files with 426 additions and 161 deletions

View file

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

View file

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

View file

@ -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<Notify>,
}
#[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<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);
menu: &MenuRoute,
guilds: &[DynamicGuildOption],
max_attempts: u8,
dtmf_rx: &mut mpsc::UnboundedReceiver<char>,
ctx: &MenuCallContext,
) -> Option<DynamicGuildOption> {
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<char>,
ctx: &MenuCallContext,
) -> Option<DynamicChannelOption> {
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<T>(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<T>(
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<char>,
ctx: &MenuCallContext,
) -> Option<char> {
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<Vec<DynamicGuildOption>, Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let guilds: Vec<DiscordRestGuild> = 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<DynamicGuildOption> = guilds
.into_iter()
.filter_map(|guild| {
let guild_id = guild.id.parse::<Snowflake>().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<Vec<DynamicChannelOption>, Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let url = format!("https://discord.com/api/v10/guilds/{}/channels", guild_id);
let channels: Vec<DiscordRestChannel> = client
.get(url)
.header("Authorization", format!("Bot {}", bot_token))
.send()
.await?
.error_for_status()?
.json()
.await?;
let mut channels: Vec<DynamicChannelOption> = channels
.into_iter()
.filter(|channel| channel.kind == 2)
.filter_map(|channel| {
let channel_id = channel.id.parse::<Snowflake>().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<SipCommand>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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<Vec<i16>, Box<dyn std::error::Error + Send + Sync>> {
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<Snowflake> = None;

View file

@ -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<String>,
}
/// Static IVR menu route.
/// Dynamic 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

View file

@ -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<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)]
@ -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"),
}