use std::hash::{Hash, Hasher};
use indexmap::{Equivalent, IndexSet};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::instrument;
use crate::{
serde::{JsonObject, Raw, StringEnum},
OwnedRoomId, OwnedUserId, PrivOwnedStr,
};
mod action;
mod condition;
mod iter;
mod predefined;
#[cfg(feature = "unstable-msc3932")]
pub use self::condition::RoomVersionFeature;
pub use self::{
action::{Action, Tweak},
condition::{
ComparisonOperator, FlattenedJson, FlattenedJsonValue, PushCondition,
PushConditionPowerLevelsCtx, PushConditionRoomCtx, RoomMemberCountIs, ScalarJsonValue,
_CustomPushCondition,
},
iter::{AnyPushRule, AnyPushRuleRef, RulesetIntoIter, RulesetIter},
predefined::{
PredefinedContentRuleId, PredefinedOverrideRuleId, PredefinedRuleId,
PredefinedUnderrideRuleId,
},
};
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
pub struct Ruleset {
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub content: IndexSet<PatternedPushRule>,
#[serde(rename = "override", default, skip_serializing_if = "IndexSet::is_empty")]
pub override_: IndexSet<ConditionalPushRule>,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub room: IndexSet<SimplePushRule<OwnedRoomId>>,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub sender: IndexSet<SimplePushRule<OwnedUserId>>,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub underride: IndexSet<ConditionalPushRule>,
}
impl Ruleset {
pub fn new() -> Self {
Default::default()
}
pub fn iter(&self) -> RulesetIter<'_> {
self.into_iter()
}
pub fn insert(
&mut self,
rule: NewPushRule,
after: Option<&str>,
before: Option<&str>,
) -> Result<(), InsertPushRuleError> {
let rule_id = rule.rule_id();
if rule_id.starts_with('.') {
return Err(InsertPushRuleError::ServerDefaultRuleId);
}
if rule_id.contains('/') {
return Err(InsertPushRuleError::InvalidRuleId);
}
if rule_id.contains('\\') {
return Err(InsertPushRuleError::InvalidRuleId);
}
if after.is_some_and(|s| s.starts_with('.')) {
return Err(InsertPushRuleError::RelativeToServerDefaultRule);
}
if before.is_some_and(|s| s.starts_with('.')) {
return Err(InsertPushRuleError::RelativeToServerDefaultRule);
}
match rule {
NewPushRule::Override(r) => {
let mut rule = ConditionalPushRule::from(r);
if let Some(prev_rule) = self.override_.get(rule.rule_id.as_str()) {
rule.enabled = prev_rule.enabled;
}
let default_position = 1;
insert_and_move_rule(&mut self.override_, rule, default_position, after, before)
}
NewPushRule::Underride(r) => {
let mut rule = ConditionalPushRule::from(r);
if let Some(prev_rule) = self.underride.get(rule.rule_id.as_str()) {
rule.enabled = prev_rule.enabled;
}
insert_and_move_rule(&mut self.underride, rule, 0, after, before)
}
NewPushRule::Content(r) => {
let mut rule = PatternedPushRule::from(r);
if let Some(prev_rule) = self.content.get(rule.rule_id.as_str()) {
rule.enabled = prev_rule.enabled;
}
insert_and_move_rule(&mut self.content, rule, 0, after, before)
}
NewPushRule::Room(r) => {
let mut rule = SimplePushRule::from(r);
if let Some(prev_rule) = self.room.get(rule.rule_id.as_str()) {
rule.enabled = prev_rule.enabled;
}
insert_and_move_rule(&mut self.room, rule, 0, after, before)
}
NewPushRule::Sender(r) => {
let mut rule = SimplePushRule::from(r);
if let Some(prev_rule) = self.sender.get(rule.rule_id.as_str()) {
rule.enabled = prev_rule.enabled;
}
insert_and_move_rule(&mut self.sender, rule, 0, after, before)
}
}
}
pub fn get(&self, kind: RuleKind, rule_id: impl AsRef<str>) -> Option<AnyPushRuleRef<'_>> {
let rule_id = rule_id.as_ref();
match kind {
RuleKind::Override => self.override_.get(rule_id).map(AnyPushRuleRef::Override),
RuleKind::Underride => self.underride.get(rule_id).map(AnyPushRuleRef::Underride),
RuleKind::Sender => self.sender.get(rule_id).map(AnyPushRuleRef::Sender),
RuleKind::Room => self.room.get(rule_id).map(AnyPushRuleRef::Room),
RuleKind::Content => self.content.get(rule_id).map(AnyPushRuleRef::Content),
RuleKind::_Custom(_) => None,
}
}
pub fn set_enabled(
&mut self,
kind: RuleKind,
rule_id: impl AsRef<str>,
enabled: bool,
) -> Result<(), RuleNotFoundError> {
let rule_id = rule_id.as_ref();
match kind {
RuleKind::Override => {
let mut rule = self.override_.get(rule_id).ok_or(RuleNotFoundError)?.clone();
rule.enabled = enabled;
self.override_.replace(rule);
}
RuleKind::Underride => {
let mut rule = self.underride.get(rule_id).ok_or(RuleNotFoundError)?.clone();
rule.enabled = enabled;
self.underride.replace(rule);
}
RuleKind::Sender => {
let mut rule = self.sender.get(rule_id).ok_or(RuleNotFoundError)?.clone();
rule.enabled = enabled;
self.sender.replace(rule);
}
RuleKind::Room => {
let mut rule = self.room.get(rule_id).ok_or(RuleNotFoundError)?.clone();
rule.enabled = enabled;
self.room.replace(rule);
}
RuleKind::Content => {
let mut rule = self.content.get(rule_id).ok_or(RuleNotFoundError)?.clone();
rule.enabled = enabled;
self.content.replace(rule);
}
RuleKind::_Custom(_) => return Err(RuleNotFoundError),
}
Ok(())
}
pub fn set_actions(
&mut self,
kind: RuleKind,
rule_id: impl AsRef<str>,
actions: Vec<Action>,
) -> Result<(), RuleNotFoundError> {
let rule_id = rule_id.as_ref();
match kind {
RuleKind::Override => {
let mut rule = self.override_.get(rule_id).ok_or(RuleNotFoundError)?.clone();
rule.actions = actions;
self.override_.replace(rule);
}
RuleKind::Underride => {
let mut rule = self.underride.get(rule_id).ok_or(RuleNotFoundError)?.clone();
rule.actions = actions;
self.underride.replace(rule);
}
RuleKind::Sender => {
let mut rule = self.sender.get(rule_id).ok_or(RuleNotFoundError)?.clone();
rule.actions = actions;
self.sender.replace(rule);
}
RuleKind::Room => {
let mut rule = self.room.get(rule_id).ok_or(RuleNotFoundError)?.clone();
rule.actions = actions;
self.room.replace(rule);
}
RuleKind::Content => {
let mut rule = self.content.get(rule_id).ok_or(RuleNotFoundError)?.clone();
rule.actions = actions;
self.content.replace(rule);
}
RuleKind::_Custom(_) => return Err(RuleNotFoundError),
}
Ok(())
}
#[instrument(skip_all, fields(context.room_id = %context.room_id))]
pub fn get_match<T>(
&self,
event: &Raw<T>,
context: &PushConditionRoomCtx,
) -> Option<AnyPushRuleRef<'_>> {
let event = FlattenedJson::from_raw(event);
if event.get_str("sender").is_some_and(|sender| sender == context.user_id) {
None
} else {
self.iter().find(|rule| rule.applies(&event, context))
}
}
#[instrument(skip_all, fields(context.room_id = %context.room_id))]
pub fn get_actions<T>(&self, event: &Raw<T>, context: &PushConditionRoomCtx) -> &[Action] {
self.get_match(event, context).map(|rule| rule.actions()).unwrap_or(&[])
}
pub fn remove(
&mut self,
kind: RuleKind,
rule_id: impl AsRef<str>,
) -> Result<(), RemovePushRuleError> {
let rule_id = rule_id.as_ref();
if let Some(rule) = self.get(kind.clone(), rule_id) {
if rule.is_server_default() {
return Err(RemovePushRuleError::ServerDefault);
}
} else {
return Err(RemovePushRuleError::NotFound);
}
match kind {
RuleKind::Override => {
self.override_.shift_remove(rule_id);
}
RuleKind::Underride => {
self.underride.shift_remove(rule_id);
}
RuleKind::Sender => {
self.sender.shift_remove(rule_id);
}
RuleKind::Room => {
self.room.shift_remove(rule_id);
}
RuleKind::Content => {
self.content.shift_remove(rule_id);
}
RuleKind::_Custom(_) => unreachable!(),
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
pub struct SimplePushRule<T> {
pub actions: Vec<Action>,
pub default: bool,
pub enabled: bool,
pub rule_id: T,
}
#[derive(Debug)]
#[allow(clippy::exhaustive_structs)]
pub struct SimplePushRuleInit<T> {
pub actions: Vec<Action>,
pub default: bool,
pub enabled: bool,
pub rule_id: T,
}
impl<T> From<SimplePushRuleInit<T>> for SimplePushRule<T> {
fn from(init: SimplePushRuleInit<T>) -> Self {
let SimplePushRuleInit { actions, default, enabled, rule_id } = init;
Self { actions, default, enabled, rule_id }
}
}
impl<T> Hash for SimplePushRule<T>
where
T: Hash,
{
fn hash<H: Hasher>(&self, state: &mut H) {
self.rule_id.hash(state);
}
}
impl<T> PartialEq for SimplePushRule<T>
where
T: PartialEq<T>,
{
fn eq(&self, other: &Self) -> bool {
self.rule_id == other.rule_id
}
}
impl<T> Eq for SimplePushRule<T> where T: Eq {}
impl<T> Equivalent<SimplePushRule<T>> for str
where
T: AsRef<str>,
{
fn equivalent(&self, key: &SimplePushRule<T>) -> bool {
self == key.rule_id.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
pub struct ConditionalPushRule {
pub actions: Vec<Action>,
pub default: bool,
pub enabled: bool,
pub rule_id: String,
#[serde(default)]
pub conditions: Vec<PushCondition>,
}
impl ConditionalPushRule {
pub fn applies(&self, event: &FlattenedJson, context: &PushConditionRoomCtx) -> bool {
if !self.enabled {
return false;
}
#[cfg(feature = "unstable-msc3932")]
{
#[allow(deprecated)]
if self.rule_id != PredefinedOverrideRuleId::Master.as_ref()
&& self.rule_id != PredefinedOverrideRuleId::RoomNotif.as_ref()
&& self.rule_id != PredefinedOverrideRuleId::ContainsDisplayName.as_ref()
{
let room_supports_ext_ev =
context.supported_features.contains(&RoomVersionFeature::ExtensibleEvents);
let rule_has_room_version_supports = self.conditions.iter().any(|condition| {
matches!(condition, PushCondition::RoomVersionSupports { .. })
});
if room_supports_ext_ev && !rule_has_room_version_supports {
return false;
}
}
}
#[allow(deprecated)]
if (self.rule_id == PredefinedOverrideRuleId::RoomNotif.as_ref()
|| self.rule_id == PredefinedOverrideRuleId::ContainsDisplayName.as_ref())
&& event.contains_mentions()
{
return false;
}
self.conditions.iter().all(|cond| cond.applies(event, context))
}
}
#[derive(Debug)]
#[allow(clippy::exhaustive_structs)]
pub struct ConditionalPushRuleInit {
pub actions: Vec<Action>,
pub default: bool,
pub enabled: bool,
pub rule_id: String,
pub conditions: Vec<PushCondition>,
}
impl From<ConditionalPushRuleInit> for ConditionalPushRule {
fn from(init: ConditionalPushRuleInit) -> Self {
let ConditionalPushRuleInit { actions, default, enabled, rule_id, conditions } = init;
Self { actions, default, enabled, rule_id, conditions }
}
}
impl Hash for ConditionalPushRule {
fn hash<H: Hasher>(&self, state: &mut H) {
self.rule_id.hash(state);
}
}
impl PartialEq for ConditionalPushRule {
fn eq(&self, other: &Self) -> bool {
self.rule_id == other.rule_id
}
}
impl Eq for ConditionalPushRule {}
impl Equivalent<ConditionalPushRule> for str {
fn equivalent(&self, key: &ConditionalPushRule) -> bool {
self == key.rule_id
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
pub struct PatternedPushRule {
pub actions: Vec<Action>,
pub default: bool,
pub enabled: bool,
pub rule_id: String,
pub pattern: String,
}
impl PatternedPushRule {
pub fn applies_to(
&self,
key: &str,
event: &FlattenedJson,
context: &PushConditionRoomCtx,
) -> bool {
#[allow(deprecated)]
if self.rule_id == PredefinedContentRuleId::ContainsUserName.as_ref()
&& event.contains_mentions()
{
return false;
}
if event.get_str("sender").is_some_and(|sender| sender == context.user_id) {
return false;
}
self.enabled && condition::check_event_match(event, key, &self.pattern, context)
}
}
#[derive(Debug)]
#[allow(clippy::exhaustive_structs)]
pub struct PatternedPushRuleInit {
pub actions: Vec<Action>,
pub default: bool,
pub enabled: bool,
pub rule_id: String,
pub pattern: String,
}
impl From<PatternedPushRuleInit> for PatternedPushRule {
fn from(init: PatternedPushRuleInit) -> Self {
let PatternedPushRuleInit { actions, default, enabled, rule_id, pattern } = init;
Self { actions, default, enabled, rule_id, pattern }
}
}
impl Hash for PatternedPushRule {
fn hash<H: Hasher>(&self, state: &mut H) {
self.rule_id.hash(state);
}
}
impl PartialEq for PatternedPushRule {
fn eq(&self, other: &Self) -> bool {
self.rule_id == other.rule_id
}
}
impl Eq for PatternedPushRule {}
impl Equivalent<PatternedPushRule> for str {
fn equivalent(&self, key: &PatternedPushRule) -> bool {
self == key.rule_id
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
pub struct HttpPusherData {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<PushFormat>,
#[serde(flatten, default, skip_serializing_if = "JsonObject::is_empty")]
pub data: JsonObject,
}
impl HttpPusherData {
pub fn new(url: String) -> Self {
Self { url, format: None, data: JsonObject::default() }
}
}
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
#[derive(Clone, PartialEq, Eq, StringEnum)]
#[ruma_enum(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PushFormat {
EventIdOnly,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, StringEnum)]
#[ruma_enum(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RuleKind {
Override,
Underride,
Sender,
Room,
Content,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}
#[derive(Clone, Debug)]
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
pub enum NewPushRule {
Override(NewConditionalPushRule),
Content(NewPatternedPushRule),
Room(NewSimplePushRule<OwnedRoomId>),
Sender(NewSimplePushRule<OwnedUserId>),
Underride(NewConditionalPushRule),
}
impl NewPushRule {
pub fn kind(&self) -> RuleKind {
match self {
NewPushRule::Override(_) => RuleKind::Override,
NewPushRule::Content(_) => RuleKind::Content,
NewPushRule::Room(_) => RuleKind::Room,
NewPushRule::Sender(_) => RuleKind::Sender,
NewPushRule::Underride(_) => RuleKind::Underride,
}
}
pub fn rule_id(&self) -> &str {
match self {
NewPushRule::Override(r) => &r.rule_id,
NewPushRule::Content(r) => &r.rule_id,
NewPushRule::Room(r) => r.rule_id.as_ref(),
NewPushRule::Sender(r) => r.rule_id.as_ref(),
NewPushRule::Underride(r) => &r.rule_id,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
pub struct NewSimplePushRule<T> {
pub rule_id: T,
pub actions: Vec<Action>,
}
impl<T> NewSimplePushRule<T> {
pub fn new(rule_id: T, actions: Vec<Action>) -> Self {
Self { rule_id, actions }
}
}
impl<T> From<NewSimplePushRule<T>> for SimplePushRule<T> {
fn from(new_rule: NewSimplePushRule<T>) -> Self {
let NewSimplePushRule { rule_id, actions } = new_rule;
Self { actions, default: false, enabled: true, rule_id }
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
pub struct NewPatternedPushRule {
pub rule_id: String,
pub pattern: String,
pub actions: Vec<Action>,
}
impl NewPatternedPushRule {
pub fn new(rule_id: String, pattern: String, actions: Vec<Action>) -> Self {
Self { rule_id, pattern, actions }
}
}
impl From<NewPatternedPushRule> for PatternedPushRule {
fn from(new_rule: NewPatternedPushRule) -> Self {
let NewPatternedPushRule { rule_id, pattern, actions } = new_rule;
Self { actions, default: false, enabled: true, rule_id, pattern }
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
pub struct NewConditionalPushRule {
pub rule_id: String,
#[serde(default)]
pub conditions: Vec<PushCondition>,
pub actions: Vec<Action>,
}
impl NewConditionalPushRule {
pub fn new(rule_id: String, conditions: Vec<PushCondition>, actions: Vec<Action>) -> Self {
Self { rule_id, conditions, actions }
}
}
impl From<NewConditionalPushRule> for ConditionalPushRule {
fn from(new_rule: NewConditionalPushRule) -> Self {
let NewConditionalPushRule { rule_id, conditions, actions } = new_rule;
Self { actions, default: false, enabled: true, rule_id, conditions }
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum InsertPushRuleError {
#[error("rule IDs starting with a dot are reserved for server-default rules")]
ServerDefaultRuleId,
#[error("invalid rule ID")]
InvalidRuleId,
#[error("can't place rule relative to server-default rule")]
RelativeToServerDefaultRule,
#[error("The before or after rule could not be found")]
UnknownRuleId,
#[error("before has a higher priority than after")]
BeforeHigherThanAfter,
}
#[derive(Debug, Error)]
#[non_exhaustive]
#[error("The rule could not be found")]
pub struct RuleNotFoundError;
pub fn insert_and_move_rule<T>(
set: &mut IndexSet<T>,
rule: T,
default_position: usize,
after: Option<&str>,
before: Option<&str>,
) -> Result<(), InsertPushRuleError>
where
T: Hash + Eq,
str: Equivalent<T>,
{
let (from, replaced) = set.replace_full(rule);
let mut to = default_position;
if let Some(rule_id) = after {
let idx = set.get_index_of(rule_id).ok_or(InsertPushRuleError::UnknownRuleId)?;
to = idx + 1;
}
if let Some(rule_id) = before {
let idx = set.get_index_of(rule_id).ok_or(InsertPushRuleError::UnknownRuleId)?;
if idx < to {
return Err(InsertPushRuleError::BeforeHigherThanAfter);
}
to = idx;
}
if replaced.is_none() || after.is_some() || before.is_some() {
set.move_index(from, to);
}
Ok(())
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RemovePushRuleError {
#[error("server-default rules cannot be removed")]
ServerDefault,
#[error("rule not found")]
NotFound,
}
#[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::RawValue as RawJsonValue, Value as JsonValue,
};
use super::{
action::{Action, Tweak},
condition::{
PushCondition, PushConditionPowerLevelsCtx, PushConditionRoomCtx, RoomMemberCountIs,
},
AnyPushRule, ConditionalPushRule, PatternedPushRule, Ruleset, SimplePushRule,
};
use crate::{
owned_room_id, owned_user_id,
power_levels::NotificationPowerLevels,
push::{PredefinedContentRuleId, PredefinedOverrideRuleId},
serde::Raw,
user_id,
};
fn example_ruleset() -> Ruleset {
let mut set = Ruleset::new();
set.override_.insert(ConditionalPushRule {
conditions: vec![PushCondition::EventMatch {
key: "type".into(),
pattern: "m.call.invite".into(),
}],
actions: vec![Action::Notify, Action::SetTweak(Tweak::Highlight(true))],
rule_id: ".m.rule.call".into(),
enabled: true,
default: true,
});
set
}
fn power_levels() -> PushConditionPowerLevelsCtx {
PushConditionPowerLevelsCtx {
users: BTreeMap::new(),
users_default: int!(50),
notifications: NotificationPowerLevels { room: int!(50) },
}
}
#[test]
fn iter() {
let mut set = example_ruleset();
let added = set.override_.insert(ConditionalPushRule {
conditions: vec![PushCondition::EventMatch {
key: "room_id".into(),
pattern: "!roomid:matrix.org".into(),
}],
actions: vec![],
rule_id: "!roomid:matrix.org".into(),
enabled: true,
default: false,
});
assert!(added);
let added = set.override_.insert(ConditionalPushRule {
conditions: vec![],
actions: vec![],
rule_id: ".m.rule.suppress_notices".into(),
enabled: false,
default: true,
});
assert!(added);
let mut iter = set.into_iter();
let rule_opt = iter.next();
assert!(rule_opt.is_some());
assert_matches!(
rule_opt.unwrap(),
AnyPushRule::Override(ConditionalPushRule { rule_id, .. })
);
assert_eq!(rule_id, ".m.rule.call");
let rule_opt = iter.next();
assert!(rule_opt.is_some());
assert_matches!(
rule_opt.unwrap(),
AnyPushRule::Override(ConditionalPushRule { rule_id, .. })
);
assert_eq!(rule_id, "!roomid:matrix.org");
let rule_opt = iter.next();
assert!(rule_opt.is_some());
assert_matches!(
rule_opt.unwrap(),
AnyPushRule::Override(ConditionalPushRule { rule_id, .. })
);
assert_eq!(rule_id, ".m.rule.suppress_notices");
assert_matches!(iter.next(), None);
}
#[test]
fn serialize_conditional_push_rule() {
let rule = ConditionalPushRule {
actions: vec![Action::Notify, Action::SetTweak(Tweak::Highlight(true))],
default: true,
enabled: true,
rule_id: ".m.rule.call".into(),
conditions: vec![
PushCondition::EventMatch { key: "type".into(), pattern: "m.call.invite".into() },
PushCondition::ContainsDisplayName,
PushCondition::RoomMemberCount { is: RoomMemberCountIs::gt(uint!(2)) },
PushCondition::SenderNotificationPermission { key: "room".into() },
],
};
let rule_value: JsonValue = to_json_value(rule).unwrap();
assert_eq!(
rule_value,
json!({
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.call.invite"
},
{
"kind": "contains_display_name"
},
{
"kind": "room_member_count",
"is": ">2"
},
{
"kind": "sender_notification_permission",
"key": "room"
}
],
"actions": [
"notify",
{
"set_tweak": "highlight"
}
],
"rule_id": ".m.rule.call",
"default": true,
"enabled": true
})
);
}
#[test]
fn serialize_simple_push_rule() {
let rule = SimplePushRule {
actions: vec![Action::Notify],
default: false,
enabled: false,
rule_id: owned_room_id!("!roomid:server.name"),
};
let rule_value: JsonValue = to_json_value(rule).unwrap();
assert_eq!(
rule_value,
json!({
"actions": [
"notify"
],
"rule_id": "!roomid:server.name",
"default": false,
"enabled": false
})
);
}
#[test]
fn serialize_patterned_push_rule() {
let rule = PatternedPushRule {
actions: vec![
Action::Notify,
Action::SetTweak(Tweak::Sound("default".into())),
Action::SetTweak(Tweak::Custom {
name: "dance".into(),
value: RawJsonValue::from_string("true".into()).unwrap(),
}),
],
default: true,
enabled: true,
pattern: "user_id".into(),
rule_id: ".m.rule.contains_user_name".into(),
};
let rule_value: JsonValue = to_json_value(rule).unwrap();
assert_eq!(
rule_value,
json!({
"actions": [
"notify",
{
"set_tweak": "sound",
"value": "default"
},
{
"set_tweak": "dance",
"value": true
}
],
"pattern": "user_id",
"rule_id": ".m.rule.contains_user_name",
"default": true,
"enabled": true
})
);
}
#[test]
fn serialize_ruleset() {
let mut set = example_ruleset();
set.override_.insert(ConditionalPushRule {
conditions: vec![
PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(2)) },
PushCondition::EventMatch { key: "type".into(), pattern: "m.room.message".into() },
],
actions: vec![
Action::Notify,
Action::SetTweak(Tweak::Sound("default".into())),
Action::SetTweak(Tweak::Highlight(false)),
],
rule_id: ".m.rule.room_one_to_one".into(),
enabled: true,
default: true,
});
set.content.insert(PatternedPushRule {
actions: vec![
Action::Notify,
Action::SetTweak(Tweak::Sound("default".into())),
Action::SetTweak(Tweak::Highlight(true)),
],
rule_id: ".m.rule.contains_user_name".into(),
pattern: "user_id".into(),
enabled: true,
default: true,
});
let set_value: JsonValue = to_json_value(set).unwrap();
assert_eq!(
set_value,
json!({
"override": [
{
"actions": [
"notify",
{
"set_tweak": "highlight",
},
],
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.call.invite"
},
],
"rule_id": ".m.rule.call",
"default": true,
"enabled": true,
},
{
"conditions": [
{
"kind": "room_member_count",
"is": "2"
},
{
"kind": "event_match",
"key": "type",
"pattern": "m.room.message"
}
],
"actions": [
"notify",
{
"set_tweak": "sound",
"value": "default"
},
{
"set_tweak": "highlight",
"value": false
}
],
"rule_id": ".m.rule.room_one_to_one",
"default": true,
"enabled": true
},
],
"content": [
{
"actions": [
"notify",
{
"set_tweak": "sound",
"value": "default"
},
{
"set_tweak": "highlight"
}
],
"pattern": "user_id",
"rule_id": ".m.rule.contains_user_name",
"default": true,
"enabled": true
}
],
})
);
}
#[test]
fn deserialize_patterned_push_rule() {
let rule = from_json_value::<PatternedPushRule>(json!({
"actions": [
"notify",
{
"set_tweak": "sound",
"value": "default"
},
{
"set_tweak": "highlight",
"value": true
}
],
"pattern": "user_id",
"rule_id": ".m.rule.contains_user_name",
"default": true,
"enabled": true
}))
.unwrap();
assert!(rule.default);
assert!(rule.enabled);
assert_eq!(rule.pattern, "user_id");
assert_eq!(rule.rule_id, ".m.rule.contains_user_name");
let mut iter = rule.actions.iter();
assert_matches!(iter.next(), Some(Action::Notify));
assert_matches!(iter.next(), Some(Action::SetTweak(Tweak::Sound(sound))));
assert_eq!(sound, "default");
assert_matches!(iter.next(), Some(Action::SetTweak(Tweak::Highlight(true))));
assert_matches!(iter.next(), None);
}
#[test]
fn deserialize_ruleset() {
let set: Ruleset = from_json_value(json!({
"override": [
{
"actions": [],
"conditions": [],
"rule_id": "!roomid:server.name",
"default": false,
"enabled": true
},
{
"actions": [],
"conditions": [],
"rule_id": ".m.rule.call",
"default": true,
"enabled": true
},
],
"underride": [
{
"actions": [],
"conditions": [],
"rule_id": ".m.rule.room_one_to_one",
"default": true,
"enabled": true
},
],
"room": [
{
"actions": [],
"rule_id": "!roomid:server.name",
"default": false,
"enabled": false
}
],
"sender": [],
"content": [
{
"actions": [],
"pattern": "user_id",
"rule_id": ".m.rule.contains_user_name",
"default": true,
"enabled": true
},
{
"actions": [],
"pattern": "ruma",
"rule_id": "ruma",
"default": false,
"enabled": true
}
]
}))
.unwrap();
let mut iter = set.into_iter();
let rule_opt = iter.next();
assert!(rule_opt.is_some());
assert_matches!(
rule_opt.unwrap(),
AnyPushRule::Override(ConditionalPushRule { rule_id, .. })
);
assert_eq!(rule_id, "!roomid:server.name");
let rule_opt = iter.next();
assert!(rule_opt.is_some());
assert_matches!(
rule_opt.unwrap(),
AnyPushRule::Override(ConditionalPushRule { rule_id, .. })
);
assert_eq!(rule_id, ".m.rule.call");
let rule_opt = iter.next();
assert!(rule_opt.is_some());
assert_matches!(rule_opt.unwrap(), AnyPushRule::Content(PatternedPushRule { rule_id, .. }));
assert_eq!(rule_id, ".m.rule.contains_user_name");
let rule_opt = iter.next();
assert!(rule_opt.is_some());
assert_matches!(rule_opt.unwrap(), AnyPushRule::Content(PatternedPushRule { rule_id, .. }));
assert_eq!(rule_id, "ruma");
let rule_opt = iter.next();
assert!(rule_opt.is_some());
assert_matches!(rule_opt.unwrap(), AnyPushRule::Room(SimplePushRule { rule_id, .. }));
assert_eq!(rule_id, "!roomid:server.name");
let rule_opt = iter.next();
assert!(rule_opt.is_some());
assert_matches!(
rule_opt.unwrap(),
AnyPushRule::Underride(ConditionalPushRule { rule_id, .. })
);
assert_eq!(rule_id, ".m.rule.room_one_to_one");
assert_matches!(iter.next(), None);
}
#[test]
fn default_ruleset_applies() {
let set = Ruleset::server_default(user_id!("@jolly_jumper:server.name"));
let context_one_to_one = &PushConditionRoomCtx {
room_id: owned_room_id!("!dm:server.name"),
member_count: uint!(2),
user_id: owned_user_id!("@jj:server.name"),
user_display_name: "Jolly Jumper".into(),
power_levels: Some(power_levels()),
#[cfg(feature = "unstable-msc3931")]
supported_features: Default::default(),
};
let context_public_room = &PushConditionRoomCtx {
room_id: owned_room_id!("!far_west:server.name"),
member_count: uint!(100),
user_id: owned_user_id!("@jj:server.name"),
user_display_name: "Jolly Jumper".into(),
power_levels: Some(power_levels()),
#[cfg(feature = "unstable-msc3931")]
supported_features: Default::default(),
};
let message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"type": "m.room.message"
}"#,
)
.unwrap();
assert_matches!(
set.get_actions(&message, context_one_to_one),
[
Action::Notify,
Action::SetTweak(Tweak::Sound(_)),
Action::SetTweak(Tweak::Highlight(false))
]
);
assert_matches!(
set.get_actions(&message, context_public_room),
[Action::Notify, Action::SetTweak(Tweak::Highlight(false))]
);
let user_name = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"type": "m.room.message",
"content": {
"body": "Hi jolly_jumper!"
}
}"#,
)
.unwrap();
assert_matches!(
set.get_actions(&user_name, context_one_to_one),
[
Action::Notify,
Action::SetTweak(Tweak::Sound(_)),
Action::SetTweak(Tweak::Highlight(true)),
]
);
assert_matches!(
set.get_actions(&user_name, context_public_room),
[
Action::Notify,
Action::SetTweak(Tweak::Sound(_)),
Action::SetTweak(Tweak::Highlight(true)),
]
);
let notice = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"type": "m.room.message",
"content": {
"msgtype": "m.notice"
}
}"#,
)
.unwrap();
assert_matches!(set.get_actions(¬ice, context_one_to_one), []);
let at_room = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"type": "m.room.message",
"sender": "@rantanplan:server.name",
"content": {
"body": "@room Attention please!",
"msgtype": "m.text"
}
}"#,
)
.unwrap();
assert_matches!(
set.get_actions(&at_room, context_public_room),
[Action::Notify, Action::SetTweak(Tweak::Highlight(true)),]
);
let empty = serde_json::from_str::<Raw<JsonValue>>(r#"{}"#).unwrap();
assert_matches!(set.get_actions(&empty, context_one_to_one), []);
}
#[test]
fn custom_ruleset_applies() {
let context_one_to_one = &PushConditionRoomCtx {
room_id: owned_room_id!("!dm:server.name"),
member_count: uint!(2),
user_id: owned_user_id!("@jj:server.name"),
user_display_name: "Jolly Jumper".into(),
power_levels: Some(power_levels()),
#[cfg(feature = "unstable-msc3931")]
supported_features: Default::default(),
};
let message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"sender": "@rantanplan:server.name",
"type": "m.room.message",
"content": {
"msgtype": "m.text",
"body": "Great joke!"
}
}"#,
)
.unwrap();
let mut set = Ruleset::new();
let disabled = ConditionalPushRule {
actions: vec![Action::Notify],
default: false,
enabled: false,
rule_id: "disabled".into(),
conditions: vec![PushCondition::RoomMemberCount {
is: RoomMemberCountIs::from(uint!(2)),
}],
};
set.underride.insert(disabled);
let test_set = set.clone();
assert_matches!(test_set.get_actions(&message, context_one_to_one), []);
let no_conditions = ConditionalPushRule {
actions: vec![Action::SetTweak(Tweak::Highlight(true))],
default: false,
enabled: true,
rule_id: "no.conditions".into(),
conditions: vec![],
};
set.underride.insert(no_conditions);
let test_set = set.clone();
assert_matches!(
test_set.get_actions(&message, context_one_to_one),
[Action::SetTweak(Tweak::Highlight(true))]
);
let sender = SimplePushRule {
actions: vec![Action::Notify],
default: false,
enabled: true,
rule_id: owned_user_id!("@rantanplan:server.name"),
};
set.sender.insert(sender);
let test_set = set.clone();
assert_matches!(test_set.get_actions(&message, context_one_to_one), [Action::Notify]);
let room = SimplePushRule {
actions: vec![Action::SetTweak(Tweak::Highlight(true))],
default: false,
enabled: true,
rule_id: owned_room_id!("!dm:server.name"),
};
set.room.insert(room);
let test_set = set.clone();
assert_matches!(
test_set.get_actions(&message, context_one_to_one),
[Action::SetTweak(Tweak::Highlight(true))]
);
let content = PatternedPushRule {
actions: vec![Action::SetTweak(Tweak::Sound("content".into()))],
default: false,
enabled: true,
rule_id: "content".into(),
pattern: "joke".into(),
};
set.content.insert(content);
let test_set = set.clone();
assert_matches!(
test_set.get_actions(&message, context_one_to_one),
[Action::SetTweak(Tweak::Sound(sound))]
);
assert_eq!(sound, "content");
let three_conditions = ConditionalPushRule {
actions: vec![Action::SetTweak(Tweak::Sound("three".into()))],
default: false,
enabled: true,
rule_id: "three.conditions".into(),
conditions: vec![
PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(2)) },
PushCondition::ContainsDisplayName,
PushCondition::EventMatch {
key: "room_id".into(),
pattern: "!dm:server.name".into(),
},
],
};
set.override_.insert(three_conditions);
assert_matches!(
set.get_actions(&message, context_one_to_one),
[Action::SetTweak(Tweak::Sound(sound))]
);
assert_eq!(sound, "content");
let new_message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"sender": "@rantanplan:server.name",
"type": "m.room.message",
"content": {
"msgtype": "m.text",
"body": "Tell me another one, Jolly Jumper!"
}
}"#,
)
.unwrap();
assert_matches!(
set.get_actions(&new_message, context_one_to_one),
[Action::SetTweak(Tweak::Sound(sound))]
);
assert_eq!(sound, "three");
}
#[test]
#[allow(deprecated)]
fn old_mentions_apply() {
let set = Ruleset::server_default(user_id!("@jolly_jumper:server.name"));
let context = &PushConditionRoomCtx {
room_id: owned_room_id!("!far_west:server.name"),
member_count: uint!(100),
user_id: owned_user_id!("@jj:server.name"),
user_display_name: "Jolly Jumper".into(),
power_levels: Some(power_levels()),
#[cfg(feature = "unstable-msc3931")]
supported_features: Default::default(),
};
let message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"content": {
"body": "jolly_jumper"
},
"type": "m.room.message"
}"#,
)
.unwrap();
assert_eq!(
set.get_match(&message, context).unwrap().rule_id(),
PredefinedContentRuleId::ContainsUserName.as_ref()
);
let message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"content": {
"body": "jolly_jumper",
"m.mentions": {}
},
"type": "m.room.message"
}"#,
)
.unwrap();
assert_ne!(
set.get_match(&message, context).unwrap().rule_id(),
PredefinedContentRuleId::ContainsUserName.as_ref()
);
let message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"content": {
"body": "Jolly Jumper"
},
"type": "m.room.message"
}"#,
)
.unwrap();
assert_eq!(
set.get_match(&message, context).unwrap().rule_id(),
PredefinedOverrideRuleId::ContainsDisplayName.as_ref()
);
let message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"content": {
"body": "Jolly Jumper",
"m.mentions": {}
},
"type": "m.room.message"
}"#,
)
.unwrap();
assert_ne!(
set.get_match(&message, context).unwrap().rule_id(),
PredefinedOverrideRuleId::ContainsDisplayName.as_ref()
);
let message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"content": {
"body": "@room"
},
"sender": "@admin:server.name",
"type": "m.room.message"
}"#,
)
.unwrap();
assert_eq!(
set.get_match(&message, context).unwrap().rule_id(),
PredefinedOverrideRuleId::RoomNotif.as_ref()
);
let message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"content": {
"body": "@room",
"m.mentions": {}
},
"sender": "@admin:server.name",
"type": "m.room.message"
}"#,
)
.unwrap();
assert_ne!(
set.get_match(&message, context).unwrap().rule_id(),
PredefinedOverrideRuleId::RoomNotif.as_ref()
);
}
#[test]
fn intentional_mentions_apply() {
let set = Ruleset::server_default(user_id!("@jolly_jumper:server.name"));
let context = &PushConditionRoomCtx {
room_id: owned_room_id!("!far_west:server.name"),
member_count: uint!(100),
user_id: owned_user_id!("@jj:server.name"),
user_display_name: "Jolly Jumper".into(),
power_levels: Some(power_levels()),
#[cfg(feature = "unstable-msc3931")]
supported_features: Default::default(),
};
let message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"content": {
"body": "Hey jolly_jumper!",
"m.mentions": {
"user_ids": ["@jolly_jumper:server.name"]
}
},
"sender": "@admin:server.name",
"type": "m.room.message"
}"#,
)
.unwrap();
assert_eq!(
set.get_match(&message, context).unwrap().rule_id(),
PredefinedOverrideRuleId::IsUserMention.as_ref()
);
let message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"content": {
"body": "Listen room!",
"m.mentions": {
"room": true
}
},
"sender": "@admin:server.name",
"type": "m.room.message"
}"#,
)
.unwrap();
assert_eq!(
set.get_match(&message, context).unwrap().rule_id(),
PredefinedOverrideRuleId::IsRoomMention.as_ref()
);
}
#[test]
fn invite_for_me_applies() {
let set = Ruleset::server_default(user_id!("@jolly_jumper:server.name"));
let context = &PushConditionRoomCtx {
room_id: owned_room_id!("!far_west:server.name"),
member_count: uint!(100),
user_id: owned_user_id!("@jj:server.name"),
user_display_name: "Jolly Jumper".into(),
power_levels: None,
#[cfg(feature = "unstable-msc3931")]
supported_features: Default::default(),
};
let message = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"content": {
"membership": "invite"
},
"state_key": "@jolly_jumper:server.name",
"sender": "@admin:server.name",
"type": "m.room.member"
}"#,
)
.unwrap();
assert_eq!(
set.get_match(&message, context).unwrap().rule_id(),
PredefinedOverrideRuleId::InviteForMe.as_ref()
);
}
}