Skip to content

Commit 2a51f3e

Browse files
authored
Implement MSC3952: Intentional mentions (matrix-org#14823)
MSC3952 defines push rules which searches for mentions in a list of Matrix IDs in the event body, instead of searching the entire event body for display name / local part. This is implemented behind an experimental configuration flag and does not yet implement the backwards compatibility pieces of the MSC.
1 parent faecc6c commit 2a51f3e

File tree

11 files changed

+263
-11
lines changed

11 files changed

+263
-11
lines changed

changelog.d/14823.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Experimental support for [MSC3952](https://github.com/matrix-org/matrix-spec-proposals/pull/3952): intentional mentions.

rust/src/push/base_rules.rs

+21
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,14 @@ pub const BASE_APPEND_OVERRIDE_RULES: &[PushRule] = &[
131131
default: true,
132132
default_enabled: true,
133133
},
134+
PushRule {
135+
rule_id: Cow::Borrowed(".org.matrix.msc3952.is_user_mentioned"),
136+
priority_class: 5,
137+
conditions: Cow::Borrowed(&[Condition::Known(KnownCondition::IsUserMention)]),
138+
actions: Cow::Borrowed(&[Action::Notify, HIGHLIGHT_ACTION, SOUND_ACTION]),
139+
default: true,
140+
default_enabled: true,
141+
},
134142
PushRule {
135143
rule_id: Cow::Borrowed("global/override/.m.rule.contains_display_name"),
136144
priority_class: 5,
@@ -139,6 +147,19 @@ pub const BASE_APPEND_OVERRIDE_RULES: &[PushRule] = &[
139147
default: true,
140148
default_enabled: true,
141149
},
150+
PushRule {
151+
rule_id: Cow::Borrowed(".org.matrix.msc3952.is_room_mentioned"),
152+
priority_class: 5,
153+
conditions: Cow::Borrowed(&[
154+
Condition::Known(KnownCondition::IsRoomMention),
155+
Condition::Known(KnownCondition::SenderNotificationPermission {
156+
key: Cow::Borrowed("room"),
157+
}),
158+
]),
159+
actions: Cow::Borrowed(&[Action::Notify, HIGHLIGHT_ACTION, SOUND_ACTION]),
160+
default: true,
161+
default_enabled: true,
162+
},
142163
PushRule {
143164
rule_id: Cow::Borrowed("global/override/.m.rule.roomnotif"),
144165
priority_class: 5,

rust/src/push/evaluator.rs

+23-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
use std::collections::BTreeMap;
15+
use std::collections::{BTreeMap, BTreeSet};
1616

1717
use anyhow::{Context, Error};
1818
use lazy_static::lazy_static;
@@ -68,6 +68,11 @@ pub struct PushRuleEvaluator {
6868
/// The "content.body", if any.
6969
body: String,
7070

71+
/// The user mentions that were part of the message.
72+
user_mentions: BTreeSet<String>,
73+
/// True if the message is a room message.
74+
room_mention: bool,
75+
7176
/// The number of users in the room.
7277
room_member_count: u64,
7378

@@ -100,6 +105,8 @@ impl PushRuleEvaluator {
100105
#[new]
101106
pub fn py_new(
102107
flattened_keys: BTreeMap<String, String>,
108+
user_mentions: BTreeSet<String>,
109+
room_mention: bool,
103110
room_member_count: u64,
104111
sender_power_level: Option<i64>,
105112
notification_power_levels: BTreeMap<String, i64>,
@@ -116,6 +123,8 @@ impl PushRuleEvaluator {
116123
Ok(PushRuleEvaluator {
117124
flattened_keys,
118125
body,
126+
user_mentions,
127+
room_mention,
119128
room_member_count,
120129
notification_power_levels,
121130
sender_power_level,
@@ -229,6 +238,14 @@ impl PushRuleEvaluator {
229238
KnownCondition::RelatedEventMatch(event_match) => {
230239
self.match_related_event_match(event_match, user_id)?
231240
}
241+
KnownCondition::IsUserMention => {
242+
if let Some(uid) = user_id {
243+
self.user_mentions.contains(uid)
244+
} else {
245+
false
246+
}
247+
}
248+
KnownCondition::IsRoomMention => self.room_mention,
232249
KnownCondition::ContainsDisplayName => {
233250
if let Some(dn) = display_name {
234251
if !dn.is_empty() {
@@ -424,6 +441,8 @@ fn push_rule_evaluator() {
424441
flattened_keys.insert("content.body".to_string(), "foo bar bob hello".to_string());
425442
let evaluator = PushRuleEvaluator::py_new(
426443
flattened_keys,
444+
BTreeSet::new(),
445+
false,
427446
10,
428447
Some(0),
429448
BTreeMap::new(),
@@ -449,6 +468,8 @@ fn test_requires_room_version_supports_condition() {
449468
let flags = vec![RoomVersionFeatures::ExtensibleEvents.as_str().to_string()];
450469
let evaluator = PushRuleEvaluator::py_new(
451470
flattened_keys,
471+
BTreeSet::new(),
472+
false,
452473
10,
453474
Some(0),
454475
BTreeMap::new(),
@@ -483,7 +504,7 @@ fn test_requires_room_version_supports_condition() {
483504
};
484505
let rules = PushRules::new(vec![custom_rule]);
485506
result = evaluator.run(
486-
&FilteredPushRules::py_new(rules, BTreeMap::new(), true, false, true),
507+
&FilteredPushRules::py_new(rules, BTreeMap::new(), true, false, true, false),
487508
None,
488509
None,
489510
);

rust/src/push/mod.rs

+34
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,10 @@ pub enum KnownCondition {
269269
EventMatch(EventMatchCondition),
270270
#[serde(rename = "im.nheko.msc3664.related_event_match")]
271271
RelatedEventMatch(RelatedEventMatchCondition),
272+
#[serde(rename = "org.matrix.msc3952.is_user_mention")]
273+
IsUserMention,
274+
#[serde(rename = "org.matrix.msc3952.is_room_mention")]
275+
IsRoomMention,
272276
ContainsDisplayName,
273277
RoomMemberCount {
274278
#[serde(skip_serializing_if = "Option::is_none")]
@@ -414,6 +418,7 @@ pub struct FilteredPushRules {
414418
msc1767_enabled: bool,
415419
msc3381_polls_enabled: bool,
416420
msc3664_enabled: bool,
421+
msc3952_intentional_mentions: bool,
417422
}
418423

419424
#[pymethods]
@@ -425,13 +430,15 @@ impl FilteredPushRules {
425430
msc1767_enabled: bool,
426431
msc3381_polls_enabled: bool,
427432
msc3664_enabled: bool,
433+
msc3952_intentional_mentions: bool,
428434
) -> Self {
429435
Self {
430436
push_rules,
431437
enabled_map,
432438
msc1767_enabled,
433439
msc3381_polls_enabled,
434440
msc3664_enabled,
441+
msc3952_intentional_mentions,
435442
}
436443
}
437444

@@ -465,6 +472,11 @@ impl FilteredPushRules {
465472
return false;
466473
}
467474

475+
if !self.msc3952_intentional_mentions && rule.rule_id.contains("org.matrix.msc3952")
476+
{
477+
return false;
478+
}
479+
468480
true
469481
})
470482
.map(|r| {
@@ -522,6 +534,28 @@ fn test_deserialize_unstable_msc3931_condition() {
522534
));
523535
}
524536

537+
#[test]
538+
fn test_deserialize_unstable_msc3952_user_condition() {
539+
let json = r#"{"kind":"org.matrix.msc3952.is_user_mention"}"#;
540+
541+
let condition: Condition = serde_json::from_str(json).unwrap();
542+
assert!(matches!(
543+
condition,
544+
Condition::Known(KnownCondition::IsUserMention)
545+
));
546+
}
547+
548+
#[test]
549+
fn test_deserialize_unstable_msc3952_room_condition() {
550+
let json = r#"{"kind":"org.matrix.msc3952.is_room_mention"}"#;
551+
552+
let condition: Condition = serde_json::from_str(json).unwrap();
553+
assert!(matches!(
554+
condition,
555+
Condition::Known(KnownCondition::IsRoomMention)
556+
));
557+
}
558+
525559
#[test]
526560
fn test_deserialize_custom_condition() {
527561
let json = r#"{"kind":"custom_tag"}"#;

stubs/synapse/synapse_rust/push.pyi

+4-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from typing import Any, Collection, Dict, Mapping, Optional, Sequence, Tuple, Union
15+
from typing import Any, Collection, Dict, Mapping, Optional, Sequence, Set, Tuple, Union
1616

1717
from synapse.types import JsonDict
1818

@@ -46,6 +46,7 @@ class FilteredPushRules:
4646
msc1767_enabled: bool,
4747
msc3381_polls_enabled: bool,
4848
msc3664_enabled: bool,
49+
msc3952_intentional_mentions: bool,
4950
): ...
5051
def rules(self) -> Collection[Tuple[PushRule, bool]]: ...
5152

@@ -55,6 +56,8 @@ class PushRuleEvaluator:
5556
def __init__(
5657
self,
5758
flattened_keys: Mapping[str, str],
59+
user_mentions: Set[str],
60+
room_mention: bool,
5861
room_member_count: int,
5962
sender_power_level: Optional[int],
6063
notification_power_levels: Mapping[str, int],

synapse/api/constants.py

+3
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,9 @@ class EventContentFields:
233233
# The authorising user for joining a restricted room.
234234
AUTHORISING_USER: Final = "join_authorised_via_users_server"
235235

236+
# Use for mentioning users.
237+
MSC3952_MENTIONS: Final = "org.matrix.msc3952.mentions"
238+
236239
# an unspecced field added to to-device messages to identify them uniquely-ish
237240
TO_DEVICE_MSGID: Final = "org.matrix.msgid"
238241

synapse/config/experimental.py

+5
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,8 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
168168

169169
# MSC3925: do not replace events with their edits
170170
self.msc3925_inhibit_edit = experimental.get("msc3925_inhibit_edit", False)
171+
172+
# MSC3952: Intentional mentions
173+
self.msc3952_intentional_mentions = experimental.get(
174+
"msc3952_intentional_mentions", False
175+
)

synapse/push/bulk_push_rule_evaluator.py

+24-1
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,20 @@
2222
List,
2323
Mapping,
2424
Optional,
25+
Set,
2526
Tuple,
2627
Union,
2728
)
2829

2930
from prometheus_client import Counter
3031

31-
from synapse.api.constants import MAIN_TIMELINE, EventTypes, Membership, RelationTypes
32+
from synapse.api.constants import (
33+
MAIN_TIMELINE,
34+
EventContentFields,
35+
EventTypes,
36+
Membership,
37+
RelationTypes,
38+
)
3239
from synapse.api.room_versions import PushRuleRoomFlag, RoomVersion
3340
from synapse.event_auth import auth_types_for_event, get_user_power_level
3441
from synapse.events import EventBase, relation_from_event
@@ -342,8 +349,24 @@ async def _action_for_event_by_user(
342349
for user_id, level in notification_levels.items():
343350
notification_levels[user_id] = int(level)
344351

352+
# Pull out any user and room mentions.
353+
mentions = event.content.get(EventContentFields.MSC3952_MENTIONS)
354+
user_mentions: Set[str] = set()
355+
room_mention = False
356+
if isinstance(mentions, dict):
357+
# Remove out any non-string items and convert to a set.
358+
user_mentions_raw = mentions.get("user_ids")
359+
if isinstance(user_mentions_raw, list):
360+
user_mentions = set(
361+
filter(lambda item: isinstance(item, str), user_mentions_raw)
362+
)
363+
# Room mention is only true if the value is exactly true.
364+
room_mention = mentions.get("room") is True
365+
345366
evaluator = PushRuleEvaluator(
346367
_flatten_dict(event, room_version=event.room_version),
368+
user_mentions,
369+
room_mention,
347370
room_member_count,
348371
sender_power_level,
349372
notification_levels,

synapse/storage/databases/main/push_rule.py

+1
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def _load_rules(
8989
msc1767_enabled=experimental_config.msc1767_enabled,
9090
msc3664_enabled=experimental_config.msc3664_enabled,
9191
msc3381_polls_enabled=experimental_config.msc3381_polls_enabled,
92+
msc3952_intentional_mentions=experimental_config.msc3952_intentional_mentions,
9293
)
9394

9495
return filtered_rules

tests/push/test_bulk_push_rule_evaluator.py

+88
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from typing import Any
1516
from unittest.mock import patch
1617

1718
from twisted.test.proto_helpers import MemoryReactor
1819

20+
from synapse.api.constants import EventContentFields
1921
from synapse.api.room_versions import RoomVersions
2022
from synapse.push.bulk_push_rule_evaluator import BulkPushRuleEvaluator
2123
from synapse.rest import admin
@@ -126,3 +128,89 @@ def test_action_for_event_by_user_disabled_by_config(self) -> None:
126128
# Ensure no actions are generated!
127129
self.get_success(bulk_evaluator.action_for_events_by_user([(event, context)]))
128130
bulk_evaluator._action_for_event_by_user.assert_not_called()
131+
132+
@override_config({"experimental_features": {"msc3952_intentional_mentions": True}})
133+
def test_mentions(self) -> None:
134+
"""Test the behavior of an event which includes invalid mentions."""
135+
bulk_evaluator = BulkPushRuleEvaluator(self.hs)
136+
137+
sentinel = object()
138+
139+
def create_and_process(mentions: Any = sentinel) -> bool:
140+
"""Returns true iff the `mentions` trigger an event push action."""
141+
content = {}
142+
if mentions is not sentinel:
143+
content[EventContentFields.MSC3952_MENTIONS] = mentions
144+
145+
# Create a new message event which should cause a notification.
146+
event, context = self.get_success(
147+
self.event_creation_handler.create_event(
148+
self.requester,
149+
{
150+
"type": "test",
151+
"room_id": self.room_id,
152+
"content": content,
153+
"sender": f"@bob:{self.hs.hostname}",
154+
},
155+
)
156+
)
157+
158+
# Ensure no actions are generated!
159+
self.get_success(
160+
bulk_evaluator.action_for_events_by_user([(event, context)])
161+
)
162+
163+
# If any actions are generated for this event, return true.
164+
result = self.get_success(
165+
self.hs.get_datastores().main.db_pool.simple_select_list(
166+
table="event_push_actions_staging",
167+
keyvalues={"event_id": event.event_id},
168+
retcols=("*",),
169+
desc="get_event_push_actions_staging",
170+
)
171+
)
172+
return len(result) > 0
173+
174+
# Not including the mentions field should not notify.
175+
self.assertFalse(create_and_process())
176+
# An empty mentions field should not notify.
177+
self.assertFalse(create_and_process({}))
178+
179+
# Non-dict mentions should be ignored.
180+
mentions: Any
181+
for mentions in (None, True, False, 1, "foo", []):
182+
self.assertFalse(create_and_process(mentions))
183+
184+
# A non-list should be ignored.
185+
for mentions in (None, True, False, 1, "foo", {}):
186+
self.assertFalse(create_and_process({"user_ids": mentions}))
187+
188+
# The Matrix ID appearing anywhere in the list should notify.
189+
self.assertTrue(create_and_process({"user_ids": [self.alice]}))
190+
self.assertTrue(create_and_process({"user_ids": ["@another:test", self.alice]}))
191+
192+
# Duplicate user IDs should notify.
193+
self.assertTrue(create_and_process({"user_ids": [self.alice, self.alice]}))
194+
195+
# Invalid entries in the list are ignored.
196+
self.assertFalse(create_and_process({"user_ids": [None, True, False, {}, []]}))
197+
self.assertTrue(
198+
create_and_process({"user_ids": [None, True, False, {}, [], self.alice]})
199+
)
200+
201+
# Room mentions from those without power should not notify.
202+
self.assertFalse(create_and_process({"room": True}))
203+
204+
# Room mentions from those with power should notify.
205+
self.helper.send_state(
206+
self.room_id,
207+
"m.room.power_levels",
208+
{"notifications": {"room": 0}},
209+
self.token,
210+
state_key="",
211+
)
212+
self.assertTrue(create_and_process({"room": True}))
213+
214+
# Invalid data should not notify.
215+
for mentions in (None, False, 1, "foo", [], {}):
216+
self.assertFalse(create_and_process({"room": mentions}))

0 commit comments

Comments
 (0)