push_rule_evaluator.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. # Copyright 2015, 2016 OpenMarket Ltd
  2. # Copyright 2017 New Vector Ltd
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. import logging
  16. import re
  17. from typing import Any, Dict, List, Mapping, Optional, Pattern, Set, Tuple, Union
  18. from matrix_common.regex import glob_to_regex, to_word_pattern
  19. from synapse.events import EventBase
  20. from synapse.types import UserID
  21. from synapse.util.caches.lrucache import LruCache
  22. logger = logging.getLogger(__name__)
  23. GLOB_REGEX = re.compile(r"\\\[(\\\!|)(.*)\\\]")
  24. IS_GLOB = re.compile(r"[\?\*\[\]]")
  25. INEQUALITY_EXPR = re.compile("^([=<>]*)([0-9]*)$")
  26. def _room_member_count(
  27. ev: EventBase, condition: Dict[str, Any], room_member_count: int
  28. ) -> bool:
  29. return _test_ineq_condition(condition, room_member_count)
  30. def _sender_notification_permission(
  31. ev: EventBase,
  32. condition: Dict[str, Any],
  33. sender_power_level: int,
  34. power_levels: Dict[str, Union[int, Dict[str, int]]],
  35. ) -> bool:
  36. notif_level_key = condition.get("key")
  37. if notif_level_key is None:
  38. return False
  39. notif_levels = power_levels.get("notifications", {})
  40. assert isinstance(notif_levels, dict)
  41. room_notif_level = notif_levels.get(notif_level_key, 50)
  42. return sender_power_level >= room_notif_level
  43. def _test_ineq_condition(condition: Dict[str, Any], number: int) -> bool:
  44. if "is" not in condition:
  45. return False
  46. m = INEQUALITY_EXPR.match(condition["is"])
  47. if not m:
  48. return False
  49. ineq = m.group(1)
  50. rhs = m.group(2)
  51. if not rhs.isdigit():
  52. return False
  53. rhs_int = int(rhs)
  54. if ineq == "" or ineq == "==":
  55. return number == rhs_int
  56. elif ineq == "<":
  57. return number < rhs_int
  58. elif ineq == ">":
  59. return number > rhs_int
  60. elif ineq == ">=":
  61. return number >= rhs_int
  62. elif ineq == "<=":
  63. return number <= rhs_int
  64. else:
  65. return False
  66. def tweaks_for_actions(actions: List[Union[str, Dict]]) -> Dict[str, Any]:
  67. """
  68. Converts a list of actions into a `tweaks` dict (which can then be passed to
  69. the push gateway).
  70. This function ignores all actions other than `set_tweak` actions, and treats
  71. absent `value`s as `True`, which agrees with the only spec-defined treatment
  72. of absent `value`s (namely, for `highlight` tweaks).
  73. Args:
  74. actions: list of actions
  75. e.g. [
  76. {"set_tweak": "a", "value": "AAA"},
  77. {"set_tweak": "b", "value": "BBB"},
  78. {"set_tweak": "highlight"},
  79. "notify"
  80. ]
  81. Returns:
  82. dictionary of tweaks for those actions
  83. e.g. {"a": "AAA", "b": "BBB", "highlight": True}
  84. """
  85. tweaks = {}
  86. for a in actions:
  87. if not isinstance(a, dict):
  88. continue
  89. if "set_tweak" in a:
  90. # value is allowed to be absent in which case the value assumed
  91. # should be True.
  92. tweaks[a["set_tweak"]] = a.get("value", True)
  93. return tweaks
  94. class PushRuleEvaluatorForEvent:
  95. def __init__(
  96. self,
  97. event: EventBase,
  98. room_member_count: int,
  99. sender_power_level: int,
  100. power_levels: Dict[str, Union[int, Dict[str, int]]],
  101. relations: Dict[str, Set[Tuple[str, str]]],
  102. relations_match_enabled: bool,
  103. ):
  104. self._event = event
  105. self._room_member_count = room_member_count
  106. self._sender_power_level = sender_power_level
  107. self._power_levels = power_levels
  108. self._relations = relations
  109. self._relations_match_enabled = relations_match_enabled
  110. # Maps strings of e.g. 'content.body' -> event["content"]["body"]
  111. self._value_cache = _flatten_dict(event)
  112. # Maps cache keys to final values.
  113. self._condition_cache: Dict[str, bool] = {}
  114. def check_conditions(
  115. self, conditions: List[dict], uid: str, display_name: Optional[str]
  116. ) -> bool:
  117. """
  118. Returns true if a user's conditions/user ID/display name match the event.
  119. Args:
  120. conditions: The user's conditions to match.
  121. uid: The user's MXID.
  122. display_name: The display name.
  123. Returns:
  124. True if all conditions match the event, False otherwise.
  125. """
  126. for cond in conditions:
  127. _cache_key = cond.get("_cache_key", None)
  128. if _cache_key:
  129. res = self._condition_cache.get(_cache_key, None)
  130. if res is False:
  131. return False
  132. elif res is True:
  133. continue
  134. res = self.matches(cond, uid, display_name)
  135. if _cache_key:
  136. self._condition_cache[_cache_key] = bool(res)
  137. if not res:
  138. return False
  139. return True
  140. def matches(
  141. self, condition: Dict[str, Any], user_id: str, display_name: Optional[str]
  142. ) -> bool:
  143. """
  144. Returns true if a user's condition/user ID/display name match the event.
  145. Args:
  146. condition: The user's condition to match.
  147. uid: The user's MXID.
  148. display_name: The display name, or None if there is not one.
  149. Returns:
  150. True if the condition matches the event, False otherwise.
  151. """
  152. if condition["kind"] == "event_match":
  153. return self._event_match(condition, user_id)
  154. elif condition["kind"] == "contains_display_name":
  155. return self._contains_display_name(display_name)
  156. elif condition["kind"] == "room_member_count":
  157. return _room_member_count(self._event, condition, self._room_member_count)
  158. elif condition["kind"] == "sender_notification_permission":
  159. return _sender_notification_permission(
  160. self._event, condition, self._sender_power_level, self._power_levels
  161. )
  162. elif (
  163. condition["kind"] == "org.matrix.msc3772.relation_match"
  164. and self._relations_match_enabled
  165. ):
  166. return self._relation_match(condition, user_id)
  167. else:
  168. # XXX This looks incorrect -- we have reached an unknown condition
  169. # kind and are unconditionally returning that it matches. Note
  170. # that it seems possible to provide a condition to the /pushrules
  171. # endpoint with an unknown kind, see _rule_tuple_from_request_object.
  172. return True
  173. def _event_match(self, condition: dict, user_id: str) -> bool:
  174. """
  175. Check an "event_match" push rule condition.
  176. Args:
  177. condition: The "event_match" push rule condition to match.
  178. user_id: The user's MXID.
  179. Returns:
  180. True if the condition matches the event, False otherwise.
  181. """
  182. pattern = condition.get("pattern", None)
  183. if not pattern:
  184. pattern_type = condition.get("pattern_type", None)
  185. if pattern_type == "user_id":
  186. pattern = user_id
  187. elif pattern_type == "user_localpart":
  188. pattern = UserID.from_string(user_id).localpart
  189. if not pattern:
  190. logger.warning("event_match condition with no pattern")
  191. return False
  192. # XXX: optimisation: cache our pattern regexps
  193. if condition["key"] == "content.body":
  194. body = self._event.content.get("body", None)
  195. if not body or not isinstance(body, str):
  196. return False
  197. return _glob_matches(pattern, body, word_boundary=True)
  198. else:
  199. haystack = self._value_cache.get(condition["key"], None)
  200. if haystack is None:
  201. return False
  202. return _glob_matches(pattern, haystack)
  203. def _contains_display_name(self, display_name: Optional[str]) -> bool:
  204. """
  205. Check an "event_match" push rule condition.
  206. Args:
  207. display_name: The display name, or None if there is not one.
  208. Returns:
  209. True if the display name is found in the event body, False otherwise.
  210. """
  211. if not display_name:
  212. return False
  213. body = self._event.content.get("body", None)
  214. if not body or not isinstance(body, str):
  215. return False
  216. # Similar to _glob_matches, but do not treat display_name as a glob.
  217. r = regex_cache.get((display_name, False, True), None)
  218. if not r:
  219. r1 = re.escape(display_name)
  220. r1 = to_word_pattern(r1)
  221. r = re.compile(r1, flags=re.IGNORECASE)
  222. regex_cache[(display_name, False, True)] = r
  223. return bool(r.search(body))
  224. def _relation_match(self, condition: dict, user_id: str) -> bool:
  225. """
  226. Check an "relation_match" push rule condition.
  227. Args:
  228. condition: The "event_match" push rule condition to match.
  229. user_id: The user's MXID.
  230. Returns:
  231. True if the condition matches the event, False otherwise.
  232. """
  233. rel_type = condition.get("rel_type")
  234. if not rel_type:
  235. logger.warning("relation_match condition missing rel_type")
  236. return False
  237. sender_pattern = condition.get("sender")
  238. if sender_pattern is None:
  239. sender_type = condition.get("sender_type")
  240. if sender_type == "user_id":
  241. sender_pattern = user_id
  242. type_pattern = condition.get("type")
  243. # If any other relations matches, return True.
  244. for sender, event_type in self._relations.get(rel_type, ()):
  245. if sender_pattern and not _glob_matches(sender_pattern, sender):
  246. continue
  247. if type_pattern and not _glob_matches(type_pattern, event_type):
  248. continue
  249. # All values must have matched.
  250. return True
  251. # No relations matched.
  252. return False
  253. # Caches (string, is_glob, word_boundary) -> regex for push. See _glob_matches
  254. regex_cache: LruCache[Tuple[str, bool, bool], Pattern] = LruCache(
  255. 50000, "regex_push_cache"
  256. )
  257. def _glob_matches(glob: str, value: str, word_boundary: bool = False) -> bool:
  258. """Tests if value matches glob.
  259. Args:
  260. glob
  261. value: String to test against glob.
  262. word_boundary: Whether to match against word boundaries or entire
  263. string. Defaults to False.
  264. """
  265. try:
  266. r = regex_cache.get((glob, True, word_boundary), None)
  267. if not r:
  268. r = glob_to_regex(glob, word_boundary=word_boundary)
  269. regex_cache[(glob, True, word_boundary)] = r
  270. return bool(r.search(value))
  271. except re.error:
  272. logger.warning("Failed to parse glob to regex: %r", glob)
  273. return False
  274. def _flatten_dict(
  275. d: Union[EventBase, Mapping[str, Any]],
  276. prefix: Optional[List[str]] = None,
  277. result: Optional[Dict[str, str]] = None,
  278. ) -> Dict[str, str]:
  279. if prefix is None:
  280. prefix = []
  281. if result is None:
  282. result = {}
  283. for key, value in d.items():
  284. if isinstance(value, str):
  285. result[".".join(prefix + [key])] = value.lower()
  286. elif isinstance(value, Mapping):
  287. _flatten_dict(value, prefix=(prefix + [key]), result=result)
  288. return result