A server you silenced months ago should be silent here
Muting a server or a channel in the official Discord client is an account setting, not a preference in one window: it arrives in READY as `user_guild_settings` and every device signed in to that account honours it. Nothing here read it, so a guild somebody deliberately silenced was as loud in moho as anything else - and the busiest servers are exactly the ones people mute. Read, not written. Being told is the half that matters and the half that fails quietly; setting it from here can follow. Three things about the shape of it. Applying is its own step, because the settings arrive before the channels they are about: READY carries the mutes, GUILD_CREATE carries the channels, so what is read is kept and applied again whenever there is somewhere new to apply it. One pass over an account's conversations, and only the ones that actually move are announced. A timed mute - "mute for 8 hours" - keeps its flag after the eight hours are up, and Discord expects the client to look at the clock. One that did not would silence a server permanently because somebody once silenced it for an evening. And a muted guild silences everything in it, so a channel and its guild are read as an "or" rather than the channel overruling the guild - which is what Discord's own client does. `set_silenced` now does both halves of what it always meant: nothing here announces itself, and the buffer says so on the wire. A client that learned only the first would draw an ordinary channel that never makes a sound, which reads as a bug. Matrix's push-rule mutes get that second half for free. Checked against the real account rather than only the payloads: of 41 conversations, 16 came up muted - one guild entirely (a guild mute), and four of five and one of five in two others (channel overrides). Both shapes, as they are set in Discord. Part of #119. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -331,6 +331,13 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// What this account has silenced on Discord itself.
|
||||
// Read before the guilds arrive, and applied again as
|
||||
// each one does - see `mutes::apply`.
|
||||
if !d["user_guild_settings"].is_null() {
|
||||
mutes::note_settings(state, &account_id, &d["user_guild_settings"]);
|
||||
}
|
||||
|
||||
// Friends list: READY's own `relationships` array
|
||||
// (type 1 = friend - 2/3/4 are blocked/incoming-
|
||||
// request/outgoing-request, not shown here) plus
|
||||
@@ -430,10 +437,18 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
|
||||
Err(e) => tracing::warn!("discord[{account_id}]: fetching /users/@me/channels: {e}"),
|
||||
}
|
||||
|
||||
// The direct messages now exist, so the settings
|
||||
// read at the top of READY have somewhere to land.
|
||||
mutes::apply(state, &account_id);
|
||||
|
||||
spawn_backfill(state.clone(), config.token.clone(), config.user_id.clone(), config.display_name.clone(), new_dm_buffers);
|
||||
}
|
||||
"GUILD_CREATE" => {
|
||||
register_guild_channels(state, config, d, channel_map).await;
|
||||
// The channels this guild's settings were about now
|
||||
// exist. READY carried the mutes before any of them
|
||||
// did, so this is where most of them actually land.
|
||||
mutes::apply(state, &account_id);
|
||||
// Kept so a channel created later can be placed
|
||||
// without re-fetching the guild: naming it and
|
||||
// deciding whether this account may see it both
|
||||
@@ -522,6 +537,11 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
|
||||
let mut one = guild.clone();
|
||||
one["channels"] = serde_json::Value::Array(vec![d.clone()]);
|
||||
register_guild_channels(state, config, &one, channel_map).await;
|
||||
// A channel made while connected inherits its
|
||||
// guild's mute, which is already known - so
|
||||
// it arrives quiet rather than becoming quiet
|
||||
// after the first thing said in it.
|
||||
mutes::apply(state, &account_id);
|
||||
// An update can also be a permission change,
|
||||
// which can take access away as easily as
|
||||
// give it - and taking it away means removing
|
||||
@@ -538,6 +558,7 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
|
||||
None => {
|
||||
let presences: HashMap<&str, &str> = HashMap::new();
|
||||
register_dm_channel(state, &account_id, d, channel_map, &presences);
|
||||
mutes::apply(state, &account_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -935,6 +956,12 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// A server or a channel muted - or unmuted - in another
|
||||
// client. One entry, carrying that guild's whole setting.
|
||||
"USER_GUILD_SETTINGS_UPDATE" => {
|
||||
mutes::note_settings(state, &account_id, d);
|
||||
}
|
||||
|
||||
"GUILD_MEMBER_ADD" | "GUILD_MEMBER_REMOVE" => {
|
||||
let Some(guild_id) = d["guild_id"].as_str() else { continue };
|
||||
if let Some(buffer_id) = state.runtime.discord_member_list_target(&account_id, guild_id) {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
//! - `media` - thumbnails, and re-signing links Discord expires
|
||||
//! - `commands` - slash commands and the interactions that answer them
|
||||
//! - `polls` - the card a poll is drawn on, and answering one
|
||||
//! - `mutes` - what this account has silenced on Discord itself
|
||||
//! - `search` - Discord's own index, asked with its own filters
|
||||
//! - `calls` - ringing and being rung; `voice` is the audio itself
|
||||
//!
|
||||
@@ -54,6 +55,7 @@ pub mod http;
|
||||
pub mod login;
|
||||
pub mod media;
|
||||
pub mod messages;
|
||||
pub mod mutes;
|
||||
pub mod people;
|
||||
pub mod polls;
|
||||
pub mod presence;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! What this account has silenced on Discord itself.
|
||||
//!
|
||||
//! Muting a server or a channel in the official client is an account setting,
|
||||
//! not a preference in one window: it arrives in READY as
|
||||
//! `user_guild_settings` and every device signed in to the account honours
|
||||
//! it. Nothing here read that, so a guild somebody deliberately silenced
|
||||
//! months ago was as loud in moho as anything else - and the busiest servers
|
||||
//! are exactly the ones people mute.
|
||||
//!
|
||||
//! Reading it, not writing it. Being *told* is the half that matters and the
|
||||
//! half that can go wrong quietly; setting it from here is a separate thing
|
||||
//! and can follow.
|
||||
//!
|
||||
//! Applying is deliberately its own step. The settings arrive in READY, and
|
||||
//! the channels they name arrive afterwards in GUILD_CREATE - so a mute
|
||||
//! applied at the moment it is read would be applied to buffers that do not
|
||||
//! exist yet, which is why what is read is kept and applied again whenever
|
||||
//! there is something new to apply it to.
|
||||
|
||||
use super::*;
|
||||
use crate::runtime::DiscordMute;
|
||||
|
||||
/// Direct messages have no guild, and Discord marks their settings entry with
|
||||
/// a null guild id. `@me` is what Discord's own API calls that scope.
|
||||
pub(super) const DM_SCOPE: &str = "@me";
|
||||
|
||||
/// Reads one `user_guild_settings` entry.
|
||||
///
|
||||
/// Returns the guild it is about along with what it says. A `mute_config`
|
||||
/// carries an end time for a mute somebody set to expire - "mute for 8 hours"
|
||||
/// - so a mute whose moment has passed is read as no mute at all rather than
|
||||
/// as a permanent one.
|
||||
pub(super) fn parse_entry(entry: &Value, now: i64) -> (String, DiscordMute) {
|
||||
let guild = entry["guild_id"].as_str().unwrap_or(DM_SCOPE).to_string();
|
||||
let mut channels = HashMap::new();
|
||||
for over in entry["channel_overrides"].as_array().into_iter().flatten() {
|
||||
let Some(channel_id) = over["channel_id"].as_str() else { continue };
|
||||
channels.insert(channel_id.to_string(), still_muted(over, now));
|
||||
}
|
||||
(guild, DiscordMute { muted: still_muted(entry, now), channels })
|
||||
}
|
||||
|
||||
/// Whether a mute flag is a mute now.
|
||||
///
|
||||
/// Discord keeps a timed mute as the flag plus an end time, and leaves the
|
||||
/// flag set after that moment passes - the client is expected to notice. One
|
||||
/// that has run out is not a mute.
|
||||
fn still_muted(block: &Value, now: i64) -> bool {
|
||||
if !block["muted"].as_bool().unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
match block["mute_config"]["end_time"].as_str() {
|
||||
Some(at) => chrono::DateTime::parse_from_rfc3339(at).map(|t| t.timestamp() > now).unwrap_or(true),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes in everything READY says about muting, or one entry from an update.
|
||||
pub(super) fn note_settings(state: &AppState, account_id: &str, settings: &Value) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
// READY carries a list; newer payloads wrap it in an object with the list
|
||||
// under `entries`, and an update sends a single entry with no wrapper at
|
||||
// all. All three are the same thing said three ways.
|
||||
let entries: Vec<Value> = if let Some(list) = settings.as_array() {
|
||||
list.clone()
|
||||
} else if let Some(list) = settings["entries"].as_array() {
|
||||
list.clone()
|
||||
} else {
|
||||
vec![settings.clone()]
|
||||
};
|
||||
for entry in entries {
|
||||
if !entry.is_object() {
|
||||
continue;
|
||||
}
|
||||
let (guild, mute) = parse_entry(&entry, now);
|
||||
state.runtime.set_discord_mute(account_id, &guild, mute);
|
||||
}
|
||||
apply(state, account_id);
|
||||
}
|
||||
|
||||
/// Puts what is known onto the buffers that exist.
|
||||
///
|
||||
/// Cheap enough to run whenever anything might have changed - one pass over
|
||||
/// this account's conversations - and `set_silenced` only tells the client
|
||||
/// about the ones that actually moved.
|
||||
pub(super) fn apply(state: &AppState, account_id: &str) {
|
||||
let prefix = format!("{account_id}|");
|
||||
for buffer in state.runtime.list_buffers() {
|
||||
if !buffer.id.starts_with(&prefix) {
|
||||
continue;
|
||||
}
|
||||
let Some(channel_id) = state.runtime.get_discord_channel(&buffer.id) else { continue };
|
||||
let guild = state.runtime.get_discord_guild(&buffer.id).unwrap_or_else(|| DM_SCOPE.to_string());
|
||||
let muted = state.runtime.discord_muted(account_id, &guild, &channel_id);
|
||||
// A local mute is still a local mute. This only ever says what
|
||||
// Discord says, so a channel muted here and not there goes back to
|
||||
// being quiet only because this window says so - which is the
|
||||
// distinction the buffer carries and a client draws.
|
||||
if muted != buffer.server_muted {
|
||||
state.runtime.set_silenced(state, &buffer.id, muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
const NOW: i64 = 1_800_000_000;
|
||||
|
||||
/// A muted guild and, inside it, one channel muted on its own. Both are
|
||||
/// read; neither is inferred from the other.
|
||||
#[test]
|
||||
fn a_guild_and_a_channel_are_both_read() {
|
||||
let entry = json!({
|
||||
"guild_id": "9",
|
||||
"muted": true,
|
||||
"channel_overrides": [
|
||||
{ "channel_id": "100", "muted": true },
|
||||
{ "channel_id": "101", "muted": false }
|
||||
]
|
||||
});
|
||||
let (guild, mute) = parse_entry(&entry, NOW);
|
||||
assert_eq!(guild, "9");
|
||||
assert!(mute.muted);
|
||||
assert_eq!(mute.channels.get("100"), Some(&true));
|
||||
assert_eq!(mute.channels.get("101"), Some(&false));
|
||||
}
|
||||
|
||||
/// Direct messages arrive with a null guild id, which is a scope of its
|
||||
/// own rather than a malformed entry.
|
||||
#[test]
|
||||
fn direct_messages_are_their_own_scope() {
|
||||
let entry = json!({
|
||||
"guild_id": null,
|
||||
"muted": false,
|
||||
"channel_overrides": [{ "channel_id": "55", "muted": true }]
|
||||
});
|
||||
let (guild, mute) = parse_entry(&entry, NOW);
|
||||
assert_eq!(guild, DM_SCOPE);
|
||||
assert!(!mute.muted);
|
||||
assert_eq!(mute.channels.get("55"), Some(&true));
|
||||
}
|
||||
|
||||
/// "Mute for 8 hours" leaves the flag set after the eight hours are up -
|
||||
/// Discord expects the client to look at the clock. One that did not
|
||||
/// would silence a server permanently because somebody once silenced it
|
||||
/// for an evening.
|
||||
#[test]
|
||||
fn a_mute_that_has_run_out_is_not_a_mute() {
|
||||
let expired = json!({
|
||||
"guild_id": "9",
|
||||
"muted": true,
|
||||
"mute_config": { "end_time": "2020-01-01T00:00:00+00:00" }
|
||||
});
|
||||
assert!(!parse_entry(&expired, NOW).1.muted);
|
||||
|
||||
let running = json!({
|
||||
"guild_id": "9",
|
||||
"muted": true,
|
||||
"mute_config": { "end_time": "2099-01-01T00:00:00+00:00" }
|
||||
});
|
||||
assert!(parse_entry(&running, NOW).1.muted);
|
||||
|
||||
// No end time at all is Discord's ordinary indefinite mute.
|
||||
let forever = json!({ "guild_id": "9", "muted": true });
|
||||
assert!(parse_entry(&forever, NOW).1.muted);
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ pub async fn set_room_muted(state: &AppState, account_id: &str, buffer_id: &str,
|
||||
} else {
|
||||
http::delete_json(&url, &account.access_token).await.context("unmuting the room")?;
|
||||
}
|
||||
state.runtime.set_silenced(buffer_id, muted);
|
||||
state.runtime.set_silenced(state, buffer_id, muted);
|
||||
|
||||
// The rules are cached; re-read rather than patch the copy, so what is
|
||||
// held is what the server actually has.
|
||||
|
||||
@@ -263,7 +263,7 @@ pub(super) async fn handle_timeline_event(
|
||||
Some(rules) => push_rule_verdict(&rules, room_id, &body),
|
||||
None => (false, false),
|
||||
};
|
||||
state.runtime.set_silenced(buffer_id, muted);
|
||||
state.runtime.set_silenced(state, buffer_id, muted);
|
||||
let mentioned = mentioned || keyword;
|
||||
|
||||
if reply_to.is_some() {
|
||||
|
||||
@@ -172,6 +172,17 @@ pub struct Buffer {
|
||||
/// before the channel was ever seen still reads correctly once it is.
|
||||
#[serde(rename = "remoteId", skip_serializing_if = "Option::is_none")]
|
||||
pub remote_id: Option<String>,
|
||||
/// Muted on the account itself rather than in this window.
|
||||
///
|
||||
/// A Discord guild or channel silenced in the official client, a Matrix
|
||||
/// room silenced by a push rule - the setting travels with the account, so
|
||||
/// every device signed in to it is already honouring it and this one
|
||||
/// should too. Carried separately from a frontend's own mute because the
|
||||
/// two are not the same offer: this one was made somewhere else and
|
||||
/// cannot be undone from here, and a client that drew them identically
|
||||
/// would put an un-mute in front of somebody that does nothing.
|
||||
#[serde(rename = "serverMuted", default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub server_muted: bool,
|
||||
}
|
||||
|
||||
/// The rail entry a buffer belongs to when its protocol has no grouping of
|
||||
@@ -670,6 +681,7 @@ mod tests {
|
||||
channel_modes: None,
|
||||
group_id: None,
|
||||
remote_id: None,
|
||||
server_muted: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+92
-6
@@ -38,6 +38,26 @@ impl VoiceFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/// What one Discord account has silenced in one guild - or, under the key
|
||||
/// "@me", in its direct messages.
|
||||
///
|
||||
/// The guild's own flag and its channel overrides are one thing because
|
||||
/// Discord sends them as one: the whole entry arrives together and replaces
|
||||
/// what was there, so holding half of it separately would be holding a half
|
||||
/// that can go stale on its own.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DiscordMute {
|
||||
/// The guild itself. A muted guild silences everything under it.
|
||||
pub muted: bool,
|
||||
/// channel id -> muted, for the channels given a setting of their own.
|
||||
///
|
||||
/// Only an override that says *muted* matters here. Discord's own client
|
||||
/// treats a muted guild as silencing everything in it - an unmuted
|
||||
/// channel inside one is still quiet - so the two are read as an "or"
|
||||
/// rather than the channel overruling the guild.
|
||||
pub channels: HashMap<String, bool>,
|
||||
}
|
||||
|
||||
/// A file somebody has offered, from the moment it is offered to the moment
|
||||
/// it is on disk or has failed.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -661,6 +681,16 @@ pub struct Runtime {
|
||||
/// Per account rather than per channel: away is a property of the person,
|
||||
/// and the same nick in three channels is away in all of them.
|
||||
irc_away: Mutex<HashMap<String, std::collections::HashSet<String>>>,
|
||||
/// (account, guild id or "@me" for direct messages) -> what that account
|
||||
/// has silenced on Discord itself.
|
||||
///
|
||||
/// Kept whole per entry rather than flattened into one set of buffer ids,
|
||||
/// because Discord sends it that way and updates it that way: an entry
|
||||
/// arrives carrying a guild's own flag and every channel override in it,
|
||||
/// and replaces what was there. Applying it to buffers is a separate step
|
||||
/// (see `backend::discord::mutes`) since the settings routinely arrive in
|
||||
/// READY before the channels they are about.
|
||||
discord_mutes: Mutex<HashMap<(String, String), DiscordMute>>,
|
||||
/// account -> the room catalogue last read off the site.
|
||||
///
|
||||
/// Cached because reading it costs a Tor round trip and a proof-of-work
|
||||
@@ -919,6 +949,7 @@ impl Runtime {
|
||||
silenced: Mutex::new(std::collections::HashSet::new()),
|
||||
sneedchat_motds: Mutex::new(HashMap::new()),
|
||||
sneedchat_rooms: Mutex::new(HashMap::new()),
|
||||
discord_mutes: Mutex::new(HashMap::new()),
|
||||
irc_splits: Mutex::new(HashMap::new()),
|
||||
irc_whois: Mutex::new(HashMap::new()),
|
||||
irc_away: Mutex::new(HashMap::new()),
|
||||
@@ -1192,6 +1223,9 @@ impl Runtime {
|
||||
channel_modes: None,
|
||||
group_id: Some(model::account_group_id(account_id)),
|
||||
remote_id: None,
|
||||
// Whichever backend knows says so; a buffer is not muted until
|
||||
// the service tells us it is.
|
||||
server_muted: false,
|
||||
};
|
||||
buffers.insert(id, buffer.clone());
|
||||
state.events.emit("bufferListChange", serde_json::to_value(&buffer).unwrap());
|
||||
@@ -1810,6 +1844,37 @@ impl Runtime {
|
||||
/// buffer alone - so answering the question guild-wide meant one account
|
||||
/// being kicked took the other account's channels with it, and one
|
||||
/// account's permission check could delete the other's buffers.
|
||||
/// Records what an account has muted in one guild, replacing whatever was
|
||||
/// held for it. Discord sends an entry whole, both in READY and in an
|
||||
/// update, so a merge would only ever preserve an override that has since
|
||||
/// been taken off.
|
||||
pub fn set_discord_mute(&self, account_id: &str, guild_key: &str, mute: DiscordMute) {
|
||||
self.discord_mutes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((account_id.to_string(), guild_key.to_string()), mute);
|
||||
}
|
||||
|
||||
/// Whether this account has silenced a channel on Discord - because the
|
||||
/// channel is muted, or because the guild holding it is.
|
||||
pub fn discord_muted(&self, account_id: &str, guild_key: &str, channel_id: &str) -> bool {
|
||||
let mutes = self.discord_mutes.lock().unwrap();
|
||||
let Some(entry) = mutes.get(&(account_id.to_string(), guild_key.to_string())) else { return false };
|
||||
entry.muted || entry.channels.get(channel_id).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Every guild this account has settings for, so they can all be applied
|
||||
/// at once when the channels they name finally exist.
|
||||
pub fn discord_mute_keys(&self, account_id: &str) -> Vec<String> {
|
||||
self.discord_mutes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|(account, _)| account == account_id)
|
||||
.map(|(_, guild)| guild.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn discord_buffers_in_guild(&self, account_id: &str, guild_id: &str) -> Vec<String> {
|
||||
let prefix = format!("{account_id}|");
|
||||
self.discord_guild_id
|
||||
@@ -2623,12 +2688,33 @@ impl Runtime {
|
||||
/// Marks a buffer as one the service says should stay quiet. Unlike a
|
||||
/// local mute this comes from the account itself, so it holds wherever
|
||||
/// that account is signed in.
|
||||
pub fn set_silenced(&self, buffer_id: &str, silenced: bool) {
|
||||
let mut all = self.silenced.lock().unwrap();
|
||||
if silenced {
|
||||
all.insert(buffer_id.to_string());
|
||||
} else {
|
||||
all.remove(buffer_id);
|
||||
///
|
||||
/// Two things follow from it, which is why this is one call rather than
|
||||
/// two: nothing here announces itself, and the buffer says so on the wire
|
||||
/// so a client can draw the row muted without having asked for it. A
|
||||
/// client that only learned the first would show an ordinary-looking
|
||||
/// channel that never makes a sound, which reads as a bug.
|
||||
pub fn set_silenced(&self, state: &AppState, buffer_id: &str, silenced: bool) {
|
||||
{
|
||||
let mut all = self.silenced.lock().unwrap();
|
||||
if silenced {
|
||||
all.insert(buffer_id.to_string());
|
||||
} else {
|
||||
all.remove(buffer_id);
|
||||
}
|
||||
}
|
||||
let updated = {
|
||||
let mut buffers = self.buffers.lock().unwrap();
|
||||
match buffers.get_mut(buffer_id) {
|
||||
Some(b) if b.server_muted != silenced => {
|
||||
b.server_muted = silenced;
|
||||
Some(b.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(b) = updated {
|
||||
state.events.emit("bufferListChange", serde_json::to_value(&b).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user