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#[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 EventMatch(EventMatchConditionData),
39
40 #[deprecated]
43 ContainsDisplayName,
44
45 RoomMemberCount(RoomMemberCountConditionData),
47
48 SenderNotificationPermission(SenderNotificationPermissionConditionData),
51
52 #[cfg(feature = "unstable-msc3931")]
54 #[serde(rename = "org.matrix.msc3931.room_version_supports")]
55 RoomVersionSupports(RoomVersionSupportsConditionData),
56
57 EventPropertyIs(EventPropertyIsConditionData),
59
60 EventPropertyContains(EventPropertyContainsConditionData),
62
63 #[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 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 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 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#[derive(Clone, Debug, Serialize, Deserialize)]
206#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
207pub struct EventMatchConditionData {
208 pub key: String,
212
213 pub pattern: String,
218}
219
220impl EventMatchConditionData {
221 pub fn new(key: String, pattern: String) -> Self {
223 Self { key, pattern }
224 }
225
226 fn applies(&self, event: &FlattenedJson, context: &PushConditionRoomCtx) -> bool {
233 check_event_match(event, &self.key, &self.pattern, context)
234 }
235}
236
237#[derive(Clone, Debug, Serialize, Deserialize)]
239#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
240pub struct RoomMemberCountConditionData {
241 pub is: RoomMemberCountIs,
243}
244
245impl RoomMemberCountConditionData {
246 pub fn new(is: RoomMemberCountIs) -> Self {
248 Self { is }
249 }
250
251 fn applies(&self, context: &PushConditionRoomCtx) -> bool {
257 self.is.contains(&context.member_count)
258 }
259}
260
261#[derive(Clone, Debug, Serialize, Deserialize)]
263#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
264pub struct SenderNotificationPermissionConditionData {
265 pub key: NotificationPowerLevelsKey,
270}
271
272impl SenderNotificationPermissionConditionData {
273 pub fn new(key: NotificationPowerLevelsKey) -> Self {
275 Self { key }
276 }
277
278 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#[cfg(feature = "unstable-msc3931")]
298#[derive(Clone, Debug, Serialize, Deserialize)]
299#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
300pub struct RoomVersionSupportsConditionData {
301 pub feature: RoomVersionFeature,
303}
304
305#[cfg(feature = "unstable-msc3931")]
306impl RoomVersionSupportsConditionData {
307 pub fn new(feature: RoomVersionFeature) -> Self {
309 Self { feature }
310 }
311
312 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#[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 #[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 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#[derive(Clone, Debug, Serialize, Deserialize)]
374#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
375pub struct EventPropertyIsConditionData {
376 pub key: String,
380
381 pub value: ScalarJsonValue,
383}
384
385impl EventPropertyIsConditionData {
386 pub fn new(key: String, value: ScalarJsonValue) -> Self {
388 Self { key, value }
389 }
390
391 fn applies(&self, event: &FlattenedJson) -> bool {
397 event.get(&self.key).is_some_and(|v| *v == self.value)
398 }
399}
400
401#[derive(Clone, Debug, Serialize, Deserialize)]
403#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
404pub struct EventPropertyContainsConditionData {
405 pub key: String,
409
410 pub value: ScalarJsonValue,
412}
413
414impl EventPropertyContainsConditionData {
415 pub fn new(key: String, value: ScalarJsonValue) -> Self {
417 Self { key, value }
418 }
419
420 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#[derive(Clone, Debug, Serialize, Deserialize)]
435#[cfg(feature = "unstable-msc4306")]
436#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
437pub struct ThreadSubscriptionConditionData {
438 pub subscribed: bool,
441}
442
443#[cfg(feature = "unstable-msc4306")]
444impl ThreadSubscriptionConditionData {
445 pub fn new(subscribed: bool) -> Self {
447 Self { subscribed }
448 }
449
450 async fn applies(&self, event: &FlattenedJson, context: &PushConditionRoomCtx) -> bool {
460 let Some(has_thread_subscription_fn) = &context.has_thread_subscription_fn else {
461 return false;
464 };
465
466 if event.get_str("content.m\\.relates_to.rel_type") != Some("m.thread") {
468 return false;
469 }
470
471 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#[doc(hidden)]
486#[derive(Clone, Debug, Serialize)]
487#[allow(clippy::exhaustive_structs)]
488pub struct _CustomPushCondition {
489 kind: String,
491
492 #[serde(flatten)]
494 data: JsonObject,
495}
496
497pub(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#[derive(Clone)]
518#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
519pub struct PushConditionRoomCtx {
520 pub room_id: OwnedRoomId,
522
523 pub member_count: UInt,
525
526 pub user_id: OwnedUserId,
528
529 pub user_display_name: String,
531
532 pub power_levels: Option<PushConditionPowerLevelsCtx>,
536
537 #[cfg(feature = "unstable-msc3931")]
539 pub supported_features: Vec<RoomVersionFeature>,
540
541 #[cfg(feature = "unstable-msc4306")]
547 has_thread_subscription_fn: Option<Arc<HasThreadSubscriptionFn>>,
548
549 #[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 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 #[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 pub fn with_power_levels(self, power_levels: PushConditionPowerLevelsCtx) -> Self {
633 Self { power_levels: Some(power_levels), ..self }
634 }
635}
636
637#[derive(Clone, Debug)]
641#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
642pub struct PushConditionPowerLevelsCtx {
643 pub users: BTreeMap<OwnedUserId, Int>,
645
646 pub users_default: Int,
648
649 pub notifications: NotificationPowerLevels,
651
652 pub rules: RoomPowerLevelsRules,
654}
655
656impl PushConditionPowerLevelsCtx {
657 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 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 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
693trait CharExt {
695 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
705trait StrExt {
707 fn char_len(&self, index: usize) -> usize;
710
711 fn char_at(&self, index: usize) -> char;
714
715 fn find_prev_char(&self, index: usize) -> Option<char>;
720
721 fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool;
729
730 fn matches_word(&self, pattern: &str) -> bool;
739
740 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 let regex = format!(r"(?-u:^|\W|\b){}(?-u:\b|\W|$)", chunks.concat());
828 let re = Regex::new(®ex).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 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 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 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 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 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 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 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 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 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 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}