# -*- coding: utf-8 -*- # Copyright 2015, 2016 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 import baserules import logging import simplejson as json import re from synapse.types import UserID logger = logging.getLogger(__name__) GLOB_REGEX = re.compile(r'\\\[(\\\!|)(.*)\\\]') IS_GLOB = re.compile(r'[\?\*\[\]]') INEQUALITY_EXPR = re.compile("^([=<>]*)([0-9]*)$") @defer.inlineCallbacks def evaluator_for_user_id_and_profile_tag(user_id, profile_tag, room_id, store): rawrules = yield store.get_push_rules_for_user(user_id) enabled_map = yield store.get_push_rules_enabled_for_user(user_id) our_member_event = yield store.get_current_state( room_id=room_id, event_type='m.room.member', state_key=user_id, ) defer.returnValue(PushRuleEvaluator( user_id, profile_tag, rawrules, enabled_map, room_id, our_member_event, store )) def _room_member_count(ev, condition, room_member_count): if 'is' not in condition: return False m = 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 class PushRuleEvaluator: DEFAULT_ACTIONS = [] def __init__(self, user_id, profile_tag, raw_rules, enabled_map, room_id, our_member_event, store): self.user_id = user_id 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) self.rules = baserules.list_with_base_rules(rules) 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_id: # let's assume you probably know about messages you sent yourself defer.returnValue([]) 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) evaluator = PushRuleEvaluatorForEvent(ev, room_member_count) for r in self.rules: if self.enabled_map.get(r['rule_id'], None) is False: continue if not r.get("enabled", True): continue conditions = r['conditions'] actions = r['actions'] # 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_id ) continue matches = True for c in conditions: matches = evaluator.matches( c, self.user_id, my_display_name, self.profile_tag ) if not matches: break logger.debug( "Rule %s %s", r['rule_id'], "matches" if matches else "doesn't match" ) if matches: logger.debug( "%s matches for user %s, event %s", r['rule_id'], self.user_id, ev['event_id'] ) # filter out dont_notify as we treat an empty actions list # as dont_notify, and this doesn't take up a row in our database actions = [x for x in actions if x != 'dont_notify'] defer.returnValue(actions) logger.debug( "No rules match for user %s, event %s", self.user_id, ev['event_id'] ) defer.returnValue(PushRuleEvaluator.DEFAULT_ACTIONS) class PushRuleEvaluatorForEvent(object): def __init__(self, event, room_member_count): self._event = event self._room_member_count = room_member_count # Maps strings of e.g. 'content.body' -> event["content"]["body"] self._value_cache = _flatten_dict(event) def matches(self, condition, user_id, display_name, profile_tag): if condition['kind'] == 'event_match': return self._event_match(condition, user_id) elif condition['kind'] == 'device': if 'profile_tag' not in condition: return True return condition['profile_tag'] == profile_tag elif condition['kind'] == 'contains_display_name': return self._contains_display_name(display_name) elif condition['kind'] == 'room_member_count': return _room_member_count( self._event, condition, self._room_member_count ) else: return True def _event_match(self, condition, user_id): pattern = condition.get('pattern', None) if not pattern: pattern_type = condition.get('pattern_type', None) if pattern_type == "user_id": pattern = user_id elif pattern_type == "user_localpart": pattern = UserID.from_string(user_id).localpart if not pattern: logger.warn("event_match condition with no pattern") return False # XXX: optimisation: cache our pattern regexps if condition['key'] == 'content.body': body = self._event["content"].get("body", None) if not body: return False return _glob_matches(pattern, body, word_boundary=True) else: haystack = self._get_value(condition['key']) if haystack is None: return False return _glob_matches(pattern, haystack) def _contains_display_name(self, display_name): if not display_name: return False body = self._event["content"].get("body", None) if not body: return False return _glob_matches(display_name, body, word_boundary=True) def _get_value(self, dotted_key): return self._value_cache.get(dotted_key, None) def _glob_matches(glob, value, word_boundary=False): """Tests if value matches glob. Args: glob (string) value (string): String to test against glob. word_boundary (bool): Whether to match against word boundaries or entire string. Defaults to False. Returns: bool """ if IS_GLOB.search(glob): r = re.escape(glob) r = r.replace(r'\*', '.*?') r = r.replace(r'\?', '.') # handle [abc], [a-z] and [!a-z] style ranges. r = GLOB_REGEX.sub( lambda x: ( '[%s%s]' % ( x.group(1) and '^' or '', x.group(2).replace(r'\\\-', '-') ) ), r, ) r = r + "$" r = re.compile(r, flags=re.IGNORECASE) return r.match(value) elif word_boundary: r = re.escape(glob) r = "\b%s\b" % (r,) r = re.compile(r, flags=re.IGNORECASE) return r.search(value) else: return value.lower() == glob.lower() def _flatten_dict(d, prefix=[], result={}): for key, value in d.items(): if isinstance(value, basestring): result[".".join(prefix + [key])] = value.lower() elif hasattr(value, "items"): _flatten_dict(value, prefix=(prefix+[key]), result=result) return result