LCOV - code coverage report
Current view: top level - lib/src/utils - pushrule_evaluator.dart (source / functions) Hit Total Coverage
Test: merged.info Lines: 168 174 96.6 %
Date: 2025-01-06 12:44:40 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          35 :   static PushRuleConditions? fromString(String name) {
      37         140 :     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          35 :   EvaluatedPushRuleAction();
      52             : 
      53          35 :   EvaluatedPushRuleAction.fromActions(List<dynamic> actions) {
      54          70 :     for (final action in actions) {
      55          35 :       if (action == 'notify') {
      56          35 :         notify = true;
      57          35 :       } else if (action == 'dont_notify') {
      58          35 :         notify = false;
      59          35 :       } else if (action is Map<String, dynamic>) {
      60          70 :         if (action['set_tweak'] == 'highlight') {
      61          70 :           highlight = action.tryGet<bool>('value') ?? true;
      62          70 :         } else if (action['set_tweak'] == 'sound') {
      63          70 :           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          35 :   _PatternCondition.fromEventMatch(PushCondition condition) {
      77         105 :     if (condition.kind != PushRuleConditions.eventMatch.name) {
      78           0 :       throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
      79             :     }
      80             : 
      81          35 :     final tempField = condition.key;
      82             :     if (tempField == null) {
      83             :       throw 'No field to match pattern on!';
      84             :     }
      85          35 :     field = tempField;
      86             : 
      87          35 :     var tempPat = condition.pattern;
      88             :     if (tempPat == null) {
      89             :       throw 'PushCondition is missing pattern';
      90             :     }
      91             :     tempPat =
      92         105 :         RegExp.escape(tempPat).replaceAll('\\*', '.*').replaceAll('\\?', '.');
      93             : 
      94          70 :     if (field == 'content.body') {
      95         105 :       pattern = RegExp('(^|\\W)$tempPat(\$|\\W)', caseSensitive: false);
      96             :     } else {
      97         105 :       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          35 :   _MemberCountCondition.fromEventMatch(PushCondition condition) {
     168         105 :     if (condition.kind != PushRuleConditions.roomMemberCount.name) {
     169           0 :       throw 'Logic error: invalid push rule passed to constructor ${condition.kind}';
     170             :     }
     171             : 
     172          35 :     var is_ = condition.is$;
     173             : 
     174             :     if (is_ == null) {
     175           0 :       throw 'Member condition has no condition set: $is_';
     176             :     }
     177             : 
     178          35 :     if (is_.startsWith('==')) {
     179           2 :       is_ = is_.replaceFirst('==', '');
     180           2 :       op = _CountComparisonOp.eq;
     181           4 :       count = int.parse(is_);
     182          35 :     } else if (is_.startsWith('>=')) {
     183           2 :       is_ = is_.replaceFirst('>=', '');
     184           2 :       op = _CountComparisonOp.ge;
     185           4 :       count = int.parse(is_);
     186          35 :     } else if (is_.startsWith('<=')) {
     187           2 :       is_ = is_.replaceFirst('<=', '');
     188           2 :       op = _CountComparisonOp.le;
     189           4 :       count = int.parse(is_);
     190          35 :     } else if (is_.startsWith('>')) {
     191           2 :       is_ = is_.replaceFirst('>', '');
     192           2 :       op = _CountComparisonOp.gt;
     193           4 :       count = int.parse(is_);
     194          35 :     } else if (is_.startsWith('<')) {
     195           2 :       is_ = is_.replaceFirst('<', '');
     196           2 :       op = _CountComparisonOp.lt;
     197           4 :       count = int.parse(is_);
     198             :     } else {
     199          35 :       op = _CountComparisonOp.eq;
     200          70 :       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          35 :   _OptimizedRules.fromRule(PushRule rule) {
     229          35 :     if (!rule.enabled) return;
     230             : 
     231         105 :     for (final condition in rule.conditions ?? <PushCondition>[]) {
     232          70 :       final kind = PushRuleConditions.fromString(condition.kind);
     233             :       switch (kind) {
     234          35 :         case PushRuleConditions.eventMatch:
     235         105 :           patterns.add(_PatternCondition.fromEventMatch(condition));
     236             :           break;
     237          35 :         case PushRuleConditions.eventPropertyIs:
     238          35 :         case PushRuleConditions.eventPropertyContains:
     239           2 :           eventProperties
     240           4 :               .add(_EventPropertyCondition.fromEventMatch(condition));
     241             :           break;
     242          35 :         case PushRuleConditions.containsDisplayName:
     243          35 :           matchDisplayname = true;
     244             :           break;
     245          35 :         case PushRuleConditions.roomMemberCount:
     246         105 :           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         105 :     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          35 :   PushruleEvaluator.fromRuleset(PushRuleSet ruleset) {
     316         138 :     for (final o in ruleset.override ?? <PushRule>[]) {
     317          35 :       if (!o.enabled) continue;
     318             :       try {
     319         105 :         _override.add(_OptimizedRules.fromRule(o));
     320             :       } catch (e) {
     321           6 :         Logs().d('Error parsing push rule $o', e);
     322             :       }
     323             :     }
     324         138 :     for (final u in ruleset.underride ?? <PushRule>[]) {
     325          35 :       if (!u.enabled) continue;
     326             :       try {
     327         105 :         _underride.add(_OptimizedRules.fromRule(u));
     328             :       } catch (e) {
     329           0 :         Logs().d('Error parsing push rule $u', e);
     330             :       }
     331             :     }
     332         138 :     for (final c in ruleset.content ?? <PushRule>[]) {
     333          35 :       if (!c.enabled) continue;
     334          35 :       final rule = PushRule(
     335          35 :         actions: c.actions,
     336          35 :         conditions: [
     337          35 :           PushCondition(
     338          35 :             kind: PushRuleConditions.eventMatch.name,
     339             :             key: 'content.body',
     340          35 :             pattern: c.pattern,
     341             :           ),
     342             :         ],
     343          35 :         ruleId: c.ruleId,
     344          35 :         default$: c.default$,
     345          35 :         enabled: c.enabled,
     346             :       );
     347             :       try {
     348         105 :         _content_rules.add(_OptimizedRules.fromRule(rule));
     349             :       } catch (e) {
     350           6 :         Logs().d('Error parsing push rule $rule', e);
     351             :       }
     352             :     }
     353         138 :     for (final r in ruleset.room ?? <PushRule>[]) {
     354          35 :       if (r.enabled) {
     355         175 :         _room_rules[r.ruleId] = EvaluatedPushRuleAction.fromActions(r.actions);
     356             :       }
     357             :     }
     358         105 :     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 1.14