Discord: stage channels, call volume, and per-person volume
Stages (#181): type 13 channels are listed with voice channels, carrying whether each is a stage and its topic while live (from the guild payload and STAGE_INSTANCE_CREATE/UPDATE/DELETE). Voice states carry suppress (audience) and request_to_speak_timestamp (hand up). setStageHand raises or lowers a hand; setStageSpeaker steps onto the stage or back, refused by Discord without a moderator's say-so, and said in words. While this account is in a stage's audience its microphone is held shut whatever the mute button says, and the button's own state comes back on stepping up. Voice sessions say whether they are on a stage. Volume (#235): voice.toml gains outputVolume and per-user userVolumes, both 0 to 2 as Discord's sliders go. The mixer applies each person's gain before the sum and the overall one after, clipping rather than wrapping. Speaking indicators are fed before either. setVoiceVolume sets one or the other; a person set back to 100% is forgotten rather than stored. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+60
-1
@@ -39,7 +39,7 @@ pub struct AudioDevice {
|
||||
/// Kept apart from accounts.toml deliberately: that file holds credentials at
|
||||
/// mode 0600, and these are preferences that belong to the machine rather than
|
||||
/// to any account.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VoicePrefs {
|
||||
/// Device ids, or None for "whatever the sound server considers default",
|
||||
@@ -55,6 +55,45 @@ pub struct VoicePrefs {
|
||||
/// Output silenced.
|
||||
#[serde(default)]
|
||||
pub deafened: bool,
|
||||
/// How loud a call is overall, 1.0 being as it arrives. Up to 2.0, as
|
||||
/// Discord's own slider goes.
|
||||
#[serde(default = "unity")]
|
||||
pub output_volume: f32,
|
||||
/// Somebody turned up or down, by Discord user id. Kept across calls, as
|
||||
/// Discord keeps it: the person who is too loud is too loud every time.
|
||||
#[serde(default)]
|
||||
pub user_volumes: std::collections::HashMap<String, f32>,
|
||||
}
|
||||
|
||||
fn unity() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl Default for VoicePrefs {
|
||||
fn default() -> Self {
|
||||
VoicePrefs { input: None, output: None, mic_muted: false, deafened: false, output_volume: 1.0, user_volumes: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl VoicePrefs {
|
||||
/// The gain for one person, as set or as it arrived.
|
||||
pub fn user_volume(&self, user_id: &str) -> f32 {
|
||||
self.user_volumes.get(user_id).copied().unwrap_or(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The loudest a volume may be set: twice as it arrived, Discord's ceiling.
|
||||
pub const MAX_VOLUME: f32 = 2.0;
|
||||
|
||||
/// Scales samples by a gain, clipping rather than wrapping where it goes
|
||||
/// past the edge - a clip sounds loud, a wrap sounds like a gunshot.
|
||||
pub fn apply_gain(samples: &mut [i16], gain: f32) {
|
||||
if (gain - 1.0).abs() < f32::EPSILON {
|
||||
return;
|
||||
}
|
||||
for s in samples.iter_mut() {
|
||||
*s = (*s as f32 * gain).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||
}
|
||||
}
|
||||
|
||||
/// The preferences file at ~/.config/nobilis/voice.toml.
|
||||
@@ -973,6 +1012,26 @@ fn route_when_ready(stream: Stream, device_id: &str, existing: &std::collections
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// A gain past the edge clips rather than wrapping round to the other
|
||||
/// extreme.
|
||||
#[test]
|
||||
fn a_gain_clips_rather_than_wraps() {
|
||||
let mut loud = [20_000i16, -20_000, 100];
|
||||
apply_gain(&mut loud, 2.0);
|
||||
assert_eq!(loud, [i16::MAX, i16::MIN, 200]);
|
||||
let mut quiet = [1000i16];
|
||||
apply_gain(&mut quiet, 0.5);
|
||||
assert_eq!(quiet, [500]);
|
||||
}
|
||||
|
||||
/// Preferences written before volumes existed read as everything at 100%.
|
||||
#[test]
|
||||
fn old_preferences_read_as_unchanged_volume() {
|
||||
let prefs: VoicePrefs = toml::from_str("micMuted = true").unwrap();
|
||||
assert_eq!(prefs.output_volume, 1.0);
|
||||
assert_eq!(prefs.user_volume("42"), 1.0);
|
||||
}
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
|
||||
@@ -303,3 +303,44 @@ pub fn leave_voice(state: &AppState, account_id: &str) -> bool {
|
||||
.send(json!({ "op": 4, "d": { "guild_id": null, "channel_id": null, "self_mute": true, "self_deaf": true } }).to_string())
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Asks to speak on a stage, or takes the request back.
|
||||
///
|
||||
/// The hand goes up as a timestamp, which is what orders the queue the
|
||||
/// stage's moderators see; down is the same field cleared.
|
||||
pub async fn set_stage_hand(state: &AppState, account_id: &str, guild_id: &str, channel_id: &str, raised: bool) -> Result<()> {
|
||||
let when = raised.then(|| chrono::Utc::now().to_rfc3339());
|
||||
patch_own_voice_state(state, account_id, guild_id, json!({ "channel_id": channel_id, "request_to_speak_timestamp": when })).await
|
||||
}
|
||||
|
||||
/// Moves this account onto the stage or back into the audience.
|
||||
///
|
||||
/// Back to the audience is always allowed. Onto the stage is allowed to a
|
||||
/// moderator, or to somebody a moderator has invited up; anyone else is
|
||||
/// refused by Discord, and the refusal is what the caller shows.
|
||||
pub async fn set_stage_speaker(state: &AppState, account_id: &str, guild_id: &str, channel_id: &str, speaking: bool) -> Result<()> {
|
||||
patch_own_voice_state(state, account_id, guild_id, json!({ "channel_id": channel_id, "suppress": !speaking })).await
|
||||
}
|
||||
|
||||
async fn patch_own_voice_state(state: &AppState, account_id: &str, guild_id: &str, body: Value) -> Result<()> {
|
||||
let cfg = state.accounts.get_discord(account_id).context("account not connected")?;
|
||||
let resp = send_write(
|
||||
http_client_for(&cfg.token)
|
||||
.patch(format!("{API_BASE}/guilds/{guild_id}/voice-states/@me"))
|
||||
.header("Authorization", &cfg.token)
|
||||
.json(&body),
|
||||
)
|
||||
.await
|
||||
.context("changing your place on the stage")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
// 403 is the ordinary answer to stepping up uninvited, and worth
|
||||
// saying in words rather than as a status line.
|
||||
if status == reqwest::StatusCode::FORBIDDEN {
|
||||
bail!("only a stage moderator, or somebody a moderator has invited up, can speak");
|
||||
}
|
||||
bail!("Discord API error {status}: {text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -900,6 +900,16 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
|
||||
// at connect, so the cheapest correct answer is to
|
||||
// register it again - which is what the role events
|
||||
// already do for the same reason.
|
||||
// A stage going live, changing its topic, or ending.
|
||||
// The channel exists either way; what this says is
|
||||
// whether anything is on in it.
|
||||
"STAGE_INSTANCE_CREATE" | "STAGE_INSTANCE_UPDATE" | "STAGE_INSTANCE_DELETE" => {
|
||||
let Some(channel) = d["channel_id"].as_str() else { continue };
|
||||
let topic = (t != "STAGE_INSTANCE_DELETE").then(|| d["topic"].as_str().unwrap_or_default());
|
||||
state.runtime.set_discord_stage_topic(&account_id, channel, topic);
|
||||
announce_voice_membership(state, &account_id, d["guild_id"].as_str(), Some(channel));
|
||||
}
|
||||
|
||||
"GUILD_UPDATE" | "GUILD_EMOJIS_UPDATE" | "GUILD_STICKERS_UPDATE" => {
|
||||
let Some(guild_id) = d["guild_id"].as_str().or_else(|| d["id"].as_str()) else { continue };
|
||||
if guild_context.contains_key(guild_id) {
|
||||
@@ -1071,6 +1081,9 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
|
||||
announce_voice_membership(state, &account_id, d["guild_id"].as_str(), channel_id);
|
||||
|
||||
if user_id == config.user_id {
|
||||
// Into a stage's audience or out of it. Held off
|
||||
// the microphone while in it.
|
||||
state.voice.set_suppressed(&account_id, channel_id.is_some() && flags.suppressed);
|
||||
// Our own move. The session id here is half of what
|
||||
// a voice connection needs; VOICE_SERVER_UPDATE
|
||||
// carries the other half.
|
||||
|
||||
@@ -443,19 +443,29 @@ pub(super) async fn register_guild_channels(state: &AppState, config: &DiscordAc
|
||||
let account_id = config.account_id();
|
||||
let mut new_channels: Vec<(String, String)> = Vec::new();
|
||||
|
||||
// Type 2 is a voice channel. Recorded rather than made into a buffer -
|
||||
// there is no conversation to show - so a client can list them and join.
|
||||
let voice: Vec<(String, String, u64)> = channels
|
||||
// Type 2 is a voice channel and 13 a stage, which is joined the same way
|
||||
// and listened to from the audience. Recorded rather than made into
|
||||
// buffers - there is no conversation to show - so a client can list them
|
||||
// and join.
|
||||
let voice: Vec<crate::runtime::VoiceChannelEntry> = channels
|
||||
.iter()
|
||||
.filter(|c| c["type"].as_i64() == Some(2))
|
||||
.filter(|c| matches!(c["type"].as_i64(), Some(2) | Some(13)))
|
||||
.filter_map(|c| {
|
||||
Some((
|
||||
c["id"].as_str()?.to_string(),
|
||||
c["name"].as_str().unwrap_or("voice").to_string(),
|
||||
c["user_limit"].as_u64().unwrap_or(0),
|
||||
))
|
||||
Some(crate::runtime::VoiceChannelEntry {
|
||||
id: c["id"].as_str()?.to_string(),
|
||||
name: c["name"].as_str().unwrap_or("voice").to_string(),
|
||||
user_limit: c["user_limit"].as_u64().unwrap_or(0),
|
||||
stage: c["type"].as_i64() == Some(13),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// A stage already live when we connect: its topic is in the guild
|
||||
// payload, and no STAGE_INSTANCE_CREATE will come for it.
|
||||
for stage in guild["stage_instances"].as_array().into_iter().flatten() {
|
||||
if let Some(channel) = stage["channel_id"].as_str() {
|
||||
state.runtime.set_discord_stage_topic(&config.account_id(), channel, stage["topic"].as_str());
|
||||
}
|
||||
}
|
||||
if !voice.is_empty() {
|
||||
state.runtime.set_discord_voice_channels(&account_id, guild_id, voice);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,13 @@ pub struct VoiceState {
|
||||
/// The microphone and output flags last told to the gateway, which a
|
||||
/// camera turned on has to repeat: the gateway takes the three together.
|
||||
flags: Mutex<HashMap<String, (bool, bool)>>,
|
||||
/// Accounts in a stage's audience. Their microphone is held shut whatever
|
||||
/// the mute button says: Discord would drop the audio, but the speaking
|
||||
/// ring would still light on everybody's screen.
|
||||
suppressed: Mutex<std::collections::HashSet<String>>,
|
||||
/// The mute button's own state, so leaving the audience restores it
|
||||
/// rather than opening a microphone somebody had closed.
|
||||
mic_wanted_muted: Mutex<HashMap<String, bool>>,
|
||||
}
|
||||
|
||||
/// Who is audible in one call, and how loudly.
|
||||
@@ -391,16 +398,35 @@ impl VoiceState {
|
||||
/// mute, and a caller that believes it muted something that was never live
|
||||
/// would show the wrong thing.
|
||||
pub fn set_mic_muted(&self, account_id: &str, muted: bool) -> bool {
|
||||
self.mic_wanted_muted.lock().unwrap().insert(account_id.to_string(), muted);
|
||||
let held = self.suppressed.lock().unwrap().contains(account_id);
|
||||
let captures = self.captures.lock().unwrap();
|
||||
match captures.get(account_id) {
|
||||
Some(capture) => {
|
||||
capture.set_muted(muted);
|
||||
capture.set_muted(muted || held);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves this account between a stage's audience and its speakers, as
|
||||
/// far as its own microphone is concerned.
|
||||
pub fn set_suppressed(&self, account_id: &str, suppressed: bool) {
|
||||
let changed = if suppressed {
|
||||
self.suppressed.lock().unwrap().insert(account_id.to_string())
|
||||
} else {
|
||||
self.suppressed.lock().unwrap().remove(account_id)
|
||||
};
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
let wanted = self.mic_wanted_muted.lock().unwrap().get(account_id).copied().unwrap_or(false);
|
||||
if let Some(capture) = self.captures.lock().unwrap().get(account_id) {
|
||||
capture.set_muted(wanted || suppressed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Peak microphone level of a transmitting session, for a level meter.
|
||||
pub fn input_level(&self, account_id: &str) -> Option<f32> {
|
||||
let captures = self.captures.lock().unwrap();
|
||||
@@ -535,7 +561,9 @@ async fn connect(state: &AppState, account_id: &str, info: &PendingHandshake) ->
|
||||
|
||||
let mut mic = None;
|
||||
if state.voice.options(account_id).transmit {
|
||||
match start_transmitting(prefs.input.as_deref(), prefs.mic_muted || prefs.deafened) {
|
||||
state.voice.mic_wanted_muted.lock().unwrap().insert(account_id.to_string(), prefs.mic_muted || prefs.deafened);
|
||||
let held = state.voice.suppressed.lock().unwrap().contains(account_id);
|
||||
match start_transmitting(prefs.input.as_deref(), prefs.mic_muted || prefs.deafened || held) {
|
||||
Ok((capture, source)) => {
|
||||
state.voice.captures.lock().unwrap().insert(account_id.to_string(), capture);
|
||||
mic = Some(source);
|
||||
@@ -572,6 +600,7 @@ pub fn set_camera(state: &AppState, account_id: &str, on: bool, quality: VideoQu
|
||||
state.voice.cameras.lock().unwrap().insert(account_id.to_string(), quality);
|
||||
} else {
|
||||
state.voice.cameras.lock().unwrap().remove(account_id);
|
||||
state.voice.suppressed.lock().unwrap().remove(account_id);
|
||||
}
|
||||
conn.set_camera(on, quality);
|
||||
let (muted, deafened) = state.voice.flags(account_id);
|
||||
|
||||
@@ -1077,8 +1077,23 @@ async fn clock(
|
||||
shared.tracker.heard_from(*ssrc, super::voice::peak_of(pcm));
|
||||
}
|
||||
if let Some(playback) = playback.as_ref() {
|
||||
// Each person at the volume they were set to, before the mix, and
|
||||
// the whole call at its own after it. The speaking rings above
|
||||
// were fed before either: turning somebody down is not the same
|
||||
// as them going quiet.
|
||||
let prefs = shared.state.voice_prefs.get();
|
||||
let mut voices = voices;
|
||||
if !prefs.user_volumes.is_empty() {
|
||||
let owners = shared.owners.lock().unwrap();
|
||||
for (ssrc, pcm) in voices.iter_mut() {
|
||||
if let Some(user) = owners.get(ssrc) {
|
||||
crate::audio::apply_gain(pcm, prefs.user_volume(&user.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let refs: Vec<&[i16]> = voices.iter().map(|(_, pcm)| pcm.as_slice()).collect();
|
||||
let mixed = super::voice::mix(&refs);
|
||||
let mut mixed = super::voice::mix(&refs);
|
||||
crate::audio::apply_gain(&mut mixed, prefs.output_volume);
|
||||
if !mixed.is_empty() {
|
||||
playback.push(&mixed);
|
||||
}
|
||||
|
||||
+55
-3
@@ -2221,8 +2221,8 @@ pub async fn dispatch(
|
||||
.runtime
|
||||
.discord_voice_channels(id, g)
|
||||
.into_iter()
|
||||
.find(|(c, _, _)| *c == channel_id)
|
||||
.map(|(_, n, _)| n)
|
||||
.find(|c| c.id == channel_id)
|
||||
.map(|c| c.name)
|
||||
})
|
||||
.or_else(|| {
|
||||
state
|
||||
@@ -2244,6 +2244,11 @@ pub async fn dispatch(
|
||||
// A call with no guild is a one-to-one call, which a
|
||||
// client shows differently.
|
||||
"isDirect": guild_id.is_none(),
|
||||
// And a stage is listened to, which a client shows
|
||||
// differently again.
|
||||
"stage": guild_id.as_deref().is_some_and(|g| {
|
||||
state.runtime.discord_voice_channels(id, g).iter().any(|c| c.id == channel_id && c.stage)
|
||||
}),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
@@ -2597,7 +2602,8 @@ pub async fn dispatch(
|
||||
.runtime
|
||||
.discord_voice_channels(account_id, guild_id)
|
||||
.into_iter()
|
||||
.map(|(id, name, limit)| {
|
||||
.map(|channel| {
|
||||
let crate::runtime::VoiceChannelEntry { id, name, user_limit: limit, stage } = channel;
|
||||
let others = state.runtime.discord_voice_occupants(account_id, &id, &own);
|
||||
// Everyone including us, since a channel list has to show
|
||||
// you your own presence; `empty` deliberately still means
|
||||
@@ -2612,6 +2618,9 @@ pub async fn dispatch(
|
||||
"id": id,
|
||||
"name": name,
|
||||
"userLimit": limit,
|
||||
"stage": stage,
|
||||
// Only while the stage is live.
|
||||
"topic": if stage { state.runtime.discord_stage_topic(account_id, &id) } else { None },
|
||||
"occupants": others.len(),
|
||||
"empty": others.is_empty(),
|
||||
"members": members
|
||||
@@ -2641,6 +2650,46 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// How loud a call is: overall, or one person in it. Answered with
|
||||
// the preferences as they now stand, like the device choice.
|
||||
"setVoiceVolume" => {
|
||||
let volume = params.get("volume").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
|
||||
let volume = volume.clamp(0.0, crate::audio::MAX_VOLUME);
|
||||
let user = p_str_opt(params, "userId").map(str::to_string);
|
||||
let prefs = state.voice_prefs.update(|p| match user {
|
||||
// Back at 100% is forgotten rather than stored, so the file
|
||||
// only lists the people somebody actually changed.
|
||||
Some(user) if (volume - 1.0).abs() < 0.005 => {
|
||||
p.user_volumes.remove(&user);
|
||||
}
|
||||
Some(user) => {
|
||||
p.user_volumes.insert(user, volume);
|
||||
}
|
||||
None => p.output_volume = volume,
|
||||
});
|
||||
(Some(serde_json::to_value(prefs).unwrap()), None)
|
||||
}
|
||||
|
||||
// A stage: asking to speak, and moving between the audience and the
|
||||
// speakers. Both are this account's own voice state in that guild.
|
||||
"setStageHand" | "setStageSpeaker" => {
|
||||
let (account_id, guild_id, channel_id) =
|
||||
match (p_str_opt(params, "accountId"), p_str_opt(params, "guildId"), p_str_opt(params, "channelId")) {
|
||||
(Some(a), Some(g), Some(c)) => (a, g, c),
|
||||
_ => return (None, Some(format!("{method} requires \"accountId\", \"guildId\" and \"channelId\""))),
|
||||
};
|
||||
let on = p_bool(params, "on", false);
|
||||
let done = if method == "setStageHand" {
|
||||
backend::discord::set_stage_hand(state, account_id, guild_id, channel_id, on).await
|
||||
} else {
|
||||
backend::discord::set_stage_speaker(state, account_id, guild_id, channel_id, on).await
|
||||
};
|
||||
match done {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
"leaveVoiceChannel" => match p_str_opt(params, "accountId") {
|
||||
None => (None, Some("leaveVoiceChannel requires \"accountId\"".to_string())),
|
||||
Some(account_id) => (Some(serde_json::json!({ "left": backend::discord::leave_voice(state, account_id) })), None),
|
||||
@@ -5796,5 +5845,8 @@ fn voice_member_json(m: &crate::runtime::VoiceRosterEntry, own: &str) -> Value {
|
||||
"video": m.flags.video,
|
||||
"muted": m.flags.muted,
|
||||
"deafened": m.flags.deafened,
|
||||
// A stage's audience, and who among it has asked to speak.
|
||||
"suppressed": m.flags.suppressed,
|
||||
"handRaised": m.flags.hand_raised,
|
||||
})
|
||||
}
|
||||
|
||||
+42
-5
@@ -15,12 +15,29 @@ use std::sync::Mutex;
|
||||
/// `streaming` is Go Live, a screen or window shared into the channel.
|
||||
/// `video` is a camera. They are independent: somebody can do both, and the
|
||||
/// two look nothing alike to the person watching.
|
||||
/// One voice channel of a guild, as listed for joining.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VoiceChannelEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 0 is no limit.
|
||||
pub user_limit: u64,
|
||||
/// A stage (type 13): speakers and an audience, rather than a room
|
||||
/// everybody talks in.
|
||||
pub stage: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct VoiceFlags {
|
||||
pub streaming: bool,
|
||||
pub video: bool,
|
||||
pub muted: bool,
|
||||
pub deafened: bool,
|
||||
/// On a stage: in the audience, which Discord calls suppressed. Off a
|
||||
/// stage nobody is.
|
||||
pub suppressed: bool,
|
||||
/// On a stage: has asked to speak and not yet been answered.
|
||||
pub hand_raised: bool,
|
||||
}
|
||||
|
||||
impl VoiceFlags {
|
||||
@@ -34,6 +51,8 @@ impl VoiceFlags {
|
||||
// anything is audible, and this says whether anything is audible.
|
||||
muted: d["self_mute"].as_bool().unwrap_or(false) || d["mute"].as_bool().unwrap_or(false),
|
||||
deafened: d["self_deaf"].as_bool().unwrap_or(false) || d["deaf"].as_bool().unwrap_or(false),
|
||||
suppressed: d["suppress"].as_bool().unwrap_or(false),
|
||||
hand_raised: d["request_to_speak_timestamp"].as_str().is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,6 +470,8 @@ pub struct Runtime {
|
||||
/// Whether this account's subscription lets it use emoji away from where
|
||||
/// they live - Discord Nitro. Absent means no.
|
||||
emoji_unrestricted: Mutex<std::collections::HashSet<String>>,
|
||||
/// A live stage's topic, by (account, stage channel).
|
||||
discord_stage_topics: Mutex<HashMap<(String, String), String>>,
|
||||
/// Each Discord account's `premium_type`, as READY gave it.
|
||||
discord_premium: Mutex<HashMap<String, u8>>,
|
||||
/// Which Kick livestreams each account is claiming to watch, by buffer -
|
||||
@@ -571,7 +592,7 @@ pub struct Runtime {
|
||||
account_status: Mutex<HashMap<String, String>>,
|
||||
discord_member_list_targets: Mutex<HashMap<(String, String), String>>,
|
||||
/// (account, guild) -> that guild's voice channels, as (id, name, limit).
|
||||
discord_voice_channels: Mutex<HashMap<(String, String), Vec<(String, String, u64)>>>,
|
||||
discord_voice_channels: Mutex<HashMap<(String, String), Vec<VoiceChannelEntry>>>,
|
||||
/// (account, user) -> the voice channel they are in. Absent means not in
|
||||
/// one; this is the only record of who is where, since Discord reports
|
||||
/// voice membership solely over the gateway.
|
||||
@@ -1000,6 +1021,7 @@ impl Runtime {
|
||||
emoji_catalogue: Mutex::new(HashMap::new()),
|
||||
emoji_unrestricted: Mutex::new(std::collections::HashSet::new()),
|
||||
discord_premium: Mutex::new(HashMap::new()),
|
||||
discord_stage_topics: Mutex::new(HashMap::new()),
|
||||
kick_watching: Mutex::new(HashMap::new()),
|
||||
matrix_emoticons: Mutex::new(HashMap::new()),
|
||||
discord_friends: Mutex::new(HashMap::new()),
|
||||
@@ -1809,14 +1831,29 @@ impl Runtime {
|
||||
self.discord_member_list_targets.lock().unwrap().get(&(account_id.to_string(), guild_id.to_string())).cloned()
|
||||
}
|
||||
|
||||
pub fn set_discord_voice_channels(&self, account_id: &str, guild_id: &str, channels: Vec<(String, String, u64)>) {
|
||||
pub fn set_discord_voice_channels(&self, account_id: &str, guild_id: &str, channels: Vec<VoiceChannelEntry>) {
|
||||
self.discord_voice_channels.lock().unwrap().insert((account_id.to_string(), guild_id.to_string()), channels);
|
||||
}
|
||||
|
||||
pub fn discord_voice_channels(&self, account_id: &str, guild_id: &str) -> Vec<(String, String, u64)> {
|
||||
pub fn discord_voice_channels(&self, account_id: &str, guild_id: &str) -> Vec<VoiceChannelEntry> {
|
||||
self.discord_voice_channels.lock().unwrap().get(&(account_id.to_string(), guild_id.to_string())).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What a stage is about right now, while it is live. Gone when the stage
|
||||
/// ends, which is when Discord deletes the stage instance.
|
||||
pub fn set_discord_stage_topic(&self, account_id: &str, channel_id: &str, topic: Option<&str>) {
|
||||
let mut all = self.discord_stage_topics.lock().unwrap();
|
||||
let key = (account_id.to_string(), channel_id.to_string());
|
||||
match topic {
|
||||
Some(t) => all.insert(key, t.to_string()),
|
||||
None => all.remove(&key),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn discord_stage_topic(&self, account_id: &str, channel_id: &str) -> Option<String> {
|
||||
self.discord_stage_topics.lock().unwrap().get(&(account_id.to_string(), channel_id.to_string())).cloned()
|
||||
}
|
||||
|
||||
/// The same, carrying what they are doing in there.
|
||||
///
|
||||
/// Discord puts these on every voice state it sends, and they are the only
|
||||
@@ -2031,7 +2068,7 @@ impl Runtime {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|((a, _), channels)| a == account_id && channels.iter().any(|(id, _, _)| id == channel_id))
|
||||
.find(|((a, _), channels)| a == account_id && channels.iter().any(|c| c.id == channel_id))
|
||||
.map(|((_, g), _)| g.clone())
|
||||
}
|
||||
|
||||
@@ -4731,7 +4768,7 @@ impl Runtime {
|
||||
matrix_room_members, matrix_read_receipts, sneedchat_motds,
|
||||
irc_whois, discord_mutes, matrix_presence,
|
||||
matrix_own_reactions, irc_splits, matrix_widgets,
|
||||
matrix_directs,
|
||||
matrix_directs, discord_stage_topics,
|
||||
);
|
||||
sweep_set!(
|
||||
discord_history_inflight, matrix_encrypted_rooms, matrix_backup_enabled,
|
||||
|
||||
Reference in New Issue
Block a user