diff --git a/README.md b/README.md index 33fe29c..1df8d10 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/sipcord-bridge/src/main.rs b/sipcord-bridge/src/main.rs index 79e2018..ee3a596 100644 --- a/sipcord-bridge/src/main.rs +++ b/sipcord-bridge/src/main.rs @@ -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"); diff --git a/sipcord-bridge/src/routing/mod.rs b/sipcord-bridge/src/routing/mod.rs index 375c2d1..d4e7ba4 100644 --- a/sipcord-bridge/src/routing/mod.rs +++ b/sipcord-bridge/src/routing/mod.rs @@ -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 { diff --git a/sipcord-bridge/src/routing/static_router.rs b/sipcord-bridge/src/routing/static_router.rs index 0d6654f..ade3ebd 100644 --- a/sipcord-bridge/src/routing/static_router.rs +++ b/sipcord-bridge/src/routing/static_router.rs @@ -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, +} + #[derive(Deserialize)] struct Dialplan { #[serde(default)] extensions: HashMap, #[serde(default)] menus: HashMap, + #[serde(default)] + phones: HashMap, } fn default_menu_timeout_seconds() -> u64 { @@ -73,6 +84,7 @@ pub struct StaticBackend { bot_token: String, extensions: HashMap, menus: HashMap, + phones: HashMap, outbound_rx: Arc>>, hangup_rx: Arc>>, } @@ -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 { + let mut entries: Vec = 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"); diff --git a/sipcord-bridge/src/transport/discord/mod.rs b/sipcord-bridge/src/transport/discord/mod.rs index 8e3dff6..8c69e85 100644 --- a/sipcord-bridge/src/transport/discord/mod.rs +++ b/sipcord-bridge/src/transport/discord/mod.rs @@ -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, pub hangup_tx: tokio::sync::mpsc::UnboundedSender, pub bot_token: String, + pub phone_directory: Vec, } 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, + user_id: serenity::all::UserId, + member_nick: Option, + global_name: Option, + username: String, + extension: &str, + request_id: &str, + cfg: &DiscordOutboundCallConfig, + command_name: &str, +) -> Result { + 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, + 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;