Skip to main content

ruma_common/push/
condition.rs

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