summary refs log tree commit diff
path: root/synapse/push
diff options
context:
space:
mode:
authorPaul "LeoNerd" Evans <paul@matrix.org>2015-12-10 16:21:00 +0000
committerPaul "LeoNerd" Evans <paul@matrix.org>2015-12-10 16:21:00 +0000
commitd7ee7b589f0535c21301f38e93b0cabc0cf288d4 (patch)
treefcd7d110dc66d5e175f1030d10e0bbd5624bbf3c /synapse/push
parentDon't complain if /make_join response lacks 'prev_state' list (SYN-517) (diff)
parentMerge pull request #432 from matrix-org/pushrules_refactor (diff)
downloadsynapse-d7ee7b589f0535c21301f38e93b0cabc0cf288d4.tar.xz
Merge branch 'develop' into paul/tiny-fixes
Diffstat (limited to 'synapse/push')
-rw-r--r--synapse/push/__init__.py195
-rw-r--r--synapse/push/baserules.py1
-rw-r--r--synapse/push/httppusher.py7
-rw-r--r--synapse/push/push_rule_evaluator.py224
4 files changed, 238 insertions, 189 deletions
diff --git a/synapse/push/__init__.py b/synapse/push/__init__.py
index 0e0c61dec8..e7c964bcd2 100644
--- a/synapse/push/__init__.py
+++ b/synapse/push/__init__.py
@@ -16,14 +16,12 @@
 from twisted.internet import defer
 
 from synapse.streams.config import PaginationConfig
-from synapse.types import StreamToken, UserID
+from synapse.types import StreamToken
 
 import synapse.util.async
-import baserules
+import push_rule_evaluator as push_rule_evaluator
 
 import logging
-import simplejson as json
-import re
 import random
 
 logger = logging.getLogger(__name__)
@@ -33,9 +31,6 @@ class Pusher(object):
     INITIAL_BACKOFF = 1000
     MAX_BACKOFF = 60 * 60 * 1000
     GIVE_UP_AFTER = 24 * 60 * 60 * 1000
-    DEFAULT_ACTIONS = ['dont_notify']
-
-    INEQUALITY_EXPR = re.compile("^([=<>]*)([0-9]*)$")
 
     def __init__(self, _hs, profile_tag, user_name, app_id,
                  app_display_name, device_display_name, pushkey, pushkey_ts,
@@ -63,161 +58,6 @@ class Pusher(object):
         self.has_unread = True
 
     @defer.inlineCallbacks
-    def _actions_for_event(self, ev):
-        """
-        This should take into account notification settings that the user
-        has configured both globally and per-room when we have the ability
-        to do such things.
-        """
-        if ev['user_id'] == self.user_name:
-            # let's assume you probably know about messages you sent yourself
-            defer.returnValue(['dont_notify'])
-
-        rawrules = yield self.store.get_push_rules_for_user(self.user_name)
-
-        rules = []
-        for rawrule in rawrules:
-            rule = dict(rawrule)
-            rule['conditions'] = json.loads(rawrule['conditions'])
-            rule['actions'] = json.loads(rawrule['actions'])
-            rules.append(rule)
-
-        enabled_map = yield self.store.get_push_rules_enabled_for_user(self.user_name)
-
-        user = UserID.from_string(self.user_name)
-
-        rules = baserules.list_with_base_rules(rules, user)
-
-        room_id = ev['room_id']
-
-        # get *our* member event for display name matching
-        my_display_name = None
-        our_member_event = yield self.store.get_current_state(
-            room_id=room_id,
-            event_type='m.room.member',
-            state_key=self.user_name,
-        )
-        if our_member_event:
-            my_display_name = our_member_event[0].content.get("displayname")
-
-        room_members = yield self.store.get_users_in_room(room_id)
-        room_member_count = len(room_members)
-
-        for r in rules:
-            if r['rule_id'] in enabled_map:
-                r['enabled'] = enabled_map[r['rule_id']]
-            elif 'enabled' not in r:
-                r['enabled'] = True
-            if not r['enabled']:
-                continue
-            matches = True
-
-            conditions = r['conditions']
-            actions = r['actions']
-
-            for c in conditions:
-                matches &= self._event_fulfills_condition(
-                    ev, c, display_name=my_display_name,
-                    room_member_count=room_member_count
-                )
-            logger.debug(
-                "Rule %s %s",
-                r['rule_id'], "matches" if matches else "doesn't match"
-            )
-            # ignore rules with no actions (we have an explict 'dont_notify')
-            if len(actions) == 0:
-                logger.warn(
-                    "Ignoring rule id %s with no actions for user %s",
-                    r['rule_id'], self.user_name
-                )
-                continue
-            if matches:
-                logger.info(
-                    "%s matches for user %s, event %s",
-                    r['rule_id'], self.user_name, ev['event_id']
-                )
-                defer.returnValue(actions)
-
-        logger.info(
-            "No rules match for user %s, event %s",
-            self.user_name, ev['event_id']
-        )
-        defer.returnValue(Pusher.DEFAULT_ACTIONS)
-
-    @staticmethod
-    def _glob_to_regexp(glob):
-        r = re.escape(glob)
-        r = re.sub(r'\\\*', r'.*?', r)
-        r = re.sub(r'\\\?', r'.', r)
-
-        # handle [abc], [a-z] and [!a-z] style ranges.
-        r = re.sub(r'\\\[(\\\!|)(.*)\\\]',
-                   lambda x: ('[%s%s]' % (x.group(1) and '^' or '',
-                                          re.sub(r'\\\-', '-', x.group(2)))), r)
-        return r
-
-    def _event_fulfills_condition(self, ev, condition, display_name, room_member_count):
-        if condition['kind'] == 'event_match':
-            if 'pattern' not in condition:
-                logger.warn("event_match condition with no pattern")
-                return False
-            # XXX: optimisation: cache our pattern regexps
-            if condition['key'] == 'content.body':
-                r = r'\b%s\b' % self._glob_to_regexp(condition['pattern'])
-            else:
-                r = r'^%s$' % self._glob_to_regexp(condition['pattern'])
-            val = _value_for_dotted_key(condition['key'], ev)
-            if val is None:
-                return False
-            return re.search(r, val, flags=re.IGNORECASE) is not None
-
-        elif condition['kind'] == 'device':
-            if 'profile_tag' not in condition:
-                return True
-            return condition['profile_tag'] == self.profile_tag
-
-        elif condition['kind'] == 'contains_display_name':
-            # This is special because display names can be different
-            # between rooms and so you can't really hard code it in a rule.
-            # Optimisation: we should cache these names and update them from
-            # the event stream.
-            if 'content' not in ev or 'body' not in ev['content']:
-                return False
-            if not display_name:
-                return False
-            return re.search(
-                r"\b%s\b" % re.escape(display_name), ev['content']['body'],
-                flags=re.IGNORECASE
-            ) is not None
-
-        elif condition['kind'] == 'room_member_count':
-            if 'is' not in condition:
-                return False
-            m = Pusher.INEQUALITY_EXPR.match(condition['is'])
-            if not m:
-                return False
-            ineq = m.group(1)
-            rhs = m.group(2)
-            if not rhs.isdigit():
-                return False
-            rhs = int(rhs)
-
-            if ineq == '' or ineq == '==':
-                return room_member_count == rhs
-            elif ineq == '<':
-                return room_member_count < rhs
-            elif ineq == '>':
-                return room_member_count > rhs
-            elif ineq == '>=':
-                return room_member_count >= rhs
-            elif ineq == '<=':
-                return room_member_count <= rhs
-            else:
-                return False
-        else:
-            return True
-
-    @defer.inlineCallbacks
     def get_context_for_event(self, ev):
         name_aliases = yield self.store.get_room_name_and_aliases(
             ev['room_id']
@@ -308,8 +148,14 @@ class Pusher(object):
             return
 
         processed = False
-        actions = yield self._actions_for_event(single_event)
-        tweaks = _tweaks_for_actions(actions)
+
+        rule_evaluator = yield \
+            push_rule_evaluator.evaluator_for_user_name_and_profile_tag(
+                self.user_name, self.profile_tag, single_event['room_id'], self.store
+            )
+
+        actions = yield rule_evaluator.actions_for_event(single_event)
+        tweaks = rule_evaluator.tweaks_for_actions(actions)
 
         if len(actions) == 0:
             logger.warn("Empty actions! Using default action.")
@@ -448,27 +294,6 @@ class Pusher(object):
                     self.has_unread = False
 
 
-def _value_for_dotted_key(dotted_key, event):
-    parts = dotted_key.split(".")
-    val = event
-    while len(parts) > 0:
-        if parts[0] not in val:
-            return None
-        val = val[parts[0]]
-        parts = parts[1:]
-    return val
-
-
-def _tweaks_for_actions(actions):
-    tweaks = {}
-    for a in actions:
-        if not isinstance(a, dict):
-            continue
-        if 'set_tweak' in a and 'value' in a:
-            tweaks[a['set_tweak']] = a['value']
-    return tweaks
-
-
 class PusherConfigException(Exception):
     def __init__(self, msg):
         super(PusherConfigException, self).__init__(msg)
diff --git a/synapse/push/baserules.py b/synapse/push/baserules.py
index 1f015a7f2e..7f76382a17 100644
--- a/synapse/push/baserules.py
+++ b/synapse/push/baserules.py
@@ -247,6 +247,7 @@ def make_base_append_underride_rules(user):
         },
         {
             'rule_id': 'global/underride/.m.rule.message',
+            'enabled': False,
             'conditions': [
                 {
                     'kind': 'event_match',
diff --git a/synapse/push/httppusher.py b/synapse/push/httppusher.py
index a02fed57b4..5160775e59 100644
--- a/synapse/push/httppusher.py
+++ b/synapse/push/httppusher.py
@@ -14,7 +14,6 @@
 # limitations under the License.
 
 from synapse.push import Pusher, PusherConfigException
-from synapse.http.client import SimpleHttpClient
 
 from twisted.internet import defer
 
@@ -46,7 +45,7 @@ class HttpPusher(Pusher):
                 "'url' required in data for HTTP pusher"
             )
         self.url = data['url']
-        self.httpCli = SimpleHttpClient(self.hs)
+        self.http_client = _hs.get_simple_http_client()
         self.data_minus_url = {}
         self.data_minus_url.update(self.data)
         del self.data_minus_url['url']
@@ -107,7 +106,7 @@ class HttpPusher(Pusher):
         if not notification_dict:
             defer.returnValue([])
         try:
-            resp = yield self.httpCli.post_json_get_json(self.url, notification_dict)
+            resp = yield self.http_client.post_json_get_json(self.url, notification_dict)
         except:
             logger.warn("Failed to push %s ", self.url)
             defer.returnValue(False)
@@ -138,7 +137,7 @@ class HttpPusher(Pusher):
             }
         }
         try:
-            resp = yield self.httpCli.post_json_get_json(self.url, d)
+            resp = yield self.http_client.post_json_get_json(self.url, d)
         except:
             logger.exception("Failed to push %s ", self.url)
             defer.returnValue(False)
diff --git a/synapse/push/push_rule_evaluator.py b/synapse/push/push_rule_evaluator.py
new file mode 100644
index 0000000000..92c7fd048f
--- /dev/null
+++ b/synapse/push/push_rule_evaluator.py
@@ -0,0 +1,224 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015 OpenMarket Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from twisted.internet import defer
+
+from synapse.types import UserID
+
+import baserules
+
+import logging
+import simplejson as json
+import re
+
+logger = logging.getLogger(__name__)
+
+
+@defer.inlineCallbacks
+def evaluator_for_user_name_and_profile_tag(user_name, profile_tag, room_id, store):
+    rawrules = yield store.get_push_rules_for_user(user_name)
+    enabled_map = yield store.get_push_rules_enabled_for_user(user_name)
+    our_member_event = yield store.get_current_state(
+        room_id=room_id,
+        event_type='m.room.member',
+        state_key=user_name,
+    )
+
+    defer.returnValue(PushRuleEvaluator(
+        user_name, profile_tag, rawrules, enabled_map,
+        room_id, our_member_event, store
+    ))
+
+
+class PushRuleEvaluator:
+    DEFAULT_ACTIONS = ['dont_notify']
+    INEQUALITY_EXPR = re.compile("^([=<>]*)([0-9]*)$")
+
+    def __init__(self, user_name, profile_tag, raw_rules, enabled_map, room_id,
+                 our_member_event, store):
+        self.user_name = user_name
+        self.profile_tag = profile_tag
+        self.room_id = room_id
+        self.our_member_event = our_member_event
+        self.store = store
+
+        rules = []
+        for raw_rule in raw_rules:
+            rule = dict(raw_rule)
+            rule['conditions'] = json.loads(raw_rule['conditions'])
+            rule['actions'] = json.loads(raw_rule['actions'])
+            rules.append(rule)
+
+        user = UserID.from_string(self.user_name)
+        self.rules = baserules.list_with_base_rules(rules, user)
+
+        self.enabled_map = enabled_map
+
+    @staticmethod
+    def tweaks_for_actions(actions):
+        tweaks = {}
+        for a in actions:
+            if not isinstance(a, dict):
+                continue
+            if 'set_tweak' in a and 'value' in a:
+                tweaks[a['set_tweak']] = a['value']
+        return tweaks
+
+    @defer.inlineCallbacks
+    def actions_for_event(self, ev):
+        """
+        This should take into account notification settings that the user
+        has configured both globally and per-room when we have the ability
+        to do such things.
+        """
+        if ev['user_id'] == self.user_name:
+            # let's assume you probably know about messages you sent yourself
+            defer.returnValue(['dont_notify'])
+
+        room_id = ev['room_id']
+
+        # get *our* member event for display name matching
+        my_display_name = None
+
+        if self.our_member_event:
+            my_display_name = self.our_member_event[0].content.get("displayname")
+
+        room_members = yield self.store.get_users_in_room(room_id)
+        room_member_count = len(room_members)
+
+        for r in self.rules:
+            if r['rule_id'] in self.enabled_map:
+                r['enabled'] = self.enabled_map[r['rule_id']]
+            elif 'enabled' not in r:
+                r['enabled'] = True
+            if not r['enabled']:
+                continue
+            matches = True
+
+            conditions = r['conditions']
+            actions = r['actions']
+
+            for c in conditions:
+                matches &= self._event_fulfills_condition(
+                    ev, c, display_name=my_display_name,
+                    room_member_count=room_member_count
+                )
+            logger.debug(
+                "Rule %s %s",
+                r['rule_id'], "matches" if matches else "doesn't match"
+            )
+            # ignore rules with no actions (we have an explict 'dont_notify')
+            if len(actions) == 0:
+                logger.warn(
+                    "Ignoring rule id %s with no actions for user %s",
+                    r['rule_id'], self.user_name
+                )
+                continue
+            if matches:
+                logger.info(
+                    "%s matches for user %s, event %s",
+                    r['rule_id'], self.user_name, ev['event_id']
+                )
+                defer.returnValue(actions)
+
+        logger.info(
+            "No rules match for user %s, event %s",
+            self.user_name, ev['event_id']
+        )
+        defer.returnValue(PushRuleEvaluator.DEFAULT_ACTIONS)
+
+    @staticmethod
+    def _glob_to_regexp(glob):
+        r = re.escape(glob)
+        r = re.sub(r'\\\*', r'.*?', r)
+        r = re.sub(r'\\\?', r'.', r)
+
+        # handle [abc], [a-z] and [!a-z] style ranges.
+        r = re.sub(r'\\\[(\\\!|)(.*)\\\]',
+                   lambda x: ('[%s%s]' % (x.group(1) and '^' or '',
+                                          re.sub(r'\\\-', '-', x.group(2)))), r)
+        return r
+
+    def _event_fulfills_condition(self, ev, condition, display_name, room_member_count):
+        if condition['kind'] == 'event_match':
+            if 'pattern' not in condition:
+                logger.warn("event_match condition with no pattern")
+                return False
+            # XXX: optimisation: cache our pattern regexps
+            if condition['key'] == 'content.body':
+                r = r'\b%s\b' % self._glob_to_regexp(condition['pattern'])
+            else:
+                r = r'^%s$' % self._glob_to_regexp(condition['pattern'])
+            val = _value_for_dotted_key(condition['key'], ev)
+            if val is None:
+                return False
+            return re.search(r, val, flags=re.IGNORECASE) is not None
+
+        elif condition['kind'] == 'device':
+            if 'profile_tag' not in condition:
+                return True
+            return condition['profile_tag'] == self.profile_tag
+
+        elif condition['kind'] == 'contains_display_name':
+            # This is special because display names can be different
+            # between rooms and so you can't really hard code it in a rule.
+            # Optimisation: we should cache these names and update them from
+            # the event stream.
+            if 'content' not in ev or 'body' not in ev['content']:
+                return False
+            if not display_name:
+                return False
+            return re.search(
+                r"\b%s\b" % re.escape(display_name), ev['content']['body'],
+                flags=re.IGNORECASE
+            ) is not None
+
+        elif condition['kind'] == 'room_member_count':
+            if 'is' not in condition:
+                return False
+            m = PushRuleEvaluator.INEQUALITY_EXPR.match(condition['is'])
+            if not m:
+                return False
+            ineq = m.group(1)
+            rhs = m.group(2)
+            if not rhs.isdigit():
+                return False
+            rhs = int(rhs)
+
+            if ineq == '' or ineq == '==':
+                return room_member_count == rhs
+            elif ineq == '<':
+                return room_member_count < rhs
+            elif ineq == '>':
+                return room_member_count > rhs
+            elif ineq == '>=':
+                return room_member_count >= rhs
+            elif ineq == '<=':
+                return room_member_count <= rhs
+            else:
+                return False
+        else:
+            return True
+
+
+def _value_for_dotted_key(dotted_key, event):
+    parts = dotted_key.split(".")
+    val = event
+    while len(parts) > 0:
+        if parts[0] not in val:
+            return None
+        val = val[parts[0]]
+        parts = parts[1:]
+    return val