LCOV - code coverage report
Current view: top level - lib/src/utils - pushrule_evaluator.dart (source / functions) Hit Total Coverage
Test: merged.info Lines: 138 142 97.2 %
Date: 2024-09-28 12:47:43 Functions: 0 0 -

          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(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          33 :   PushruleEvaluator.fromRuleset(PushRuleSet ruleset) {
     239         130 :     for (final o in ruleset.override ?? []) {
     240          33 :       if (!o.enabled) continue;
     241             :       try {
     242          99 :         _override.add(_OptimizedRules.fromRule(o));
     243             :       } catch (e) {
     244           6 :         Logs().d('Error parsing push rule $o', e);
     245             :       }
     246             :     }
     247         130 :     for (final u in ruleset.underride ?? []) {
     248          33 :       if (!u.enabled) continue;
     249             :       try {
     250          99 :         _underride.add(_OptimizedRules.fromRule(u));
     251             :       } catch (e) {
     252           0 :         Logs().d('Error parsing push rule $u', e);
     253             :       }
     254             :     }
     255         130 :     for (final c in ruleset.content ?? []) {
     256          33 :       if (!c.enabled) continue;
     257          33 :       final rule = PushRule(
     258          33 :         actions: c.actions,
     259          33 :         conditions: [
     260          33 :           PushCondition(
     261          33 :               kind: 'event_match', key: 'content.body', pattern: c.pattern)
     262             :         ],
     263          33 :         ruleId: c.ruleId,
     264          33 :         default$: c.default$,
     265          33 :         enabled: c.enabled,
     266             :       );
     267             :       try {
     268          99 :         _content_rules.add(_OptimizedRules.fromRule(rule));
     269             :       } catch (e) {
     270           6 :         Logs().d('Error parsing push rule $rule', e);
     271             :       }
     272             :     }
     273         130 :     for (final r in ruleset.room ?? []) {
     274          33 :       if (r.enabled) {
     275         165 :         _room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions);
     276             :       }
     277             :     }
     278          99 :     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             : }

Generated by: LCOV version 1.14