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:
2026-09-13 13:07:21 -04:00
co-authored by Claude Opus 5
parent 276bbb0ca6
commit 7d2a04a740
3 changed files with 88 additions and 7 deletions
+40
View File
@@ -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!({})));
}
}
+32
View File
@@ -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!({
+16 -7
View File
@@ -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.