mirror of
https://github.com/coral/sipcord-bridge.git
synced 2026-06-29 09:23:14 -06:00
slop hangup
This commit is contained in:
parent
14fecbb3e1
commit
e4576231ba
|
|
@ -7,7 +7,6 @@ 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
|
||||
# DISCORD_OUTBOUND_EXTENSION_PREFIX=
|
||||
# DATA_DIR=/var/lib/sipcord
|
||||
# CONFIG_PATH=./config.toml
|
||||
# SOUNDS_DIR=./wav
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -73,7 +73,6 @@ 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
|
||||
DISCORD_OUTBOUND_EXTENSION_PREFIX=
|
||||
```
|
||||
|
||||
Set both IPs to the address other SIP devices use to reach the bridge. For
|
||||
|
|
@ -86,12 +85,6 @@ 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`.
|
||||
|
||||
Discord-originated calls dial the requested extension directly by default. The
|
||||
bridge also attaches common auto-answer headers (`Call-Info:
|
||||
<uri>;answer-after=0` and `Alert-Info: <http://127.0.0.1>;info=Ring Answer`)
|
||||
to Discord-originated outbound calls, and appends `intercom=true` to the SIP
|
||||
URI, so auto-answer phones can behave more like FreePBX intercom targets.
|
||||
|
||||
Create a `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
|
|
@ -200,13 +193,14 @@ to the bridge host address, not the FreePBX address and not `0.0.0.0`.
|
|||
|
||||
### 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.
|
||||
If `DISCORD_OUTBOUND_SIP_HOST` is set, the bot registers `/call` and `/hangup`
|
||||
slash commands in each guild it is connected to.
|
||||
|
||||
Usage:
|
||||
|
||||
```text
|
||||
/call extension:1101
|
||||
/hangup
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
|
@ -215,17 +209,17 @@ Behavior:
|
|||
- The bridge dials the requested extension through the configured PBX target.
|
||||
- It dials the requested extension directly, for example
|
||||
`sip:1101@192.168.0.25:5060;transport=udp`.
|
||||
- Discord-originated outbound calls also include auto-answer headers and append
|
||||
`intercom=true` to the SIP URI so phones configured for that behavior can
|
||||
answer immediately.
|
||||
- When the SIP side answers, the phone call is connected to the Discord voice
|
||||
channel where the command was run.
|
||||
- `/hangup` ends active SIP calls connected to the 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.
|
||||
- `/hangup` ends calls already connected to the current Discord voice channel.
|
||||
- Rich status updates back into Discord after the initial slash command reply
|
||||
are not implemented yet.
|
||||
|
||||
### 4d. Build from source
|
||||
|
||||
|
|
@ -263,7 +257,6 @@ Dial `1000` (or whatever you put in `dialplan.toml`) and you should hear the bot
|
|||
| `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` |
|
||||
| `DISCORD_OUTBOUND_EXTENSION_PREFIX` | `""` | Optional prefix prepended before the requested extension for Discord-originated calls |
|
||||
| `CONFIG_PATH` | `./config.toml` | Path to config.toml |
|
||||
| `DIALPLAN_PATH` | `./dialplan.toml` | Path to dialplan.toml |
|
||||
| `SOUNDS_DIR` | `./wav` | Path to sound files directory |
|
||||
|
|
|
|||
|
|
@ -553,7 +553,6 @@ impl BridgeCoordinator {
|
|||
tracking_id: req.call_id.clone(),
|
||||
sip_uri,
|
||||
caller_display_name: Some(req.caller_username.clone()),
|
||||
auto_answer: true,
|
||||
fork_total,
|
||||
});
|
||||
outbound_backend.report_call_status(&req.call_id, "ringing");
|
||||
|
|
@ -586,7 +585,6 @@ impl BridgeCoordinator {
|
|||
tracking_id: req.call_id.clone(),
|
||||
sip_uri,
|
||||
caller_display_name: Some(req.caller_username.clone()),
|
||||
auto_answer: false,
|
||||
fork_total,
|
||||
});
|
||||
}
|
||||
|
|
@ -595,6 +593,50 @@ impl BridgeCoordinator {
|
|||
}
|
||||
});
|
||||
|
||||
// Handle hangup requests from the backend (Discord /hangup)
|
||||
let hangup_backend = self.backend.clone();
|
||||
let hangup_bridges = self.bridges.clone();
|
||||
let hangup_sip_cmd_tx = self.sip_cmd_tx.clone();
|
||||
|
||||
let hangup_handle = tokio::spawn(async move {
|
||||
while let Some(req) = hangup_backend.next_hangup_request().await {
|
||||
let channel_id = match req.channel_id.parse::<Snowflake>() {
|
||||
Ok(channel_id) => channel_id,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Invalid /hangup channel id {} for request {}: {}",
|
||||
req.channel_id, req.request_id, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let call_ids: Vec<CallId> = hangup_bridges
|
||||
.get(&channel_id)
|
||||
.map(|bridge| bridge.sip_calls.iter().copied().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
if call_ids.is_empty() {
|
||||
info!(
|
||||
"No active SIP calls to hang up for Discord channel {} (requested by {})",
|
||||
channel_id, req.requested_by
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Hanging up {} active SIP call(s) for Discord channel {} (requested by {})",
|
||||
call_ids.len(),
|
||||
channel_id,
|
||||
req.requested_by
|
||||
);
|
||||
|
||||
for call_id in call_ids {
|
||||
let _ = hangup_sip_cmd_tx.send(SipCommand::Hangup { call_id });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Discord events
|
||||
let discord_event_rx = self.discord_event_rx.clone();
|
||||
|
||||
|
|
@ -962,6 +1004,7 @@ impl BridgeCoordinator {
|
|||
_ = discord_handle => { info!("Discord event handler finished"); }
|
||||
_ = health_check_handle => { info!("Health check handler finished"); }
|
||||
_ = outbound_handle => { info!("Outbound call handler finished"); }
|
||||
_ = hangup_handle => { info!("Hangup request handler finished"); }
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -71,9 +71,6 @@ fn default_discord_outbound_sip_port() -> u16 {
|
|||
fn default_discord_outbound_sip_transport() -> String {
|
||||
"udp".to_string()
|
||||
}
|
||||
fn default_discord_outbound_extension_prefix() -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// All environment variables consumed by the bridge, deserialized once at startup.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
|
|
@ -120,8 +117,6 @@ pub struct EnvConfig {
|
|||
pub discord_outbound_sip_port: u16,
|
||||
#[serde(default = "default_discord_outbound_sip_transport")]
|
||||
pub discord_outbound_sip_transport: String,
|
||||
#[serde(default = "default_discord_outbound_extension_prefix")]
|
||||
pub discord_outbound_extension_prefix: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -147,28 +142,18 @@ pub struct DiscordOutboundSipConfig {
|
|||
pub host: String,
|
||||
pub port: u16,
|
||||
pub transport: OutboundSipTransport,
|
||||
pub extension_prefix: String,
|
||||
}
|
||||
|
||||
impl DiscordOutboundSipConfig {
|
||||
pub fn build_sip_uri(&self, extension: &str) -> String {
|
||||
let dialed_extension = format!("{}{}", self.extension_prefix, extension);
|
||||
match self.transport {
|
||||
OutboundSipTransport::Udp => {
|
||||
format!(
|
||||
"sip:{}@{}:{};transport=udp",
|
||||
dialed_extension, self.host, self.port
|
||||
)
|
||||
format!("sip:{}@{}:{};transport=udp", extension, self.host, self.port)
|
||||
}
|
||||
OutboundSipTransport::Tcp => {
|
||||
format!(
|
||||
"sip:{}@{}:{};transport=tcp",
|
||||
dialed_extension, self.host, self.port
|
||||
)
|
||||
}
|
||||
OutboundSipTransport::Tls => {
|
||||
format!("sips:{}@{}:{}", dialed_extension, self.host, self.port)
|
||||
format!("sip:{}@{}:{};transport=tcp", extension, self.host, self.port)
|
||||
}
|
||||
OutboundSipTransport::Tls => format!("sips:{}@{}:{}", extension, self.host, self.port),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -245,7 +230,6 @@ impl EnvConfig {
|
|||
host,
|
||||
port: self.discord_outbound_sip_port,
|
||||
transport,
|
||||
extension_prefix: self.discord_outbound_extension_prefix.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -546,7 +530,6 @@ mod tests {
|
|||
discord_outbound_sip_host: None,
|
||||
discord_outbound_sip_port: 5060,
|
||||
discord_outbound_sip_transport: "udp".to_string(),
|
||||
discord_outbound_extension_prefix: String::new(),
|
||||
};
|
||||
assert_eq!(env.resolved_data_dir(), ".");
|
||||
}
|
||||
|
|
@ -574,7 +557,6 @@ mod tests {
|
|||
discord_outbound_sip_host: None,
|
||||
discord_outbound_sip_port: 5060,
|
||||
discord_outbound_sip_transport: "udp".to_string(),
|
||||
discord_outbound_extension_prefix: String::new(),
|
||||
};
|
||||
assert_eq!(env.resolved_data_dir(), "/tmp");
|
||||
}
|
||||
|
|
@ -602,7 +584,6 @@ mod tests {
|
|||
discord_outbound_sip_host: None,
|
||||
discord_outbound_sip_port: 5060,
|
||||
discord_outbound_sip_transport: "udp".to_string(),
|
||||
discord_outbound_extension_prefix: String::new(),
|
||||
};
|
||||
let tls = env.to_tls_config();
|
||||
assert_eq!(tls.cert_dir, PathBuf::from("/data/certs"));
|
||||
|
|
@ -646,7 +627,6 @@ mod tests {
|
|||
discord_outbound_sip_host: Some("192.168.0.25".to_string()),
|
||||
discord_outbound_sip_port: 5060,
|
||||
discord_outbound_sip_transport: "udp".to_string(),
|
||||
discord_outbound_extension_prefix: String::new(),
|
||||
};
|
||||
|
||||
let outbound = env.discord_outbound_sip_config().unwrap();
|
||||
|
|
|
|||
|
|
@ -59,10 +59,12 @@ async fn run_static_router() -> Result<(), BridgeError> {
|
|||
// Load dialplan
|
||||
let dialplan_path = PathBuf::from(&EnvConfig::global().dialplan_path);
|
||||
let (outbound_request_tx, outbound_request_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (hangup_request_tx, hangup_request_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend = Arc::new(StaticBackend::load(
|
||||
&dialplan_path,
|
||||
bot_token.clone(),
|
||||
outbound_request_rx,
|
||||
hangup_request_rx,
|
||||
)?);
|
||||
|
||||
// Create SIP transport (no TLS for static router)
|
||||
|
|
@ -87,6 +89,7 @@ async fn run_static_router() -> Result<(), BridgeError> {
|
|||
.map(|sip| DiscordOutboundCallConfig {
|
||||
sip,
|
||||
request_tx: outbound_request_tx,
|
||||
hangup_tx: hangup_request_tx,
|
||||
bot_token: bot_token.clone(),
|
||||
});
|
||||
let shared_discord = SharedDiscordClient::new(&bot_token, outbound_call_config).await?;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ pub struct OutboundCallRequest {
|
|||
pub created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
/// Hangup request from the backend (e.g., Discord /hangup command)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HangupCallRequest {
|
||||
pub request_id: String,
|
||||
pub guild_id: String,
|
||||
pub channel_id: String,
|
||||
pub requested_by: String,
|
||||
pub created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
/// Result of routing an incoming SIP call
|
||||
pub enum RouteDecision {
|
||||
/// Connect to this Discord voice channel
|
||||
|
|
@ -104,4 +114,7 @@ pub trait Backend: Send + Sync {
|
|||
|
||||
/// Get the next outbound call request (None if backend doesn't support outbound)
|
||||
async fn next_outbound_request(&self) -> Option<OutboundCallRequest>;
|
||||
|
||||
/// Get the next hangup request (None if backend doesn't support hangup control)
|
||||
async fn next_hangup_request(&self) -> Option<HangupCallRequest>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ use tokio::sync::Mutex;
|
|||
use tracing::info;
|
||||
|
||||
use crate::config::ConfigError;
|
||||
use crate::routing::{Backend, CallError, CallStartedInfo, OutboundCallRequest, RouteDecision};
|
||||
use crate::routing::{
|
||||
Backend, CallError, CallStartedInfo, HangupCallRequest, OutboundCallRequest, RouteDecision,
|
||||
};
|
||||
use crate::services::snowflake::Snowflake;
|
||||
use crate::transport::sip::DigestAuthParams;
|
||||
|
||||
|
|
@ -46,6 +48,7 @@ pub struct StaticBackend {
|
|||
bot_token: String,
|
||||
extensions: HashMap<String, ExtensionTarget>,
|
||||
outbound_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>>>,
|
||||
hangup_rx: Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<HangupCallRequest>>>,
|
||||
}
|
||||
|
||||
impl StaticBackend {
|
||||
|
|
@ -54,6 +57,7 @@ impl StaticBackend {
|
|||
path: &Path,
|
||||
bot_token: String,
|
||||
outbound_rx: tokio::sync::mpsc::UnboundedReceiver<OutboundCallRequest>,
|
||||
hangup_rx: tokio::sync::mpsc::UnboundedReceiver<HangupCallRequest>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let content = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
|
||||
path: path.to_path_buf(),
|
||||
|
|
@ -80,6 +84,7 @@ impl StaticBackend {
|
|||
bot_token,
|
||||
extensions: dialplan.extensions,
|
||||
outbound_rx: Arc::new(Mutex::new(outbound_rx)),
|
||||
hangup_rx: Arc::new(Mutex::new(hangup_rx)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -125,6 +130,10 @@ impl Backend for StaticBackend {
|
|||
async fn next_outbound_request(&self) -> Option<OutboundCallRequest> {
|
||||
self.outbound_rx.lock().await.recv().await
|
||||
}
|
||||
|
||||
async fn next_hangup_request(&self) -> Option<HangupCallRequest> {
|
||||
self.hangup_rx.lock().await.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -144,7 +153,9 @@ mod tests {
|
|||
std::fs::write(&path, toml_content).unwrap();
|
||||
|
||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend = StaticBackend::load(&path, "test_token".to_string(), rx).unwrap();
|
||||
let (_hangup_tx, hangup_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend =
|
||||
StaticBackend::load(&path, "test_token".to_string(), rx, hangup_rx).unwrap();
|
||||
assert_eq!(backend.extensions.len(), 2);
|
||||
assert!(backend.extensions.contains_key("1000"));
|
||||
assert!(backend.extensions.contains_key("2000"));
|
||||
|
|
@ -162,7 +173,8 @@ mod tests {
|
|||
std::fs::write(&path, toml_content).unwrap();
|
||||
|
||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend = StaticBackend::load(&path, "tok".to_string(), rx).unwrap();
|
||||
let (_hangup_tx, hangup_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend = StaticBackend::load(&path, "tok".to_string(), rx, hangup_rx).unwrap();
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
|
|
@ -192,7 +204,8 @@ mod tests {
|
|||
std::fs::write(&path, toml_content).unwrap();
|
||||
|
||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend = StaticBackend::load(&path, "tok".to_string(), rx).unwrap();
|
||||
let (_hangup_tx, hangup_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend = StaticBackend::load(&path, "tok".to_string(), rx, hangup_rx).unwrap();
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
|
|
@ -218,7 +231,8 @@ mod tests {
|
|||
std::fs::write(&path, "this is not valid toml [[[").unwrap();
|
||||
|
||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let result = StaticBackend::load(&path, "tok".to_string(), rx);
|
||||
let (_hangup_tx, hangup_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let result = StaticBackend::load(&path, "tok".to_string(), rx, hangup_rx);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ mod voice;
|
|||
|
||||
use crate::audio::simd;
|
||||
use crate::config::DiscordOutboundSipConfig;
|
||||
use crate::routing::OutboundCallRequest;
|
||||
use crate::routing::{HangupCallRequest, OutboundCallRequest};
|
||||
use crate::services::snowflake::Snowflake;
|
||||
use audioadapter::Adapter;
|
||||
use audioadapter_buffers::direct::SequentialSliceOfVecs;
|
||||
|
|
@ -506,6 +506,7 @@ pub struct SharedDiscordClient {
|
|||
pub struct DiscordOutboundCallConfig {
|
||||
pub sip: DiscordOutboundSipConfig,
|
||||
pub request_tx: tokio::sync::mpsc::UnboundedSender<OutboundCallRequest>,
|
||||
pub hangup_tx: tokio::sync::mpsc::UnboundedSender<HangupCallRequest>,
|
||||
pub bot_token: String,
|
||||
}
|
||||
|
||||
|
|
@ -605,9 +606,9 @@ impl EventHandler for SharedClientEventHandler {
|
|||
|
||||
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 {
|
||||
if let Err(e) = register_call_commands(ctx, guild_status.id).await {
|
||||
error!(
|
||||
"Failed to register /call command for guild {}: {}",
|
||||
"Failed to register call commands for guild {}: {}",
|
||||
guild_status.id, e
|
||||
);
|
||||
}
|
||||
|
|
@ -617,9 +618,12 @@ impl EventHandler for SharedClientEventHandler {
|
|||
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;
|
||||
match command.data.name.as_str() {
|
||||
"call" => handle_call_command(ctx, command, cfg).await,
|
||||
"hangup" => handle_hangup_command(ctx, command, cfg).await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -627,8 +631,8 @@ impl EventHandler for SharedClientEventHandler {
|
|||
}
|
||||
}
|
||||
|
||||
async fn register_call_command(ctx: &Context, guild_id: GuildId) -> Result<(), serenity::Error> {
|
||||
let command = CreateCommand::new("call")
|
||||
async fn register_call_commands(ctx: &Context, guild_id: GuildId) -> Result<(), serenity::Error> {
|
||||
let call_command = CreateCommand::new("call")
|
||||
.description("Call a SIP/PBX extension from your current voice channel")
|
||||
.add_option(
|
||||
CreateCommandOption::new(
|
||||
|
|
@ -639,8 +643,12 @@ async fn register_call_command(ctx: &Context, guild_id: GuildId) -> Result<(), s
|
|||
.required(true),
|
||||
);
|
||||
|
||||
guild_id.create_command(&ctx.http, command).await?;
|
||||
info!("Registered /call command for guild {}", guild_id);
|
||||
let hangup_command = CreateCommand::new("hangup")
|
||||
.description("Hang up active SIP calls in your current voice channel");
|
||||
|
||||
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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -653,7 +661,10 @@ async fn handle_call_command(
|
|||
Ok(req) => {
|
||||
let extension = req.discord_username.clone();
|
||||
match cfg.request_tx.send(req) {
|
||||
Ok(()) => format!("Dialing extension `{}` from your current voice channel.", extension),
|
||||
Ok(()) => format!(
|
||||
"Dialing extension `{}` from your current voice channel.",
|
||||
extension
|
||||
),
|
||||
Err(_) => "Outbound call queue is unavailable right now.".to_string(),
|
||||
}
|
||||
}
|
||||
|
|
@ -675,6 +686,34 @@ async fn handle_call_command(
|
|||
}
|
||||
}
|
||||
|
||||
async fn handle_hangup_command(
|
||||
ctx: &Context,
|
||||
command: &CommandInteraction,
|
||||
cfg: &DiscordOutboundCallConfig,
|
||||
) {
|
||||
let response = match build_hangup_request(ctx, command) {
|
||||
Ok(req) => match cfg.hangup_tx.send(req) {
|
||||
Ok(()) => "Hanging up active calls in your current voice channel.".to_string(),
|
||||
Err(_) => "Hangup queue is unavailable right now.".to_string(),
|
||||
},
|
||||
Err(msg) => msg,
|
||||
};
|
||||
|
||||
if let Err(e) = command
|
||||
.create_response(
|
||||
&ctx.http,
|
||||
CreateInteractionResponse::Message(
|
||||
CreateInteractionResponseMessage::new()
|
||||
.content(response)
|
||||
.ephemeral(true),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to respond to /hangup interaction: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_outbound_request(
|
||||
ctx: &Context,
|
||||
command: &CommandInteraction,
|
||||
|
|
@ -697,18 +736,7 @@ fn build_outbound_request(
|
|||
);
|
||||
}
|
||||
|
||||
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 (guild_id, voice_channel_id) = current_voice_channel(ctx, command, "/call")?;
|
||||
|
||||
let caller_username = command
|
||||
.member
|
||||
|
|
@ -729,6 +757,49 @@ fn build_outbound_request(
|
|||
})
|
||||
}
|
||||
|
||||
fn build_hangup_request(
|
||||
ctx: &Context,
|
||||
command: &CommandInteraction,
|
||||
) -> Result<HangupCallRequest, String> {
|
||||
let (guild_id, voice_channel_id) = current_voice_channel(ctx, command, "/hangup")?;
|
||||
let requested_by = command
|
||||
.member
|
||||
.as_ref()
|
||||
.and_then(|member| member.nick.clone())
|
||||
.or_else(|| command.user.global_name.clone())
|
||||
.unwrap_or_else(|| command.user.name.clone())
|
||||
.to_string();
|
||||
|
||||
Ok(HangupCallRequest {
|
||||
request_id: format!("hangup-{}", command.id),
|
||||
guild_id: guild_id.get().to_string(),
|
||||
channel_id: voice_channel_id.get().to_string(),
|
||||
requested_by,
|
||||
created_at: std::time::Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
fn current_voice_channel(
|
||||
ctx: &Context,
|
||||
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())?;
|
||||
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(|| format!("Join a voice channel first, then run `{command_name}` there."))?;
|
||||
|
||||
Ok((guild_id, voice_channel_id))
|
||||
}
|
||||
|
||||
fn is_safe_extension(extension: &str) -> bool {
|
||||
!extension.is_empty()
|
||||
&& extension.len() <= 64
|
||||
|
|
|
|||
|
|
@ -117,7 +117,6 @@ pub enum SipCommand {
|
|||
tracking_id: String,
|
||||
sip_uri: String,
|
||||
caller_display_name: Option<String>,
|
||||
auto_answer: bool,
|
||||
/// Total number of fork legs for this tracking_id (for multi-contact forking)
|
||||
fork_total: usize,
|
||||
},
|
||||
|
|
@ -408,14 +407,13 @@ fn process_sip_command(cmd: SipCommand, calls: &Arc<DashMap<CallId, CallState>>)
|
|||
tracking_id,
|
||||
sip_uri,
|
||||
caller_display_name,
|
||||
auto_answer,
|
||||
fork_total,
|
||||
} => {
|
||||
info!(
|
||||
"Making outbound call: tracking_id={}, uri={}, caller={:?}, auto_answer={}, fork={}/{}",
|
||||
tracking_id, sip_uri, caller_display_name, auto_answer, fork_total, fork_total
|
||||
"Making outbound call: tracking_id={}, uri={}, caller={:?}, fork={}/{}",
|
||||
tracking_id, sip_uri, caller_display_name, fork_total, fork_total
|
||||
);
|
||||
match make_outbound_call(&sip_uri, caller_display_name.as_deref(), auto_answer) {
|
||||
match make_outbound_call(&sip_uri, caller_display_name.as_deref()) {
|
||||
Ok(call_id) => {
|
||||
// Store tracking_id -> call_id mapping
|
||||
let outbound_calls = OUTBOUND_CALL_TRACKING.get_or_init(DashMap::new);
|
||||
|
|
@ -526,24 +524,10 @@ pub fn remove_outbound_tracking(call_id: CallId) -> Option<String> {
|
|||
fn make_outbound_call(
|
||||
sip_uri: &str,
|
||||
caller_display_name: Option<&str>,
|
||||
auto_answer: bool,
|
||||
) -> Result<CallId, SipCallError> {
|
||||
unsafe {
|
||||
use self::ffi::pj_str::make_string_hdr;
|
||||
let outbound_uri = if auto_answer {
|
||||
if sip_uri.contains("intercom=true") {
|
||||
sip_uri.to_string()
|
||||
} else if let Some((base, params)) = sip_uri.split_once(';') {
|
||||
format!("{base};intercom=true;{params}")
|
||||
} else {
|
||||
format!("{sip_uri};intercom=true")
|
||||
}
|
||||
} else {
|
||||
sip_uri.to_string()
|
||||
};
|
||||
|
||||
let uri =
|
||||
std::ffi::CString::new(outbound_uri).map_err(|source| SipCallError::InvalidString {
|
||||
std::ffi::CString::new(sip_uri).map_err(|source| SipCallError::InvalidString {
|
||||
field: "sip_uri",
|
||||
source,
|
||||
})?;
|
||||
|
|
@ -564,28 +548,6 @@ fn make_outbound_call(
|
|||
::pjsua::pjsua_msg_data_init(msg_data.as_mut_ptr());
|
||||
let msg_data_ptr = msg_data.assume_init_mut();
|
||||
|
||||
if auto_answer {
|
||||
let pool = ::pjsua::pjsua_pool_create(c"outbound_call".as_ptr(), 512, 512);
|
||||
if pool.is_null() {
|
||||
return Err(SipCallError::MakeCall(-1));
|
||||
}
|
||||
|
||||
let call_info = make_string_hdr(pool, c"Call-Info", "<uri>;answer-after=0")
|
||||
.map_err(|_| SipCallError::MakeCall(-1))?;
|
||||
::pjsua::pj_list_insert_before(
|
||||
&mut msg_data_ptr.hdr_list as *mut _ as *mut ::pjsua::pj_list_type,
|
||||
call_info as *mut ::pjsua::pj_list_type,
|
||||
);
|
||||
|
||||
let alert_info =
|
||||
make_string_hdr(pool, c"Alert-Info", "<http://127.0.0.1>;info=Ring Answer")
|
||||
.map_err(|_| SipCallError::MakeCall(-1))?;
|
||||
::pjsua::pj_list_insert_before(
|
||||
&mut msg_data_ptr.hdr_list as *mut _ as *mut ::pjsua::pj_list_type,
|
||||
alert_info as *mut ::pjsua::pj_list_type,
|
||||
);
|
||||
}
|
||||
|
||||
// Build the From URI with display name: "name" <sip:sipcord@host>
|
||||
// The local_uri field overrides the From header in the outgoing INVITE
|
||||
let from_uri_cstring;
|
||||
|
|
|
|||
Loading…
Reference in a new issue