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