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_matches::assert_matches;
886 use js_int::{Int, int, uint};
887 use macro_rules_attribute::apply;
888 use serde_json::{from_value as from_json_value, json};
889 use smol_macros::test;
890 use strass::assert_let;
891
892 use super::{
893 EventMatchConditionData, EventPropertyContainsConditionData, EventPropertyIsConditionData,
894 FlattenedJson, PushCondition, PushConditionPowerLevelsCtx, PushConditionRoomCtx,
895 RoomMemberCountConditionData, RoomMemberCountIs, SenderNotificationPermissionConditionData,
896 StrExt,
897 };
898 use crate::{
899 OwnedUserId, assert_to_canonical_json_eq, owned_room_id, owned_user_id,
900 power_levels::{NotificationPowerLevels, NotificationPowerLevelsKey},
901 room_version_rules::{AuthorizationRules, RoomPowerLevelsRules},
902 };
903
904 #[test]
905 fn serialize_event_match_condition() {
906 assert_to_canonical_json_eq!(
907 PushCondition::EventMatch(EventMatchConditionData::new(
908 "content.msgtype".into(),
909 "m.notice".into()
910 )),
911 json!({
912 "key": "content.msgtype",
913 "kind": "event_match",
914 "pattern": "m.notice"
915 }),
916 );
917 }
918
919 #[test]
920 #[allow(deprecated)]
921 fn serialize_contains_display_name_condition() {
922 assert_to_canonical_json_eq!(
923 PushCondition::ContainsDisplayName,
924 json!({ "kind": "contains_display_name" }),
925 );
926 }
927
928 #[test]
929 fn serialize_room_member_count_condition() {
930 assert_to_canonical_json_eq!(
931 PushCondition::RoomMemberCount(RoomMemberCountConditionData::new(
932 RoomMemberCountIs::from(uint!(2))
933 )),
934 json!({
935 "is": "2",
936 "kind": "room_member_count"
937 }),
938 );
939 }
940
941 #[test]
942 fn serialize_sender_notification_permission_condition() {
943 assert_to_canonical_json_eq!(
944 PushCondition::SenderNotificationPermission(
945 SenderNotificationPermissionConditionData::new("room".into())
946 ),
947 json!({
948 "key": "room",
949 "kind": "sender_notification_permission"
950 }),
951 );
952 }
953
954 #[test]
955 fn deserialize_event_match_condition() {
956 let json_data = json!({
957 "key": "content.msgtype",
958 "kind": "event_match",
959 "pattern": "m.notice"
960 });
961 assert_let!(
962 PushCondition::EventMatch(condition) =
963 from_json_value::<PushCondition>(json_data).unwrap()
964 );
965 assert_eq!(condition.key, "content.msgtype");
966 assert_eq!(condition.pattern, "m.notice");
967 }
968
969 #[test]
970 #[allow(deprecated)]
971 fn deserialize_contains_display_name_condition() {
972 assert_matches!(
973 from_json_value::<PushCondition>(json!({ "kind": "contains_display_name" })).unwrap(),
974 PushCondition::ContainsDisplayName
975 );
976 }
977
978 #[test]
979 fn deserialize_room_member_count_condition() {
980 let json_data = json!({
981 "is": "2",
982 "kind": "room_member_count"
983 });
984 assert_let!(
985 PushCondition::RoomMemberCount(condition) =
986 from_json_value::<PushCondition>(json_data).unwrap()
987 );
988 assert_eq!(condition.is, RoomMemberCountIs::from(uint!(2)));
989 }
990
991 #[test]
992 fn deserialize_sender_notification_permission_condition() {
993 let json_data = json!({
994 "key": "room",
995 "kind": "sender_notification_permission"
996 });
997 assert_let!(
998 PushCondition::SenderNotificationPermission(condition) =
999 from_json_value::<PushCondition>(json_data).unwrap()
1000 );
1001 assert_eq!(condition.key, NotificationPowerLevelsKey::Room);
1002 }
1003
1004 #[test]
1005 fn words_match() {
1006 assert!("foo bar".matches_word("foo"));
1007 assert!(!"Foo bar".matches_word("foo"));
1008 assert!(!"foobar".matches_word("foo"));
1009 assert!("foobar foo".matches_word("foo"));
1010 assert!(!"foobar foobar".matches_word("foo"));
1011 assert!(!"foobar bar".matches_word("bar bar"));
1012 assert!("foobar bar bar".matches_word("bar bar"));
1013 assert!(!"foobar bar barfoo".matches_word("bar bar"));
1014 assert!("conduit ⚡️".matches_word("conduit ⚡️"));
1015 assert!("conduit ⚡️".matches_word("conduit"));
1016 assert!("conduit ⚡️".matches_word("⚡️"));
1017 assert!("conduit⚡️".matches_word("conduit"));
1018 assert!("conduit⚡️".matches_word("⚡️"));
1019 assert!("⚡️conduit".matches_word("conduit"));
1020 assert!("⚡️conduit".matches_word("⚡️"));
1021 assert!("Ruma Dev👩💻".matches_word("Dev"));
1022 assert!("Ruma Dev👩💻".matches_word("👩💻"));
1023 assert!("Ruma Dev👩💻".matches_word("Dev👩💻"));
1024
1025 assert!(!"matrix".matches_word(r"\w*"));
1027 assert!(r"\w".matches_word(r"\w*"));
1028 assert!(!"matrix".matches_word("[a-z]*"));
1029 assert!("[a-z] and [0-9]".matches_word("[a-z]*"));
1030 assert!(!"m".matches_word("[[:alpha:]]?"));
1031 assert!("[[:alpha:]]!".matches_word("[[:alpha:]]?"));
1032
1033 assert!("An example event.".matches_word("ex*ple"));
1035 assert!("exple".matches_word("ex*ple"));
1036 assert!("An exciting triple-whammy".matches_word("ex*ple"));
1037 }
1038
1039 #[test]
1040 fn patterns_match() {
1041 assert!("foo bar".matches_pattern("foo", true));
1043 assert!("Foo bar".matches_pattern("foo", true));
1044 assert!(!"foobar".matches_pattern("foo", true));
1045 assert!("".matches_pattern("", true));
1046 assert!(!"foo".matches_pattern("", true));
1047 assert!("foo bar".matches_pattern("foo bar", true));
1048 assert!(" foo bar ".matches_pattern("foo bar", true));
1049 assert!("baz foo bar baz".matches_pattern("foo bar", true));
1050 assert!("foo baré".matches_pattern("foo bar", true));
1051 assert!(!"bar foo".matches_pattern("foo bar", true));
1052 assert!("foo bar".matches_pattern("foo ", true));
1053 assert!("foo ".matches_pattern("foo ", true));
1054 assert!("foo ".matches_pattern("foo ", true));
1055 assert!(" foo ".matches_pattern("foo ", true));
1056
1057 assert!("foo bar".matches_pattern("foo*", true));
1059 assert!("foo bar".matches_pattern("foo b?r", true));
1060 assert!(" foo bar ".matches_pattern("foo b?r", true));
1061 assert!("baz foo bar baz".matches_pattern("foo b?r", true));
1062 assert!("foo baré".matches_pattern("foo b?r", true));
1063 assert!(!"bar foo".matches_pattern("foo b?r", true));
1064 assert!("foo bar".matches_pattern("f*o ", true));
1065 assert!("foo ".matches_pattern("f*o ", true));
1066 assert!("foo ".matches_pattern("f*o ", true));
1067 assert!(" foo ".matches_pattern("f*o ", true));
1068
1069 assert!(!"foo bar".matches_pattern("foo", false));
1071 assert!("foo".matches_pattern("foo", false));
1072 assert!("foo".matches_pattern("foo*", false));
1073 assert!("foobar".matches_pattern("foo*", false));
1074 assert!("foo bar".matches_pattern("foo*", false));
1075 assert!(!"foo".matches_pattern("foo?", false));
1076 assert!("fooo".matches_pattern("foo?", false));
1077 assert!("FOO".matches_pattern("foo", false));
1078 assert!("".matches_pattern("", false));
1079 assert!("".matches_pattern("*", false));
1080 assert!(!"foo".matches_pattern("", false));
1081
1082 assert!("Lunch plans".matches_pattern("lunc?*", false));
1084 assert!("LUNCH".matches_pattern("lunc?*", false));
1085 assert!(!" lunch".matches_pattern("lunc?*", false));
1086 assert!(!"lunc".matches_pattern("lunc?*", false));
1087 }
1088
1089 fn sender() -> OwnedUserId {
1090 owned_user_id!("@worthy_whale:server.name")
1091 }
1092
1093 fn push_context() -> PushConditionRoomCtx {
1094 let mut users = BTreeMap::new();
1095 users.insert(sender(), int!(25));
1096
1097 let power_levels = PushConditionPowerLevelsCtx {
1098 users,
1099 users_default: int!(50),
1100 notifications: NotificationPowerLevels { room: int!(50) },
1101 rules: RoomPowerLevelsRules::new(&AuthorizationRules::V1, None),
1102 };
1103
1104 let mut ctx = PushConditionRoomCtx::new(
1105 owned_room_id!("!room:server.name"),
1106 uint!(3),
1107 owned_user_id!("@gorilla:server.name"),
1108 "Groovy Gorilla".into(),
1109 );
1110 ctx.power_levels = Some(power_levels);
1111 ctx
1112 }
1113
1114 fn first_flattened_event() -> FlattenedJson {
1115 FlattenedJson::from_value(json!({
1116 "sender": "@worthy_whale:server.name",
1117 "content": {
1118 "msgtype": "m.text",
1119 "body": "@room Give a warm welcome to Groovy Gorilla",
1120 },
1121 }))
1122 }
1123
1124 fn second_flattened_event() -> FlattenedJson {
1125 FlattenedJson::from_value(json!({
1126 "sender": "@party_bot:server.name",
1127 "content": {
1128 "msgtype": "m.notice",
1129 "body": "Everybody come to party!",
1130 },
1131 }))
1132 }
1133
1134 #[apply(test!)]
1135 async fn event_match_applies() {
1136 let context = push_context();
1137 let first_event = first_flattened_event();
1138 let second_event = second_flattened_event();
1139
1140 let correct_room = PushCondition::EventMatch(EventMatchConditionData::new(
1141 "room_id".into(),
1142 "!room:server.name".into(),
1143 ));
1144 let incorrect_room = PushCondition::EventMatch(EventMatchConditionData::new(
1145 "room_id".into(),
1146 "!incorrect:server.name".into(),
1147 ));
1148
1149 assert!(correct_room.applies(&first_event, &context).await);
1150 assert!(!incorrect_room.applies(&first_event, &context).await);
1151
1152 let keyword = PushCondition::EventMatch(EventMatchConditionData::new(
1153 "content.body".into(),
1154 "come".into(),
1155 ));
1156
1157 assert!(!keyword.applies(&first_event, &context).await);
1158 assert!(keyword.applies(&second_event, &context).await);
1159
1160 let msgtype = PushCondition::EventMatch(EventMatchConditionData::new(
1161 "content.msgtype".into(),
1162 "m.notice".into(),
1163 ));
1164
1165 assert!(!msgtype.applies(&first_event, &context).await);
1166 assert!(msgtype.applies(&second_event, &context).await);
1167 }
1168
1169 #[apply(test!)]
1170 async fn room_member_count_is_applies() {
1171 let context = push_context();
1172 let event = first_flattened_event();
1173
1174 let member_count_eq = PushCondition::RoomMemberCount(RoomMemberCountConditionData::new(
1175 RoomMemberCountIs::from(uint!(3)),
1176 ));
1177 let member_count_gt = PushCondition::RoomMemberCount(RoomMemberCountConditionData::new(
1178 RoomMemberCountIs::from(uint!(2)..),
1179 ));
1180 let member_count_lt = PushCondition::RoomMemberCount(RoomMemberCountConditionData::new(
1181 RoomMemberCountIs::from(..uint!(3)),
1182 ));
1183
1184 assert!(member_count_eq.applies(&event, &context).await);
1185 assert!(member_count_gt.applies(&event, &context).await);
1186 assert!(!member_count_lt.applies(&event, &context).await);
1187 }
1188
1189 #[apply(test!)]
1190 #[allow(deprecated)]
1191 async fn contains_display_name_applies() {
1192 let context = push_context();
1193 let first_event = first_flattened_event();
1194 let second_event = second_flattened_event();
1195
1196 let contains_display_name = PushCondition::ContainsDisplayName;
1197
1198 assert!(contains_display_name.applies(&first_event, &context).await);
1199 assert!(!contains_display_name.applies(&second_event, &context).await);
1200 }
1201
1202 #[apply(test!)]
1203 async fn sender_notification_permission_applies() {
1204 let context = push_context();
1205 let first_event = first_flattened_event();
1206 let second_event = second_flattened_event();
1207
1208 let sender_notification_permission = PushCondition::SenderNotificationPermission(
1209 SenderNotificationPermissionConditionData::new("room".into()),
1210 );
1211
1212 assert!(!sender_notification_permission.applies(&first_event, &context).await);
1213 assert!(sender_notification_permission.applies(&second_event, &context).await);
1214 }
1215
1216 #[cfg(feature = "unstable-msc3932")]
1217 #[apply(test!)]
1218 async fn room_version_supports_applies() {
1219 use assign::assign;
1220
1221 use super::{RoomVersionFeature, RoomVersionSupportsConditionData};
1222
1223 let context_not_matching = push_context();
1224 let context_matching = assign!(
1225 PushConditionRoomCtx::new(
1226 owned_room_id!("!room:server.name"),
1227 uint!(3),
1228 owned_user_id!("@gorilla:server.name"),
1229 "Groovy Gorilla".into(),
1230 ), {
1231 power_levels: context_not_matching.power_levels.clone(),
1232 supported_features: vec![RoomVersionFeature::ExtensibleEvents],
1233 }
1234 );
1235
1236 let simple_event = FlattenedJson::from_value(json!({
1237 "sender": "@worthy_whale:server.name",
1238 "content": {
1239 "msgtype": "org.matrix.msc3932.extensible_events",
1240 "body": "@room Give a warm welcome to Groovy Gorilla",
1241 },
1242 }));
1243
1244 let room_version_condition = PushCondition::RoomVersionSupports(
1245 RoomVersionSupportsConditionData::new(RoomVersionFeature::ExtensibleEvents),
1246 );
1247
1248 assert!(room_version_condition.applies(&simple_event, &context_matching).await);
1249 assert!(!room_version_condition.applies(&simple_event, &context_not_matching).await);
1250 }
1251
1252 #[apply(test!)]
1253 async fn event_property_is_applies() {
1254 use crate::push::condition::ScalarJsonValue;
1255
1256 let context = push_context();
1257 let event = FlattenedJson::from_value(json!({
1258 "sender": "@worthy_whale:server.name",
1259 "content": {
1260 "msgtype": "m.text",
1261 "body": "Boom!",
1262 "org.fake.boolean": false,
1263 "org.fake.number": 13,
1264 "org.fake.null": null,
1265 },
1266 }));
1267
1268 let string_match = PushCondition::EventPropertyIs(EventPropertyIsConditionData::new(
1269 "content.body".to_owned(),
1270 "Boom!".into(),
1271 ));
1272 assert!(string_match.applies(&event, &context).await);
1273
1274 let string_no_match = PushCondition::EventPropertyIs(EventPropertyIsConditionData::new(
1275 "content.body".to_owned(),
1276 "Boom".into(),
1277 ));
1278 assert!(!string_no_match.applies(&event, &context).await);
1279
1280 let wrong_type = PushCondition::EventPropertyIs(EventPropertyIsConditionData::new(
1281 "content.body".to_owned(),
1282 false.into(),
1283 ));
1284 assert!(!wrong_type.applies(&event, &context).await);
1285
1286 let bool_match = PushCondition::EventPropertyIs(EventPropertyIsConditionData::new(
1287 r"content.org\.fake\.boolean".to_owned(),
1288 false.into(),
1289 ));
1290 assert!(bool_match.applies(&event, &context).await);
1291
1292 let bool_no_match = PushCondition::EventPropertyIs(EventPropertyIsConditionData::new(
1293 r"content.org\.fake\.boolean".to_owned(),
1294 true.into(),
1295 ));
1296 assert!(!bool_no_match.applies(&event, &context).await);
1297
1298 let int_match = PushCondition::EventPropertyIs(EventPropertyIsConditionData::new(
1299 r"content.org\.fake\.number".to_owned(),
1300 int!(13).into(),
1301 ));
1302 assert!(int_match.applies(&event, &context).await);
1303
1304 let int_no_match = PushCondition::EventPropertyIs(EventPropertyIsConditionData::new(
1305 r"content.org\.fake\.number".to_owned(),
1306 int!(130).into(),
1307 ));
1308 assert!(!int_no_match.applies(&event, &context).await);
1309
1310 let null_match = PushCondition::EventPropertyIs(EventPropertyIsConditionData::new(
1311 r"content.org\.fake\.null".to_owned(),
1312 ScalarJsonValue::Null,
1313 ));
1314 assert!(null_match.applies(&event, &context).await);
1315 }
1316
1317 #[apply(test!)]
1318 async fn event_property_contains_applies() {
1319 use crate::push::condition::ScalarJsonValue;
1320
1321 let context = push_context();
1322 let event = FlattenedJson::from_value(json!({
1323 "sender": "@worthy_whale:server.name",
1324 "content": {
1325 "org.fake.array": ["Boom!", false, 13, null],
1326 },
1327 }));
1328
1329 let wrong_key = PushCondition::EventPropertyContains(
1330 EventPropertyContainsConditionData::new("send".to_owned(), false.into()),
1331 );
1332 assert!(!wrong_key.applies(&event, &context).await);
1333
1334 let string_match =
1335 PushCondition::EventPropertyContains(EventPropertyContainsConditionData::new(
1336 r"content.org\.fake\.array".to_owned(),
1337 "Boom!".into(),
1338 ));
1339 assert!(string_match.applies(&event, &context).await);
1340
1341 let string_no_match =
1342 PushCondition::EventPropertyContains(EventPropertyContainsConditionData::new(
1343 r"content.org\.fake\.array".to_owned(),
1344 "Boom".into(),
1345 ));
1346 assert!(!string_no_match.applies(&event, &context).await);
1347
1348 let bool_match =
1349 PushCondition::EventPropertyContains(EventPropertyContainsConditionData::new(
1350 r"content.org\.fake\.array".to_owned(),
1351 false.into(),
1352 ));
1353 assert!(bool_match.applies(&event, &context).await);
1354
1355 let bool_no_match =
1356 PushCondition::EventPropertyContains(EventPropertyContainsConditionData::new(
1357 r"content.org\.fake\.array".to_owned(),
1358 true.into(),
1359 ));
1360 assert!(!bool_no_match.applies(&event, &context).await);
1361
1362 let int_match =
1363 PushCondition::EventPropertyContains(EventPropertyContainsConditionData::new(
1364 r"content.org\.fake\.array".to_owned(),
1365 int!(13).into(),
1366 ));
1367 assert!(int_match.applies(&event, &context).await);
1368
1369 let int_no_match =
1370 PushCondition::EventPropertyContains(EventPropertyContainsConditionData::new(
1371 r"content.org\.fake\.array".to_owned(),
1372 int!(130).into(),
1373 ));
1374 assert!(!int_no_match.applies(&event, &context).await);
1375
1376 let null_match =
1377 PushCondition::EventPropertyContains(EventPropertyContainsConditionData::new(
1378 r"content.org\.fake\.array".to_owned(),
1379 ScalarJsonValue::Null,
1380 ));
1381 assert!(null_match.applies(&event, &context).await);
1382 }
1383
1384 #[apply(test!)]
1385 async fn room_creators_always_have_notification_permission() {
1386 let mut context = push_context();
1387 context.power_levels = Some(PushConditionPowerLevelsCtx {
1388 users: BTreeMap::new(),
1389 users_default: Int::MIN,
1390 notifications: NotificationPowerLevels { room: Int::MAX },
1391 rules: RoomPowerLevelsRules::new(&AuthorizationRules::V12, Some(sender())),
1392 });
1393
1394 let first_event = first_flattened_event();
1395
1396 let sender_notification_permission = PushCondition::SenderNotificationPermission(
1397 SenderNotificationPermissionConditionData::new(NotificationPowerLevelsKey::Room),
1398 );
1399
1400 assert!(sender_notification_permission.applies(&first_event, &context).await);
1401 }
1402
1403 #[cfg(feature = "unstable-msc4306")]
1404 #[apply(test!)]
1405 async fn thread_subscriptions_match() {
1406 use super::ThreadSubscriptionConditionData;
1407 use crate::{EventId, event_id};
1408
1409 let context = push_context().with_has_thread_subscription_fn(|event_id: &EventId| {
1410 Box::pin(async move {
1411 event_id == event_id!("$subscribed_thread")
1413 })
1414 });
1415
1416 let subscribed_thread_event = FlattenedJson::from_value(json!({
1417 "event_id": "$thread_response",
1418 "sender": "@worthy_whale:server.name",
1419 "content": {
1420 "msgtype": "m.text",
1421 "body": "response in thread $subscribed_thread",
1422 "m.relates_to": {
1423 "rel_type": "m.thread",
1424 "event_id": "$subscribed_thread",
1425 "is_falling_back": true,
1426 "m.in_reply_to": {
1427 "event_id": "$prev_event",
1428 },
1429 },
1430 },
1431 }));
1432
1433 let unsubscribed_thread_event = FlattenedJson::from_value(json!({
1434 "event_id": "$thread_response2",
1435 "sender": "@worthy_whale:server.name",
1436 "content": {
1437 "msgtype": "m.text",
1438 "body": "response in thread $unsubscribed_thread",
1439 "m.relates_to": {
1440 "rel_type": "m.thread",
1441 "event_id": "$unsubscribed_thread",
1442 "is_falling_back": true,
1443 "m.in_reply_to": {
1444 "event_id": "$prev_event2",
1445 },
1446 },
1447 },
1448 }));
1449
1450 let non_thread_related_event = FlattenedJson::from_value(json!({
1451 "event_id": "$thread_response2",
1452 "sender": "@worthy_whale:server.name",
1453 "content": {
1454 "m.relates_to": {
1455 "rel_type": "m.reaction",
1456 "event_id": "$subscribed_thread",
1457 "key": "👍",
1458 },
1459 },
1460 }));
1461
1462 let subscribed_thread_condition =
1463 PushCondition::ThreadSubscription(ThreadSubscriptionConditionData::new(true));
1464 assert!(subscribed_thread_condition.applies(&subscribed_thread_event, &context).await);
1465 assert!(!subscribed_thread_condition.applies(&unsubscribed_thread_event, &context).await);
1466 assert!(!subscribed_thread_condition.applies(&non_thread_related_event, &context).await);
1467
1468 let unsubscribed_thread_condition =
1469 PushCondition::ThreadSubscription(ThreadSubscriptionConditionData::new(false));
1470 assert!(unsubscribed_thread_condition.applies(&unsubscribed_thread_event, &context).await);
1471 assert!(!unsubscribed_thread_condition.applies(&subscribed_thread_event, &context).await);
1472 assert!(!unsubscribed_thread_condition.applies(&non_thread_related_event, &context).await);
1473 }
1474
1475 #[test]
1476 fn custom_condition_roundtrip() {
1477 let json = json!({
1478 "kind": "local_dev_custom",
1479 "foo": "bar"
1480 });
1481
1482 let condition = from_json_value::<PushCondition>(json.clone()).unwrap();
1483 assert_eq!(condition.kind(), "local_dev_custom");
1484 let data = condition.data();
1485 assert_eq!(data.len(), 1);
1486 assert_eq!(data["foo"], "bar");
1487
1488 assert_to_canonical_json_eq!(condition, json);
1489 }
1490}