diff --git a/crates/ruma-common/CHANGELOG.md b/crates/ruma-common/CHANGELOG.md index 5a28a2da..af87dd9d 100644 --- a/crates/ruma-common/CHANGELOG.md +++ b/crates/ruma-common/CHANGELOG.md @@ -42,6 +42,7 @@ Improvements: - Add `MessageType::sanitize` behind the `unstable-sanitize` feature - Add `MatrixVersion::V1_7` - Stabilize support for annotations and reactions (MSC2677 / Matrix 1.7) +- Add support for intentional mentions push rules (MSC3952 / Matrix 1.7) # 0.11.3 diff --git a/crates/ruma-common/src/push.rs b/crates/ruma-common/src/push.rs index f0222e98..79396dd4 100644 --- a/crates/ruma-common/src/push.rs +++ b/crates/ruma-common/src/push.rs @@ -484,6 +484,7 @@ impl ConditionalPushRule { #[cfg(feature = "unstable-msc3932")] { // These 3 rules always apply. + #[allow(deprecated)] if self.rule_id != PredefinedOverrideRuleId::Master.as_ref() && self.rule_id != PredefinedOverrideRuleId::RoomNotif.as_ref() && self.rule_id != PredefinedOverrideRuleId::ContainsDisplayName.as_ref() @@ -503,6 +504,15 @@ impl ConditionalPushRule { } } + // The old mention rules are disabled when an m.mentions field is present. + #[allow(deprecated)] + if (self.rule_id == PredefinedOverrideRuleId::RoomNotif.as_ref() + || self.rule_id == PredefinedOverrideRuleId::ContainsDisplayName.as_ref()) + && event.contains_mentions() + { + return false; + } + self.conditions.iter().all(|cond| cond.applies(event, context)) } } @@ -601,6 +611,14 @@ impl PatternedPushRule { event: &FlattenedJson, context: &PushConditionRoomCtx, ) -> bool { + // The old mention rules are disabled when an m.mentions field is present. + #[allow(deprecated)] + if self.rule_id == PredefinedContentRuleId::ContainsUserName.as_ref() + && event.contains_mentions() + { + return false; + } + if event.get_str("sender").map_or(false, |sender| sender == context.user_id) { return false; } @@ -971,7 +989,13 @@ mod tests { condition::{PushCondition, PushConditionRoomCtx, RoomMemberCountIs}, AnyPushRule, ConditionalPushRule, PatternedPushRule, Ruleset, SimplePushRule, }; - use crate::{power_levels::NotificationPowerLevels, room_id, serde::Raw, user_id}; + use crate::{ + power_levels::NotificationPowerLevels, + push::{PredefinedContentRuleId, PredefinedOverrideRuleId}, + room_id, + serde::Raw, + user_id, + }; fn example_ruleset() -> Ruleset { let mut set = Ruleset::new(); @@ -1652,4 +1676,172 @@ mod tests { ); assert_eq!(sound, "three"); } + + #[test] + #[allow(deprecated)] + fn old_mentions_apply() { + let set = Ruleset::server_default(user_id!("@jolly_jumper:server.name")); + + let context = &PushConditionRoomCtx { + room_id: room_id!("!far_west:server.name").to_owned(), + member_count: uint!(100), + user_id: user_id!("@jj:server.name").to_owned(), + user_display_name: "Jolly Jumper".into(), + users_power_levels: BTreeMap::new(), + default_power_level: int!(50), + notification_power_levels: NotificationPowerLevels { room: int!(50) }, + #[cfg(feature = "unstable-msc3931")] + supported_features: Default::default(), + }; + + let message = serde_json::from_str::>( + r#"{ + "content": { + "body": "jolly_jumper" + }, + "type": "m.room.message" + }"#, + ) + .unwrap(); + + assert_eq!( + set.get_match(&message, context).unwrap().rule_id(), + PredefinedContentRuleId::ContainsUserName.as_ref() + ); + + let message = serde_json::from_str::>( + r#"{ + "content": { + "body": "jolly_jumper", + "m.mentions": {} + }, + "type": "m.room.message" + }"#, + ) + .unwrap(); + + assert_ne!( + set.get_match(&message, context).unwrap().rule_id(), + PredefinedContentRuleId::ContainsUserName.as_ref() + ); + + let message = serde_json::from_str::>( + r#"{ + "content": { + "body": "Jolly Jumper" + }, + "type": "m.room.message" + }"#, + ) + .unwrap(); + + assert_eq!( + set.get_match(&message, context).unwrap().rule_id(), + PredefinedOverrideRuleId::ContainsDisplayName.as_ref() + ); + + let message = serde_json::from_str::>( + r#"{ + "content": { + "body": "Jolly Jumper", + "m.mentions": {} + }, + "type": "m.room.message" + }"#, + ) + .unwrap(); + + assert_ne!( + set.get_match(&message, context).unwrap().rule_id(), + PredefinedOverrideRuleId::ContainsDisplayName.as_ref() + ); + + let message = serde_json::from_str::>( + r#"{ + "content": { + "body": "@room" + }, + "sender": "@admin:server.name", + "type": "m.room.message" + }"#, + ) + .unwrap(); + + assert_eq!( + set.get_match(&message, context).unwrap().rule_id(), + PredefinedOverrideRuleId::RoomNotif.as_ref() + ); + + let message = serde_json::from_str::>( + r#"{ + "content": { + "body": "@room", + "m.mentions": {} + }, + "sender": "@admin:server.name", + "type": "m.room.message" + }"#, + ) + .unwrap(); + + assert_ne!( + set.get_match(&message, context).unwrap().rule_id(), + PredefinedOverrideRuleId::RoomNotif.as_ref() + ); + } + + #[test] + fn intentional_mentions_apply() { + let set = Ruleset::server_default(user_id!("@jolly_jumper:server.name")); + + let context = &PushConditionRoomCtx { + room_id: room_id!("!far_west:server.name").to_owned(), + member_count: uint!(100), + user_id: user_id!("@jj:server.name").to_owned(), + user_display_name: "Jolly Jumper".into(), + users_power_levels: BTreeMap::new(), + default_power_level: int!(50), + notification_power_levels: NotificationPowerLevels { room: int!(50) }, + #[cfg(feature = "unstable-msc3931")] + supported_features: Default::default(), + }; + + let message = serde_json::from_str::>( + r#"{ + "content": { + "body": "Hey jolly_jumper!", + "m.mentions": { + "user_ids": ["@jolly_jumper:server.name"] + } + }, + "sender": "@admin:server.name", + "type": "m.room.message" + }"#, + ) + .unwrap(); + + assert_eq!( + set.get_match(&message, context).unwrap().rule_id(), + PredefinedOverrideRuleId::IsUserMention.as_ref() + ); + + let message = serde_json::from_str::>( + r#"{ + "content": { + "body": "Listen room!", + "m.mentions": { + "room": true + } + }, + "sender": "@admin:server.name", + "type": "m.room.message" + }"#, + ) + .unwrap(); + + assert_eq!( + set.get_match(&message, context).unwrap().rule_id(), + PredefinedOverrideRuleId::IsRoomMention.as_ref() + ); + } } diff --git a/crates/ruma-common/src/push/condition/flattened_json.rs b/crates/ruma-common/src/push/condition/flattened_json.rs index 676afa95..f4476c7b 100644 --- a/crates/ruma-common/src/push/condition/flattened_json.rs +++ b/crates/ruma-common/src/push/condition/flattened_json.rs @@ -58,6 +58,13 @@ impl FlattenedJson { pub fn get_str(&self, path: &str) -> Option<&str> { self.map.get(path).and_then(|v| v.as_str()) } + + /// Whether this flattened JSON contains an `m.mentions` property under the `content` property. + pub fn contains_mentions(&self) -> bool { + self.map + .keys() + .any(|s| s == r"content.m\.mentions" || s.starts_with(r"content.m\.mentions.")) + } } /// Escape a key for path matching. @@ -382,4 +389,48 @@ mod tests { }, ); } + + #[test] + fn contains_mentions() { + let raw = serde_json::from_str::>( + r#"{ + "m.mentions": {}, + "content": { + "body": "Text" + } + }"#, + ) + .unwrap(); + + let flattened = FlattenedJson::from_raw(&raw); + assert!(!flattened.contains_mentions()); + + let raw = serde_json::from_str::>( + r#"{ + "content": { + "body": "Text", + "m.mentions": {} + } + }"#, + ) + .unwrap(); + + let flattened = FlattenedJson::from_raw(&raw); + assert!(flattened.contains_mentions()); + + let raw = serde_json::from_str::>( + r#"{ + "content": { + "body": "Text", + "m.mentions": { + "room": true + } + } + }"#, + ) + .unwrap(); + + let flattened = FlattenedJson::from_raw(&raw); + assert!(flattened.contains_mentions()); + } } diff --git a/crates/ruma-common/src/push/predefined.rs b/crates/ruma-common/src/push/predefined.rs index 3e2e79eb..547ce948 100644 --- a/crates/ruma-common/src/push/predefined.rs +++ b/crates/ruma-common/src/push/predefined.rs @@ -21,13 +21,21 @@ impl Ruleset { /// [predefined push rules]: https://spec.matrix.org/latest/client-server-api/#predefined-rules pub fn server_default(user_id: &UserId) -> Self { Self { - content: [PatternedPushRule::contains_user_name(user_id)].into(), + content: [ + #[allow(deprecated)] + PatternedPushRule::contains_user_name(user_id), + ] + .into(), override_: [ ConditionalPushRule::master(), ConditionalPushRule::suppress_notices(), ConditionalPushRule::invite_for_me(user_id), ConditionalPushRule::member_event(), + ConditionalPushRule::is_user_mention(user_id), + #[allow(deprecated)] ConditionalPushRule::contains_display_name(), + ConditionalPushRule::is_room_mention(), + #[allow(deprecated)] ConditionalPushRule::roomnotif(), ConditionalPushRule::tombstone(), ConditionalPushRule::reaction(), @@ -179,9 +187,33 @@ impl ConditionalPushRule { } } + /// Matches any message which contains the user’s Matrix ID in the list of `user_ids` under the + /// `m.mentions` property. + pub fn is_user_mention(user_id: &UserId) -> Self { + Self { + actions: vec![ + Notify, + SetTweak(Tweak::Sound("default".to_owned())), + SetTweak(Tweak::Highlight(true)), + ], + default: true, + enabled: true, + rule_id: PredefinedOverrideRuleId::IsUserMention.to_string(), + conditions: vec![EventPropertyContains { + key: r"content.m\.mentions.user_ids".to_owned(), + value: user_id.as_str().into(), + }], + } + } + /// Matches any message whose content is unencrypted and contains the user's current display /// name in the room in which it was sent. + /// + /// Since Matrix 1.7, this rule only matches if the event's content does not contain an + /// `m.mentions` property. + #[deprecated = "Since Matrix 1.7. Use the m.mentions property with ConditionalPushRule::is_user_mention() instead."] pub fn contains_display_name() -> Self { + #[allow(deprecated)] Self { actions: vec![ Notify, @@ -211,9 +243,29 @@ impl ConditionalPushRule { } } + /// Matches any message from a sender with the proper power level with the `room` property of + /// the `m.mentions` property set to `true`. + pub fn is_room_mention() -> Self { + Self { + actions: vec![Notify, SetTweak(Tweak::Highlight(true))], + default: true, + enabled: true, + rule_id: PredefinedOverrideRuleId::IsRoomMention.to_string(), + conditions: vec![ + EventPropertyIs { key: r"content.m\.mentions.room".to_owned(), value: true.into() }, + SenderNotificationPermission { key: "room".to_owned() }, + ], + } + } + /// Matches any message whose content is unencrypted and contains the text `@room`, signifying /// the whole room should be notified of the event. + /// + /// Since Matrix 1.7, this rule only matches if the event's content does not contain an + /// `m.mentions` property. + #[deprecated = "Since Matrix 1.7. Use the m.mentions property with ConditionalPushRule::is_room_mention() instead."] pub fn roomnotif() -> Self { + #[allow(deprecated)] Self { actions: vec![Notify, SetTweak(Tweak::Highlight(true))], default: true, @@ -260,7 +312,12 @@ impl ConditionalPushRule { impl PatternedPushRule { /// Matches any message whose content is unencrypted and contains the local part of the user's /// Matrix ID, separated by word boundaries. + /// + /// Since Matrix 1.7, this rule only matches if the event's content does not contain an + /// `m.mentions` property. + #[deprecated = "Since Matrix 1.7. Use the m.mentions property with ConditionalPushRule::is_user_mention() instead."] pub fn contains_user_name(user_id: &UserId) -> Self { + #[allow(deprecated)] Self { rule_id: PredefinedContentRuleId::ContainsUserName.to_string(), enabled: true, @@ -453,11 +510,19 @@ pub enum PredefinedOverrideRuleId { /// `.m.rule.member_event` MemberEvent, + /// `.m.rule.is_user_mention` + IsUserMention, + /// `.m.rule.contains_display_name` + #[deprecated = "Since Matrix 1.7. Use the m.mentions property with PredefinedOverrideRuleId::IsUserMention instead."] ContainsDisplayName, + /// `.m.rule.is_room_mention` + IsRoomMention, + /// `.m.rule.roomnotif` #[ruma_enum(rename = ".m.rule.roomnotif")] + #[deprecated = "Since Matrix 1.7. Use the m.mentions property with PredefinedOverrideRuleId::IsRoomMention instead."] RoomNotif, /// `.m.rule.tombstone` @@ -522,6 +587,7 @@ pub enum PredefinedUnderrideRuleId { #[non_exhaustive] pub enum PredefinedContentRuleId { /// `.m.rule.contains_user_name` + #[deprecated = "Since Matrix 1.7. Use the m.mentions property with PredefinedOverrideRuleId::IsUserMention instead."] ContainsUserName, #[doc(hidden)]