use std::{collections::BTreeMap, ops::RangeBounds, str::FromStr};
use js_int::{Int, UInt};
use regex::bytes::Regex;
#[cfg(feature = "unstable-msc3931")]
use ruma_macros::StringEnum;
use serde::{Deserialize, Serialize};
use serde_json::value::Value as JsonValue;
use wildmatch::WildMatch;
use crate::{power_levels::NotificationPowerLevels, OwnedRoomId, OwnedUserId, UserId};
#[cfg(feature = "unstable-msc3931")]
use crate::{PrivOwnedStr, RoomVersionId};
mod flattened_json;
mod push_condition_serde;
mod room_member_count_is;
pub use self::{
flattened_json::{FlattenedJson, FlattenedJsonValue, ScalarJsonValue},
room_member_count_is::{ComparisonOperator, RoomMemberCountIs},
};
#[cfg(feature = "unstable-msc3931")]
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
#[derive(Clone, PartialEq, Eq, StringEnum)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub enum RoomVersionFeature {
#[cfg(feature = "unstable-msc3932")]
#[ruma_enum(rename = "org.matrix.msc3932.extensible_events")]
ExtensibleEvents,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}
#[cfg(feature = "unstable-msc3931")]
impl RoomVersionFeature {
pub fn list_for_room_version(version: &RoomVersionId) -> Vec<Self> {
match version {
RoomVersionId::V1
| RoomVersionId::V2
| RoomVersionId::V3
| RoomVersionId::V4
| RoomVersionId::V5
| RoomVersionId::V6
| RoomVersionId::V7
| RoomVersionId::V8
| RoomVersionId::V9
| RoomVersionId::V10
| RoomVersionId::V11
| RoomVersionId::_Custom(_) => vec![],
}
}
}
#[derive(Clone, Debug)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub enum PushCondition {
EventMatch {
key: String,
pattern: String,
},
ContainsDisplayName,
RoomMemberCount {
is: RoomMemberCountIs,
},
SenderNotificationPermission {
key: String,
},
#[cfg(feature = "unstable-msc3931")]
RoomVersionSupports {
feature: RoomVersionFeature,
},
EventPropertyIs {
key: String,
value: ScalarJsonValue,
},
EventPropertyContains {
key: String,
value: ScalarJsonValue,
},
#[doc(hidden)]
_Custom(_CustomPushCondition),
}
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_str(key) {
Some(v) => v,
None => return false,
},
};
value.matches_pattern(pattern, key == "content.body")
}
impl PushCondition {
pub fn applies(&self, event: &FlattenedJson, context: &PushConditionRoomCtx) -> bool {
if event.get_str("sender").is_some_and(|sender| sender == context.user_id) {
return false;
}
match self {
Self::EventMatch { key, pattern } => check_event_match(event, key, pattern, context),
Self::ContainsDisplayName => {
let value = match event.get_str("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 Some(power_levels) = &context.power_levels else {
return false;
};
let sender_id = match event.get_str("sender") {
Some(v) => match <&UserId>::try_from(v) {
Ok(u) => u,
Err(_) => return false,
},
None => return false,
};
let sender_level =
power_levels.users.get(sender_id).unwrap_or(&power_levels.users_default);
match power_levels.notifications.get(key) {
Some(l) => sender_level >= l,
None => false,
}
}
#[cfg(feature = "unstable-msc3931")]
Self::RoomVersionSupports { feature } => match feature {
RoomVersionFeature::ExtensibleEvents => {
context.supported_features.contains(&RoomVersionFeature::ExtensibleEvents)
}
RoomVersionFeature::_Custom(_) => false,
},
Self::EventPropertyIs { key, value } => event.get(key).is_some_and(|v| v == value),
Self::EventPropertyContains { key, value } => event
.get(key)
.and_then(FlattenedJsonValue::as_array)
.is_some_and(|a| a.contains(value)),
Self::_Custom(_) => false,
}
}
}
#[doc(hidden)]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[allow(clippy::exhaustive_structs)]
pub struct _CustomPushCondition {
kind: String,
#[serde(flatten)]
data: BTreeMap<String, JsonValue>,
}
#[derive(Clone, Debug)]
#[allow(clippy::exhaustive_structs)]
pub struct PushConditionRoomCtx {
pub room_id: OwnedRoomId,
pub member_count: UInt,
pub user_id: OwnedUserId,
pub user_display_name: String,
pub power_levels: Option<PushConditionPowerLevelsCtx>,
#[cfg(feature = "unstable-msc3931")]
pub supported_features: Vec<RoomVersionFeature>,
}
#[derive(Clone, Debug)]
#[allow(clippy::exhaustive_structs)]
pub struct PushConditionPowerLevelsCtx {
pub users: BTreeMap<OwnedUserId, Int>,
pub users_default: Int,
pub notifications: NotificationPowerLevels,
}
trait CharExt {
fn is_word_char(&self) -> bool;
}
impl CharExt for char {
fn is_word_char(&self) -> bool {
self.is_ascii_alphanumeric() || *self == '_'
}
}
trait StrExt {
fn char_len(&self, index: usize) -> usize;
fn char_at(&self, index: usize) -> char;
fn find_prev_char(&self, index: usize) -> Option<char>;
fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool;
fn matches_word(&self, pattern: &str) -> bool;
fn wildcards_to_regex(&self) -> String;
}
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 '{char_str}' to char"))
}
fn find_prev_char(&self, index: usize) -> Option<char> {
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;
}
let has_wildcards = pattern.contains(['?', '*']);
if has_wildcards {
let mut chunks: Vec<String> = vec![];
let mut prev_wildcard = false;
let mut chunk_start = 0;
for (i, c) in pattern.char_indices() {
if matches!(c, '?' | '*') && !prev_wildcard {
if i != 0 {
chunks.push(regex::escape(&pattern[chunk_start..i]));
chunk_start = i;
}
prev_wildcard = true;
} else if prev_wildcard {
let chunk = &pattern[chunk_start..i];
chunks.push(chunk.wildcards_to_regex());
chunk_start = i;
prev_wildcard = false;
}
}
let len = pattern.len();
if !prev_wildcard {
chunks.push(regex::escape(&pattern[chunk_start..len]));
} else if prev_wildcard {
let chunk = &pattern[chunk_start..len];
chunks.push(chunk.wildcards_to_regex());
}
let regex = format!(r"(?-u:^|\W|\b){}(?-u:\b|\W|$)", chunks.concat());
let re = Regex::new(®ex).expect("regex construction should succeed");
re.is_match(self.as_bytes())
} else {
match self.find(pattern) {
Some(start) => {
let end = start + pattern.len();
let word_boundary_start = !self.char_at(start).is_word_char()
|| !self.find_prev_char(start).is_some_and(|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;
}
}
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,
}
}
}
fn wildcards_to_regex(&self) -> String {
let question_marks = self.matches('?').count();
if self.contains('*') {
format!(".{{{question_marks},}}")
} else {
format!(".{{{question_marks}}}")
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use assert_matches2::assert_matches;
use js_int::{int, uint};
use serde_json::{
from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue,
};
use super::{
FlattenedJson, PushCondition, PushConditionPowerLevelsCtx, PushConditionRoomCtx,
RoomMemberCountIs, StrExt,
};
use crate::{
owned_room_id, owned_user_id, power_levels::NotificationPowerLevels, serde::Raw,
OwnedUserId,
};
#[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::<PushCondition>(json_data).unwrap(),
PushCondition::EventMatch { key, pattern }
);
assert_eq!(key, "content.msgtype");
assert_eq!(pattern, "m.notice");
}
#[test]
fn deserialize_contains_display_name_condition() {
assert_matches!(
from_json_value::<PushCondition>(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::<PushCondition>(json_data).unwrap(),
PushCondition::RoomMemberCount { is }
);
assert_eq!(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::<PushCondition>(json_data).unwrap(),
PushCondition::SenderNotificationPermission { key }
);
assert_eq!(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👩💻"));
assert!(!"matrix".matches_word(r"\w*"));
assert!(r"\w".matches_word(r"\w*"));
assert!(!"matrix".matches_word("[a-z]*"));
assert!("[a-z] and [0-9]".matches_word("[a-z]*"));
assert!(!"m".matches_word("[[:alpha:]]?"));
assert!("[[:alpha:]]!".matches_word("[[:alpha:]]?"));
assert!("An example event.".matches_word("ex*ple"));
assert!("exple".matches_word("ex*ple"));
assert!("An exciting triple-whammy".matches_word("ex*ple"));
}
#[test]
fn patterns_match() {
assert!("foo bar".matches_pattern("foo", true));
assert!("Foo bar".matches_pattern("foo", true));
assert!(!"foobar".matches_pattern("foo", true));
assert!("".matches_pattern("", true));
assert!(!"foo".matches_pattern("", true));
assert!("foo bar".matches_pattern("foo bar", true));
assert!(" foo bar ".matches_pattern("foo bar", true));
assert!("baz foo bar baz".matches_pattern("foo bar", true));
assert!("foo baré".matches_pattern("foo bar", true));
assert!(!"bar foo".matches_pattern("foo bar", true));
assert!("foo bar".matches_pattern("foo ", true));
assert!("foo ".matches_pattern("foo ", true));
assert!("foo ".matches_pattern("foo ", true));
assert!(" foo ".matches_pattern("foo ", true));
assert!("foo bar".matches_pattern("foo*", true));
assert!("foo bar".matches_pattern("foo b?r", true));
assert!(" foo bar ".matches_pattern("foo b?r", true));
assert!("baz foo bar baz".matches_pattern("foo b?r", true));
assert!("foo baré".matches_pattern("foo b?r", true));
assert!(!"bar foo".matches_pattern("foo b?r", true));
assert!("foo bar".matches_pattern("f*o ", true));
assert!("foo ".matches_pattern("f*o ", true));
assert!("foo ".matches_pattern("f*o ", true));
assert!(" foo ".matches_pattern("f*o ", true));
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!("fooo".matches_pattern("foo?", false));
assert!("FOO".matches_pattern("foo", false));
assert!("".matches_pattern("", false));
assert!("".matches_pattern("*", false));
assert!(!"foo".matches_pattern("", false));
assert!("Lunch plans".matches_pattern("lunc?*", false));
assert!("LUNCH".matches_pattern("lunc?*", false));
assert!(!" lunch".matches_pattern("lunc?*", false));
assert!(!"lunc".matches_pattern("lunc?*", false));
}
fn sender() -> OwnedUserId {
owned_user_id!("@worthy_whale:server.name")
}
fn push_context() -> PushConditionRoomCtx {
let mut users = BTreeMap::new();
users.insert(sender(), int!(25));
let power_levels = PushConditionPowerLevelsCtx {
users,
users_default: int!(50),
notifications: NotificationPowerLevels { room: int!(50) },
};
PushConditionRoomCtx {
room_id: owned_room_id!("!room:server.name"),
member_count: uint!(3),
user_id: owned_user_id!("@gorilla:server.name"),
user_display_name: "Groovy Gorilla".into(),
power_levels: Some(power_levels),
#[cfg(feature = "unstable-msc3931")]
supported_features: Default::default(),
}
}
fn first_flattened_event() -> FlattenedJson {
let raw = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"sender": "@worthy_whale:server.name",
"content": {
"msgtype": "m.text",
"body": "@room Give a warm welcome to Groovy Gorilla"
}
}"#,
)
.unwrap();
FlattenedJson::from_raw(&raw)
}
fn second_flattened_event() -> FlattenedJson {
let raw = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"sender": "@party_bot:server.name",
"content": {
"msgtype": "m.notice",
"body": "Everybody come to party!"
}
}"#,
)
.unwrap();
FlattenedJson::from_raw(&raw)
}
#[test]
fn event_match_applies() {
let context = push_context();
let first_event = first_flattened_event();
let second_event = second_flattened_event();
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));
}
#[test]
fn room_member_count_is_applies() {
let context = push_context();
let event = first_flattened_event();
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(&event, &context));
assert!(member_count_gt.applies(&event, &context));
assert!(!member_count_lt.applies(&event, &context));
}
#[test]
fn contains_display_name_applies() {
let context = push_context();
let first_event = first_flattened_event();
let second_event = second_flattened_event();
let contains_display_name = PushCondition::ContainsDisplayName;
assert!(contains_display_name.applies(&first_event, &context));
assert!(!contains_display_name.applies(&second_event, &context));
}
#[test]
fn sender_notification_permission_applies() {
let context = push_context();
let first_event = first_flattened_event();
let second_event = second_flattened_event();
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));
}
#[cfg(feature = "unstable-msc3932")]
#[test]
fn room_version_supports_applies() {
let context_not_matching = push_context();
let context_matching = PushConditionRoomCtx {
room_id: owned_room_id!("!room:server.name"),
member_count: uint!(3),
user_id: owned_user_id!("@gorilla:server.name"),
user_display_name: "Groovy Gorilla".into(),
power_levels: context_not_matching.power_levels.clone(),
supported_features: vec![super::RoomVersionFeature::ExtensibleEvents],
};
let simple_event_raw = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"sender": "@worthy_whale:server.name",
"content": {
"msgtype": "org.matrix.msc3932.extensible_events",
"body": "@room Give a warm welcome to Groovy Gorilla"
}
}"#,
)
.unwrap();
let simple_event = FlattenedJson::from_raw(&simple_event_raw);
let room_version_condition = PushCondition::RoomVersionSupports {
feature: super::RoomVersionFeature::ExtensibleEvents,
};
assert!(room_version_condition.applies(&simple_event, &context_matching));
assert!(!room_version_condition.applies(&simple_event, &context_not_matching));
}
#[test]
fn event_property_is_applies() {
use crate::push::condition::ScalarJsonValue;
let context = push_context();
let event_raw = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"sender": "@worthy_whale:server.name",
"content": {
"msgtype": "m.text",
"body": "Boom!",
"org.fake.boolean": false,
"org.fake.number": 13,
"org.fake.null": null
}
}"#,
)
.unwrap();
let event = FlattenedJson::from_raw(&event_raw);
let string_match = PushCondition::EventPropertyIs {
key: "content.body".to_owned(),
value: "Boom!".into(),
};
assert!(string_match.applies(&event, &context));
let string_no_match =
PushCondition::EventPropertyIs { key: "content.body".to_owned(), value: "Boom".into() };
assert!(!string_no_match.applies(&event, &context));
let wrong_type =
PushCondition::EventPropertyIs { key: "content.body".to_owned(), value: false.into() };
assert!(!wrong_type.applies(&event, &context));
let bool_match = PushCondition::EventPropertyIs {
key: r"content.org\.fake\.boolean".to_owned(),
value: false.into(),
};
assert!(bool_match.applies(&event, &context));
let bool_no_match = PushCondition::EventPropertyIs {
key: r"content.org\.fake\.boolean".to_owned(),
value: true.into(),
};
assert!(!bool_no_match.applies(&event, &context));
let int_match = PushCondition::EventPropertyIs {
key: r"content.org\.fake\.number".to_owned(),
value: int!(13).into(),
};
assert!(int_match.applies(&event, &context));
let int_no_match = PushCondition::EventPropertyIs {
key: r"content.org\.fake\.number".to_owned(),
value: int!(130).into(),
};
assert!(!int_no_match.applies(&event, &context));
let null_match = PushCondition::EventPropertyIs {
key: r"content.org\.fake\.null".to_owned(),
value: ScalarJsonValue::Null,
};
assert!(null_match.applies(&event, &context));
}
#[test]
fn event_property_contains_applies() {
use crate::push::condition::ScalarJsonValue;
let context = push_context();
let event_raw = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"sender": "@worthy_whale:server.name",
"content": {
"org.fake.array": ["Boom!", false, 13, null]
}
}"#,
)
.unwrap();
let event = FlattenedJson::from_raw(&event_raw);
let wrong_key =
PushCondition::EventPropertyContains { key: "send".to_owned(), value: false.into() };
assert!(!wrong_key.applies(&event, &context));
let string_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: "Boom!".into(),
};
assert!(string_match.applies(&event, &context));
let string_no_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: "Boom".into(),
};
assert!(!string_no_match.applies(&event, &context));
let bool_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: false.into(),
};
assert!(bool_match.applies(&event, &context));
let bool_no_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: true.into(),
};
assert!(!bool_no_match.applies(&event, &context));
let int_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: int!(13).into(),
};
assert!(int_match.applies(&event, &context));
let int_no_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: int!(130).into(),
};
assert!(!int_no_match.applies(&event, &context));
let null_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: ScalarJsonValue::Null,
};
assert!(null_match.applies(&event, &context));
}
}