common: Add a function to test an event against PushCondition

This commit is contained in:
Kévin Commaille 2021-03-31 11:43:59 +02:00 committed by Jonas Platte
parent cdb998c83f
commit 038f0eec6d
4 changed files with 456 additions and 2 deletions

View File

@ -18,6 +18,8 @@ Improvements:
* Add `push::{PusherData, PushFormat}` (moved from `ruma_client_api::r0::push`) * Add `push::{PusherData, PushFormat}` (moved from `ruma_client_api::r0::push`)
* Add `authentication::TokenType` (moved from * Add `authentication::TokenType` (moved from
`ruma_client_api::r0::account:request_openid_token`) `ruma_client_api::r0::account:request_openid_token`)
* Add `push::PushCondition::applies` and
`push::{FlattenedJson, PushConditionRoomCtx}`
# 0.2.0 # 0.2.0

View File

@ -18,6 +18,8 @@ ruma-serde = { version = "0.3.1", path = "../ruma-serde" }
serde = { version = "1.0.118", features = ["derive"] } serde = { version = "1.0.118", features = ["derive"] }
serde_json = { version = "1.0.60", features = ["raw_value"] } serde_json = { version = "1.0.60", features = ["raw_value"] }
indexmap = { version = "1.6.2", features = ["serde-1"] } indexmap = { version = "1.6.2", features = ["serde-1"] }
wildmatch = "2.0.0"
tracing = "0.1.25"
[dev-dependencies] [dev-dependencies]
matches = "0.1.8" matches = "0.1.8"

View File

@ -14,6 +14,16 @@ pub struct NotificationPowerLevels {
pub room: Int, pub room: Int,
} }
impl NotificationPowerLevels {
/// Value associated with the given `key`.
pub fn get(&self, key: &str) -> Option<&Int> {
match key {
"room" => Some(&self.room),
_ => None,
}
}
}
impl Default for NotificationPowerLevels { impl Default for NotificationPowerLevels {
fn default() -> Self { fn default() -> Self {
Self { room: default_power_level() } Self { room: default_power_level() }

View File

@ -1,4 +1,14 @@
use std::{collections::BTreeMap, convert::TryFrom, ops::RangeBounds, str::FromStr};
use js_int::{Int, UInt};
use ruma_identifiers::UserId;
use ruma_serde::Raw;
use serde::{Deserialize, Serialize}; 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; mod room_member_count_is;
@ -42,13 +52,269 @@ pub enum PushCondition {
}, },
} }
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 } => {
let value = match key.as_str() {
"room_id" => &context.room_id,
_ => match event.get(key) {
Some(v) => v,
None => return false,
},
};
value.matches_pattern(pattern, key == "content.body")
}
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 roomId of the room.
pub room_id: String,
/// 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<UserId, Int>,
/// 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<char>;
/// 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<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 {
if self.is_empty() || pattern.is_empty() {
return false;
}
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 {
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<String, String>,
}
impl FlattenedJson {
/// Create a `FlattenedJson` from `Raw`.
pub fn from_raw<T>(raw: &Raw<T>) -> 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)] #[cfg(test)]
mod tests { mod tests {
use std::collections::BTreeMap;
use js_int::uint; use js_int::uint;
use matches::assert_matches; use matches::assert_matches;
use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; use ruma_identifiers::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 super::{PushCondition, RoomMemberCountIs}; use crate::power_levels::NotificationPowerLevels;
use super::{FlattenedJson, PushCondition, PushConditionRoomCtx, RoomMemberCountIs, StrExt};
#[test] #[test]
fn serialize_event_match_condition() { fn serialize_event_match_condition() {
@ -151,4 +417,178 @@ mod tests {
} if key == "room" } 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));
// 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));
}
#[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.clone(), 25.into());
let context = PushConditionRoomCtx {
room_id: "!room:server.name".into(),
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::<Raw<JsonValue>>(
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::<Raw<JsonValue>>(
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::<Raw<JsonValue>>(
r#"{
"string": "Hello World",
"number": 10,
"array": [1, 2],
"boolean": true,
"null": null
}"#,
)
.unwrap();
let mut map = BTreeMap::new();
map.insert("string".into(), "Hello World".into());
map.insert("number".into(), "10".into());
map.insert("boolean".into(), "true".into());
let flattened = FlattenedJson::from_raw(&raw);
assert_eq!(flattened.map, map);
}
#[test]
fn flattened_json_nested() {
let raw = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"desc": "Level 0",
"up": {
"desc": "Level 1",
"up": {
"desc": "Level 2"
}
}
}"#,
)
.unwrap();
let mut map = BTreeMap::new();
map.insert("desc".into(), "Level 0".into());
map.insert("up.desc".into(), "Level 1".into());
map.insert("up.up.desc".into(), "Level 2".into());
let flattened = FlattenedJson::from_raw(&raw);
assert_eq!(flattened.map, map);
}
} }