mirror of
https://github.com/coral/sipcord-bridge.git
synced 2026-06-29 17:32:52 -06:00
slopfeatures
This commit is contained in:
parent
025d5ffd88
commit
4f77e77b1e
13
Dockerfile
13
Dockerfile
|
|
@ -115,10 +115,21 @@ RUN apt-get update && apt-get install -y \
|
||||||
libopus0 \
|
libopus0 \
|
||||||
libtiff6 \
|
libtiff6 \
|
||||||
libjpeg62-turbo \
|
libjpeg62-turbo \
|
||||||
espeak-ng \
|
curl \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN mkdir -p /opt/piper /opt/piper-voices && \
|
||||||
|
curl -fL https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz \
|
||||||
|
| tar -xzf - -C /opt && \
|
||||||
|
printf '#!/bin/sh\nLD_LIBRARY_PATH=/opt/piper exec /opt/piper/piper "$@"\n' \
|
||||||
|
> /usr/local/bin/piper && \
|
||||||
|
chmod +x /usr/local/bin/piper && \
|
||||||
|
curl -fL -o /opt/piper-voices/en_US-amy-medium.onnx \
|
||||||
|
https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx && \
|
||||||
|
curl -fL -o /opt/piper-voices/en_US-amy-medium.onnx.json \
|
||||||
|
https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx.json
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /build/target/release/sipcord-bridge /app/sipcord-bridge
|
COPY --from=builder /build/target/release/sipcord-bridge /app/sipcord-bridge
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -78,10 +78,12 @@ timeout_seconds = 10
|
||||||
max_attempts = 3
|
max_attempts = 3
|
||||||
```
|
```
|
||||||
|
|
||||||
The menu uses `espeak-ng` for local text-to-speech with a female English voice.
|
The menu uses Piper for local text-to-speech with a bundled English female
|
||||||
Emoji and common Discord channel separators are skipped in spoken names. Press
|
voice. Emoji and common Discord channel separators are skipped in spoken names.
|
||||||
`#` to repeat the current menu page, `9` for the next page when available, and
|
Voice channels with people in them are read first and include the number of
|
||||||
`*` for the previous page when available.
|
people present, excluding the bot itself. Empty voice channels are read by name.
|
||||||
|
Press `#` to repeat the current menu page, `9` for the next page when
|
||||||
|
available, and `*` for the previous page when available.
|
||||||
|
|
||||||
You can also add a phone directory for Discord-originated calls. These entries
|
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
|
show up in `/directory` as buttons. Clicking one dials that extension from your
|
||||||
|
|
@ -278,7 +280,7 @@ Current scope:
|
||||||
|
|
||||||
### 4d. Build from source
|
### 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.
|
Requires Rust nightly (for `portable_simd`) and system dependencies for pjproject (OpenSSL, Opus, libtiff, etc). Dynamic menu TTS also requires the `piper` binary and a Piper voice model at `/opt/piper-voices/en_US-amy-medium.onnx`. See the `Dockerfile` for the full list.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --release -p sipcord-bridge
|
cargo run --release -p sipcord-bridge
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ use crate::services::snowflake::Snowflake;
|
||||||
use crate::services::sound::{SoundManager, create_sound_manager};
|
use crate::services::sound::{SoundManager, create_sound_manager};
|
||||||
use crate::transport::discord::{
|
use crate::transport::discord::{
|
||||||
DiscordEvent, DiscordVoiceConnection, SharedDiscordClient, register_discord_to_sip_producer,
|
DiscordEvent, DiscordVoiceConnection, SharedDiscordClient, register_discord_to_sip_producer,
|
||||||
unregister_discord_to_sip_producer,
|
set_bot_nickname, unregister_discord_to_sip_producer,
|
||||||
};
|
};
|
||||||
use crate::transport::sip::{
|
use crate::transport::sip::{
|
||||||
CONF_SAMPLE_RATE, CallId, SipCommand, SipEvent, cleanup_channel_port,
|
CONF_SAMPLE_RATE, CallId, SipCommand, SipEvent, cleanup_channel_port,
|
||||||
|
|
@ -36,8 +36,10 @@ use crossbeam_channel::{Receiver, Sender, bounded};
|
||||||
use dashmap::{DashMap, DashSet};
|
use dashmap::{DashMap, DashSet};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
|
|
@ -222,6 +224,7 @@ impl BridgeCoordinator {
|
||||||
SipEvent::IncomingCall {
|
SipEvent::IncomingCall {
|
||||||
call_id,
|
call_id,
|
||||||
digest_auth,
|
digest_auth,
|
||||||
|
caller_id,
|
||||||
extension,
|
extension,
|
||||||
source_ip,
|
source_ip,
|
||||||
} => {
|
} => {
|
||||||
|
|
@ -270,8 +273,15 @@ impl BridgeCoordinator {
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
handle_incoming_call(ctx, call_id, *digest_auth, extension, source_ip)
|
handle_incoming_call(
|
||||||
.await;
|
ctx,
|
||||||
|
call_id,
|
||||||
|
*digest_auth,
|
||||||
|
caller_id,
|
||||||
|
extension,
|
||||||
|
source_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1036,6 +1046,7 @@ async fn handle_incoming_call(
|
||||||
ctx: BridgeContext,
|
ctx: BridgeContext,
|
||||||
call_id: CallId,
|
call_id: CallId,
|
||||||
digest_auth: crate::transport::sip::DigestAuthParams,
|
digest_auth: crate::transport::sip::DigestAuthParams,
|
||||||
|
caller_id: String,
|
||||||
extension: String,
|
extension: String,
|
||||||
source_ip: Option<std::net::IpAddr>,
|
source_ip: Option<std::net::IpAddr>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -1193,6 +1204,7 @@ async fn handle_incoming_call(
|
||||||
health_check_notify,
|
health_check_notify,
|
||||||
},
|
},
|
||||||
call_id,
|
call_id,
|
||||||
|
caller_id,
|
||||||
extension,
|
extension,
|
||||||
menu,
|
menu,
|
||||||
)
|
)
|
||||||
|
|
@ -1342,6 +1354,8 @@ async fn handle_incoming_call(
|
||||||
backend.on_call_started(&info).await;
|
backend.on_call_started(&info).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
set_bot_nickname(&bot_token, guild_id, &caller_id).await;
|
||||||
|
|
||||||
// Answer call first, then play join sound
|
// Answer call first, then play join sound
|
||||||
let _ = sip_cmd_tx.send(SipCommand::Answer { call_id });
|
let _ = sip_cmd_tx.send(SipCommand::Answer { call_id });
|
||||||
play_discord_join(call_id, &sound_manager, &sip_cmd_tx).await;
|
play_discord_join(call_id, &sound_manager, &sip_cmd_tx).await;
|
||||||
|
|
@ -1424,6 +1438,8 @@ async fn handle_incoming_call(
|
||||||
backend.on_call_started(&info).await;
|
backend.on_call_started(&info).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
set_bot_nickname(&bot_token, guild_id, &caller_id).await;
|
||||||
|
|
||||||
// Answer call first, then play join sound
|
// Answer call first, then play join sound
|
||||||
let _ = sip_cmd_tx.send(SipCommand::Answer { call_id });
|
let _ = sip_cmd_tx.send(SipCommand::Answer { call_id });
|
||||||
play_discord_join(call_id, &sound_manager, &sip_cmd_tx).await;
|
play_discord_join(call_id, &sound_manager, &sip_cmd_tx).await;
|
||||||
|
|
@ -1486,11 +1502,13 @@ struct DynamicGuildOption {
|
||||||
struct DynamicChannelOption {
|
struct DynamicChannelOption {
|
||||||
channel_id: Snowflake,
|
channel_id: Snowflake,
|
||||||
name: String,
|
name: String,
|
||||||
|
user_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_menu_call(
|
async fn handle_menu_call(
|
||||||
ctx: MenuCallContext,
|
ctx: MenuCallContext,
|
||||||
call_id: CallId,
|
call_id: CallId,
|
||||||
|
caller_id: String,
|
||||||
extension: String,
|
extension: String,
|
||||||
menu: MenuRoute,
|
menu: MenuRoute,
|
||||||
) {
|
) {
|
||||||
|
|
@ -1536,7 +1554,12 @@ async fn handle_menu_call(
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let channels = match fetch_discord_voice_channels(ctx.backend.bot_token(), guild.guild_id).await
|
let channels = match fetch_discord_voice_channels(
|
||||||
|
ctx.backend.bot_token(),
|
||||||
|
guild.guild_id,
|
||||||
|
&ctx.shared_discord,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok(channels) if !channels.is_empty() => channels,
|
Ok(channels) if !channels.is_empty() => channels,
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
|
@ -1576,7 +1599,7 @@ async fn handle_menu_call(
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.dtmf_waiters.remove(&call_id);
|
ctx.dtmf_waiters.remove(&call_id);
|
||||||
connect_menu_selection(ctx, call_id, extension, guild, selected).await;
|
connect_menu_selection(ctx, call_id, caller_id, extension, guild, selected).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn select_guild_from_menu(
|
async fn select_guild_from_menu(
|
||||||
|
|
@ -1649,7 +1672,7 @@ async fn select_channel_from_menu(
|
||||||
let prompt = build_option_prompt(
|
let prompt = build_option_prompt(
|
||||||
&intro,
|
&intro,
|
||||||
page_items,
|
page_items,
|
||||||
|channel| clean_tts_label(&channel.name),
|
channel_tts_label,
|
||||||
page,
|
page,
|
||||||
channels.len(),
|
channels.len(),
|
||||||
);
|
);
|
||||||
|
|
@ -1848,6 +1871,7 @@ async fn fetch_discord_guilds(
|
||||||
async fn fetch_discord_voice_channels(
|
async fn fetch_discord_voice_channels(
|
||||||
bot_token: &str,
|
bot_token: &str,
|
||||||
guild_id: Snowflake,
|
guild_id: Snowflake,
|
||||||
|
shared_discord: &SharedDiscordClient,
|
||||||
) -> Result<Vec<DynamicChannelOption>, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<Vec<DynamicChannelOption>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let url = format!("https://discord.com/api/v10/guilds/{}/channels", guild_id);
|
let url = format!("https://discord.com/api/v10/guilds/{}/channels", guild_id);
|
||||||
|
|
@ -1865,16 +1889,31 @@ async fn fetch_discord_voice_channels(
|
||||||
.filter(|channel| channel.kind == 2)
|
.filter(|channel| channel.kind == 2)
|
||||||
.filter_map(|channel| {
|
.filter_map(|channel| {
|
||||||
let channel_id = channel.id.parse::<Snowflake>().ok()?;
|
let channel_id = channel.id.parse::<Snowflake>().ok()?;
|
||||||
|
let user_count = shared_discord.voice_channel_user_count(guild_id, channel_id);
|
||||||
Some(DynamicChannelOption {
|
Some(DynamicChannelOption {
|
||||||
channel_id,
|
channel_id,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
|
user_count,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
channels.sort_by(|a, b| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()));
|
channels.sort_by(|a, b| {
|
||||||
|
b.user_count
|
||||||
|
.cmp(&a.user_count)
|
||||||
|
.then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
|
||||||
|
});
|
||||||
Ok(channels)
|
Ok(channels)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn channel_tts_label(channel: &DynamicChannelOption) -> String {
|
||||||
|
let name = clean_tts_label(&channel.name);
|
||||||
|
match channel.user_count {
|
||||||
|
0 => name,
|
||||||
|
1 => format!("{name}, with 1 person"),
|
||||||
|
count => format!("{name}, with {count} people"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn play_tts_prompt(
|
async fn play_tts_prompt(
|
||||||
call_id: CallId,
|
call_id: CallId,
|
||||||
text: &str,
|
text: &str,
|
||||||
|
|
@ -1897,17 +1936,22 @@ async fn synthesize_tts_samples(
|
||||||
let raw_path = std::env::temp_dir().join(format!("sipcord-tts-{}-{}-raw.wav", call_id, stamp));
|
let raw_path = std::env::temp_dir().join(format!("sipcord-tts-{}-{}-raw.wav", call_id, stamp));
|
||||||
let out_path = std::env::temp_dir().join(format!("sipcord-tts-{}-{}.wav", call_id, stamp));
|
let out_path = std::env::temp_dir().join(format!("sipcord-tts-{}-{}.wav", call_id, stamp));
|
||||||
|
|
||||||
let espeak_status = Command::new("espeak-ng")
|
let mut piper = Command::new("piper")
|
||||||
.arg("-v")
|
.arg("--model")
|
||||||
.arg("en+f3")
|
.arg("/opt/piper-voices/en_US-amy-medium.onnx")
|
||||||
.arg("-w")
|
.arg("--output_file")
|
||||||
.arg(&raw_path)
|
.arg(&raw_path)
|
||||||
.arg(text)
|
.stdin(Stdio::piped())
|
||||||
.status()
|
.spawn()?;
|
||||||
.await?;
|
|
||||||
if !espeak_status.success() {
|
if let Some(mut stdin) = piper.stdin.take() {
|
||||||
|
stdin.write_all(text.as_bytes()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let piper_status = piper.wait().await?;
|
||||||
|
if !piper_status.success() {
|
||||||
let _ = tokio::fs::remove_file(&raw_path).await;
|
let _ = tokio::fs::remove_file(&raw_path).await;
|
||||||
return Err(format!("espeak-ng exited with status {}", espeak_status).into());
|
return Err(format!("piper exited with status {}", piper_status).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let ffmpeg_status = Command::new("ffmpeg")
|
let ffmpeg_status = Command::new("ffmpeg")
|
||||||
|
|
@ -1953,6 +1997,7 @@ async fn synthesize_tts_samples(
|
||||||
async fn connect_menu_selection(
|
async fn connect_menu_selection(
|
||||||
ctx: MenuCallContext,
|
ctx: MenuCallContext,
|
||||||
call_id: CallId,
|
call_id: CallId,
|
||||||
|
caller_id: String,
|
||||||
extension: String,
|
extension: String,
|
||||||
guild: DynamicGuildOption,
|
guild: DynamicGuildOption,
|
||||||
selected: DynamicChannelOption,
|
selected: DynamicChannelOption,
|
||||||
|
|
@ -2067,6 +2112,7 @@ async fn connect_menu_selection(
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
backend.on_call_started(&info).await;
|
backend.on_call_started(&info).await;
|
||||||
});
|
});
|
||||||
|
set_bot_nickname(ctx.backend.bot_token(), guild_id, &caller_id).await;
|
||||||
play_discord_join(call_id, &ctx.sound_manager, &ctx.sip_cmd_tx).await;
|
play_discord_join(call_id, &ctx.sound_manager, &ctx.sip_cmd_tx).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2134,6 +2180,7 @@ async fn connect_menu_selection(
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
backend.on_call_started(&info).await;
|
backend.on_call_started(&info).await;
|
||||||
});
|
});
|
||||||
|
set_bot_nickname(ctx.backend.bot_token(), guild_id, &caller_id).await;
|
||||||
play_discord_join(call_id, &ctx.sound_manager, &ctx.sip_cmd_tx).await;
|
play_discord_join(call_id, &ctx.sound_manager, &ctx.sip_cmd_tx).await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ use songbird::tracks::PlayMode;
|
||||||
use songbird::{
|
use songbird::{
|
||||||
Config, CoreEvent, Event, EventContext, EventHandler as VoiceEventHandler, Songbird, TrackEvent,
|
Config, CoreEvent, Event, EventContext, EventHandler as VoiceEventHandler, Songbird, TrackEvent,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||||
|
|
@ -501,9 +501,63 @@ pub enum DiscordEvent {
|
||||||
pub struct SharedDiscordClient {
|
pub struct SharedDiscordClient {
|
||||||
songbird: Arc<Songbird>,
|
songbird: Arc<Songbird>,
|
||||||
bot_user_id: AtomicU64,
|
bot_user_id: AtomicU64,
|
||||||
|
voice_state_tracker: Arc<VoiceStateTracker>,
|
||||||
_client_handle: tokio::task::JoinHandle<()>,
|
_client_handle: tokio::task::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct VoiceStateTracker {
|
||||||
|
users: Mutex<HashMap<Snowflake, (Snowflake, Snowflake)>>,
|
||||||
|
channels: Mutex<HashMap<(Snowflake, Snowflake), HashSet<Snowflake>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceStateTracker {
|
||||||
|
fn update(
|
||||||
|
&self,
|
||||||
|
user_id: Snowflake,
|
||||||
|
guild_id: Option<Snowflake>,
|
||||||
|
channel_id: Option<Snowflake>,
|
||||||
|
) {
|
||||||
|
let mut users = self.users.lock();
|
||||||
|
let mut channels = self.channels.lock();
|
||||||
|
|
||||||
|
if let Some((old_guild_id, old_channel_id)) = users.remove(&user_id)
|
||||||
|
&& let Some(users_in_channel) = channels.get_mut(&(old_guild_id, old_channel_id))
|
||||||
|
{
|
||||||
|
users_in_channel.remove(&user_id);
|
||||||
|
if users_in_channel.is_empty() {
|
||||||
|
channels.remove(&(old_guild_id, old_channel_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(guild_id), Some(channel_id)) = (guild_id, channel_id) {
|
||||||
|
users.insert(user_id, (guild_id, channel_id));
|
||||||
|
channels
|
||||||
|
.entry((guild_id, channel_id))
|
||||||
|
.or_default()
|
||||||
|
.insert(user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_excluding(
|
||||||
|
&self,
|
||||||
|
guild_id: Snowflake,
|
||||||
|
channel_id: Snowflake,
|
||||||
|
excluded_user_id: Snowflake,
|
||||||
|
) -> usize {
|
||||||
|
self.channels
|
||||||
|
.lock()
|
||||||
|
.get(&(guild_id, channel_id))
|
||||||
|
.map(|users| {
|
||||||
|
users
|
||||||
|
.iter()
|
||||||
|
.filter(|user_id| **user_id != excluded_user_id)
|
||||||
|
.count()
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DiscordOutboundCallConfig {
|
pub struct DiscordOutboundCallConfig {
|
||||||
pub sip: DiscordOutboundSipConfig,
|
pub sip: DiscordOutboundSipConfig,
|
||||||
|
|
@ -529,6 +583,7 @@ impl SharedDiscordClient {
|
||||||
|
|
||||||
let songbird_config = Config::default().decode_mode(DecodeMode::Decode(Default::default()));
|
let songbird_config = Config::default().decode_mode(DecodeMode::Decode(Default::default()));
|
||||||
let songbird = Songbird::serenity_from_config(songbird_config);
|
let songbird = Songbird::serenity_from_config(songbird_config);
|
||||||
|
let voice_state_tracker = Arc::new(VoiceStateTracker::default());
|
||||||
|
|
||||||
let (ready_tx, ready_rx) = oneshot::channel::<u64>();
|
let (ready_tx, ready_rx) = oneshot::channel::<u64>();
|
||||||
let ready_tx = Arc::new(tokio::sync::Mutex::new(Some(ready_tx)));
|
let ready_tx = Arc::new(tokio::sync::Mutex::new(Some(ready_tx)));
|
||||||
|
|
@ -541,6 +596,7 @@ impl SharedDiscordClient {
|
||||||
.event_handler(Arc::new(SharedClientEventHandler {
|
.event_handler(Arc::new(SharedClientEventHandler {
|
||||||
ready_tx,
|
ready_tx,
|
||||||
outbound_call_config,
|
outbound_call_config,
|
||||||
|
voice_state_tracker: voice_state_tracker.clone(),
|
||||||
}))
|
}))
|
||||||
.voice_manager(songbird.clone())
|
.voice_manager(songbird.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -573,6 +629,7 @@ impl SharedDiscordClient {
|
||||||
Ok(Arc::new(Self {
|
Ok(Arc::new(Self {
|
||||||
songbird,
|
songbird,
|
||||||
bot_user_id: AtomicU64::new(bot_user_id),
|
bot_user_id: AtomicU64::new(bot_user_id),
|
||||||
|
voice_state_tracker,
|
||||||
_client_handle: client_handle,
|
_client_handle: client_handle,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -586,12 +643,19 @@ impl SharedDiscordClient {
|
||||||
pub fn bot_user_id(&self) -> Snowflake {
|
pub fn bot_user_id(&self) -> Snowflake {
|
||||||
Snowflake::new(self.bot_user_id.load(Ordering::Relaxed))
|
Snowflake::new(self.bot_user_id.load(Ordering::Relaxed))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Count users in a voice channel, excluding this bot if it is present.
|
||||||
|
pub fn voice_channel_user_count(&self, guild_id: Snowflake, channel_id: Snowflake) -> usize {
|
||||||
|
self.voice_state_tracker
|
||||||
|
.count_excluding(guild_id, channel_id, self.bot_user_id())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serenity event handler for the shared client
|
/// Serenity event handler for the shared client
|
||||||
struct SharedClientEventHandler {
|
struct SharedClientEventHandler {
|
||||||
ready_tx: Arc<tokio::sync::Mutex<Option<oneshot::Sender<u64>>>>,
|
ready_tx: Arc<tokio::sync::Mutex<Option<oneshot::Sender<u64>>>>,
|
||||||
outbound_call_config: Option<DiscordOutboundCallConfig>,
|
outbound_call_config: Option<DiscordOutboundCallConfig>,
|
||||||
|
voice_state_tracker: Arc<VoiceStateTracker>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -636,11 +700,63 @@ impl EventHandler for SharedClientEventHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
FullEvent::GuildCreate { guild, .. } => {
|
||||||
|
let guild_id = Snowflake::new(guild.id.get());
|
||||||
|
for voice_state in guild.voice_states.values() {
|
||||||
|
self.voice_state_tracker.update(
|
||||||
|
Snowflake::new(voice_state.user_id.get()),
|
||||||
|
Some(guild_id),
|
||||||
|
voice_state.channel_id.map(|id| Snowflake::new(id.get())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FullEvent::VoiceStateUpdate { new, .. } => {
|
||||||
|
self.voice_state_tracker.update(
|
||||||
|
Snowflake::new(new.user_id.get()),
|
||||||
|
new.guild_id.map(|id| Snowflake::new(id.get())),
|
||||||
|
new.channel_id.map(|id| Snowflake::new(id.get())),
|
||||||
|
);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Best-effort bot nickname update for a guild.
|
||||||
|
pub async fn set_bot_nickname(bot_token: &str, guild_id: Snowflake, display_name: &str) {
|
||||||
|
let nickname = call_nickname(display_name);
|
||||||
|
let url = format!(
|
||||||
|
"https://discord.com/api/v10/guilds/{}/members/@me",
|
||||||
|
guild_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = reqwest::Client::new()
|
||||||
|
.patch(url)
|
||||||
|
.header("Authorization", format!("Bot {}", bot_token))
|
||||||
|
.json(&serde_json::json!({ "nick": nickname }))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(response) if response.status().is_success() => {
|
||||||
|
debug!(
|
||||||
|
"Set bot nickname in guild {} while calling {}",
|
||||||
|
guild_id, display_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(response) => {
|
||||||
|
warn!(
|
||||||
|
"Failed to set bot nickname in guild {}: HTTP {}",
|
||||||
|
guild_id,
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to set bot nickname in guild {}: {}", guild_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn register_call_commands(ctx: &Context, guild_id: GuildId) -> Result<(), serenity::Error> {
|
async fn register_call_commands(ctx: &Context, guild_id: GuildId) -> Result<(), serenity::Error> {
|
||||||
let call_command = CreateCommand::new("call")
|
let call_command = CreateCommand::new("call")
|
||||||
.description("Call a SIP/PBX extension from your current voice channel")
|
.description("Call a SIP/PBX extension from your current voice channel")
|
||||||
|
|
@ -834,38 +950,12 @@ async fn set_call_nickname(
|
||||||
let Some(guild_id) = guild_id else {
|
let Some(guild_id) = guild_id else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
set_bot_nickname(
|
||||||
let nickname = call_nickname(display_name);
|
&cfg.bot_token,
|
||||||
let url = format!(
|
Snowflake::new(guild_id.get()),
|
||||||
"https://discord.com/api/v10/guilds/{}/members/@me",
|
display_name,
|
||||||
guild_id.get()
|
)
|
||||||
);
|
.await;
|
||||||
|
|
||||||
let result = reqwest::Client::new()
|
|
||||||
.patch(url)
|
|
||||||
.header("Authorization", format!("Bot {}", cfg.bot_token))
|
|
||||||
.json(&serde_json::json!({ "nick": nickname }))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(response) if response.status().is_success() => {
|
|
||||||
debug!(
|
|
||||||
"Set bot nickname in guild {} while calling {}",
|
|
||||||
guild_id, display_name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(response) => {
|
|
||||||
warn!(
|
|
||||||
"Failed to set bot nickname in guild {}: HTTP {}",
|
|
||||||
guild_id,
|
|
||||||
response.status()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to set bot nickname in guild {}: {}", guild_id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn call_nickname(display_name: &str) -> String {
|
fn call_nickname(display_name: &str) -> String {
|
||||||
|
|
|
||||||
|
|
@ -476,8 +476,13 @@ pub unsafe extern "C" fn on_incoming_call_cb(
|
||||||
if let Some(callbacks) = CALLBACKS.get()
|
if let Some(callbacks) = CALLBACKS.get()
|
||||||
&& let Some(ref handlers) = *callbacks.lock()
|
&& let Some(ref handlers) = *callbacks.lock()
|
||||||
{
|
{
|
||||||
(handlers.on_incoming_call)(call_id, sip_username, extension.clone(), source_ip);
|
(handlers.on_incoming_call)(
|
||||||
(handlers.on_call_authenticated)(call_id, params, extension, source_ip);
|
call_id,
|
||||||
|
sip_username.clone(),
|
||||||
|
extension.clone(),
|
||||||
|
source_ip,
|
||||||
|
);
|
||||||
|
(handlers.on_call_authenticated)(call_id, params, sip_username, extension, source_ip);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No Authorization header - send 401 challenge
|
// No Authorization header - send 401 challenge
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ pub struct DigestAuthParams {
|
||||||
pub struct CallbackHandlers {
|
pub struct CallbackHandlers {
|
||||||
pub on_incoming_call: Box<dyn Fn(CallId, String, String, Option<IpAddr>) + Send + Sync>,
|
pub on_incoming_call: Box<dyn Fn(CallId, String, String, Option<IpAddr>) + Send + Sync>,
|
||||||
pub on_call_authenticated:
|
pub on_call_authenticated:
|
||||||
Box<dyn Fn(CallId, DigestAuthParams, String, Option<IpAddr>) + Send + Sync>,
|
Box<dyn Fn(CallId, DigestAuthParams, String, String, Option<IpAddr>) + Send + Sync>,
|
||||||
pub on_dtmf: Box<dyn Fn(CallId, char) + Send + Sync>,
|
pub on_dtmf: Box<dyn Fn(CallId, char) + Send + Sync>,
|
||||||
pub on_call_ended: Box<dyn Fn(CallId) + Send + Sync>,
|
pub on_call_ended: Box<dyn Fn(CallId) + Send + Sync>,
|
||||||
/// Audio frame callback: (channel_id, samples, sample_rate)
|
/// Audio frame callback: (channel_id, samples, sample_rate)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ pub enum SipEvent {
|
||||||
call_id: CallId,
|
call_id: CallId,
|
||||||
/// SIP Digest auth parameters (boxed to reduce enum size)
|
/// SIP Digest auth parameters (boxed to reduce enum size)
|
||||||
digest_auth: Box<DigestAuthParams>,
|
digest_auth: Box<DigestAuthParams>,
|
||||||
|
/// Caller ID / SIP username from the From header.
|
||||||
|
caller_id: String,
|
||||||
/// Extension being called (from To header)
|
/// Extension being called (from To header)
|
||||||
extension: String,
|
extension: String,
|
||||||
/// Source IP address of the caller
|
/// Source IP address of the caller
|
||||||
|
|
@ -265,7 +267,7 @@ fn run_pjsua_loop(
|
||||||
}),
|
}),
|
||||||
on_call_authenticated: Box::new({
|
on_call_authenticated: Box::new({
|
||||||
let event_tx = event_tx.clone();
|
let event_tx = event_tx.clone();
|
||||||
move |call_id, digest_auth, extension, source_ip| {
|
move |call_id, digest_auth, caller_id, extension, source_ip| {
|
||||||
info!(
|
info!(
|
||||||
"Call {} authenticated: user={}",
|
"Call {} authenticated: user={}",
|
||||||
call_id, digest_auth.username
|
call_id, digest_auth.username
|
||||||
|
|
@ -274,6 +276,7 @@ fn run_pjsua_loop(
|
||||||
let _ = event_tx.send(SipEvent::IncomingCall {
|
let _ = event_tx.send(SipEvent::IncomingCall {
|
||||||
call_id,
|
call_id,
|
||||||
digest_auth: Box::new(digest_auth),
|
digest_auth: Box::new(digest_auth),
|
||||||
|
caller_id,
|
||||||
extension,
|
extension,
|
||||||
source_ip,
|
source_ip,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue