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,
[],
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
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,24 +74,20 @@ 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!';
}
}
field = tempField;
var tempPat = condition.pattern;
if (tempPat == null) {
{
throw 'PushCondition is missing pattern';
}
}
tempPat =
RegExp.escape(tempPat).replaceAll('\\*', '.*').replaceAll('\\?', '.');
@ -84,15 +98,60 @@ class _PatternCondition {
}
}
bool match(Map<String, String> content) {
final fieldContent = content[field];
if (fieldContent == null) {
bool match(Map<String, Object?> 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<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 {
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<String> 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 ?? <PushCondition>[]) {
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<String, String> event,
Map<String, Object?> 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<String>('content.body');
final body = flattenedEventJson.tryGet<String>('content.body');
if (displayName == null || body == null) {
return null;
}
@ -220,7 +289,7 @@ class _OptimizedRules {
}
if (notificationPermissions.isNotEmpty) {
final sender = event.tryGet<String>('sender');
final sender = flattenedEventJson.tryGet<String>('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 ?? <PushRule>[]) {
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 ?? <PushRule>[]) {
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 ?? <PushRule>[]) {
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 ?? <PushRule>[]) {
if (r.enabled) {
_room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions);
}
}
for (final r in ruleset.sender ?? []) {
for (final r in ruleset.sender ?? <PushRule>[]) {
if (r.enabled) {
_sender_rules[r.ruleId] =
EvaluatedPushRuleAction.fromActions(r.actions);
@ -294,18 +363,18 @@ class PushruleEvaluator {
}
}
Map<String, String> _flattenJson(
Map<String, Object?> _flattenJson(
Map<String, dynamic> obj,
Map<String, String> flattened,
Map<String, Object?> 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<String, dynamic>) {
if (value is Map<String, dynamic>) {
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;
}

View File

@ -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);
final evaluator = PushruleEvaluator.fromRuleset(invalid_ruleset);
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 actions = evaluator.match(event);
expect(actions.highlight, false);
expect(actions.sound, null);
expect(actions.notify, false);
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',
),
],
),
],
);
_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 {