mirror of
https://github.com/coral/sipcord-bridge.git
synced 2026-06-29 09:23:14 -06:00
slopcoding feature: calling from discord commands
This commit is contained in:
parent
d021861858
commit
b18b876cb3
|
|
@ -4,6 +4,9 @@ SIP_PUBLIC_HOST=192.168.0.100
|
|||
RTP_PUBLIC_IP=192.168.0.100
|
||||
|
||||
# Optional (defaults shown)
|
||||
# DISCORD_OUTBOUND_SIP_HOST=192.168.0.25
|
||||
# DISCORD_OUTBOUND_SIP_PORT=5060
|
||||
# DISCORD_OUTBOUND_SIP_TRANSPORT=udp
|
||||
# DATA_DIR=/var/lib/sipcord
|
||||
# CONFIG_PATH=./config.toml
|
||||
# SOUNDS_DIR=./wav
|
||||
|
|
|
|||
41
README.md
41
README.md
|
|
@ -23,8 +23,8 @@ This was written a mix between myself and claude, sure, some of it's big slop bu
|
|||
## Self-host setup notes
|
||||
|
||||
These notes cover the static-router Docker setup. The bridge maps inbound SIP
|
||||
extension digits to Discord voice channels; it does not currently expose a
|
||||
static Discord command for placing outbound calls to SIP phones.
|
||||
extension digits to Discord voice channels, and can also place outbound calls
|
||||
from Discord into a PBX extension when outbound SIP target settings are enabled.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
|
|
@ -70,6 +70,9 @@ Create a `.env` file:
|
|||
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||
SIP_PUBLIC_HOST=192.168.0.100
|
||||
RTP_PUBLIC_IP=192.168.0.100
|
||||
DISCORD_OUTBOUND_SIP_HOST=192.168.0.25
|
||||
DISCORD_OUTBOUND_SIP_PORT=5060
|
||||
DISCORD_OUTBOUND_SIP_TRANSPORT=udp
|
||||
```
|
||||
|
||||
Set both IPs to the address other SIP devices use to reach the bridge. For
|
||||
|
|
@ -78,6 +81,10 @@ example, if FreePBX is `192.168.0.25` and this container runs on an OMV host at
|
|||
advertised in SIP Contact/SDP headers, and callers must be able to route back to
|
||||
it.
|
||||
|
||||
Set `DISCORD_OUTBOUND_SIP_HOST` to the PBX or SIP server that should receive
|
||||
Discord-originated extension calls. For a FreePBX box at `192.168.0.25`, that
|
||||
means `DISCORD_OUTBOUND_SIP_HOST=192.168.0.25`.
|
||||
|
||||
Create a `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
|
|
@ -184,7 +191,32 @@ challenge, a second INVITE with auth, a `200 OK`, and an `ACK`. If the call ends
|
|||
after about 32 seconds, check that `SIP_PUBLIC_HOST` and `RTP_PUBLIC_IP` are set
|
||||
to the bridge host address, not the FreePBX address and not `0.0.0.0`.
|
||||
|
||||
### 4c. Build from source
|
||||
### 4c. Discord -> extension calling
|
||||
|
||||
If `DISCORD_OUTBOUND_SIP_HOST` is set, the bot registers a `/call` slash command
|
||||
in each guild it is connected to.
|
||||
|
||||
Usage:
|
||||
|
||||
```text
|
||||
/call extension:1101
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- The user running `/call` must already be in a Discord voice channel.
|
||||
- The bot uses that voice channel as the bridge destination.
|
||||
- The bridge dials the requested extension through the configured PBX target, for
|
||||
example `sip:1101@192.168.0.25:5060;transport=udp`.
|
||||
- When the SIP side answers, the phone call is connected to the Discord voice
|
||||
channel where the command was run.
|
||||
|
||||
Current scope:
|
||||
- `/call` is implemented for the static self-host backend.
|
||||
- It dials a configured PBX/SIP host by extension.
|
||||
- It does not yet include a Discord `/hangup` command or rich status updates
|
||||
back into Discord after the initial slash command reply.
|
||||
|
||||
### 4d. Build from source
|
||||
|
||||
Requires Rust nightly (for `portable_simd`) and system dependencies for pjproject (OpenSSL, Opus, libtiff, etc). See the `Dockerfile` for the full list.
|
||||
|
||||
|
|
@ -217,6 +249,9 @@ Dial `1000` (or whatever you put in `dialplan.toml`) and you should hear the bot
|
|||
| `RTP_PORT_START` | `10000` | Start of RTP port range |
|
||||
| `RTP_PORT_END` | `15000` | End of RTP port range |
|
||||
| `RTP_PUBLIC_IP` | *(local address if unset)* | Routable IP advertised in SDP for RTP media |
|
||||
| `DISCORD_OUTBOUND_SIP_HOST` | *(disabled if unset)* | PBX/SIP host used by Discord `/call` |
|
||||
| `DISCORD_OUTBOUND_SIP_PORT` | `5060` | Port for Discord-originated outbound SIP calls |
|
||||
| `DISCORD_OUTBOUND_SIP_TRANSPORT` | `udp` | Transport for Discord-originated outbound SIP calls: `udp`, `tcp`, or `tls` |
|
||||
| `CONFIG_PATH` | `./config.toml` | Path to config.toml |
|
||||
| `DIALPLAN_PATH` | `./dialplan.toml` | Path to dialplan.toml |
|
||||
| `SOUNDS_DIR` | `./wav` | Path to sound files directory |
|
||||
|
|
|
|||
|
|
@ -522,8 +522,15 @@ impl BridgeCoordinator {
|
|||
req.call_id, req.discord_username
|
||||
);
|
||||
|
||||
// Look up the user's SIP contact from the registrar
|
||||
let contacts = if let Some(ref registrar) = outbound_registrar {
|
||||
// Either dial the explicitly configured SIP URI, or look up
|
||||
// registered contacts for the Discord username.
|
||||
let contacts = if let Some(sip_uri) = req.sip_uri.clone() {
|
||||
vec![(
|
||||
sip_uri,
|
||||
String::new(),
|
||||
crate::services::registrar::SipTransport::Udp,
|
||||
)]
|
||||
} else if let Some(ref registrar) = outbound_registrar {
|
||||
registrar.get_contacts_for_discord_user(&req.discord_username)
|
||||
} else {
|
||||
Vec::new()
|
||||
|
|
@ -549,6 +556,16 @@ impl BridgeCoordinator {
|
|||
|
||||
// Ring ALL registered contacts simultaneously
|
||||
for (contact_uri, source_addr, transport) in &contacts {
|
||||
if req.sip_uri.is_some() {
|
||||
let _ = outbound_sip_cmd_tx.send(SipCommand::MakeOutboundCall {
|
||||
tracking_id: req.call_id.clone(),
|
||||
sip_uri: contact_uri.clone(),
|
||||
caller_display_name: Some(req.caller_username.clone()),
|
||||
fork_total,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the user part from the Contact URI (e.g., "sip:3001@10.0.1.151:5060" -> "3001")
|
||||
// The contact_uri has the correct SIP username/extension; source_addr is the NAT'd public address
|
||||
let user_part = contact_uri
|
||||
|
|
|
|||
|
|
@ -65,6 +65,12 @@ fn default_tls_refresh() -> u64 {
|
|||
fn default_dialplan_path() -> String {
|
||||
"./dialplan.toml".to_string()
|
||||
}
|
||||
fn default_discord_outbound_sip_port() -> u16 {
|
||||
5060
|
||||
}
|
||||
fn default_discord_outbound_sip_transport() -> String {
|
||||
"udp".to_string()
|
||||
}
|
||||
|
||||
/// All environment variables consumed by the bridge, deserialized once at startup.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
|
|
@ -106,6 +112,50 @@ pub struct EnvConfig {
|
|||
pub discord_bot_token: Option<String>,
|
||||
#[serde(default = "default_dialplan_path")]
|
||||
pub dialplan_path: String,
|
||||
pub discord_outbound_sip_host: Option<String>,
|
||||
#[serde(default = "default_discord_outbound_sip_port")]
|
||||
pub discord_outbound_sip_port: u16,
|
||||
#[serde(default = "default_discord_outbound_sip_transport")]
|
||||
pub discord_outbound_sip_transport: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutboundSipTransport {
|
||||
Udp,
|
||||
Tcp,
|
||||
Tls,
|
||||
}
|
||||
|
||||
impl OutboundSipTransport {
|
||||
fn parse(raw: &str) -> Option<Self> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"udp" => Some(Self::Udp),
|
||||
"tcp" => Some(Self::Tcp),
|
||||
"tls" | "sips" => Some(Self::Tls),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiscordOutboundSipConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub transport: OutboundSipTransport,
|
||||
}
|
||||
|
||||
impl DiscordOutboundSipConfig {
|
||||
pub fn build_sip_uri(&self, extension: &str) -> String {
|
||||
match self.transport {
|
||||
OutboundSipTransport::Udp => {
|
||||
format!("sip:{}@{}:{};transport=udp", extension, self.host, self.port)
|
||||
}
|
||||
OutboundSipTransport::Tcp => {
|
||||
format!("sip:{}@{}:{};transport=tcp", extension, self.host, self.port)
|
||||
}
|
||||
OutboundSipTransport::Tls => format!("sips:{}@{}:{}", extension, self.host, self.port),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EnvConfig {
|
||||
|
|
@ -172,6 +222,17 @@ impl EnvConfig {
|
|||
self.sip_public_host.as_deref().unwrap_or("0.0.0.0")
|
||||
}
|
||||
|
||||
/// Build outbound Discord->SIP call config when enabled.
|
||||
pub fn discord_outbound_sip_config(&self) -> Option<DiscordOutboundSipConfig> {
|
||||
let host = self.discord_outbound_sip_host.clone()?;
|
||||
let transport = OutboundSipTransport::parse(&self.discord_outbound_sip_transport)?;
|
||||
Some(DiscordOutboundSipConfig {
|
||||
host,
|
||||
port: self.discord_outbound_sip_port,
|
||||
transport,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the resolved DATA_DIR path, applying the smart fallback:
|
||||
/// if the default `/var/lib/sipcord` doesn't exist on disk, fall back to `.`.
|
||||
pub fn resolved_data_dir(&self) -> String {
|
||||
|
|
@ -466,6 +527,9 @@ mod tests {
|
|||
tls_refresh_interval: 3600,
|
||||
discord_bot_token: None,
|
||||
dialplan_path: "./dialplan.toml".to_string(),
|
||||
discord_outbound_sip_host: None,
|
||||
discord_outbound_sip_port: 5060,
|
||||
discord_outbound_sip_transport: "udp".to_string(),
|
||||
};
|
||||
assert_eq!(env.resolved_data_dir(), ".");
|
||||
}
|
||||
|
|
@ -490,6 +554,9 @@ mod tests {
|
|||
tls_refresh_interval: 3600,
|
||||
discord_bot_token: None,
|
||||
dialplan_path: "./dialplan.toml".to_string(),
|
||||
discord_outbound_sip_host: None,
|
||||
discord_outbound_sip_port: 5060,
|
||||
discord_outbound_sip_transport: "udp".to_string(),
|
||||
};
|
||||
assert_eq!(env.resolved_data_dir(), "/tmp");
|
||||
}
|
||||
|
|
@ -514,6 +581,9 @@ mod tests {
|
|||
tls_refresh_interval: 3600,
|
||||
discord_bot_token: None,
|
||||
dialplan_path: "./dialplan.toml".to_string(),
|
||||
discord_outbound_sip_host: None,
|
||||
discord_outbound_sip_port: 5060,
|
||||
discord_outbound_sip_transport: "udp".to_string(),
|
||||
};
|
||||
let tls = env.to_tls_config();
|
||||
assert_eq!(tls.cert_dir, PathBuf::from("/data/certs"));
|
||||
|
|
@ -534,6 +604,38 @@ mod tests {
|
|||
assert_eq!(tls.key_path(), PathBuf::from("/etc/ssl/sipcord/bridge.key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_outbound_sip_config_uri() {
|
||||
let env = EnvConfig {
|
||||
data_dir: "/data".to_string(),
|
||||
config_path: "./config.toml".to_string(),
|
||||
bridge_id: "br_test".to_string(),
|
||||
sounds_dir: "./wav".to_string(),
|
||||
dev_mode: false,
|
||||
sip_public_host: None,
|
||||
sip_port: 5060,
|
||||
rtp_port_start: 10000,
|
||||
rtp_port_end: 15000,
|
||||
rtp_public_ip: None,
|
||||
sip_local_host: None,
|
||||
sip_local_cidr: None,
|
||||
tls_cert_dir: None,
|
||||
tls_port: 5061,
|
||||
tls_refresh_interval: 3600,
|
||||
discord_bot_token: None,
|
||||
dialplan_path: "./dialplan.toml".to_string(),
|
||||
discord_outbound_sip_host: Some("192.168.0.25".to_string()),
|
||||
discord_outbound_sip_port: 5060,
|
||||
discord_outbound_sip_transport: "udp".to_string(),
|
||||
};
|
||||
|
||||
let outbound = env.discord_outbound_sip_config().unwrap();
|
||||
assert_eq!(
|
||||
outbound.build_sip_uri("1101"),
|
||||
"sip:1101@192.168.0.25:5060;transport=udp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_config_load_valid_toml() {
|
||||
let toml_content = r#"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use sipcord_bridge::BridgeError;
|
|||
use sipcord_bridge::call::BridgeCoordinator;
|
||||
use sipcord_bridge::config::{APP_CONFIG, AppConfig, ConfigError, EnvConfig, SipConfig};
|
||||
use sipcord_bridge::routing::static_router::StaticBackend;
|
||||
use sipcord_bridge::transport::discord::SharedDiscordClient;
|
||||
use sipcord_bridge::transport::discord::{DiscordOutboundCallConfig, SharedDiscordClient};
|
||||
use sipcord_bridge::transport::sip::SipTransport;
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -58,7 +58,12 @@ async fn run_static_router() -> Result<(), BridgeError> {
|
|||
|
||||
// Load dialplan
|
||||
let dialplan_path = PathBuf::from(&EnvConfig::global().dialplan_path);
|
||||
let backend = Arc::new(StaticBackend::load(&dialplan_path, bot_token.clone())?);
|
||||
let (outbound_request_tx, outbound_request_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend = Arc::new(StaticBackend::load(
|
||||
&dialplan_path,
|
||||
bot_token.clone(),
|
||||
outbound_request_rx,
|
||||
)?);
|
||||
|
||||
// Create SIP transport (no TLS for static router)
|
||||
let sip_transport = SipTransport::new(sip_config.clone(), None);
|
||||
|
|
@ -77,7 +82,14 @@ async fn run_static_router() -> Result<(), BridgeError> {
|
|||
});
|
||||
|
||||
// Create shared Discord client
|
||||
let shared_discord = SharedDiscordClient::new(&bot_token).await?;
|
||||
let outbound_call_config = EnvConfig::global()
|
||||
.discord_outbound_sip_config()
|
||||
.map(|sip| DiscordOutboundCallConfig {
|
||||
sip,
|
||||
request_tx: outbound_request_tx,
|
||||
bot_token: bot_token.clone(),
|
||||
});
|
||||
let shared_discord = SharedDiscordClient::new(&bot_token, outbound_call_config).await?;
|
||||
info!("Shared Discord client initialized");
|
||||
|
||||
let bridge = BridgeCoordinator::new(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub struct OutboundCallRequest {
|
|||
pub channel_id: String,
|
||||
pub bot_token: String,
|
||||
pub caller_username: String,
|
||||
pub sip_uri: Option<String>,
|
||||
pub created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@
|
|||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::info;
|
||||
|
||||
use crate::config::ConfigError;
|
||||
|
|
@ -39,15 +41,20 @@ struct Dialplan {
|
|||
///
|
||||
/// Routes calls by looking up the dialed extension in a TOML dialplan file.
|
||||
/// No authentication is performed — any caller dialing a known extension is connected.
|
||||
/// Outbound calls are not supported.
|
||||
/// Outbound calls can also be queued by the self-host Discord `/call` command.
|
||||
pub struct StaticBackend {
|
||||
bot_token: String,
|
||||
extensions: HashMap<String, ExtensionTarget>,
|
||||
outbound_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>>>,
|
||||
}
|
||||
|
||||
impl StaticBackend {
|
||||
/// Load the dialplan from a TOML file. `bot_token` comes from the environment.
|
||||
pub fn load(path: &Path, bot_token: String) -> Result<Self, ConfigError> {
|
||||
pub fn load(
|
||||
path: &Path,
|
||||
bot_token: String,
|
||||
outbound_rx: tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let content = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
|
|
@ -72,6 +79,7 @@ impl StaticBackend {
|
|||
Ok(Self {
|
||||
bot_token,
|
||||
extensions: dialplan.extensions,
|
||||
outbound_rx: Arc::new(Mutex::new(outbound_rx)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -115,8 +123,7 @@ impl Backend for StaticBackend {
|
|||
fn report_call_status(&self, _call_id: &str, _status: &str) {}
|
||||
|
||||
async fn next_outbound_request(&self) -> Option<OutboundCallRequest> {
|
||||
// Static router doesn't support outbound calls — block forever
|
||||
std::future::pending().await
|
||||
self.outbound_rx.lock().await.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +143,8 @@ mod tests {
|
|||
let path = dir.join("test_dialplan.toml");
|
||||
std::fs::write(&path, toml_content).unwrap();
|
||||
|
||||
let backend = StaticBackend::load(&path, "test_token".to_string()).unwrap();
|
||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend = StaticBackend::load(&path, "test_token".to_string(), rx).unwrap();
|
||||
assert_eq!(backend.extensions.len(), 2);
|
||||
assert!(backend.extensions.contains_key("1000"));
|
||||
assert!(backend.extensions.contains_key("2000"));
|
||||
|
|
@ -153,7 +161,8 @@ mod tests {
|
|||
let path = dir.join("test_route.toml");
|
||||
std::fs::write(&path, toml_content).unwrap();
|
||||
|
||||
let backend = StaticBackend::load(&path, "tok".to_string()).unwrap();
|
||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend = StaticBackend::load(&path, "tok".to_string(), rx).unwrap();
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
|
|
@ -182,7 +191,8 @@ mod tests {
|
|||
let path = dir.join("test_route_unknown.toml");
|
||||
std::fs::write(&path, toml_content).unwrap();
|
||||
|
||||
let backend = StaticBackend::load(&path, "tok".to_string()).unwrap();
|
||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend = StaticBackend::load(&path, "tok".to_string(), rx).unwrap();
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
|
|
@ -207,7 +217,8 @@ mod tests {
|
|||
let path = dir.join("test_bad.toml");
|
||||
std::fs::write(&path, "this is not valid toml [[[").unwrap();
|
||||
|
||||
let result = StaticBackend::load(&path, "tok".to_string());
|
||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let result = StaticBackend::load(&path, "tok".to_string(), rx);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
mod voice;
|
||||
|
||||
use crate::audio::simd;
|
||||
use crate::config::DiscordOutboundSipConfig;
|
||||
use crate::routing::OutboundCallRequest;
|
||||
use crate::services::snowflake::Snowflake;
|
||||
use audioadapter::Adapter;
|
||||
use audioadapter_buffers::direct::SequentialSliceOfVecs;
|
||||
|
|
@ -12,7 +14,11 @@ use rubato::{
|
|||
Async, FixedAsync, Resampler, SincInterpolationParameters, SincInterpolationType,
|
||||
WindowFunction,
|
||||
};
|
||||
use serenity::all::{ChannelId, Client, Context, EventHandler, FullEvent, GatewayIntents, GuildId};
|
||||
use serenity::all::{
|
||||
ChannelId, Client, Command, CommandInteraction, CommandOptionType, Context, CreateCommand,
|
||||
CreateCommandOption, CreateInteractionResponse, CreateInteractionResponseMessage, EventHandler,
|
||||
FullEvent, GatewayIntents, GuildId, Interaction,
|
||||
};
|
||||
use serenity::async_trait;
|
||||
use serenity::secrets::Token;
|
||||
use songbird::driver::DecodeMode;
|
||||
|
|
@ -45,6 +51,9 @@ pub enum DiscordError {
|
|||
attempts: u32,
|
||||
last_error: String,
|
||||
},
|
||||
|
||||
#[error("failed to register Discord slash command: {0}")]
|
||||
CommandRegistration(String),
|
||||
}
|
||||
|
||||
// Direct audio path: SIP audio thread → Discord
|
||||
|
|
@ -493,13 +502,23 @@ pub struct SharedDiscordClient {
|
|||
_client_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DiscordOutboundCallConfig {
|
||||
pub sip: DiscordOutboundSipConfig,
|
||||
pub request_tx: tokio::sync::mpsc::UnboundedSender<OutboundCallRequest>,
|
||||
pub bot_token: String,
|
||||
}
|
||||
|
||||
impl SharedDiscordClient {
|
||||
/// Create the shared Discord client. Call once at bridge startup.
|
||||
///
|
||||
/// This opens a single gateway WebSocket connection that stays alive for
|
||||
/// the bridge's lifetime. The returned Songbird manager is used by all
|
||||
/// voice connections to join/leave channels.
|
||||
pub async fn new(bot_token: &str) -> Result<Arc<Self>, DiscordError> {
|
||||
pub async fn new(
|
||||
bot_token: &str,
|
||||
outbound_call_config: Option<DiscordOutboundCallConfig>,
|
||||
) -> Result<Arc<Self>, DiscordError> {
|
||||
info!("Creating shared Discord client (single gateway connection)");
|
||||
|
||||
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES;
|
||||
|
|
@ -515,7 +534,10 @@ impl SharedDiscordClient {
|
|||
.map_err(|e| DiscordError::InvalidToken(format!("{e}")))?;
|
||||
|
||||
let mut client = Client::builder(token, intents)
|
||||
.event_handler(Arc::new(SharedClientEventHandler { ready_tx }))
|
||||
.event_handler(Arc::new(SharedClientEventHandler {
|
||||
ready_tx,
|
||||
outbound_call_config,
|
||||
}))
|
||||
.voice_manager(songbird.clone())
|
||||
.await?;
|
||||
|
||||
|
|
@ -565,20 +587,173 @@ impl SharedDiscordClient {
|
|||
/// Serenity event handler for the shared client
|
||||
struct SharedClientEventHandler {
|
||||
ready_tx: Arc<tokio::sync::Mutex<Option<oneshot::Sender<u64>>>>,
|
||||
outbound_call_config: Option<DiscordOutboundCallConfig>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for SharedClientEventHandler {
|
||||
async fn dispatch(&self, _ctx: &Context, event: &FullEvent) {
|
||||
if let FullEvent::Ready { data_about_bot, .. } = event {
|
||||
info!(
|
||||
"Shared Discord bot connected as {} (ID: {})",
|
||||
data_about_bot.user.name, data_about_bot.user.id
|
||||
);
|
||||
if let Some(tx) = self.ready_tx.lock().await.take() {
|
||||
let _ = tx.send(data_about_bot.user.id.get());
|
||||
async fn dispatch(&self, ctx: &Context, event: &FullEvent) {
|
||||
match event {
|
||||
FullEvent::Ready { data_about_bot, .. } => {
|
||||
info!(
|
||||
"Shared Discord bot connected as {} (ID: {})",
|
||||
data_about_bot.user.name, data_about_bot.user.id
|
||||
);
|
||||
if let Some(tx) = self.ready_tx.lock().await.take() {
|
||||
let _ = tx.send(data_about_bot.user.id.get());
|
||||
}
|
||||
|
||||
if self.outbound_call_config.is_some() {
|
||||
for guild_status in &data_about_bot.guilds {
|
||||
if let Err(e) = register_call_command(ctx, guild_status.id).await {
|
||||
error!(
|
||||
"Failed to register /call command for guild {}: {}",
|
||||
guild_status.id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FullEvent::InteractionCreate { interaction } => {
|
||||
if let Some(ref cfg) = self.outbound_call_config
|
||||
&& let Interaction::Command(command) = interaction
|
||||
&& command.data.name == "call"
|
||||
{
|
||||
handle_call_command(ctx, command, cfg).await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn register_call_command(ctx: &Context, guild_id: GuildId) -> Result<(), serenity::Error> {
|
||||
let command = CreateCommand::new("call")
|
||||
.description("Call a SIP/PBX extension from your current voice channel")
|
||||
.add_option(
|
||||
CreateCommandOption::new(
|
||||
CommandOptionType::String,
|
||||
"extension",
|
||||
"The extension to dial",
|
||||
)
|
||||
.required(true),
|
||||
);
|
||||
|
||||
Command::create_guild_command(&ctx.http, guild_id, command).await?;
|
||||
info!("Registered /call command for guild {}", guild_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_call_command(
|
||||
ctx: &Context,
|
||||
command: &CommandInteraction,
|
||||
cfg: &DiscordOutboundCallConfig,
|
||||
) {
|
||||
let response = match build_outbound_request(ctx, command, cfg) {
|
||||
Ok(req) => {
|
||||
let extension = req.discord_username.clone();
|
||||
match cfg.request_tx.send(req) {
|
||||
Ok(()) => format!("Dialing extension `{}` from your current voice channel.", extension),
|
||||
Err(_) => "Outbound call queue is unavailable right now.".to_string(),
|
||||
}
|
||||
}
|
||||
Err(msg) => msg,
|
||||
};
|
||||
|
||||
if let Err(e) = command
|
||||
.create_response(
|
||||
ctx,
|
||||
CreateInteractionResponse::Message(
|
||||
CreateInteractionResponseMessage::new()
|
||||
.content(response)
|
||||
.ephemeral(true),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to respond to /call interaction: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_outbound_request(
|
||||
ctx: &Context,
|
||||
command: &CommandInteraction,
|
||||
cfg: &DiscordOutboundCallConfig,
|
||||
) -> Result<OutboundCallRequest, String> {
|
||||
let extension = command
|
||||
.data
|
||||
.options
|
||||
.iter()
|
||||
.find(|opt| opt.name == "extension")
|
||||
.and_then(|opt| opt.value.as_str())
|
||||
.ok_or_else(|| "Missing extension.".to_string())?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if !is_safe_extension(&extension) {
|
||||
return Err(
|
||||
"Extension contains unsupported characters. Use digits or simple SIP-safe extension text."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let guild_id = command
|
||||
.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)
|
||||
.and_then(|state| state.channel_id)
|
||||
.ok_or_else(|| "Join a voice channel first, then run `/call` there.".to_string())?;
|
||||
|
||||
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());
|
||||
|
||||
Ok(OutboundCallRequest {
|
||||
call_id: format!("discord-{}-{}", command.id, extension),
|
||||
discord_username: extension.clone(),
|
||||
guild_id: guild_id.get().to_string(),
|
||||
channel_id: voice_channel_id.get().to_string(),
|
||||
bot_token: cfg.bot_token.clone(),
|
||||
caller_username,
|
||||
sip_uri: Some(cfg.sip.build_sip_uri(&extension)),
|
||||
created_at: std::time::Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_safe_extension(extension: &str) -> bool {
|
||||
!extension.is_empty()
|
||||
&& extension.len() <= 64
|
||||
&& extension.chars().all(|ch| {
|
||||
ch.is_ascii_alphanumeric() || matches!(ch, '*' | '#' | '+' | '-' | '_' | '.')
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod outbound_command_tests {
|
||||
use super::is_safe_extension;
|
||||
|
||||
#[test]
|
||||
fn safe_extensions_are_accepted() {
|
||||
assert!(is_safe_extension("1101"));
|
||||
assert!(is_safe_extension("*98"));
|
||||
assert!(is_safe_extension("queue-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_extensions_are_rejected() {
|
||||
assert!(!is_safe_extension(""));
|
||||
assert!(!is_safe_extension("1101@pbx"));
|
||||
assert!(!is_safe_extension("11 01"));
|
||||
assert!(!is_safe_extension("1101/../../"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue