feat: support push rule conditions event_property_is & event_property_contains

This commit is contained in:
Karthikeyan S 2024-12-23 15:21:01 +05:30
parent 735190cd78
commit 239a39e2cb
No known key found for this signature in database
GPG Key ID: 28BA6AEE539ECE2E
3 changed files with 281 additions and 129 deletions

View File

@ -2188,7 +2188,11 @@ class Room {
id, id,
[], [],
conditions: [ conditions: [
PushCondition(kind: 'event_match', key: 'room_id', pattern: id), PushCondition(
kind: PushRuleConditions.eventMatch.name,
key: 'room_id',
pattern: id,
),
], ],
); );
} }

View File

@ -18,8 +18,26 @@
// Helper for fast evaluation of push conditions on a bunch of events // Helper for fast evaluation of push conditions on a bunch of events
import 'package:collection/collection.dart';
import 'package:matrix/matrix.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 { class EvaluatedPushRuleAction {
// if this message should be highlighted. // if this message should be highlighted.
bool highlight = false; bool highlight = false;
@ -56,23 +74,19 @@ class _PatternCondition {
String field = ''; String field = '';
_PatternCondition.fromEventMatch(PushCondition condition) { _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}'; throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
} }
final tempField = condition.key; final tempField = condition.key;
if (tempField == null) { if (tempField == null) {
{ throw 'No field to match pattern on!';
throw 'No field to match pattern on!';
}
} }
field = tempField; field = tempField;
var tempPat = condition.pattern; var tempPat = condition.pattern;
if (tempPat == null) { if (tempPat == null) {
{ throw 'PushCondition is missing pattern';
throw 'PushCondition is missing pattern';
}
} }
tempPat = tempPat =
RegExp.escape(tempPat).replaceAll('\\*', '.*').replaceAll('\\?', '.'); RegExp.escape(tempPat).replaceAll('\\*', '.*').replaceAll('\\?', '.');
@ -84,15 +98,60 @@ class _PatternCondition {
} }
} }
bool match(Map<String, String> content) { bool match(Map<String, Object?> flattenedEventJson) {
final fieldContent = content[field]; final fieldContent = flattenedEventJson[field];
if (fieldContent == null) { if (fieldContent == null || fieldContent is! String) {
return false; return false;
} }
return pattern.hasMatch(fieldContent); 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<String, Object?> 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 { enum _CountComparisonOp {
eq, eq,
lt, lt,
@ -106,7 +165,7 @@ class _MemberCountCondition {
int count = 0; int count = 0;
_MemberCountCondition.fromEventMatch(PushCondition condition) { _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}'; throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
} }
@ -160,6 +219,7 @@ class _MemberCountCondition {
class _OptimizedRules { class _OptimizedRules {
List<_PatternCondition> patterns = []; List<_PatternCondition> patterns = [];
List<_EventPropertyCondition> eventProperties = [];
List<_MemberCountCondition> memberCounts = []; List<_MemberCountCondition> memberCounts = [];
List<String> notificationPermissions = []; List<String> notificationPermissions = [];
bool matchDisplayname = false; bool matchDisplayname = false;
@ -168,18 +228,24 @@ class _OptimizedRules {
_OptimizedRules.fromRule(PushRule rule) { _OptimizedRules.fromRule(PushRule rule) {
if (!rule.enabled) return; if (!rule.enabled) return;
for (final condition in rule.conditions ?? []) { for (final condition in rule.conditions ?? <PushCondition>[]) {
switch (condition.kind) { final kind = PushRuleConditions.fromString(condition.kind);
case 'event_match': switch (kind) {
case PushRuleConditions.eventMatch:
patterns.add(_PatternCondition.fromEventMatch(condition)); patterns.add(_PatternCondition.fromEventMatch(condition));
break; break;
case 'contains_display_name': case PushRuleConditions.eventPropertyIs:
case PushRuleConditions.eventPropertyContains:
eventProperties
.add(_EventPropertyCondition.fromEventMatch(condition));
break;
case PushRuleConditions.containsDisplayName:
matchDisplayname = true; matchDisplayname = true;
break; break;
case 'room_member_count': case PushRuleConditions.roomMemberCount:
memberCounts.add(_MemberCountCondition.fromEventMatch(condition)); memberCounts.add(_MemberCountCondition.fromEventMatch(condition));
break; break;
case 'sender_notification_permission': case PushRuleConditions.senderNotificationPermission:
final key = condition.key; final key = condition.key;
if (key != null) { if (key != null) {
notificationPermissions.add(key); notificationPermissions.add(key);
@ -193,19 +259,22 @@ class _OptimizedRules {
} }
EvaluatedPushRuleAction? match( EvaluatedPushRuleAction? match(
Map<String, String> event, Map<String, Object?> flattenedEventJson,
String? displayName, String? displayName,
int memberCount, int memberCount,
Room room, 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; return null;
} }
if (memberCounts.any((pat) => !pat.match(memberCount))) { if (memberCounts.any((pat) => !pat.match(memberCount))) {
return null; return null;
} }
if (matchDisplayname) { if (matchDisplayname) {
final body = event.tryGet<String>('content.body'); final body = flattenedEventJson.tryGet<String>('content.body');
if (displayName == null || body == null) { if (displayName == null || body == null) {
return null; return null;
} }
@ -220,7 +289,7 @@ class _OptimizedRules {
} }
if (notificationPermissions.isNotEmpty) { if (notificationPermissions.isNotEmpty) {
final sender = event.tryGet<String>('sender'); final sender = flattenedEventJson.tryGet<String>('sender');
if (sender == null || if (sender == null ||
notificationPermissions.any( notificationPermissions.any(
(notificationType) => !room.canSendNotification( (notificationType) => !room.canSendNotification(
@ -244,7 +313,7 @@ class PushruleEvaluator {
final List<_OptimizedRules> _underride = []; final List<_OptimizedRules> _underride = [];
PushruleEvaluator.fromRuleset(PushRuleSet ruleset) { PushruleEvaluator.fromRuleset(PushRuleSet ruleset) {
for (final o in ruleset.override ?? []) { for (final o in ruleset.override ?? <PushRule>[]) {
if (!o.enabled) continue; if (!o.enabled) continue;
try { try {
_override.add(_OptimizedRules.fromRule(o)); _override.add(_OptimizedRules.fromRule(o));
@ -252,7 +321,7 @@ class PushruleEvaluator {
Logs().d('Error parsing push rule $o', e); Logs().d('Error parsing push rule $o', e);
} }
} }
for (final u in ruleset.underride ?? []) { for (final u in ruleset.underride ?? <PushRule>[]) {
if (!u.enabled) continue; if (!u.enabled) continue;
try { try {
_underride.add(_OptimizedRules.fromRule(u)); _underride.add(_OptimizedRules.fromRule(u));
@ -260,13 +329,13 @@ class PushruleEvaluator {
Logs().d('Error parsing push rule $u', e); Logs().d('Error parsing push rule $u', e);
} }
} }
for (final c in ruleset.content ?? []) { for (final c in ruleset.content ?? <PushRule>[]) {
if (!c.enabled) continue; if (!c.enabled) continue;
final rule = PushRule( final rule = PushRule(
actions: c.actions, actions: c.actions,
conditions: [ conditions: [
PushCondition( PushCondition(
kind: 'event_match', kind: PushRuleConditions.eventMatch.name,
key: 'content.body', key: 'content.body',
pattern: c.pattern, pattern: c.pattern,
), ),
@ -281,12 +350,12 @@ class PushruleEvaluator {
Logs().d('Error parsing push rule $rule', e); Logs().d('Error parsing push rule $rule', e);
} }
} }
for (final r in ruleset.room ?? []) { for (final r in ruleset.room ?? <PushRule>[]) {
if (r.enabled) { if (r.enabled) {
_room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions); _room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions);
} }
} }
for (final r in ruleset.sender ?? []) { for (final r in ruleset.sender ?? <PushRule>[]) {
if (r.enabled) { if (r.enabled) {
_sender_rules[r.ruleId] = _sender_rules[r.ruleId] =
EvaluatedPushRuleAction.fromActions(r.actions); EvaluatedPushRuleAction.fromActions(r.actions);
@ -294,18 +363,18 @@ class PushruleEvaluator {
} }
} }
Map<String, String> _flattenJson( Map<String, Object?> _flattenJson(
Map<String, dynamic> obj, Map<String, dynamic> obj,
Map<String, String> flattened, Map<String, Object?> flattened,
String prefix, String prefix,
) { ) {
for (final entry in obj.entries) { for (final entry in obj.entries) {
final key = prefix == '' ? entry.key : '$prefix.${entry.key}'; final key = prefix == '' ? entry.key : '$prefix.${entry.key}';
final value = entry.value; final value = entry.value;
if (value is String) { if (value is Map<String, dynamic>) {
flattened[key] = value;
} else if (value is Map<String, dynamic>) {
flattened = _flattenJson(value, flattened, key); flattened = _flattenJson(value, flattened, key);
} else {
flattened[key] = value;
} }
} }
@ -317,12 +386,13 @@ class PushruleEvaluator {
final displayName = event.room final displayName = event.room
.unsafeGetUserFromMemoryOrFallback(event.room.client.userID!) .unsafeGetUserFromMemoryOrFallback(event.room.client.userID!)
.displayName; .displayName;
final content = _flattenJson(event.toJson(), {}, ''); final flattenedEventJson = _flattenJson(event.toJson(), {}, '');
// ensure roomid is present // ensure roomid is present
content['room_id'] = event.room.id; flattenedEventJson['room_id'] = event.room.id;
for (final o in _override) { 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) { if (actions != null) {
return actions; return actions;
} }
@ -339,14 +409,16 @@ class PushruleEvaluator {
} }
for (final o in _content_rules) { 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) { if (actions != null) {
return actions; return actions;
} }
} }
for (final o in _underride) { 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) { if (actions != null) {
return actions; return actions;
} }

View File

@ -23,6 +23,22 @@ import 'package:test/test.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'fake_client.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() { void main() {
/// All Tests related to the Event /// All Tests related to the Event
group('Event', () { group('Event', () {
@ -145,62 +161,46 @@ void main() {
], ],
); );
void testMatch(PushRuleSet ruleset, Event event) { _testMatch(override_ruleset, event);
final evaluator = PushruleEvaluator.fromRuleset(ruleset); _testMatch(underride_ruleset, event);
final actions = evaluator.match(event); _testMatch(content_ruleset, event);
expect(actions.notify, true); _testMatch(room_ruleset, event);
expect(actions.highlight, true); _testMatch(sender_ruleset, event);
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'; event.content['body'] = 'FoX';
testMatch(override_ruleset, event); _testMatch(override_ruleset, event);
testMatch(underride_ruleset, event); _testMatch(underride_ruleset, event);
testMatch(content_ruleset, event); _testMatch(content_ruleset, event);
testMatch(room_ruleset, event); _testMatch(room_ruleset, event);
testMatch(sender_ruleset, event); _testMatch(sender_ruleset, event);
event.content['body'] = '@FoX:'; event.content['body'] = '@FoX:';
testMatch(override_ruleset, event); _testMatch(override_ruleset, event);
testMatch(underride_ruleset, event); _testMatch(underride_ruleset, event);
testMatch(content_ruleset, event); _testMatch(content_ruleset, event);
testMatch(room_ruleset, event); _testMatch(room_ruleset, event);
testMatch(sender_ruleset, event); _testMatch(sender_ruleset, event);
event.content['body'] = 'äFoXü'; event.content['body'] = 'äFoXü';
testMatch(override_ruleset, event); _testMatch(override_ruleset, event);
testMatch(underride_ruleset, event); _testMatch(underride_ruleset, event);
testMatch(content_ruleset, event); _testMatch(content_ruleset, event);
testMatch(room_ruleset, event); _testMatch(room_ruleset, event);
testMatch(sender_ruleset, event); _testMatch(sender_ruleset, event);
event.content['body'] = 'äFoXu'; event.content['body'] = 'äFoXu';
testNotMatch(override_ruleset, event); _testNotMatch(override_ruleset, event);
testNotMatch(underride_ruleset, event); _testNotMatch(underride_ruleset, event);
testNotMatch(content_ruleset, event); _testNotMatch(content_ruleset, event);
testMatch(room_ruleset, event); _testMatch(room_ruleset, event);
testMatch(sender_ruleset, event); _testMatch(sender_ruleset, event);
event.content['body'] = 'aFoXü'; event.content['body'] = 'aFoXü';
testNotMatch(override_ruleset, event); _testNotMatch(override_ruleset, event);
testNotMatch(underride_ruleset, event); _testNotMatch(underride_ruleset, event);
testNotMatch(content_ruleset, event); _testNotMatch(content_ruleset, event);
testMatch(room_ruleset, event); _testMatch(room_ruleset, event);
testMatch(sender_ruleset, event); _testMatch(sender_ruleset, event);
final override_ruleset2 = PushRuleSet( final override_ruleset2 = PushRuleSet(
override: [ override: [
@ -224,22 +224,25 @@ void main() {
], ],
); );
testMatch(override_ruleset2, event); _testMatch(override_ruleset2, event);
event.senderId = '@nope:server.tld'; event.senderId = '@nope:server.tld';
testNotMatch(override_ruleset2, event); _testNotMatch(override_ruleset2, event);
event.senderId = '${senderID}a'; event.senderId = '${senderID}a';
testNotMatch(override_ruleset2, event); _testNotMatch(override_ruleset2, event);
event.senderId = 'a$senderID'; event.senderId = 'a$senderID';
testNotMatch(override_ruleset2, event); _testNotMatch(override_ruleset2, event);
event.senderId = senderID; event.senderId = senderID;
testMatch(override_ruleset2, event); _testMatch(override_ruleset2, event);
override_ruleset2.override?[0].enabled = false; override_ruleset2.override?[0].enabled = false;
testNotMatch(override_ruleset2, event); _testNotMatch(override_ruleset2, event);
}); });
test('invalid push condition', () async { test('event_property_is rule', () async {
final invalid_ruleset = PushRuleSet( final event = Event.fromJson(jsonObj, room);
event.content['body'] = 'Hello fox';
final ruleset = PushRuleSet(
override: [ override: [
PushRule( PushRule(
ruleId: 'my.rule', ruleId: 'my.rule',
@ -252,26 +255,81 @@ void main() {
], ],
conditions: [ conditions: [
PushCondition( PushCondition(
kind: 'invalidcondition', kind: 'event_property_is',
pattern: 'fox',
key: 'content.body', key: 'content.body',
value: 'Hello fox',
), ),
], ],
), ),
], ],
); );
expect( _testMatch(ruleset, event);
() => PushruleEvaluator.fromRuleset(invalid_ruleset),
returnsNormally, 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); _testNotMatch(ruleset, event);
final event = Event.fromJson(jsonObj, room);
final actions = evaluator.match(event); event.content['body'] = [];
expect(actions.highlight, false); _testNotMatch(ruleset, event);
expect(actions.sound, null);
expect(actions.notify, false); 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 { test('match_display_name rule', () async {
@ -306,17 +364,12 @@ void main() {
), ),
], ],
); );
event.content['body'] = 'äNicoü';
final evaluator = PushruleEvaluator.fromRuleset(ruleset); event.content['body'] = 'äNicoü';
var actions = evaluator.match(event); _testMatch(ruleset, event);
expect(actions.notify, true);
expect(actions.highlight, true);
expect(actions.sound, 'goose.wav');
event.content['body'] = 'äNicou'; event.content['body'] = 'äNicou';
actions = evaluator.match(event); _testNotMatch(ruleset, event);
expect(actions.notify, false);
}); });
test('member_count rule', () async { test('member_count rule', () async {
@ -352,33 +405,25 @@ void main() {
], ],
); );
event.content['body'] = 'äNicoü'; event.content['body'] = 'äNicoü';
_testMatch(ruleset, event);
var evaluator = PushruleEvaluator.fromRuleset(ruleset);
expect(evaluator.match(event).notify, true);
ruleset.override?[0].conditions?[0].is$ = '<=0'; ruleset.override?[0].conditions?[0].is$ = '<=0';
evaluator = PushruleEvaluator.fromRuleset(ruleset); _testNotMatch(ruleset, event);
expect(evaluator.match(event).notify, false);
ruleset.override?[0].conditions?[0].is$ = '<=1'; ruleset.override?[0].conditions?[0].is$ = '<=1';
evaluator = PushruleEvaluator.fromRuleset(ruleset); _testMatch(ruleset, event);
expect(evaluator.match(event).notify, true);
ruleset.override?[0].conditions?[0].is$ = '>=1'; ruleset.override?[0].conditions?[0].is$ = '>=1';
evaluator = PushruleEvaluator.fromRuleset(ruleset); _testMatch(ruleset, event);
expect(evaluator.match(event).notify, true);
ruleset.override?[0].conditions?[0].is$ = '>1'; ruleset.override?[0].conditions?[0].is$ = '>1';
evaluator = PushruleEvaluator.fromRuleset(ruleset); _testNotMatch(ruleset, event);
expect(evaluator.match(event).notify, false);
ruleset.override?[0].conditions?[0].is$ = '==1'; ruleset.override?[0].conditions?[0].is$ = '==1';
evaluator = PushruleEvaluator.fromRuleset(ruleset); _testMatch(ruleset, event);
expect(evaluator.match(event).notify, true);
ruleset.override?[0].conditions?[0].is$ = '1'; ruleset.override?[0].conditions?[0].is$ = '1';
evaluator = PushruleEvaluator.fromRuleset(ruleset); _testMatch(ruleset, event);
expect(evaluator.match(event).notify, true);
}); });
test('notification permissions rule', () async { test('notification permissions rule', () async {
@ -420,11 +465,42 @@ void main() {
], ],
); );
final evaluator = PushruleEvaluator.fromRuleset(ruleset); _testMatch(ruleset, event);
expect(evaluator.match(event).notify, true);
event.senderId = '@a:b.c'; 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 { test('invalid content rule', () async {