LCOV - code coverage report
Current view: top level - lib/src/utils - pushrule_evaluator.dart (source / functions) Hit Total Coverage
Test: merged.info Lines: 139 143 97.2 %
Date: 2024-11-12 07:37:08 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(
     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             : }

Generated by: LCOV version 1.14