mirror of
https://github.com/coral/sipcord-bridge.git
synced 2026-06-29 09:23:14 -06:00
discord directory menu
This commit is contained in:
parent
4d8dd36538
commit
d44e3544d1
25
README.md
25
README.md
|
|
@ -12,6 +12,10 @@ This means you have to build the call routing backend yourself. I am including a
|
||||||
extension = "8000"
|
extension = "8000"
|
||||||
timeout_seconds = 10
|
timeout_seconds = 10
|
||||||
max_attempts = 3
|
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.
|
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
|
`#` to repeat the current menu page, `9` for the next page when available, and
|
||||||
`*` for the previous page when available.
|
`*` 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)
|
### 4a. Run with Docker (recommended)
|
||||||
|
|
||||||
Create a directory for your deployment:
|
Create a directory for your deployment:
|
||||||
|
|
@ -225,6 +247,7 @@ Usage:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/call extension:1101
|
/call extension:1101
|
||||||
|
/directory
|
||||||
/hangup
|
/hangup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -238,6 +261,8 @@ Behavior:
|
||||||
channel where the command was run.
|
channel where the command was run.
|
||||||
- `/hangup` ends active SIP calls connected to the voice channel where the
|
- `/hangup` ends active SIP calls connected to the voice channel where the
|
||||||
command was run.
|
command was run.
|
||||||
|
- `/directory` opens the configured phone directory as Discord buttons. Clicking
|
||||||
|
a phone button behaves like `/call` for that extension.
|
||||||
|
|
||||||
Current scope:
|
Current scope:
|
||||||
- `/call` is implemented for the static self-host backend.
|
- `/call` is implemented for the static self-host backend.
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ async fn run_static_router() -> Result<(), BridgeError> {
|
||||||
outbound_request_rx,
|
outbound_request_rx,
|
||||||
hangup_request_rx,
|
hangup_request_rx,
|
||||||
)?);
|
)?);
|
||||||
|
let phone_directory = backend.phone_directory();
|
||||||
|
|
||||||
// Create SIP transport (no TLS for static router)
|
// Create SIP transport (no TLS for static router)
|
||||||
let sip_transport = SipTransport::new(sip_config.clone(), None);
|
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,
|
request_tx: outbound_request_tx,
|
||||||
hangup_tx: hangup_request_tx,
|
hangup_tx: hangup_request_tx,
|
||||||
bot_token: bot_token.clone(),
|
bot_token: bot_token.clone(),
|
||||||
|
phone_directory: phone_directory.clone(),
|
||||||
});
|
});
|
||||||
let shared_discord = SharedDiscordClient::new(&bot_token, outbound_call_config).await?;
|
let shared_discord = SharedDiscordClient::new(&bot_token, outbound_call_config).await?;
|
||||||
info!("Shared Discord client initialized");
|
info!("Shared Discord client initialized");
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,14 @@ pub struct HangupCallRequest {
|
||||||
pub created_at: std::time::Instant,
|
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.
|
/// Dynamic IVR menu route.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MenuRoute {
|
pub struct MenuRoute {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@
|
||||||
//!
|
//!
|
||||||
//! [menus.main]
|
//! [menus.main]
|
||||||
//! extension = "8000"
|
//! extension = "8000"
|
||||||
|
//!
|
||||||
|
//! [phones]
|
||||||
|
//! 777 = { label = "Shop speakerphone" }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -28,7 +31,7 @@ use tracing::info;
|
||||||
use crate::config::ConfigError;
|
use crate::config::ConfigError;
|
||||||
use crate::routing::{
|
use crate::routing::{
|
||||||
Backend, CallError, CallStartedInfo, HangupCallRequest, MenuRoute, OutboundCallRequest,
|
Backend, CallError, CallStartedInfo, HangupCallRequest, MenuRoute, OutboundCallRequest,
|
||||||
RouteDecision,
|
PhoneDirectoryEntry, RouteDecision,
|
||||||
};
|
};
|
||||||
use crate::services::snowflake::Snowflake;
|
use crate::services::snowflake::Snowflake;
|
||||||
use crate::transport::sip::DigestAuthParams;
|
use crate::transport::sip::DigestAuthParams;
|
||||||
|
|
@ -48,12 +51,20 @@ struct MenuConfig {
|
||||||
max_attempts: u8,
|
max_attempts: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
struct PhoneConfig {
|
||||||
|
label: String,
|
||||||
|
extension: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Dialplan {
|
struct Dialplan {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
extensions: HashMap<String, ExtensionTarget>,
|
extensions: HashMap<String, ExtensionTarget>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
menus: HashMap<String, MenuConfig>,
|
menus: HashMap<String, MenuConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
phones: HashMap<String, PhoneConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_menu_timeout_seconds() -> u64 {
|
fn default_menu_timeout_seconds() -> u64 {
|
||||||
|
|
@ -73,6 +84,7 @@ pub struct StaticBackend {
|
||||||
bot_token: String,
|
bot_token: String,
|
||||||
extensions: HashMap<String, ExtensionTarget>,
|
extensions: HashMap<String, ExtensionTarget>,
|
||||||
menus: HashMap<String, MenuConfig>,
|
menus: HashMap<String, MenuConfig>,
|
||||||
|
phones: HashMap<String, PhoneConfig>,
|
||||||
outbound_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>>>,
|
outbound_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>>>,
|
||||||
hangup_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<HangupCallRequest>>>,
|
hangup_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<HangupCallRequest>>>,
|
||||||
}
|
}
|
||||||
|
|
@ -111,15 +123,38 @@ impl StaticBackend {
|
||||||
info!(" dynamic menu {} on ext {}", id, menu.extension);
|
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 {
|
Ok(Self {
|
||||||
bot_token,
|
bot_token,
|
||||||
extensions: dialplan.extensions,
|
extensions: dialplan.extensions,
|
||||||
menus: dialplan.menus,
|
menus: dialplan.menus,
|
||||||
|
phones: dialplan.phones,
|
||||||
outbound_rx: Arc::new(Mutex::new(outbound_rx)),
|
outbound_rx: Arc::new(Mutex::new(outbound_rx)),
|
||||||
hangup_rx: Arc::new(Mutex::new(hangup_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]
|
#[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]
|
#[test]
|
||||||
fn test_load_malformed_toml() {
|
fn test_load_malformed_toml() {
|
||||||
let dir = std::env::temp_dir().join("sipcord_test_dialplan");
|
let dir = std::env::temp_dir().join("sipcord_test_dialplan");
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ mod voice;
|
||||||
|
|
||||||
use crate::audio::simd;
|
use crate::audio::simd;
|
||||||
use crate::config::DiscordOutboundSipConfig;
|
use crate::config::DiscordOutboundSipConfig;
|
||||||
use crate::routing::{HangupCallRequest, OutboundCallRequest};
|
use crate::routing::{HangupCallRequest, OutboundCallRequest, PhoneDirectoryEntry};
|
||||||
use crate::services::snowflake::Snowflake;
|
use crate::services::snowflake::Snowflake;
|
||||||
use audioadapter::Adapter;
|
use audioadapter::Adapter;
|
||||||
use audioadapter_buffers::direct::SequentialSliceOfVecs;
|
use audioadapter_buffers::direct::SequentialSliceOfVecs;
|
||||||
|
|
@ -15,9 +15,11 @@ use rubato::{
|
||||||
WindowFunction,
|
WindowFunction,
|
||||||
};
|
};
|
||||||
use serenity::all::{
|
use serenity::all::{
|
||||||
ChannelId, Client, Command, CommandInteraction, CommandOptionType, Context, CreateCommand,
|
ButtonStyle, ChannelId, Client, CommandInteraction, CommandOptionType,
|
||||||
CreateCommandOption, CreateInteractionResponse, CreateInteractionResponseMessage, EventHandler,
|
ComponentInteraction, Context, CreateActionRow, CreateButton, CreateCommand,
|
||||||
FullEvent, GatewayIntents, GuildId, Interaction,
|
CreateCommandOption, CreateEmbed, CreateInteractionResponse,
|
||||||
|
CreateInteractionResponseMessage, EventHandler, FullEvent, GatewayIntents, GuildId,
|
||||||
|
Interaction,
|
||||||
};
|
};
|
||||||
use serenity::async_trait;
|
use serenity::async_trait;
|
||||||
use serenity::secrets::Token;
|
use serenity::secrets::Token;
|
||||||
|
|
@ -508,6 +510,7 @@ pub struct DiscordOutboundCallConfig {
|
||||||
pub request_tx: tokio::sync::mpsc::UnboundedSender<OutboundCallRequest>,
|
pub request_tx: tokio::sync::mpsc::UnboundedSender<OutboundCallRequest>,
|
||||||
pub hangup_tx: tokio::sync::mpsc::UnboundedSender<HangupCallRequest>,
|
pub hangup_tx: tokio::sync::mpsc::UnboundedSender<HangupCallRequest>,
|
||||||
pub bot_token: String,
|
pub bot_token: String,
|
||||||
|
pub phone_directory: Vec<PhoneDirectoryEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SharedDiscordClient {
|
impl SharedDiscordClient {
|
||||||
|
|
@ -616,12 +619,19 @@ impl EventHandler for SharedClientEventHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FullEvent::InteractionCreate { interaction, .. } => {
|
FullEvent::InteractionCreate { interaction, .. } => {
|
||||||
if let Some(ref cfg) = self.outbound_call_config
|
if let Some(ref cfg) = self.outbound_call_config {
|
||||||
&& let Interaction::Command(command) = interaction
|
match interaction {
|
||||||
{
|
Interaction::Command(command) => match command.data.name.as_str() {
|
||||||
match command.data.name.as_str() {
|
"call" => handle_call_command(ctx, command, cfg).await,
|
||||||
"call" => handle_call_command(ctx, command, cfg).await,
|
"hangup" => handle_hangup_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")
|
let hangup_command = CreateCommand::new("hangup")
|
||||||
.description("Hang up active SIP calls in your current voice channel");
|
.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, call_command).await?;
|
||||||
guild_id.create_command(&ctx.http, hangup_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(())
|
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(
|
fn build_outbound_request(
|
||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
command: &CommandInteraction,
|
command: &CommandInteraction,
|
||||||
|
|
@ -729,6 +880,36 @@ fn build_outbound_request(
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.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) {
|
if !is_safe_extension(&extension) {
|
||||||
return Err(
|
return Err(
|
||||||
"Extension contains unsupported characters. Use digits or simple SIP-safe extension text."
|
"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 (guild_id, voice_channel_id) =
|
||||||
|
current_voice_channel_for_user(ctx, guild_id, user_id, command_name)?;
|
||||||
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 caller_username = member_nick.or(global_name).unwrap_or(username);
|
||||||
Ok(OutboundCallRequest {
|
Ok(OutboundCallRequest {
|
||||||
call_id: format!("discord-{}-{}", command.id, extension),
|
call_id: format!("discord-{}-{}", request_id, extension),
|
||||||
discord_username: extension.clone(),
|
discord_username: extension.clone(),
|
||||||
guild_id: guild_id.get().to_string(),
|
guild_id: guild_id.get().to_string(),
|
||||||
channel_id: voice_channel_id.get().to_string(),
|
channel_id: voice_channel_id.get().to_string(),
|
||||||
|
|
@ -784,22 +960,37 @@ fn current_voice_channel(
|
||||||
command: &CommandInteraction,
|
command: &CommandInteraction,
|
||||||
command_name: &str,
|
command_name: &str,
|
||||||
) -> Result<(GuildId, ChannelId), String> {
|
) -> Result<(GuildId, ChannelId), String> {
|
||||||
let guild_id = command
|
current_voice_channel_for_user(ctx, command.guild_id, command.user.id, command_name)
|
||||||
.guild_id
|
}
|
||||||
.ok_or_else(|| "This command only works inside a server.".to_string())?;
|
|
||||||
|
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
|
let guild = ctx
|
||||||
.cache
|
.cache
|
||||||
.guild(guild_id)
|
.guild(guild_id)
|
||||||
.ok_or_else(|| "Guild is not available in cache yet. Try again in a moment.".to_string())?;
|
.ok_or_else(|| "Guild is not available in cache yet. Try again in a moment.".to_string())?;
|
||||||
let voice_channel_id = guild
|
let voice_channel_id = guild
|
||||||
.voice_states
|
.voice_states
|
||||||
.get(&command.user.id)
|
.get(&user_id)
|
||||||
.and_then(|state| state.channel_id)
|
.and_then(|state| state.channel_id)
|
||||||
.ok_or_else(|| format!("Join a voice channel first, then run `{command_name}` there."))?;
|
.ok_or_else(|| format!("Join a voice channel first, then run `{command_name}` there."))?;
|
||||||
|
|
||||||
Ok((guild_id, voice_channel_id))
|
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 {
|
fn is_safe_extension(extension: &str) -> bool {
|
||||||
!extension.is_empty()
|
!extension.is_empty()
|
||||||
&& extension.len() <= 64
|
&& 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)]
|
#[cfg(test)]
|
||||||
mod outbound_command_tests {
|
mod outbound_command_tests {
|
||||||
use super::is_safe_extension;
|
use super::is_safe_extension;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue