use std::{collections::BTreeMap, convert::TryFrom, ops::RangeBounds, str::FromStr}; use js_int::{Int, UInt}; use ruma_identifiers::{RoomId, UserId}; use ruma_serde::Raw; use serde::{Deserialize, Serialize}; use serde_json::{to_value as to_json_value, value::Value as JsonValue}; use tracing::warn; use wildmatch::WildMatch; use crate::power_levels::NotificationPowerLevels; mod room_member_count_is; pub use room_member_count_is::{ComparisonOperator, RoomMemberCountIs}; /// A condition that must apply for an associated push rule's action to be taken. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum PushCondition { /// This is a glob pattern match on a field of the event. EventMatch { /// The dot-separated field of the event to match. key: String, /// The glob-style pattern to match against. /// /// Patterns with no special glob characters should be treated as having asterisks /// prepended and appended when testing the condition. pattern: String, }, /// This matches unencrypted messages where `content.body` contains the owner's display name in /// that room. ContainsDisplayName, /// This matches the current number of members in the room. RoomMemberCount { /// The condition on the current number of members in the room. is: RoomMemberCountIs, }, /// This takes into account the current power levels in the room, ensuring the sender of the /// event has high enough power to trigger the notification. SenderNotificationPermission { /// The field in the power level event the user needs a minimum power level for. /// /// Fields must be specified under the `notifications` property in the power level event's /// `content`. key: String, }, } pub(super) fn check_event_match( event: &FlattenedJson, key: &str, pattern: &str, context: &PushConditionRoomCtx, ) -> bool { let value = match key { "room_id" => context.room_id.as_str(), _ => match event.get(key) { Some(v) => v, None => return false, }, }; value.matches_pattern(pattern, key == "content.body") } impl PushCondition { /// Check if this condition applies to the event. /// /// # Arguments /// /// * `event` - The flattened JSON representation of a room message event. /// * `context` - The context of the room at the time of the event. pub fn applies(&self, event: &FlattenedJson, context: &PushConditionRoomCtx) -> bool { match self { Self::EventMatch { key, pattern } => check_event_match(event, &key, &pattern, context), Self::ContainsDisplayName => { let value = match event.get("content.body") { Some(v) => v, None => return false, }; value.matches_pattern(&context.user_display_name, true) } Self::RoomMemberCount { is } => is.contains(&context.member_count), Self::SenderNotificationPermission { key } => { let sender_id = match event.get("sender") { Some(v) => match UserId::try_from(v) { Ok(u) => u, Err(_) => return false, }, None => return false, }; let sender_level = context .users_power_levels .get(&sender_id) .unwrap_or(&context.default_power_level); match context.notification_power_levels.get(key) { Some(l) => sender_level >= l, None => false, } } } } } /// The context of the room associated to an event to be able to test all push conditions. #[derive(Clone, Debug)] pub struct PushConditionRoomCtx { /// The ID of the room. pub room_id: RoomId, /// The number of members in the room. pub member_count: UInt, /// The display name of the current user in the room. pub user_display_name: String, /// The power levels of the users of the room. pub users_power_levels: BTreeMap, /// The default power level of the users of the room. pub default_power_level: Int, /// The notification power levels of the room. pub notification_power_levels: NotificationPowerLevels, } /// Additional functions for character matching. trait CharExt { /// Whether or not this char can be part of a word. fn is_word_char(&self) -> bool; } impl CharExt for char { fn is_word_char(&self) -> bool { self.is_alphanumeric() || *self == '_' } } /// Additional functions for string matching. trait StrExt { /// Get the length of the char at `index`. The byte index must correspond to /// the start of a char boundary. fn char_len(&self, index: usize) -> usize; /// Get the char at `index`. The byte index must correspond to the start of /// a char boundary. fn char_at(&self, index: usize) -> char; /// Get the index of the char that is before the char at `index`. The byte index /// must correspond to a char boundary. /// /// Returns `None` if there's no previous char. Otherwise, returns the char. fn find_prev_char(&self, index: usize) -> Option; /// Matches this string against `pattern`. /// /// The match is case insensitive. /// /// If `match_words` is `true`, looks for `pattern` as a substring of `self`, /// and checks that it is separated from other words. Otherwise, checks /// `pattern` as a glob with wildcards `*` and `?`. fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool; /// Matches this string against `pattern`, with word boundaries. /// /// The match is case sensitive. fn matches_word(&self, pattern: &str) -> bool; } impl StrExt for str { fn char_len(&self, index: usize) -> usize { let mut len = 1; while !self.is_char_boundary(index + len) { len += 1; } len } fn char_at(&self, index: usize) -> char { let end = index + self.char_len(index); let char_str = &self[index..end]; char::from_str(char_str) .unwrap_or_else(|_| panic!("Could not convert str '{}' to char", char_str)) } fn find_prev_char(&self, index: usize) -> Option { if index == 0 { return None; } let mut pos = index - 1; while !self.is_char_boundary(pos) { pos -= 1; } Some(self.char_at(pos)) } fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool { let value = &self.to_lowercase(); let pattern = &pattern.to_lowercase(); if match_words { value.matches_word(pattern) } else { WildMatch::new(pattern).matches(value) } } fn matches_word(&self, pattern: &str) -> bool { if self == pattern { return true; } if pattern.is_empty() { return false; } match self.find(pattern) { Some(start) => { let end = start + pattern.len(); // Look if the match has word boundaries. let word_boundary_start = !self.char_at(start).is_word_char() || self.find_prev_char(start).map_or(true, |c| !c.is_word_char()); if word_boundary_start { let word_boundary_end = end == self.len() || !self.find_prev_char(end).unwrap().is_word_char() || !self.char_at(end).is_word_char(); if word_boundary_end { return true; } } // Find next word. let non_word_str = &self[start..]; let non_word = match non_word_str.find(|c: char| !c.is_word_char()) { Some(pos) => pos, None => return false, }; let word_str = &non_word_str[non_word..]; let word = match word_str.find(|c: char| c.is_word_char()) { Some(pos) => pos, None => return false, }; word_str[word..].matches_word(pattern) } None => false, } } } /// The flattened representation of a JSON object. #[derive(Clone, Debug)] pub struct FlattenedJson { /// The internal map containing the flattened JSON as a pair path, value. map: BTreeMap, } impl FlattenedJson { /// Create a `FlattenedJson` from `Raw`. pub fn from_raw(raw: &Raw) -> Self where T: Serialize, { let mut s = Self { map: BTreeMap::new() }; s.flatten_value(to_json_value(raw).unwrap(), "".into()); s } /// Flatten and insert the `value` at `path`. fn flatten_value(&mut self, value: JsonValue, path: String) { match value { JsonValue::Object(fields) => { for (key, value) in fields { let path = if path.is_empty() { key } else { format!("{}.{}", path, key) }; self.flatten_value(value, path); } } JsonValue::String(s) => { if self.map.insert(path.clone(), s).is_some() { warn!("Duplicate path in flattened JSON: {}", path); }; } JsonValue::Number(_) | JsonValue::Bool(_) => { if self.map.insert(path.clone(), value.to_string()).is_some() { warn!("Duplicate path in flattened JSON: {}", path); }; } JsonValue::Array(_) | JsonValue::Null => {} } } /// Value associated with the given `path`. pub fn get(&self, path: &str) -> Option<&str> { self.map.get(path).map(|s| s.as_str()) } } #[cfg(test)] mod tests { use std::collections::BTreeMap; use js_int::uint; use maplit::btreemap; use matches::assert_matches; use ruma_identifiers::{room_id, user_id}; use ruma_serde::Raw; use serde_json::{ from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue, }; use crate::power_levels::NotificationPowerLevels; use super::{FlattenedJson, PushCondition, PushConditionRoomCtx, RoomMemberCountIs, StrExt}; #[test] fn serialize_event_match_condition() { let json_data = json!({ "key": "content.msgtype", "kind": "event_match", "pattern": "m.notice" }); assert_eq!( to_json_value(&PushCondition::EventMatch { key: "content.msgtype".into(), pattern: "m.notice".into(), }) .unwrap(), json_data ); } #[test] fn serialize_contains_display_name_condition() { assert_eq!( to_json_value(&PushCondition::ContainsDisplayName).unwrap(), json!({ "kind": "contains_display_name" }) ); } #[test] fn serialize_room_member_count_condition() { let json_data = json!({ "is": "2", "kind": "room_member_count" }); assert_eq!( to_json_value(&PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(2)) }) .unwrap(), json_data ); } #[test] fn serialize_sender_notification_permission_condition() { let json_data = json!({ "key": "room", "kind": "sender_notification_permission" }); assert_eq!( json_data, to_json_value(&PushCondition::SenderNotificationPermission { key: "room".into() }) .unwrap() ); } #[test] fn deserialize_event_match_condition() { let json_data = json!({ "key": "content.msgtype", "kind": "event_match", "pattern": "m.notice" }); assert_matches!( from_json_value::(json_data).unwrap(), PushCondition::EventMatch { key, pattern } if key == "content.msgtype" && pattern == "m.notice" ); } #[test] fn deserialize_contains_display_name_condition() { assert_matches!( from_json_value::(json!({ "kind": "contains_display_name" })).unwrap(), PushCondition::ContainsDisplayName ); } #[test] fn deserialize_room_member_count_condition() { let json_data = json!({ "is": "2", "kind": "room_member_count" }); assert_matches!( from_json_value::(json_data).unwrap(), PushCondition::RoomMemberCount { is } if is == RoomMemberCountIs::from(uint!(2)) ); } #[test] fn deserialize_sender_notification_permission_condition() { let json_data = json!({ "key": "room", "kind": "sender_notification_permission" }); assert_matches!( from_json_value::(json_data).unwrap(), PushCondition::SenderNotificationPermission { key } if key == "room" ); } #[test] fn words_match() { assert!("foo bar".matches_word("foo")); assert!(!"Foo bar".matches_word("foo")); assert!(!"foobar".matches_word("foo")); assert!("foobar foo".matches_word("foo")); assert!(!"foobar foobar".matches_word("foo")); assert!(!"foobar bar".matches_word("bar bar")); assert!("foobar bar bar".matches_word("bar bar")); assert!(!"foobar bar barfoo".matches_word("bar bar")); assert!("conduit ⚡️".matches_word("conduit ⚡️")); assert!("conduit ⚡️".matches_word("conduit")); assert!("conduit ⚡️".matches_word("⚡️")); assert!("conduit⚡️".matches_word("conduit")); assert!("conduit⚡️".matches_word("⚡️")); assert!("⚡️conduit".matches_word("conduit")); assert!("⚡️conduit".matches_word("⚡️")); assert!("Ruma Dev👩‍💻".matches_word("Dev")); assert!("Ruma Dev👩‍💻".matches_word("👩‍💻")); assert!("Ruma Dev👩‍💻".matches_word("Dev👩‍💻")); } #[test] fn patterns_match() { // Word matching assert!("foo bar".matches_pattern("foo", true)); assert!("Foo bar".matches_pattern("foo", true)); assert!(!"foobar".matches_pattern("foo", true)); assert!(!"foo bar".matches_pattern("foo*", true)); assert!("".matches_pattern("", true)); assert!(!"foo".matches_pattern("", true)); // Glob matching assert!(!"foo bar".matches_pattern("foo", false)); assert!("foo".matches_pattern("foo", false)); assert!("foo".matches_pattern("foo*", false)); assert!("foobar".matches_pattern("foo*", false)); assert!("foo bar".matches_pattern("foo*", false)); assert!(!"foo".matches_pattern("foo?", false)); assert!("foo".matches_pattern("fo?", false)); assert!("FOO".matches_pattern("foo", false)); assert!("".matches_pattern("", false)); assert!("".matches_pattern("*", false)); assert!(!"foo".matches_pattern("", false)); } #[test] fn conditions_apply_to_events() { let first_sender = user_id!("@worthy_whale:server.name"); let mut users_power_levels = BTreeMap::new(); users_power_levels.insert(first_sender, 25.into()); let context = PushConditionRoomCtx { room_id: room_id!("!room:server.name"), member_count: 3u8.into(), user_display_name: "Groovy Gorilla".into(), users_power_levels, default_power_level: 50.into(), notification_power_levels: NotificationPowerLevels { room: 50.into() }, }; let first_event_raw = serde_json::from_str::>( r#"{ "sender": "@worthy_whale:server.name", "content": { "msgtype": "m.text", "body": "@room Give a warm welcome to Groovy Gorilla" } }"#, ) .unwrap(); let first_event = FlattenedJson::from_raw(&first_event_raw); let second_event_raw = serde_json::from_str::>( r#"{ "sender": "@party_bot:server.name", "content": { "msgtype": "m.notice", "body": "@room Ready to come to the party?" } }"#, ) .unwrap(); let second_event = FlattenedJson::from_raw(&second_event_raw); let correct_room = PushCondition::EventMatch { key: "room_id".into(), pattern: "!room:server.name".into(), }; let incorrect_room = PushCondition::EventMatch { key: "room_id".into(), pattern: "!incorrect:server.name".into(), }; assert!(correct_room.applies(&first_event, &context)); assert!(!incorrect_room.applies(&first_event, &context)); let keyword = PushCondition::EventMatch { key: "content.body".into(), pattern: "come".into() }; assert!(!keyword.applies(&first_event, &context)); assert!(keyword.applies(&second_event, &context)); let msgtype = PushCondition::EventMatch { key: "content.msgtype".into(), pattern: "m.notice".into() }; assert!(!msgtype.applies(&first_event, &context)); assert!(msgtype.applies(&second_event, &context)); let member_count_eq = PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(3)) }; let member_count_gt = PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(2)..) }; let member_count_lt = PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(..uint!(3)) }; assert!(member_count_eq.applies(&first_event, &context)); assert!(member_count_gt.applies(&first_event, &context)); assert!(!member_count_lt.applies(&first_event, &context)); let contains_display_name = PushCondition::ContainsDisplayName; assert!(contains_display_name.applies(&first_event, &context)); assert!(!contains_display_name.applies(&second_event, &context)); let sender_notification_permission = PushCondition::SenderNotificationPermission { key: "room".into() }; assert!(!sender_notification_permission.applies(&first_event, &context)); assert!(sender_notification_permission.applies(&second_event, &context)); } #[test] fn flattened_json_values() { let raw = serde_json::from_str::>( r#"{ "string": "Hello World", "number": 10, "array": [1, 2], "boolean": true, "null": null }"#, ) .unwrap(); let flattened = FlattenedJson::from_raw(&raw); assert_eq!( flattened.map, btreemap! { "string".into() => "Hello World".into(), "number".into() => "10".into(), "boolean".into() => "true".into(), }, ); } #[test] fn flattened_json_nested() { let raw = serde_json::from_str::>( r#"{ "desc": "Level 0", "up": { "desc": "Level 1", "up": { "desc": "Level 2" } } }"#, ) .unwrap(); let flattened = FlattenedJson::from_raw(&raw); assert_eq!( flattened.map, btreemap! { "desc".into() => "Level 0".into(), "up.desc".into() => "Level 1".into(), "up.up.desc".into() => "Level 2".into(), }, ); } }