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:matrix/matrix.dart';
22 :
23 : class EvaluatedPushRuleAction {
24 : // if this message should be highlighted.
25 : bool highlight = false;
26 :
27 : // if this is set, play a sound on a notification. Usually the sound is "default".
28 : String? sound;
29 :
30 : // If this event should notify.
31 : bool notify = false;
32 :
33 33 : EvaluatedPushRuleAction();
34 :
35 33 : EvaluatedPushRuleAction.fromActions(List<dynamic> actions) {
36 66 : for (final action in actions) {
37 33 : if (action == 'notify') {
38 33 : notify = true;
39 33 : } else if (action == 'dont_notify') {
40 33 : notify = false;
41 33 : } else if (action is Map<String, dynamic>) {
42 66 : if (action['set_tweak'] == 'highlight') {
43 66 : highlight = action.tryGet<bool>('value') ?? true;
44 66 : } else if (action['set_tweak'] == 'sound') {
45 66 : sound = action.tryGet<String>('value') ?? 'default';
46 : }
47 : }
48 : }
49 : }
50 : }
51 :
52 : class _PatternCondition {
53 : RegExp pattern = RegExp('');
54 :
55 : // what field to match on, i.e. content.body
56 : String field = '';
57 :
58 33 : _PatternCondition.fromEventMatch(PushCondition condition) {
59 66 : if (condition.kind != 'event_match') {
60 0 : throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
61 : }
62 :
63 33 : final tempField = condition.key;
64 : if (tempField == null) {
65 : {
66 : throw 'No field to match pattern on!';
67 : }
68 : }
69 33 : field = tempField;
70 :
71 33 : var tempPat = condition.pattern;
72 : if (tempPat == null) {
73 : {
74 : throw 'PushCondition is missing pattern';
75 : }
76 : }
77 : tempPat =
78 99 : RegExp.escape(tempPat).replaceAll('\\*', '.*').replaceAll('\\?', '.');
79 :
80 66 : if (field == 'content.body') {
81 99 : pattern = RegExp('(^|\\W)$tempPat(\$|\\W)', caseSensitive: false);
82 : } else {
83 99 : pattern = RegExp('^$tempPat\$', caseSensitive: false);
84 : }
85 : }
86 :
87 2 : bool match(Map<String, String> content) {
88 4 : final fieldContent = content[field];
89 : if (fieldContent == null) {
90 : return false;
91 : }
92 4 : return pattern.hasMatch(fieldContent);
93 : }
94 : }
95 :
96 : enum _CountComparisonOp {
97 : eq,
98 : lt,
99 : le,
100 : ge,
101 : gt,
102 : }
103 :
104 : class _MemberCountCondition {
105 : _CountComparisonOp op = _CountComparisonOp.eq;
106 : int count = 0;
107 :
108 33 : _MemberCountCondition.fromEventMatch(PushCondition condition) {
109 66 : if (condition.kind != 'room_member_count') {
110 0 : throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
111 : }
112 :
113 33 : var is_ = condition.is$;
114 :
115 : if (is_ == null) {
116 0 : throw 'Member condition has no condition set: $is_';
117 : }
118 :
119 33 : if (is_.startsWith('==')) {
120 2 : is_ = is_.replaceFirst('==', '');
121 2 : op = _CountComparisonOp.eq;
122 4 : count = int.parse(is_);
123 33 : } else if (is_.startsWith('>=')) {
124 2 : is_ = is_.replaceFirst('>=', '');
125 2 : op = _CountComparisonOp.ge;
126 4 : count = int.parse(is_);
127 33 : } else if (is_.startsWith('<=')) {
128 2 : is_ = is_.replaceFirst('<=', '');
129 2 : op = _CountComparisonOp.le;
130 4 : count = int.parse(is_);
131 33 : } else if (is_.startsWith('>')) {
132 2 : is_ = is_.replaceFirst('>', '');
133 2 : op = _CountComparisonOp.gt;
134 4 : count = int.parse(is_);
135 33 : } else if (is_.startsWith('<')) {
136 2 : is_ = is_.replaceFirst('<', '');
137 2 : op = _CountComparisonOp.lt;
138 4 : count = int.parse(is_);
139 : } else {
140 33 : op = _CountComparisonOp.eq;
141 66 : count = int.parse(is_);
142 : }
143 : }
144 :
145 2 : bool match(int memberCount) {
146 2 : switch (op) {
147 2 : case _CountComparisonOp.ge:
148 4 : return memberCount >= count;
149 2 : case _CountComparisonOp.gt:
150 4 : return memberCount > count;
151 2 : case _CountComparisonOp.le:
152 4 : return memberCount <= count;
153 2 : case _CountComparisonOp.lt:
154 4 : return memberCount < count;
155 : case _CountComparisonOp.eq:
156 : default:
157 4 : return memberCount == count;
158 : }
159 : }
160 : }
161 :
162 : class _OptimizedRules {
163 : List<_PatternCondition> patterns = [];
164 : List<_MemberCountCondition> memberCounts = [];
165 : List<String> notificationPermissions = [];
166 : bool matchDisplayname = false;
167 : EvaluatedPushRuleAction actions = EvaluatedPushRuleAction();
168 :
169 33 : _OptimizedRules.fromRule(PushRule rule) {
170 33 : if (!rule.enabled) return;
171 :
172 99 : for (final condition in rule.conditions ?? []) {
173 33 : switch (condition.kind) {
174 33 : case 'event_match':
175 99 : patterns.add(_PatternCondition.fromEventMatch(condition));
176 : break;
177 33 : case 'contains_display_name':
178 33 : matchDisplayname = true;
179 : break;
180 33 : case 'room_member_count':
181 99 : memberCounts.add(_MemberCountCondition.fromEventMatch(condition));
182 : break;
183 3 : case 'sender_notification_permission':
184 3 : final key = condition.key;
185 : if (key != null) {
186 6 : notificationPermissions.add(key);
187 : }
188 : break;
189 : default:
190 6 : throw Exception('Unknown push condition: ${condition.kind}');
191 : }
192 : }
193 99 : actions = EvaluatedPushRuleAction.fromActions(rule.actions);
194 : }
195 :
196 2 : EvaluatedPushRuleAction? match(
197 : Map<String, String> event,
198 : String? displayName,
199 : int memberCount,
200 : Room room,
201 : ) {
202 8 : if (patterns.any((pat) => !pat.match(event))) {
203 : return null;
204 : }
205 8 : if (memberCounts.any((pat) => !pat.match(memberCount))) {
206 : return null;
207 : }
208 2 : if (matchDisplayname) {
209 2 : final body = event.tryGet<String>('content.body');
210 : if (displayName == null || body == null) {
211 : return null;
212 : }
213 :
214 2 : final regex = RegExp(
215 4 : '(^|\\W)${RegExp.escape(displayName)}(\$|\\W)',
216 : caseSensitive: false,
217 : );
218 2 : if (!regex.hasMatch(body)) {
219 : return null;
220 : }
221 : }
222 :
223 4 : if (notificationPermissions.isNotEmpty) {
224 2 : final sender = event.tryGet<String>('sender');
225 : if (sender == null ||
226 4 : notificationPermissions.any(
227 4 : (notificationType) => !room.canSendNotification(
228 : sender,
229 : notificationType: notificationType,
230 : ),
231 : )) {
232 : return null;
233 : }
234 : }
235 :
236 2 : return actions;
237 : }
238 : }
239 :
240 : class PushruleEvaluator {
241 : final List<_OptimizedRules> _override = [];
242 : final Map<String, EvaluatedPushRuleAction> _room_rules = {};
243 : final Map<String, EvaluatedPushRuleAction> _sender_rules = {};
244 : final List<_OptimizedRules> _content_rules = [];
245 : final List<_OptimizedRules> _underride = [];
246 :
247 33 : PushruleEvaluator.fromRuleset(PushRuleSet ruleset) {
248 130 : for (final o in ruleset.override ?? []) {
249 33 : if (!o.enabled) continue;
250 : try {
251 99 : _override.add(_OptimizedRules.fromRule(o));
252 : } catch (e) {
253 6 : Logs().d('Error parsing push rule $o', e);
254 : }
255 : }
256 130 : for (final u in ruleset.underride ?? []) {
257 33 : if (!u.enabled) continue;
258 : try {
259 99 : _underride.add(_OptimizedRules.fromRule(u));
260 : } catch (e) {
261 0 : Logs().d('Error parsing push rule $u', e);
262 : }
263 : }
264 130 : for (final c in ruleset.content ?? []) {
265 33 : if (!c.enabled) continue;
266 33 : final rule = PushRule(
267 33 : actions: c.actions,
268 33 : conditions: [
269 33 : PushCondition(
270 : kind: 'event_match',
271 : key: 'content.body',
272 33 : pattern: c.pattern,
273 : ),
274 : ],
275 33 : ruleId: c.ruleId,
276 33 : default$: c.default$,
277 33 : enabled: c.enabled,
278 : );
279 : try {
280 99 : _content_rules.add(_OptimizedRules.fromRule(rule));
281 : } catch (e) {
282 6 : Logs().d('Error parsing push rule $rule', e);
283 : }
284 : }
285 130 : for (final r in ruleset.room ?? []) {
286 33 : if (r.enabled) {
287 165 : _room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions);
288 : }
289 : }
290 99 : for (final r in ruleset.sender ?? []) {
291 2 : if (r.enabled) {
292 6 : _sender_rules[r.ruleId] =
293 4 : EvaluatedPushRuleAction.fromActions(r.actions);
294 : }
295 : }
296 : }
297 :
298 2 : Map<String, String> _flattenJson(
299 : Map<String, dynamic> obj,
300 : Map<String, String> flattened,
301 : String prefix,
302 : ) {
303 4 : for (final entry in obj.entries) {
304 8 : final key = prefix == '' ? entry.key : '$prefix.${entry.key}';
305 2 : final value = entry.value;
306 2 : if (value is String) {
307 2 : flattened[key] = value;
308 2 : } else if (value is Map<String, dynamic>) {
309 2 : flattened = _flattenJson(value, flattened, key);
310 : }
311 : }
312 :
313 : return flattened;
314 : }
315 :
316 2 : EvaluatedPushRuleAction match(Event event) {
317 8 : final memberCount = event.room.getParticipants([Membership.join]).length;
318 2 : final displayName = event.room
319 8 : .unsafeGetUserFromMemoryOrFallback(event.room.client.userID!)
320 2 : .displayName;
321 6 : final content = _flattenJson(event.toJson(), {}, '');
322 : // ensure roomid is present
323 6 : content['room_id'] = event.room.id;
324 :
325 4 : for (final o in _override) {
326 4 : final actions = o.match(content, displayName, memberCount, event.room);
327 : if (actions != null) {
328 : return actions;
329 : }
330 : }
331 :
332 8 : final roomActions = _room_rules[event.room.id];
333 : if (roomActions != null) {
334 : return roomActions;
335 : }
336 :
337 6 : final senderActions = _sender_rules[event.senderId];
338 : if (senderActions != null) {
339 : return senderActions;
340 : }
341 :
342 4 : for (final o in _content_rules) {
343 4 : final actions = o.match(content, displayName, memberCount, event.room);
344 : if (actions != null) {
345 : return actions;
346 : }
347 : }
348 :
349 4 : for (final o in _underride) {
350 4 : final actions = o.match(content, displayName, memberCount, event.room);
351 : if (actions != null) {
352 : return actions;
353 : }
354 : }
355 :
356 2 : return EvaluatedPushRuleAction();
357 : }
358 : }
|