From 43bdb619b7ac76d0fa6efbb6f5ffc94b09af49e6 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 14 Jul 2026 16:37:13 +0100 Subject: [PATCH] Add the default `.m.rule.knock` push rule (MSC4506) `.m.rule.member_event` suppresses every `m.room.member` event, so a knock (MSC2403) pushes nobody today. The new override rule `.org.matrix.msc4506.rule.knock` matches a knock membership event and the `recipient_permission` condition on `invite`, and notifies with a sound. It sits after `.m.rule.invite_for_me` and before `.m.rule.member_event`. A knock has sender == state_key, so the bulk evaluator's member-event fast path finds nobody to evaluate rules for. With the flag on, a knock now evaluates rules for every local member, at the cost of a message in the same room. Blast radius: the rule pushes every member at or above the room's `invite` level. Synapse creates rooms with `invite: 50`, but a room that omits the key defaults to 0, as do the `private_chat` and `trusted_private_chat` presets, and there every member gets pushed for every knock. `filter_push_rules` drops the rule while `msc4506_enabled` is off, so `/pushrules` serves it only with the flag on. The code departs from the MSC text in three places, to raise as MSC follow-ups. It uses `event_match` where the MSC uses `event_property_is`, matching `.m.rule.invite_for_me`. The MSC escapes the value as `m\.room\.member`, which `event_property_is` compares literally, so that condition never fires. The actions add `highlight: false`, as every other default notify rule does. Co-authored-by: Quentin Gliech Co-Authored-By: Claude Fable 5.1 --- changelog.d/20443.feature | 1 + rust/benches/evaluator.rs | 1 + rust/src/push/base_rules.rs | 25 +++++++++++++++++++++ rust/src/push/evaluator.rs | 1 + rust/src/push/mod.rs | 8 +++++++ synapse/push/bulk_push_rule_evaluator.py | 9 ++++++++ synapse/storage/databases/main/push_rule.py | 1 + synapse/synapse_rust/push.pyi | 1 + 8 files changed, 47 insertions(+) create mode 100644 changelog.d/20443.feature diff --git a/changelog.d/20443.feature b/changelog.d/20443.feature new file mode 100644 index 0000000000..fe776af627 --- /dev/null +++ b/changelog.d/20443.feature @@ -0,0 +1 @@ +Implement experimental support for [MSC4506](https://github.com/matrix-org/matrix-spec-proposals/pull/4506): notify relevant users if there's a knock on a room. diff --git a/rust/benches/evaluator.rs b/rust/benches/evaluator.rs index 4405932750..b9e66779a7 100644 --- a/rust/benches/evaluator.rs +++ b/rust/benches/evaluator.rs @@ -235,6 +235,7 @@ fn bench_eval_message(b: &mut Bencher) { false, false, false, + false, ); b.iter(|| eval.run(&rules, Some("bob"), Some("person"), None, None)); diff --git a/rust/src/push/base_rules.rs b/rust/src/push/base_rules.rs index fbc985a2f0..a458bcf053 100644 --- a/rust/src/push/base_rules.rs +++ b/rust/src/push/base_rules.rs @@ -117,6 +117,30 @@ pub const BASE_APPEND_OVERRIDE_RULES: &[PushRule] = &[ default: true, default_enabled: true, }, + // MSC4506: notify the members of a room who are able to act on a knock + // (i.e. those with a power level sufficient to invite the knocker). Must + // come before `.m.rule.member_event`, which suppresses all other member + // events. + PushRule { + rule_id: Cow::Borrowed("global/override/.org.matrix.msc4506.rule.knock"), + priority_class: 5, + conditions: Cow::Borrowed(&[ + Condition::Known(KnownCondition::EventMatch(EventMatchCondition { + key: Cow::Borrowed("type"), + pattern: Cow::Borrowed("m.room.member"), + })), + Condition::Known(KnownCondition::EventMatch(EventMatchCondition { + key: Cow::Borrowed("content.membership"), + pattern: Cow::Borrowed("knock"), + })), + Condition::Known(KnownCondition::RecipientPermission { + key: Cow::Borrowed("invite"), + }), + ]), + actions: Cow::Borrowed(&[Action::Notify, HIGHLIGHT_FALSE_ACTION, SOUND_ACTION]), + default: true, + default_enabled: true, + }, PushRule { rule_id: Cow::Borrowed("global/override/.m.rule.member_event"), priority_class: 5, @@ -811,6 +835,7 @@ mod tests { msc4075_enabled, false, // msc4210_enabled false, // msc4306_enabled + false, // msc4506_enabled ) } diff --git a/rust/src/push/evaluator.rs b/rust/src/push/evaluator.rs index 63e149315f..9babc0b0d0 100644 --- a/rust/src/push/evaluator.rs +++ b/rust/src/push/evaluator.rs @@ -730,6 +730,7 @@ fn test_requires_room_version_supports_condition() { false, false, false, + false, ), None, None, diff --git a/rust/src/push/mod.rs b/rust/src/push/mod.rs index 480b1ccf40..c972da91c6 100644 --- a/rust/src/push/mod.rs +++ b/rust/src/push/mod.rs @@ -561,6 +561,7 @@ pub struct FilteredPushRules { msc4075_enabled: bool, msc4210_enabled: bool, msc4306_enabled: bool, + msc4506_enabled: bool, } #[pymethods] @@ -577,6 +578,7 @@ impl FilteredPushRules { msc4075_enabled: bool, msc4210_enabled: bool, msc4306_enabled: bool, + msc4506_enabled: bool, ) -> Self { Self { push_rules, @@ -588,6 +590,7 @@ impl FilteredPushRules { msc4075_enabled, msc4210_enabled, msc4306_enabled, + msc4506_enabled, } } @@ -624,6 +627,11 @@ impl FilteredPushRules { return false; } + if !self.msc4506_enabled && rule.rule_id.contains("/.org.matrix.msc4506.rule.knock") + { + return false; + } + if !self.msc4028_push_encrypted_events && rule.rule_id == "global/override/.org.matrix.msc4028.encrypted_event" { diff --git a/synapse/push/bulk_push_rule_evaluator.py b/synapse/push/bulk_push_rule_evaluator.py index a880ab0faa..7b5341fe0c 100644 --- a/synapse/push/bulk_push_rule_evaluator.py +++ b/synapse/push/bulk_push_rule_evaluator.py @@ -192,6 +192,15 @@ class BulkPushRuleEvaluator: ) if target_already_in_room: local_users = [event.state_key] + elif ( + self.hs.config.experimental.msc4506_enabled + and event.membership == Membership.KNOCK + ): + # MSC4506: a knock (sender == state_key, so not caught above) + # should notify the members of the room who can act on it, so + # we can't take the membership-events-only-notify-their-target + # fast path. + local_users = await self.store.get_local_users_in_room(event.room_id) else: # We get the users who may need to be notified by first fetching the # local users currently in the room, finding those that have push rules, diff --git a/synapse/storage/databases/main/push_rule.py b/synapse/storage/databases/main/push_rule.py index b65f5e8bfa..7a11483cf4 100644 --- a/synapse/storage/databases/main/push_rule.py +++ b/synapse/storage/databases/main/push_rule.py @@ -128,6 +128,7 @@ def filter_push_rules( msc4075_enabled=experimental_config.msc4075_enabled, msc4210_enabled=experimental_config.msc4210_enabled, msc4306_enabled=experimental_config.msc4306_enabled, + msc4506_enabled=experimental_config.msc4506_enabled, ) diff --git a/synapse/synapse_rust/push.pyi b/synapse/synapse_rust/push.pyi index e677cf3841..80f029195d 100644 --- a/synapse/synapse_rust/push.pyi +++ b/synapse/synapse_rust/push.pyi @@ -51,6 +51,7 @@ class FilteredPushRules: msc4075_enabled: bool, msc4210_enabled: bool, msc4306_enabled: bool, + msc4506_enabled: bool, ): ... def rules(self) -> Collection[tuple[PushRule, bool]]: ...