slopcoding feature: calling from discord commands

This commit is contained in:
legop3 2026-06-14 00:04:10 -04:00
parent d021861858
commit b18b876cb3
8 changed files with 383 additions and 27 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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

View file

@ -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#"

View file

@ -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(

View file

@ -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,
}

View file

@ -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());
}
}

View file

@ -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,12 +587,14 @@ 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 {
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
@ -578,8 +602,159 @@ impl EventHandler for SharedClientEventHandler {
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/../../"));
}
}
/// Inner state for Discord voice connection