An m.notice is not somebody talking
protocol::message_body tested only for m.emote, so every other msgtype - m.notice included - became a plain message. m.notice is what bots and bridges send, and it exists precisely so a client can draw it more quietly; without that, a room with an active bridge makes automated traffic indistinguishable from people talking (#215). message_kind names it, and the three paths that store a message - live, threaded and backfilled - all ask. It stays a chat kind rather than becoming a system line: a notice has a sender, can be replied to and redacted, and belongs in the conversation. A notice also stops raising a mention while the account's own .m.rule.suppress_notices override is enabled, which is what every homeserver ships. An override beats the content rules, so on a stock account a notice carrying somebody's name notifies nowhere else either - and it is read from the real rules, because somebody who turned it off meant to. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -160,6 +160,23 @@ pub(super) fn push_rule_verdict(rules: &Value, room_id: &str, body: &str) -> (bo
|
||||
(muted, keyword)
|
||||
}
|
||||
|
||||
/// Whether the account still has the spec's own rule suppressing notices.
|
||||
///
|
||||
/// `.m.rule.suppress_notices` is an override rule every homeserver ships
|
||||
/// enabled, and an override beats the content rules that would otherwise
|
||||
/// notify - so on a stock account a bot's notice never raises anything, even
|
||||
/// one carrying somebody's name. Asked of the account's real rules rather
|
||||
/// than assumed, because it is a rule like any other and somebody who has
|
||||
/// turned it off meant to.
|
||||
pub(super) fn notices_are_suppressed(rules: &Value) -> bool {
|
||||
rules["global"]["override"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|rule| rule["rule_id"].as_str() == Some(".m.rule.suppress_notices"))
|
||||
.any(|rule| rule["enabled"].as_bool().unwrap_or(true) && silences(&rule["actions"]))
|
||||
}
|
||||
|
||||
/// Whether a rule's actions amount to "say nothing".
|
||||
///
|
||||
/// Both spellings: the old `dont_notify` action, and the newer form where an
|
||||
@@ -190,6 +207,7 @@ pub(super) fn matches_keyword(body: &str, pattern: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod push_rule_tests {
|
||||
use super::notices_are_suppressed;
|
||||
use super::{matches_keyword, push_rule_verdict};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -232,4 +250,26 @@ mod push_rule_tests {
|
||||
assert!(matches_keyword("a build failed", "*failed"));
|
||||
assert!(!matches_keyword("a build passed", "*failed"));
|
||||
}
|
||||
|
||||
/// Every homeserver ships `.m.rule.suppress_notices` enabled, and an
|
||||
/// override beats the content rules - so on a stock account a bot's
|
||||
/// notice raises nothing, even one carrying somebody's name.
|
||||
#[test]
|
||||
fn a_stock_account_does_not_announce_notices() {
|
||||
let stock = json!({ "global": { "override": [
|
||||
{ "rule_id": ".m.rule.master", "enabled": false, "actions": [] },
|
||||
{ "rule_id": ".m.rule.suppress_notices", "enabled": true, "actions": ["dont_notify"] }
|
||||
]}});
|
||||
assert!(notices_are_suppressed(&stock));
|
||||
|
||||
// Somebody who turned it off meant to.
|
||||
let off = json!({ "global": { "override": [
|
||||
{ "rule_id": ".m.rule.suppress_notices", "enabled": false, "actions": ["dont_notify"] }
|
||||
]}});
|
||||
assert!(!notices_are_suppressed(&off));
|
||||
|
||||
// And an account whose rules have not been read yet says nothing
|
||||
// either way rather than inventing a rule.
|
||||
assert!(!notices_are_suppressed(&json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,22 @@ pub fn message_body(content: &Value) -> (String, bool) {
|
||||
(body, is_action)
|
||||
}
|
||||
|
||||
/// What kind of line a message content is: something said, or something
|
||||
/// announced.
|
||||
///
|
||||
/// `m.notice` exists precisely so a client can draw it more quietly. It is
|
||||
/// what bots and bridges send, and what a homeserver's own notices arrive as -
|
||||
/// the spec's default push rules even suppress notifications for it. Drawn
|
||||
/// like anybody else's message, an active bridge makes automated traffic
|
||||
/// indistinguishable from people talking.
|
||||
///
|
||||
/// Still a chat line rather than a system one: it has a sender, it can be
|
||||
/// replied to and redacted, and it belongs beside the conversation. Only its
|
||||
/// weight changes.
|
||||
pub fn message_kind(content: &Value) -> &'static str {
|
||||
if content["msgtype"].as_str() == Some("m.notice") { "notice" } else { "message" }
|
||||
}
|
||||
|
||||
/// Whether this is a voice message rather than an attached audio file.
|
||||
///
|
||||
/// The marker is still under its MSC prefix everywhere that sends it, and the
|
||||
@@ -165,6 +181,22 @@ pub fn reaction_target(content: &Value) -> Option<(&str, &str)> {
|
||||
mod thread_tests {
|
||||
use super::*;
|
||||
|
||||
/// A bot's notice is still a chat line - it has a sender and can be
|
||||
/// replied to - but it is not somebody talking, and drawing it the same
|
||||
/// way makes a bridged room unreadable.
|
||||
#[test]
|
||||
fn a_notice_is_its_own_kind_of_line() {
|
||||
use serde_json::json;
|
||||
assert_eq!(message_kind(&json!({ "msgtype": "m.notice", "body": "build failed" })), "notice");
|
||||
assert_eq!(message_kind(&json!({ "msgtype": "m.text", "body": "hello" })), "message");
|
||||
// An emote is a person talking about themselves, not an
|
||||
// announcement; it keeps its own handling through `is_action`.
|
||||
assert_eq!(message_kind(&json!({ "msgtype": "m.emote", "body": "waves" })), "message");
|
||||
// Media, and content with no msgtype at all, stay ordinary.
|
||||
assert_eq!(message_kind(&json!({ "msgtype": "m.image", "body": "cat.png" })), "message");
|
||||
assert_eq!(message_kind(&json!({ "body": "hello" })), "message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_thread_a_message_belongs_to() {
|
||||
let threaded = serde_json::json!({
|
||||
|
||||
@@ -269,12 +269,19 @@ pub(super) async fn handle_timeline_event(
|
||||
// including for the nick match this client makes on its own - which is
|
||||
// why the mute is put on the buffer rather than folded into this one
|
||||
// message's answer.
|
||||
let (muted, keyword) = match state.runtime.matrix_push_rules(account_id) {
|
||||
Some(rules) => push_rule_verdict(&rules, room_id, &body),
|
||||
None => (false, false),
|
||||
let (muted, keyword, notices_quiet) = match state.runtime.matrix_push_rules(account_id) {
|
||||
Some(rules) => {
|
||||
let (muted, keyword) = push_rule_verdict(&rules, room_id, &body);
|
||||
(muted, keyword, notices_are_suppressed(&rules))
|
||||
}
|
||||
None => (false, false, false),
|
||||
};
|
||||
state.runtime.set_silenced(state, buffer_id, muted);
|
||||
let mentioned = mentioned || keyword;
|
||||
// A notice does not announce itself while the account's own override
|
||||
// rule says so - which is the state every homeserver ships. Without
|
||||
// this, a bridge relaying a room full of somebody's name would light up
|
||||
// the mentions inbox with traffic no person wrote.
|
||||
let mentioned = (mentioned || keyword) && !(notices_quiet && protocol::message_kind(&content) == "notice");
|
||||
|
||||
if reply_to.is_some() {
|
||||
// The raw body Matrix sends for a reply includes a quoted `> `
|
||||
@@ -313,7 +320,9 @@ pub(super) async fn handle_timeline_event(
|
||||
&from,
|
||||
&body,
|
||||
is_action,
|
||||
"message",
|
||||
// A bot's announcement is drawn more quietly than a person's
|
||||
// sentence, which is the whole reason m.notice exists.
|
||||
protocol::message_kind(&content),
|
||||
reply_to,
|
||||
Some(event_id.to_string()),
|
||||
mentioned,
|
||||
@@ -423,7 +432,7 @@ pub(super) async fn store_thread_event(
|
||||
ts,
|
||||
is_action,
|
||||
false,
|
||||
"message",
|
||||
protocol::message_kind(content),
|
||||
reply_to.as_ref(),
|
||||
&[],
|
||||
sender_mxid == own_user_id,
|
||||
@@ -501,7 +510,7 @@ pub(super) async fn store_history_event(
|
||||
// thread read back from the server arrived as loose messages.
|
||||
let reply_to = relation_preview(state, buffer_id, content);
|
||||
if let Err(e) = state.store.append_message(
|
||||
buffer_id, event_id, &from, &body, ts, is_action, false, "message", reply_to.as_ref(), &[], is_own,
|
||||
buffer_id, event_id, &from, &body, ts, is_action, false, protocol::message_kind(content), reply_to.as_ref(), &[], is_own,
|
||||
avatar.as_deref(), &[], &[], Some(sender_mxid), html.as_deref(),
|
||||
&state.runtime.buffer_kind_of(buffer_id),
|
||||
// Matrix has no per-sender colour or badges of its own.
|
||||
|
||||
Reference in New Issue
Block a user