ruma_common/push/
condition.rs

1use std::{collections::BTreeMap, ops::RangeBounds, str::FromStr};
2#[cfg(feature = "unstable-msc4306")]
3use std::{future::Future, pin::Pin, sync::Arc};
4
5use js_int::{Int, UInt};
6use regex::bytes::Regex;
7#[cfg(feature = "unstable-msc3931")]
8use ruma_macros::StringEnum;
9use serde::{Deserialize, Serialize};
10use serde_json::value::Value as JsonValue;
11use wildmatch::WildMatch;
12
13#[cfg(feature = "unstable-msc4306")]
14use crate::EventId;
15use crate::{
16    power_levels::{NotificationPowerLevels, NotificationPowerLevelsKey},
17    room_version_rules::RoomPowerLevelsRules,
18    OwnedRoomId, OwnedUserId, UserId,
19};
20#[cfg(feature = "unstable-msc3931")]
21use crate::{PrivOwnedStr, RoomVersionId};
22
23mod flattened_json;
24mod push_condition_serde;
25mod room_member_count_is;
26
27pub use self::{
28    flattened_json::{FlattenedJson, FlattenedJsonValue, ScalarJsonValue},
29    room_member_count_is::{ComparisonOperator, RoomMemberCountIs},
30};
31
32/// Features supported by room versions.
33#[cfg(feature = "unstable-msc3931")]
34#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
35#[derive(Clone, StringEnum)]
36#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
37pub enum RoomVersionFeature {
38    /// m.extensible_events
39    ///
40    /// The room supports [extensible events].
41    ///
42    /// [extensible events]: https://github.com/matrix-org/matrix-spec-proposals/pull/1767
43    #[cfg(feature = "unstable-msc3932")]
44    #[ruma_enum(rename = "org.matrix.msc3932.extensible_events")]
45    ExtensibleEvents,
46
47    #[doc(hidden)]
48    _Custom(PrivOwnedStr),
49}
50
51#[cfg(feature = "unstable-msc3931")]
52impl RoomVersionFeature {
53    /// Get the default features for the given room version.
54    pub fn list_for_room_version(version: &RoomVersionId) -> Vec<Self> {
55        match version {
56            RoomVersionId::V1
57            | RoomVersionId::V2
58            | RoomVersionId::V3
59            | RoomVersionId::V4
60            | RoomVersionId::V5
61            | RoomVersionId::V6
62            | RoomVersionId::V7
63            | RoomVersionId::V8
64            | RoomVersionId::V9
65            | RoomVersionId::V10
66            | RoomVersionId::V11
67            | RoomVersionId::V12
68            | RoomVersionId::_Custom(_) => vec![],
69            #[cfg(feature = "unstable-msc2870")]
70            RoomVersionId::MSC2870 => vec![],
71        }
72    }
73}
74
75/// A condition that must apply for an associated push rule's action to be taken.
76#[derive(Clone, Debug)]
77#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
78pub enum PushCondition {
79    /// A glob pattern match on a field of the event.
80    EventMatch {
81        /// The [dot-separated path] of the property of the event to match.
82        ///
83        /// [dot-separated path]: https://spec.matrix.org/latest/appendices/#dot-separated-property-paths
84        key: String,
85
86        /// The glob-style pattern to match against.
87        ///
88        /// Patterns with no special glob characters should be treated as having asterisks
89        /// prepended and appended when testing the condition.
90        pattern: String,
91    },
92
93    /// Matches unencrypted messages where `content.body` contains the owner's display name in that
94    /// room.
95    #[deprecated]
96    ContainsDisplayName,
97
98    /// Matches the current number of members in the room.
99    RoomMemberCount {
100        /// The condition on the current number of members in the room.
101        is: RoomMemberCountIs,
102    },
103
104    /// Takes into account the current power levels in the room, ensuring the sender of the event
105    /// has high enough power to trigger the notification.
106    SenderNotificationPermission {
107        /// The field in the power level event the user needs a minimum power level for.
108        ///
109        /// Fields must be specified under the `notifications` property in the power level event's
110        /// `content`.
111        key: NotificationPowerLevelsKey,
112    },
113
114    /// Apply the rule only to rooms that support a given feature.
115    #[cfg(feature = "unstable-msc3931")]
116    RoomVersionSupports {
117        /// The feature the room must support for the push rule to apply.
118        feature: RoomVersionFeature,
119    },
120
121    /// Exact value match on a property of the event.
122    EventPropertyIs {
123        /// The [dot-separated path] of the property of the event to match.
124        ///
125        /// [dot-separated path]: https://spec.matrix.org/latest/appendices/#dot-separated-property-paths
126        key: String,
127
128        /// The value to match against.
129        value: ScalarJsonValue,
130    },
131
132    /// Exact value match on a value in an array property of the event.
133    EventPropertyContains {
134        /// The [dot-separated path] of the property of the event to match.
135        ///
136        /// [dot-separated path]: https://spec.matrix.org/latest/appendices/#dot-separated-property-paths
137        key: String,
138
139        /// The value to match against.
140        value: ScalarJsonValue,
141    },
142
143    /// Matches a thread event based on the user's thread subscription status, as defined by
144    /// [MSC4306].
145    ///
146    /// [MSC4306]: https://github.com/matrix-org/matrix-spec-proposals/pull/4306
147    #[cfg(feature = "unstable-msc4306")]
148    ThreadSubscription {
149        /// Whether the user must be subscribed (`true`) or unsubscribed (`false`) to the thread
150        /// for the condition to match.
151        subscribed: bool,
152    },
153
154    #[doc(hidden)]
155    _Custom(_CustomPushCondition),
156}
157
158pub(super) fn check_event_match(
159    event: &FlattenedJson,
160    key: &str,
161    pattern: &str,
162    context: &PushConditionRoomCtx,
163) -> bool {
164    let value = match key {
165        "room_id" => context.room_id.as_str(),
166        _ => match event.get_str(key) {
167            Some(v) => v,
168            None => return false,
169        },
170    };
171
172    value.matches_pattern(pattern, key == "content.body")
173}
174
175impl PushCondition {
176    /// Check if this condition applies to the event.
177    ///
178    /// # Arguments
179    ///
180    /// * `event` - The flattened JSON representation of a room message event.
181    /// * `context` - The context of the room at the time of the event. If the power levels context
182    ///   is missing from it, conditions that depend on it will never apply.
183    pub async fn applies(&self, event: &FlattenedJson, context: &PushConditionRoomCtx) -> bool {
184        if event.get_str("sender").is_some_and(|sender| sender == context.user_id) {
185            return false;
186        }
187
188        match self {
189            Self::EventMatch { key, pattern } => check_event_match(event, key, pattern, context),
190            #[allow(deprecated)]
191            Self::ContainsDisplayName => {
192                let Some(value) = event.get_str("content.body") else { return false };
193                value.matches_pattern(&context.user_display_name, true)
194            }
195            Self::RoomMemberCount { is } => is.contains(&context.member_count),
196            Self::SenderNotificationPermission { key } => {
197                let Some(power_levels) = &context.power_levels else { return false };
198                let Some(sender_id) = event.get_str("sender") else { return false };
199                let Ok(sender_id) = <&UserId>::try_from(sender_id) else { return false };
200
201                power_levels.has_sender_notification_permission(sender_id, key)
202            }
203            #[cfg(feature = "unstable-msc3931")]
204            Self::RoomVersionSupports { feature } => match feature {
205                RoomVersionFeature::ExtensibleEvents => {
206                    context.supported_features.contains(&RoomVersionFeature::ExtensibleEvents)
207                }
208                RoomVersionFeature::_Custom(_) => false,
209            },
210            Self::EventPropertyIs { key, value } => event.get(key).is_some_and(|v| v == value),
211            Self::EventPropertyContains { key, value } => event
212                .get(key)
213                .and_then(FlattenedJsonValue::as_array)
214                .is_some_and(|a| a.contains(value)),
215            #[cfg(feature = "unstable-msc4306")]
216            Self::ThreadSubscription { subscribed: must_be_subscribed } => {
217                let Some(has_thread_subscription_fn) = &context.has_thread_subscription_fn else {
218                    // If we don't have a function to check thread subscriptions, we can't
219                    // determine if the condition applies.
220                    return false;
221                };
222
223                // The event must have a relation of type `m.thread`.
224                if event.get_str("content.m\\.relates_to.rel_type") != Some("m.thread") {
225                    return false;
226                }
227
228                // Retrieve the thread root event ID.
229                let Some(Ok(thread_root)) =
230                    event.get_str("content.m\\.relates_to.event_id").map(<&EventId>::try_from)
231                else {
232                    return false;
233                };
234
235                let is_subscribed = has_thread_subscription_fn(thread_root).await;
236
237                *must_be_subscribed == is_subscribed
238            }
239            Self::_Custom(_) => false,
240        }
241    }
242}
243
244/// An unknown push condition.
245#[doc(hidden)]
246#[derive(Clone, Debug, Deserialize, Serialize)]
247#[allow(clippy::exhaustive_structs)]
248pub struct _CustomPushCondition {
249    /// The kind of the condition.
250    kind: String,
251
252    /// The additional fields that the condition contains.
253    #[serde(flatten)]
254    data: BTreeMap<String, JsonValue>,
255}
256
257/// The context of the room associated to an event to be able to test all push conditions.
258#[derive(Clone)]
259#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
260pub struct PushConditionRoomCtx {
261    /// The ID of the room.
262    pub room_id: OwnedRoomId,
263
264    /// The number of members in the room.
265    pub member_count: UInt,
266
267    /// The user's matrix ID.
268    pub user_id: OwnedUserId,
269
270    /// The display name of the current user in the room.
271    pub user_display_name: String,
272
273    /// The room power levels context for the room.
274    ///
275    /// If this is missing, push rules that require this will never match.
276    pub power_levels: Option<PushConditionPowerLevelsCtx>,
277
278    /// The list of features this room's version or the room itself supports.
279    #[cfg(feature = "unstable-msc3931")]
280    pub supported_features: Vec<RoomVersionFeature>,
281
282    /// A closure that returns a future indicating if the given thread (represented by its thread
283    /// root event id) is subscribed to by the current user, where subscriptions are defined as per
284    /// [MSC4306].
285    ///
286    /// [MSC4306]: https://github.com/matrix-org/matrix-spec-proposals/pull/4306
287    #[cfg(feature = "unstable-msc4306")]
288    has_thread_subscription_fn: Option<Arc<HasThreadSubscriptionFn>>,
289}
290
291#[cfg(all(feature = "unstable-msc4306", not(target_family = "wasm")))]
292type HasThreadSubscriptionFuture<'a> = Pin<Box<dyn Future<Output = bool> + Send + 'a>>;
293
294#[cfg(all(feature = "unstable-msc4306", target_family = "wasm"))]
295type HasThreadSubscriptionFuture<'a> = Pin<Box<dyn Future<Output = bool> + 'a>>;
296
297#[cfg(all(feature = "unstable-msc4306", not(target_family = "wasm")))]
298type HasThreadSubscriptionFn =
299    dyn for<'a> Fn(&'a EventId) -> HasThreadSubscriptionFuture<'a> + Send + Sync;
300
301#[cfg(all(feature = "unstable-msc4306", target_family = "wasm"))]
302type HasThreadSubscriptionFn = dyn for<'a> Fn(&'a EventId) -> HasThreadSubscriptionFuture<'a>;
303
304impl std::fmt::Debug for PushConditionRoomCtx {
305    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        let mut debug_struct = f.debug_struct("PushConditionRoomCtx");
307
308        debug_struct
309            .field("room_id", &self.room_id)
310            .field("member_count", &self.member_count)
311            .field("user_id", &self.user_id)
312            .field("user_display_name", &self.user_display_name)
313            .field("power_levels", &self.power_levels);
314
315        #[cfg(feature = "unstable-msc3931")]
316        debug_struct.field("supported_features", &self.supported_features);
317
318        debug_struct.finish_non_exhaustive()
319    }
320}
321
322impl PushConditionRoomCtx {
323    /// Create a new `PushConditionRoomCtx`.
324    pub fn new(
325        room_id: OwnedRoomId,
326        member_count: UInt,
327        user_id: OwnedUserId,
328        user_display_name: String,
329    ) -> Self {
330        Self {
331            room_id,
332            member_count,
333            user_id,
334            user_display_name,
335            power_levels: None,
336            #[cfg(feature = "unstable-msc3931")]
337            supported_features: Vec::new(),
338            #[cfg(feature = "unstable-msc4306")]
339            has_thread_subscription_fn: None,
340        }
341    }
342
343    /// Set a function to check if the user is subscribed to a thread, so as to define the push
344    /// rules defined in [MSC4306].
345    ///
346    /// [MSC4306]: https://github.com/matrix-org/matrix-spec-proposals/pull/4306
347    #[cfg(feature = "unstable-msc4306")]
348    pub fn with_has_thread_subscription_fn(
349        self,
350        #[cfg(not(target_family = "wasm"))]
351        has_thread_subscription_fn: impl for<'a> Fn(&'a EventId) -> HasThreadSubscriptionFuture<'a>
352            + Send
353            + Sync
354            + 'static,
355        #[cfg(target_family = "wasm")]
356        has_thread_subscription_fn: impl for<'a> Fn(&'a EventId) -> HasThreadSubscriptionFuture<'a>
357            + 'static,
358    ) -> Self {
359        Self { has_thread_subscription_fn: Some(Arc::new(has_thread_subscription_fn)), ..self }
360    }
361
362    /// Add the given power levels context to this `PushConditionRoomCtx`.
363    pub fn with_power_levels(self, power_levels: PushConditionPowerLevelsCtx) -> Self {
364        Self { power_levels: Some(power_levels), ..self }
365    }
366}
367
368/// The room power levels context to be able to test the corresponding push conditions.
369///
370/// Should be constructed using `From<RoomPowerLevels>`.
371#[derive(Clone, Debug)]
372#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
373pub struct PushConditionPowerLevelsCtx {
374    /// The power levels of the users of the room.
375    pub users: BTreeMap<OwnedUserId, Int>,
376
377    /// The default power level of the users of the room.
378    pub users_default: Int,
379
380    /// The notification power levels of the room.
381    pub notifications: NotificationPowerLevels,
382
383    /// The tweaks for determining the power level of a user.
384    pub rules: RoomPowerLevelsRules,
385}
386
387impl PushConditionPowerLevelsCtx {
388    /// Create a new `PushConditionPowerLevelsCtx`.
389    pub fn new(
390        users: BTreeMap<OwnedUserId, Int>,
391        users_default: Int,
392        notifications: NotificationPowerLevels,
393        rules: RoomPowerLevelsRules,
394    ) -> Self {
395        Self { users, users_default, notifications, rules }
396    }
397
398    /// Whether the given user has the permission to notify for the given key.
399    pub fn has_sender_notification_permission(
400        &self,
401        user_id: &UserId,
402        key: &NotificationPowerLevelsKey,
403    ) -> bool {
404        let Some(notification_power_level) = self.notifications.get(key) else {
405            // We don't know the required power level for the key.
406            return false;
407        };
408
409        if self
410            .rules
411            .privileged_creators
412            .as_ref()
413            .is_some_and(|creators| creators.contains(user_id))
414        {
415            return true;
416        }
417
418        let user_power_level = self.users.get(user_id).unwrap_or(&self.users_default);
419
420        user_power_level >= notification_power_level
421    }
422}
423
424/// Additional functions for character matching.
425trait CharExt {
426    /// Whether or not this char can be part of a word.
427    fn is_word_char(&self) -> bool;
428}
429
430impl CharExt for char {
431    fn is_word_char(&self) -> bool {
432        self.is_ascii_alphanumeric() || *self == '_'
433    }
434}
435
436/// Additional functions for string matching.
437trait StrExt {
438    /// Get the length of the char at `index`. The byte index must correspond to
439    /// the start of a char boundary.
440    fn char_len(&self, index: usize) -> usize;
441
442    /// Get the char at `index`. The byte index must correspond to the start of
443    /// a char boundary.
444    fn char_at(&self, index: usize) -> char;
445
446    /// Get the index of the char that is before the char at `index`. The byte index
447    /// must correspond to a char boundary.
448    ///
449    /// Returns `None` if there's no previous char. Otherwise, returns the char.
450    fn find_prev_char(&self, index: usize) -> Option<char>;
451
452    /// Matches this string against `pattern`.
453    ///
454    /// The pattern can be a glob with wildcards `*` and `?`.
455    ///
456    /// The match is case insensitive.
457    ///
458    /// If `match_words` is `true`, checks that the pattern is separated from other words.
459    fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool;
460
461    /// Matches this string against `pattern`, with word boundaries.
462    ///
463    /// The pattern can be a glob with wildcards `*` and `?`.
464    ///
465    /// A word boundary is defined as the start or end of the value, or any character not in the
466    /// sets `[A-Z]`, `[a-z]`, `[0-9]` or `_`.
467    ///
468    /// The match is case sensitive.
469    fn matches_word(&self, pattern: &str) -> bool;
470
471    /// Translate the wildcards in `self` to a regex syntax.
472    ///
473    /// `self` must only contain wildcards.
474    fn wildcards_to_regex(&self) -> String;
475}
476
477impl StrExt for str {
478    fn char_len(&self, index: usize) -> usize {
479        let mut len = 1;
480        while !self.is_char_boundary(index + len) {
481            len += 1;
482        }
483        len
484    }
485
486    fn char_at(&self, index: usize) -> char {
487        let end = index + self.char_len(index);
488        let char_str = &self[index..end];
489        char::from_str(char_str)
490            .unwrap_or_else(|_| panic!("Could not convert str '{char_str}' to char"))
491    }
492
493    fn find_prev_char(&self, index: usize) -> Option<char> {
494        if index == 0 {
495            return None;
496        }
497
498        let mut pos = index - 1;
499        while !self.is_char_boundary(pos) {
500            pos -= 1;
501        }
502        Some(self.char_at(pos))
503    }
504
505    fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool {
506        let value = &self.to_lowercase();
507        let pattern = &pattern.to_lowercase();
508
509        if match_words {
510            value.matches_word(pattern)
511        } else {
512            WildMatch::new(pattern).matches(value)
513        }
514    }
515
516    fn matches_word(&self, pattern: &str) -> bool {
517        if self == pattern {
518            return true;
519        }
520        if pattern.is_empty() {
521            return false;
522        }
523
524        let has_wildcards = pattern.contains(['?', '*']);
525
526        if has_wildcards {
527            let mut chunks: Vec<String> = vec![];
528            let mut prev_wildcard = false;
529            let mut chunk_start = 0;
530
531            for (i, c) in pattern.char_indices() {
532                if matches!(c, '?' | '*') && !prev_wildcard {
533                    if i != 0 {
534                        chunks.push(regex::escape(&pattern[chunk_start..i]));
535                        chunk_start = i;
536                    }
537
538                    prev_wildcard = true;
539                } else if prev_wildcard {
540                    let chunk = &pattern[chunk_start..i];
541                    chunks.push(chunk.wildcards_to_regex());
542
543                    chunk_start = i;
544                    prev_wildcard = false;
545                }
546            }
547
548            let len = pattern.len();
549            if !prev_wildcard {
550                chunks.push(regex::escape(&pattern[chunk_start..len]));
551            } else if prev_wildcard {
552                let chunk = &pattern[chunk_start..len];
553                chunks.push(chunk.wildcards_to_regex());
554            }
555
556            // The word characters in ASCII compatible mode (with the `-u` flag) match the
557            // definition in the spec: any character not in the set `[A-Za-z0-9_]`.
558            let regex = format!(r"(?-u:^|\W|\b){}(?-u:\b|\W|$)", chunks.concat());
559            let re = Regex::new(&regex).expect("regex construction should succeed");
560            re.is_match(self.as_bytes())
561        } else {
562            match self.find(pattern) {
563                Some(start) => {
564                    let end = start + pattern.len();
565
566                    // Look if the match has word boundaries.
567                    let word_boundary_start = !self.char_at(start).is_word_char()
568                        || !self.find_prev_char(start).is_some_and(|c| c.is_word_char());
569
570                    if word_boundary_start {
571                        let word_boundary_end = end == self.len()
572                            || !self.find_prev_char(end).unwrap().is_word_char()
573                            || !self.char_at(end).is_word_char();
574
575                        if word_boundary_end {
576                            return true;
577                        }
578                    }
579
580                    // Find next word.
581                    let non_word_str = &self[start..];
582                    let Some(non_word) = non_word_str.find(|c: char| !c.is_word_char()) else {
583                        return false;
584                    };
585
586                    let word_str = &non_word_str[non_word..];
587                    let Some(word) = word_str.find(|c: char| c.is_word_char()) else {
588                        return false;
589                    };
590
591                    word_str[word..].matches_word(pattern)
592                }
593                None => false,
594            }
595        }
596    }
597
598    fn wildcards_to_regex(&self) -> String {
599        // Simplify pattern to avoid performance issues:
600        // - The glob `?**?**?` is equivalent to the glob `???*`
601        // - The glob `???*` is equivalent to the regex `.{3,}`
602        let question_marks = self.matches('?').count();
603
604        if self.contains('*') {
605            format!(".{{{question_marks},}}")
606        } else {
607            format!(".{{{question_marks}}}")
608        }
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use std::collections::BTreeMap;
615
616    use assert_matches2::assert_matches;
617    use js_int::{int, uint, Int};
618    use macro_rules_attribute::apply;
619    use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
620    use smol_macros::test;
621
622    use super::{
623        FlattenedJson, PushCondition, PushConditionPowerLevelsCtx, PushConditionRoomCtx,
624        RoomMemberCountIs, StrExt,
625    };
626    use crate::{
627        owned_room_id, owned_user_id,
628        power_levels::{NotificationPowerLevels, NotificationPowerLevelsKey},
629        room_version_rules::{AuthorizationRules, RoomPowerLevelsRules},
630        OwnedUserId,
631    };
632
633    #[test]
634    fn serialize_event_match_condition() {
635        let json_data = json!({
636            "key": "content.msgtype",
637            "kind": "event_match",
638            "pattern": "m.notice"
639        });
640        assert_eq!(
641            to_json_value(PushCondition::EventMatch {
642                key: "content.msgtype".into(),
643                pattern: "m.notice".into(),
644            })
645            .unwrap(),
646            json_data
647        );
648    }
649
650    #[test]
651    #[allow(deprecated)]
652    fn serialize_contains_display_name_condition() {
653        assert_eq!(
654            to_json_value(PushCondition::ContainsDisplayName).unwrap(),
655            json!({ "kind": "contains_display_name" })
656        );
657    }
658
659    #[test]
660    fn serialize_room_member_count_condition() {
661        let json_data = json!({
662            "is": "2",
663            "kind": "room_member_count"
664        });
665        assert_eq!(
666            to_json_value(PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(2)) })
667                .unwrap(),
668            json_data
669        );
670    }
671
672    #[test]
673    fn serialize_sender_notification_permission_condition() {
674        let json_data = json!({
675            "key": "room",
676            "kind": "sender_notification_permission"
677        });
678        assert_eq!(
679            json_data,
680            to_json_value(PushCondition::SenderNotificationPermission { key: "room".into() })
681                .unwrap()
682        );
683    }
684
685    #[test]
686    fn deserialize_event_match_condition() {
687        let json_data = json!({
688            "key": "content.msgtype",
689            "kind": "event_match",
690            "pattern": "m.notice"
691        });
692        assert_matches!(
693            from_json_value::<PushCondition>(json_data).unwrap(),
694            PushCondition::EventMatch { key, pattern }
695        );
696        assert_eq!(key, "content.msgtype");
697        assert_eq!(pattern, "m.notice");
698    }
699
700    #[test]
701    #[allow(deprecated)]
702    fn deserialize_contains_display_name_condition() {
703        assert_matches!(
704            from_json_value::<PushCondition>(json!({ "kind": "contains_display_name" })).unwrap(),
705            PushCondition::ContainsDisplayName
706        );
707    }
708
709    #[test]
710    fn deserialize_room_member_count_condition() {
711        let json_data = json!({
712            "is": "2",
713            "kind": "room_member_count"
714        });
715        assert_matches!(
716            from_json_value::<PushCondition>(json_data).unwrap(),
717            PushCondition::RoomMemberCount { is }
718        );
719        assert_eq!(is, RoomMemberCountIs::from(uint!(2)));
720    }
721
722    #[test]
723    fn deserialize_sender_notification_permission_condition() {
724        let json_data = json!({
725            "key": "room",
726            "kind": "sender_notification_permission"
727        });
728        assert_matches!(
729            from_json_value::<PushCondition>(json_data).unwrap(),
730            PushCondition::SenderNotificationPermission { key }
731        );
732        assert_eq!(key, NotificationPowerLevelsKey::Room);
733    }
734
735    #[test]
736    fn words_match() {
737        assert!("foo bar".matches_word("foo"));
738        assert!(!"Foo bar".matches_word("foo"));
739        assert!(!"foobar".matches_word("foo"));
740        assert!("foobar foo".matches_word("foo"));
741        assert!(!"foobar foobar".matches_word("foo"));
742        assert!(!"foobar bar".matches_word("bar bar"));
743        assert!("foobar bar bar".matches_word("bar bar"));
744        assert!(!"foobar bar barfoo".matches_word("bar bar"));
745        assert!("conduit ⚡️".matches_word("conduit ⚡️"));
746        assert!("conduit ⚡️".matches_word("conduit"));
747        assert!("conduit ⚡️".matches_word("⚡️"));
748        assert!("conduit⚡️".matches_word("conduit"));
749        assert!("conduit⚡️".matches_word("⚡️"));
750        assert!("⚡️conduit".matches_word("conduit"));
751        assert!("⚡️conduit".matches_word("⚡️"));
752        assert!("Ruma Dev👩‍💻".matches_word("Dev"));
753        assert!("Ruma Dev👩‍💻".matches_word("👩‍💻"));
754        assert!("Ruma Dev👩‍💻".matches_word("Dev👩‍💻"));
755
756        // Regex syntax is escaped
757        assert!(!"matrix".matches_word(r"\w*"));
758        assert!(r"\w".matches_word(r"\w*"));
759        assert!(!"matrix".matches_word("[a-z]*"));
760        assert!("[a-z] and [0-9]".matches_word("[a-z]*"));
761        assert!(!"m".matches_word("[[:alpha:]]?"));
762        assert!("[[:alpha:]]!".matches_word("[[:alpha:]]?"));
763
764        // From the spec: <https://spec.matrix.org/v1.16/client-server-api/#conditions-1>
765        assert!("An example event.".matches_word("ex*ple"));
766        assert!("exple".matches_word("ex*ple"));
767        assert!("An exciting triple-whammy".matches_word("ex*ple"));
768    }
769
770    #[test]
771    fn patterns_match() {
772        // Word matching without glob
773        assert!("foo bar".matches_pattern("foo", true));
774        assert!("Foo bar".matches_pattern("foo", true));
775        assert!(!"foobar".matches_pattern("foo", true));
776        assert!("".matches_pattern("", true));
777        assert!(!"foo".matches_pattern("", true));
778        assert!("foo bar".matches_pattern("foo bar", true));
779        assert!(" foo bar ".matches_pattern("foo bar", true));
780        assert!("baz foo bar baz".matches_pattern("foo bar", true));
781        assert!("foo baré".matches_pattern("foo bar", true));
782        assert!(!"bar foo".matches_pattern("foo bar", true));
783        assert!("foo bar".matches_pattern("foo ", true));
784        assert!("foo ".matches_pattern("foo ", true));
785        assert!("foo  ".matches_pattern("foo ", true));
786        assert!(" foo  ".matches_pattern("foo ", true));
787
788        // Word matching with glob
789        assert!("foo bar".matches_pattern("foo*", true));
790        assert!("foo bar".matches_pattern("foo b?r", true));
791        assert!(" foo bar ".matches_pattern("foo b?r", true));
792        assert!("baz foo bar baz".matches_pattern("foo b?r", true));
793        assert!("foo baré".matches_pattern("foo b?r", true));
794        assert!(!"bar foo".matches_pattern("foo b?r", true));
795        assert!("foo bar".matches_pattern("f*o ", true));
796        assert!("foo ".matches_pattern("f*o ", true));
797        assert!("foo  ".matches_pattern("f*o ", true));
798        assert!(" foo  ".matches_pattern("f*o ", true));
799
800        // Glob matching
801        assert!(!"foo bar".matches_pattern("foo", false));
802        assert!("foo".matches_pattern("foo", false));
803        assert!("foo".matches_pattern("foo*", false));
804        assert!("foobar".matches_pattern("foo*", false));
805        assert!("foo bar".matches_pattern("foo*", false));
806        assert!(!"foo".matches_pattern("foo?", false));
807        assert!("fooo".matches_pattern("foo?", false));
808        assert!("FOO".matches_pattern("foo", false));
809        assert!("".matches_pattern("", false));
810        assert!("".matches_pattern("*", false));
811        assert!(!"foo".matches_pattern("", false));
812
813        // From the spec: <https://spec.matrix.org/v1.16/client-server-api/#conditions-1>
814        assert!("Lunch plans".matches_pattern("lunc?*", false));
815        assert!("LUNCH".matches_pattern("lunc?*", false));
816        assert!(!" lunch".matches_pattern("lunc?*", false));
817        assert!(!"lunc".matches_pattern("lunc?*", false));
818    }
819
820    fn sender() -> OwnedUserId {
821        owned_user_id!("@worthy_whale:server.name")
822    }
823
824    fn push_context() -> PushConditionRoomCtx {
825        let mut users = BTreeMap::new();
826        users.insert(sender(), int!(25));
827
828        let power_levels = PushConditionPowerLevelsCtx {
829            users,
830            users_default: int!(50),
831            notifications: NotificationPowerLevels { room: int!(50) },
832            rules: RoomPowerLevelsRules::new(&AuthorizationRules::V1, None),
833        };
834
835        let mut ctx = PushConditionRoomCtx::new(
836            owned_room_id!("!room:server.name"),
837            uint!(3),
838            owned_user_id!("@gorilla:server.name"),
839            "Groovy Gorilla".into(),
840        );
841        ctx.power_levels = Some(power_levels);
842        ctx
843    }
844
845    fn first_flattened_event() -> FlattenedJson {
846        FlattenedJson::from_value(json!({
847            "sender": "@worthy_whale:server.name",
848            "content": {
849                "msgtype": "m.text",
850                "body": "@room Give a warm welcome to Groovy Gorilla",
851            },
852        }))
853    }
854
855    fn second_flattened_event() -> FlattenedJson {
856        FlattenedJson::from_value(json!({
857            "sender": "@party_bot:server.name",
858            "content": {
859                "msgtype": "m.notice",
860                "body": "Everybody come to party!",
861            },
862        }))
863    }
864
865    #[apply(test!)]
866    async fn event_match_applies() {
867        let context = push_context();
868        let first_event = first_flattened_event();
869        let second_event = second_flattened_event();
870
871        let correct_room = PushCondition::EventMatch {
872            key: "room_id".into(),
873            pattern: "!room:server.name".into(),
874        };
875        let incorrect_room = PushCondition::EventMatch {
876            key: "room_id".into(),
877            pattern: "!incorrect:server.name".into(),
878        };
879
880        assert!(correct_room.applies(&first_event, &context).await);
881        assert!(!incorrect_room.applies(&first_event, &context).await);
882
883        let keyword =
884            PushCondition::EventMatch { key: "content.body".into(), pattern: "come".into() };
885
886        assert!(!keyword.applies(&first_event, &context).await);
887        assert!(keyword.applies(&second_event, &context).await);
888
889        let msgtype =
890            PushCondition::EventMatch { key: "content.msgtype".into(), pattern: "m.notice".into() };
891
892        assert!(!msgtype.applies(&first_event, &context).await);
893        assert!(msgtype.applies(&second_event, &context).await);
894    }
895
896    #[apply(test!)]
897    async fn room_member_count_is_applies() {
898        let context = push_context();
899        let event = first_flattened_event();
900
901        let member_count_eq =
902            PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(3)) };
903        let member_count_gt =
904            PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(2)..) };
905        let member_count_lt =
906            PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(..uint!(3)) };
907
908        assert!(member_count_eq.applies(&event, &context).await);
909        assert!(member_count_gt.applies(&event, &context).await);
910        assert!(!member_count_lt.applies(&event, &context).await);
911    }
912
913    #[apply(test!)]
914    #[allow(deprecated)]
915    async fn contains_display_name_applies() {
916        let context = push_context();
917        let first_event = first_flattened_event();
918        let second_event = second_flattened_event();
919
920        let contains_display_name = PushCondition::ContainsDisplayName;
921
922        assert!(contains_display_name.applies(&first_event, &context).await);
923        assert!(!contains_display_name.applies(&second_event, &context).await);
924    }
925
926    #[apply(test!)]
927    async fn sender_notification_permission_applies() {
928        let context = push_context();
929        let first_event = first_flattened_event();
930        let second_event = second_flattened_event();
931
932        let sender_notification_permission =
933            PushCondition::SenderNotificationPermission { key: "room".into() };
934
935        assert!(!sender_notification_permission.applies(&first_event, &context).await);
936        assert!(sender_notification_permission.applies(&second_event, &context).await);
937    }
938
939    #[cfg(feature = "unstable-msc3932")]
940    #[apply(test!)]
941    async fn room_version_supports_applies() {
942        use assign::assign;
943
944        let context_not_matching = push_context();
945        let context_matching = assign!(
946            PushConditionRoomCtx::new(
947                owned_room_id!("!room:server.name"),
948                uint!(3),
949                owned_user_id!("@gorilla:server.name"),
950                "Groovy Gorilla".into(),
951            ), {
952                power_levels: context_not_matching.power_levels.clone(),
953                supported_features: vec![super::RoomVersionFeature::ExtensibleEvents],
954            }
955        );
956
957        let simple_event = FlattenedJson::from_value(json!({
958            "sender": "@worthy_whale:server.name",
959            "content": {
960                "msgtype": "org.matrix.msc3932.extensible_events",
961                "body": "@room Give a warm welcome to Groovy Gorilla",
962            },
963        }));
964
965        let room_version_condition = PushCondition::RoomVersionSupports {
966            feature: super::RoomVersionFeature::ExtensibleEvents,
967        };
968
969        assert!(room_version_condition.applies(&simple_event, &context_matching).await);
970        assert!(!room_version_condition.applies(&simple_event, &context_not_matching).await);
971    }
972
973    #[apply(test!)]
974    async fn event_property_is_applies() {
975        use crate::push::condition::ScalarJsonValue;
976
977        let context = push_context();
978        let event = FlattenedJson::from_value(json!({
979            "sender": "@worthy_whale:server.name",
980            "content": {
981                "msgtype": "m.text",
982                "body": "Boom!",
983                "org.fake.boolean": false,
984                "org.fake.number": 13,
985                "org.fake.null": null,
986            },
987        }));
988
989        let string_match = PushCondition::EventPropertyIs {
990            key: "content.body".to_owned(),
991            value: "Boom!".into(),
992        };
993        assert!(string_match.applies(&event, &context).await);
994
995        let string_no_match =
996            PushCondition::EventPropertyIs { key: "content.body".to_owned(), value: "Boom".into() };
997        assert!(!string_no_match.applies(&event, &context).await);
998
999        let wrong_type =
1000            PushCondition::EventPropertyIs { key: "content.body".to_owned(), value: false.into() };
1001        assert!(!wrong_type.applies(&event, &context).await);
1002
1003        let bool_match = PushCondition::EventPropertyIs {
1004            key: r"content.org\.fake\.boolean".to_owned(),
1005            value: false.into(),
1006        };
1007        assert!(bool_match.applies(&event, &context).await);
1008
1009        let bool_no_match = PushCondition::EventPropertyIs {
1010            key: r"content.org\.fake\.boolean".to_owned(),
1011            value: true.into(),
1012        };
1013        assert!(!bool_no_match.applies(&event, &context).await);
1014
1015        let int_match = PushCondition::EventPropertyIs {
1016            key: r"content.org\.fake\.number".to_owned(),
1017            value: int!(13).into(),
1018        };
1019        assert!(int_match.applies(&event, &context).await);
1020
1021        let int_no_match = PushCondition::EventPropertyIs {
1022            key: r"content.org\.fake\.number".to_owned(),
1023            value: int!(130).into(),
1024        };
1025        assert!(!int_no_match.applies(&event, &context).await);
1026
1027        let null_match = PushCondition::EventPropertyIs {
1028            key: r"content.org\.fake\.null".to_owned(),
1029            value: ScalarJsonValue::Null,
1030        };
1031        assert!(null_match.applies(&event, &context).await);
1032    }
1033
1034    #[apply(test!)]
1035    async fn event_property_contains_applies() {
1036        use crate::push::condition::ScalarJsonValue;
1037
1038        let context = push_context();
1039        let event = FlattenedJson::from_value(json!({
1040            "sender": "@worthy_whale:server.name",
1041            "content": {
1042                "org.fake.array": ["Boom!", false, 13, null],
1043            },
1044        }));
1045
1046        let wrong_key =
1047            PushCondition::EventPropertyContains { key: "send".to_owned(), value: false.into() };
1048        assert!(!wrong_key.applies(&event, &context).await);
1049
1050        let string_match = PushCondition::EventPropertyContains {
1051            key: r"content.org\.fake\.array".to_owned(),
1052            value: "Boom!".into(),
1053        };
1054        assert!(string_match.applies(&event, &context).await);
1055
1056        let string_no_match = PushCondition::EventPropertyContains {
1057            key: r"content.org\.fake\.array".to_owned(),
1058            value: "Boom".into(),
1059        };
1060        assert!(!string_no_match.applies(&event, &context).await);
1061
1062        let bool_match = PushCondition::EventPropertyContains {
1063            key: r"content.org\.fake\.array".to_owned(),
1064            value: false.into(),
1065        };
1066        assert!(bool_match.applies(&event, &context).await);
1067
1068        let bool_no_match = PushCondition::EventPropertyContains {
1069            key: r"content.org\.fake\.array".to_owned(),
1070            value: true.into(),
1071        };
1072        assert!(!bool_no_match.applies(&event, &context).await);
1073
1074        let int_match = PushCondition::EventPropertyContains {
1075            key: r"content.org\.fake\.array".to_owned(),
1076            value: int!(13).into(),
1077        };
1078        assert!(int_match.applies(&event, &context).await);
1079
1080        let int_no_match = PushCondition::EventPropertyContains {
1081            key: r"content.org\.fake\.array".to_owned(),
1082            value: int!(130).into(),
1083        };
1084        assert!(!int_no_match.applies(&event, &context).await);
1085
1086        let null_match = PushCondition::EventPropertyContains {
1087            key: r"content.org\.fake\.array".to_owned(),
1088            value: ScalarJsonValue::Null,
1089        };
1090        assert!(null_match.applies(&event, &context).await);
1091    }
1092
1093    #[apply(test!)]
1094    async fn room_creators_always_have_notification_permission() {
1095        let mut context = push_context();
1096        context.power_levels = Some(PushConditionPowerLevelsCtx {
1097            users: BTreeMap::new(),
1098            users_default: Int::MIN,
1099            notifications: NotificationPowerLevels { room: Int::MAX },
1100            rules: RoomPowerLevelsRules::new(&AuthorizationRules::V12, Some(sender())),
1101        });
1102
1103        let first_event = first_flattened_event();
1104
1105        let sender_notification_permission =
1106            PushCondition::SenderNotificationPermission { key: NotificationPowerLevelsKey::Room };
1107
1108        assert!(sender_notification_permission.applies(&first_event, &context).await);
1109    }
1110
1111    #[cfg(feature = "unstable-msc4306")]
1112    #[apply(test!)]
1113    async fn thread_subscriptions_match() {
1114        use crate::{event_id, EventId};
1115
1116        let context = push_context().with_has_thread_subscription_fn(|event_id: &EventId| {
1117            Box::pin(async move {
1118                // Simulate thread subscriptions for testing.
1119                event_id == event_id!("$subscribed_thread")
1120            })
1121        });
1122
1123        let subscribed_thread_event = FlattenedJson::from_value(json!({
1124            "event_id": "$thread_response",
1125            "sender": "@worthy_whale:server.name",
1126            "content": {
1127                "msgtype": "m.text",
1128                "body": "response in thread $subscribed_thread",
1129                "m.relates_to": {
1130                    "rel_type": "m.thread",
1131                    "event_id": "$subscribed_thread",
1132                    "is_falling_back": true,
1133                    "m.in_reply_to": {
1134                        "event_id": "$prev_event",
1135                    },
1136                },
1137            },
1138        }));
1139
1140        let unsubscribed_thread_event = FlattenedJson::from_value(json!({
1141            "event_id": "$thread_response2",
1142            "sender": "@worthy_whale:server.name",
1143            "content": {
1144                "msgtype": "m.text",
1145                "body": "response in thread $unsubscribed_thread",
1146                "m.relates_to": {
1147                    "rel_type": "m.thread",
1148                    "event_id": "$unsubscribed_thread",
1149                    "is_falling_back": true,
1150                    "m.in_reply_to": {
1151                        "event_id": "$prev_event2",
1152                    },
1153                },
1154            },
1155        }));
1156
1157        let non_thread_related_event = FlattenedJson::from_value(json!({
1158            "event_id": "$thread_response2",
1159            "sender": "@worthy_whale:server.name",
1160            "content": {
1161                "m.relates_to": {
1162                    "rel_type": "m.reaction",
1163                    "event_id": "$subscribed_thread",
1164                    "key": "👍",
1165                },
1166            },
1167        }));
1168
1169        let subscribed_thread_condition = PushCondition::ThreadSubscription { subscribed: true };
1170        assert!(subscribed_thread_condition.applies(&subscribed_thread_event, &context).await);
1171        assert!(!subscribed_thread_condition.applies(&unsubscribed_thread_event, &context).await);
1172        assert!(!subscribed_thread_condition.applies(&non_thread_related_event, &context).await);
1173
1174        let unsubscribed_thread_condition = PushCondition::ThreadSubscription { subscribed: false };
1175        assert!(unsubscribed_thread_condition.applies(&unsubscribed_thread_event, &context).await);
1176        assert!(!unsubscribed_thread_condition.applies(&subscribed_thread_event, &context).await);
1177        assert!(!unsubscribed_thread_condition.applies(&non_thread_related_event, &context).await);
1178    }
1179}