discord directory menu

This commit is contained in:
legop3 2026-06-14 11:54:01 -04:00
parent 4d8dd36538
commit d44e3544d1
5 changed files with 327 additions and 25 deletions

View file

@ -12,6 +12,10 @@ This means you have to build the call routing backend yourself. I am including a
extension = "8000"
timeout_seconds = 10
max_attempts = 3
[phones]
777 = { label = "Shop speakerphone" }
111 = { label = "Desk phone" }
```
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.
@ -77,6 +81,24 @@ Emoji and common Discord channel separators are skipped in spoken names. Press
`#` to repeat the current menu page, `9` for the next page when available, and
`*` for the previous page when available.
You can also add a phone directory for Discord-originated calls. These entries
show up in `/directory` as buttons. Clicking one dials that extension from your
current Discord voice channel:
```toml
[phones]
777 = { label = "Shop speakerphone" }
111 = { label = "Desk phone" }
```
By default, the TOML key is the extension to dial. If you want the key and
dialed extension to differ, set `extension` explicitly:
```toml
[phones]
shop = { label = "Shop speakerphone", extension = "777" }
```
### 4a. Run with Docker (recommended)
Create a directory for your deployment:
@ -225,6 +247,7 @@ Usage:
```text
/call extension:1101
/directory
/hangup
```
@ -238,6 +261,8 @@ Behavior:
channel where the command was run.
- `/hangup` ends active SIP calls connected to the voice channel where the
command was run.
- `/directory` opens the configured phone directory as Discord buttons. Clicking
a phone button behaves like `/call` for that extension.
Current scope:
- `/call` is implemented for the static self-host backend.

View file

@ -66,6 +66,7 @@ async fn run_static_router() -> Result<(), BridgeError> {
outbound_request_rx,
hangup_request_rx,
)?);
let phone_directory = backend.phone_directory();
// Create SIP transport (no TLS for static router)
let sip_transport = SipTransport::new(sip_config.clone(), None);
@ -91,6 +92,7 @@ async fn run_static_router() -> Result<(), BridgeError> {
request_tx: outbound_request_tx,
hangup_tx: hangup_request_tx,
bot_token: bot_token.clone(),
phone_directory: phone_directory.clone(),
});
let shared_discord = SharedDiscordClient::new(&bot_token, outbound_call_config).await?;
info!("Shared Discord client initialized");

View file

@ -27,6 +27,14 @@ pub struct HangupCallRequest {
pub created_at: std::time::Instant,
}
/// Manually configured phone directory entry for Discord-originated calls.
#[derive(Debug, Clone)]
pub struct PhoneDirectoryEntry {
pub id: String,
pub label: String,
pub extension: String,
}
/// Dynamic IVR menu route.
#[derive(Debug, Clone)]
pub struct MenuRoute {

View file

@ -14,6 +14,9 @@
//!
//! [menus.main]
//! extension = "8000"
//!
//! [phones]
//! 777 = { label = "Shop speakerphone" }
//! ```
use std::collections::HashMap;
@ -28,7 +31,7 @@ use tracing::info;
use crate::config::ConfigError;
use crate::routing::{
Backend, CallError, CallStartedInfo, HangupCallRequest, MenuRoute, OutboundCallRequest,
RouteDecision,
PhoneDirectoryEntry, RouteDecision,
};
use crate::services::snowflake::Snowflake;
use crate::transport::sip::DigestAuthParams;
@ -48,12 +51,20 @@ struct MenuConfig {
max_attempts: u8,
}
#[derive(Deserialize, Clone)]
struct PhoneConfig {
label: String,
extension: Option<String>,
}
#[derive(Deserialize)]
struct Dialplan {
#[serde(default)]
extensions: HashMap<String, ExtensionTarget>,
#[serde(default)]
menus: HashMap<String, MenuConfig>,
#[serde(default)]
phones: HashMap<String, PhoneConfig>,
}
fn default_menu_timeout_seconds() -> u64 {
@ -73,6 +84,7 @@ pub struct StaticBackend {
bot_token: String,
extensions: HashMap<String, ExtensionTarget>,
menus: HashMap<String, MenuConfig>,
phones: HashMap<String, PhoneConfig>,
outbound_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>>>,
hangup_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<HangupCallRequest>>>,
}
@ -111,15 +123,38 @@ impl StaticBackend {
info!(" dynamic menu {} on ext {}", id, menu.extension);
}
}
if !dialplan.phones.is_empty() {
info!("Loaded {} phone directory entries", dialplan.phones.len());
for (id, phone) in &dialplan.phones {
let extension = phone.extension.as_deref().unwrap_or(id);
info!(" phone {} -> {} ({})", id, extension, phone.label);
}
}
Ok(Self {
bot_token,
extensions: dialplan.extensions,
menus: dialplan.menus,
phones: dialplan.phones,
outbound_rx: Arc::new(Mutex::new(outbound_rx)),
hangup_rx: Arc::new(Mutex::new(hangup_rx)),
})
}
/// Phone directory entries exposed through the Discord `/directory` command.
pub fn phone_directory(&self) -> Vec<PhoneDirectoryEntry> {
let mut entries: Vec<PhoneDirectoryEntry> = self
.phones
.iter()
.map(|(id, phone)| PhoneDirectoryEntry {
id: id.clone(),
label: phone.label.clone(),
extension: phone.extension.clone().unwrap_or_else(|| id.clone()),
})
.collect();
entries.sort_by(|a, b| a.label.to_ascii_lowercase().cmp(&b.label.to_ascii_lowercase()));
entries
}
}
#[async_trait]
@ -305,6 +340,31 @@ max_attempts = 2
});
}
#[test]
fn test_load_phone_directory() {
let toml_content = r#"
[phones]
777 = { label = "Shop speakerphone" }
desk = { label = "Desk phone", extension = "111" }
"#;
let dir = std::env::temp_dir().join("sipcord_test_dialplan");
std::fs::create_dir_all(&dir).ok();
let path = dir.join("test_phone_directory.toml");
std::fs::write(&path, toml_content).unwrap();
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
let (_hangup_tx, hangup_rx) = tokio::sync::mpsc::unbounded_channel();
let backend = StaticBackend::load(&path, "tok".to_string(), rx, hangup_rx).unwrap();
let directory = backend.phone_directory();
assert_eq!(directory.len(), 2);
assert_eq!(directory[0].id, "desk");
assert_eq!(directory[0].label, "Desk phone");
assert_eq!(directory[0].extension, "111");
assert_eq!(directory[1].id, "777");
assert_eq!(directory[1].extension, "777");
}
#[test]
fn test_load_malformed_toml() {
let dir = std::env::temp_dir().join("sipcord_test_dialplan");

View file

@ -2,7 +2,7 @@ mod voice;
use crate::audio::simd;
use crate::config::DiscordOutboundSipConfig;
use crate::routing::{HangupCallRequest, OutboundCallRequest};
use crate::routing::{HangupCallRequest, OutboundCallRequest, PhoneDirectoryEntry};
use crate::services::snowflake::Snowflake;
use audioadapter::Adapter;
use audioadapter_buffers::direct::SequentialSliceOfVecs;
@ -15,9 +15,11 @@ use rubato::{
WindowFunction,
};
use serenity::all::{
ChannelId, Client, Command, CommandInteraction, CommandOptionType, Context, CreateCommand,
CreateCommandOption, CreateInteractionResponse, CreateInteractionResponseMessage, EventHandler,
FullEvent, GatewayIntents, GuildId, Interaction,
ButtonStyle, ChannelId, Client, CommandInteraction, CommandOptionType,
ComponentInteraction, Context, CreateActionRow, CreateButton, CreateCommand,
CreateCommandOption, CreateEmbed, CreateInteractionResponse,
CreateInteractionResponseMessage, EventHandler, FullEvent, GatewayIntents, GuildId,
Interaction,
};
use serenity::async_trait;
use serenity::secrets::Token;
@ -508,6 +510,7 @@ pub struct DiscordOutboundCallConfig {
pub request_tx: tokio::sync::mpsc::UnboundedSender<OutboundCallRequest>,
pub hangup_tx: tokio::sync::mpsc::UnboundedSender<HangupCallRequest>,
pub bot_token: String,
pub phone_directory: Vec<PhoneDirectoryEntry>,
}
impl SharedDiscordClient {
@ -616,12 +619,19 @@ impl EventHandler for SharedClientEventHandler {
}
}
FullEvent::InteractionCreate { interaction, .. } => {
if let Some(ref cfg) = self.outbound_call_config
&& let Interaction::Command(command) = interaction
{
match command.data.name.as_str() {
"call" => handle_call_command(ctx, command, cfg).await,
"hangup" => handle_hangup_command(ctx, command, cfg).await,
if let Some(ref cfg) = self.outbound_call_config {
match interaction {
Interaction::Command(command) => match command.data.name.as_str() {
"call" => handle_call_command(ctx, command, cfg).await,
"hangup" => handle_hangup_command(ctx, command, cfg).await,
"directory" => handle_directory_command(ctx, command, cfg).await,
_ => {}
},
Interaction::Component(component) => {
if component.data.custom_id.starts_with("sipcord:call:") {
handle_directory_button(ctx, component, cfg).await;
}
}
_ => {}
}
}
@ -646,9 +656,16 @@ async fn register_call_commands(ctx: &Context, guild_id: GuildId) -> Result<(),
let hangup_command = CreateCommand::new("hangup")
.description("Hang up active SIP calls in your current voice channel");
let directory_command =
CreateCommand::new("directory").description("Open the configured phone directory");
guild_id.create_command(&ctx.http, call_command).await?;
guild_id.create_command(&ctx.http, hangup_command).await?;
info!("Registered /call and /hangup commands for guild {}", guild_id);
guild_id.create_command(&ctx.http, directory_command).await?;
info!(
"Registered /call, /hangup, and /directory commands for guild {}",
guild_id
);
Ok(())
}
@ -714,6 +731,140 @@ async fn handle_hangup_command(
}
}
async fn handle_directory_command(
ctx: &Context,
command: &CommandInteraction,
cfg: &DiscordOutboundCallConfig,
) {
let response = if cfg.phone_directory.is_empty() {
CreateInteractionResponseMessage::new()
.content("No phones are configured in the directory.")
.ephemeral(true)
} else {
build_directory_response(&cfg.phone_directory)
};
if let Err(e) = command
.create_response(
&ctx.http,
CreateInteractionResponse::Message(response.ephemeral(true)),
)
.await
{
error!("Failed to respond to /directory interaction: {}", e);
}
}
async fn handle_directory_button(
ctx: &Context,
component: &ComponentInteraction,
cfg: &DiscordOutboundCallConfig,
) {
let Some(entry_id) = component.data.custom_id.strip_prefix("sipcord:call:") else {
return;
};
let Some(entry) = cfg
.phone_directory
.iter()
.find(|entry| entry.id == entry_id)
else {
respond_to_component(
ctx,
component,
"That phone is no longer in the configured directory.",
)
.await;
return;
};
let response = match build_outbound_request_for_extension(
ctx,
component.guild_id,
component.user.id,
component
.member
.as_ref()
.and_then(|member| member.nick.clone()),
component.user.global_name.clone(),
component.user.name.clone(),
&entry.extension,
&format!("directory-{}", component.id),
cfg,
"/directory",
) {
Ok(req) => match cfg.request_tx.send(req) {
Ok(()) => format!(
"Dialing `{}` (`{}`) from your current voice channel.",
entry.label, entry.extension
),
Err(_) => "Outbound call queue is unavailable right now.".to_string(),
},
Err(msg) => msg,
};
respond_to_component(ctx, component, &response).await;
}
fn build_directory_response(entries: &[PhoneDirectoryEntry]) -> CreateInteractionResponseMessage {
let visible_entries: Vec<&PhoneDirectoryEntry> = entries
.iter()
.filter(|entry| is_safe_directory_id(&entry.id) && is_safe_extension(&entry.extension))
.take(25)
.collect();
if visible_entries.is_empty() {
return CreateInteractionResponseMessage::new()
.content("No callable phones are configured in the directory.")
.ephemeral(true);
}
let mut description = String::new();
for entry in &visible_entries {
description.push_str(&format!("`{}` - {}\n", entry.extension, entry.label));
}
if entries.len() > visible_entries.len() {
description.push_str("\nOnly the first 25 callable phones are shown.");
}
let embed = CreateEmbed::new()
.title("Phone Directory")
.description(description);
let mut rows = Vec::new();
for chunk in visible_entries.chunks(5) {
let buttons = chunk
.iter()
.map(|entry| {
CreateButton::new(format!("sipcord:call:{}", entry.id))
.label(truncate_button_label(&entry.label, &entry.extension))
.style(ButtonStyle::Primary)
})
.collect();
rows.push(CreateActionRow::Buttons(buttons));
}
CreateInteractionResponseMessage::new()
.embed(embed)
.components(rows)
.ephemeral(true)
}
async fn respond_to_component(ctx: &Context, component: &ComponentInteraction, response: &str) {
if let Err(e) = component
.create_response(
&ctx.http,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content(response)
.ephemeral(true),
),
)
.await
{
error!("Failed to respond to directory button interaction: {}", e);
}
}
fn build_outbound_request(
ctx: &Context,
command: &CommandInteraction,
@ -729,6 +880,36 @@ fn build_outbound_request(
.trim()
.to_string();
build_outbound_request_for_extension(
ctx,
command.guild_id,
command.user.id,
command
.member
.as_ref()
.and_then(|member| member.nick.clone()),
command.user.global_name.clone(),
command.user.name.clone(),
&extension,
&command.id.to_string(),
cfg,
"/call",
)
}
fn build_outbound_request_for_extension(
ctx: &Context,
guild_id: Option<GuildId>,
user_id: serenity::all::UserId,
member_nick: Option<String>,
global_name: Option<String>,
username: String,
extension: &str,
request_id: &str,
cfg: &DiscordOutboundCallConfig,
command_name: &str,
) -> Result<OutboundCallRequest, String> {
let extension = extension.trim().to_string();
if !is_safe_extension(&extension) {
return Err(
"Extension contains unsupported characters. Use digits or simple SIP-safe extension text."
@ -736,17 +917,12 @@ fn build_outbound_request(
);
}
let (guild_id, voice_channel_id) = current_voice_channel(ctx, command, "/call")?;
let caller_username = command
.member
.as_ref()
.and_then(|member| member.nick.clone())
.or_else(|| command.user.global_name.clone())
.unwrap_or_else(|| command.user.name.clone());
let (guild_id, voice_channel_id) =
current_voice_channel_for_user(ctx, guild_id, user_id, command_name)?;
let caller_username = member_nick.or(global_name).unwrap_or(username);
Ok(OutboundCallRequest {
call_id: format!("discord-{}-{}", command.id, extension),
call_id: format!("discord-{}-{}", request_id, extension),
discord_username: extension.clone(),
guild_id: guild_id.get().to_string(),
channel_id: voice_channel_id.get().to_string(),
@ -784,22 +960,37 @@ fn current_voice_channel(
command: &CommandInteraction,
command_name: &str,
) -> Result<(GuildId, ChannelId), String> {
let guild_id = command
.guild_id
.ok_or_else(|| "This command only works inside a server.".to_string())?;
current_voice_channel_for_user(ctx, command.guild_id, command.user.id, command_name)
}
fn current_voice_channel_for_user(
ctx: &Context,
guild_id: Option<GuildId>,
user_id: serenity::all::UserId,
command_name: &str,
) -> Result<(GuildId, ChannelId), String> {
let guild_id = guild_id.ok_or_else(|| "This command only works inside a server.".to_string())?;
let guild = ctx
.cache
.guild(guild_id)
.ok_or_else(|| "Guild is not available in cache yet. Try again in a moment.".to_string())?;
let voice_channel_id = guild
.voice_states
.get(&command.user.id)
.get(&user_id)
.and_then(|state| state.channel_id)
.ok_or_else(|| format!("Join a voice channel first, then run `{command_name}` there."))?;
Ok((guild_id, voice_channel_id))
}
fn is_safe_directory_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 48
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
}
fn is_safe_extension(extension: &str) -> bool {
!extension.is_empty()
&& extension.len() <= 64
@ -808,6 +999,22 @@ fn is_safe_extension(extension: &str) -> bool {
})
}
fn truncate_button_label(label: &str, fallback: &str) -> String {
const MAX_BUTTON_LABEL_CHARS: usize = 80;
let trimmed = if label.trim().is_empty() {
fallback.trim()
} else {
label.trim()
};
if trimmed.chars().count() <= MAX_BUTTON_LABEL_CHARS {
return trimmed.to_string();
}
let mut out: String = trimmed.chars().take(MAX_BUTTON_LABEL_CHARS - 3).collect();
out.push_str("...");
out
}
#[cfg(test)]
mod outbound_command_tests {
use super::is_safe_extension;