ruma_common/push/
condition.rs

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