diff --git a/lib/matrix.dart b/lib/matrix.dart index cab9d87b..fe158c63 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -43,12 +43,13 @@ export 'src/utils/device_keys_list.dart'; export 'src/utils/event_update.dart'; export 'src/utils/http_timeout.dart'; export 'src/utils/image_pack_extension.dart'; +export 'src/utils/matrix_default_localizations.dart'; export 'src/utils/matrix_file.dart'; export 'src/utils/matrix_id_string_extension.dart'; -export 'src/utils/matrix_default_localizations.dart'; export 'src/utils/matrix_localizations.dart'; export 'src/utils/native_implementations.dart'; export 'src/utils/push_notification.dart'; +export 'src/utils/pushrule_evaluator.dart'; export 'src/utils/receipt.dart'; export 'src/utils/sync_update_extension.dart'; export 'src/utils/to_device_event.dart'; diff --git a/lib/src/client.dart b/lib/src/client.dart index 1500ee00..bc1c1bbb 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -34,6 +34,7 @@ import 'package:matrix/src/models/timeline_chunk.dart'; import 'package:matrix/src/utils/cached_stream_controller.dart'; import 'package:matrix/src/utils/compute_callback.dart'; import 'package:matrix/src/utils/multilock.dart'; +import 'package:matrix/src/utils/pushrule_evaluator.dart'; import 'package:matrix/src/utils/run_benchmarked.dart'; import 'package:matrix/src/utils/run_in_root.dart'; import 'package:matrix/src/utils/sync_update_item_count.dart'; @@ -278,6 +279,17 @@ class Client extends MatrixApi { Map get accountData => _accountData; + /// Evaluate if an event should notify quickly + PushruleEvaluator get pushruleEvaluator => + _pushruleEvaluator ?? PushruleEvaluator.fromRuleset(PushRuleSet()); + PushruleEvaluator? _pushruleEvaluator; + + void _updatePushrules() { + final ruleset = PushRuleSet.fromJson( + _accountData[EventTypes.PushRules]?.content['global'] ?? {}); + _pushruleEvaluator = PushruleEvaluator.fromRuleset(ruleset); + } + /// Presences of users by a given matrix ID Map presences = {}; @@ -1434,8 +1446,10 @@ class Client extends MatrixApi { _rooms = rooms; _sortRooms(); }); - _accountDataLoading = - database.getAccountData().then((data) => _accountData = data); + _accountDataLoading = database.getAccountData().then((data) { + _accountData = data; + _updatePushrules(); + }); presences.clear(); if (waitUntilLoadCompletedLoaded) { await userDeviceKeysLoading; @@ -1667,6 +1681,10 @@ class Client extends MatrixApi { ); accountData[newAccountData.type] = newAccountData; onAccountData.add(newAccountData); + + if (newAccountData.type == EventTypes.PushRules) { + _updatePushrules(); + } } final syncDeviceLists = sync.deviceLists; @@ -2606,7 +2624,7 @@ class Client extends MatrixApi { /// rule of the push rules: https://matrix.org/docs/spec/client_server/r0.6.0#m-rule-master bool get allPushNotificationsMuted { final Map? globalPushRules = - _accountData['m.push_rules']?.content['global']; + _accountData[EventTypes.PushRules]?.content['global']; if (globalPushRules == null) return false; if (globalPushRules['override'] is List) { diff --git a/lib/src/room.dart b/lib/src/room.dart index c5480132..5865fec2 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -1764,6 +1764,17 @@ class Room { return ownPowerLevel >= pl; } + bool canSendNotification(String userid, {String notificationType = 'room'}) { + final userLevel = getPowerLevelByUserId(userid); + final notificationLevel = getState(EventTypes.RoomPowerLevels) + ?.content + .tryGetMap('notifications') + ?.tryGet(notificationType) ?? + 50; + + return userLevel >= notificationLevel; + } + /// Returns the [PushRuleState] for this room, based on the m.push_rules stored in /// the account_data. PushRuleState get pushRuleState { diff --git a/lib/src/utils/pushrule_evaluator.dart b/lib/src/utils/pushrule_evaluator.dart new file mode 100644 index 00000000..c65696b7 --- /dev/null +++ b/lib/src/utils/pushrule_evaluator.dart @@ -0,0 +1,327 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2019, 2020, 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// Helper for fast evaluation of push conditions on a bunch of events + +import 'package:matrix/matrix.dart'; + +class EvaluatedPushRuleAction { + // if this message should be highlighted. + bool highlight = false; + + // if this is set, play a sound on a notification. Usually the sound is "default". + String? sound; + + // If this event should notify. + bool notify = false; + + EvaluatedPushRuleAction(); + + EvaluatedPushRuleAction.fromActions(List actions) { + for (final action in actions) { + if (action == 'notify') { + notify = true; + } else if (action == 'dont_notify') { + notify = false; + } else if (action is Map) { + if (action['set_tweak'] == 'highlight') { + highlight = action.tryGet('value') ?? true; + } else if (action['set_tweak'] == 'sound') { + sound = action.tryGet('value') ?? 'default'; + } + } + } + } +} + +class _PatternCondition { + RegExp pattern = RegExp(''); + // what field to match on, i.e. content.body + String field = ''; + + _PatternCondition.fromEventMatch(PushCondition condition) { + if (condition.kind != 'event_match') { + throw 'Logic error: invalid push rule passed to constructor ${condition.kind}'; + } + + final tempField = condition.key; + if (tempField == null) { + { + throw 'No field to match pattern on!'; + } + } + field = tempField; + + var tempPat = condition.pattern; + if (tempPat == null) { + { + throw 'PushCondition is missing pattern'; + } + } + tempPat = + RegExp.escape(tempPat).replaceAll('\\*', '.*').replaceAll('\\?', '.'); + + if (field == 'content.body') { + pattern = RegExp('(^|\\W)$tempPat(\$|\\W)', caseSensitive: false); + } else { + pattern = RegExp('^$tempPat\$', caseSensitive: false); + } + } + + bool match(Map content) { + final fieldContent = content[field]; + if (fieldContent == null) { + return false; + } + return pattern.hasMatch(fieldContent); + } +} + +enum _CountComparisonOp { + eq, + lt, + le, + ge, + gt, +} + +class _MemberCountCondition { + _CountComparisonOp op = _CountComparisonOp.eq; + int count = 0; + + _MemberCountCondition.fromEventMatch(PushCondition condition) { + if (condition.kind != 'room_member_count') { + throw 'Logic error: invalid push rule passed to constructor ${condition.kind}'; + } + + var is_ = condition.is$; + + if (is_ == null) { + throw 'Member condition has no condition set: $is_'; + } + + if (is_.startsWith('==')) { + is_ = is_.replaceFirst('==', ''); + op = _CountComparisonOp.eq; + count = int.parse(is_); + } else if (is_.startsWith('>=')) { + is_ = is_.replaceFirst('>=', ''); + op = _CountComparisonOp.ge; + count = int.parse(is_); + } else if (is_.startsWith('<=')) { + is_ = is_.replaceFirst('<=', ''); + op = _CountComparisonOp.le; + count = int.parse(is_); + } else if (is_.startsWith('>')) { + is_ = is_.replaceFirst('>', ''); + op = _CountComparisonOp.gt; + count = int.parse(is_); + } else if (is_.startsWith('<')) { + is_ = is_.replaceFirst('<', ''); + op = _CountComparisonOp.lt; + count = int.parse(is_); + } else { + op = _CountComparisonOp.eq; + count = int.parse(is_); + } + } + bool match(int memberCount) { + switch (op) { + case _CountComparisonOp.ge: + return memberCount >= count; + case _CountComparisonOp.gt: + return memberCount > count; + case _CountComparisonOp.le: + return memberCount <= count; + case _CountComparisonOp.lt: + return memberCount < count; + case _CountComparisonOp.eq: + default: + return memberCount == count; + } + } +} + +class _OptimizedRules { + List<_PatternCondition> patterns = []; + List<_MemberCountCondition> memberCounts = []; + List notificationPermissions = []; + bool matchDisplayname = false; + EvaluatedPushRuleAction actions = EvaluatedPushRuleAction(); + + _OptimizedRules.fromRule(PushRule rule) { + if (!rule.enabled) return; + + for (final condition in rule.conditions ?? []) { + switch (condition.kind) { + case 'event_match': + patterns.add(_PatternCondition.fromEventMatch(condition)); + break; + case 'contains_display_name': + matchDisplayname = true; + break; + case 'room_member_count': + memberCounts.add(_MemberCountCondition.fromEventMatch(condition)); + break; + case 'sender_notification_permission': + final key = condition.key; + if (key != null) { + notificationPermissions.add(key); + } + break; + } + } + actions = EvaluatedPushRuleAction.fromActions(rule.actions); + } + + EvaluatedPushRuleAction? match(Map event, String? displayName, + int memberCount, Room room) { + if (patterns.any((pat) => !pat.match(event))) { + return null; + } + if (memberCounts.any((pat) => !pat.match(memberCount))) { + return null; + } + if (matchDisplayname) { + final body = event.tryGet('content.body'); + if (displayName == null || body == null) { + return null; + } + + final regex = RegExp('(^|\\W)${RegExp.escape(displayName)}(\$|\\W)', + caseSensitive: false); + if (!regex.hasMatch(body)) { + return null; + } + } + + if (notificationPermissions.isNotEmpty) { + final sender = event.tryGet('sender'); + if (sender == null || + notificationPermissions.any((notificationType) => + !room.canSendNotification(sender, + notificationType: notificationType))) { + return null; + } + } + + return actions; + } +} + +class PushruleEvaluator { + final List<_OptimizedRules> _override = []; + final Map _room_rules = {}; + final Map _sender_rules = {}; + final List<_OptimizedRules> _content_rules = []; + final List<_OptimizedRules> _underride = []; + + PushruleEvaluator.fromRuleset(PushRuleSet ruleset) { + for (final o in ruleset.override ?? []) { + if (!o.enabled) continue; + _override.add(_OptimizedRules.fromRule(o)); + } + for (final u in ruleset.underride ?? []) { + if (!u.enabled) continue; + _underride.add(_OptimizedRules.fromRule(u)); + } + for (final c in ruleset.content ?? []) { + if (!c.enabled) continue; + final rule = PushRule( + actions: c.actions, + conditions: [ + PushCondition( + kind: 'event_match', key: 'content.body', pattern: c.pattern) + ], + ruleId: c.ruleId, + default$: c.default$, + enabled: c.enabled, + ); + _content_rules.add(_OptimizedRules.fromRule(rule)); + } + for (final r in ruleset.room ?? []) { + if (r.enabled) { + _room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions); + } + } + for (final r in ruleset.sender ?? []) { + if (r.enabled) { + _sender_rules[r.ruleId] = + EvaluatedPushRuleAction.fromActions(r.actions); + } + } + } + + Map _flattenJson( + Map obj, Map flattened, String prefix) { + for (final entry in obj.entries) { + final key = prefix == '' ? entry.key : '$prefix.${entry.key}'; + final value = entry.value; + if (value is String) { + flattened[key] = value; + } else if (value is Map) { + flattened = _flattenJson(value, flattened, key); + } + } + + return flattened; + } + + EvaluatedPushRuleAction match(Event event) { + final memberCount = event.room.getParticipants([Membership.join]).length; + final displayName = event.room + .unsafeGetUserFromMemoryOrFallback(event.room.client.userID!) + .displayName; + final content = _flattenJson(event.toJson(), {}, ''); + // ensure roomid is present + content['room_id'] = event.room.id; + + for (final o in _override) { + final actions = o.match(content, displayName, memberCount, event.room); + if (actions != null) { + return actions; + } + } + + final roomActions = _room_rules[event.room.id]; + if (roomActions != null) { + return roomActions; + } + + final senderActions = _sender_rules[event.senderId]; + if (senderActions != null) { + return senderActions; + } + + for (final o in _content_rules) { + final actions = o.match(content, displayName, memberCount, event.room); + if (actions != null) { + return actions; + } + } + + for (final o in _underride) { + final actions = o.match(content, displayName, memberCount, event.room); + if (actions != null) { + return actions; + } + } + + return EvaluatedPushRuleAction(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 04310b6c..c8ab4441 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: image: ^3.1.1 js: ^0.6.3 markdown: ^4.0.0 - matrix_api_lite: ^1.1.7 + matrix_api_lite: ^1.1.8 mime: ^1.0.0 olm: ^2.0.2 random_string: ^2.3.1 diff --git a/test/pushevaluator_test.dart b/test/pushevaluator_test.dart new file mode 100644 index 00000000..3fc6b0b5 --- /dev/null +++ b/test/pushevaluator_test.dart @@ -0,0 +1,332 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2019, 2020 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:olm/olm.dart' as olm; +import 'package:test/test.dart'; + +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; +import 'package:matrix/src/models/timeline_chunk.dart'; +import 'fake_client.dart'; +import 'fake_matrix_api.dart'; + +void main() { + /// All Tests related to the Event + group('Event', () { + Logs().level = Level.error; + var olmEnabled = true; + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final id = '!4fsdfjisjf:server.abc'; + final senderID = '@alice:server.abc'; + final type = 'm.room.message'; + final msgtype = 'm.text'; + final body = 'Hello fox'; + final formatted_body = 'Hello fox'; + + final contentJson = + '{"msgtype":"$msgtype","body":"$body","formatted_body":"$formatted_body","m.relates_to":{"m.in_reply_to":{"event_id":"\$1234:example.com"}}}'; + + final jsonObj = { + 'event_id': id, + 'sender': senderID, + 'origin_server_ts': timestamp, + 'type': type, + 'room_id': '!testroom:example.abc', + 'status': EventStatus.synced.intValue, + 'content': json.decode(contentJson), + }; + late Client client; + late Room room; + + setUpAll(() async { + try { + await olm.init(); + olm.get_library_version(); + } catch (e) { + olmEnabled = false; + Logs().w('[LibOlm] Failed to load LibOlm', e); + } + Logs().i('[LibOlm] Enabled: $olmEnabled'); + client = await getClient(); + room = Room(id: '!testroom:example.abc', client: client); + }); + + test('event_match rule', () async { + final event = Event.fromJson(jsonObj, room); + + final override_ruleset = PushRuleSet(override: [ + PushRule(ruleId: 'my.rule', default$: false, enabled: true, actions: [ + 'notify', + {'set_tweak': 'highlight', 'value': true}, + {'set_tweak': 'sound', 'value': 'goose.wav'}, + ], conditions: [ + PushCondition( + kind: 'event_match', pattern: 'fox', key: 'content.body'), + ]) + ]); + final underride_ruleset = PushRuleSet(underride: [ + PushRule(ruleId: 'my.rule', default$: false, enabled: true, actions: [ + 'notify', + {'set_tweak': 'highlight', 'value': true}, + {'set_tweak': 'sound', 'value': 'goose.wav'}, + ], conditions: [ + PushCondition( + kind: 'event_match', pattern: 'fox', key: 'content.body'), + ]) + ]); + final content_ruleset = PushRuleSet(content: [ + PushRule( + ruleId: 'my.rule', + default$: false, + enabled: true, + actions: [ + 'notify', + {'set_tweak': 'highlight', 'value': true}, + {'set_tweak': 'sound', 'value': 'goose.wav'}, + ], + pattern: 'fox', + ) + ]); + final room_ruleset = PushRuleSet(room: [ + PushRule( + ruleId: room.id, + default$: false, + enabled: true, + actions: [ + 'notify', + {'set_tweak': 'highlight', 'value': true}, + {'set_tweak': 'sound', 'value': 'goose.wav'}, + ], + ) + ]); + final sender_ruleset = PushRuleSet(sender: [ + PushRule( + ruleId: senderID, + default$: false, + enabled: true, + actions: [ + 'notify', + {'set_tweak': 'highlight', 'value': true}, + {'set_tweak': 'sound', 'value': 'goose.wav'}, + ], + ) + ]); + + void testMatch(PushRuleSet ruleset, Event event) { + final evaluator = PushruleEvaluator.fromRuleset(ruleset); + final actions = evaluator.match(event); + expect(actions.notify, true); + expect(actions.highlight, true); + expect(actions.sound, 'goose.wav'); + } + + void testNotMatch(PushRuleSet ruleset, Event event) { + final evaluator = PushruleEvaluator.fromRuleset(ruleset); + final actions = evaluator.match(event); + expect(actions.notify, false); + expect(actions.highlight, false); + expect(actions.sound, null); + } + + testMatch(override_ruleset, event); + testMatch(underride_ruleset, event); + testMatch(content_ruleset, event); + testMatch(room_ruleset, event); + testMatch(sender_ruleset, event); + + event.content['body'] = 'FoX'; + testMatch(override_ruleset, event); + testMatch(underride_ruleset, event); + testMatch(content_ruleset, event); + testMatch(room_ruleset, event); + testMatch(sender_ruleset, event); + + event.content['body'] = '@FoX:'; + testMatch(override_ruleset, event); + testMatch(underride_ruleset, event); + testMatch(content_ruleset, event); + testMatch(room_ruleset, event); + testMatch(sender_ruleset, event); + + event.content['body'] = 'äFoXü'; + testMatch(override_ruleset, event); + testMatch(underride_ruleset, event); + testMatch(content_ruleset, event); + testMatch(room_ruleset, event); + testMatch(sender_ruleset, event); + + event.content['body'] = 'äFoXu'; + testNotMatch(override_ruleset, event); + testNotMatch(underride_ruleset, event); + testNotMatch(content_ruleset, event); + testMatch(room_ruleset, event); + testMatch(sender_ruleset, event); + + event.content['body'] = 'aFoXü'; + testNotMatch(override_ruleset, event); + testNotMatch(underride_ruleset, event); + testNotMatch(content_ruleset, event); + testMatch(room_ruleset, event); + testMatch(sender_ruleset, event); + + final override_ruleset2 = PushRuleSet(override: [ + PushRule(ruleId: 'my.rule', default$: false, enabled: true, actions: [ + 'notify', + {'set_tweak': 'highlight', 'value': true}, + {'set_tweak': 'sound', 'value': 'goose.wav'}, + ], conditions: [ + PushCondition(kind: 'event_match', pattern: senderID, key: 'sender'), + ]) + ]); + + testMatch(override_ruleset2, event); + event.senderId = '@nope:server.tld'; + testNotMatch(override_ruleset2, event); + event.senderId = '${senderID}a'; + testNotMatch(override_ruleset2, event); + event.senderId = 'a$senderID'; + testNotMatch(override_ruleset2, event); + + event.senderId = senderID; + testMatch(override_ruleset2, event); + override_ruleset2.override?[0].enabled = false; + testNotMatch(override_ruleset2, event); + }); + + test('match_display_name rule', () async { + final event = Event.fromJson(jsonObj, room); + (event.room.states[EventTypes.RoomMember] ??= {})[client.userID!] = + Event.fromJson({ + 'type': EventTypes.RoomMember, + 'sender': senderID, + 'state_key': 'client.senderID', + 'content': {'displayname': 'Nico', 'membership': 'join'}, + 'room_id': room.id, + 'origin_server_ts': 5, + }, room); + + final ruleset = PushRuleSet(override: [ + PushRule(ruleId: 'my.rule', default$: false, enabled: true, actions: [ + 'notify', + {'set_tweak': 'highlight', 'value': true}, + {'set_tweak': 'sound', 'value': 'goose.wav'}, + ], conditions: [ + PushCondition(kind: 'contains_display_name'), + ]) + ]); + event.content['body'] = 'äNicoü'; + + final evaluator = PushruleEvaluator.fromRuleset(ruleset); + var actions = evaluator.match(event); + expect(actions.notify, true); + expect(actions.highlight, true); + expect(actions.sound, 'goose.wav'); + + event.content['body'] = 'äNicou'; + actions = evaluator.match(event); + expect(actions.notify, false); + }); + + test('member_count rule', () async { + final event = Event.fromJson(jsonObj, room); + (event.room.states[EventTypes.RoomMember] ??= {})[client.userID!] = + Event.fromJson({ + 'type': EventTypes.RoomMember, + 'sender': senderID, + 'state_key': 'client.senderID', + 'content': {'displayname': 'Nico', 'membership': 'join'}, + 'room_id': room.id, + 'origin_server_ts': 5, + }, room); + + final ruleset = PushRuleSet(override: [ + PushRule(ruleId: 'my.rule', default$: false, enabled: true, actions: [ + 'notify', + {'set_tweak': 'highlight', 'value': true}, + {'set_tweak': 'sound', 'value': 'goose.wav'}, + ], conditions: [ + PushCondition(kind: 'room_member_count', is$: '<5'), + ]) + ]); + event.content['body'] = 'äNicoü'; + + var evaluator = PushruleEvaluator.fromRuleset(ruleset); + expect(evaluator.match(event).notify, true); + + ruleset.override?[0].conditions?[0].is$ = '<=0'; + evaluator = PushruleEvaluator.fromRuleset(ruleset); + expect(evaluator.match(event).notify, false); + + ruleset.override?[0].conditions?[0].is$ = '<=1'; + evaluator = PushruleEvaluator.fromRuleset(ruleset); + expect(evaluator.match(event).notify, true); + + ruleset.override?[0].conditions?[0].is$ = '>=1'; + evaluator = PushruleEvaluator.fromRuleset(ruleset); + expect(evaluator.match(event).notify, true); + + ruleset.override?[0].conditions?[0].is$ = '>1'; + evaluator = PushruleEvaluator.fromRuleset(ruleset); + expect(evaluator.match(event).notify, false); + + ruleset.override?[0].conditions?[0].is$ = '==1'; + evaluator = PushruleEvaluator.fromRuleset(ruleset); + expect(evaluator.match(event).notify, true); + + ruleset.override?[0].conditions?[0].is$ = '1'; + evaluator = PushruleEvaluator.fromRuleset(ruleset); + expect(evaluator.match(event).notify, true); + }); + + test('notification permissions rule', () async { + final event = Event.fromJson(jsonObj, room); + (event.room.states[EventTypes.RoomPowerLevels] ??= {})[''] = + Event.fromJson({ + 'type': EventTypes.RoomMember, + 'sender': senderID, + 'state_key': 'client.senderID', + 'content': { + 'notifications': {'broom': 20}, + 'users': {senderID: 20}, + }, + 'room_id': room.id, + 'origin_server_ts': 5, + }, room); + + final ruleset = PushRuleSet(override: [ + PushRule(ruleId: 'my.rule', default$: false, enabled: true, actions: [ + 'notify', + {'set_tweak': 'highlight', 'value': true}, + {'set_tweak': 'sound', 'value': 'goose.wav'}, + ], conditions: [ + PushCondition(kind: 'sender_notification_permission', key: 'broom'), + ]) + ]); + + final evaluator = PushruleEvaluator.fromRuleset(ruleset); + expect(evaluator.match(event).notify, true); + + event.senderId = '@a:b.c'; + expect(evaluator.match(event).notify, false); + }); + }); +}