diff --git a/Cargo.toml b/Cargo.toml index b62ab3e8..281d0cfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ ruma-html = { version = "0.1.0", path = "crates/ruma-html" } ruma-identifiers-validation = { version = "0.9.3", path = "crates/ruma-identifiers-validation" } ruma-identity-service-api = { version = "0.8.0", path = "crates/ruma-identity-service-api" } ruma-macros = { version = "=0.12.0", path = "crates/ruma-macros" } -ruma-push-gateway-api = { version = "0.8.0", path = "crates/ruma-push-gateway-api" } ruma-signatures = { version = "0.14.0", path = "crates/ruma-signatures" } ruma-server-util = { version = "0.2.0", path = "crates/ruma-server-util" } ruma-state-res = { version = "0.10.0", path = "crates/ruma-state-res" } diff --git a/crates/ruma-client-api/src/device.rs b/crates/ruma-client-api/src/device.rs index fb97f2ec..e7fa0bdd 100644 --- a/crates/ruma-client-api/src/device.rs +++ b/crates/ruma-client-api/src/device.rs @@ -25,11 +25,20 @@ pub struct Device { /// Unix timestamp that the session was last active. #[serde(skip_serializing_if = "Option::is_none")] pub last_seen_ts: Option, + + /// Whether to enable notifications for this device + pub enable_push: bool, } impl Device { /// Creates a new `Device` with the given device ID. pub fn new(device_id: OwnedDeviceId) -> Self { - Self { device_id, display_name: None, last_seen_ip: None, last_seen_ts: None } + Self { + device_id, + display_name: None, + last_seen_ip: None, + last_seen_ts: None, + enable_push: true, + } } } diff --git a/crates/ruma-client-api/src/device/update_device.rs b/crates/ruma-client-api/src/device/update_device.rs index 691775bf..e9b570c7 100644 --- a/crates/ruma-client-api/src/device/update_device.rs +++ b/crates/ruma-client-api/src/device/update_device.rs @@ -34,6 +34,10 @@ pub mod v3 { /// If this is `None`, the display name won't be changed. #[serde(skip_serializing_if = "Option::is_none")] pub display_name: Option, + + /// Whether to enable notifications for this device + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_push: Option, } /// Response type for the `update_device` endpoint. @@ -44,7 +48,7 @@ pub mod v3 { impl Request { /// Creates a new `Request` with the given device ID. pub fn new(device_id: OwnedDeviceId) -> Self { - Self { device_id, display_name: None } + Self { device_id, display_name: None, enable_push: None } } } diff --git a/crates/ruma-client-api/src/push.rs b/crates/ruma-client-api/src/push.rs index 7c4045dd..f711a385 100644 --- a/crates/ruma-client-api/src/push.rs +++ b/crates/ruma-client-api/src/push.rs @@ -1,197 +1,14 @@ //! Endpoints for push notifications. -use std::{error::Error, fmt}; - -pub use ruma_common::push::RuleKind; -use ruma_common::{ - push::{ - Action, AnyPushRule, AnyPushRuleRef, ConditionalPushRule, ConditionalPushRuleInit, - HttpPusherData, PatternedPushRule, PatternedPushRuleInit, PushCondition, SimplePushRule, - SimplePushRuleInit, - }, - serde::{JsonObject, StringEnum}, -}; +use ruma_common::{push::HttpPusherData, serde::JsonObject}; use serde::{Deserialize, Serialize}; -use crate::PrivOwnedStr; - -// TODO: remove almost all of these -pub mod delete_pushrule; -pub mod get_notifications; -pub mod get_pushers; -pub mod get_pushrule; -pub mod get_pushrule_actions; -pub mod get_pushrule_enabled; -pub mod get_pushrules_all; -pub mod get_pushrules_global_scope; -mod pusher_serde; -pub mod set_pusher; -pub mod set_pushrule; -pub mod set_pushrule_actions; -pub mod set_pushrule_enabled; pub mod get_inbox; - -/// Like `SimplePushRule`, but may represent any kind of push rule thanks to `pattern` and -/// `conditions` being optional. -/// -/// To create an instance of this type, use one of its `From` implementations. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct PushRule { - /// The actions to perform when this rule is matched. - pub actions: Vec, - - /// Whether this is a default rule, or has been set explicitly. - pub default: bool, - - /// Whether the push rule is enabled or not. - pub enabled: bool, - - /// The ID of this rule. - pub rule_id: String, - - /// The conditions that must hold true for an event in order for a rule to be applied to an - /// event. - /// - /// A rule with no conditions always matches. Only applicable to underride and override rules. - #[serde(skip_serializing_if = "Option::is_none")] - pub conditions: Option>, - - /// The glob-style pattern to match against. - /// - /// Only applicable to content rules. - #[serde(skip_serializing_if = "Option::is_none")] - pub pattern: Option, -} - -impl From> for PushRule -where - T: Into, -{ - fn from(push_rule: SimplePushRule) -> Self { - let SimplePushRule { actions, default, enabled, rule_id, .. } = push_rule; - let rule_id = rule_id.into(); - Self { actions, default, enabled, rule_id, conditions: None, pattern: None } - } -} - -impl From for PushRule { - fn from(push_rule: PatternedPushRule) -> Self { - let PatternedPushRule { actions, default, enabled, rule_id, pattern, .. } = push_rule; - Self { actions, default, enabled, rule_id, conditions: None, pattern: Some(pattern) } - } -} - -impl From for PushRule { - fn from(push_rule: ConditionalPushRule) -> Self { - let ConditionalPushRule { actions, default, enabled, rule_id, conditions, .. } = push_rule; - Self { actions, default, enabled, rule_id, conditions: Some(conditions), pattern: None } - } -} - -impl From> for PushRule -where - T: Into, -{ - fn from(init: SimplePushRuleInit) -> Self { - let SimplePushRuleInit { actions, default, enabled, rule_id } = init; - let rule_id = rule_id.into(); - Self { actions, default, enabled, rule_id, pattern: None, conditions: None } - } -} - -impl From for PushRule { - fn from(init: ConditionalPushRuleInit) -> Self { - let ConditionalPushRuleInit { actions, default, enabled, rule_id, conditions } = init; - Self { actions, default, enabled, rule_id, pattern: None, conditions: Some(conditions) } - } -} - -impl From for PushRule { - fn from(init: PatternedPushRuleInit) -> Self { - let PatternedPushRuleInit { actions, default, enabled, rule_id, pattern } = init; - Self { actions, default, enabled, rule_id, pattern: Some(pattern), conditions: None } - } -} - -impl From for PushRule { - fn from(push_rule: AnyPushRule) -> Self { - // The catch-all is unreachable if the "unstable-exhaustive-types" feature is enabled. - #[allow(unreachable_patterns)] - match push_rule { - AnyPushRule::Override(r) => r.into(), - AnyPushRule::Content(r) => r.into(), - AnyPushRule::Room(r) => r.into(), - AnyPushRule::Sender(r) => r.into(), - AnyPushRule::Underride(r) => r.into(), - _ => unreachable!(), - } - } -} - -impl<'a> From> for PushRule { - fn from(push_rule: AnyPushRuleRef<'a>) -> Self { - push_rule.to_owned().into() - } -} - -impl TryFrom for SimplePushRule -where - T: TryFrom, -{ - type Error = >::Error; - - fn try_from(push_rule: PushRule) -> Result { - let PushRule { actions, default, enabled, rule_id, .. } = push_rule; - let rule_id = T::try_from(rule_id)?; - Ok(SimplePushRuleInit { actions, default, enabled, rule_id }.into()) - } -} - -/// An error that happens when `PushRule` cannot -/// be converted into `PatternedPushRule` -#[derive(Debug)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct MissingPatternError; - -impl fmt::Display for MissingPatternError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Push rule does not have a pattern.") - } -} - -impl Error for MissingPatternError {} - -impl TryFrom for PatternedPushRule { - type Error = MissingPatternError; - - fn try_from(push_rule: PushRule) -> Result { - if let PushRule { actions, default, enabled, rule_id, pattern: Some(pattern), .. } = - push_rule - { - Ok(PatternedPushRuleInit { actions, default, enabled, rule_id, pattern }.into()) - } else { - Err(MissingPatternError) - } - } -} - -impl From for ConditionalPushRule { - fn from(push_rule: PushRule) -> Self { - let PushRule { actions, default, enabled, rule_id, conditions, .. } = push_rule; - - ConditionalPushRuleInit { - actions, - default, - enabled, - rule_id, - conditions: conditions.unwrap_or_default(), - } - .into() - } -} +pub mod get_pushers; +pub mod set_ack; +pub mod set_pusher; /// Which kind a pusher is, and the information for that kind. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[non_exhaustive] pub enum PusherKind { /// A pusher that sends HTTP pokes. @@ -208,7 +25,7 @@ pub enum PusherKind { /// /// To create an instance of this type, first create a `PusherInit` and convert it via /// `Pusher::from` / `.into()`. -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] pub struct Pusher { /// Identifiers for this pusher. @@ -290,7 +107,7 @@ impl PusherIds { } /// Information for an email pusher. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] pub struct EmailPusherData; @@ -302,22 +119,9 @@ impl EmailPusherData { } #[doc(hidden)] -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[non_exhaustive] pub struct CustomPusherData { kind: String, data: JsonObject, } - -/// The scope of a push rule. -#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))] -#[derive(Clone, PartialEq, Eq, StringEnum)] -#[ruma_enum(rename_all = "lowercase")] -#[non_exhaustive] -pub enum RuleScope { - /// The global rules. - Global, - - #[doc(hidden)] - _Custom(PrivOwnedStr), -} diff --git a/crates/ruma-client-api/src/push/get_inbox.rs b/crates/ruma-client-api/src/push/get_inbox.rs index bf5fce17..23ff02c7 100644 --- a/crates/ruma-client-api/src/push/get_inbox.rs +++ b/crates/ruma-client-api/src/push/get_inbox.rs @@ -1,4 +1,4 @@ -//! `POST /_matrix/client/*/inbox/query` +//! `POST /_matrix/client/*/inbox` //! //! Paginate through the list of events that the user has been, or would have been notified about. @@ -11,12 +11,14 @@ pub mod v3 { use ruma_common::{ api::{request, response, Metadata}, metadata, - serde::Raw, + serde::{Raw, StringEnum}, OwnedRoomId, }; use ruma_events::AnyTimelineEvent; use serde::{Deserialize, Serialize}; + use crate::PrivOwnedStr; + const METADATA: Metadata = metadata! { method: GET, rate_limited: false, @@ -60,7 +62,7 @@ pub mod v3 { pub next_batch: Option, /// The list of threads mention events are in - pub threads: Vec, + pub threads: Vec>, /// The list of thread and notification events pub chunk: Vec, @@ -81,10 +83,10 @@ pub mod v3 { } /// An inbox filter. - #[derive(Clone, Debug, Default, Deserialize, Serialize)] + #[derive(Clone, StringEnum)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] + #[ruma_enum(rename_all = "snake_case")] pub enum InboxFilter { - #[default] /// The default filter: MentionsUser | MentionsBulk | ThreadsParticipating | ThreadsInteresting Default, @@ -102,6 +104,9 @@ pub mod v3 { /// Include read threads. IncludeRead, + + #[doc(hidden)] + _Custom(PrivOwnedStr), } /// Represents a notification. diff --git a/crates/ruma-client-api/src/push/set_ack.rs b/crates/ruma-client-api/src/push/set_ack.rs new file mode 100644 index 00000000..4b1b854b --- /dev/null +++ b/crates/ruma-client-api/src/push/set_ack.rs @@ -0,0 +1,66 @@ +//! `POST /_matrix/client/*/ack` +//! +//! Acknoledge/mark some events as read/unread + +pub mod v3 { + //! `/v3/` ([spec]) + //! + //! [spec]: TODO: write and link spec + + use ruma_common::{ + api::{request, response, Metadata}, + metadata, + OwnedRoomId, OwnedEventId, + }; + use serde::{Deserialize, Serialize}; + + const METADATA: Metadata = metadata! { + method: GET, + rate_limited: false, + authentication: AccessToken, + history: { + 1.0 => "/_matrix/client/v1/ack", + } + }; + + /// Request type for the `get_notifications` endpoint. + #[request(error = crate::Error)] + #[derive(Default)] + pub struct Request { + /// The events being acknowledged + pub acks: Vec, + } + + /// Response type for the `get_inbox` endpoint. + #[response(error = crate::Error)] + pub struct Response {} + + impl Request { + /// Creates an empty `Request`. + pub fn new(acks: Vec) -> Self { + Request { acks } + } + } + + impl Response { + /// Creates a new `Response` with the given notifications. + pub fn new() -> Self { + Self {} + } + } + + /// Represents a notification. + #[derive(Clone, Debug, Deserialize, Serialize)] + #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] + pub struct Ack { + /// The room id this thread is in + pub room_id: OwnedRoomId, + + /// The root event id of the thread to mark as read. Set as empty for the "main" thread. + pub thread_id: Option, + + /// The last read event id. Set as empty to use the last event. + pub event_id: Option, + } +} + diff --git a/crates/ruma-client-api/src/sync/sync_events.rs b/crates/ruma-client-api/src/sync/sync_events.rs index 0e522aeb..3c89ab7d 100644 --- a/crates/ruma-client-api/src/sync/sync_events.rs +++ b/crates/ruma-client-api/src/sync/sync_events.rs @@ -3,7 +3,7 @@ //! Get all new events from all rooms since the last sync or a given point in time. use js_int::UInt; -use ruma_common::OwnedUserId; +use ruma_common::{OwnedUserId, OwnedEventId}; use serde::{self, Deserialize, Serialize}; pub mod v3; @@ -15,13 +15,27 @@ pub mod v4; #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] pub struct UnreadNotificationsCount { - /// The number of unread notifications with the highlight flag set. + /// The last acked/read event or message #[serde(skip_serializing_if = "Option::is_none")] - pub highlight_count: Option, + pub last_ack: Option, - /// The total number of unread notifications. + /// The number of user mentions since the last ack. #[serde(skip_serializing_if = "Option::is_none")] - pub notification_count: Option, + pub mention_user: Option, + + /// The number of bulk mentions since the last ack + /// Bulk mentions are @room and @thread. If there is a user mention, + /// `mention_user` is incremented instead + #[serde(skip_serializing_if = "Option::is_none")] + pub mention_bulk: Option, + + /// The total number of unread notifications since the last ack. + #[serde(skip_serializing_if = "Option::is_none")] + pub notify: Option, + + /// The total number of unread messages since the last ack. + #[serde(skip_serializing_if = "Option::is_none")] + pub messages: Option, } impl UnreadNotificationsCount { @@ -32,7 +46,11 @@ impl UnreadNotificationsCount { /// Returns true if there are no notification count updates. pub fn is_empty(&self) -> bool { - self.highlight_count.is_none() && self.notification_count.is_none() + self.mention_user.is_none() + && self.mention_bulk.is_none() + && self.notify.is_none() + && self.messages.is_none() + && self.last_ack.is_none() } } diff --git a/crates/ruma-common/src/push.rs b/crates/ruma-common/src/push.rs index 3c0fd31f..0a7555f5 100644 --- a/crates/ruma-common/src/push.rs +++ b/crates/ruma-common/src/push.rs @@ -1,684 +1,63 @@ -//! Common types for the [push notifications module][push]. -//! -//! [push]: https://spec.matrix.org/latest/client-server-api/#push-notifications -//! -//! ## Understanding the types of this module -//! -//! Push rules are grouped in `RuleSet`s, and are grouped in five kinds (for -//! more details about the different kind of rules, see the `Ruleset` documentation, -//! or the specification). These five kinds are, by order of priority: -//! -//! - override rules -//! - content rules -//! - room rules -//! - sender rules -//! - underride rules +//! Common types for notifications -use std::hash::{Hash, Hasher}; - -use indexmap::{Equivalent, IndexSet}; +use js_int::UInt; +use ruma_macros::StringEnum; use serde::{Deserialize, Serialize}; -#[cfg(feature = "unstable-unspecified")] use serde_json::Value as JsonValue; -use thiserror::Error; -use tracing::instrument; +use crate::PrivOwnedStr; -use crate::{ - serde::{Raw, StringEnum}, - OwnedRoomId, OwnedUserId, PrivOwnedStr, -}; +/// Push rules which may be set globally or per room. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct PushRules { + /// The push rule to use for a given room, or the default if set globally. + #[serde(skip_serializing_if = "Option::is_none")] + pub rule: Option, -mod action; -mod condition; -mod iter; -mod predefined; + /// Whether to suppress "bulk notifications" (@room, @thread) + #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")] + pub suppress_bulk: bool, -#[cfg(feature = "unstable-msc3932")] -pub use self::condition::RoomVersionFeature; -pub use self::{ - action::{Action, Tweak}, - condition::{ - ComparisonOperator, FlattenedJson, FlattenedJsonValue, PushCondition, PushConditionRoomCtx, - RoomMemberCountIs, ScalarJsonValue, _CustomPushCondition, - }, - iter::{AnyPushRule, AnyPushRuleRef, RulesetIntoIter, RulesetIter}, - predefined::{ - PredefinedContentRuleId, PredefinedOverrideRuleId, PredefinedRuleId, - PredefinedUnderrideRuleId, - }, -}; - -/// A push ruleset scopes a set of rules according to some criteria. -/// -/// For example, some rules may only be applied for messages from a particular sender, a particular -/// room, or by default. The push ruleset contains the entire set of scopes and rules. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct Ruleset { - /// These rules configure behavior for (unencrypted) messages that match certain patterns. - pub content: IndexSet, - - /// These user-configured rules are given the highest priority. - /// - /// This field is named `override_` instead of `override` because the latter is a reserved - /// keyword in Rust. - #[serde(rename = "override")] - pub override_: IndexSet, - - /// These rules change the behavior of all messages for a given room. - pub room: IndexSet>, - - /// These rules configure notification behavior for messages from a specific Matrix user ID. - pub sender: IndexSet>, - - /// These rules are identical to override rules, but have a lower priority than `content`, - /// `room` and `sender` rules. - pub underride: IndexSet, + /// Whether this room should be muted. + /// When set globally, it mutes *all* rooms. + /// On spaces, it mutes all rooms inside of the space, except for + /// rooms in a non-muted spaces. + #[serde(skip_serializing_if = "Option::is_none")] + pub mute: Option, } -impl Ruleset { - /// Creates an empty `Ruleset`. - pub fn new() -> Self { - Default::default() - } +/// A room's push rule, determines what events will cause push +/// notifications. +#[derive(Clone, Default, PartialEq, Eq, StringEnum)] +#[ruma_enum(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Rule { + /// Notify on all messages + Everything, - /// Creates a borrowing iterator over all push rules in this `Ruleset`. - /// - /// For an owning iterator, use `.into_iter()`. - pub fn iter(&self) -> RulesetIter<'_> { - self.into_iter() - } + /// Notify on mentions + #[default] + Mentions, - /// Inserts a user-defined rule in the rule set. - /// - /// If a rule with the same kind and `rule_id` exists, it will be replaced. - /// - /// If `after` or `before` is set, the rule will be moved relative to the rule with the given - /// ID. If both are set, the rule will become the next-most important rule with respect to - /// `before`. If neither are set, and the rule is newly inserted, it will become the rule with - /// the highest priority of its kind. - /// - /// Returns an error if the parameters are invalid. - 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); - } + /// Hide unread dot and don't notify, but show mention badge + Subdued, - match rule { - NewPushRule::Override(r) => { - let mut rule = ConditionalPushRule::from(r); + /// Don't show any unreads or mentions + Nothing, - if let Some(prev_rule) = self.override_.get(rule.rule_id.as_str()) { - rule.enabled = prev_rule.enabled; - } - - // `m.rule.master` should always be the rule with the highest priority, so we insert - // this one at most at the second place. - 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) - } - } - } - - /// Get the rule from the given kind and with the given `rule_id` in the rule set. - pub fn get(&self, kind: RuleKind, rule_id: impl AsRef) -> Option> { - 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, - } - } - - /// Set whether the rule from the given kind and with the given `rule_id` in the rule set is - /// enabled. - /// - /// Returns an error if the rule can't be found. - pub fn set_enabled( - &mut self, - kind: RuleKind, - rule_id: impl AsRef, - 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(()) - } - - /// Set the actions of the rule from the given kind and with the given `rule_id` in the rule - /// set. - /// - /// Returns an error if the rule can't be found. - pub fn set_actions( - &mut self, - kind: RuleKind, - rule_id: impl AsRef, - actions: Vec, - ) -> 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(()) - } - - /// Get the first push rule that applies to this event, if any. - /// - /// # Arguments - /// - /// * `event` - The raw JSON of a room message event. - /// * `context` - The context of the message and room at the time of the event. - #[instrument(skip_all, fields(context.room_id = %context.room_id))] - pub fn get_match( - &self, - event: &Raw, - context: &PushConditionRoomCtx, - ) -> Option> { - let event = FlattenedJson::from_raw(event); - - if event.get_str("sender").is_some_and(|sender| sender == context.user_id) { - // no need to look at the rules if the event was by the user themselves - None - } else { - self.iter().find(|rule| rule.applies(&event, context)) - } - } - - /// Get the push actions that apply to this event. - /// - /// Returns an empty slice if no push rule applies. - /// - /// # Arguments - /// - /// * `event` - The raw JSON of a room message event. - /// * `context` - The context of the message and room at the time of the event. - #[instrument(skip_all, fields(context.room_id = %context.room_id))] - pub fn get_actions(&self, event: &Raw, context: &PushConditionRoomCtx) -> &[Action] { - self.get_match(event, context).map(|rule| rule.actions()).unwrap_or(&[]) - } - - /// Removes a user-defined rule in the rule set. - /// - /// Returns an error if the parameters are invalid. - pub fn remove( - &mut self, - kind: RuleKind, - rule_id: impl AsRef, - ) -> 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); - } - // This has been handled in the `self.get` call earlier. - RuleKind::_Custom(_) => unreachable!(), - } - - Ok(()) - } + #[doc(hidden)] + _Custom(PrivOwnedStr), } -/// A push rule is a single rule that states under what conditions an event should be passed onto a -/// push gateway and how the notification should be presented. -/// -/// These rules are stored on the user's homeserver. They are manually configured by the user, who -/// can create and view them via the Client/Server API. -/// -/// To create an instance of this type, first create a `SimplePushRuleInit` and convert it via -/// `SimplePushRule::from` / `.into()`. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct SimplePushRule { - /// Actions to determine if and how a notification is delivered for events matching this rule. - pub actions: Vec, - - /// Whether this is a default rule, or has been set explicitly. - pub default: bool, - - /// Whether the push rule is enabled or not. - pub enabled: bool, - - /// The ID of this rule. - /// - /// This is generally the Matrix ID of the entity that it applies to. - pub rule_id: T, -} - -/// Initial set of fields of `SimplePushRule`. -/// -/// This struct will not be updated even if additional fields are added to `SimplePushRule` in a new -/// (non-breaking) release of the Matrix specification. -#[derive(Debug)] -#[allow(clippy::exhaustive_structs)] -pub struct SimplePushRuleInit { - /// Actions to determine if and how a notification is delivered for events matching this rule. - pub actions: Vec, - - /// Whether this is a default rule, or has been set explicitly. - pub default: bool, - - /// Whether the push rule is enabled or not. - pub enabled: bool, - - /// The ID of this rule. - /// - /// This is generally the Matrix ID of the entity that it applies to. - pub rule_id: T, -} - -impl From> for SimplePushRule { - fn from(init: SimplePushRuleInit) -> Self { - let SimplePushRuleInit { actions, default, enabled, rule_id } = init; - Self { actions, default, enabled, rule_id } - } -} - -// The following trait are needed to be able to make -// an IndexSet of the type - -impl Hash for SimplePushRule -where - T: Hash, -{ - fn hash(&self, state: &mut H) { - self.rule_id.hash(state); - } -} - -impl PartialEq for SimplePushRule -where - T: PartialEq, -{ - fn eq(&self, other: &Self) -> bool { - self.rule_id == other.rule_id - } -} - -impl Eq for SimplePushRule where T: Eq {} - -impl Equivalent> for str -where - T: AsRef, -{ - fn equivalent(&self, key: &SimplePushRule) -> bool { - self == key.rule_id.as_ref() - } -} - -/// Like `SimplePushRule`, but with an additional `conditions` field. -/// -/// Only applicable to underride and override rules. -/// -/// To create an instance of this type, first create a `ConditionalPushRuleInit` and convert it via -/// `ConditionalPushRule::from` / `.into()`. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct ConditionalPushRule { - /// Actions to determine if and how a notification is delivered for events matching this rule. - pub actions: Vec, - - /// Whether this is a default rule, or has been set explicitly. - pub default: bool, - - /// Whether the push rule is enabled or not. - pub enabled: bool, - - /// The ID of this rule. - pub rule_id: String, - - /// The conditions that must hold true for an event in order for a rule to be applied to an - /// event. - /// - /// A rule with no conditions always matches. - #[serde(default)] - pub conditions: Vec, -} - -impl ConditionalPushRule { - /// Check if the push rule 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 { - if !self.enabled { - return false; - } - - #[cfg(feature = "unstable-msc3932")] - { - // These 3 rules always apply. - #[allow(deprecated)] - if self.rule_id != PredefinedOverrideRuleId::Master.as_ref() - && self.rule_id != PredefinedOverrideRuleId::RoomNotif.as_ref() - && self.rule_id != PredefinedOverrideRuleId::ContainsDisplayName.as_ref() - { - // Push rules which don't specify a `room_version_supports` condition are assumed - // to not support extensible events and are therefore expected to be treated as - // disabled when a room version does support extensible events. - 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; - } - } - } - - // The old mention rules are disabled when an m.mentions field is present. - #[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)) - } -} - -/// Initial set of fields of `ConditionalPushRule`. -/// -/// This struct will not be updated even if additional fields are added to `ConditionalPushRule` in -/// a new (non-breaking) release of the Matrix specification. -#[derive(Debug)] -#[allow(clippy::exhaustive_structs)] -pub struct ConditionalPushRuleInit { - /// Actions to determine if and how a notification is delivered for events matching this rule. - pub actions: Vec, - - /// Whether this is a default rule, or has been set explicitly. - pub default: bool, - - /// Whether the push rule is enabled or not. - pub enabled: bool, - - /// The ID of this rule. - pub rule_id: String, - - /// The conditions that must hold true for an event in order for a rule to be applied to an - /// event. - /// - /// A rule with no conditions always matches. - pub conditions: Vec, -} - -impl From for ConditionalPushRule { - fn from(init: ConditionalPushRuleInit) -> Self { - let ConditionalPushRuleInit { actions, default, enabled, rule_id, conditions } = init; - Self { actions, default, enabled, rule_id, conditions } - } -} - -// The following trait are needed to be able to make -// an IndexSet of the type - -impl Hash for ConditionalPushRule { - fn hash(&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 for str { - fn equivalent(&self, key: &ConditionalPushRule) -> bool { - self == key.rule_id - } -} - -/// Like `SimplePushRule`, but with an additional `pattern` field. -/// -/// Only applicable to content rules. -/// -/// To create an instance of this type, first create a `PatternedPushRuleInit` and convert it via -/// `PatternedPushRule::from` / `.into()`. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct PatternedPushRule { - /// Actions to determine if and how a notification is delivered for events matching this rule. - pub actions: Vec, - - /// Whether this is a default rule, or has been set explicitly. - pub default: bool, - - /// Whether the push rule is enabled or not. - pub enabled: bool, - - /// The ID of this rule. - pub rule_id: String, - - /// The glob-style pattern to match against. - pub pattern: String, -} - -impl PatternedPushRule { - /// Check if the push rule 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_to( - &self, - key: &str, - event: &FlattenedJson, - context: &PushConditionRoomCtx, - ) -> bool { - // The old mention rules are disabled when an m.mentions field is present. - #[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) - } -} - -/// Initial set of fields of `PatterenedPushRule`. -/// -/// This struct will not be updated even if additional fields are added to `PatterenedPushRule` in a -/// new (non-breaking) release of the Matrix specification. -#[derive(Debug)] -#[allow(clippy::exhaustive_structs)] -pub struct PatternedPushRuleInit { - /// Actions to determine if and how a notification is delivered for events matching this rule. - pub actions: Vec, - - /// Whether this is a default rule, or has been set explicitly. - pub default: bool, - - /// Whether the push rule is enabled or not. - pub enabled: bool, - - /// The ID of this rule. - pub rule_id: String, - - /// The glob-style pattern to match against. - pub pattern: String, -} - -impl From for PatternedPushRule { - fn from(init: PatternedPushRuleInit) -> Self { - let PatternedPushRuleInit { actions, default, enabled, rule_id, pattern } = init; - Self { actions, default, enabled, rule_id, pattern } - } -} - -// The following trait are needed to be able to make -// an IndexSet of the type - -impl Hash for PatternedPushRule { - fn hash(&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 for str { - fn equivalent(&self, key: &PatternedPushRule) -> bool { - self == key.rule_id - } +/// A room's mute config. A room can be muted, in which case its `rule` +/// will be set to Nothing. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Mute { + /// Mute a room for an indeterminate amount of time + Forever, + + /// Mute a room temporarily, expiring at a specified unix timestamp + /// in milliseconds + Expires(UInt), } /// Information for a pusher using the Push Gateway API. @@ -733,1105 +112,3 @@ pub enum PushFormat { #[doc(hidden)] _Custom(PrivOwnedStr), } - -/// The kinds of push rules that are available. -#[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 { - /// User-configured rules that override all other kinds. - Override, - - /// Lowest priority user-defined rules. - Underride, - - /// Sender-specific rules. - Sender, - - /// Room-specific rules. - Room, - - /// Content-specific rules. - Content, - - #[doc(hidden)] - _Custom(PrivOwnedStr), -} - -/// A push rule to update or create. -#[derive(Clone, Debug)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub enum NewPushRule { - /// Rules that override all other kinds. - Override(NewConditionalPushRule), - - /// Content-specific rules. - Content(NewPatternedPushRule), - - /// Room-specific rules. - Room(NewSimplePushRule), - - /// Sender-specific rules. - Sender(NewSimplePushRule), - - /// Lowest priority rules. - Underride(NewConditionalPushRule), -} - -impl NewPushRule { - /// The kind of this `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, - } - } - - /// The ID of this `NewPushRule`. - 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, - } - } -} - -/// A simple push rule to update or create. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct NewSimplePushRule { - /// The ID of this rule. - /// - /// This is generally the Matrix ID of the entity that it applies to. - pub rule_id: T, - - /// Actions to determine if and how a notification is delivered for events matching this - /// rule. - pub actions: Vec, -} - -impl NewSimplePushRule { - /// Creates a `NewSimplePushRule` with the given ID and actions. - pub fn new(rule_id: T, actions: Vec) -> Self { - Self { rule_id, actions } - } -} - -impl From> for SimplePushRule { - fn from(new_rule: NewSimplePushRule) -> Self { - let NewSimplePushRule { rule_id, actions } = new_rule; - Self { actions, default: false, enabled: true, rule_id } - } -} - -/// A patterned push rule to update or create. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct NewPatternedPushRule { - /// The ID of this rule. - pub rule_id: String, - - /// The glob-style pattern to match against. - pub pattern: String, - - /// Actions to determine if and how a notification is delivered for events matching this - /// rule. - pub actions: Vec, -} - -impl NewPatternedPushRule { - /// Creates a `NewPatternedPushRule` with the given ID, pattern and actions. - pub fn new(rule_id: String, pattern: String, actions: Vec) -> Self { - Self { rule_id, pattern, actions } - } -} - -impl From 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 } - } -} - -/// A conditional push rule to update or create. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct NewConditionalPushRule { - /// The ID of this rule. - pub rule_id: String, - - /// The conditions that must hold true for an event in order for a rule to be applied to an - /// event. - /// - /// A rule with no conditions always matches. - #[serde(default)] - pub conditions: Vec, - - /// Actions to determine if and how a notification is delivered for events matching this - /// rule. - pub actions: Vec, -} - -impl NewConditionalPushRule { - /// Creates a `NewConditionalPushRule` with the given ID, conditions and actions. - pub fn new(rule_id: String, conditions: Vec, actions: Vec) -> Self { - Self { rule_id, conditions, actions } - } -} - -impl From 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 } - } -} - -/// The error type returned when trying to insert a user-defined push rule into a `Ruleset`. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum InsertPushRuleError { - /// The rule ID starts with a dot (`.`), which is reserved for server-default rules. - #[error("rule IDs starting with a dot are reserved for server-default rules")] - ServerDefaultRuleId, - - /// The rule ID contains an invalid character. - #[error("invalid rule ID")] - InvalidRuleId, - - /// The rule is being placed relative to a server-default rule, which is forbidden. - #[error("can't place rule relative to server-default rule")] - RelativeToServerDefaultRule, - - /// The `before` or `after` rule could not be found. - #[error("The before or after rule could not be found")] - UnknownRuleId, - - /// `before` has a higher priority than `after`. - #[error("before has a higher priority than after")] - BeforeHigherThanAfter, -} - -/// The error type returned when trying modify a push rule that could not be found in a `Ruleset`. -#[derive(Debug, Error)] -#[non_exhaustive] -#[error("The rule could not be found")] -pub struct RuleNotFoundError; - -/// Insert the rule in the given indexset and move it to the given position. -pub fn insert_and_move_rule( - set: &mut IndexSet, - rule: T, - default_position: usize, - after: Option<&str>, - before: Option<&str>, -) -> Result<(), InsertPushRuleError> -where - T: Hash + Eq, - str: Equivalent, -{ - 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; - } - - // Only move the item if it's new or if it was positioned. - if replaced.is_none() || after.is_some() || before.is_some() { - set.move_index(from, to); - } - - Ok(()) -} - -/// The error type returned when trying to remove a user-defined push rule from a `Ruleset`. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum RemovePushRuleError { - /// The rule is a server-default rules and they can't be removed. - #[error("server-default rules cannot be removed")] - ServerDefault, - - /// The rule was not found. - #[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, 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 - } - - #[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 - }, - ], - "room": [], - "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 - } - ], - "sender": [], - "underride": [], - }) - ); - } - - #[test] - fn deserialize_patterned_push_rule() { - let rule = from_json_value::(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(), - users_power_levels: BTreeMap::new(), - default_power_level: int!(50), - notification_power_levels: NotificationPowerLevels { room: int!(50) }, - #[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(), - users_power_levels: BTreeMap::new(), - default_power_level: int!(50), - notification_power_levels: NotificationPowerLevels { room: int!(50) }, - #[cfg(feature = "unstable-msc3931")] - supported_features: Default::default(), - }; - - let message = serde_json::from_str::>( - 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::>( - 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::>( - 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::>( - 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::>(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(), - users_power_levels: BTreeMap::new(), - default_power_level: int!(50), - notification_power_levels: NotificationPowerLevels { room: int!(50) }, - #[cfg(feature = "unstable-msc3931")] - supported_features: Default::default(), - }; - - let message = serde_json::from_str::>( - 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::>( - 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(), - users_power_levels: BTreeMap::new(), - default_power_level: int!(50), - notification_power_levels: NotificationPowerLevels { room: int!(50) }, - #[cfg(feature = "unstable-msc3931")] - supported_features: Default::default(), - }; - - let message = serde_json::from_str::>( - 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::>( - 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::>( - 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::>( - 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::>( - 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::>( - 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(), - users_power_levels: BTreeMap::new(), - default_power_level: int!(50), - notification_power_levels: NotificationPowerLevels { room: int!(50) }, - #[cfg(feature = "unstable-msc3931")] - supported_features: Default::default(), - }; - - let message = serde_json::from_str::>( - 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::>( - 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() - ); - } -} diff --git a/crates/ruma-common/src/push/action.rs b/crates/ruma-common/src/push/action.rs deleted file mode 100644 index ea57cc8d..00000000 --- a/crates/ruma-common/src/push/action.rs +++ /dev/null @@ -1,252 +0,0 @@ -use std::collections::BTreeMap; - -use as_variant::as_variant; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::value::{RawValue as RawJsonValue, Value as JsonValue}; - -use crate::serde::from_raw_json_value; - -/// This represents the different actions that should be taken when a rule is matched, and -/// controls how notifications are delivered to the client. -/// -/// See [the spec](https://spec.matrix.org/latest/client-server-api/#actions) for details. -#[derive(Clone, Debug)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub enum Action { - /// Causes matching events to generate a notification. - Notify, - - /// Sets an entry in the 'tweaks' dictionary sent to the push gateway. - SetTweak(Tweak), - - /// An unknown action. - #[doc(hidden)] - _Custom(CustomAction), -} - -impl Action { - /// Whether this action is an `Action::SetTweak(Tweak::Highlight(true))`. - pub fn is_highlight(&self) -> bool { - matches!(self, Action::SetTweak(Tweak::Highlight(true))) - } - - /// Whether this action should trigger a notification. - pub fn should_notify(&self) -> bool { - matches!(self, Action::Notify) - } - - /// The sound that should be played with this action, if any. - pub fn sound(&self) -> Option<&str> { - as_variant!(self, Action::SetTweak(Tweak::Sound(sound)) => sound) - } -} - -/// The `set_tweak` action. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -#[serde(from = "tweak_serde::Tweak", into = "tweak_serde::Tweak")] -pub enum Tweak { - /// A string representing the sound to be played when this notification arrives. - /// - /// A value of "default" means to play a default sound. A device may choose to alert the user - /// by some other means if appropriate, eg. vibration. - Sound(String), - - /// A boolean representing whether or not this message should be highlighted in the UI. - /// - /// This will normally take the form of presenting the message in a different color and/or - /// style. The UI might also be adjusted to draw particular attention to the room in which the - /// event occurred. If a `highlight` tweak is given with no value, its value is defined to be - /// `true`. If no highlight tweak is given at all then the value of `highlight` is defined to - /// be `false`. - Highlight(#[serde(default = "crate::serde::default_true")] bool), - - /// A custom tweak - Custom { - /// The name of the custom tweak (`set_tweak` field) - name: String, - - /// The value of the custom tweak - value: Box, - }, -} - -impl<'de> Deserialize<'de> for Action { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let json = Box::::deserialize(deserializer)?; - let custom: CustomAction = from_raw_json_value(&json)?; - - match &custom { - CustomAction::String(s) => match s.as_str() { - "notify" => Ok(Action::Notify), - _ => Ok(Action::_Custom(custom)), - }, - CustomAction::Object(o) => { - if o.get("set_tweak").is_some() { - Ok(Action::SetTweak(from_raw_json_value(&json)?)) - } else { - Ok(Action::_Custom(custom)) - } - } - } - } -} - -impl Serialize for Action { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self { - Action::Notify => serializer.serialize_unit_variant("Action", 0, "notify"), - Action::SetTweak(kind) => kind.serialize(serializer), - Action::_Custom(custom) => custom.serialize(serializer), - } - } -} - -/// An unknown action. -#[doc(hidden)] -#[allow(unknown_lints, unnameable_types)] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum CustomAction { - /// A string. - String(String), - - /// An object. - Object(BTreeMap), -} - -mod tweak_serde { - use serde::{Deserialize, Serialize}; - use serde_json::value::RawValue as RawJsonValue; - - /// Values for the `set_tweak` action. - #[derive(Clone, Deserialize, Serialize)] - #[serde(untagged)] - pub(crate) enum Tweak { - Sound(SoundTweak), - Highlight(HighlightTweak), - Custom { - #[serde(rename = "set_tweak")] - name: String, - value: Box, - }, - } - - #[derive(Clone, PartialEq, Deserialize, Serialize)] - #[serde(tag = "set_tweak", rename = "sound")] - pub(crate) struct SoundTweak { - value: String, - } - - #[derive(Clone, PartialEq, Deserialize, Serialize)] - #[serde(tag = "set_tweak", rename = "highlight")] - pub(crate) struct HighlightTweak { - #[serde( - default = "crate::serde::default_true", - skip_serializing_if = "crate::serde::is_true" - )] - value: bool, - } - - impl From for Tweak { - fn from(tweak: super::Tweak) -> Self { - use super::Tweak::*; - - match tweak { - Sound(value) => Self::Sound(SoundTweak { value }), - Highlight(value) => Self::Highlight(HighlightTweak { value }), - Custom { name, value } => Self::Custom { name, value }, - } - } - } - - impl From for super::Tweak { - fn from(tweak: Tweak) -> Self { - use Tweak::*; - - match tweak { - Sound(SoundTweak { value }) => Self::Sound(value), - Highlight(HighlightTweak { value }) => Self::Highlight(value), - Custom { name, value } => Self::Custom { name, value }, - } - } - } -} - -#[cfg(test)] -mod tests { - use assert_matches2::assert_matches; - use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; - - use super::{Action, Tweak}; - - #[test] - fn serialize_string() { - assert_eq!(to_json_value(&Action::Notify).unwrap(), json!("notify")); - } - - #[test] - fn serialize_tweak_sound() { - assert_eq!( - to_json_value(&Action::SetTweak(Tweak::Sound("default".into()))).unwrap(), - json!({ "set_tweak": "sound", "value": "default" }) - ); - } - - #[test] - fn serialize_tweak_highlight() { - assert_eq!( - to_json_value(&Action::SetTweak(Tweak::Highlight(true))).unwrap(), - json!({ "set_tweak": "highlight" }) - ); - - assert_eq!( - to_json_value(&Action::SetTweak(Tweak::Highlight(false))).unwrap(), - json!({ "set_tweak": "highlight", "value": false }) - ); - } - - #[test] - fn deserialize_string() { - assert_matches!(from_json_value::(json!("notify")), Ok(Action::Notify)); - } - - #[test] - fn deserialize_tweak_sound() { - let json_data = json!({ - "set_tweak": "sound", - "value": "default" - }); - assert_matches!( - from_json_value::(json_data), - Ok(Action::SetTweak(Tweak::Sound(value))) - ); - assert_eq!(value, "default"); - } - - #[test] - fn deserialize_tweak_highlight() { - let json_data = json!({ - "set_tweak": "highlight", - "value": true - }); - assert_matches!( - from_json_value::(json_data), - Ok(Action::SetTweak(Tweak::Highlight(true))) - ); - } - - #[test] - fn deserialize_tweak_highlight_with_default_value() { - assert_matches!( - from_json_value::(json!({ "set_tweak": "highlight" })), - Ok(Action::SetTweak(Tweak::Highlight(true))) - ); - } -} diff --git a/crates/ruma-common/src/push/condition.rs b/crates/ruma-common/src/push/condition.rs deleted file mode 100644 index bda7930d..00000000 --- a/crates/ruma-common/src/push/condition.rs +++ /dev/null @@ -1,932 +0,0 @@ -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}, -}; - -/// Features supported by room versions. -#[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 { - /// m.extensible_events - /// - /// The room supports [extensible events]. - /// - /// [extensible events]: https://github.com/matrix-org/matrix-spec-proposals/pull/1767 - #[cfg(feature = "unstable-msc3932")] - #[ruma_enum(rename = "org.matrix.msc3932.extensible_events")] - ExtensibleEvents, - - #[doc(hidden)] - _Custom(PrivOwnedStr), -} - -#[cfg(feature = "unstable-msc3931")] -impl RoomVersionFeature { - /// Get the default features for the given room version. - pub fn list_for_room_version(version: &RoomVersionId) -> Vec { - 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![], - } - } -} - -/// A condition that must apply for an associated push rule's action to be taken. -#[derive(Clone, Debug)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub enum PushCondition { - /// A glob pattern match on a field of the event. - EventMatch { - /// The [dot-separated path] of the property of the event to match. - /// - /// [dot-separated path]: https://spec.matrix.org/latest/appendices/#dot-separated-property-paths - 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, - }, - - /// Matches unencrypted messages where `content.body` contains the owner's display name in that - /// room. - ContainsDisplayName, - - /// Matches the current number of members in the room. - RoomMemberCount { - /// The condition on the current number of members in the room. - is: RoomMemberCountIs, - }, - - /// 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, - }, - - /// Apply the rule only to rooms that support a given feature. - #[cfg(feature = "unstable-msc3931")] - RoomVersionSupports { - /// The feature the room must support for the push rule to apply. - feature: RoomVersionFeature, - }, - - /// Exact value match on a property of the event. - EventPropertyIs { - /// The [dot-separated path] of the property of the event to match. - /// - /// [dot-separated path]: https://spec.matrix.org/latest/appendices/#dot-separated-property-paths - key: String, - - /// The value to match against. - value: ScalarJsonValue, - }, - - /// Exact value match on a value in an array property of the event. - EventPropertyContains { - /// The [dot-separated path] of the property of the event to match. - /// - /// [dot-separated path]: https://spec.matrix.org/latest/appendices/#dot-separated-property-paths - key: String, - - /// The value to match against. - 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 { - /// 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 { - 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 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 = 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, - } - } - #[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, - } - } -} - -/// An unknown push condition. -#[doc(hidden)] -#[derive(Clone, Debug, Deserialize, Serialize)] -#[allow(clippy::exhaustive_structs)] -pub struct _CustomPushCondition { - /// The kind of the condition. - kind: String, - - /// The additional fields that the condition contains. - #[serde(flatten)] - data: BTreeMap, -} - -/// The context of the room associated to an event to be able to test all push conditions. -#[derive(Clone, Debug)] -#[allow(clippy::exhaustive_structs)] -pub struct PushConditionRoomCtx { - /// The ID of the room. - pub room_id: OwnedRoomId, - - /// The number of members in the room. - pub member_count: UInt, - - /// The users matrix ID. - pub user_id: OwnedUserId, - - /// 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, - - #[cfg(feature = "unstable-msc3931")] - /// The list of features this room's version or the room itself supports. - pub supported_features: Vec, -} - -/// 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_ascii_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 pattern can be a glob with wildcards `*` and `?`. - /// - /// The match is case insensitive. - /// - /// If `match_words` is `true`, checks that the pattern is separated from other words. - fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool; - - /// Matches this string against `pattern`, with word boundaries. - /// - /// The pattern can be a glob with wildcards `*` and `?`. - /// - /// A word boundary is defined as the start or end of the value, or any character not in the - /// sets `[A-Z]`, `[a-z]`, `[0-9]` or `_`. - /// - /// The match is case sensitive. - fn matches_word(&self, pattern: &str) -> bool; - - /// Translate the wildcards in `self` to a regex syntax. - /// - /// `self` must only contain wildcards. - 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 { - 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(|c| matches!(c, '?' | '*')); - - if has_wildcards { - let mut chunks: Vec = 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()); - } - - // The word characters in ASCII compatible mode (with the `-u` flag) match the - // definition in the spec: any character not in the set `[A-Za-z0-9_]`. - 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(); - - // Look if the match has word boundaries. - 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; - } - } - - // 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, - } - } - } - - fn wildcards_to_regex(&self) -> String { - // Simplify pattern to avoid performance issues: - // - The glob `?**?**?` is equivalent to the glob `???*` - // - The glob `???*` is equivalent to the regex `.{3,}` - 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, 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::(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::(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 } - ); - 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::(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👩‍💻")); - - // Regex syntax is escaped - 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:]]?")); - - // From the spec: - 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() { - // Word matching without glob - 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)); - - // Word matching with glob - 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)); - - // 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!("fooo".matches_pattern("foo?", false)); - assert!("FOO".matches_pattern("foo", false)); - assert!("".matches_pattern("", false)); - assert!("".matches_pattern("*", false)); - assert!(!"foo".matches_pattern("", false)); - - // From the spec: - 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_power_levels = BTreeMap::new(); - users_power_levels.insert(sender(), int!(25)); - - 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(), - users_power_levels, - default_power_level: int!(50), - notification_power_levels: NotificationPowerLevels { room: int!(50) }, - #[cfg(feature = "unstable-msc3931")] - supported_features: Default::default(), - } - } - - fn first_flattened_event() -> FlattenedJson { - let 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(); - - FlattenedJson::from_raw(&raw) - } - - fn second_flattened_event() -> FlattenedJson { - let raw = serde_json::from_str::>( - 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(), - users_power_levels: context_not_matching.users_power_levels.clone(), - default_power_level: int!(50), - notification_power_levels: NotificationPowerLevels { room: int!(50) }, - supported_features: vec![super::RoomVersionFeature::ExtensibleEvents], - }; - - let simple_event_raw = serde_json::from_str::>( - 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::>( - 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::>( - 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)); - } -} diff --git a/crates/ruma-common/src/push/condition/flattened_json.rs b/crates/ruma-common/src/push/condition/flattened_json.rs deleted file mode 100644 index f3c7369c..00000000 --- a/crates/ruma-common/src/push/condition/flattened_json.rs +++ /dev/null @@ -1,417 +0,0 @@ -use std::collections::BTreeMap; - -use as_variant::as_variant; -use js_int::Int; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::{to_value as to_json_value, value::Value as JsonValue}; -use thiserror::Error; -use tracing::{instrument, warn}; - -use crate::serde::Raw; - -/// 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 { - let mut s = Self { map: BTreeMap::new() }; - s.flatten_value(to_json_value(raw).unwrap(), "".into()); - s - } - - /// Flatten and insert the `value` at `path`. - #[instrument(skip(self, value))] - fn flatten_value(&mut self, value: JsonValue, path: String) { - match value { - JsonValue::Object(fields) => { - if fields.is_empty() { - if self.map.insert(path.clone(), FlattenedJsonValue::EmptyObject).is_some() { - warn!("Duplicate path in flattened JSON: {path}"); - } - } else { - for (key, value) in fields { - let key = escape_key(&key); - let path = if path.is_empty() { key } else { format!("{path}.{key}") }; - self.flatten_value(value, path); - } - } - } - value => { - if let Some(v) = FlattenedJsonValue::from_json_value(value) { - if self.map.insert(path.clone(), v).is_some() { - warn!("Duplicate path in flattened JSON: {path}"); - } - } - } - } - } - - /// Get the value associated with the given `path`. - pub fn get(&self, path: &str) -> Option<&FlattenedJsonValue> { - self.map.get(path) - } - - /// Get the value associated with the given `path`, if it is a string. - pub fn get_str(&self, path: &str) -> Option<&str> { - self.map.get(path).and_then(|v| v.as_str()) - } - - /// Whether this flattened JSON contains an `m.mentions` property under the `content` property. - pub fn contains_mentions(&self) -> bool { - self.map - .keys() - .any(|s| s == r"content.m\.mentions" || s.starts_with(r"content.m\.mentions.")) - } -} - -/// Escape a key for path matching. -/// -/// This escapes the dots (`.`) and backslashes (`\`) in the key with a backslash. -fn escape_key(key: &str) -> String { - key.replace('\\', r"\\").replace('.', r"\.") -} - -/// The set of possible errors when converting to a JSON subset. -#[derive(Debug, Error)] -#[allow(clippy::exhaustive_enums)] -enum IntoJsonSubsetError { - /// The numeric value failed conversion to js_int::Int. - #[error("number found is not a valid `js_int::Int`")] - IntConvert, - - /// The JSON type is not accepted in this subset. - #[error("JSON type is not accepted in this subset")] - NotInSubset, -} - -/// Scalar (non-compound) JSON values. -#[derive(Debug, Clone, Default, Eq, PartialEq)] -#[allow(clippy::exhaustive_enums)] -pub enum ScalarJsonValue { - /// Represents a `null` value. - #[default] - Null, - - /// Represents a boolean. - Bool(bool), - - /// Represents an integer. - Integer(Int), - - /// Represents a string. - String(String), -} - -impl ScalarJsonValue { - fn try_from_json_value(val: JsonValue) -> Result { - Ok(match val { - JsonValue::Bool(b) => Self::Bool(b), - JsonValue::Number(num) => Self::Integer( - Int::try_from(num.as_i64().ok_or(IntoJsonSubsetError::IntConvert)?) - .map_err(|_| IntoJsonSubsetError::IntConvert)?, - ), - JsonValue::String(string) => Self::String(string), - JsonValue::Null => Self::Null, - _ => Err(IntoJsonSubsetError::NotInSubset)?, - }) - } - - /// If the `ScalarJsonValue` is a `Bool`, return the inner value. - pub fn as_bool(&self) -> Option { - as_variant!(self, Self::Bool).copied() - } - - /// If the `ScalarJsonValue` is an `Integer`, return the inner value. - pub fn as_integer(&self) -> Option { - as_variant!(self, Self::Integer).copied() - } - - /// If the `ScalarJsonValue` is a `String`, return a reference to the inner value. - pub fn as_str(&self) -> Option<&str> { - as_variant!(self, Self::String) - } -} - -impl Serialize for ScalarJsonValue { - #[inline] - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self { - Self::Null => serializer.serialize_unit(), - Self::Bool(b) => serializer.serialize_bool(*b), - Self::Integer(n) => n.serialize(serializer), - Self::String(s) => serializer.serialize_str(s), - } - } -} - -impl<'de> Deserialize<'de> for ScalarJsonValue { - #[inline] - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let val = JsonValue::deserialize(deserializer)?; - ScalarJsonValue::try_from_json_value(val).map_err(serde::de::Error::custom) - } -} - -impl From for ScalarJsonValue { - fn from(value: bool) -> Self { - Self::Bool(value) - } -} - -impl From for ScalarJsonValue { - fn from(value: Int) -> Self { - Self::Integer(value) - } -} - -impl From for ScalarJsonValue { - fn from(value: String) -> Self { - Self::String(value) - } -} - -impl From<&str> for ScalarJsonValue { - fn from(value: &str) -> Self { - value.to_owned().into() - } -} - -impl PartialEq for ScalarJsonValue { - fn eq(&self, other: &FlattenedJsonValue) -> bool { - match self { - Self::Null => *other == FlattenedJsonValue::Null, - Self::Bool(b) => other.as_bool() == Some(*b), - Self::Integer(i) => other.as_integer() == Some(*i), - Self::String(s) => other.as_str() == Some(s), - } - } -} - -/// Possible JSON values after an object is flattened. -#[derive(Debug, Clone, Default, Eq, PartialEq)] -#[allow(clippy::exhaustive_enums)] -pub enum FlattenedJsonValue { - /// Represents a `null` value. - #[default] - Null, - - /// Represents a boolean. - Bool(bool), - - /// Represents an integer. - Integer(Int), - - /// Represents a string. - String(String), - - /// Represents an array. - Array(Vec), - - /// Represents an empty object. - EmptyObject, -} - -impl FlattenedJsonValue { - fn from_json_value(val: JsonValue) -> Option { - Some(match val { - JsonValue::Bool(b) => Self::Bool(b), - JsonValue::Number(num) => Self::Integer(Int::try_from(num.as_i64()?).ok()?), - JsonValue::String(string) => Self::String(string), - JsonValue::Null => Self::Null, - JsonValue::Array(vec) => Self::Array( - // Drop values we don't need instead of throwing an error. - vec.into_iter() - .filter_map(|v| ScalarJsonValue::try_from_json_value(v).ok()) - .collect::>(), - ), - _ => None?, - }) - } - - /// If the `FlattenedJsonValue` is a `Bool`, return the inner value. - pub fn as_bool(&self) -> Option { - as_variant!(self, Self::Bool).copied() - } - - /// If the `FlattenedJsonValue` is an `Integer`, return the inner value. - pub fn as_integer(&self) -> Option { - as_variant!(self, Self::Integer).copied() - } - - /// If the `FlattenedJsonValue` is a `String`, return a reference to the inner value. - pub fn as_str(&self) -> Option<&str> { - as_variant!(self, Self::String) - } - - /// If the `FlattenedJsonValue` is an `Array`, return a reference to the inner value. - pub fn as_array(&self) -> Option<&[ScalarJsonValue]> { - as_variant!(self, Self::Array) - } -} - -impl From for FlattenedJsonValue { - fn from(value: bool) -> Self { - Self::Bool(value) - } -} - -impl From for FlattenedJsonValue { - fn from(value: Int) -> Self { - Self::Integer(value) - } -} - -impl From for FlattenedJsonValue { - fn from(value: String) -> Self { - Self::String(value) - } -} - -impl From<&str> for FlattenedJsonValue { - fn from(value: &str) -> Self { - value.to_owned().into() - } -} - -impl From> for FlattenedJsonValue { - fn from(value: Vec) -> Self { - Self::Array(value) - } -} - -impl PartialEq for FlattenedJsonValue { - fn eq(&self, other: &ScalarJsonValue) -> bool { - match self { - Self::Null => *other == ScalarJsonValue::Null, - Self::Bool(b) => other.as_bool() == Some(*b), - Self::Integer(i) => other.as_integer() == Some(*i), - Self::String(s) => other.as_str() == Some(s), - Self::Array(_) | Self::EmptyObject => false, - } - } -} - -#[cfg(test)] -mod tests { - use js_int::int; - use maplit::btreemap; - use serde_json::Value as JsonValue; - - use super::{FlattenedJson, FlattenedJsonValue}; - use crate::serde::Raw; - - #[test] - fn flattened_json_values() { - let raw = serde_json::from_str::>( - r#"{ - "string": "Hello World", - "number": 10, - "array": [1, 2], - "boolean": true, - "null": null, - "empty_object": {} - }"#, - ) - .unwrap(); - - let flattened = FlattenedJson::from_raw(&raw); - assert_eq!( - flattened.map, - btreemap! { - "string".into() => "Hello World".into(), - "number".into() => int!(10).into(), - "array".into() => vec![int!(1).into(), int!(2).into()].into(), - "boolean".into() => true.into(), - "null".into() => FlattenedJsonValue::Null, - "empty_object".into() => FlattenedJsonValue::EmptyObject, - } - ); - } - - #[test] - fn flattened_json_nested() { - let raw = serde_json::from_str::>( - r#"{ - "desc": "Level 0", - "desc.bis": "Level 0 bis", - "up": { - "desc": 1, - "desc.bis": null, - "up": { - "desc": ["Level 2a", "Level 2b"], - "desc\\bis": true - } - } - }"#, - ) - .unwrap(); - - let flattened = FlattenedJson::from_raw(&raw); - assert_eq!( - flattened.map, - btreemap! { - "desc".into() => "Level 0".into(), - r"desc\.bis".into() => "Level 0 bis".into(), - "up.desc".into() => int!(1).into(), - r"up.desc\.bis".into() => FlattenedJsonValue::Null, - "up.up.desc".into() => vec!["Level 2a".into(), "Level 2b".into()].into(), - r"up.up.desc\\bis".into() => true.into(), - }, - ); - } - - #[test] - fn contains_mentions() { - let raw = serde_json::from_str::>( - r#"{ - "m.mentions": {}, - "content": { - "body": "Text" - } - }"#, - ) - .unwrap(); - - let flattened = FlattenedJson::from_raw(&raw); - assert!(!flattened.contains_mentions()); - - let raw = serde_json::from_str::>( - r#"{ - "content": { - "body": "Text", - "m.mentions": {} - } - }"#, - ) - .unwrap(); - - let flattened = FlattenedJson::from_raw(&raw); - assert!(flattened.contains_mentions()); - - let raw = serde_json::from_str::>( - r#"{ - "content": { - "body": "Text", - "m.mentions": { - "room": true - } - } - }"#, - ) - .unwrap(); - - let flattened = FlattenedJson::from_raw(&raw); - assert!(flattened.contains_mentions()); - } -} diff --git a/crates/ruma-common/src/push/condition/push_condition_serde.rs b/crates/ruma-common/src/push/condition/push_condition_serde.rs deleted file mode 100644 index cade8f72..00000000 --- a/crates/ruma-common/src/push/condition/push_condition_serde.rs +++ /dev/null @@ -1,152 +0,0 @@ -use serde::{de, Deserialize, Serialize, Serializer}; -use serde_json::value::RawValue as RawJsonValue; - -#[cfg(feature = "unstable-msc3931")] -use super::RoomVersionFeature; -use super::{PushCondition, RoomMemberCountIs, ScalarJsonValue}; -use crate::serde::from_raw_json_value; - -impl Serialize for PushCondition { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self { - PushCondition::_Custom(custom) => custom.serialize(serializer), - _ => PushConditionSerDeHelper::from(self.clone()).serialize(serializer), - } - } -} - -impl<'de> Deserialize<'de> for PushCondition { - fn deserialize(deserializer: D) -> Result - where - D: de::Deserializer<'de>, - { - let json = Box::::deserialize(deserializer)?; - let ExtractKind { kind } = from_raw_json_value(&json)?; - - match kind.as_ref() { - "event_match" - | "contains_display_name" - | "room_member_count" - | "sender_notification_permission" - | "event_property_is" - | "event_property_contains" => { - let helper: PushConditionSerDeHelper = from_raw_json_value(&json)?; - Ok(helper.into()) - } - #[cfg(feature = "unstable-msc3931")] - "org.matrix.msc3931.room_version_supports" => { - let helper: PushConditionSerDeHelper = from_raw_json_value(&json)?; - Ok(helper.into()) - } - _ => from_raw_json_value(&json).map(Self::_Custom), - } - } -} - -#[derive(Deserialize)] -struct ExtractKind { - kind: String, -} - -#[derive(Serialize, Deserialize)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -#[serde(tag = "kind", rename_all = "snake_case")] -enum PushConditionSerDeHelper { - /// 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, - }, - - /// Matches unencrypted messages where `content.body` contains the owner's display name in that - /// room. - ContainsDisplayName, - - /// Matches the current number of members in the room. - RoomMemberCount { - /// The condition on the current number of members in the room. - is: RoomMemberCountIs, - }, - - /// 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, - }, - - /// Apply the rule only to rooms that support a given feature. - #[cfg(feature = "unstable-msc3931")] - #[serde(rename = "org.matrix.msc3931.room_version_supports")] - RoomVersionSupports { - /// The feature the room must support for the push rule to apply. - feature: RoomVersionFeature, - }, - - EventPropertyIs { - key: String, - value: ScalarJsonValue, - }, - - EventPropertyContains { - key: String, - value: ScalarJsonValue, - }, -} - -impl From for PushCondition { - fn from(value: PushConditionSerDeHelper) -> Self { - match value { - PushConditionSerDeHelper::EventMatch { key, pattern } => { - Self::EventMatch { key, pattern } - } - PushConditionSerDeHelper::ContainsDisplayName => Self::ContainsDisplayName, - PushConditionSerDeHelper::RoomMemberCount { is } => Self::RoomMemberCount { is }, - PushConditionSerDeHelper::SenderNotificationPermission { key } => { - Self::SenderNotificationPermission { key } - } - #[cfg(feature = "unstable-msc3931")] - PushConditionSerDeHelper::RoomVersionSupports { feature } => { - Self::RoomVersionSupports { feature } - } - PushConditionSerDeHelper::EventPropertyIs { key, value } => { - Self::EventPropertyIs { key, value } - } - PushConditionSerDeHelper::EventPropertyContains { key, value } => { - Self::EventPropertyContains { key, value } - } - } - } -} - -impl From for PushConditionSerDeHelper { - fn from(value: PushCondition) -> Self { - match value { - PushCondition::EventMatch { key, pattern } => Self::EventMatch { key, pattern }, - PushCondition::ContainsDisplayName => Self::ContainsDisplayName, - PushCondition::RoomMemberCount { is } => Self::RoomMemberCount { is }, - PushCondition::SenderNotificationPermission { key } => { - Self::SenderNotificationPermission { key } - } - #[cfg(feature = "unstable-msc3931")] - PushCondition::RoomVersionSupports { feature } => Self::RoomVersionSupports { feature }, - PushCondition::EventPropertyIs { key, value } => Self::EventPropertyIs { key, value }, - PushCondition::EventPropertyContains { key, value } => { - Self::EventPropertyContains { key, value } - } - PushCondition::_Custom(_) => unimplemented!(), - } - } -} diff --git a/crates/ruma-common/src/push/condition/room_member_count_is.rs b/crates/ruma-common/src/push/condition/room_member_count_is.rs deleted file mode 100644 index 3ac737a6..00000000 --- a/crates/ruma-common/src/push/condition/room_member_count_is.rs +++ /dev/null @@ -1,211 +0,0 @@ -use std::{ - fmt, - ops::{Bound, RangeBounds, RangeFrom, RangeTo, RangeToInclusive}, - str::FromStr, -}; - -use js_int::UInt; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -/// One of `==`, `<`, `>`, `>=` or `<=`. -/// -/// Used by `RoomMemberCountIs`. Defaults to `==`. -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] -#[allow(clippy::exhaustive_enums)] -pub enum ComparisonOperator { - /// Equals - #[default] - Eq, - - /// Less than - Lt, - - /// Greater than - Gt, - - /// Greater or equal - Ge, - - /// Less or equal - Le, -} - -/// A decimal integer optionally prefixed by one of `==`, `<`, `>`, `>=` or `<=`. -/// -/// A prefix of `<` matches rooms where the member count is strictly less than the given -/// number and so forth. If no prefix is present, this parameter defaults to `==`. -/// -/// Can be constructed from a number or a range: -/// ``` -/// use js_int::uint; -/// use ruma_common::push::RoomMemberCountIs; -/// -/// // equivalent to `is: "3"` or `is: "==3"` -/// let exact = RoomMemberCountIs::from(uint!(3)); -/// -/// // equivalent to `is: ">=3"` -/// let greater_or_equal = RoomMemberCountIs::from(uint!(3)..); -/// -/// // equivalent to `is: "<3"` -/// let less = RoomMemberCountIs::from(..uint!(3)); -/// -/// // equivalent to `is: "<=3"` -/// let less_or_equal = RoomMemberCountIs::from(..=uint!(3)); -/// -/// // An exclusive range can be constructed with `RoomMemberCountIs::gt`: -/// // (equivalent to `is: ">3"`) -/// let greater = RoomMemberCountIs::gt(uint!(3)); -/// ``` -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[allow(clippy::exhaustive_structs)] -pub struct RoomMemberCountIs { - /// One of `==`, `<`, `>`, `>=`, `<=`, or no prefix. - pub prefix: ComparisonOperator, - - /// The number of people in the room. - pub count: UInt, -} - -impl RoomMemberCountIs { - /// Creates an instance of `RoomMemberCount` equivalent to ` Self { - RoomMemberCountIs { prefix: ComparisonOperator::Gt, count } - } -} - -impl From for RoomMemberCountIs { - fn from(x: UInt) -> Self { - RoomMemberCountIs { prefix: ComparisonOperator::Eq, count: x } - } -} - -impl From> for RoomMemberCountIs { - fn from(x: RangeFrom) -> Self { - RoomMemberCountIs { prefix: ComparisonOperator::Ge, count: x.start } - } -} - -impl From> for RoomMemberCountIs { - fn from(x: RangeTo) -> Self { - RoomMemberCountIs { prefix: ComparisonOperator::Lt, count: x.end } - } -} - -impl From> for RoomMemberCountIs { - fn from(x: RangeToInclusive) -> Self { - RoomMemberCountIs { prefix: ComparisonOperator::Le, count: x.end } - } -} - -impl fmt::Display for RoomMemberCountIs { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - use ComparisonOperator as Op; - - let prefix = match self.prefix { - Op::Eq => "", - Op::Lt => "<", - Op::Gt => ">", - Op::Ge => ">=", - Op::Le => "<=", - }; - - write!(f, "{prefix}{}", self.count) - } -} - -impl Serialize for RoomMemberCountIs { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let s = self.to_string(); - s.serialize(serializer) - } -} - -impl FromStr for RoomMemberCountIs { - type Err = js_int::ParseIntError; - - fn from_str(s: &str) -> Result { - use ComparisonOperator as Op; - - let (prefix, count_str) = match s { - s if s.starts_with("<=") => (Op::Le, &s[2..]), - s if s.starts_with('<') => (Op::Lt, &s[1..]), - s if s.starts_with(">=") => (Op::Ge, &s[2..]), - s if s.starts_with('>') => (Op::Gt, &s[1..]), - s if s.starts_with("==") => (Op::Eq, &s[2..]), - s => (Op::Eq, s), - }; - - Ok(RoomMemberCountIs { prefix, count: UInt::from_str(count_str)? }) - } -} - -impl<'de> Deserialize<'de> for RoomMemberCountIs { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = crate::serde::deserialize_cow_str(deserializer)?; - FromStr::from_str(&s).map_err(serde::de::Error::custom) - } -} - -impl RangeBounds for RoomMemberCountIs { - fn start_bound(&self) -> Bound<&UInt> { - use ComparisonOperator as Op; - - match self.prefix { - Op::Eq => Bound::Included(&self.count), - Op::Lt | Op::Le => Bound::Unbounded, - Op::Gt => Bound::Excluded(&self.count), - Op::Ge => Bound::Included(&self.count), - } - } - - fn end_bound(&self) -> Bound<&UInt> { - use ComparisonOperator as Op; - - match self.prefix { - Op::Eq => Bound::Included(&self.count), - Op::Gt | Op::Ge => Bound::Unbounded, - Op::Lt => Bound::Excluded(&self.count), - Op::Le => Bound::Included(&self.count), - } - } -} - -#[cfg(test)] -mod tests { - use std::ops::RangeBounds; - - use js_int::uint; - - use super::RoomMemberCountIs; - - #[test] - fn eq_range_contains_its_own_count() { - let count = uint!(2); - let range = RoomMemberCountIs::from(count); - - assert!(range.contains(&count)); - } - - #[test] - fn ge_range_contains_large_number() { - let range = RoomMemberCountIs::from(uint!(2)..); - let large_number = uint!(9001); - - assert!(range.contains(&large_number)); - } - - #[test] - fn gt_range_does_not_contain_initial_point() { - let range = RoomMemberCountIs::gt(uint!(2)); - let initial_point = uint!(2); - - assert!(!range.contains(&initial_point)); - } -} diff --git a/crates/ruma-common/src/push/iter.rs b/crates/ruma-common/src/push/iter.rs deleted file mode 100644 index aa1ad497..00000000 --- a/crates/ruma-common/src/push/iter.rs +++ /dev/null @@ -1,286 +0,0 @@ -use indexmap::set::{IntoIter as IndexSetIntoIter, Iter as IndexSetIter}; - -use super::{ - condition, Action, ConditionalPushRule, FlattenedJson, PatternedPushRule, PushConditionRoomCtx, - Ruleset, SimplePushRule, -}; -use crate::{OwnedRoomId, OwnedUserId}; - -/// The kinds of push rules that are available. -#[derive(Clone, Debug)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub enum AnyPushRule { - /// Rules that override all other kinds. - Override(ConditionalPushRule), - - /// Content-specific rules. - Content(PatternedPushRule), - - /// Room-specific rules. - Room(SimplePushRule), - - /// Sender-specific rules. - Sender(SimplePushRule), - - /// Lowest priority rules. - Underride(ConditionalPushRule), -} - -impl AnyPushRule { - /// Convert `AnyPushRule` to `AnyPushRuleRef`. - pub fn as_ref(&self) -> AnyPushRuleRef<'_> { - match self { - Self::Override(o) => AnyPushRuleRef::Override(o), - Self::Content(c) => AnyPushRuleRef::Content(c), - Self::Room(r) => AnyPushRuleRef::Room(r), - Self::Sender(s) => AnyPushRuleRef::Sender(s), - Self::Underride(u) => AnyPushRuleRef::Underride(u), - } - } - - /// Get the `enabled` flag of the push rule. - pub fn enabled(&self) -> bool { - self.as_ref().enabled() - } - - /// Get the `actions` of the push rule. - pub fn actions(&self) -> &[Action] { - self.as_ref().actions() - } - - /// Whether an event that matches the push rule should be highlighted. - pub fn triggers_highlight(&self) -> bool { - self.as_ref().triggers_highlight() - } - - /// Whether an event that matches the push rule should trigger a notification. - pub fn triggers_notification(&self) -> bool { - self.as_ref().triggers_notification() - } - - /// The sound that should be played when an event matches the push rule, if any. - pub fn triggers_sound(&self) -> Option<&str> { - self.as_ref().triggers_sound() - } - - /// Get the `rule_id` of the push rule. - pub fn rule_id(&self) -> &str { - self.as_ref().rule_id() - } - - /// Whether the push rule is a server-default rule. - pub fn is_server_default(&self) -> bool { - self.as_ref().is_server_default() - } - - /// Check if the push rule 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 { - self.as_ref().applies(event, context) - } -} - -/// Iterator type for `Ruleset` -#[derive(Debug)] -pub struct RulesetIntoIter { - content: IndexSetIntoIter, - override_: IndexSetIntoIter, - room: IndexSetIntoIter>, - sender: IndexSetIntoIter>, - underride: IndexSetIntoIter, -} - -impl Iterator for RulesetIntoIter { - type Item = AnyPushRule; - - fn next(&mut self) -> Option { - self.override_ - .next() - .map(AnyPushRule::Override) - .or_else(|| self.content.next().map(AnyPushRule::Content)) - .or_else(|| self.room.next().map(AnyPushRule::Room)) - .or_else(|| self.sender.next().map(AnyPushRule::Sender)) - .or_else(|| self.underride.next().map(AnyPushRule::Underride)) - } -} - -impl IntoIterator for Ruleset { - type Item = AnyPushRule; - type IntoIter = RulesetIntoIter; - - fn into_iter(self) -> Self::IntoIter { - RulesetIntoIter { - content: self.content.into_iter(), - override_: self.override_.into_iter(), - room: self.room.into_iter(), - sender: self.sender.into_iter(), - underride: self.underride.into_iter(), - } - } -} - -/// Reference to any kind of push rule. -#[derive(Clone, Copy, Debug)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub enum AnyPushRuleRef<'a> { - /// Rules that override all other kinds. - Override(&'a ConditionalPushRule), - - /// Content-specific rules. - Content(&'a PatternedPushRule), - - /// Room-specific rules. - Room(&'a SimplePushRule), - - /// Sender-specific rules. - Sender(&'a SimplePushRule), - - /// Lowest priority rules. - Underride(&'a ConditionalPushRule), -} - -impl<'a> AnyPushRuleRef<'a> { - /// Convert `AnyPushRuleRef` to `AnyPushRule` by cloning the inner value. - pub fn to_owned(self) -> AnyPushRule { - match self { - Self::Override(o) => AnyPushRule::Override(o.clone()), - Self::Content(c) => AnyPushRule::Content(c.clone()), - Self::Room(r) => AnyPushRule::Room(r.clone()), - Self::Sender(s) => AnyPushRule::Sender(s.clone()), - Self::Underride(u) => AnyPushRule::Underride(u.clone()), - } - } - - /// Get the `enabled` flag of the push rule. - pub fn enabled(self) -> bool { - match self { - Self::Override(rule) => rule.enabled, - Self::Underride(rule) => rule.enabled, - Self::Content(rule) => rule.enabled, - Self::Room(rule) => rule.enabled, - Self::Sender(rule) => rule.enabled, - } - } - - /// Get the `actions` of the push rule. - pub fn actions(self) -> &'a [Action] { - match self { - Self::Override(rule) => &rule.actions, - Self::Underride(rule) => &rule.actions, - Self::Content(rule) => &rule.actions, - Self::Room(rule) => &rule.actions, - Self::Sender(rule) => &rule.actions, - } - } - - /// Whether an event that matches the push rule should be highlighted. - pub fn triggers_highlight(self) -> bool { - self.actions().iter().any(|a| a.is_highlight()) - } - - /// Whether an event that matches the push rule should trigger a notification. - pub fn triggers_notification(self) -> bool { - self.actions().iter().any(|a| a.should_notify()) - } - - /// The sound that should be played when an event matches the push rule, if any. - pub fn triggers_sound(self) -> Option<&'a str> { - self.actions().iter().find_map(|a| a.sound()) - } - - /// Get the `rule_id` of the push rule. - pub fn rule_id(self) -> &'a str { - match self { - Self::Override(rule) => &rule.rule_id, - Self::Underride(rule) => &rule.rule_id, - Self::Content(rule) => &rule.rule_id, - Self::Room(rule) => rule.rule_id.as_ref(), - Self::Sender(rule) => rule.rule_id.as_ref(), - } - } - - /// Whether the push rule is a server-default rule. - pub fn is_server_default(self) -> bool { - match self { - Self::Override(rule) => rule.default, - Self::Underride(rule) => rule.default, - Self::Content(rule) => rule.default, - Self::Room(rule) => rule.default, - Self::Sender(rule) => rule.default, - } - } - - /// Check if the push rule 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 { - if event.get_str("sender").is_some_and(|sender| sender == context.user_id) { - return false; - } - - match self { - Self::Override(rule) => rule.applies(event, context), - Self::Underride(rule) => rule.applies(event, context), - Self::Content(rule) => rule.applies_to("content.body", event, context), - Self::Room(rule) => { - rule.enabled - && condition::check_event_match( - event, - "room_id", - rule.rule_id.as_ref(), - context, - ) - } - Self::Sender(rule) => { - rule.enabled - && condition::check_event_match(event, "sender", rule.rule_id.as_ref(), context) - } - } - } -} - -/// Iterator type for `Ruleset` -#[derive(Debug)] -pub struct RulesetIter<'a> { - content: IndexSetIter<'a, PatternedPushRule>, - override_: IndexSetIter<'a, ConditionalPushRule>, - room: IndexSetIter<'a, SimplePushRule>, - sender: IndexSetIter<'a, SimplePushRule>, - underride: IndexSetIter<'a, ConditionalPushRule>, -} - -impl<'a> Iterator for RulesetIter<'a> { - type Item = AnyPushRuleRef<'a>; - - fn next(&mut self) -> Option { - self.override_ - .next() - .map(AnyPushRuleRef::Override) - .or_else(|| self.content.next().map(AnyPushRuleRef::Content)) - .or_else(|| self.room.next().map(AnyPushRuleRef::Room)) - .or_else(|| self.sender.next().map(AnyPushRuleRef::Sender)) - .or_else(|| self.underride.next().map(AnyPushRuleRef::Underride)) - } -} - -impl<'a> IntoIterator for &'a Ruleset { - type Item = AnyPushRuleRef<'a>; - type IntoIter = RulesetIter<'a>; - - fn into_iter(self) -> Self::IntoIter { - RulesetIter { - content: self.content.iter(), - override_: self.override_.iter(), - room: self.room.iter(), - sender: self.sender.iter(), - underride: self.underride.iter(), - } - } -} diff --git a/crates/ruma-common/src/push/predefined.rs b/crates/ruma-common/src/push/predefined.rs deleted file mode 100644 index a847e261..00000000 --- a/crates/ruma-common/src/push/predefined.rs +++ /dev/null @@ -1,761 +0,0 @@ -//! Constructors for [predefined push rules]. -//! -//! [predefined push rules]: https://spec.matrix.org/latest/client-server-api/#predefined-rules - -use ruma_macros::StringEnum; - -use super::{ - Action::*, ConditionalPushRule, PatternedPushRule, PushCondition::*, RoomMemberCountIs, - Ruleset, Tweak, -}; -use crate::{PrivOwnedStr, UserId}; - -impl Ruleset { - /// The list of all [predefined push rules]. - /// - /// # Parameters - /// - /// - `user_id`: the user for which to generate the default rules. Some rules depend on the - /// user's ID (for instance those to send notifications when they are mentioned). - /// - /// [predefined push rules]: https://spec.matrix.org/latest/client-server-api/#predefined-rules - pub fn server_default(user_id: &UserId) -> Self { - Self { - content: [ - #[allow(deprecated)] - PatternedPushRule::contains_user_name(user_id), - ] - .into(), - override_: [ - ConditionalPushRule::master(), - ConditionalPushRule::suppress_notices(), - ConditionalPushRule::invite_for_me(user_id), - ConditionalPushRule::member_event(), - ConditionalPushRule::is_user_mention(user_id), - #[allow(deprecated)] - ConditionalPushRule::contains_display_name(), - ConditionalPushRule::is_room_mention(), - #[allow(deprecated)] - ConditionalPushRule::roomnotif(), - ConditionalPushRule::tombstone(), - ConditionalPushRule::reaction(), - ConditionalPushRule::server_acl(), - ConditionalPushRule::suppress_edits(), - #[cfg(feature = "unstable-msc3930")] - ConditionalPushRule::poll_response(), - ] - .into(), - underride: [ - ConditionalPushRule::call(), - ConditionalPushRule::encrypted_room_one_to_one(), - ConditionalPushRule::room_one_to_one(), - ConditionalPushRule::message(), - ConditionalPushRule::encrypted(), - #[cfg(feature = "unstable-msc3930")] - ConditionalPushRule::poll_start_one_to_one(), - #[cfg(feature = "unstable-msc3930")] - ConditionalPushRule::poll_start(), - #[cfg(feature = "unstable-msc3930")] - ConditionalPushRule::poll_end_one_to_one(), - #[cfg(feature = "unstable-msc3930")] - ConditionalPushRule::poll_end(), - ] - .into(), - ..Default::default() - } - } - - /// Update this ruleset with the given server-default push rules. - /// - /// This will replace the server-default rules in this ruleset (with `default` set to `true`) - /// with the given ones while keeping the `enabled` and `actions` fields in the same state. - /// - /// The default rules in this ruleset that are not in the new server-default rules are removed. - /// - /// # Parameters - /// - /// - `server_default`: the new server-default push rules. This ruleset must not contain - /// non-default rules. - pub fn update_with_server_default(&mut self, mut new_server_default: Ruleset) { - // Copy the default rules states from the old rules to the new rules and remove the - // server-default rules from the old rules. - macro_rules! copy_rules_state { - ($new_ruleset:ident, $old_ruleset:ident, @fields $($field_name:ident),+) => { - $( - $new_ruleset.$field_name = $new_ruleset - .$field_name - .into_iter() - .map(|mut new_rule| { - if let Some(old_rule) = - $old_ruleset.$field_name.take(new_rule.rule_id.as_str()) - { - new_rule.enabled = old_rule.enabled; - new_rule.actions = old_rule.actions; - } - - new_rule - }) - .collect(); - )+ - }; - } - copy_rules_state!(new_server_default, self, @fields override_, content, room, sender, underride); - - // Remove the remaining server-default rules from the old rules. - macro_rules! remove_remaining_default_rules { - ($ruleset:ident, @fields $($field_name:ident),+) => { - $( - $ruleset.$field_name.retain(|rule| !rule.default); - )+ - }; - } - remove_remaining_default_rules!(self, @fields override_, content, room, sender, underride); - - // `.m.rule.master` comes before all other push rules, while the other server-default push - // rules come after. - if let Some(master_rule) = - new_server_default.override_.take(PredefinedOverrideRuleId::Master.as_str()) - { - let (pos, _) = self.override_.insert_full(master_rule); - self.override_.move_index(pos, 0); - } - - // Merge the new server-default rules into the old rules. - macro_rules! merge_rules { - ($old_ruleset:ident, $new_ruleset:ident, @fields $($field_name:ident),+) => { - $( - $old_ruleset.$field_name.extend($new_ruleset.$field_name); - )+ - }; - } - merge_rules!(self, new_server_default, @fields override_, content, room, sender, underride); - } -} - -/// Default override push rules -impl ConditionalPushRule { - /// Matches all events, this can be enabled to turn off all push notifications other than those - /// generated by override rules set by the user. - pub fn master() -> Self { - Self { - actions: vec![], - default: true, - enabled: false, - rule_id: PredefinedOverrideRuleId::Master.to_string(), - conditions: vec![], - } - } - - /// Matches messages with a `msgtype` of `notice`. - pub fn suppress_notices() -> Self { - Self { - actions: vec![], - default: true, - enabled: true, - rule_id: PredefinedOverrideRuleId::SuppressNotices.to_string(), - conditions: vec![EventMatch { - key: "content.msgtype".into(), - pattern: "m.notice".into(), - }], - } - } - - /// Matches any invites to a new room for this user. - pub fn invite_for_me(user_id: &UserId) -> Self { - Self { - actions: vec![ - Notify, - SetTweak(Tweak::Sound("default".into())), - SetTweak(Tweak::Highlight(false)), - ], - default: true, - enabled: true, - rule_id: PredefinedOverrideRuleId::InviteForMe.to_string(), - conditions: vec![ - EventMatch { key: "type".into(), pattern: "m.room.member".into() }, - EventMatch { key: "content.membership".into(), pattern: "invite".into() }, - EventMatch { key: "state_key".into(), pattern: user_id.to_string() }, - ], - } - } - - /// Matches any `m.room.member_event`. - pub fn member_event() -> Self { - Self { - actions: vec![], - default: true, - enabled: true, - rule_id: PredefinedOverrideRuleId::MemberEvent.to_string(), - conditions: vec![EventMatch { key: "type".into(), pattern: "m.room.member".into() }], - } - } - - /// Matches any message which contains the user’s Matrix ID in the list of `user_ids` under the - /// `m.mentions` property. - pub fn is_user_mention(user_id: &UserId) -> Self { - Self { - actions: vec![ - Notify, - SetTweak(Tweak::Sound("default".to_owned())), - SetTweak(Tweak::Highlight(true)), - ], - default: true, - enabled: true, - rule_id: PredefinedOverrideRuleId::IsUserMention.to_string(), - conditions: vec![EventPropertyContains { - key: r"content.m\.mentions.user_ids".to_owned(), - value: user_id.as_str().into(), - }], - } - } - - /// Matches any message whose content is unencrypted and contains the user's current display - /// name in the room in which it was sent. - /// - /// Since Matrix 1.7, this rule only matches if the event's content does not contain an - /// `m.mentions` property. - #[deprecated = "Since Matrix 1.7. Use the m.mentions property with ConditionalPushRule::is_user_mention() instead."] - pub fn contains_display_name() -> Self { - #[allow(deprecated)] - Self { - actions: vec![ - Notify, - SetTweak(Tweak::Sound("default".into())), - SetTweak(Tweak::Highlight(true)), - ], - default: true, - enabled: true, - rule_id: PredefinedOverrideRuleId::ContainsDisplayName.to_string(), - conditions: vec![ContainsDisplayName], - } - } - - /// Matches any state event whose type is `m.room.tombstone`. This - /// is intended to notify users of a room when it is upgraded, - /// similar to what an `@room` notification would accomplish. - pub fn tombstone() -> Self { - Self { - actions: vec![Notify, SetTweak(Tweak::Highlight(true))], - default: true, - enabled: true, - rule_id: PredefinedOverrideRuleId::Tombstone.to_string(), - conditions: vec![ - EventMatch { key: "type".into(), pattern: "m.room.tombstone".into() }, - EventMatch { key: "state_key".into(), pattern: "".into() }, - ], - } - } - - /// Matches any message from a sender with the proper power level with the `room` property of - /// the `m.mentions` property set to `true`. - pub fn is_room_mention() -> Self { - Self { - actions: vec![Notify, SetTweak(Tweak::Highlight(true))], - default: true, - enabled: true, - rule_id: PredefinedOverrideRuleId::IsRoomMention.to_string(), - conditions: vec![ - EventPropertyIs { key: r"content.m\.mentions.room".to_owned(), value: true.into() }, - SenderNotificationPermission { key: "room".to_owned() }, - ], - } - } - - /// Matches any message whose content is unencrypted and contains the text `@room`, signifying - /// the whole room should be notified of the event. - /// - /// Since Matrix 1.7, this rule only matches if the event's content does not contain an - /// `m.mentions` property. - #[deprecated = "Since Matrix 1.7. Use the m.mentions property with ConditionalPushRule::is_room_mention() instead."] - pub fn roomnotif() -> Self { - #[allow(deprecated)] - Self { - actions: vec![Notify, SetTweak(Tweak::Highlight(true))], - default: true, - enabled: true, - rule_id: PredefinedOverrideRuleId::RoomNotif.to_string(), - conditions: vec![ - EventMatch { key: "content.body".into(), pattern: "@room".into() }, - SenderNotificationPermission { key: "room".into() }, - ], - } - } - - /// Matches [reactions] to a message. - /// - /// [reactions]: https://spec.matrix.org/latest/client-server-api/#event-annotations-and-reactions - pub fn reaction() -> Self { - Self { - actions: vec![], - default: true, - enabled: true, - rule_id: PredefinedOverrideRuleId::Reaction.to_string(), - conditions: vec![EventMatch { key: "type".into(), pattern: "m.reaction".into() }], - } - } - - /// Matches [room server ACLs]. - /// - /// [room server ACLs]: https://spec.matrix.org/latest/client-server-api/#server-access-control-lists-acls-for-rooms - pub fn server_acl() -> Self { - Self { - actions: vec![], - default: true, - enabled: true, - rule_id: PredefinedOverrideRuleId::RoomServerAcl.to_string(), - conditions: vec![ - EventMatch { key: "type".into(), pattern: "m.room.server_acl".into() }, - EventMatch { key: "state_key".into(), pattern: "".into() }, - ], - } - } - - /// Matches [event replacements]. - /// - /// [event replacements]: https://spec.matrix.org/latest/client-server-api/#event-replacements - pub fn suppress_edits() -> Self { - Self { - actions: vec![], - default: true, - enabled: true, - rule_id: PredefinedOverrideRuleId::SuppressEdits.to_string(), - conditions: vec![EventPropertyIs { - key: r"content.m\.relates_to.rel_type".to_owned(), - value: "m.replace".into(), - }], - } - } - - /// Matches a poll response event sent in any room. - /// - /// This rule uses the unstable prefixes defined in [MSC3381] and [MSC3930]. - /// - /// [MSC3381]: https://github.com/matrix-org/matrix-spec-proposals/pull/3381 - /// [MSC3930]: https://github.com/matrix-org/matrix-spec-proposals/pull/3930 - #[cfg(feature = "unstable-msc3930")] - pub fn poll_response() -> Self { - Self { - rule_id: PredefinedOverrideRuleId::PollResponse.to_string(), - default: true, - enabled: true, - conditions: vec![EventPropertyIs { - key: "type".to_owned(), - value: "org.matrix.msc3381.poll.response".into(), - }], - actions: vec![], - } - } -} - -/// Default content push rules -impl PatternedPushRule { - /// Matches any message whose content is unencrypted and contains the local part of the user's - /// Matrix ID, separated by word boundaries. - /// - /// Since Matrix 1.7, this rule only matches if the event's content does not contain an - /// `m.mentions` property. - #[deprecated = "Since Matrix 1.7. Use the m.mentions property with ConditionalPushRule::is_user_mention() instead."] - pub fn contains_user_name(user_id: &UserId) -> Self { - #[allow(deprecated)] - Self { - rule_id: PredefinedContentRuleId::ContainsUserName.to_string(), - enabled: true, - default: true, - pattern: user_id.localpart().into(), - actions: vec![ - Notify, - SetTweak(Tweak::Sound("default".into())), - SetTweak(Tweak::Highlight(true)), - ], - } - } -} - -/// Default underrides push rules -impl ConditionalPushRule { - /// Matches any incoming VOIP call. - pub fn call() -> Self { - Self { - rule_id: PredefinedUnderrideRuleId::Call.to_string(), - default: true, - enabled: true, - conditions: vec![EventMatch { key: "type".into(), pattern: "m.call.invite".into() }], - actions: vec![ - Notify, - SetTweak(Tweak::Sound("ring".into())), - SetTweak(Tweak::Highlight(false)), - ], - } - } - - /// Matches any encrypted event sent in a room with exactly two members. - /// - /// Unlike other push rules, this rule cannot be matched against the content of the event by - /// nature of it being encrypted. This causes the rule to be an "all or nothing" match where it - /// either matches all events that are encrypted (in 1:1 rooms) or none. - pub fn encrypted_room_one_to_one() -> Self { - Self { - rule_id: PredefinedUnderrideRuleId::EncryptedRoomOneToOne.to_string(), - default: true, - enabled: true, - conditions: vec![ - RoomMemberCount { is: RoomMemberCountIs::from(js_int::uint!(2)) }, - EventMatch { key: "type".into(), pattern: "m.room.encrypted".into() }, - ], - actions: vec![ - Notify, - SetTweak(Tweak::Sound("default".into())), - SetTweak(Tweak::Highlight(false)), - ], - } - } - - /// Matches any message sent in a room with exactly two members. - pub fn room_one_to_one() -> Self { - Self { - rule_id: PredefinedUnderrideRuleId::RoomOneToOne.to_string(), - default: true, - enabled: true, - conditions: vec![ - RoomMemberCount { is: RoomMemberCountIs::from(js_int::uint!(2)) }, - EventMatch { key: "type".into(), pattern: "m.room.message".into() }, - ], - actions: vec![ - Notify, - SetTweak(Tweak::Sound("default".into())), - SetTweak(Tweak::Highlight(false)), - ], - } - } - - /// Matches all chat messages. - pub fn message() -> Self { - Self { - rule_id: PredefinedUnderrideRuleId::Message.to_string(), - default: true, - enabled: true, - conditions: vec![EventMatch { key: "type".into(), pattern: "m.room.message".into() }], - actions: vec![Notify, SetTweak(Tweak::Highlight(false))], - } - } - - /// Matches all encrypted events. - /// - /// Unlike other push rules, this rule cannot be matched against the content of the event by - /// nature of it being encrypted. This causes the rule to be an "all or nothing" match where it - /// either matches all events that are encrypted (in group rooms) or none. - pub fn encrypted() -> Self { - Self { - rule_id: PredefinedUnderrideRuleId::Encrypted.to_string(), - default: true, - enabled: true, - conditions: vec![EventMatch { key: "type".into(), pattern: "m.room.encrypted".into() }], - actions: vec![Notify, SetTweak(Tweak::Highlight(false))], - } - } - - /// Matches a poll start event sent in a room with exactly two members. - /// - /// This rule uses the unstable prefixes defined in [MSC3381] and [MSC3930]. - /// - /// [MSC3381]: https://github.com/matrix-org/matrix-spec-proposals/pull/3381 - /// [MSC3930]: https://github.com/matrix-org/matrix-spec-proposals/pull/3930 - #[cfg(feature = "unstable-msc3930")] - pub fn poll_start_one_to_one() -> Self { - Self { - rule_id: PredefinedUnderrideRuleId::PollStartOneToOne.to_string(), - default: true, - enabled: true, - conditions: vec![ - RoomMemberCount { is: RoomMemberCountIs::from(js_int::uint!(2)) }, - EventPropertyIs { - key: "type".to_owned(), - value: "org.matrix.msc3381.poll.start".into(), - }, - ], - actions: vec![Notify, SetTweak(Tweak::Sound("default".into()))], - } - } - - /// Matches a poll start event sent in any room. - /// - /// This rule uses the unstable prefixes defined in [MSC3381] and [MSC3930]. - /// - /// [MSC3381]: https://github.com/matrix-org/matrix-spec-proposals/pull/3381 - /// [MSC3930]: https://github.com/matrix-org/matrix-spec-proposals/pull/3930 - #[cfg(feature = "unstable-msc3930")] - pub fn poll_start() -> Self { - Self { - rule_id: PredefinedUnderrideRuleId::PollStart.to_string(), - default: true, - enabled: true, - conditions: vec![EventPropertyIs { - key: "type".to_owned(), - value: "org.matrix.msc3381.poll.start".into(), - }], - actions: vec![Notify], - } - } - - /// Matches a poll end event sent in a room with exactly two members. - /// - /// This rule uses the unstable prefixes defined in [MSC3381] and [MSC3930]. - /// - /// [MSC3381]: https://github.com/matrix-org/matrix-spec-proposals/pull/3381 - /// [MSC3930]: https://github.com/matrix-org/matrix-spec-proposals/pull/3930 - #[cfg(feature = "unstable-msc3930")] - pub fn poll_end_one_to_one() -> Self { - Self { - rule_id: PredefinedUnderrideRuleId::PollEndOneToOne.to_string(), - default: true, - enabled: true, - conditions: vec![ - RoomMemberCount { is: RoomMemberCountIs::from(js_int::uint!(2)) }, - EventPropertyIs { - key: "type".to_owned(), - value: "org.matrix.msc3381.poll.end".into(), - }, - ], - actions: vec![Notify, SetTweak(Tweak::Sound("default".into()))], - } - } - - /// Matches a poll end event sent in any room. - /// - /// This rule uses the unstable prefixes defined in [MSC3381] and [MSC3930]. - /// - /// [MSC3381]: https://github.com/matrix-org/matrix-spec-proposals/pull/3381 - /// [MSC3930]: https://github.com/matrix-org/matrix-spec-proposals/pull/3930 - #[cfg(feature = "unstable-msc3930")] - pub fn poll_end() -> Self { - Self { - rule_id: PredefinedUnderrideRuleId::PollEnd.to_string(), - default: true, - enabled: true, - conditions: vec![EventPropertyIs { - key: "type".to_owned(), - value: "org.matrix.msc3381.poll.end".into(), - }], - actions: vec![Notify], - } - } -} - -/// The rule IDs of the predefined server push rules. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -#[non_exhaustive] -pub enum PredefinedRuleId { - /// User-configured rules that override all other kinds. - Override(PredefinedOverrideRuleId), - - /// Lowest priority user-defined rules. - Underride(PredefinedUnderrideRuleId), - - /// Content-specific rules. - Content(PredefinedContentRuleId), -} - -/// The rule IDs of the predefined override server push rules. -#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))] -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, StringEnum)] -#[ruma_enum(rename_all = ".m.rule.snake_case")] -#[non_exhaustive] -pub enum PredefinedOverrideRuleId { - /// `.m.rule.master` - Master, - - /// `.m.rule.suppress_notices` - SuppressNotices, - - /// `.m.rule.invite_for_me` - InviteForMe, - - /// `.m.rule.member_event` - MemberEvent, - - /// `.m.rule.is_user_mention` - IsUserMention, - - /// `.m.rule.contains_display_name` - #[deprecated = "Since Matrix 1.7. Use the m.mentions property with PredefinedOverrideRuleId::IsUserMention instead."] - ContainsDisplayName, - - /// `.m.rule.is_room_mention` - IsRoomMention, - - /// `.m.rule.roomnotif` - #[ruma_enum(rename = ".m.rule.roomnotif")] - #[deprecated = "Since Matrix 1.7. Use the m.mentions property with PredefinedOverrideRuleId::IsRoomMention instead."] - RoomNotif, - - /// `.m.rule.tombstone` - Tombstone, - - /// `.m.rule.reaction` - Reaction, - - /// `.m.rule.room.server_acl` - #[ruma_enum(rename = ".m.rule.room.server_acl")] - RoomServerAcl, - - /// `.m.rule.suppress_edits` - SuppressEdits, - - /// `.m.rule.poll_response` - /// - /// This uses the unstable prefix defined in [MSC3930]. - /// - /// [MSC3930]: https://github.com/matrix-org/matrix-spec-proposals/pull/3930 - #[cfg(feature = "unstable-msc3930")] - #[ruma_enum(rename = ".org.matrix.msc3930.rule.poll_response")] - PollResponse, - - #[doc(hidden)] - _Custom(PrivOwnedStr), -} - -/// The rule IDs of the predefined underride server push rules. -#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))] -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, StringEnum)] -#[ruma_enum(rename_all = ".m.rule.snake_case")] -#[non_exhaustive] -pub enum PredefinedUnderrideRuleId { - /// `.m.rule.call` - Call, - - /// `.m.rule.encrypted_room_one_to_one` - EncryptedRoomOneToOne, - - /// `.m.rule.room_one_to_one` - RoomOneToOne, - - /// `.m.rule.message` - Message, - - /// `.m.rule.encrypted` - Encrypted, - - /// `.m.rule.poll_start_one_to_one` - /// - /// This uses the unstable prefix defined in [MSC3930]. - /// - /// [MSC3930]: https://github.com/matrix-org/matrix-spec-proposals/pull/3930 - #[cfg(feature = "unstable-msc3930")] - #[ruma_enum(rename = ".org.matrix.msc3930.rule.poll_start_one_to_one")] - PollStartOneToOne, - - /// `.m.rule.poll_start` - /// - /// This uses the unstable prefix defined in [MSC3930]. - /// - /// [MSC3930]: https://github.com/matrix-org/matrix-spec-proposals/pull/3930 - #[cfg(feature = "unstable-msc3930")] - #[ruma_enum(rename = ".org.matrix.msc3930.rule.poll_start")] - PollStart, - - /// `.m.rule.poll_end_one_to_one` - /// - /// This uses the unstable prefix defined in [MSC3930]. - /// - /// [MSC3930]: https://github.com/matrix-org/matrix-spec-proposals/pull/3930 - #[cfg(feature = "unstable-msc3930")] - #[ruma_enum(rename = ".org.matrix.msc3930.rule.poll_end_one_to_one")] - PollEndOneToOne, - - /// `.m.rule.poll_end` - /// - /// This uses the unstable prefix defined in [MSC3930]. - /// - /// [MSC3930]: https://github.com/matrix-org/matrix-spec-proposals/pull/3930 - #[cfg(feature = "unstable-msc3930")] - #[ruma_enum(rename = ".org.matrix.msc3930.rule.poll_end")] - PollEnd, - - #[doc(hidden)] - _Custom(PrivOwnedStr), -} - -/// The rule IDs of the predefined content server push rules. -#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))] -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, StringEnum)] -#[ruma_enum(rename_all = ".m.rule.snake_case")] -#[non_exhaustive] -pub enum PredefinedContentRuleId { - /// `.m.rule.contains_user_name` - #[deprecated = "Since Matrix 1.7. Use the m.mentions property with PredefinedOverrideRuleId::IsUserMention instead."] - ContainsUserName, - - #[doc(hidden)] - _Custom(PrivOwnedStr), -} - -#[cfg(test)] -mod tests { - use assert_matches2::assert_matches; - use assign::assign; - - use super::PredefinedOverrideRuleId; - use crate::{ - push::{Action, ConditionalPushRule, ConditionalPushRuleInit, Ruleset}, - user_id, - }; - - #[test] - fn update_with_server_default() { - let user_rule_id = "user_always_true"; - let default_rule_id = ".default_always_true"; - - let override_ = [ - // Default `.m.rule.master` push rule with non-default state. - assign!(ConditionalPushRule::master(), { enabled: true, actions: vec![Action::Notify]}), - // User-defined push rule. - ConditionalPushRuleInit { - actions: vec![], - default: false, - enabled: false, - rule_id: user_rule_id.to_owned(), - conditions: vec![], - } - .into(), - // Old server-default push rule. - ConditionalPushRuleInit { - actions: vec![], - default: true, - enabled: true, - rule_id: default_rule_id.to_owned(), - conditions: vec![], - } - .into(), - ] - .into_iter() - .collect(); - let mut ruleset = Ruleset { override_, ..Default::default() }; - - let new_server_default = Ruleset::server_default(user_id!("@user:localhost")); - - ruleset.update_with_server_default(new_server_default); - - // Master rule is in first position. - let master_rule = &ruleset.override_[0]; - assert_eq!(master_rule.rule_id, PredefinedOverrideRuleId::Master.as_str()); - - // `enabled` and `actions` have been copied from the old rules. - assert!(master_rule.enabled); - assert_eq!(master_rule.actions.len(), 1); - assert_matches!(&master_rule.actions[0], Action::Notify); - - // Non-server-default rule is still present and hasn't changed. - let user_rule = ruleset.override_.get(user_rule_id).unwrap(); - assert!(!user_rule.enabled); - assert_eq!(user_rule.actions.len(), 0); - - // Old server-default rule is gone. - assert_matches!(ruleset.override_.get(default_rule_id), None); - - // New server-default rule is present and hasn't changed. - let member_event_rule = - ruleset.override_.get(PredefinedOverrideRuleId::MemberEvent.as_str()).unwrap(); - assert!(member_event_rule.enabled); - assert_eq!(member_event_rule.actions.len(), 0); - } -} diff --git a/crates/ruma-events/src/enums.rs b/crates/ruma-events/src/enums.rs index c64207b9..1d93ce57 100644 --- a/crates/ruma-events/src/enums.rs +++ b/crates/ruma-events/src/enums.rs @@ -18,6 +18,7 @@ event_enum! { /// Any room account data event. enum RoomAccountData { "m.tag" => super::tag, + "m.push_rules.override" => super::push_rules, } /// Any ephemeral room event. diff --git a/crates/ruma-events/src/lib.rs b/crates/ruma-events/src/lib.rs index 91bb1834..9fdb1552 100644 --- a/crates/ruma-events/src/lib.rs +++ b/crates/ruma-events/src/lib.rs @@ -147,9 +147,9 @@ pub mod message; pub mod mixins; #[cfg(feature = "unstable-pdu")] pub mod pdu; +pub mod push_rules; pub mod policy; pub mod presence; -pub mod push_rules; pub mod reaction; pub mod receipt; pub mod relation; @@ -278,6 +278,12 @@ pub struct Mentions { /// Defaults to `false`. #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")] pub room: bool, + + /// Whether the thread is mentioned. Notifies anyone watching. + /// + /// Defaults to `false`. + #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")] + pub thread: bool, } impl Mentions { @@ -296,9 +302,15 @@ impl Mentions { Self { room: true, ..Default::default() } } + /// Create a `Mentions` for a room mention. + pub fn with_thread_mention() -> Self { + Self { thread: true, ..Default::default() } + } + fn add(&mut self, mentions: Self) { self.user_ids.extend(mentions.user_ids); self.room |= mentions.room; + self.thread |= mentions.thread; } } diff --git a/crates/ruma-events/src/mixins.rs b/crates/ruma-events/src/mixins.rs index 7f36175f..f5db7d65 100644 --- a/crates/ruma-events/src/mixins.rs +++ b/crates/ruma-events/src/mixins.rs @@ -1,3 +1,6 @@ +//! mixins that are added to events +//! this will probably be removed + use std::collections::HashMap; use js_int::UInt; diff --git a/crates/ruma-events/src/push.rs b/crates/ruma-events/src/push.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/ruma-events/src/push.rs @@ -0,0 +1 @@ + diff --git a/crates/ruma-events/src/push_rules.rs b/crates/ruma-events/src/push_rules.rs index 2d96c18d..01b6949b 100644 --- a/crates/ruma-events/src/push_rules.rs +++ b/crates/ruma-events/src/push_rules.rs @@ -1,235 +1,23 @@ -//! Types for the [`m.push_rules`] event. -//! -//! [`m.push_rules`]: https://spec.matrix.org/latest/client-server-api/#mpush_rules +//! types for push rules -use ruma_common::push::Ruleset; +use ruma_common::push::PushRules; use ruma_macros::EventContent; -use serde::{Deserialize, Serialize}; +use serde::{Serialize, Deserialize}; /// The content of an `m.push_rules` event. -/// -/// Describes all push rules for a user. -#[derive(Clone, Debug, Deserialize, Serialize, EventContent)] +#[derive(Clone, Debug, Default, Deserialize, Serialize, EventContent)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[ruma_event(type = "m.push_rules", kind = GlobalAccountData)] pub struct PushRulesEventContent { - /// The global ruleset. - pub global: Ruleset, + #[serde(flatten)] + pub rules: PushRules, } -impl PushRulesEventContent { - /// Creates a new `PushRulesEventContent` with the given global ruleset. - /// - /// You can also construct a `PushRulesEventContent` from a global ruleset using `From` / - /// `Into`. - pub fn new(global: Ruleset) -> Self { - Self { global } - } -} - -impl From for PushRulesEventContent { - fn from(global: Ruleset) -> Self { - Self::new(global) - } -} - -#[cfg(test)] -mod tests { - use serde_json::{from_value as from_json_value, json}; - - use super::PushRulesEvent; - - #[test] - fn sanity_check() { - // This is a full example of a push rules event from the specification. - let json_data = json!({ - "content": { - "global": { - "content": [ - { - "actions": [ - "notify", - { - "set_tweak": "sound", - "value": "default" - }, - { - "set_tweak": "highlight" - } - ], - "default": true, - "enabled": true, - "pattern": "alice", - "rule_id": ".m.rule.contains_user_name" - } - ], - "override": [ - { - "actions": [], - "conditions": [], - "default": true, - "enabled": false, - "rule_id": ".m.rule.master" - }, - { - "actions": [], - "conditions": [ - { - "key": "content.msgtype", - "kind": "event_match", - "pattern": "m.notice" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.suppress_notices" - } - ], - "room": [], - "sender": [], - "underride": [ - { - "actions": [ - "notify", - { - "set_tweak": "sound", - "value": "ring" - }, - { - "set_tweak": "highlight", - "value": false - } - ], - "conditions": [ - { - "key": "type", - "kind": "event_match", - "pattern": "m.call.invite" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.call" - }, - { - "actions": [ - "notify", - { - "set_tweak": "sound", - "value": "default" - }, - { - "set_tweak": "highlight" - } - ], - "conditions": [ - { - "kind": "contains_display_name" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.contains_display_name" - }, - { - "actions": [ - "notify", - { - "set_tweak": "sound", - "value": "default" - }, - { - "set_tweak": "highlight", - "value": false - } - ], - "conditions": [ - { - "is": "2", - "kind": "room_member_count" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.room_one_to_one" - }, - { - "actions": [ - "notify", - { - "set_tweak": "sound", - "value": "default" - }, - { - "set_tweak": "highlight", - "value": false - } - ], - "conditions": [ - { - "key": "type", - "kind": "event_match", - "pattern": "m.room.member" - }, - { - "key": "content.membership", - "kind": "event_match", - "pattern": "invite" - }, - { - "key": "state_key", - "kind": "event_match", - "pattern": "@alice:example.com" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.invite_for_me" - }, - { - "actions": [ - "notify", - { - "set_tweak": "highlight", - "value": false - } - ], - "conditions": [ - { - "key": "type", - "kind": "event_match", - "pattern": "m.room.member" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.member_event" - }, - { - "actions": [ - "notify", - { - "set_tweak": "highlight", - "value": false - } - ], - "conditions": [ - { - "key": "type", - "kind": "event_match", - "pattern": "m.room.message" - } - ], - "default": true, - "enabled": true, - "rule_id": ".m.rule.message" - } - ] - } - }, - "type": "m.push_rules" - }); - - from_json_value::(json_data).unwrap(); - } +/// The content of an `m.push_rules` event. +#[derive(Clone, Debug, Default, Deserialize, Serialize, EventContent)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[ruma_event(type = "m.push_rules.override", kind = RoomAccountData)] +pub struct PushRulesOverrideEventContent { + #[serde(flatten)] + pub rules: PushRules, } diff --git a/crates/ruma-push-gateway-api/CHANGELOG.md b/crates/ruma-push-gateway-api/CHANGELOG.md deleted file mode 100644 index 4c8a2ac2..00000000 --- a/crates/ruma-push-gateway-api/CHANGELOG.md +++ /dev/null @@ -1,58 +0,0 @@ -# [unreleased] - -# 0.8.0 - -No changes for this version - -# 0.7.1 - -Improvements: - -* Update links to the latest version of the Matrix spec - -# 0.7.0 - -No changes for this version - -# 0.6.0 - -Breaking changes: - -* Remove `PartialEq` implementation for `NotificationCounts` - -# 0.5.0 - -Breaking changes: - -* Upgrade dependencies - -# 0.4.0 - -Breaking changes: - -* Upgrade dependencies - -# 0.3.0 - -Breaking changes: - -* Upgrade dependencies - -# 0.2.0 - -Breaking changes: - -* Upgrade ruma-events to 0.23.0 - -# 0.1.0 - -Breaking changes: - -* Remove `Copy` implementation for `NotificationCounts` to avoid simple changes - being breaking -* Change `Box` to `&RawJsonValue` in request types -* Upgrade public dependencies - -# 0.0.1 - -* Add endpoint `send_event_notification::v1` diff --git a/crates/ruma-push-gateway-api/Cargo.toml b/crates/ruma-push-gateway-api/Cargo.toml index 61d1b5fb..9ebd86cf 100644 --- a/crates/ruma-push-gateway-api/Cargo.toml +++ b/crates/ruma-push-gateway-api/Cargo.toml @@ -1,27 +1,9 @@ [package] name = "ruma-push-gateway-api" -version = "0.8.0" -description = "Types for the endpoints in the Matrix push gateway API." -homepage = "https://ruma.io/" -keywords = ["matrix", "chat", "messaging", "ruma"] -license = "MIT" -readme = "README.md" -repository = "https://github.com/ruma/ruma" +version = "0.1.0" edition = "2021" -rust-version = { workspace = true } +rust-version.workspace = true -[package.metadata.docs.rs] -all-features = true - -[features] -unstable-exhaustive-types = [] -unstable-unspecified = [] -client = [] -server = [] +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -js_int = { workspace = true, features = ["serde"] } -ruma-common = { workspace = true, features = ["api"] } -ruma-events = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } diff --git a/crates/ruma-push-gateway-api/README.md b/crates/ruma-push-gateway-api/README.md index 3e540777..d0e88546 100644 --- a/crates/ruma-push-gateway-api/README.md +++ b/crates/ruma-push-gateway-api/README.md @@ -5,3 +5,5 @@ ![license: MIT](https://img.shields.io/crates/l/ruma-push-gateway-api.svg) **ruma-push-gateway-api** contains serializable types for the requests and responses for each endpoint in the [Matrix](https://matrix.org/) push gateway API specification. + +It is being removed in favor of having the server itself push the notifications. diff --git a/crates/ruma-push-gateway-api/src/lib.rs b/crates/ruma-push-gateway-api/src/lib.rs index 738f66ec..8b137891 100644 --- a/crates/ruma-push-gateway-api/src/lib.rs +++ b/crates/ruma-push-gateway-api/src/lib.rs @@ -1,25 +1 @@ -#![doc(html_favicon_url = "https://ruma.io/favicon.ico")] -#![doc(html_logo_url = "https://ruma.io/images/logo.png")] -//! (De)serializable types for the [Matrix Push Gateway API][push-api]. -//! These types can be shared by push gateway and server code. -//! -//! [push-api]: https://spec.matrix.org/latest/push-gateway-api/ -#![warn(missing_docs)] - -use std::fmt; - -pub mod send_event_notification; - -// Wrapper around `Box` that cannot be used in a meaningful way outside of -// this crate. Used for string enums because their `_Custom` variant can't be -// truly private (only `#[doc(hidden)]`). -#[doc(hidden)] -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct PrivOwnedStr(Box); - -impl fmt::Debug for PrivOwnedStr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} diff --git a/crates/ruma-push-gateway-api/src/send_event_notification.rs b/crates/ruma-push-gateway-api/src/send_event_notification.rs deleted file mode 100644 index 3d800a68..00000000 --- a/crates/ruma-push-gateway-api/src/send_event_notification.rs +++ /dev/null @@ -1,458 +0,0 @@ -//! `POST /_matrix/push/*/notify` -//! -//! Notify a push gateway about an event or update the number of unread notifications a user has. - -pub mod v1 { - //! `/v1/` ([spec]) - //! - //! [spec]: https://spec.matrix.org/latest/push-gateway-api/#post_matrixpushv1notify - - use js_int::{uint, UInt}; - use ruma_common::{ - api::{request, response, Metadata}, - metadata, - push::{PushFormat, Tweak}, - serde::StringEnum, - OwnedEventId, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, SecondsSinceUnixEpoch, - }; - use ruma_events::TimelineEventType; - use serde::{Deserialize, Serialize}; - use serde_json::value::RawValue as RawJsonValue; - #[cfg(feature = "unstable-unspecified")] - use serde_json::value::Value as JsonValue; - - use crate::PrivOwnedStr; - - const METADATA: Metadata = metadata! { - method: POST, - rate_limited: false, - authentication: None, - history: { - 1.0 => "/_matrix/push/v1/notify", - } - }; - - /// Request type for the `send_event_notification` endpoint. - #[request] - pub struct Request { - /// Information about the push notification - pub notification: Notification, - } - - /// Response type for the `send_event_notification` endpoint. - #[response] - #[derive(Default)] - pub struct Response { - /// A list of all pushkeys given in the notification request that are not valid. - /// - /// These could have been rejected by an upstream gateway because they have expired or have - /// never been valid. Homeservers must cease sending notification requests for these - /// pushkeys and remove the associated pushers. It may not necessarily be the notification - /// in the request that failed: it could be that a previous notification to the same - /// pushkey failed. May be empty. - pub rejected: Vec, - } - - impl Request { - /// Creates a new `Request` with the given notification. - pub fn new(notification: Notification) -> Self { - Self { notification } - } - } - - impl Response { - /// Creates a new `Response` with the given list of rejected pushkeys. - pub fn new(rejected: Vec) -> Self { - Self { rejected } - } - } - - /// Type for passing information about a push notification - #[derive(Clone, Debug, Default, Deserialize, Serialize)] - #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] - pub struct Notification { - /// The Matrix event ID of the event being notified about. - /// - /// Required if the notification is about a particular Matrix event. May be omitted for - /// notifications that only contain updated badge counts. This ID can and should be used to - /// detect duplicate notification requests. - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option, - - /// The ID of the room in which this event occurred. - /// - /// Required if the notification relates to a specific Matrix event. - #[serde(skip_serializing_if = "Option::is_none")] - pub room_id: Option, - - /// The type of the event as in the event's `type` field. - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub event_type: Option, - - /// The sender of the event as in the corresponding event field. - #[serde(skip_serializing_if = "Option::is_none")] - pub sender: Option, - - /// The current display name of the sender in the room in which the event occurred. - #[serde(skip_serializing_if = "Option::is_none")] - pub sender_display_name: Option, - - /// The name of the room in which the event occurred. - #[serde(skip_serializing_if = "Option::is_none")] - pub room_name: Option, - - /// An alias to display for the room in which the event occurred. - #[serde(skip_serializing_if = "Option::is_none")] - pub room_alias: Option, - - /// Whether the user receiving the notification is the subject of a member event (i.e. the - /// `state_key` of the member event is equal to the user's Matrix ID). - #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")] - pub user_is_target: bool, - - /// The priority of the notification. - /// - /// If omitted, `high` is assumed. This may be used by push gateways to deliver less - /// time-sensitive notifications in a way that will preserve battery power on mobile - /// devices. - #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")] - pub prio: NotificationPriority, - - /// The `content` field from the event, if present. - /// - /// The pusher may omit this if the event had no content or for any other reason. - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option>, - - /// Current number of unacknowledged communications for the recipient user. - /// - /// Counts whose value is zero should be omitted. - #[serde(default, skip_serializing_if = "NotificationCounts::is_default")] - pub counts: NotificationCounts, - - /// An array of devices that the notification should be sent to. - pub devices: Vec, - } - - impl Notification { - /// Create a new notification for the given devices. - pub fn new(devices: Vec) -> Self { - Notification { devices, ..Default::default() } - } - } - - /// Type for passing information about notification priority. - /// - /// This may be used by push gateways to deliver less time-sensitive - /// notifications in a way that will preserve battery power on mobile devices. - /// - /// This type can hold an arbitrary string. To build this with a custom value, convert it from a - /// string with `::from()` / `.into()`. To check for values that are not available as a - /// documented variant here, use its string representation, obtained through `.as_str()`. - #[derive(Clone, Default, PartialEq, Eq, StringEnum)] - #[ruma_enum(rename_all = "snake_case")] - #[non_exhaustive] - pub enum NotificationPriority { - /// A high priority notification - #[default] - High, - - /// A low priority notification - Low, - - #[doc(hidden)] - _Custom(PrivOwnedStr), - } - - /// Type for passing information about notification counts. - #[derive(Clone, Debug, Default, Deserialize, Serialize)] - #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] - pub struct NotificationCounts { - /// The number of unread messages a user has across all of the rooms they - /// are a member of. - #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")] - pub unread: UInt, - - /// The number of unacknowledged missed calls a user has across all rooms of - /// which they are a member. - #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")] - pub missed_calls: UInt, - } - - impl NotificationCounts { - /// Create new notification counts from the given unread and missed call - /// counts. - pub fn new(unread: UInt, missed_calls: UInt) -> Self { - NotificationCounts { unread, missed_calls } - } - - fn is_default(&self) -> bool { - self.unread == uint!(0) && self.missed_calls == uint!(0) - } - } - - /// Type for passing information about devices. - #[derive(Clone, Debug, Deserialize, Serialize)] - #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] - pub struct Device { - /// The `app_id` given when the pusher was created. - /// - /// Max length: 64 chars. - pub app_id: String, - - /// The `pushkey` given when the pusher was created. - /// - /// Max length: 512 bytes. - pub pushkey: String, - - /// The unix timestamp (in seconds) when the pushkey was last updated. - #[serde(skip_serializing_if = "Option::is_none")] - pub pushkey_ts: Option, - - /// A dictionary of additional pusher-specific data. - #[serde(default, skip_serializing_if = "PusherData::is_empty")] - pub data: PusherData, - - /// A dictionary of customisations made to the way this notification is to be presented. - /// - /// These are added by push rules. - #[serde(with = "tweak_serde", skip_serializing_if = "Vec::is_empty")] - pub tweaks: Vec, - } - - impl Device { - /// Create a new device with the given app id and pushkey - pub fn new(app_id: String, pushkey: String) -> Self { - Device { - app_id, - pushkey, - pushkey_ts: None, - data: PusherData::new(), - tweaks: Vec::new(), - } - } - } - - /// Information for the pusher implementation itself. - /// - /// This is the data dictionary passed in at pusher creation minus the `url` key. - /// - /// It can be constructed from [`ruma_common::push::HttpPusherData`] with `::from()` / - /// `.into()`. - #[derive(Clone, Debug, Default, Serialize, Deserialize)] - #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] - pub struct PusherData { - /// The format to use when sending notifications to the Push Gateway. - #[serde(skip_serializing_if = "Option::is_none")] - pub format: Option, - - /// iOS (+ macOS?) specific default payload that will be sent to apple push notification - /// service. - /// - /// For more information, see [Sygnal docs][sygnal]. - /// - /// [sygnal]: https://github.com/matrix-org/sygnal/blob/main/docs/applications.md#ios-applications-beware - // Not specified, issue: https://github.com/matrix-org/matrix-spec/issues/921 - #[cfg(feature = "unstable-unspecified")] - #[serde(default, skip_serializing_if = "JsonValue::is_null")] - pub default_payload: JsonValue, - } - - impl PusherData { - /// Creates an empty `PusherData`. - pub fn new() -> Self { - Default::default() - } - - /// Returns `true` if all fields are `None`. - pub fn is_empty(&self) -> bool { - #[cfg(not(feature = "unstable-unspecified"))] - { - self.format.is_none() - } - - #[cfg(feature = "unstable-unspecified")] - { - self.format.is_none() && self.default_payload.is_null() - } - } - } - - impl From for PusherData { - fn from(data: ruma_common::push::HttpPusherData) -> Self { - let ruma_common::push::HttpPusherData { - format, - #[cfg(feature = "unstable-unspecified")] - default_payload, - .. - } = data; - - Self { - format, - #[cfg(feature = "unstable-unspecified")] - default_payload, - } - } - } - - mod tweak_serde { - use std::fmt; - - use ruma_common::push::Tweak; - use serde::{ - de::{MapAccess, Visitor}, - ser::SerializeMap, - Deserializer, Serializer, - }; - - pub(super) fn serialize(tweak: &[Tweak], serializer: S) -> Result - where - S: Serializer, - { - let mut map = serializer.serialize_map(Some(tweak.len()))?; - for item in tweak { - #[allow(unreachable_patterns)] - match item { - Tweak::Highlight(b) => map.serialize_entry("highlight", b)?, - Tweak::Sound(value) => map.serialize_entry("sound", value)?, - Tweak::Custom { value, name } => map.serialize_entry(name, value)?, - _ => unreachable!("variant added to Tweak not covered by Custom"), - } - } - map.end() - } - - struct TweaksVisitor; - - impl<'de> Visitor<'de> for TweaksVisitor { - type Value = Vec; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("List of tweaks") - } - - fn visit_map(self, mut access: M) -> Result - where - M: MapAccess<'de>, - { - let mut tweaks = vec![]; - while let Some(key) = access.next_key::()? { - match &*key { - "sound" => tweaks.push(Tweak::Sound(access.next_value()?)), - // If a highlight tweak is given with no value, its value is defined to be - // true. - "highlight" => { - let highlight = if let Ok(highlight) = access.next_value() { - highlight - } else { - true - }; - - tweaks.push(Tweak::Highlight(highlight)); - } - _ => tweaks.push(Tweak::Custom { name: key, value: access.next_value()? }), - }; - } - - // If no highlight tweak is given at all then the value of highlight is defined to - // be false. - if !tweaks.iter().any(|tw| matches!(tw, Tweak::Highlight(_))) { - tweaks.push(Tweak::Highlight(false)); - } - - Ok(tweaks) - } - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - deserializer.deserialize_map(TweaksVisitor) - } - } - - #[cfg(test)] - mod tests { - use js_int::uint; - use ruma_common::{ - owned_event_id, owned_room_alias_id, owned_room_id, owned_user_id, - SecondsSinceUnixEpoch, - }; - use ruma_events::TimelineEventType; - use serde_json::{ - from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue, - }; - - use super::{Device, Notification, NotificationCounts, NotificationPriority, Tweak}; - - #[test] - fn serialize_request() { - let expected = json!({ - "event_id": "$3957tyerfgewrf384", - "room_id": "!slw48wfj34rtnrf:example.com", - "type": "m.room.message", - "sender": "@exampleuser:matrix.org", - "sender_display_name": "Major Tom", - "room_alias": "#exampleroom:matrix.org", - "prio": "low", - "content": {}, - "counts": { - "unread": 2, - }, - "devices": [ - { - "app_id": "org.matrix.matrixConsole.ios", - "pushkey": "V2h5IG9uIGVhcnRoIGRpZCB5b3UgZGVjb2RlIHRoaXM/", - "pushkey_ts": 123, - "tweaks": { - "sound": "silence", - "highlight": true, - "custom": "go wild" - } - } - ] - }); - - let eid = owned_event_id!("$3957tyerfgewrf384"); - let rid = owned_room_id!("!slw48wfj34rtnrf:example.com"); - let uid = owned_user_id!("@exampleuser:matrix.org"); - let alias = owned_room_alias_id!("#exampleroom:matrix.org"); - - let count = NotificationCounts { unread: uint!(2), ..NotificationCounts::default() }; - - let device = Device { - pushkey_ts: Some(SecondsSinceUnixEpoch(uint!(123))), - tweaks: vec![ - Tweak::Highlight(true), - Tweak::Sound("silence".into()), - Tweak::Custom { - name: "custom".into(), - value: from_json_value(JsonValue::String("go wild".into())).unwrap(), - }, - ], - ..Device::new( - "org.matrix.matrixConsole.ios".into(), - "V2h5IG9uIGVhcnRoIGRpZCB5b3UgZGVjb2RlIHRoaXM/".into(), - ) - }; - let devices = vec![device]; - - let notice = Notification { - event_id: Some(eid), - room_id: Some(rid), - event_type: Some(TimelineEventType::RoomMessage), - sender: Some(uid), - sender_display_name: Some("Major Tom".to_owned()), - room_alias: Some(alias), - content: Some(serde_json::from_str("{}").unwrap()), - counts: count, - prio: NotificationPriority::Low, - devices, - ..Notification::default() - }; - - assert_eq!(expected, to_json_value(notice).unwrap()); - } - } -} diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml index 4f6cdd11..489c2f39 100644 --- a/crates/ruma/Cargo.toml +++ b/crates/ruma/Cargo.toml @@ -60,10 +60,6 @@ identity-service-api-s = [ ] identity-service-api = ["identity-service-api-c", "identity-service-api-s"] -push-gateway-api-c = ["api", "dep:ruma-push-gateway-api", "ruma-push-gateway-api?/client"] -push-gateway-api-s = ["api", "dep:ruma-push-gateway-api", "ruma-push-gateway-api?/server"] -push-gateway-api = ["push-gateway-api-c", "push-gateway-api-s"] - # Required for randomness, current system time in browser environments js = ["ruma-common/js"] @@ -84,7 +80,6 @@ full = [ "client-api", "federation-api", "identity-service-api", - "push-gateway-api", "rand", "markdown", "html", @@ -162,7 +157,6 @@ unstable-exhaustive-types = [ "ruma-client-api?/unstable-exhaustive-types", "ruma-federation-api?/unstable-exhaustive-types", "ruma-identity-service-api?/unstable-exhaustive-types", - "ruma-push-gateway-api?/unstable-exhaustive-types", "ruma-state-res?/unstable-exhaustive-types", "ruma-events?/unstable-exhaustive-types" ] @@ -218,7 +212,6 @@ unstable-pdu = ["ruma-events?/unstable-pdu"] unstable-unspecified = [ "ruma-common/unstable-unspecified", "ruma-federation-api?/unstable-unspecified", - "ruma-push-gateway-api?/unstable-unspecified", ] # Private feature, only used in test / benchmarking code @@ -279,7 +272,6 @@ ruma-appservice-api = { workspace = true, optional = true } ruma-client-api = { workspace = true, optional = true } ruma-federation-api = { workspace = true, optional = true } ruma-identity-service-api = { workspace = true, optional = true } -ruma-push-gateway-api = { workspace = true, optional = true } [dev-dependencies] serde = { workspace = true }