Rework push rules

This commit is contained in:
tezlm 2023-12-04 22:56:45 -08:00
parent e31cc955cc
commit fab9709be2
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
26 changed files with 205 additions and 5793 deletions

View file

@ -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" }

View file

@ -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<MilliSecondsSinceUnixEpoch>,
/// 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,
}
}
}

View file

@ -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<String>,
/// Whether to enable notifications for this device
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_push: Option<bool>,
}
/// 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 }
}
}

View file

@ -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<Action>,
/// 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<Vec<PushCondition>>,
/// The glob-style pattern to match against.
///
/// Only applicable to content rules.
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
}
impl<T> From<SimplePushRule<T>> for PushRule
where
T: Into<String>,
{
fn from(push_rule: SimplePushRule<T>) -> 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<PatternedPushRule> 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<ConditionalPushRule> 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<T> From<SimplePushRuleInit<T>> for PushRule
where
T: Into<String>,
{
fn from(init: SimplePushRuleInit<T>) -> 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<ConditionalPushRuleInit> 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<PatternedPushRuleInit> 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<AnyPushRule> 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<AnyPushRuleRef<'a>> for PushRule {
fn from(push_rule: AnyPushRuleRef<'a>) -> Self {
push_rule.to_owned().into()
}
}
impl<T> TryFrom<PushRule> for SimplePushRule<T>
where
T: TryFrom<String>,
{
type Error = <T as TryFrom<String>>::Error;
fn try_from(push_rule: PushRule) -> Result<Self, Self::Error> {
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<PushRule> for PatternedPushRule {
type Error = MissingPatternError;
fn try_from(push_rule: PushRule) -> Result<Self, Self::Error> {
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<PushRule> 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),
}

View file

@ -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<String>,
/// The list of threads mention events are in
pub threads: Vec<Notification>,
pub threads: Vec<Raw<AnyTimelineEvent>>,
/// The list of thread and notification events
pub chunk: Vec<Notification>,
@ -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.

View file

@ -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<Ack>,
}
/// 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<Ack>) -> 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<OwnedEventId>,
/// The last read event id. Set as empty to use the last event.
pub event_id: Option<OwnedEventId>,
}
}

View file

@ -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<UInt>,
pub last_ack: Option<OwnedEventId>,
/// 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<UInt>,
pub mention_user: Option<UInt>,
/// 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<UInt>,
/// The total number of unread notifications since the last ack.
#[serde(skip_serializing_if = "Option::is_none")]
pub notify: Option<UInt>,
/// The total number of unread messages since the last ack.
#[serde(skip_serializing_if = "Option::is_none")]
pub messages: Option<UInt>,
}
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()
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<RawJsonValue>,
},
}
impl<'de> Deserialize<'de> for Action {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let json = Box::<RawJsonValue>::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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<String, JsonValue>),
}
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<RawJsonValue>,
},
}
#[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<super::Tweak> 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<Tweak> 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::<Action>(json!("notify")), Ok(Action::Notify));
}
#[test]
fn deserialize_tweak_sound() {
let json_data = json!({
"set_tweak": "sound",
"value": "default"
});
assert_matches!(
from_json_value::<Action>(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::<Action>(json_data),
Ok(Action::SetTweak(Tweak::Highlight(true)))
);
}
#[test]
fn deserialize_tweak_highlight_with_default_value() {
assert_matches!(
from_json_value::<Action>(json!({ "set_tweak": "highlight" })),
Ok(Action::SetTweak(Tweak::Highlight(true)))
);
}
}

View file

@ -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<Self> {
match version {
RoomVersionId::V1
| RoomVersionId::V2
| RoomVersionId::V3
| RoomVersionId::V4
| RoomVersionId::V5
| RoomVersionId::V6
| RoomVersionId::V7
| RoomVersionId::V8
| RoomVersionId::V9
| RoomVersionId::V10
| RoomVersionId::V11
| RoomVersionId::_Custom(_) => vec![],
}
}
}
/// 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<String, JsonValue>,
}
/// 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<OwnedUserId, Int>,
/// The default power level of the users of the room.
pub default_power_level: Int,
/// The notification power levels of the room.
pub notification_power_levels: NotificationPowerLevels,
#[cfg(feature = "unstable-msc3931")]
/// The list of features this room's version or the room itself supports.
pub supported_features: Vec<RoomVersionFeature>,
}
/// 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<char>;
/// 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<char> {
if index == 0 {
return None;
}
let mut pos = index - 1;
while !self.is_char_boundary(pos) {
pos -= 1;
}
Some(self.char_at(pos))
}
fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool {
let value = &self.to_lowercase();
let pattern = &pattern.to_lowercase();
if match_words {
value.matches_word(pattern)
} else {
WildMatch::new(pattern).matches(value)
}
}
fn matches_word(&self, pattern: &str) -> bool {
if self == pattern {
return true;
}
if pattern.is_empty() {
return false;
}
let has_wildcards = pattern.contains(|c| matches!(c, '?' | '*'));
if has_wildcards {
let mut chunks: Vec<String> = vec![];
let mut prev_wildcard = false;
let mut chunk_start = 0;
for (i, c) in pattern.char_indices() {
if matches!(c, '?' | '*') && !prev_wildcard {
if i != 0 {
chunks.push(regex::escape(&pattern[chunk_start..i]));
chunk_start = i;
}
prev_wildcard = true;
} else if prev_wildcard {
let chunk = &pattern[chunk_start..i];
chunks.push(chunk.wildcards_to_regex());
chunk_start = i;
prev_wildcard = false;
}
}
let len = pattern.len();
if !prev_wildcard {
chunks.push(regex::escape(&pattern[chunk_start..len]));
} else if prev_wildcard {
let chunk = &pattern[chunk_start..len];
chunks.push(chunk.wildcards_to_regex());
}
// 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(&regex).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::<PushCondition>(json_data).unwrap(),
PushCondition::EventMatch { key, pattern }
);
assert_eq!(key, "content.msgtype");
assert_eq!(pattern, "m.notice");
}
#[test]
fn deserialize_contains_display_name_condition() {
assert_matches!(
from_json_value::<PushCondition>(json!({ "kind": "contains_display_name" })).unwrap(),
PushCondition::ContainsDisplayName
);
}
#[test]
fn deserialize_room_member_count_condition() {
let json_data = json!({
"is": "2",
"kind": "room_member_count"
});
assert_matches!(
from_json_value::<PushCondition>(json_data).unwrap(),
PushCondition::RoomMemberCount { is }
);
assert_eq!(is, RoomMemberCountIs::from(uint!(2)));
}
#[test]
fn deserialize_sender_notification_permission_condition() {
let json_data = json!({
"key": "room",
"kind": "sender_notification_permission"
});
assert_matches!(
from_json_value::<PushCondition>(json_data).unwrap(),
PushCondition::SenderNotificationPermission { key }
);
assert_eq!(key, "room");
}
#[test]
fn words_match() {
assert!("foo bar".matches_word("foo"));
assert!(!"Foo bar".matches_word("foo"));
assert!(!"foobar".matches_word("foo"));
assert!("foobar foo".matches_word("foo"));
assert!(!"foobar foobar".matches_word("foo"));
assert!(!"foobar bar".matches_word("bar bar"));
assert!("foobar bar bar".matches_word("bar bar"));
assert!(!"foobar bar barfoo".matches_word("bar bar"));
assert!("conduit ⚡️".matches_word("conduit ⚡️"));
assert!("conduit ⚡️".matches_word("conduit"));
assert!("conduit ⚡️".matches_word("⚡️"));
assert!("conduit⚡".matches_word("conduit"));
assert!("conduit⚡".matches_word("⚡️"));
assert!("conduit".matches_word("conduit"));
assert!("conduit".matches_word("⚡️"));
assert!("Ruma Dev👩💻".matches_word("Dev"));
assert!("Ruma Dev👩💻".matches_word("👩‍💻"));
assert!("Ruma Dev👩💻".matches_word("Dev👩💻"));
// 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: <https://spec.matrix.org/v1.9/client-server-api/#conditions-1>
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: <https://spec.matrix.org/v1.9/client-server-api/#conditions-1>
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::<Raw<JsonValue>>(
r#"{
"sender": "@worthy_whale:server.name",
"content": {
"msgtype": "m.text",
"body": "@room Give a warm welcome to Groovy Gorilla"
}
}"#,
)
.unwrap();
FlattenedJson::from_raw(&raw)
}
fn second_flattened_event() -> FlattenedJson {
let raw = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"sender": "@party_bot:server.name",
"content": {
"msgtype": "m.notice",
"body": "Everybody come to party!"
}
}"#,
)
.unwrap();
FlattenedJson::from_raw(&raw)
}
#[test]
fn event_match_applies() {
let context = push_context();
let first_event = first_flattened_event();
let second_event = second_flattened_event();
let correct_room = PushCondition::EventMatch {
key: "room_id".into(),
pattern: "!room:server.name".into(),
};
let incorrect_room = PushCondition::EventMatch {
key: "room_id".into(),
pattern: "!incorrect:server.name".into(),
};
assert!(correct_room.applies(&first_event, &context));
assert!(!incorrect_room.applies(&first_event, &context));
let keyword =
PushCondition::EventMatch { key: "content.body".into(), pattern: "come".into() };
assert!(!keyword.applies(&first_event, &context));
assert!(keyword.applies(&second_event, &context));
let msgtype =
PushCondition::EventMatch { key: "content.msgtype".into(), pattern: "m.notice".into() };
assert!(!msgtype.applies(&first_event, &context));
assert!(msgtype.applies(&second_event, &context));
}
#[test]
fn room_member_count_is_applies() {
let context = push_context();
let event = first_flattened_event();
let member_count_eq =
PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(3)) };
let member_count_gt =
PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(2)..) };
let member_count_lt =
PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(..uint!(3)) };
assert!(member_count_eq.applies(&event, &context));
assert!(member_count_gt.applies(&event, &context));
assert!(!member_count_lt.applies(&event, &context));
}
#[test]
fn contains_display_name_applies() {
let context = push_context();
let first_event = first_flattened_event();
let second_event = second_flattened_event();
let contains_display_name = PushCondition::ContainsDisplayName;
assert!(contains_display_name.applies(&first_event, &context));
assert!(!contains_display_name.applies(&second_event, &context));
}
#[test]
fn sender_notification_permission_applies() {
let context = push_context();
let first_event = first_flattened_event();
let second_event = second_flattened_event();
let sender_notification_permission =
PushCondition::SenderNotificationPermission { key: "room".into() };
assert!(!sender_notification_permission.applies(&first_event, &context));
assert!(sender_notification_permission.applies(&second_event, &context));
}
#[cfg(feature = "unstable-msc3932")]
#[test]
fn room_version_supports_applies() {
let context_not_matching = push_context();
let context_matching = PushConditionRoomCtx {
room_id: owned_room_id!("!room:server.name"),
member_count: uint!(3),
user_id: owned_user_id!("@gorilla:server.name"),
user_display_name: "Groovy Gorilla".into(),
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::<Raw<JsonValue>>(
r#"{
"sender": "@worthy_whale:server.name",
"content": {
"msgtype": "org.matrix.msc3932.extensible_events",
"body": "@room Give a warm welcome to Groovy Gorilla"
}
}"#,
)
.unwrap();
let simple_event = FlattenedJson::from_raw(&simple_event_raw);
let room_version_condition = PushCondition::RoomVersionSupports {
feature: super::RoomVersionFeature::ExtensibleEvents,
};
assert!(room_version_condition.applies(&simple_event, &context_matching));
assert!(!room_version_condition.applies(&simple_event, &context_not_matching));
}
#[test]
fn event_property_is_applies() {
use crate::push::condition::ScalarJsonValue;
let context = push_context();
let event_raw = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"sender": "@worthy_whale:server.name",
"content": {
"msgtype": "m.text",
"body": "Boom!",
"org.fake.boolean": false,
"org.fake.number": 13,
"org.fake.null": null
}
}"#,
)
.unwrap();
let event = FlattenedJson::from_raw(&event_raw);
let string_match = PushCondition::EventPropertyIs {
key: "content.body".to_owned(),
value: "Boom!".into(),
};
assert!(string_match.applies(&event, &context));
let string_no_match =
PushCondition::EventPropertyIs { key: "content.body".to_owned(), value: "Boom".into() };
assert!(!string_no_match.applies(&event, &context));
let wrong_type =
PushCondition::EventPropertyIs { key: "content.body".to_owned(), value: false.into() };
assert!(!wrong_type.applies(&event, &context));
let bool_match = PushCondition::EventPropertyIs {
key: r"content.org\.fake\.boolean".to_owned(),
value: false.into(),
};
assert!(bool_match.applies(&event, &context));
let bool_no_match = PushCondition::EventPropertyIs {
key: r"content.org\.fake\.boolean".to_owned(),
value: true.into(),
};
assert!(!bool_no_match.applies(&event, &context));
let int_match = PushCondition::EventPropertyIs {
key: r"content.org\.fake\.number".to_owned(),
value: int!(13).into(),
};
assert!(int_match.applies(&event, &context));
let int_no_match = PushCondition::EventPropertyIs {
key: r"content.org\.fake\.number".to_owned(),
value: int!(130).into(),
};
assert!(!int_no_match.applies(&event, &context));
let null_match = PushCondition::EventPropertyIs {
key: r"content.org\.fake\.null".to_owned(),
value: ScalarJsonValue::Null,
};
assert!(null_match.applies(&event, &context));
}
#[test]
fn event_property_contains_applies() {
use crate::push::condition::ScalarJsonValue;
let context = push_context();
let event_raw = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"sender": "@worthy_whale:server.name",
"content": {
"org.fake.array": ["Boom!", false, 13, null]
}
}"#,
)
.unwrap();
let event = FlattenedJson::from_raw(&event_raw);
let wrong_key =
PushCondition::EventPropertyContains { key: "send".to_owned(), value: false.into() };
assert!(!wrong_key.applies(&event, &context));
let string_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: "Boom!".into(),
};
assert!(string_match.applies(&event, &context));
let string_no_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: "Boom".into(),
};
assert!(!string_no_match.applies(&event, &context));
let bool_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: false.into(),
};
assert!(bool_match.applies(&event, &context));
let bool_no_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: true.into(),
};
assert!(!bool_no_match.applies(&event, &context));
let int_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: int!(13).into(),
};
assert!(int_match.applies(&event, &context));
let int_no_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: int!(130).into(),
};
assert!(!int_no_match.applies(&event, &context));
let null_match = PushCondition::EventPropertyContains {
key: r"content.org\.fake\.array".to_owned(),
value: ScalarJsonValue::Null,
};
assert!(null_match.applies(&event, &context));
}
}

View file

@ -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<String, FlattenedJsonValue>,
}
impl FlattenedJson {
/// Create a `FlattenedJson` from `Raw`.
pub fn from_raw<T>(raw: &Raw<T>) -> 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<Self, IntoJsonSubsetError> {
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<bool> {
as_variant!(self, Self::Bool).copied()
}
/// If the `ScalarJsonValue` is an `Integer`, return the inner value.
pub fn as_integer(&self) -> Option<Int> {
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let val = JsonValue::deserialize(deserializer)?;
ScalarJsonValue::try_from_json_value(val).map_err(serde::de::Error::custom)
}
}
impl From<bool> for ScalarJsonValue {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
impl From<Int> for ScalarJsonValue {
fn from(value: Int) -> Self {
Self::Integer(value)
}
}
impl From<String> 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<FlattenedJsonValue> 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<ScalarJsonValue>),
/// Represents an empty object.
EmptyObject,
}
impl FlattenedJsonValue {
fn from_json_value(val: JsonValue) -> Option<Self> {
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::<Vec<_>>(),
),
_ => None?,
})
}
/// If the `FlattenedJsonValue` is a `Bool`, return the inner value.
pub fn as_bool(&self) -> Option<bool> {
as_variant!(self, Self::Bool).copied()
}
/// If the `FlattenedJsonValue` is an `Integer`, return the inner value.
pub fn as_integer(&self) -> Option<Int> {
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<bool> for FlattenedJsonValue {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
impl From<Int> for FlattenedJsonValue {
fn from(value: Int) -> Self {
Self::Integer(value)
}
}
impl From<String> 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<Vec<ScalarJsonValue>> for FlattenedJsonValue {
fn from(value: Vec<ScalarJsonValue>) -> Self {
Self::Array(value)
}
}
impl PartialEq<ScalarJsonValue> 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::<Raw<JsonValue>>(
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::<Raw<JsonValue>>(
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::<Raw<JsonValue>>(
r#"{
"m.mentions": {},
"content": {
"body": "Text"
}
}"#,
)
.unwrap();
let flattened = FlattenedJson::from_raw(&raw);
assert!(!flattened.contains_mentions());
let raw = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"content": {
"body": "Text",
"m.mentions": {}
}
}"#,
)
.unwrap();
let flattened = FlattenedJson::from_raw(&raw);
assert!(flattened.contains_mentions());
let raw = serde_json::from_str::<Raw<JsonValue>>(
r#"{
"content": {
"body": "Text",
"m.mentions": {
"room": true
}
}
}"#,
)
.unwrap();
let flattened = FlattenedJson::from_raw(&raw);
assert!(flattened.contains_mentions());
}
}

View file

@ -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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
let json = Box::<RawJsonValue>::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<PushConditionSerDeHelper> 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<PushCondition> 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!(),
}
}
}

View file

@ -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 `<X`,
/// where X is the specified member count.
pub fn gt(count: UInt) -> Self {
RoomMemberCountIs { prefix: ComparisonOperator::Gt, count }
}
}
impl From<UInt> for RoomMemberCountIs {
fn from(x: UInt) -> Self {
RoomMemberCountIs { prefix: ComparisonOperator::Eq, count: x }
}
}
impl From<RangeFrom<UInt>> for RoomMemberCountIs {
fn from(x: RangeFrom<UInt>) -> Self {
RoomMemberCountIs { prefix: ComparisonOperator::Ge, count: x.start }
}
}
impl From<RangeTo<UInt>> for RoomMemberCountIs {
fn from(x: RangeTo<UInt>) -> Self {
RoomMemberCountIs { prefix: ComparisonOperator::Lt, count: x.end }
}
}
impl From<RangeToInclusive<UInt>> for RoomMemberCountIs {
fn from(x: RangeToInclusive<UInt>) -> 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<Self, Self::Err> {
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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = crate::serde::deserialize_cow_str(deserializer)?;
FromStr::from_str(&s).map_err(serde::de::Error::custom)
}
}
impl RangeBounds<UInt> 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));
}
}

View file

@ -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<OwnedRoomId>),
/// Sender-specific rules.
Sender(SimplePushRule<OwnedUserId>),
/// 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<PatternedPushRule>,
override_: IndexSetIntoIter<ConditionalPushRule>,
room: IndexSetIntoIter<SimplePushRule<OwnedRoomId>>,
sender: IndexSetIntoIter<SimplePushRule<OwnedUserId>>,
underride: IndexSetIntoIter<ConditionalPushRule>,
}
impl Iterator for RulesetIntoIter {
type Item = AnyPushRule;
fn next(&mut self) -> Option<Self::Item> {
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<OwnedRoomId>),
/// Sender-specific rules.
Sender(&'a SimplePushRule<OwnedUserId>),
/// 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<OwnedRoomId>>,
sender: IndexSetIter<'a, SimplePushRule<OwnedUserId>>,
underride: IndexSetIter<'a, ConditionalPushRule>,
}
impl<'a> Iterator for RulesetIter<'a> {
type Item = AnyPushRuleRef<'a>;
fn next(&mut self) -> Option<Self::Item> {
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(),
}
}
}

View file

@ -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 users 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);
}
}

View file

@ -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.

View file

@ -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;
}
}

View file

@ -1,3 +1,6 @@
//! mixins that are added to events
//! this will probably be removed
use std::collections::HashMap;
use js_int::UInt;

View file

@ -0,0 +1 @@

View file

@ -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<Ruleset> 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::<PushRulesEvent>(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,
}

View file

@ -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<RawJsonValue>` to `&RawJsonValue` in request types
* Upgrade public dependencies
# 0.0.1
* Add endpoint `send_event_notification::v1`

View file

@ -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 }

View file

@ -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.

View file

@ -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<str>` 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<str>);
impl fmt::Debug for PrivOwnedStr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}

View file

@ -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<String>,
}
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<String>) -> 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<OwnedEventId>,
/// 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<OwnedRoomId>,
/// 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<TimelineEventType>,
/// The sender of the event as in the corresponding event field.
#[serde(skip_serializing_if = "Option::is_none")]
pub sender: Option<OwnedUserId>,
/// 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<String>,
/// The name of the room in which the event occurred.
#[serde(skip_serializing_if = "Option::is_none")]
pub room_name: Option<String>,
/// An alias to display for the room in which the event occurred.
#[serde(skip_serializing_if = "Option::is_none")]
pub room_alias: Option<OwnedRoomAliasId>,
/// 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<Box<RawJsonValue>>,
/// 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<Device>,
}
impl Notification {
/// Create a new notification for the given devices.
pub fn new(devices: Vec<Device>) -> 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<SecondsSinceUnixEpoch>,
/// 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<Tweak>,
}
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<PushFormat>,
/// 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<ruma_common::push::HttpPusherData> 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<S>(tweak: &[Tweak], serializer: S) -> Result<S::Ok, S::Error>
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<Tweak>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("List of tweaks")
}
fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut tweaks = vec![];
while let Some(key) = access.next_key::<String>()? {
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<Vec<Tweak>, 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());
}
}
}

View file

@ -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 }