mirror of
https://github.com/coral/sipcord-bridge.git
synced 2026-06-29 09:23:14 -06:00
dynamic guild and channel menu with espeak tts slopcoded
This commit is contained in:
parent
8a3023e275
commit
fb8bd8e738
|
|
@ -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
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue