Line data Source code
1 : /*
2 : * Famedly Matrix SDK
3 : * Copyright (C) 2019, 2020, 2021 Famedly GmbH
4 : *
5 : * This program is free software: you can redistribute it and/or modify
6 : * it under the terms of the GNU Affero General Public License as
7 : * published by the Free Software Foundation, either version 3 of the
8 : * License, or (at your option) any later version.
9 : *
10 : * This program is distributed in the hope that it will be useful,
11 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 : * GNU Affero General Public License for more details.
14 : *
15 : * You should have received a copy of the GNU Affero General Public License
16 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 : */
18 :
19 : // Helper for fast evaluation of push conditions on a bunch of events
20 :
21 : import 'package:collection/collection.dart';
22 :
23 : import 'package:matrix/matrix.dart';
24 :
25 : enum PushRuleConditions {
26 : eventMatch('event_match'),
27 : eventPropertyIs('event_property_is'),
28 : eventPropertyContains('event_property_contains'),
29 : containsDisplayName('contains_display_name'),
30 : roomMemberCount('room_member_count'),
31 : senderNotificationPermission('sender_notification_permission');
32 :
33 : final String name;
34 : const PushRuleConditions(this.name);
35 :
36 35 : static PushRuleConditions? fromString(String name) {
37 140 : return values.firstWhereOrNull((e) => e.name == name);
38 : }
39 : }
40 :
41 : class EvaluatedPushRuleAction {
42 : // if this message should be highlighted.
43 : bool highlight = false;
44 :
45 : // if this is set, play a sound on a notification. Usually the sound is "default".
46 : String? sound;
47 :
48 : // If this event should notify.
49 : bool notify = false;
50 :
51 35 : EvaluatedPushRuleAction();
52 :
53 35 : EvaluatedPushRuleAction.fromActions(List<dynamic> actions) {
54 70 : for (final action in actions) {
55 35 : if (action == 'notify') {
56 35 : notify = true;
57 35 : } else if (action == 'dont_notify') {
58 35 : notify = false;
59 35 : } else if (action is Map<String, dynamic>) {
60 70 : if (action['set_tweak'] == 'highlight') {
61 70 : highlight = action.tryGet<bool>('value') ?? true;
62 70 : } else if (action['set_tweak'] == 'sound') {
63 70 : sound = action.tryGet<String>('value') ?? 'default';
64 : }
65 : }
66 : }
67 : }
68 : }
69 :
70 : class _PatternCondition {
71 : RegExp pattern = RegExp('');
72 :
73 : // what field to match on, i.e. content.body
74 : String field = '';
75 :
76 35 : _PatternCondition.fromEventMatch(PushCondition condition) {
77 105 : if (condition.kind != PushRuleConditions.eventMatch.name) {
78 0 : throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
79 : }
80 :
81 35 : final tempField = condition.key;
82 : if (tempField == null) {
83 : throw 'No field to match pattern on!';
84 : }
85 35 : field = tempField;
86 :
87 35 : var tempPat = condition.pattern;
88 : if (tempPat == null) {
89 : throw 'PushCondition is missing pattern';
90 : }
91 : tempPat =
92 105 : RegExp.escape(tempPat).replaceAll('\\*', '.*').replaceAll('\\?', '.');
93 :
94 70 : if (field == 'content.body') {
95 105 : pattern = RegExp('(^|\\W)$tempPat(\$|\\W)', caseSensitive: false);
96 : } else {
97 105 : pattern = RegExp('^$tempPat\$', caseSensitive: false);
98 : }
99 : }
100 :
101 2 : bool match(Map<String, Object?> flattenedEventJson) {
102 4 : final fieldContent = flattenedEventJson[field];
103 2 : if (fieldContent == null || fieldContent is! String) {
104 : return false;
105 : }
106 4 : return pattern.hasMatch(fieldContent);
107 : }
108 : }
109 :
110 : class _EventPropertyCondition {
111 : PushRuleConditions? kind;
112 : // what field to match on, i.e. content.body
113 : String field = '';
114 : Object? value;
115 :
116 2 : _EventPropertyCondition.fromEventMatch(PushCondition condition) {
117 2 : if (![
118 2 : PushRuleConditions.eventPropertyIs.name,
119 2 : PushRuleConditions.eventPropertyContains.name,
120 4 : ].contains(condition.kind)) {
121 0 : throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
122 : }
123 6 : kind = PushRuleConditions.fromString(condition.kind);
124 :
125 2 : final tempField = condition.key;
126 : if (tempField == null) {
127 : throw 'No field to check event property on!';
128 : }
129 2 : field = tempField;
130 :
131 2 : final tempValue = condition.value;
132 6 : if (![String, int, bool, Null].contains(tempValue.runtimeType)) {
133 : throw 'PushCondition value is not a string, int, bool or null';
134 : }
135 2 : value = tempValue;
136 : }
137 :
138 2 : bool match(Map<String, Object?> flattenedEventJson) {
139 4 : final fieldContent = flattenedEventJson[field];
140 2 : switch (kind) {
141 2 : case PushRuleConditions.eventPropertyIs:
142 : // We check if the property exists because null is a valid property value.
143 6 : if (!flattenedEventJson.keys.contains(field)) return false;
144 4 : return fieldContent == value;
145 2 : case PushRuleConditions.eventPropertyContains:
146 2 : if (fieldContent is! Iterable) return false;
147 4 : return fieldContent.contains(value);
148 : default:
149 : // This should never happen
150 0 : throw 'Logic error: invalid push rule passed in _EventPropertyCondition ${kind?.name}';
151 : }
152 : }
153 : }
154 :
155 : enum _CountComparisonOp {
156 : eq,
157 : lt,
158 : le,
159 : ge,
160 : gt,
161 : }
162 :
163 : class _MemberCountCondition {
164 : _CountComparisonOp op = _CountComparisonOp.eq;
165 : int count = 0;
166 :
167 35 : _MemberCountCondition.fromEventMatch(PushCondition condition) {
168 105 : if (condition.kind != PushRuleConditions.roomMemberCount.name) {
169 0 : throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
170 : }
171 :
172 35 : var is_ = condition.is$;
173 :
174 : if (is_ == null) {
175 0 : throw 'Member condition has no condition set: $is_';
176 : }
177 :
178 35 : if (is_.startsWith('==')) {
179 2 : is_ = is_.replaceFirst('==', '');
180 2 : op = _CountComparisonOp.eq;
181 4 : count = int.parse(is_);
182 35 : } else if (is_.startsWith('>=')) {
183 2 : is_ = is_.replaceFirst('>=', '');
184 2 : op = _CountComparisonOp.ge;
185 4 : count = int.parse(is_);
186 35 : } else if (is_.startsWith('<=')) {
187 2 : is_ = is_.replaceFirst('<=', '');
188 2 : op = _CountComparisonOp.le;
189 4 : count = int.parse(is_);
190 35 : } else if (is_.startsWith('>')) {
191 2 : is_ = is_.replaceFirst('>', '');
192 2 : op = _CountComparisonOp.gt;
193 4 : count = int.parse(is_);
194 35 : } else if (is_.startsWith('<')) {
195 2 : is_ = is_.replaceFirst('<', '');
196 2 : op = _CountComparisonOp.lt;
197 4 : count = int.parse(is_);
198 : } else {
199 35 : op = _CountComparisonOp.eq;
200 70 : count = int.parse(is_);
201 : }
202 : }
203 :
204 2 : bool match(int memberCount) {
205 2 : switch (op) {
206 2 : case _CountComparisonOp.ge:
207 4 : return memberCount >= count;
208 2 : case _CountComparisonOp.gt:
209 4 : return memberCount > count;
210 2 : case _CountComparisonOp.le:
211 4 : return memberCount <= count;
212 2 : case _CountComparisonOp.lt:
213 4 : return memberCount < count;
214 2 : case _CountComparisonOp.eq:
215 4 : return memberCount == count;
216 : }
217 : }
218 : }
219 :
220 : class _OptimizedRules {
221 : List<_PatternCondition> patterns = [];
222 : List<_EventPropertyCondition> eventProperties = [];
223 : List<_MemberCountCondition> memberCounts = [];
224 : List<String> notificationPermissions = [];
225 : bool matchDisplayname = false;
226 : EvaluatedPushRuleAction actions = EvaluatedPushRuleAction();
227 :
228 35 : _OptimizedRules.fromRule(PushRule rule) {
229 35 : if (!rule.enabled) return;
230 :
231 105 : for (final condition in rule.conditions ?? <PushCondition>[]) {
232 70 : final kind = PushRuleConditions.fromString(condition.kind);
233 : switch (kind) {
234 35 : case PushRuleConditions.eventMatch:
235 105 : patterns.add(_PatternCondition.fromEventMatch(condition));
236 : break;
237 35 : case PushRuleConditions.eventPropertyIs:
238 35 : case PushRuleConditions.eventPropertyContains:
239 2 : eventProperties
240 4 : .add(_EventPropertyCondition.fromEventMatch(condition));
241 : break;
242 35 : case PushRuleConditions.containsDisplayName:
243 35 : matchDisplayname = true;
244 : break;
245 35 : case PushRuleConditions.roomMemberCount:
246 105 : memberCounts.add(_MemberCountCondition.fromEventMatch(condition));
247 : break;
248 3 : case PushRuleConditions.senderNotificationPermission:
249 3 : final key = condition.key;
250 : if (key != null) {
251 6 : notificationPermissions.add(key);
252 : }
253 : break;
254 : default:
255 6 : throw Exception('Unknown push condition: ${condition.kind}');
256 : }
257 : }
258 105 : actions = EvaluatedPushRuleAction.fromActions(rule.actions);
259 : }
260 :
261 2 : EvaluatedPushRuleAction? match(
262 : Map<String, Object?> flattenedEventJson,
263 : String? displayName,
264 : int memberCount,
265 : Room room,
266 : ) {
267 8 : if (patterns.any((pat) => !pat.match(flattenedEventJson))) {
268 : return null;
269 : }
270 8 : if (eventProperties.any((pat) => !pat.match(flattenedEventJson))) {
271 : return null;
272 : }
273 8 : if (memberCounts.any((pat) => !pat.match(memberCount))) {
274 : return null;
275 : }
276 2 : if (matchDisplayname) {
277 2 : final body = flattenedEventJson.tryGet<String>('content.body');
278 : if (displayName == null || body == null) {
279 : return null;
280 : }
281 :
282 2 : final regex = RegExp(
283 4 : '(^|\\W)${RegExp.escape(displayName)}(\$|\\W)',
284 : caseSensitive: false,
285 : );
286 2 : if (!regex.hasMatch(body)) {
287 : return null;
288 : }
289 : }
290 :
291 4 : if (notificationPermissions.isNotEmpty) {
292 2 : final sender = flattenedEventJson.tryGet<String>('sender');
293 : if (sender == null ||
294 4 : notificationPermissions.any(
295 4 : (notificationType) => !room.canSendNotification(
296 : sender,
297 : notificationType: notificationType,
298 : ),
299 : )) {
300 : return null;
301 : }
302 : }
303 :
304 2 : return actions;
305 : }
306 : }
307 :
308 : class PushruleEvaluator {
309 : final List<_OptimizedRules> _override = [];
310 : final Map<String, EvaluatedPushRuleAction> _room_rules = {};
311 : final Map<String, EvaluatedPushRuleAction> _sender_rules = {};
312 : final List<_OptimizedRules> _content_rules = [];
313 : final List<_OptimizedRules> _underride = [];
314 :
315 35 : PushruleEvaluator.fromRuleset(PushRuleSet ruleset) {
316 138 : for (final o in ruleset.override ?? <PushRule>[]) {
317 35 : if (!o.enabled) continue;
318 : try {
319 105 : _override.add(_OptimizedRules.fromRule(o));
320 : } catch (e) {
321 6 : Logs().d('Error parsing push rule $o', e);
322 : }
323 : }
324 138 : for (final u in ruleset.underride ?? <PushRule>[]) {
325 35 : if (!u.enabled) continue;
326 : try {
327 105 : _underride.add(_OptimizedRules.fromRule(u));
328 : } catch (e) {
329 0 : Logs().d('Error parsing push rule $u', e);
330 : }
331 : }
332 138 : for (final c in ruleset.content ?? <PushRule>[]) {
333 35 : if (!c.enabled) continue;
334 35 : final rule = PushRule(
335 35 : actions: c.actions,
336 35 : conditions: [
337 35 : PushCondition(
338 35 : kind: PushRuleConditions.eventMatch.name,
339 : key: 'content.body',
340 35 : pattern: c.pattern,
341 : ),
342 : ],
343 35 : ruleId: c.ruleId,
344 35 : default$: c.default$,
345 35 : enabled: c.enabled,
346 : );
347 : try {
348 105 : _content_rules.add(_OptimizedRules.fromRule(rule));
349 : } catch (e) {
350 6 : Logs().d('Error parsing push rule $rule', e);
351 : }
352 : }
353 138 : for (final r in ruleset.room ?? <PushRule>[]) {
354 35 : if (r.enabled) {
355 175 : _room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions);
356 : }
357 : }
358 105 : for (final r in ruleset.sender ?? <PushRule>[]) {
359 2 : if (r.enabled) {
360 6 : _sender_rules[r.ruleId] =
361 4 : EvaluatedPushRuleAction.fromActions(r.actions);
362 : }
363 : }
364 : }
365 :
366 2 : Map<String, Object?> _flattenJson(
367 : Map<String, dynamic> obj,
368 : Map<String, Object?> flattened,
369 : String prefix,
370 : ) {
371 4 : for (final entry in obj.entries) {
372 8 : final key = prefix == '' ? entry.key : '$prefix.${entry.key}';
373 2 : final value = entry.value;
374 2 : if (value is Map<String, dynamic>) {
375 2 : flattened = _flattenJson(value, flattened, key);
376 : } else {
377 2 : flattened[key] = value;
378 : }
379 : }
380 :
381 : return flattened;
382 : }
383 :
384 2 : EvaluatedPushRuleAction match(Event event) {
385 8 : final memberCount = event.room.getParticipants([Membership.join]).length;
386 2 : final displayName = event.room
387 8 : .unsafeGetUserFromMemoryOrFallback(event.room.client.userID!)
388 2 : .displayName;
389 6 : final flattenedEventJson = _flattenJson(event.toJson(), {}, '');
390 : // ensure roomid is present
391 6 : flattenedEventJson['room_id'] = event.room.id;
392 :
393 4 : for (final o in _override) {
394 : final actions =
395 4 : o.match(flattenedEventJson, displayName, memberCount, event.room);
396 : if (actions != null) {
397 : return actions;
398 : }
399 : }
400 :
401 8 : final roomActions = _room_rules[event.room.id];
402 : if (roomActions != null) {
403 : return roomActions;
404 : }
405 :
406 6 : final senderActions = _sender_rules[event.senderId];
407 : if (senderActions != null) {
408 : return senderActions;
409 : }
410 :
411 4 : for (final o in _content_rules) {
412 : final actions =
413 4 : o.match(flattenedEventJson, displayName, memberCount, event.room);
414 : if (actions != null) {
415 : return actions;
416 : }
417 : }
418 :
419 4 : for (final o in _underride) {
420 : final actions =
421 4 : o.match(flattenedEventJson, displayName, memberCount, event.room);
422 : if (actions != null) {
423 : return actions;
424 : }
425 : }
426 :
427 2 : return EvaluatedPushRuleAction();
428 : }
429 : }
|