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 \ libopus0 \
libtiff6 \ libtiff6 \
libjpeg62-turbo \ libjpeg62-turbo \
espeak-ng \
ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app 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] [menus.main]
extension = "8000" extension = "8000"
prompt = "main_menu"
invalid_prompt = "invalid"
timeout_seconds = 10 timeout_seconds = 10
max_attempts = 3 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. 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. 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 You can also add a dynamic phone menu. A caller dials the menu extension,
the prompt, presses a digit, and Sipcord joins the selected Discord voice Sipcord reads the available Discord servers, the caller picks one with DTMF,
channel: then Sipcord reads that server's voice channels and joins the selected channel:
```toml ```toml
[menus.main] [menus.main]
extension = "8000" extension = "8000"
prompt = "main_menu"
invalid_prompt = "invalid"
timeout_seconds = 10 timeout_seconds = 10
max_attempts = 3 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`. The menu uses `espeak-ng` for local text-to-speech. Press `#` to repeat the
They must be preloaded 16kHz mono audio files. Press `#` to repeat the menu. current menu page, `9` for the next page when available, and `*` for the
previous page when available.
### 4a. Run with Docker (recommended) ### 4a. Run with Docker (recommended)

View file

@ -17,8 +17,7 @@
use crate::fax::session::FaxSession; use crate::fax::session::FaxSession;
use crate::fax::spandsp::FaxT38Receiver; use crate::fax::spandsp::FaxT38Receiver;
use crate::routing::{ use crate::routing::{
Backend, CallError, CallStartedInfo, MenuOptionRoute, MenuRoute, OutboundCallRequest, Backend, CallError, CallStartedInfo, MenuRoute, OutboundCallRequest, RouteDecision,
RouteDecision,
}; };
use crate::services::snowflake::Snowflake; use crate::services::snowflake::Snowflake;
use crate::services::sound::{SoundManager, create_sound_manager}; use crate::services::sound::{SoundManager, create_sound_manager};
@ -39,6 +38,7 @@ use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::process::Command;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::Notify; use tokio::sync::Notify;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@ -1462,6 +1462,32 @@ struct MenuCallContext {
health_check_notify: Arc<Notify>, 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( async fn handle_menu_call(
ctx: MenuCallContext, ctx: MenuCallContext,
call_id: CallId, call_id: CallId,
@ -1480,103 +1506,405 @@ async fn handle_menu_call(
ctx.dtmf_waiters.insert(call_id, dtmf_tx); ctx.dtmf_waiters.insert(call_id, dtmf_tx);
let max_attempts = menu.max_attempts.max(1); let max_attempts = menu.max_attempts.max(1);
let mut attempts = 0u8; let guilds = match fetch_discord_guilds(ctx.backend.bot_token()).await {
let selected = loop { Ok(guilds) if !guilds.is_empty() => guilds,
if !ctx.sip_calls.contains_key(&call_id) { Ok(_) => {
ctx.dtmf_waiters.remove(&call_id); warn!("Dynamic menu {} has no Discord guilds to offer", menu.id);
return; let _ =
} play_tts_prompt(call_id, "No Discord servers are available.", &ctx.sip_cmd_tx)
.await;
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); ctx.dtmf_waiters.remove(&call_id);
let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id }); let _ = ctx.sip_cmd_tx.send(SipCommand::Hangup { call_id });
return; return;
} }
if let Some(invalid_prompt) = menu.invalid_prompt.as_deref() { Err(e) => {
play_named_prompt(call_id, invalid_prompt, &ctx.sound_manager, &ctx.sip_cmd_tx).await; 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); 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, call_id: CallId,
sound_name: &str, menu: &MenuRoute,
sound_manager: &SoundManager, guilds: &[DynamicGuildOption],
sip_cmd_tx: &Sender<SipCommand>, max_attempts: u8,
) { dtmf_rx: &mut mpsc::UnboundedReceiver<char>,
if let Some(sound) = sound_manager.get_preloaded(sound_name) { ctx: &MenuCallContext,
info!("Playing menu prompt '{}' for call {}", sound_name, call_id); ) -> Option<DynamicGuildOption> {
let _ = sip_cmd_tx.send(SipCommand::PlayDirectToCall { let mut page = 0usize;
call_id, let mut attempts = 0u8;
samples: (*sound.samples).clone(), loop {
}); let page_items = page_slice(guilds, page);
tokio::time::sleep(Duration::from_millis(sound.duration_ms + 100)).await; let prompt = build_option_prompt(
} else { "Select a Discord server.",
warn!("Menu prompt sound '{}' is not preloaded or does not exist", sound_name); 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( async fn connect_menu_selection(
ctx: MenuCallContext, ctx: MenuCallContext,
call_id: CallId, call_id: CallId,
extension: String, extension: String,
selected: MenuOptionRoute, guild: DynamicGuildOption,
selected: DynamicChannelOption,
) { ) {
let channel_id = selected.channel_id; 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 user_id = "menu".to_string();
let bot_token = ctx.backend.bot_token().to_string(); let bot_token = ctx.backend.bot_token().to_string();
@ -1584,7 +1912,7 @@ async fn connect_menu_selection(
"Menu call {} selected channel {} ({})", "Menu call {} selected channel {} ({})",
call_id, call_id,
channel_id, channel_id,
selected.label.as_deref().unwrap_or("unlabeled") selected.name
); );
let mut conflicting_channel: Option<Snowflake> = None; let mut conflicting_channel: Option<Snowflake> = None;

View file

@ -27,23 +27,12 @@ pub struct HangupCallRequest {
pub created_at: std::time::Instant, pub created_at: std::time::Instant,
} }
/// A single static IVR menu option. /// Dynamic IVR menu route.
#[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)] #[derive(Debug, Clone)]
pub struct MenuRoute { pub struct MenuRoute {
pub id: String, pub id: String,
pub prompt: Option<String>,
pub invalid_prompt: Option<String>,
pub timeout_seconds: u64, pub timeout_seconds: u64,
pub max_attempts: u8, pub max_attempts: u8,
pub options: std::collections::HashMap<char, MenuOptionRoute>,
} }
/// Result of routing an incoming SIP call /// Result of routing an incoming SIP call

View file

@ -1,7 +1,8 @@
//! Static dialplan router — routes calls based on a TOML file. //! Static dialplan router — routes calls based on a TOML file.
//! //!
//! This is the open-source-friendly backend that doesn't require the SIPcord API. //! 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` //! Required env var: `DISCORD_BOT_TOKEN`
//! //!
@ -10,6 +11,9 @@
//! [extensions] //! [extensions]
//! 1000 = { guild = "123456789012345678", channel = "987654321012345678" } //! 1000 = { guild = "123456789012345678", channel = "987654321012345678" }
//! 2000 = { guild = "123456789012345678", channel = "111222333444555666" } //! 2000 = { guild = "123456789012345678", channel = "111222333444555666" }
//!
//! [menus.main]
//! extension = "8000"
//! ``` //! ```
use std::collections::HashMap; use std::collections::HashMap;
@ -23,8 +27,8 @@ use tracing::info;
use crate::config::ConfigError; use crate::config::ConfigError;
use crate::routing::{ use crate::routing::{
Backend, CallError, CallStartedInfo, HangupCallRequest, MenuOptionRoute, MenuRoute, Backend, CallError, CallStartedInfo, HangupCallRequest, MenuRoute, OutboundCallRequest,
OutboundCallRequest, RouteDecision, RouteDecision,
}; };
use crate::services::snowflake::Snowflake; use crate::services::snowflake::Snowflake;
use crate::transport::sip::DigestAuthParams; use crate::transport::sip::DigestAuthParams;
@ -35,23 +39,13 @@ struct ExtensionTarget {
channel: Snowflake, channel: Snowflake,
} }
#[derive(Deserialize, Clone)]
struct MenuOptionTarget {
guild: Snowflake,
channel: Snowflake,
label: Option<String>,
}
#[derive(Deserialize, Clone)] #[derive(Deserialize, Clone)]
struct MenuConfig { struct MenuConfig {
extension: String, extension: String,
prompt: Option<String>,
invalid_prompt: Option<String>,
#[serde(default = "default_menu_timeout_seconds")] #[serde(default = "default_menu_timeout_seconds")]
timeout_seconds: u64, timeout_seconds: u64,
#[serde(default = "default_menu_max_attempts")] #[serde(default = "default_menu_max_attempts")]
max_attempts: u8, max_attempts: u8,
options: HashMap<String, MenuOptionTarget>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -112,14 +106,9 @@ impl StaticBackend {
); );
} }
if !dialplan.menus.is_empty() { 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 { for (id, menu) in &dialplan.menus {
info!( info!(" dynamic menu {} on ext {}", id, menu.extension);
" menu {} on ext {} ({} options)",
id,
menu.extension,
menu.options.len()
);
} }
} }
@ -145,30 +134,11 @@ impl Backend for StaticBackend {
.iter() .iter()
.find(|(_, menu)| menu.extension == extension) .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 { return RouteDecision::Menu {
menu: MenuRoute { menu: MenuRoute {
id: id.clone(), id: id.clone(),
prompt: menu.prompt.clone(),
invalid_prompt: menu.invalid_prompt.clone(),
timeout_seconds: menu.timeout_seconds, timeout_seconds: menu.timeout_seconds,
max_attempts: menu.max_attempts, max_attempts: menu.max_attempts,
options,
}, },
}; };
} }
@ -305,14 +275,8 @@ mod tests {
let toml_content = r#" let toml_content = r#"
[menus.main] [menus.main]
extension = "8000" extension = "8000"
prompt = "main_menu"
invalid_prompt = "invalid"
timeout_seconds = 7 timeout_seconds = 7
max_attempts = 2 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"); let dir = std::env::temp_dir().join("sipcord_test_dialplan");
std::fs::create_dir_all(&dir).ok(); std::fs::create_dir_all(&dir).ok();
@ -333,15 +297,8 @@ max_attempts = 2
match decision { match decision {
RouteDecision::Menu { menu } => { RouteDecision::Menu { menu } => {
assert_eq!(menu.id, "main"); 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.timeout_seconds, 7);
assert_eq!(menu.max_attempts, 2); 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"), _ => panic!("Expected Menu"),
} }