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 32 : EvaluatedPushRuleAction();
34 :
35 32 : EvaluatedPushRuleAction.fromActions(List<dynamic> actions) {
36 64 : for (final action in actions) {
37 32 : if (action == 'notify') {
38 32 : notify = true;
39 32 : } else if (action == 'dont_notify') {
40 32 : notify = false;
41 32 : } else if (action is Map<String, dynamic>) {
42 64 : if (action['set_tweak'] == 'highlight') {
43 64 : highlight = action.tryGet<bool>('value') ?? true;
44 64 : } else if (action['set_tweak'] == 'sound') {
45 64 : 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 32 : _PatternCondition.fromEventMatch(PushCondition condition) {
59 64 : if (condition.kind != 'event_match') {
60 0 : throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
61 : }
62 :
63 32 : final tempField = condition.key;
64 : if (tempField == null) {
65 : {
66 : throw 'No field to match pattern on!';
67 : }
68 : }
69 32 : field = tempField;
70 :
71 32 : var tempPat = condition.pattern;
72 : if (tempPat == null) {
73 : {
74 : throw 'PushCondition is missing pattern';
75 : }
76 : }
77 : tempPat =
78 96 : RegExp.escape(tempPat).replaceAll('\\*', '.*').replaceAll('\\?', '.');
79 :
80 64 : if (field == 'content.body') {
81 96 : pattern = RegExp('(^|\\W)$tempPat(\$|\\W)', caseSensitive: false);
82 : } else {
83 96 : 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 32 : _MemberCountCondition.fromEventMatch(PushCondition condition) {
109 64 : if (condition.kind != 'room_member_count') {
110 0 : throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
111 : }
112 :
113 32 : var is_ = condition.is$;
114 :
115 : if (is_ == null) {
116 0 : throw 'Member condition has no condition set: $is_';
117 : }
118 :
119 32 : if (is_.startsWith('==')) {
120 2 : is_ = is_.replaceFirst('==', '');
121 2 : op = _CountComparisonOp.eq;
122 4 : count = int.parse(is_);
123 32 : } else if (is_.startsWith('>=')) {
124 2 : is_ = is_.replaceFirst('>=', '');
125 2 : op = _CountComparisonOp.ge;
126 4 : count = int.parse(is_);
127 32 : } else if (is_.startsWith('<=')) {
128 2 : is_ = is_.replaceFirst('<=', '');
129 2 : op = _CountComparisonOp.le;
130 4 : count = int.parse(is_);
131 32 : } else if (is_.startsWith('>')) {
132 2 : is_ = is_.replaceFirst('>', '');
133 2 : op = _CountComparisonOp.gt;
134 4 : count = int.parse(is_);
135 32 : } else if (is_.startsWith('<')) {
136 2 : is_ = is_.replaceFirst('<', '');
137 2 : op = _CountComparisonOp.lt;
138 4 : count = int.parse(is_);
139 : } else {
140 32 : op = _CountComparisonOp.eq;
141 64 : 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 32 : _OptimizedRules.fromRule(PushRule rule) {
170 32 : if (!rule.enabled) return;
171 :
172 96 : for (final condition in rule.conditions ?? []) {
173 32 : switch (condition.kind) {
174 32 : case 'event_match':
175 96 : patterns.add(_PatternCondition.fromEventMatch(condition));
176 : break;
177 32 : case 'contains_display_name':
178 32 : matchDisplayname = true;
179 : break;
180 32 : case 'room_member_count':
181 96 : 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 96 : actions = EvaluatedPushRuleAction.fromActions(rule.actions);
194 : }
195 :
196 2 : EvaluatedPushRuleAction? match(Map<String, String> event, String? displayName,
197 : int memberCount, Room room) {
198 8 : if (patterns.any((pat) => !pat.match(event))) {
199 : return null;
200 : }
201 8 : if (memberCounts.any((pat) => !pat.match(memberCount))) {
202 : return null;
203 : }
204 2 : if (matchDisplayname) {
205 2 : final body = event.tryGet<String>('content.body');
206 : if (displayName == null || body == null) {
207 : return null;
208 : }
209 :
210 6 : final regex = RegExp('(^|\\W)${RegExp.escape(displayName)}(\$|\\W)',
211 : caseSensitive: false);
212 2 : if (!regex.hasMatch(body)) {
213 : return null;
214 : }
215 : }
216 :
217 4 : if (notificationPermissions.isNotEmpty) {
218 2 : final sender = event.tryGet<String>('sender');
219 : if (sender == null ||
220 6 : notificationPermissions.any((notificationType) =>
221 2 : !room.canSendNotification(sender,
222 : notificationType: notificationType))) {
223 : return null;
224 : }
225 : }
226 :
227 2 : return actions;
228 : }
229 : }
230 :
231 : class PushruleEvaluator {
232 : final List<_OptimizedRules> _override = [];
233 : final Map<String, EvaluatedPushRuleAction> _room_rules = {};
234 : final Map<String, EvaluatedPushRuleAction> _sender_rules = {};
235 : final List<_OptimizedRules> _content_rules = [];
236 : final List<_OptimizedRules> _underride = [];
237 :
238 32 : PushruleEvaluator.fromRuleset(PushRuleSet ruleset) {
239 126 : for (final o in ruleset.override ?? []) {
240 32 : if (!o.enabled) continue;
241 : try {
242 96 : _override.add(_OptimizedRules.fromRule(o));
243 : } catch (e) {
244 6 : Logs().d('Error parsing push rule $o', e);
245 : }
246 : }
247 126 : for (final u in ruleset.underride ?? []) {
248 32 : if (!u.enabled) continue;
249 : try {
250 96 : _underride.add(_OptimizedRules.fromRule(u));
251 : } catch (e) {
252 0 : Logs().d('Error parsing push rule $u', e);
253 : }
254 : }
255 126 : for (final c in ruleset.content ?? []) {
256 32 : if (!c.enabled) continue;
257 32 : final rule = PushRule(
258 32 : actions: c.actions,
259 32 : conditions: [
260 32 : PushCondition(
261 32 : kind: 'event_match', key: 'content.body', pattern: c.pattern)
262 : ],
263 32 : ruleId: c.ruleId,
264 32 : default$: c.default$,
265 32 : enabled: c.enabled,
266 : );
267 : try {
268 96 : _content_rules.add(_OptimizedRules.fromRule(rule));
269 : } catch (e) {
270 6 : Logs().d('Error parsing push rule $rule', e);
271 : }
272 : }
273 126 : for (final r in ruleset.room ?? []) {
274 32 : if (r.enabled) {
275 160 : _room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions);
276 : }
277 : }
278 96 : for (final r in ruleset.sender ?? []) {
279 2 : if (r.enabled) {
280 6 : _sender_rules[r.ruleId] =
281 4 : EvaluatedPushRuleAction.fromActions(r.actions);
282 : }
283 : }
284 : }
285 :
286 2 : Map<String, String> _flattenJson(
287 : Map<String, dynamic> obj, Map<String, String> flattened, String prefix) {
288 4 : for (final entry in obj.entries) {
289 8 : final key = prefix == '' ? entry.key : '$prefix.${entry.key}';
290 2 : final value = entry.value;
291 2 : if (value is String) {
292 2 : flattened[key] = value;
293 2 : } else if (value is Map<String, dynamic>) {
294 2 : flattened = _flattenJson(value, flattened, key);
295 : }
296 : }
297 :
298 : return flattened;
299 : }
300 :
301 2 : EvaluatedPushRuleAction match(Event event) {
302 8 : final memberCount = event.room.getParticipants([Membership.join]).length;
303 2 : final displayName = event.room
304 8 : .unsafeGetUserFromMemoryOrFallback(event.room.client.userID!)
305 2 : .displayName;
306 6 : final content = _flattenJson(event.toJson(), {}, '');
307 : // ensure roomid is present
308 6 : content['room_id'] = event.room.id;
309 :
310 4 : for (final o in _override) {
311 4 : final actions = o.match(content, displayName, memberCount, event.room);
312 : if (actions != null) {
313 : return actions;
314 : }
315 : }
316 :
317 8 : final roomActions = _room_rules[event.room.id];
318 : if (roomActions != null) {
319 : return roomActions;
320 : }
321 :
322 6 : final senderActions = _sender_rules[event.senderId];
323 : if (senderActions != null) {
324 : return senderActions;
325 : }
326 :
327 4 : for (final o in _content_rules) {
328 4 : final actions = o.match(content, displayName, memberCount, event.room);
329 : if (actions != null) {
330 : return actions;
331 : }
332 : }
333 :
334 4 : for (final o in _underride) {
335 4 : final actions = o.match(content, displayName, memberCount, event.room);
336 : if (actions != null) {
337 : return actions;
338 : }
339 : }
340 :
341 2 : return EvaluatedPushRuleAction();
342 : }
343 : }
|