/* * Famedly Matrix SDK * Copyright (C) 2019, 2020, 2021 Famedly GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ // Helper for fast evaluation of push conditions on a bunch of events import 'package:matrix/matrix.dart'; class EvaluatedPushRuleAction { // if this message should be highlighted. bool highlight = false; // if this is set, play a sound on a notification. Usually the sound is "default". String? sound; // If this event should notify. bool notify = false; EvaluatedPushRuleAction(); EvaluatedPushRuleAction.fromActions(List actions) { for (final action in actions) { if (action == 'notify') { notify = true; } else if (action == 'dont_notify') { notify = false; } else if (action is Map) { if (action['set_tweak'] == 'highlight') { highlight = action.tryGet('value') ?? true; } else if (action['set_tweak'] == 'sound') { sound = action.tryGet('value') ?? 'default'; } } } } } class _PatternCondition { RegExp pattern = RegExp(''); // what field to match on, i.e. content.body String field = ''; _PatternCondition.fromEventMatch(PushCondition condition) { if (condition.kind != 'event_match') { 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('\\?', '.'); if (field == 'content.body') { pattern = RegExp('(^|\\W)$tempPat(\$|\\W)', caseSensitive: false); } else { pattern = RegExp('^$tempPat\$', caseSensitive: false); } } bool match(Map content) { final fieldContent = content[field]; if (fieldContent == null) { return false; } return pattern.hasMatch(fieldContent); } } enum _CountComparisonOp { eq, lt, le, ge, gt, } class _MemberCountCondition { _CountComparisonOp op = _CountComparisonOp.eq; int count = 0; _MemberCountCondition.fromEventMatch(PushCondition condition) { if (condition.kind != 'room_member_count') { throw 'Logic error: invalid push rule passed to constructor ${condition.kind}'; } var is_ = condition.is$; if (is_ == null) { throw 'Member condition has no condition set: $is_'; } if (is_.startsWith('==')) { is_ = is_.replaceFirst('==', ''); op = _CountComparisonOp.eq; count = int.parse(is_); } else if (is_.startsWith('>=')) { is_ = is_.replaceFirst('>=', ''); op = _CountComparisonOp.ge; count = int.parse(is_); } else if (is_.startsWith('<=')) { is_ = is_.replaceFirst('<=', ''); op = _CountComparisonOp.le; count = int.parse(is_); } else if (is_.startsWith('>')) { is_ = is_.replaceFirst('>', ''); op = _CountComparisonOp.gt; count = int.parse(is_); } else if (is_.startsWith('<')) { is_ = is_.replaceFirst('<', ''); op = _CountComparisonOp.lt; count = int.parse(is_); } else { op = _CountComparisonOp.eq; count = int.parse(is_); } } bool match(int memberCount) { switch (op) { case _CountComparisonOp.ge: return memberCount >= count; case _CountComparisonOp.gt: return memberCount > count; case _CountComparisonOp.le: return memberCount <= count; case _CountComparisonOp.lt: return memberCount < count; case _CountComparisonOp.eq: return memberCount == count; } } } class _OptimizedRules { List<_PatternCondition> patterns = []; List<_MemberCountCondition> memberCounts = []; List notificationPermissions = []; bool matchDisplayname = false; EvaluatedPushRuleAction actions = EvaluatedPushRuleAction(); _OptimizedRules.fromRule(PushRule rule) { if (!rule.enabled) return; for (final condition in rule.conditions ?? []) { switch (condition.kind) { case 'event_match': patterns.add(_PatternCondition.fromEventMatch(condition)); break; case 'contains_display_name': matchDisplayname = true; break; case 'room_member_count': memberCounts.add(_MemberCountCondition.fromEventMatch(condition)); break; case 'sender_notification_permission': final key = condition.key; if (key != null) { notificationPermissions.add(key); } break; default: throw Exception('Unknown push condition: ${condition.kind}'); } } actions = EvaluatedPushRuleAction.fromActions(rule.actions); } EvaluatedPushRuleAction? match( Map event, String? displayName, int memberCount, Room room, ) { if (patterns.any((pat) => !pat.match(event))) { return null; } if (memberCounts.any((pat) => !pat.match(memberCount))) { return null; } if (matchDisplayname) { final body = event.tryGet('content.body'); if (displayName == null || body == null) { return null; } final regex = RegExp( '(^|\\W)${RegExp.escape(displayName)}(\$|\\W)', caseSensitive: false, ); if (!regex.hasMatch(body)) { return null; } } if (notificationPermissions.isNotEmpty) { final sender = event.tryGet('sender'); if (sender == null || notificationPermissions.any( (notificationType) => !room.canSendNotification( sender, notificationType: notificationType, ), )) { return null; } } return actions; } } class PushruleEvaluator { final List<_OptimizedRules> _override = []; final Map _room_rules = {}; final Map _sender_rules = {}; final List<_OptimizedRules> _content_rules = []; final List<_OptimizedRules> _underride = []; PushruleEvaluator.fromRuleset(PushRuleSet ruleset) { for (final o in ruleset.override ?? []) { if (!o.enabled) continue; try { _override.add(_OptimizedRules.fromRule(o)); } catch (e) { Logs().d('Error parsing push rule $o', e); } } for (final u in ruleset.underride ?? []) { if (!u.enabled) continue; try { _underride.add(_OptimizedRules.fromRule(u)); } catch (e) { Logs().d('Error parsing push rule $u', e); } } for (final c in ruleset.content ?? []) { if (!c.enabled) continue; final rule = PushRule( actions: c.actions, conditions: [ PushCondition( kind: 'event_match', key: 'content.body', pattern: c.pattern, ), ], ruleId: c.ruleId, default$: c.default$, enabled: c.enabled, ); try { _content_rules.add(_OptimizedRules.fromRule(rule)); } catch (e) { Logs().d('Error parsing push rule $rule', e); } } for (final r in ruleset.room ?? []) { if (r.enabled) { _room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions); } } for (final r in ruleset.sender ?? []) { if (r.enabled) { _sender_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions); } } } Map _flattenJson( Map obj, Map 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) { flattened = _flattenJson(value, flattened, key); } } return flattened; } EvaluatedPushRuleAction match(Event event) { final memberCount = event.room.getParticipants([Membership.join]).length; final displayName = event.room .unsafeGetUserFromMemoryOrFallback(event.room.client.userID!) .displayName; final content = _flattenJson(event.toJson(), {}, ''); // ensure roomid is present content['room_id'] = event.room.id; for (final o in _override) { final actions = o.match(content, displayName, memberCount, event.room); if (actions != null) { return actions; } } final roomActions = _room_rules[event.room.id]; if (roomActions != null) { return roomActions; } final senderActions = _sender_rules[event.senderId]; if (senderActions != null) { return senderActions; } for (final o in _content_rules) { final actions = o.match(content, displayName, memberCount, event.room); if (actions != null) { return actions; } } for (final o in _underride) { final actions = o.match(content, displayName, memberCount, event.room); if (actions != null) { return actions; } } return EvaluatedPushRuleAction(); } }