diff --git a/bindings/matrix-sdk-ffi/CHANGELOG.md b/bindings/matrix-sdk-ffi/CHANGELOG.md index c7703df78..73a8ec3aa 100644 --- a/bindings/matrix-sdk-ffi/CHANGELOG.md +++ b/bindings/matrix-sdk-ffi/CHANGELOG.md @@ -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` diff --git a/bindings/matrix-sdk-ffi/src/notification_settings.rs b/bindings/matrix-sdk-ffi/src/notification_settings.rs index fc1b7fbb8..cb2dbff9c 100644 --- a/bindings/matrix-sdk-ffi/src/notification_settings.rs +++ b/bindings/matrix-sdk-ffi/src/notification_settings.rs @@ -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 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 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 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 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 for PushCondition { + type Error = (); + + fn try_from(value: SdkPushCondition) -> Result { + 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 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 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 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 for Tweak { + type Error = String; + + fn try_from(value: SdkTweak) -> Result { + 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 for SdkTweak { + type Error = String; + + fn try_from(value: Tweak) -> Result { + 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 for Action { + type Error = String; + + fn try_from(value: SdkAction) -> Result { + 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 for SdkAction { + type Error = String; + + fn try_from(value: Action) -> Result { + 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 { 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 { 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 { 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, + conditions: Vec, + ) -> Result<(), NotificationSettingsError> { + let notification_settings = self.sdk_notification_settings.read().await; + let actions: Result, _> = + 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 diff --git a/crates/matrix-sdk/CHANGELOG.md b/crates/matrix-sdk/CHANGELOG.md index 14136d2f5..0c3c1c7c7 100644 --- a/crates/matrix-sdk/CHANGELOG.md +++ b/crates/matrix-sdk/CHANGELOG.md @@ -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 diff --git a/crates/matrix-sdk/src/notification_settings/command.rs b/crates/matrix-sdk/src/notification_settings/command.rs index f73ce30fe..65ce37b71 100644 --- a/crates/matrix-sdk/src/notification_settings/command.rs +++ b/crates/matrix-sdk/src/notification_settings/command.rs @@ -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 }, + /// Sets a custom push rule + SetCustomPushRule { rule: NewPushRule }, } fn get_notify_actions(notify: bool) -> Vec { @@ -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()), } } } diff --git a/crates/matrix-sdk/src/notification_settings/mod.rs b/crates/matrix-sdk/src/notification_settings/mod.rs index dfb036bf1..f02e66c9d 100644 --- a/crates/matrix-sdk/src/notification_settings/mod.rs +++ b/crates/matrix-sdk/src/notification_settings/mod.rs @@ -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: + pub async fn create_custom_conditional_push_rule( + &self, + rule_id: String, + rule_kind: RuleKind, + actions: Vec, + conditions: Vec, + ) -> 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(_))); + } } diff --git a/crates/matrix-sdk/src/notification_settings/rule_commands.rs b/crates/matrix-sdk/src/notification_settings/rule_commands.rs index 47956c412..126e30011 100644 --- a/crates/matrix-sdk/src/notification_settings/rule_commands.rs +++ b/crates/matrix-sdk/src/notification_settings/rule_commands.rs @@ -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, diff --git a/crates/matrix-sdk/src/notification_settings/rules.rs b/crates/matrix-sdk/src/notification_settings/rules.rs index 353af7b83..7a022c429 100644 --- a/crates/matrix-sdk/src/notification_settings/rules.rs +++ b/crates/matrix-sdk/src/notification_settings/rules.rs @@ -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); + } } } }