LCOV - code coverage report
Current view: top level - lib/src/utils - pushrule_evaluator.dart (source / functions) Coverage Total Hit
Test: merged.info Lines: 96.6 % 174 168
Test Date: 2025-01-14 13:39:53 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:collection/collection.dart';
      22              : 
      23              : import 'package:matrix/matrix.dart';
      24              : 
      25              : enum PushRuleConditions {
      26              :   eventMatch('event_match'),
      27              :   eventPropertyIs('event_property_is'),
      28              :   eventPropertyContains('event_property_contains'),
      29              :   containsDisplayName('contains_display_name'),
      30              :   roomMemberCount('room_member_count'),
      31              :   senderNotificationPermission('sender_notification_permission');
      32              : 
      33              :   final String name;
      34              :   const PushRuleConditions(this.name);
      35              : 
      36           33 :   static PushRuleConditions? fromString(String name) {
      37          132 :     return values.firstWhereOrNull((e) => e.name == name);
      38              :   }
      39              : }
      40              : 
      41              : class EvaluatedPushRuleAction {
      42              :   // if this message should be highlighted.
      43              :   bool highlight = false;
      44              : 
      45              :   // if this is set, play a sound on a notification. Usually the sound is "default".
      46              :   String? sound;
      47              : 
      48              :   // If this event should notify.
      49              :   bool notify = false;
      50              : 
      51           33 :   EvaluatedPushRuleAction();
      52              : 
      53           33 :   EvaluatedPushRuleAction.fromActions(List<dynamic> actions) {
      54           66 :     for (final action in actions) {
      55           33 :       if (action == 'notify') {
      56           33 :         notify = true;
      57           33 :       } else if (action == 'dont_notify') {
      58           33 :         notify = false;
      59           33 :       } else if (action is Map<String, dynamic>) {
      60           66 :         if (action['set_tweak'] == 'highlight') {
      61           66 :           highlight = action.tryGet<bool>('value') ?? true;
      62           66 :         } else if (action['set_tweak'] == 'sound') {
      63           66 :           sound = action.tryGet<String>('value') ?? 'default';
      64              :         }
      65              :       }
      66              :     }
      67              :   }
      68              : }
      69              : 
      70              : class _PatternCondition {
      71              :   RegExp pattern = RegExp('');
      72              : 
      73              :   // what field to match on, i.e. content.body
      74              :   String field = '';
      75              : 
      76           33 :   _PatternCondition.fromEventMatch(PushCondition condition) {
      77           99 :     if (condition.kind != PushRuleConditions.eventMatch.name) {
      78            0 :       throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
      79              :     }
      80              : 
      81           33 :     final tempField = condition.key;
      82              :     if (tempField == null) {
      83              :       throw 'No field to match pattern on!';
      84              :     }
      85           33 :     field = tempField;
      86              : 
      87           33 :     var tempPat = condition.pattern;
      88              :     if (tempPat == null) {
      89              :       throw 'PushCondition is missing pattern';
      90              :     }
      91              :     tempPat =
      92           99 :         RegExp.escape(tempPat).replaceAll('\\*', '.*').replaceAll('\\?', '.');
      93              : 
      94           66 :     if (field == 'content.body') {
      95           99 :       pattern = RegExp('(^|\\W)$tempPat(\$|\\W)', caseSensitive: false);
      96              :     } else {
      97           99 :       pattern = RegExp('^$tempPat\$', caseSensitive: false);
      98              :     }
      99              :   }
     100              : 
     101            2 :   bool match(Map<String, Object?> flattenedEventJson) {
     102            4 :     final fieldContent = flattenedEventJson[field];
     103            2 :     if (fieldContent == null || fieldContent is! String) {
     104              :       return false;
     105              :     }
     106            4 :     return pattern.hasMatch(fieldContent);
     107              :   }
     108              : }
     109              : 
     110              : class _EventPropertyCondition {
     111              :   PushRuleConditions? kind;
     112              :   // what field to match on, i.e. content.body
     113              :   String field = '';
     114              :   Object? value;
     115              : 
     116            2 :   _EventPropertyCondition.fromEventMatch(PushCondition condition) {
     117            2 :     if (![
     118            2 :       PushRuleConditions.eventPropertyIs.name,
     119            2 :       PushRuleConditions.eventPropertyContains.name,
     120            4 :     ].contains(condition.kind)) {
     121            0 :       throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
     122              :     }
     123            6 :     kind = PushRuleConditions.fromString(condition.kind);
     124              : 
     125            2 :     final tempField = condition.key;
     126              :     if (tempField == null) {
     127              :       throw 'No field to check event property on!';
     128              :     }
     129            2 :     field = tempField;
     130              : 
     131            2 :     final tempValue = condition.value;
     132            6 :     if (![String, int, bool, Null].contains(tempValue.runtimeType)) {
     133              :       throw 'PushCondition value is not a string, int, bool or null';
     134              :     }
     135            2 :     value = tempValue;
     136              :   }
     137              : 
     138            2 :   bool match(Map<String, Object?> flattenedEventJson) {
     139            4 :     final fieldContent = flattenedEventJson[field];
     140            2 :     switch (kind) {
     141            2 :       case PushRuleConditions.eventPropertyIs:
     142              :         // We check if the property exists because null is a valid property value.
     143            6 :         if (!flattenedEventJson.keys.contains(field)) return false;
     144            4 :         return fieldContent == value;
     145            2 :       case PushRuleConditions.eventPropertyContains:
     146            2 :         if (fieldContent is! Iterable) return false;
     147            4 :         return fieldContent.contains(value);
     148              :       default:
     149              :         // This should never happen
     150            0 :         throw 'Logic error: invalid push rule passed in _EventPropertyCondition ${kind?.name}';
     151              :     }
     152              :   }
     153              : }
     154              : 
     155              : enum _CountComparisonOp {
     156              :   eq,
     157              :   lt,
     158              :   le,
     159              :   ge,
     160              :   gt,
     161              : }
     162              : 
     163              : class _MemberCountCondition {
     164              :   _CountComparisonOp op = _CountComparisonOp.eq;
     165              :   int count = 0;
     166              : 
     167           33 :   _MemberCountCondition.fromEventMatch(PushCondition condition) {
     168           99 :     if (condition.kind != PushRuleConditions.roomMemberCount.name) {
     169            0 :       throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
     170              :     }
     171              : 
     172           33 :     var is_ = condition.is$;
     173              : 
     174              :     if (is_ == null) {
     175            0 :       throw 'Member condition has no condition set: $is_';
     176              :     }
     177              : 
     178           33 :     if (is_.startsWith('==')) {
     179            2 :       is_ = is_.replaceFirst('==', '');
     180            2 :       op = _CountComparisonOp.eq;
     181            4 :       count = int.parse(is_);
     182           33 :     } else if (is_.startsWith('>=')) {
     183            2 :       is_ = is_.replaceFirst('>=', '');
     184            2 :       op = _CountComparisonOp.ge;
     185            4 :       count = int.parse(is_);
     186           33 :     } else if (is_.startsWith('<=')) {
     187            2 :       is_ = is_.replaceFirst('<=', '');
     188            2 :       op = _CountComparisonOp.le;
     189            4 :       count = int.parse(is_);
     190           33 :     } else if (is_.startsWith('>')) {
     191            2 :       is_ = is_.replaceFirst('>', '');
     192            2 :       op = _CountComparisonOp.gt;
     193            4 :       count = int.parse(is_);
     194           33 :     } else if (is_.startsWith('<')) {
     195            2 :       is_ = is_.replaceFirst('<', '');
     196            2 :       op = _CountComparisonOp.lt;
     197            4 :       count = int.parse(is_);
     198              :     } else {
     199           33 :       op = _CountComparisonOp.eq;
     200           66 :       count = int.parse(is_);
     201              :     }
     202              :   }
     203              : 
     204            2 :   bool match(int memberCount) {
     205            2 :     switch (op) {
     206            2 :       case _CountComparisonOp.ge:
     207            4 :         return memberCount >= count;
     208            2 :       case _CountComparisonOp.gt:
     209            4 :         return memberCount > count;
     210            2 :       case _CountComparisonOp.le:
     211            4 :         return memberCount <= count;
     212            2 :       case _CountComparisonOp.lt:
     213            4 :         return memberCount < count;
     214            2 :       case _CountComparisonOp.eq:
     215            4 :         return memberCount == count;
     216              :     }
     217              :   }
     218              : }
     219              : 
     220              : class _OptimizedRules {
     221              :   List<_PatternCondition> patterns = [];
     222              :   List<_EventPropertyCondition> eventProperties = [];
     223              :   List<_MemberCountCondition> memberCounts = [];
     224              :   List<String> notificationPermissions = [];
     225              :   bool matchDisplayname = false;
     226              :   EvaluatedPushRuleAction actions = EvaluatedPushRuleAction();
     227              : 
     228           33 :   _OptimizedRules.fromRule(PushRule rule) {
     229           33 :     if (!rule.enabled) return;
     230              : 
     231           99 :     for (final condition in rule.conditions ?? <PushCondition>[]) {
     232           66 :       final kind = PushRuleConditions.fromString(condition.kind);
     233              :       switch (kind) {
     234           33 :         case PushRuleConditions.eventMatch:
     235           99 :           patterns.add(_PatternCondition.fromEventMatch(condition));
     236              :           break;
     237           33 :         case PushRuleConditions.eventPropertyIs:
     238           33 :         case PushRuleConditions.eventPropertyContains:
     239            2 :           eventProperties
     240            4 :               .add(_EventPropertyCondition.fromEventMatch(condition));
     241              :           break;
     242           33 :         case PushRuleConditions.containsDisplayName:
     243           33 :           matchDisplayname = true;
     244              :           break;
     245           33 :         case PushRuleConditions.roomMemberCount:
     246           99 :           memberCounts.add(_MemberCountCondition.fromEventMatch(condition));
     247              :           break;
     248            3 :         case PushRuleConditions.senderNotificationPermission:
     249            3 :           final key = condition.key;
     250              :           if (key != null) {
     251            6 :             notificationPermissions.add(key);
     252              :           }
     253              :           break;
     254              :         default:
     255            6 :           throw Exception('Unknown push condition: ${condition.kind}');
     256              :       }
     257              :     }
     258           99 :     actions = EvaluatedPushRuleAction.fromActions(rule.actions);
     259              :   }
     260              : 
     261            2 :   EvaluatedPushRuleAction? match(
     262              :     Map<String, Object?> flattenedEventJson,
     263              :     String? displayName,
     264              :     int memberCount,
     265              :     Room room,
     266              :   ) {
     267            8 :     if (patterns.any((pat) => !pat.match(flattenedEventJson))) {
     268              :       return null;
     269              :     }
     270            8 :     if (eventProperties.any((pat) => !pat.match(flattenedEventJson))) {
     271              :       return null;
     272              :     }
     273            8 :     if (memberCounts.any((pat) => !pat.match(memberCount))) {
     274              :       return null;
     275              :     }
     276            2 :     if (matchDisplayname) {
     277            2 :       final body = flattenedEventJson.tryGet<String>('content.body');
     278              :       if (displayName == null || body == null) {
     279              :         return null;
     280              :       }
     281              : 
     282            2 :       final regex = RegExp(
     283            4 :         '(^|\\W)${RegExp.escape(displayName)}(\$|\\W)',
     284              :         caseSensitive: false,
     285              :       );
     286            2 :       if (!regex.hasMatch(body)) {
     287              :         return null;
     288              :       }
     289              :     }
     290              : 
     291            4 :     if (notificationPermissions.isNotEmpty) {
     292            2 :       final sender = flattenedEventJson.tryGet<String>('sender');
     293              :       if (sender == null ||
     294            4 :           notificationPermissions.any(
     295            4 :             (notificationType) => !room.canSendNotification(
     296              :               sender,
     297              :               notificationType: notificationType,
     298              :             ),
     299              :           )) {
     300              :         return null;
     301              :       }
     302              :     }
     303              : 
     304            2 :     return actions;
     305              :   }
     306              : }
     307              : 
     308              : class PushruleEvaluator {
     309              :   final List<_OptimizedRules> _override = [];
     310              :   final Map<String, EvaluatedPushRuleAction> _room_rules = {};
     311              :   final Map<String, EvaluatedPushRuleAction> _sender_rules = {};
     312              :   final List<_OptimizedRules> _content_rules = [];
     313              :   final List<_OptimizedRules> _underride = [];
     314              : 
     315           33 :   PushruleEvaluator.fromRuleset(PushRuleSet ruleset) {
     316          130 :     for (final o in ruleset.override ?? <PushRule>[]) {
     317           33 :       if (!o.enabled) continue;
     318              :       try {
     319           99 :         _override.add(_OptimizedRules.fromRule(o));
     320              :       } catch (e) {
     321            6 :         Logs().d('Error parsing push rule $o', e);
     322              :       }
     323              :     }
     324          130 :     for (final u in ruleset.underride ?? <PushRule>[]) {
     325           33 :       if (!u.enabled) continue;
     326              :       try {
     327           99 :         _underride.add(_OptimizedRules.fromRule(u));
     328              :       } catch (e) {
     329            0 :         Logs().d('Error parsing push rule $u', e);
     330              :       }
     331              :     }
     332          130 :     for (final c in ruleset.content ?? <PushRule>[]) {
     333           33 :       if (!c.enabled) continue;
     334           33 :       final rule = PushRule(
     335           33 :         actions: c.actions,
     336           33 :         conditions: [
     337           33 :           PushCondition(
     338           33 :             kind: PushRuleConditions.eventMatch.name,
     339              :             key: 'content.body',
     340           33 :             pattern: c.pattern,
     341              :           ),
     342              :         ],
     343           33 :         ruleId: c.ruleId,
     344           33 :         default$: c.default$,
     345           33 :         enabled: c.enabled,
     346              :       );
     347              :       try {
     348           99 :         _content_rules.add(_OptimizedRules.fromRule(rule));
     349              :       } catch (e) {
     350            6 :         Logs().d('Error parsing push rule $rule', e);
     351              :       }
     352              :     }
     353          130 :     for (final r in ruleset.room ?? <PushRule>[]) {
     354           33 :       if (r.enabled) {
     355          165 :         _room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions);
     356              :       }
     357              :     }
     358           99 :     for (final r in ruleset.sender ?? <PushRule>[]) {
     359            2 :       if (r.enabled) {
     360            6 :         _sender_rules[r.ruleId] =
     361            4 :             EvaluatedPushRuleAction.fromActions(r.actions);
     362              :       }
     363              :     }
     364              :   }
     365              : 
     366            2 :   Map<String, Object?> _flattenJson(
     367              :     Map<String, dynamic> obj,
     368              :     Map<String, Object?> flattened,
     369              :     String prefix,
     370              :   ) {
     371            4 :     for (final entry in obj.entries) {
     372            8 :       final key = prefix == '' ? entry.key : '$prefix.${entry.key}';
     373            2 :       final value = entry.value;
     374            2 :       if (value is Map<String, dynamic>) {
     375            2 :         flattened = _flattenJson(value, flattened, key);
     376              :       } else {
     377            2 :         flattened[key] = value;
     378              :       }
     379              :     }
     380              : 
     381              :     return flattened;
     382              :   }
     383              : 
     384            2 :   EvaluatedPushRuleAction match(Event event) {
     385            8 :     final memberCount = event.room.getParticipants([Membership.join]).length;
     386            2 :     final displayName = event.room
     387            8 :         .unsafeGetUserFromMemoryOrFallback(event.room.client.userID!)
     388            2 :         .displayName;
     389            6 :     final flattenedEventJson = _flattenJson(event.toJson(), {}, '');
     390              :     // ensure roomid is present
     391            6 :     flattenedEventJson['room_id'] = event.room.id;
     392              : 
     393            4 :     for (final o in _override) {
     394              :       final actions =
     395            4 :           o.match(flattenedEventJson, displayName, memberCount, event.room);
     396              :       if (actions != null) {
     397              :         return actions;
     398              :       }
     399              :     }
     400              : 
     401            8 :     final roomActions = _room_rules[event.room.id];
     402              :     if (roomActions != null) {
     403              :       return roomActions;
     404              :     }
     405              : 
     406            6 :     final senderActions = _sender_rules[event.senderId];
     407              :     if (senderActions != null) {
     408              :       return senderActions;
     409              :     }
     410              : 
     411            4 :     for (final o in _content_rules) {
     412              :       final actions =
     413            4 :           o.match(flattenedEventJson, displayName, memberCount, event.room);
     414              :       if (actions != null) {
     415              :         return actions;
     416              :       }
     417              :     }
     418              : 
     419            4 :     for (final o in _underride) {
     420              :       final actions =
     421            4 :           o.match(flattenedEventJson, displayName, memberCount, event.room);
     422              :       if (actions != null) {
     423              :         return actions;
     424              :       }
     425              :     }
     426              : 
     427            2 :     return EvaluatedPushRuleAction();
     428              :   }
     429              : }
        

Generated by: LCOV version 2.0-1