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#[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 EventMatch(EventMatchConditionData),
37
38 #[deprecated]
41 ContainsDisplayName,
42
43 RoomMemberCount(RoomMemberCountConditionData),
45
46 SenderNotificationPermission(SenderNotificationPermissionConditionData),
49
50 #[cfg(feature = "unstable-msc3931")]
52 #[serde(rename = "org.matrix.msc3931.room_version_supports")]
53 RoomVersionSupports(RoomVersionSupportsConditionData),
54
55 EventPropertyIs(EventPropertyIsConditionData),
57
58 EventPropertyContains(EventPropertyContainsConditionData),
60
61 #[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 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 pub fn custom_data(&self) -> Option<&JsonObject> {
95 as_variant!(self, Self::_Custom).map(|condition| &condition.data)
96 }
97
98 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#[derive(Clone, Debug, Serialize, Deserialize)]
178#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
179pub struct EventMatchConditionData {
180 pub key: String,
184
185 pub pattern: String,
190}
191
192impl EventMatchConditionData {
193 pub fn new(key: String, pattern: String) -> Self {
195 Self { key, pattern }
196 }
197
198 fn applies(&self, event: &FlattenedJson, context: &PushConditionRoomCtx) -> bool {
205 check_event_match(event, &self.key, &self.pattern, context)
206 }
207}
208
209#[derive(Clone, Debug, Serialize, Deserialize)]
211#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
212pub struct RoomMemberCountConditionData {
213 pub is: RoomMemberCountIs,
215}
216
217impl RoomMemberCountConditionData {
218 pub fn new(is: RoomMemberCountIs) -> Self {
220 Self { is }
221 }
222
223 fn applies(&self, context: &PushConditionRoomCtx) -> bool {
229 self.is.contains(&context.member_count)
230 }
231}
232
233#[derive(Clone, Debug, Serialize, Deserialize)]
235#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
236pub struct SenderNotificationPermissionConditionData {
237 pub key: NotificationPowerLevelsKey,
242}
243
244impl SenderNotificationPermissionConditionData {
245 pub fn new(key: NotificationPowerLevelsKey) -> Self {
247 Self { key }
248 }
249
250 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#[cfg(feature = "unstable-msc3931")]
270#[derive(Clone, Debug, Serialize, Deserialize)]
271#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
272pub struct RoomVersionSupportsConditionData {
273 pub feature: RoomVersionFeature,
275}
276
277#[cfg(feature = "unstable-msc3931")]
278impl RoomVersionSupportsConditionData {
279 pub fn new(feature: RoomVersionFeature) -> Self {
281 Self { feature }
282 }
283
284 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#[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 #[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 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#[derive(Clone, Debug, Serialize, Deserialize)]
346#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
347pub struct EventPropertyIsConditionData {
348 pub key: String,
352
353 pub value: ScalarJsonValue,
355}
356
357impl EventPropertyIsConditionData {
358 pub fn new(key: String, value: ScalarJsonValue) -> Self {
360 Self { key, value }
361 }
362
363 fn applies(&self, event: &FlattenedJson) -> bool {
369 event.get(&self.key).is_some_and(|v| *v == self.value)
370 }
371}
372
373#[derive(Clone, Debug, Serialize, Deserialize)]
375#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
376pub struct EventPropertyContainsConditionData {
377 pub key: String,
381
382 pub value: ScalarJsonValue,
384}
385
386impl EventPropertyContainsConditionData {
387 pub fn new(key: String, value: ScalarJsonValue) -> Self {
389 Self { key, value }
390 }
391
392 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#[derive(Clone, Debug, Serialize, Deserialize)]
407#[cfg(feature = "unstable-msc4306")]
408#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
409pub struct ThreadSubscriptionConditionData {
410 pub subscribed: bool,
413}
414
415#[cfg(feature = "unstable-msc4306")]
416impl ThreadSubscriptionConditionData {
417 pub fn new(subscribed: bool) -> Self {
419 Self { subscribed }
420 }
421
422 async fn applies(&self, event: &FlattenedJson, context: &PushConditionRoomCtx) -> bool {
432 let Some(has_thread_subscription_fn) = &context.has_thread_subscription_fn else {
433 return false;
436 };
437
438 if event.get_str("content.m\\.relates_to.rel_type") != Some("m.thread") {
440 return false;
441 }
442
443 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#[doc(hidden)]
458#[derive(Clone, Debug, Deserialize, Serialize)]
459#[allow(clippy::exhaustive_structs)]
460pub struct _CustomPushCondition {
461 kind: String,
463
464 #[serde(flatten)]
466 data: JsonObject,
467}
468
469pub(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#[derive(Clone)]
490#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
491pub struct PushConditionRoomCtx {
492 pub room_id: OwnedRoomId,
494
495 pub member_count: UInt,
497
498 pub user_id: OwnedUserId,
500
501 pub user_display_name: String,
503
504 pub power_levels: Option<PushConditionPowerLevelsCtx>,
508
509 #[cfg(feature = "unstable-msc3931")]
511 pub supported_features: Vec<RoomVersionFeature>,
512
513 #[cfg(feature = "unstable-msc4306")]
519 has_thread_subscription_fn: Option<Arc<HasThreadSubscriptionFn>>,
520
521 #[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 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 #[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 pub fn with_power_levels(self, power_levels: PushConditionPowerLevelsCtx) -> Self {
605 Self { power_levels: Some(power_levels), ..self }
606 }
607}
608
609#[derive(Clone, Debug)]
613#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
614pub struct PushConditionPowerLevelsCtx {
615 pub users: BTreeMap<OwnedUserId, Int>,
617
618 pub users_default: Int,
620
621 pub notifications: NotificationPowerLevels,
623
624 pub rules: RoomPowerLevelsRules,
626}
627
628impl PushConditionPowerLevelsCtx {
629 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 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 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
665trait CharExt {
667 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
677trait StrExt {
679 fn char_len(&self, index: usize) -> usize;
682
683 fn char_at(&self, index: usize) -> char;
686
687 fn find_prev_char(&self, index: usize) -> Option<char>;
692
693 fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool;
701
702 fn matches_word(&self, pattern: &str) -> bool;
711
712 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 let regex = format!(r"(?-u:^|\W|\b){}(?-u:\b|\W|$)", chunks.concat());
800 let re = Regex::new(®ex).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 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 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 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 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 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 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 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 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 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 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}