ruma_common/push/
condition.rs

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