Skip to main content

ruma_common/push/
condition.rs

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