1use std::{
2 collections::BTreeMap, future::Future, ops::RangeBounds, pin::Pin, str::FromStr, sync::Arc,
3};
4
5use js_int::{Int, UInt};
6use regex::bytes::Regex;
7#[cfg(feature = "unstable-msc3931")]
8use ruma_macros::StringEnum;
9use serde::{Deserialize, Serialize};
10use serde_json::value::Value as JsonValue;
11use wildmatch::WildMatch;
12
13use crate::{
14 EventId, OwnedRoomId, OwnedUserId, UserId,
15 power_levels::{NotificationPowerLevels, NotificationPowerLevelsKey},
16 room_version_rules::RoomPowerLevelsRules,
17};
18#[cfg(feature = "unstable-msc3931")]
19use crate::{PrivOwnedStr, RoomVersionId};
20
21mod flattened_json;
22mod push_condition_serde;
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#[cfg(feature = "unstable-msc3931")]
32#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
33#[derive(Clone, StringEnum)]
34#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
35pub enum RoomVersionFeature {
36 #[cfg(feature = "unstable-msc3932")]
42 #[ruma_enum(rename = "org.matrix.msc3932.extensible_events")]
43 ExtensibleEvents,
44
45 #[doc(hidden)]
46 _Custom(PrivOwnedStr),
47}
48
49#[cfg(feature = "unstable-msc3931")]
50impl RoomVersionFeature {
51 pub fn list_for_room_version(version: &RoomVersionId) -> Vec<Self> {
53 match version {
54 RoomVersionId::V1
55 | RoomVersionId::V2
56 | RoomVersionId::V3
57 | RoomVersionId::V4
58 | RoomVersionId::V5
59 | RoomVersionId::V6
60 | RoomVersionId::V7
61 | RoomVersionId::V8
62 | RoomVersionId::V9
63 | RoomVersionId::V10
64 | RoomVersionId::V11
65 | RoomVersionId::V12
66 | RoomVersionId::_Custom(_) => vec![],
67 #[cfg(feature = "unstable-msc2870")]
68 RoomVersionId::MSC2870 => vec![],
69 }
70 }
71}
72
73#[derive(Clone, Debug)]
75#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
76pub enum PushCondition {
77 EventMatch {
79 key: String,
83
84 pattern: String,
89 },
90
91 #[deprecated]
94 ContainsDisplayName,
95
96 RoomMemberCount {
98 is: RoomMemberCountIs,
100 },
101
102 SenderNotificationPermission {
105 key: NotificationPowerLevelsKey,
110 },
111
112 #[cfg(feature = "unstable-msc3931")]
114 RoomVersionSupports {
115 feature: RoomVersionFeature,
117 },
118
119 EventPropertyIs {
121 key: String,
125
126 value: ScalarJsonValue,
128 },
129
130 EventPropertyContains {
132 key: String,
136
137 value: ScalarJsonValue,
139 },
140
141 #[cfg(feature = "unstable-msc4306")]
146 ThreadSubscription {
147 subscribed: bool,
150 },
151
152 #[doc(hidden)]
153 _Custom(_CustomPushCondition),
154}
155
156pub(super) fn check_event_match(
157 event: &FlattenedJson,
158 key: &str,
159 pattern: &str,
160 context: &PushConditionRoomCtx,
161) -> bool {
162 let value = match key {
163 "room_id" => context.room_id.as_str(),
164 _ => match event.get_str(key) {
165 Some(v) => v,
166 None => return false,
167 },
168 };
169
170 value.matches_pattern(pattern, key == "content.body")
171}
172
173impl PushCondition {
174 pub async fn applies(&self, event: &FlattenedJson, context: &PushConditionRoomCtx) -> bool {
182 if event.get_str("sender").is_some_and(|sender| sender == context.user_id) {
183 return false;
184 }
185
186 match self {
187 Self::EventMatch { key, pattern } => check_event_match(event, key, pattern, context),
188 #[allow(deprecated)]
189 Self::ContainsDisplayName => {
190 let Some(value) = event.get_str("content.body") else { return false };
191 value.matches_pattern(&context.user_display_name, true)
192 }
193 Self::RoomMemberCount { is } => is.contains(&context.member_count),
194 Self::SenderNotificationPermission { key } => {
195 let Some(power_levels) = &context.power_levels else { return false };
196 let Some(sender_id) = event.get_str("sender") else { return false };
197 let Ok(sender_id) = <&UserId>::try_from(sender_id) else { return false };
198
199 power_levels.has_sender_notification_permission(sender_id, key)
200 }
201 #[cfg(feature = "unstable-msc3931")]
202 Self::RoomVersionSupports { feature } => match feature {
203 RoomVersionFeature::ExtensibleEvents => {
204 context.supported_features.contains(&RoomVersionFeature::ExtensibleEvents)
205 }
206 RoomVersionFeature::_Custom(_) => false,
207 },
208 Self::EventPropertyIs { key, value } => event.get(key).is_some_and(|v| v == value),
209 Self::EventPropertyContains { key, value } => event
210 .get(key)
211 .and_then(FlattenedJsonValue::as_array)
212 .is_some_and(|a| a.contains(value)),
213 #[cfg(feature = "unstable-msc4306")]
214 Self::ThreadSubscription { subscribed: must_be_subscribed } => {
215 let Some(has_thread_subscription_fn) = &context.has_thread_subscription_fn else {
216 return false;
219 };
220
221 if event.get_str("content.m\\.relates_to.rel_type") != Some("m.thread") {
223 return false;
224 }
225
226 let Some(Ok(thread_root)) =
228 event.get_str("content.m\\.relates_to.event_id").map(<&EventId>::try_from)
229 else {
230 return false;
231 };
232
233 let is_subscribed = has_thread_subscription_fn(thread_root).await;
234
235 *must_be_subscribed == is_subscribed
236 }
237 Self::_Custom(_) => false,
238 }
239 }
240}
241
242#[doc(hidden)]
244#[derive(Clone, Debug, Deserialize, Serialize)]
245#[allow(clippy::exhaustive_structs)]
246pub struct _CustomPushCondition {
247 kind: String,
249
250 #[serde(flatten)]
252 data: BTreeMap<String, JsonValue>,
253}
254
255#[derive(Clone)]
257#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
258pub struct PushConditionRoomCtx {
259 pub room_id: OwnedRoomId,
261
262 pub member_count: UInt,
264
265 pub user_id: OwnedUserId,
267
268 pub user_display_name: String,
270
271 pub power_levels: Option<PushConditionPowerLevelsCtx>,
275
276 #[cfg(feature = "unstable-msc3931")]
278 pub supported_features: Vec<RoomVersionFeature>,
279
280 #[cfg(feature = "unstable-msc4306")]
286 has_thread_subscription_fn: Option<Arc<HasThreadSubscriptionFn>>,
287
288 #[cfg(not(feature = "unstable-msc4306"))]
293 has_thread_subscription_fn: std::marker::PhantomData<Arc<HasThreadSubscriptionFn>>,
294}
295
296#[cfg(not(target_family = "wasm"))]
297type HasThreadSubscriptionFuture<'a> = Pin<Box<dyn Future<Output = bool> + Send + 'a>>;
298
299#[cfg(target_family = "wasm")]
300type HasThreadSubscriptionFuture<'a> = Pin<Box<dyn Future<Output = bool> + 'a>>;
301
302#[cfg(not(target_family = "wasm"))]
303type HasThreadSubscriptionFn =
304 dyn for<'a> Fn(&'a EventId) -> HasThreadSubscriptionFuture<'a> + Send + Sync;
305
306#[cfg(target_family = "wasm")]
307type HasThreadSubscriptionFn = dyn for<'a> Fn(&'a EventId) -> HasThreadSubscriptionFuture<'a>;
308
309impl std::fmt::Debug for PushConditionRoomCtx {
310 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311 let mut debug_struct = f.debug_struct("PushConditionRoomCtx");
312
313 debug_struct
314 .field("room_id", &self.room_id)
315 .field("member_count", &self.member_count)
316 .field("user_id", &self.user_id)
317 .field("user_display_name", &self.user_display_name)
318 .field("power_levels", &self.power_levels);
319
320 #[cfg(feature = "unstable-msc3931")]
321 debug_struct.field("supported_features", &self.supported_features);
322
323 debug_struct.finish_non_exhaustive()
324 }
325}
326
327impl PushConditionRoomCtx {
328 pub fn new(
330 room_id: OwnedRoomId,
331 member_count: UInt,
332 user_id: OwnedUserId,
333 user_display_name: String,
334 ) -> Self {
335 Self {
336 room_id,
337 member_count,
338 user_id,
339 user_display_name,
340 power_levels: None,
341 #[cfg(feature = "unstable-msc3931")]
342 supported_features: Vec::new(),
343 has_thread_subscription_fn: Default::default(),
344 }
345 }
346
347 #[cfg(feature = "unstable-msc4306")]
352 pub fn with_has_thread_subscription_fn(
353 self,
354 #[cfg(not(target_family = "wasm"))]
355 has_thread_subscription_fn: impl for<'a> Fn(
356 &'a EventId,
357 ) -> HasThreadSubscriptionFuture<'a>
358 + Send
359 + Sync
360 + 'static,
361 #[cfg(target_family = "wasm")]
362 has_thread_subscription_fn: impl for<'a> Fn(
363 &'a EventId,
364 ) -> HasThreadSubscriptionFuture<'a>
365 + 'static,
366 ) -> Self {
367 Self { has_thread_subscription_fn: Some(Arc::new(has_thread_subscription_fn)), ..self }
368 }
369
370 pub fn with_power_levels(self, power_levels: PushConditionPowerLevelsCtx) -> Self {
372 Self { power_levels: Some(power_levels), ..self }
373 }
374}
375
376#[derive(Clone, Debug)]
380#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
381pub struct PushConditionPowerLevelsCtx {
382 pub users: BTreeMap<OwnedUserId, Int>,
384
385 pub users_default: Int,
387
388 pub notifications: NotificationPowerLevels,
390
391 pub rules: RoomPowerLevelsRules,
393}
394
395impl PushConditionPowerLevelsCtx {
396 pub fn new(
398 users: BTreeMap<OwnedUserId, Int>,
399 users_default: Int,
400 notifications: NotificationPowerLevels,
401 rules: RoomPowerLevelsRules,
402 ) -> Self {
403 Self { users, users_default, notifications, rules }
404 }
405
406 pub fn has_sender_notification_permission(
408 &self,
409 user_id: &UserId,
410 key: &NotificationPowerLevelsKey,
411 ) -> bool {
412 let Some(notification_power_level) = self.notifications.get(key) else {
413 return false;
415 };
416
417 if self
418 .rules
419 .privileged_creators
420 .as_ref()
421 .is_some_and(|creators| creators.contains(user_id))
422 {
423 return true;
424 }
425
426 let user_power_level = self.users.get(user_id).unwrap_or(&self.users_default);
427
428 user_power_level >= notification_power_level
429 }
430}
431
432trait CharExt {
434 fn is_word_char(&self) -> bool;
436}
437
438impl CharExt for char {
439 fn is_word_char(&self) -> bool {
440 self.is_ascii_alphanumeric() || *self == '_'
441 }
442}
443
444trait StrExt {
446 fn char_len(&self, index: usize) -> usize;
449
450 fn char_at(&self, index: usize) -> char;
453
454 fn find_prev_char(&self, index: usize) -> Option<char>;
459
460 fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool;
468
469 fn matches_word(&self, pattern: &str) -> bool;
478
479 fn wildcards_to_regex(&self) -> String;
483}
484
485impl StrExt for str {
486 fn char_len(&self, index: usize) -> usize {
487 let mut len = 1;
488 while !self.is_char_boundary(index + len) {
489 len += 1;
490 }
491 len
492 }
493
494 fn char_at(&self, index: usize) -> char {
495 let end = index + self.char_len(index);
496 let char_str = &self[index..end];
497 char::from_str(char_str)
498 .unwrap_or_else(|_| panic!("Could not convert str '{char_str}' to char"))
499 }
500
501 fn find_prev_char(&self, index: usize) -> Option<char> {
502 if index == 0 {
503 return None;
504 }
505
506 let mut pos = index - 1;
507 while !self.is_char_boundary(pos) {
508 pos -= 1;
509 }
510 Some(self.char_at(pos))
511 }
512
513 fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool {
514 let value = &self.to_lowercase();
515 let pattern = &pattern.to_lowercase();
516
517 if match_words {
518 value.matches_word(pattern)
519 } else {
520 WildMatch::new(pattern).matches(value)
521 }
522 }
523
524 fn matches_word(&self, pattern: &str) -> bool {
525 if self == pattern {
526 return true;
527 }
528 if pattern.is_empty() {
529 return false;
530 }
531
532 let has_wildcards = pattern.contains(['?', '*']);
533
534 if has_wildcards {
535 let mut chunks: Vec<String> = vec![];
536 let mut prev_wildcard = false;
537 let mut chunk_start = 0;
538
539 for (i, c) in pattern.char_indices() {
540 if matches!(c, '?' | '*') && !prev_wildcard {
541 if i != 0 {
542 chunks.push(regex::escape(&pattern[chunk_start..i]));
543 chunk_start = i;
544 }
545
546 prev_wildcard = true;
547 } else if prev_wildcard {
548 let chunk = &pattern[chunk_start..i];
549 chunks.push(chunk.wildcards_to_regex());
550
551 chunk_start = i;
552 prev_wildcard = false;
553 }
554 }
555
556 let len = pattern.len();
557 if !prev_wildcard {
558 chunks.push(regex::escape(&pattern[chunk_start..len]));
559 } else if prev_wildcard {
560 let chunk = &pattern[chunk_start..len];
561 chunks.push(chunk.wildcards_to_regex());
562 }
563
564 let regex = format!(r"(?-u:^|\W|\b){}(?-u:\b|\W|$)", chunks.concat());
567 let re = Regex::new(®ex).expect("regex construction should succeed");
568 re.is_match(self.as_bytes())
569 } else {
570 match self.find(pattern) {
571 Some(start) => {
572 let end = start + pattern.len();
573
574 let word_boundary_start = !self.char_at(start).is_word_char()
576 || !self.find_prev_char(start).is_some_and(|c| c.is_word_char());
577
578 if word_boundary_start {
579 let word_boundary_end = end == self.len()
580 || !self.find_prev_char(end).unwrap().is_word_char()
581 || !self.char_at(end).is_word_char();
582
583 if word_boundary_end {
584 return true;
585 }
586 }
587
588 let non_word_str = &self[start..];
590 let Some(non_word) = non_word_str.find(|c: char| !c.is_word_char()) else {
591 return false;
592 };
593
594 let word_str = &non_word_str[non_word..];
595 let Some(word) = word_str.find(|c: char| c.is_word_char()) else {
596 return false;
597 };
598
599 word_str[word..].matches_word(pattern)
600 }
601 None => false,
602 }
603 }
604 }
605
606 fn wildcards_to_regex(&self) -> String {
607 let question_marks = self.matches('?').count();
611
612 if self.contains('*') {
613 format!(".{{{question_marks},}}")
614 } else {
615 format!(".{{{question_marks}}}")
616 }
617 }
618}
619
620#[cfg(test)]
621mod tests {
622 use std::collections::BTreeMap;
623
624 use assert_matches2::assert_matches;
625 use js_int::{Int, int, uint};
626 use macro_rules_attribute::apply;
627 use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
628 use smol_macros::test;
629
630 use super::{
631 FlattenedJson, PushCondition, PushConditionPowerLevelsCtx, PushConditionRoomCtx,
632 RoomMemberCountIs, StrExt,
633 };
634 use crate::{
635 OwnedUserId, owned_room_id, owned_user_id,
636 power_levels::{NotificationPowerLevels, NotificationPowerLevelsKey},
637 room_version_rules::{AuthorizationRules, RoomPowerLevelsRules},
638 };
639
640 #[test]
641 fn serialize_event_match_condition() {
642 let json_data = json!({
643 "key": "content.msgtype",
644 "kind": "event_match",
645 "pattern": "m.notice"
646 });
647 assert_eq!(
648 to_json_value(PushCondition::EventMatch {
649 key: "content.msgtype".into(),
650 pattern: "m.notice".into(),
651 })
652 .unwrap(),
653 json_data
654 );
655 }
656
657 #[test]
658 #[allow(deprecated)]
659 fn serialize_contains_display_name_condition() {
660 assert_eq!(
661 to_json_value(PushCondition::ContainsDisplayName).unwrap(),
662 json!({ "kind": "contains_display_name" })
663 );
664 }
665
666 #[test]
667 fn serialize_room_member_count_condition() {
668 let json_data = json!({
669 "is": "2",
670 "kind": "room_member_count"
671 });
672 assert_eq!(
673 to_json_value(PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(2)) })
674 .unwrap(),
675 json_data
676 );
677 }
678
679 #[test]
680 fn serialize_sender_notification_permission_condition() {
681 let json_data = json!({
682 "key": "room",
683 "kind": "sender_notification_permission"
684 });
685 assert_eq!(
686 json_data,
687 to_json_value(PushCondition::SenderNotificationPermission { key: "room".into() })
688 .unwrap()
689 );
690 }
691
692 #[test]
693 fn deserialize_event_match_condition() {
694 let json_data = json!({
695 "key": "content.msgtype",
696 "kind": "event_match",
697 "pattern": "m.notice"
698 });
699 assert_matches!(
700 from_json_value::<PushCondition>(json_data).unwrap(),
701 PushCondition::EventMatch { key, pattern }
702 );
703 assert_eq!(key, "content.msgtype");
704 assert_eq!(pattern, "m.notice");
705 }
706
707 #[test]
708 #[allow(deprecated)]
709 fn deserialize_contains_display_name_condition() {
710 assert_matches!(
711 from_json_value::<PushCondition>(json!({ "kind": "contains_display_name" })).unwrap(),
712 PushCondition::ContainsDisplayName
713 );
714 }
715
716 #[test]
717 fn deserialize_room_member_count_condition() {
718 let json_data = json!({
719 "is": "2",
720 "kind": "room_member_count"
721 });
722 assert_matches!(
723 from_json_value::<PushCondition>(json_data).unwrap(),
724 PushCondition::RoomMemberCount { is }
725 );
726 assert_eq!(is, RoomMemberCountIs::from(uint!(2)));
727 }
728
729 #[test]
730 fn deserialize_sender_notification_permission_condition() {
731 let json_data = json!({
732 "key": "room",
733 "kind": "sender_notification_permission"
734 });
735 assert_matches!(
736 from_json_value::<PushCondition>(json_data).unwrap(),
737 PushCondition::SenderNotificationPermission { key }
738 );
739 assert_eq!(key, NotificationPowerLevelsKey::Room);
740 }
741
742 #[test]
743 fn words_match() {
744 assert!("foo bar".matches_word("foo"));
745 assert!(!"Foo bar".matches_word("foo"));
746 assert!(!"foobar".matches_word("foo"));
747 assert!("foobar foo".matches_word("foo"));
748 assert!(!"foobar foobar".matches_word("foo"));
749 assert!(!"foobar bar".matches_word("bar bar"));
750 assert!("foobar bar bar".matches_word("bar bar"));
751 assert!(!"foobar bar barfoo".matches_word("bar bar"));
752 assert!("conduit ⚡️".matches_word("conduit ⚡️"));
753 assert!("conduit ⚡️".matches_word("conduit"));
754 assert!("conduit ⚡️".matches_word("⚡️"));
755 assert!("conduit⚡️".matches_word("conduit"));
756 assert!("conduit⚡️".matches_word("⚡️"));
757 assert!("⚡️conduit".matches_word("conduit"));
758 assert!("⚡️conduit".matches_word("⚡️"));
759 assert!("Ruma Dev👩💻".matches_word("Dev"));
760 assert!("Ruma Dev👩💻".matches_word("👩💻"));
761 assert!("Ruma Dev👩💻".matches_word("Dev👩💻"));
762
763 assert!(!"matrix".matches_word(r"\w*"));
765 assert!(r"\w".matches_word(r"\w*"));
766 assert!(!"matrix".matches_word("[a-z]*"));
767 assert!("[a-z] and [0-9]".matches_word("[a-z]*"));
768 assert!(!"m".matches_word("[[:alpha:]]?"));
769 assert!("[[:alpha:]]!".matches_word("[[:alpha:]]?"));
770
771 assert!("An example event.".matches_word("ex*ple"));
773 assert!("exple".matches_word("ex*ple"));
774 assert!("An exciting triple-whammy".matches_word("ex*ple"));
775 }
776
777 #[test]
778 fn patterns_match() {
779 assert!("foo bar".matches_pattern("foo", true));
781 assert!("Foo bar".matches_pattern("foo", true));
782 assert!(!"foobar".matches_pattern("foo", true));
783 assert!("".matches_pattern("", true));
784 assert!(!"foo".matches_pattern("", true));
785 assert!("foo bar".matches_pattern("foo bar", true));
786 assert!(" foo bar ".matches_pattern("foo bar", true));
787 assert!("baz foo bar baz".matches_pattern("foo bar", true));
788 assert!("foo baré".matches_pattern("foo bar", true));
789 assert!(!"bar foo".matches_pattern("foo bar", true));
790 assert!("foo bar".matches_pattern("foo ", true));
791 assert!("foo ".matches_pattern("foo ", true));
792 assert!("foo ".matches_pattern("foo ", true));
793 assert!(" foo ".matches_pattern("foo ", true));
794
795 assert!("foo bar".matches_pattern("foo*", true));
797 assert!("foo bar".matches_pattern("foo b?r", true));
798 assert!(" foo bar ".matches_pattern("foo b?r", true));
799 assert!("baz foo bar baz".matches_pattern("foo b?r", true));
800 assert!("foo baré".matches_pattern("foo b?r", true));
801 assert!(!"bar foo".matches_pattern("foo b?r", true));
802 assert!("foo bar".matches_pattern("f*o ", true));
803 assert!("foo ".matches_pattern("f*o ", true));
804 assert!("foo ".matches_pattern("f*o ", true));
805 assert!(" foo ".matches_pattern("f*o ", true));
806
807 assert!(!"foo bar".matches_pattern("foo", false));
809 assert!("foo".matches_pattern("foo", false));
810 assert!("foo".matches_pattern("foo*", false));
811 assert!("foobar".matches_pattern("foo*", false));
812 assert!("foo bar".matches_pattern("foo*", false));
813 assert!(!"foo".matches_pattern("foo?", false));
814 assert!("fooo".matches_pattern("foo?", false));
815 assert!("FOO".matches_pattern("foo", false));
816 assert!("".matches_pattern("", false));
817 assert!("".matches_pattern("*", false));
818 assert!(!"foo".matches_pattern("", false));
819
820 assert!("Lunch plans".matches_pattern("lunc?*", false));
822 assert!("LUNCH".matches_pattern("lunc?*", false));
823 assert!(!" lunch".matches_pattern("lunc?*", false));
824 assert!(!"lunc".matches_pattern("lunc?*", false));
825 }
826
827 fn sender() -> OwnedUserId {
828 owned_user_id!("@worthy_whale:server.name")
829 }
830
831 fn push_context() -> PushConditionRoomCtx {
832 let mut users = BTreeMap::new();
833 users.insert(sender(), int!(25));
834
835 let power_levels = PushConditionPowerLevelsCtx {
836 users,
837 users_default: int!(50),
838 notifications: NotificationPowerLevels { room: int!(50) },
839 rules: RoomPowerLevelsRules::new(&AuthorizationRules::V1, None),
840 };
841
842 let mut ctx = PushConditionRoomCtx::new(
843 owned_room_id!("!room:server.name"),
844 uint!(3),
845 owned_user_id!("@gorilla:server.name"),
846 "Groovy Gorilla".into(),
847 );
848 ctx.power_levels = Some(power_levels);
849 ctx
850 }
851
852 fn first_flattened_event() -> FlattenedJson {
853 FlattenedJson::from_value(json!({
854 "sender": "@worthy_whale:server.name",
855 "content": {
856 "msgtype": "m.text",
857 "body": "@room Give a warm welcome to Groovy Gorilla",
858 },
859 }))
860 }
861
862 fn second_flattened_event() -> FlattenedJson {
863 FlattenedJson::from_value(json!({
864 "sender": "@party_bot:server.name",
865 "content": {
866 "msgtype": "m.notice",
867 "body": "Everybody come to party!",
868 },
869 }))
870 }
871
872 #[apply(test!)]
873 async fn event_match_applies() {
874 let context = push_context();
875 let first_event = first_flattened_event();
876 let second_event = second_flattened_event();
877
878 let correct_room = PushCondition::EventMatch {
879 key: "room_id".into(),
880 pattern: "!room:server.name".into(),
881 };
882 let incorrect_room = PushCondition::EventMatch {
883 key: "room_id".into(),
884 pattern: "!incorrect:server.name".into(),
885 };
886
887 assert!(correct_room.applies(&first_event, &context).await);
888 assert!(!incorrect_room.applies(&first_event, &context).await);
889
890 let keyword =
891 PushCondition::EventMatch { key: "content.body".into(), pattern: "come".into() };
892
893 assert!(!keyword.applies(&first_event, &context).await);
894 assert!(keyword.applies(&second_event, &context).await);
895
896 let msgtype =
897 PushCondition::EventMatch { key: "content.msgtype".into(), pattern: "m.notice".into() };
898
899 assert!(!msgtype.applies(&first_event, &context).await);
900 assert!(msgtype.applies(&second_event, &context).await);
901 }
902
903 #[apply(test!)]
904 async fn room_member_count_is_applies() {
905 let context = push_context();
906 let event = first_flattened_event();
907
908 let member_count_eq =
909 PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(3)) };
910 let member_count_gt =
911 PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(2)..) };
912 let member_count_lt =
913 PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(..uint!(3)) };
914
915 assert!(member_count_eq.applies(&event, &context).await);
916 assert!(member_count_gt.applies(&event, &context).await);
917 assert!(!member_count_lt.applies(&event, &context).await);
918 }
919
920 #[apply(test!)]
921 #[allow(deprecated)]
922 async fn contains_display_name_applies() {
923 let context = push_context();
924 let first_event = first_flattened_event();
925 let second_event = second_flattened_event();
926
927 let contains_display_name = PushCondition::ContainsDisplayName;
928
929 assert!(contains_display_name.applies(&first_event, &context).await);
930 assert!(!contains_display_name.applies(&second_event, &context).await);
931 }
932
933 #[apply(test!)]
934 async fn sender_notification_permission_applies() {
935 let context = push_context();
936 let first_event = first_flattened_event();
937 let second_event = second_flattened_event();
938
939 let sender_notification_permission =
940 PushCondition::SenderNotificationPermission { key: "room".into() };
941
942 assert!(!sender_notification_permission.applies(&first_event, &context).await);
943 assert!(sender_notification_permission.applies(&second_event, &context).await);
944 }
945
946 #[cfg(feature = "unstable-msc3932")]
947 #[apply(test!)]
948 async fn room_version_supports_applies() {
949 use assign::assign;
950
951 let context_not_matching = push_context();
952 let context_matching = assign!(
953 PushConditionRoomCtx::new(
954 owned_room_id!("!room:server.name"),
955 uint!(3),
956 owned_user_id!("@gorilla:server.name"),
957 "Groovy Gorilla".into(),
958 ), {
959 power_levels: context_not_matching.power_levels.clone(),
960 supported_features: vec![super::RoomVersionFeature::ExtensibleEvents],
961 }
962 );
963
964 let simple_event = FlattenedJson::from_value(json!({
965 "sender": "@worthy_whale:server.name",
966 "content": {
967 "msgtype": "org.matrix.msc3932.extensible_events",
968 "body": "@room Give a warm welcome to Groovy Gorilla",
969 },
970 }));
971
972 let room_version_condition = PushCondition::RoomVersionSupports {
973 feature: super::RoomVersionFeature::ExtensibleEvents,
974 };
975
976 assert!(room_version_condition.applies(&simple_event, &context_matching).await);
977 assert!(!room_version_condition.applies(&simple_event, &context_not_matching).await);
978 }
979
980 #[apply(test!)]
981 async fn event_property_is_applies() {
982 use crate::push::condition::ScalarJsonValue;
983
984 let context = push_context();
985 let event = FlattenedJson::from_value(json!({
986 "sender": "@worthy_whale:server.name",
987 "content": {
988 "msgtype": "m.text",
989 "body": "Boom!",
990 "org.fake.boolean": false,
991 "org.fake.number": 13,
992 "org.fake.null": null,
993 },
994 }));
995
996 let string_match = PushCondition::EventPropertyIs {
997 key: "content.body".to_owned(),
998 value: "Boom!".into(),
999 };
1000 assert!(string_match.applies(&event, &context).await);
1001
1002 let string_no_match =
1003 PushCondition::EventPropertyIs { key: "content.body".to_owned(), value: "Boom".into() };
1004 assert!(!string_no_match.applies(&event, &context).await);
1005
1006 let wrong_type =
1007 PushCondition::EventPropertyIs { key: "content.body".to_owned(), value: false.into() };
1008 assert!(!wrong_type.applies(&event, &context).await);
1009
1010 let bool_match = PushCondition::EventPropertyIs {
1011 key: r"content.org\.fake\.boolean".to_owned(),
1012 value: false.into(),
1013 };
1014 assert!(bool_match.applies(&event, &context).await);
1015
1016 let bool_no_match = PushCondition::EventPropertyIs {
1017 key: r"content.org\.fake\.boolean".to_owned(),
1018 value: true.into(),
1019 };
1020 assert!(!bool_no_match.applies(&event, &context).await);
1021
1022 let int_match = PushCondition::EventPropertyIs {
1023 key: r"content.org\.fake\.number".to_owned(),
1024 value: int!(13).into(),
1025 };
1026 assert!(int_match.applies(&event, &context).await);
1027
1028 let int_no_match = PushCondition::EventPropertyIs {
1029 key: r"content.org\.fake\.number".to_owned(),
1030 value: int!(130).into(),
1031 };
1032 assert!(!int_no_match.applies(&event, &context).await);
1033
1034 let null_match = PushCondition::EventPropertyIs {
1035 key: r"content.org\.fake\.null".to_owned(),
1036 value: ScalarJsonValue::Null,
1037 };
1038 assert!(null_match.applies(&event, &context).await);
1039 }
1040
1041 #[apply(test!)]
1042 async fn event_property_contains_applies() {
1043 use crate::push::condition::ScalarJsonValue;
1044
1045 let context = push_context();
1046 let event = FlattenedJson::from_value(json!({
1047 "sender": "@worthy_whale:server.name",
1048 "content": {
1049 "org.fake.array": ["Boom!", false, 13, null],
1050 },
1051 }));
1052
1053 let wrong_key =
1054 PushCondition::EventPropertyContains { key: "send".to_owned(), value: false.into() };
1055 assert!(!wrong_key.applies(&event, &context).await);
1056
1057 let string_match = PushCondition::EventPropertyContains {
1058 key: r"content.org\.fake\.array".to_owned(),
1059 value: "Boom!".into(),
1060 };
1061 assert!(string_match.applies(&event, &context).await);
1062
1063 let string_no_match = PushCondition::EventPropertyContains {
1064 key: r"content.org\.fake\.array".to_owned(),
1065 value: "Boom".into(),
1066 };
1067 assert!(!string_no_match.applies(&event, &context).await);
1068
1069 let bool_match = PushCondition::EventPropertyContains {
1070 key: r"content.org\.fake\.array".to_owned(),
1071 value: false.into(),
1072 };
1073 assert!(bool_match.applies(&event, &context).await);
1074
1075 let bool_no_match = PushCondition::EventPropertyContains {
1076 key: r"content.org\.fake\.array".to_owned(),
1077 value: true.into(),
1078 };
1079 assert!(!bool_no_match.applies(&event, &context).await);
1080
1081 let int_match = PushCondition::EventPropertyContains {
1082 key: r"content.org\.fake\.array".to_owned(),
1083 value: int!(13).into(),
1084 };
1085 assert!(int_match.applies(&event, &context).await);
1086
1087 let int_no_match = PushCondition::EventPropertyContains {
1088 key: r"content.org\.fake\.array".to_owned(),
1089 value: int!(130).into(),
1090 };
1091 assert!(!int_no_match.applies(&event, &context).await);
1092
1093 let null_match = PushCondition::EventPropertyContains {
1094 key: r"content.org\.fake\.array".to_owned(),
1095 value: ScalarJsonValue::Null,
1096 };
1097 assert!(null_match.applies(&event, &context).await);
1098 }
1099
1100 #[apply(test!)]
1101 async fn room_creators_always_have_notification_permission() {
1102 let mut context = push_context();
1103 context.power_levels = Some(PushConditionPowerLevelsCtx {
1104 users: BTreeMap::new(),
1105 users_default: Int::MIN,
1106 notifications: NotificationPowerLevels { room: Int::MAX },
1107 rules: RoomPowerLevelsRules::new(&AuthorizationRules::V12, Some(sender())),
1108 });
1109
1110 let first_event = first_flattened_event();
1111
1112 let sender_notification_permission =
1113 PushCondition::SenderNotificationPermission { key: NotificationPowerLevelsKey::Room };
1114
1115 assert!(sender_notification_permission.applies(&first_event, &context).await);
1116 }
1117
1118 #[cfg(feature = "unstable-msc4306")]
1119 #[apply(test!)]
1120 async fn thread_subscriptions_match() {
1121 use crate::{EventId, event_id};
1122
1123 let context = push_context().with_has_thread_subscription_fn(|event_id: &EventId| {
1124 Box::pin(async move {
1125 event_id == event_id!("$subscribed_thread")
1127 })
1128 });
1129
1130 let subscribed_thread_event = FlattenedJson::from_value(json!({
1131 "event_id": "$thread_response",
1132 "sender": "@worthy_whale:server.name",
1133 "content": {
1134 "msgtype": "m.text",
1135 "body": "response in thread $subscribed_thread",
1136 "m.relates_to": {
1137 "rel_type": "m.thread",
1138 "event_id": "$subscribed_thread",
1139 "is_falling_back": true,
1140 "m.in_reply_to": {
1141 "event_id": "$prev_event",
1142 },
1143 },
1144 },
1145 }));
1146
1147 let unsubscribed_thread_event = FlattenedJson::from_value(json!({
1148 "event_id": "$thread_response2",
1149 "sender": "@worthy_whale:server.name",
1150 "content": {
1151 "msgtype": "m.text",
1152 "body": "response in thread $unsubscribed_thread",
1153 "m.relates_to": {
1154 "rel_type": "m.thread",
1155 "event_id": "$unsubscribed_thread",
1156 "is_falling_back": true,
1157 "m.in_reply_to": {
1158 "event_id": "$prev_event2",
1159 },
1160 },
1161 },
1162 }));
1163
1164 let non_thread_related_event = FlattenedJson::from_value(json!({
1165 "event_id": "$thread_response2",
1166 "sender": "@worthy_whale:server.name",
1167 "content": {
1168 "m.relates_to": {
1169 "rel_type": "m.reaction",
1170 "event_id": "$subscribed_thread",
1171 "key": "👍",
1172 },
1173 },
1174 }));
1175
1176 let subscribed_thread_condition = PushCondition::ThreadSubscription { subscribed: true };
1177 assert!(subscribed_thread_condition.applies(&subscribed_thread_event, &context).await);
1178 assert!(!subscribed_thread_condition.applies(&unsubscribed_thread_event, &context).await);
1179 assert!(!subscribed_thread_condition.applies(&non_thread_related_event, &context).await);
1180
1181 let unsubscribed_thread_condition = PushCondition::ThreadSubscription { subscribed: false };
1182 assert!(unsubscribed_thread_condition.applies(&unsubscribed_thread_event, &context).await);
1183 assert!(!unsubscribed_thread_condition.applies(&subscribed_thread_event, &context).await);
1184 assert!(!unsubscribed_thread_condition.applies(&non_thread_related_event, &context).await);
1185 }
1186}