diff --git a/lib/src/room.dart b/lib/src/room.dart index 9bcbd28a..367be1eb 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -2188,7 +2188,11 @@ class Room { id, [], conditions: [ - PushCondition(kind: 'event_match', key: 'room_id', pattern: id), + PushCondition( + kind: PushRuleConditions.eventMatch.name, + key: 'room_id', + pattern: id, + ), ], ); } diff --git a/lib/src/utils/pushrule_evaluator.dart b/lib/src/utils/pushrule_evaluator.dart index 9851d121..ae60fbe2 100644 --- a/lib/src/utils/pushrule_evaluator.dart +++ b/lib/src/utils/pushrule_evaluator.dart @@ -18,8 +18,26 @@ // Helper for fast evaluation of push conditions on a bunch of events +import 'package:collection/collection.dart'; + import 'package:matrix/matrix.dart'; +enum PushRuleConditions { + eventMatch('event_match'), + eventPropertyIs('event_property_is'), + eventPropertyContains('event_property_contains'), + containsDisplayName('contains_display_name'), + roomMemberCount('room_member_count'), + senderNotificationPermission('sender_notification_permission'); + + final String name; + const PushRuleConditions(this.name); + + static PushRuleConditions? fromString(String name) { + return values.firstWhereOrNull((e) => e.name == name); + } +} + class EvaluatedPushRuleAction { // if this message should be highlighted. bool highlight = false; @@ -56,23 +74,19 @@ class _PatternCondition { String field = ''; _PatternCondition.fromEventMatch(PushCondition condition) { - if (condition.kind != 'event_match') { + if (condition.kind != PushRuleConditions.eventMatch.name) { 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!'; - } + throw 'No field to match pattern on!'; } field = tempField; var tempPat = condition.pattern; if (tempPat == null) { - { - throw 'PushCondition is missing pattern'; - } + throw 'PushCondition is missing pattern'; } tempPat = RegExp.escape(tempPat).replaceAll('\\*', '.*').replaceAll('\\?', '.'); @@ -84,15 +98,60 @@ class _PatternCondition { } } - bool match(Map content) { - final fieldContent = content[field]; - if (fieldContent == null) { + bool match(Map flattenedEventJson) { + final fieldContent = flattenedEventJson[field]; + if (fieldContent == null || fieldContent is! String) { return false; } return pattern.hasMatch(fieldContent); } } +class _EventPropertyCondition { + PushRuleConditions? kind; + // what field to match on, i.e. content.body + String field = ''; + Object? value; + + _EventPropertyCondition.fromEventMatch(PushCondition condition) { + if (![ + PushRuleConditions.eventPropertyIs.name, + PushRuleConditions.eventPropertyContains.name, + ].contains(condition.kind)) { + throw 'Logic error: invalid push rule passed to constructor ${condition.kind}'; + } + kind = PushRuleConditions.fromString(condition.kind); + + final tempField = condition.key; + if (tempField == null) { + throw 'No field to check event property on!'; + } + field = tempField; + + final tempValue = condition.value; + if (![String, int, bool, Null].contains(tempValue.runtimeType)) { + throw 'PushCondition value is not a string, int, bool or null'; + } + value = tempValue; + } + + bool match(Map flattenedEventJson) { + final fieldContent = flattenedEventJson[field]; + switch (kind) { + case PushRuleConditions.eventPropertyIs: + // We check if the property exists because null is a valid property value. + if (!flattenedEventJson.keys.contains(field)) return false; + return fieldContent == value; + case PushRuleConditions.eventPropertyContains: + if (fieldContent is! Iterable) return false; + return fieldContent.contains(value); + default: + // This should never happen + throw 'Logic error: invalid push rule passed in _EventPropertyCondition ${kind?.name}'; + } + } +} + enum _CountComparisonOp { eq, lt, @@ -106,7 +165,7 @@ class _MemberCountCondition { int count = 0; _MemberCountCondition.fromEventMatch(PushCondition condition) { - if (condition.kind != 'room_member_count') { + if (condition.kind != PushRuleConditions.roomMemberCount.name) { throw 'Logic error: invalid push rule passed to constructor ${condition.kind}'; } @@ -160,6 +219,7 @@ class _MemberCountCondition { class _OptimizedRules { List<_PatternCondition> patterns = []; + List<_EventPropertyCondition> eventProperties = []; List<_MemberCountCondition> memberCounts = []; List notificationPermissions = []; bool matchDisplayname = false; @@ -168,18 +228,24 @@ class _OptimizedRules { _OptimizedRules.fromRule(PushRule rule) { if (!rule.enabled) return; - for (final condition in rule.conditions ?? []) { - switch (condition.kind) { - case 'event_match': + for (final condition in rule.conditions ?? []) { + final kind = PushRuleConditions.fromString(condition.kind); + switch (kind) { + case PushRuleConditions.eventMatch: patterns.add(_PatternCondition.fromEventMatch(condition)); break; - case 'contains_display_name': + case PushRuleConditions.eventPropertyIs: + case PushRuleConditions.eventPropertyContains: + eventProperties + .add(_EventPropertyCondition.fromEventMatch(condition)); + break; + case PushRuleConditions.containsDisplayName: matchDisplayname = true; break; - case 'room_member_count': + case PushRuleConditions.roomMemberCount: memberCounts.add(_MemberCountCondition.fromEventMatch(condition)); break; - case 'sender_notification_permission': + case PushRuleConditions.senderNotificationPermission: final key = condition.key; if (key != null) { notificationPermissions.add(key); @@ -193,19 +259,22 @@ class _OptimizedRules { } EvaluatedPushRuleAction? match( - Map event, + Map flattenedEventJson, String? displayName, int memberCount, Room room, ) { - if (patterns.any((pat) => !pat.match(event))) { + if (patterns.any((pat) => !pat.match(flattenedEventJson))) { + return null; + } + if (eventProperties.any((pat) => !pat.match(flattenedEventJson))) { return null; } if (memberCounts.any((pat) => !pat.match(memberCount))) { return null; } if (matchDisplayname) { - final body = event.tryGet('content.body'); + final body = flattenedEventJson.tryGet('content.body'); if (displayName == null || body == null) { return null; } @@ -220,7 +289,7 @@ class _OptimizedRules { } if (notificationPermissions.isNotEmpty) { - final sender = event.tryGet('sender'); + final sender = flattenedEventJson.tryGet('sender'); if (sender == null || notificationPermissions.any( (notificationType) => !room.canSendNotification( @@ -244,7 +313,7 @@ class PushruleEvaluator { final List<_OptimizedRules> _underride = []; PushruleEvaluator.fromRuleset(PushRuleSet ruleset) { - for (final o in ruleset.override ?? []) { + for (final o in ruleset.override ?? []) { if (!o.enabled) continue; try { _override.add(_OptimizedRules.fromRule(o)); @@ -252,7 +321,7 @@ class PushruleEvaluator { Logs().d('Error parsing push rule $o', e); } } - for (final u in ruleset.underride ?? []) { + for (final u in ruleset.underride ?? []) { if (!u.enabled) continue; try { _underride.add(_OptimizedRules.fromRule(u)); @@ -260,13 +329,13 @@ class PushruleEvaluator { Logs().d('Error parsing push rule $u', e); } } - for (final c in ruleset.content ?? []) { + for (final c in ruleset.content ?? []) { if (!c.enabled) continue; final rule = PushRule( actions: c.actions, conditions: [ PushCondition( - kind: 'event_match', + kind: PushRuleConditions.eventMatch.name, key: 'content.body', pattern: c.pattern, ), @@ -281,12 +350,12 @@ class PushruleEvaluator { Logs().d('Error parsing push rule $rule', e); } } - for (final r in ruleset.room ?? []) { + for (final r in ruleset.room ?? []) { if (r.enabled) { _room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions); } } - for (final r in ruleset.sender ?? []) { + for (final r in ruleset.sender ?? []) { if (r.enabled) { _sender_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions); @@ -294,18 +363,18 @@ class PushruleEvaluator { } } - Map _flattenJson( + Map _flattenJson( Map obj, - Map flattened, + 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) { + if (value is Map) { flattened = _flattenJson(value, flattened, key); + } else { + flattened[key] = value; } } @@ -317,12 +386,13 @@ class PushruleEvaluator { final displayName = event.room .unsafeGetUserFromMemoryOrFallback(event.room.client.userID!) .displayName; - final content = _flattenJson(event.toJson(), {}, ''); + final flattenedEventJson = _flattenJson(event.toJson(), {}, ''); // ensure roomid is present - content['room_id'] = event.room.id; + flattenedEventJson['room_id'] = event.room.id; for (final o in _override) { - final actions = o.match(content, displayName, memberCount, event.room); + final actions = + o.match(flattenedEventJson, displayName, memberCount, event.room); if (actions != null) { return actions; } @@ -339,14 +409,16 @@ class PushruleEvaluator { } for (final o in _content_rules) { - final actions = o.match(content, displayName, memberCount, event.room); + final actions = + o.match(flattenedEventJson, displayName, memberCount, event.room); if (actions != null) { return actions; } } for (final o in _underride) { - final actions = o.match(content, displayName, memberCount, event.room); + final actions = + o.match(flattenedEventJson, displayName, memberCount, event.room); if (actions != null) { return actions; } diff --git a/test/pushevaluator_test.dart b/test/pushevaluator_test.dart index 6bace374..5115f4b3 100644 --- a/test/pushevaluator_test.dart +++ b/test/pushevaluator_test.dart @@ -23,6 +23,22 @@ import 'package:test/test.dart'; import 'package:matrix/matrix.dart'; import 'fake_client.dart'; +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); +} + void main() { /// All Tests related to the Event group('Event', () { @@ -145,62 +161,46 @@ void main() { ], ); - 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); + _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); + _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); + _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); + _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); + _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); + _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: [ @@ -224,22 +224,25 @@ void main() { ], ); - testMatch(override_ruleset2, event); + _testMatch(override_ruleset2, event); event.senderId = '@nope:server.tld'; - testNotMatch(override_ruleset2, event); + _testNotMatch(override_ruleset2, event); event.senderId = '${senderID}a'; - testNotMatch(override_ruleset2, event); + _testNotMatch(override_ruleset2, event); event.senderId = 'a$senderID'; - testNotMatch(override_ruleset2, event); + _testNotMatch(override_ruleset2, event); event.senderId = senderID; - testMatch(override_ruleset2, event); + _testMatch(override_ruleset2, event); override_ruleset2.override?[0].enabled = false; - testNotMatch(override_ruleset2, event); + _testNotMatch(override_ruleset2, event); }); - test('invalid push condition', () async { - final invalid_ruleset = PushRuleSet( + test('event_property_is rule', () async { + final event = Event.fromJson(jsonObj, room); + event.content['body'] = 'Hello fox'; + + final ruleset = PushRuleSet( override: [ PushRule( ruleId: 'my.rule', @@ -252,26 +255,81 @@ void main() { ], conditions: [ PushCondition( - kind: 'invalidcondition', - pattern: 'fox', + kind: 'event_property_is', key: 'content.body', + value: 'Hello fox', ), ], ), ], ); - expect( - () => PushruleEvaluator.fromRuleset(invalid_ruleset), - returnsNormally, + _testMatch(ruleset, event); + + event.content['body'] = 'Hello Fox'; + _testNotMatch(ruleset, event); + + event.content['body'] = null; + ruleset.override?[0].conditions?[0].value = null; + _testMatch(ruleset, event); + + event.content['body'] = true; + _testNotMatch(ruleset, event); + + ruleset.override?[0].conditions?[0].value = true; + _testMatch(ruleset, event); + + event.content['body'] = 12345; + _testNotMatch(ruleset, event); + + ruleset.override?[0].conditions?[0].value = 12345; + _testMatch(ruleset, event); + }); + + test('event_property_contains rule', () async { + final event = Event.fromJson(jsonObj, 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: 'event_property_contains', + key: 'content.body', + value: 'Fox', + ), + ], + ), + ], ); - final evaluator = PushruleEvaluator.fromRuleset(invalid_ruleset); - final event = Event.fromJson(jsonObj, room); - final actions = evaluator.match(event); - expect(actions.highlight, false); - expect(actions.sound, null); - expect(actions.notify, false); + _testNotMatch(ruleset, event); + + event.content['body'] = []; + _testNotMatch(ruleset, event); + + event.content['body'] = null; + _testNotMatch(ruleset, event); + + event.content['body'] = ['Fox']; + _testMatch(ruleset, event); + + ruleset.override?[0].conditions?[0].value = true; + _testNotMatch(ruleset, event); + + event.content['body'] = [12345, true]; + _testMatch(ruleset, event); + + ruleset.override?[0].conditions?[0].value = 12345; + _testMatch(ruleset, event); }); test('match_display_name rule', () async { @@ -306,17 +364,12 @@ void main() { ), ], ); - 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'] = 'äNicoü'; + _testMatch(ruleset, event); event.content['body'] = 'äNicou'; - actions = evaluator.match(event); - expect(actions.notify, false); + _testNotMatch(ruleset, event); }); test('member_count rule', () async { @@ -352,33 +405,25 @@ void main() { ], ); event.content['body'] = 'äNicoü'; - - var evaluator = PushruleEvaluator.fromRuleset(ruleset); - expect(evaluator.match(event).notify, true); + _testMatch(ruleset, event); ruleset.override?[0].conditions?[0].is$ = '<=0'; - evaluator = PushruleEvaluator.fromRuleset(ruleset); - expect(evaluator.match(event).notify, false); + _testNotMatch(ruleset, event); ruleset.override?[0].conditions?[0].is$ = '<=1'; - evaluator = PushruleEvaluator.fromRuleset(ruleset); - expect(evaluator.match(event).notify, true); + _testMatch(ruleset, event); ruleset.override?[0].conditions?[0].is$ = '>=1'; - evaluator = PushruleEvaluator.fromRuleset(ruleset); - expect(evaluator.match(event).notify, true); + _testMatch(ruleset, event); ruleset.override?[0].conditions?[0].is$ = '>1'; - evaluator = PushruleEvaluator.fromRuleset(ruleset); - expect(evaluator.match(event).notify, false); + _testNotMatch(ruleset, event); ruleset.override?[0].conditions?[0].is$ = '==1'; - evaluator = PushruleEvaluator.fromRuleset(ruleset); - expect(evaluator.match(event).notify, true); + _testMatch(ruleset, event); ruleset.override?[0].conditions?[0].is$ = '1'; - evaluator = PushruleEvaluator.fromRuleset(ruleset); - expect(evaluator.match(event).notify, true); + _testMatch(ruleset, event); }); test('notification permissions rule', () async { @@ -420,11 +465,42 @@ void main() { ], ); - final evaluator = PushruleEvaluator.fromRuleset(ruleset); - expect(evaluator.match(event).notify, true); + _testMatch(ruleset, event); event.senderId = '@a:b.c'; - expect(evaluator.match(event).notify, false); + _testNotMatch(ruleset, event); + }); + + test('invalid push condition', () async { + final invalid_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: 'invalidcondition', + pattern: 'fox', + key: 'content.body', + ), + ], + ), + ], + ); + + expect( + () => PushruleEvaluator.fromRuleset(invalid_ruleset), + returnsNormally, + ); + + final event = Event.fromJson(jsonObj, room); + _testNotMatch(invalid_ruleset, event); }); test('invalid content rule', () async {