# -*- 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) user = UserID.from_string(self.user_id) 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_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.create(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, 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.info( "%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.info( "No rules match for user %s, event %s", self.user_id, ev['event_id'] ) defer.returnValue(PushRuleEvaluator.DEFAULT_ACTIONS) class PushRuleEvaluatorForEvent(object): WORD_BOUNDARY = re.compile(r'\b') def __init__(self, event, body_parts, room_member_count): self._event = event self._body_parts = body_parts self._room_member_count = room_member_count self._value_cache = _flatten_dict(event) @staticmethod def create(event, room_member_count): body = event.get("content", {}).get("body", None) if body: body_parts = PushRuleEvaluatorForEvent.WORD_BOUNDARY.split(body) body_parts[:] = [ part.lower() for part in body_parts ] else: body_parts = [] return PushRuleEvaluatorForEvent(event, body_parts, room_member_count) def matches(self, condition, display_name, profile_tag): if condition['kind'] == 'event_match': return self._event_match(condition) 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): pattern = condition.get('pattern', None) if not pattern: logger.warn("event_match condition with no pattern") return False # XXX: optimisation: cache our pattern regexps if condition['key'] == 'content.body': matcher = _glob_to_matcher(pattern) for part in self._body_parts: if matcher(part): return True return False else: haystack = self._get_value(condition['key']) if haystack is None: return False matcher = _glob_to_matcher(pattern) return matcher(haystack.lower()) def _contains_display_name(self, display_name): if not display_name: return False lower_display_name = display_name.lower() for part in self._body_parts: if part == lower_display_name: return True return False def _get_value(self, dotted_key): return self._value_cache.get(dotted_key, None) 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 _glob_to_matcher(glob): glob = glob.lower() if not IS_GLOB.search(glob): return lambda value: value == 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) return lambda value: r.match(value) 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