feat(notification): Add support for custom conditional push rules (#4587)

---
Signed-off-by: Jonas Richard Richter <jonas-richard.richter@telekom.de>
This commit is contained in:
Jonas Richard Richter
2025-03-19 12:50:02 +01:00
committed by GitHub
parent 1d9d4d3b3a
commit dbdbfd0b38
7 changed files with 511 additions and 15 deletions

View File

@@ -63,6 +63,7 @@ Additions:
- Add `Encryption::get_user_identity` which returns `UserIdentity`
- Add `ClientBuilder::room_key_recipient_strategy`
- Add `Room::send_raw`
- Add `NotificationSettings::set_custom_push_rule`
- Expose `withdraw_verification` to `UserIdentity`
- Expose `report_room` to `Room`
- Add `RoomInfo::encryption_state`

View File

@@ -10,13 +10,350 @@ use matrix_sdk::{
Client as MatrixClient,
};
use ruma::{
push::{PredefinedOverrideRuleId, PredefinedUnderrideRuleId, RuleKind},
RoomId,
push::{
Action as SdkAction, ComparisonOperator as SdkComparisonOperator, PredefinedOverrideRuleId,
PredefinedUnderrideRuleId, PushCondition as SdkPushCondition, RoomMemberCountIs,
RuleKind as SdkRuleKind, ScalarJsonValue as SdkJsonValue, Tweak as SdkTweak,
},
Int, RoomId, UInt,
};
use tokio::sync::RwLock as AsyncRwLock;
use crate::error::NotificationSettingsError;
#[derive(Clone, Default, uniffi::Enum)]
pub enum ComparisonOperator {
/// Equals
#[default]
Eq,
/// Less than
Lt,
/// Greater than
Gt,
/// Greater or equal
Ge,
/// Less or equal
Le,
}
impl From<SdkComparisonOperator> for ComparisonOperator {
fn from(value: SdkComparisonOperator) -> Self {
match value {
SdkComparisonOperator::Eq => Self::Eq,
SdkComparisonOperator::Lt => Self::Lt,
SdkComparisonOperator::Gt => Self::Gt,
SdkComparisonOperator::Ge => Self::Ge,
SdkComparisonOperator::Le => Self::Le,
}
}
}
impl From<ComparisonOperator> for SdkComparisonOperator {
fn from(value: ComparisonOperator) -> Self {
match value {
ComparisonOperator::Eq => Self::Eq,
ComparisonOperator::Lt => Self::Lt,
ComparisonOperator::Gt => Self::Gt,
ComparisonOperator::Ge => Self::Ge,
ComparisonOperator::Le => Self::Le,
}
}
}
#[derive(Debug, Clone, Default, uniffi::Enum)]
pub enum JsonValue {
/// Represents a `null` value.
#[default]
Null,
/// Represents a boolean.
Bool { value: bool },
/// Represents an integer.
Integer { value: i64 },
/// Represents a string.
String { value: String },
}
impl From<SdkJsonValue> for JsonValue {
fn from(value: SdkJsonValue) -> Self {
match value {
SdkJsonValue::Null => Self::Null,
SdkJsonValue::Bool(b) => Self::Bool { value: b },
SdkJsonValue::Integer(i) => Self::Integer { value: i.into() },
SdkJsonValue::String(s) => Self::String { value: s },
}
}
}
impl From<JsonValue> for SdkJsonValue {
fn from(value: JsonValue) -> Self {
match value {
JsonValue::Null => Self::Null,
JsonValue::Bool { value } => Self::Bool(value),
JsonValue::Integer { value } => Self::Integer(Int::new(value).unwrap_or_default()),
JsonValue::String { value } => Self::String(value),
}
}
}
#[derive(Clone, uniffi::Enum)]
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 { prefix: ComparisonOperator, count: u64 },
/// 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,
},
/// 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: JsonValue,
},
/// 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: JsonValue,
},
}
impl TryFrom<SdkPushCondition> for PushCondition {
type Error = ();
fn try_from(value: SdkPushCondition) -> Result<Self, Self::Error> {
Ok(match value {
SdkPushCondition::EventMatch { key, pattern } => Self::EventMatch { key, pattern },
SdkPushCondition::ContainsDisplayName => Self::ContainsDisplayName,
SdkPushCondition::RoomMemberCount { is } => {
Self::RoomMemberCount { prefix: is.prefix.into(), count: is.count.into() }
}
SdkPushCondition::SenderNotificationPermission { key } => {
Self::SenderNotificationPermission { key }
}
SdkPushCondition::EventPropertyIs { key, value } => {
Self::EventPropertyIs { key, value: value.into() }
}
SdkPushCondition::EventPropertyContains { key, value } => {
Self::EventPropertyContains { key, value: value.into() }
}
_ => return Err(()),
})
}
}
impl From<PushCondition> for SdkPushCondition {
fn from(value: PushCondition) -> Self {
match value {
PushCondition::EventMatch { key, pattern } => Self::EventMatch { key, pattern },
PushCondition::ContainsDisplayName => Self::ContainsDisplayName,
PushCondition::RoomMemberCount { prefix, count } => Self::RoomMemberCount {
is: RoomMemberCountIs {
prefix: prefix.into(),
count: UInt::new(count).unwrap_or_default(),
},
},
PushCondition::SenderNotificationPermission { key } => {
Self::SenderNotificationPermission { key }
}
PushCondition::EventPropertyIs { key, value } => {
Self::EventPropertyIs { key, value: value.into() }
}
PushCondition::EventPropertyContains { key, value } => {
Self::EventPropertyContains { key, value: value.into() }
}
}
}
}
#[derive(Clone, uniffi::Enum)]
pub enum RuleKind {
/// User-configured rules that override all other kinds.
Override,
/// Lowest priority user-defined rules.
Underride,
/// Sender-specific rules.
Sender,
/// Room-specific rules.
Room,
/// Content-specific rules.
Content,
Custom {
value: String,
},
}
impl From<SdkRuleKind> for RuleKind {
fn from(value: SdkRuleKind) -> Self {
match value {
SdkRuleKind::Override => Self::Override,
SdkRuleKind::Underride => Self::Underride,
SdkRuleKind::Sender => Self::Sender,
SdkRuleKind::Room => Self::Room,
SdkRuleKind::Content => Self::Content,
SdkRuleKind::_Custom(_) => Self::Custom { value: value.as_str().to_owned() },
_ => Self::Custom { value: value.to_string() },
}
}
}
impl From<RuleKind> for SdkRuleKind {
fn from(value: RuleKind) -> Self {
match value {
RuleKind::Override => Self::Override,
RuleKind::Underride => Self::Underride,
RuleKind::Sender => Self::Sender,
RuleKind::Room => Self::Room,
RuleKind::Content => Self::Content,
RuleKind::Custom { value } => SdkRuleKind::from(value),
}
}
}
#[derive(Clone, uniffi::Enum)]
/// Enum representing the push notification tweaks for a rule.
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 { value: String },
/// A boolean representing whether or not this message should be highlighted
/// in the UI.
Highlight { value: bool },
/// A custom tweak
Custom {
/// The name of the custom tweak (`set_tweak` field)
name: String,
/// The value of the custom tweak as an encoded JSON string
value: String,
},
}
impl TryFrom<SdkTweak> for Tweak {
type Error = String;
fn try_from(value: SdkTweak) -> Result<Self, Self::Error> {
Ok(match value {
SdkTweak::Sound(sound) => Self::Sound { value: sound },
SdkTweak::Highlight(highlight) => Self::Highlight { value: highlight },
SdkTweak::Custom { name, value } => {
let json_string = serde_json::to_string(&value)
.map_err(|e| format!("Failed to serialize custom tweak value: {}", e))?;
Self::Custom { name, value: json_string }
}
_ => return Err("Unsupported tweak type".to_owned()),
})
}
}
impl TryFrom<Tweak> for SdkTweak {
type Error = String;
fn try_from(value: Tweak) -> Result<Self, Self::Error> {
Ok(match value {
Tweak::Sound { value } => Self::Sound(value),
Tweak::Highlight { value } => Self::Highlight(value),
Tweak::Custom { name, value } => {
let json_value: serde_json::Value = serde_json::from_str(&value)
.map_err(|e| format!("Failed to deserialize custom tweak value: {}", e))?;
let value = serde_json::from_value(json_value)
.map_err(|e| format!("Failed to convert JSON value: {}", e))?;
Self::Custom { name, value }
}
})
}
}
#[derive(Clone, uniffi::Enum)]
/// Enum representing the push notification actions for a rule.
pub enum Action {
/// Causes matching events to generate a notification.
Notify,
/// Sets an entry in the 'tweaks' dictionary sent to the push gateway.
SetTweak { value: Tweak },
}
impl TryFrom<SdkAction> for Action {
type Error = String;
fn try_from(value: SdkAction) -> Result<Self, Self::Error> {
Ok(match value {
SdkAction::Notify => Self::Notify,
SdkAction::SetTweak(tweak) => Self::SetTweak {
value: tweak.try_into().map_err(|e| format!("Failed to convert tweak: {}", e))?,
},
_ => return Err("Unsupported action type".to_owned()),
})
}
}
impl TryFrom<Action> for SdkAction {
type Error = String;
fn try_from(value: Action) -> Result<Self, Self::Error> {
Ok(match value {
Action::Notify => Self::Notify,
Action::SetTweak { value } => Self::SetTweak(
value.try_into().map_err(|e| format!("Failed to convert tweak: {}", e))?,
),
})
}
}
/// Enum representing the push notification modes for a room.
#[derive(Clone, uniffi::Enum)]
pub enum RoomNotificationMode {
@@ -267,7 +604,7 @@ impl NotificationSettings {
pub async fn is_room_mention_enabled(&self) -> Result<bool, NotificationSettingsError> {
let notification_settings = self.sdk_notification_settings.read().await;
let enabled = notification_settings
.is_push_rule_enabled(RuleKind::Override, PredefinedOverrideRuleId::IsRoomMention)
.is_push_rule_enabled(SdkRuleKind::Override, PredefinedOverrideRuleId::IsRoomMention)
.await?;
Ok(enabled)
}
@@ -280,7 +617,7 @@ impl NotificationSettings {
let notification_settings = self.sdk_notification_settings.read().await;
notification_settings
.set_push_rule_enabled(
RuleKind::Override,
SdkRuleKind::Override,
PredefinedOverrideRuleId::IsRoomMention,
enabled,
)
@@ -292,7 +629,7 @@ impl NotificationSettings {
pub async fn is_user_mention_enabled(&self) -> Result<bool, NotificationSettingsError> {
let notification_settings = self.sdk_notification_settings.read().await;
let enabled = notification_settings
.is_push_rule_enabled(RuleKind::Override, PredefinedOverrideRuleId::IsUserMention)
.is_push_rule_enabled(SdkRuleKind::Override, PredefinedOverrideRuleId::IsUserMention)
.await?;
Ok(enabled)
}
@@ -304,14 +641,14 @@ impl NotificationSettings {
let notification_settings = self.sdk_notification_settings.read().await;
// Check stable identifier
if let Ok(enabled) = notification_settings
.is_push_rule_enabled(RuleKind::Override, ".m.rule.encrypted_event")
.is_push_rule_enabled(SdkRuleKind::Override, ".m.rule.encrypted_event")
.await
{
enabled
} else {
// Check unstable identifier
notification_settings
.is_push_rule_enabled(RuleKind::Override, ".org.matrix.msc4028.encrypted_event")
.is_push_rule_enabled(SdkRuleKind::Override, ".org.matrix.msc4028.encrypted_event")
.await
.unwrap_or(false)
}
@@ -332,7 +669,7 @@ impl NotificationSettings {
let notification_settings = self.sdk_notification_settings.read().await;
notification_settings
.set_push_rule_enabled(
RuleKind::Override,
SdkRuleKind::Override,
PredefinedOverrideRuleId::IsUserMention,
enabled,
)
@@ -344,7 +681,7 @@ impl NotificationSettings {
pub async fn is_call_enabled(&self) -> Result<bool, NotificationSettingsError> {
let notification_settings = self.sdk_notification_settings.read().await;
let enabled = notification_settings
.is_push_rule_enabled(RuleKind::Underride, PredefinedUnderrideRuleId::Call)
.is_push_rule_enabled(SdkRuleKind::Underride, PredefinedUnderrideRuleId::Call)
.await?;
Ok(enabled)
}
@@ -353,7 +690,7 @@ impl NotificationSettings {
pub async fn set_call_enabled(&self, enabled: bool) -> Result<(), NotificationSettingsError> {
let notification_settings = self.sdk_notification_settings.read().await;
notification_settings
.set_push_rule_enabled(RuleKind::Underride, PredefinedUnderrideRuleId::Call, enabled)
.set_push_rule_enabled(SdkRuleKind::Underride, PredefinedUnderrideRuleId::Call, enabled)
.await?;
Ok(())
}
@@ -363,7 +700,7 @@ impl NotificationSettings {
let notification_settings = self.sdk_notification_settings.read().await;
let enabled = notification_settings
.is_push_rule_enabled(
RuleKind::Override,
SdkRuleKind::Override,
PredefinedOverrideRuleId::InviteForMe.as_str(),
)
.await?;
@@ -378,7 +715,7 @@ impl NotificationSettings {
let notification_settings = self.sdk_notification_settings.read().await;
notification_settings
.set_push_rule_enabled(
RuleKind::Override,
SdkRuleKind::Override,
PredefinedOverrideRuleId::InviteForMe.as_str(),
enabled,
)
@@ -386,6 +723,30 @@ impl NotificationSettings {
Ok(())
}
/// Sets a custom push rule with the given actions and conditions.
pub async fn set_custom_push_rule(
&self,
rule_id: String,
rule_kind: RuleKind,
actions: Vec<Action>,
conditions: Vec<PushCondition>,
) -> Result<(), NotificationSettingsError> {
let notification_settings = self.sdk_notification_settings.read().await;
let actions: Result<Vec<_>, _> =
actions.into_iter().map(|action| action.try_into()).collect();
let actions = actions.map_err(|e| NotificationSettingsError::Generic { msg: e })?;
notification_settings
.create_custom_conditional_push_rule(
rule_id,
rule_kind.into(),
actions,
conditions.into_iter().map(|condition| condition.into()).collect(),
)
.await?;
Ok(())
}
/// Unmute a room.
///
/// # Arguments

View File

@@ -204,11 +204,17 @@ simpler methods:
- Enable HTTP/2 support in the HTTP client.
([#4566](https://github.com/matrix-org/matrix-rust-sdk/pull/4566))
- Add support for creating custom conditional push rules in `NotificationSettings::create_custom_conditional_push_rule`.
([#4587](https://github.com/matrix-org/matrix-rust-sdk/pull/4587))
- The media contents stored in the media cache can now be controlled with a
`MediaRetentionPolicy` and the new `Media` methods `media_retention_policy()`,
`set_media_retention_policy()`, `clean_up_media_cache()`.
([#4571](https://github.com/matrix-org/matrix-rust-sdk/pull/4571))
- Add support for creating custom conditional push rules in `NotificationSettings::create_custom_conditional_push_rule`.
([#4587](https://github.com/matrix-org/matrix-rust-sdk/pull/4587))
### Refactor
- [**breaking**]: The `RoomEventCacheUpdate::Clear` variant has been removed, as

View File

@@ -25,6 +25,8 @@ pub(crate) enum Command {
DeletePushRule { kind: RuleKind, rule_id: String },
/// Set a list of actions
SetPushRuleActions { kind: RuleKind, rule_id: String, actions: Vec<Action> },
/// Sets a custom push rule
SetCustomPushRule { rule: NewPushRule },
}
fn get_notify_actions(notify: bool) -> Vec<Action> {
@@ -73,6 +75,8 @@ impl Command {
| Self::SetPushRuleActions { .. } => Err(NotificationSettingsError::InvalidParameter(
"cannot create a push rule from this command.".to_owned(),
)),
Self::SetCustomPushRule { rule } => Ok(rule.clone()),
}
}
}

View File

@@ -22,7 +22,7 @@ use ruma::{
delete_pushrule, set_pushrule, set_pushrule_actions, set_pushrule_enabled,
},
events::push_rules::PushRulesEvent,
push::{Action, PredefinedUnderrideRuleId, RuleKind, Ruleset, Tweak},
push::{Action, NewPushRule, PredefinedUnderrideRuleId, RuleKind, Ruleset, Tweak},
RoomId,
};
use tokio::sync::{
@@ -265,6 +265,44 @@ impl NotificationSettings {
Ok(())
}
/// Create a custom conditional push rule.
///
/// # Arguments
///
/// * `rule_id` - The identifier of the push rule.
/// * `rule_kind` - The kind of the push rule.
/// * `actions` - The actions to set for the push rule.
/// * `conditions` - The conditions for the push rule.
///
/// See more in the matrix spec: <https://spec.matrix.org/latest/client-server-api/#push-rules>
pub async fn create_custom_conditional_push_rule(
&self,
rule_id: String,
rule_kind: RuleKind,
actions: Vec<Action>,
conditions: Vec<ruma::push::PushCondition>,
) -> Result<(), NotificationSettingsError> {
let new_conditional_rule =
ruma::push::NewConditionalPushRule::new(rule_id, conditions, actions);
let new_push_rule = match rule_kind {
RuleKind::Override => NewPushRule::Override(new_conditional_rule),
RuleKind::Underride => NewPushRule::Underride(new_conditional_rule),
_ => return Err(NotificationSettingsError::InvalidParameter("rule_kind".to_owned())),
};
let rules = self.rules.read().await.clone();
let mut rule_commands = RuleCommands::new(rules.clone().ruleset);
rule_commands.insert_custom_rule(new_push_rule)?;
self.run_server_commands(&rule_commands).await?;
let rules = &mut *self.rules.write().await;
rules.apply(rule_commands);
Ok(())
}
/// Set the notification mode for a room.
pub async fn set_room_notification_mode(
&self,
@@ -515,6 +553,16 @@ impl NotificationSettings {
},
)?;
}
Command::SetCustomPushRule { rule } => {
let request = set_pushrule::v3::Request::new(rule.clone());
self.client.send(request).with_request_config(request_config).await.map_err(
|error| {
error!("Unable to set custom push rule `{rule:#?}`: {error}");
NotificationSettingsError::UnableToAddPushRule
},
)?;
}
}
}
Ok(())
@@ -1574,4 +1622,64 @@ mod tests {
RoomNotificationMode::MentionsAndKeywordsOnly
);
}
#[async_test]
async fn test_create_custom_conditional_push_rule() {
let server = MockServer::start().await;
let client = logged_in_client(Some(server.uri())).await;
let settings = client.notification_settings().await;
Mock::given(method("PUT"))
.and(path("/_matrix/client/r0/pushrules/global/override/custom_rule"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let actions = vec![Action::Notify];
let conditions = vec![ruma::push::PushCondition::EventMatch {
key: "content.body".to_owned(),
pattern: "hello".to_owned(),
}];
settings
.create_custom_conditional_push_rule(
"custom_rule".to_owned(),
RuleKind::Override,
actions.clone(),
conditions.clone(),
)
.await
.unwrap();
let rules = settings.rules.read().await;
let rule = rules.ruleset.get(RuleKind::Override, "custom_rule").unwrap();
assert_eq!(rule.rule_id(), "custom_rule");
assert!(rule.enabled());
}
#[async_test]
async fn test_create_custom_conditional_push_rule_invalid_kind() {
let server = MockServer::start().await;
let client = logged_in_client(Some(server.uri())).await;
let settings = client.notification_settings().await;
let actions = vec![Action::Notify];
let conditions = vec![ruma::push::PushCondition::EventMatch {
key: "content.body".to_owned(),
pattern: "hello".to_owned(),
}];
let result = settings
.create_custom_conditional_push_rule(
"custom_rule".to_owned(),
RuleKind::Room,
actions,
conditions,
)
.await;
assert_matches!(result, Err(NotificationSettingsError::InvalidParameter(_)));
}
}

View File

@@ -1,7 +1,7 @@
use ruma::{
push::{
Action, PredefinedContentRuleId, PredefinedOverrideRuleId, RemovePushRuleError, RuleKind,
Ruleset,
Action, NewPushRule, PredefinedContentRuleId, PredefinedOverrideRuleId,
RemovePushRuleError, RuleKind, Ruleset,
},
RoomId,
};
@@ -62,6 +62,19 @@ impl RuleCommands {
Ok(())
}
/// Insert a new custom rule
pub(crate) fn insert_custom_rule(
&mut self,
rule: NewPushRule,
) -> Result<(), NotificationSettingsError> {
let command = Command::SetCustomPushRule { rule: rule.clone() };
self.rules.insert(rule, None, None)?;
self.commands.push(command);
Ok(())
}
/// Delete a rule
pub(crate) fn delete_rule(
&mut self,

View File

@@ -269,6 +269,9 @@ impl Rules {
Command::SetPushRuleActions { kind, rule_id, actions } => {
_ = self.ruleset.set_actions(kind, rule_id, actions);
}
Command::SetCustomPushRule { rule } => {
_ = self.ruleset.insert(rule, None, None);
}
}
}
}