From fb214d8f0bcf6d4659ade4ea6eae28e7a5bb6e55 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 8 Apr 2021 20:28:01 +0300 Subject: [PATCH] Add experimental support for rules matching different event fields --- reactbot/bot.py | 7 +++--- reactbot/config.py | 16 +++++++----- reactbot/rule.py | 53 ++++++++++++++++++++++++++++++--------- reactbot/simplepattern.py | 35 +++++++++++++++++++------- reactbot/template.py | 2 +- 5 files changed, 82 insertions(+), 31 deletions(-) diff --git a/reactbot/bot.py b/reactbot/bot.py index b893110..cd06466 100644 --- a/reactbot/bot.py +++ b/reactbot/bot.py @@ -1,4 +1,4 @@ -# reminder - A maubot plugin that reacts to messages that match predefined rules. +# reactbot - A maubot plugin that reacts to messages that match predefined rules. # Copyright (C) 2019 Tulir Asokan # # This program is free software: you can redistribute it and/or modify @@ -49,6 +49,7 @@ class ReactBot(Plugin): allowed_msgtypes: Tuple[MessageType, ...] = (MessageType.TEXT, MessageType.EMOTE) user_flood: Dict[UserID, FloodInfo] room_flood: Dict[RoomID, FloodInfo] + config: Config @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: @@ -100,6 +101,6 @@ class ReactBot(Plugin): return try: await rule.execute(evt, match) - except Exception: - self.log.exception(f"Failed to execute {name}") + except Exception as e: + self.log.warning(f"Failed to execute {name}: {e}") return diff --git a/reactbot/config.py b/reactbot/config.py index 8cb9ba5..1ed6f38 100644 --- a/reactbot/config.py +++ b/reactbot/config.py @@ -1,5 +1,5 @@ -# reminder - A maubot plugin that reacts to messages that match predefined rules. -# Copyright (C) 2019 Tulir Asokan +# reactbot - A maubot plugin that reacts to messages that match predefined rules. +# Copyright (C) 2021 Tulir Asokan # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -21,7 +21,7 @@ from jinja2 import Template as JinjaTemplate from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper from mautrix.types import EventType -from .simplepattern import SimplePattern +from .simplepattern import SimplePattern, RegexPattern from .template import Template from .rule import Rule, RPattern @@ -58,7 +58,8 @@ class Config(BaseProxyConfig): not_matches=self._compile_all(rule.get("not_matches", [])), type=EventType.find(rule["type"]) if "type" in rule else None, template=self.templates[rule["template"]], - variables=self._parse_variables(rule)) + variables=self._parse_variables(rule), + field=rule.get("field", ["content", "body"])) except Exception as e: raise ConfigError(f"Failed to load {name}") from e @@ -79,13 +80,16 @@ class Config(BaseProxyConfig): def _compile(self, pattern: InputPattern) -> RPattern: flags = self.default_flags raw = None + field = None if isinstance(pattern, dict): flags = self._get_flags(pattern["flags"]) if "flags" in pattern else flags raw = pattern.get("raw", False) + field = pattern.get("field", None) pattern = pattern["pattern"] if raw is not False and (not flags & re.MULTILINE or raw is True): - return SimplePattern.compile(pattern, flags, raw) or re.compile(pattern, flags=flags) - return re.compile(pattern, flags=flags) + return (SimplePattern.compile(pattern, flags, raw, field=field) + or RegexPattern(re.compile(pattern, flags=flags), field=field)) + return RegexPattern(re.compile(pattern, flags=flags), field=field) @staticmethod def _parse_variables(data: Dict[str, Any]) -> Dict[str, Any]: diff --git a/reactbot/rule.py b/reactbot/rule.py index f7703d2..59cc21f 100644 --- a/reactbot/rule.py +++ b/reactbot/rule.py @@ -1,5 +1,5 @@ -# reminder - A maubot plugin that reacts to messages that match predefined rules. -# Copyright (C) 2019 Tulir Asokan +# reactbot - A maubot plugin that reacts to messages that match predefined rules. +# Copyright (C) 2021 Tulir Asokan # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -13,46 +13,70 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Optional, Match, Dict, List, Set, Union, Pattern, Any +from typing import Optional, Match, Dict, List, Set, Union, Any +import json from attr import dataclass -from jinja2 import Template as JinjaTemplate -from mautrix.types import RoomID, EventType +from mautrix.types import RoomID, EventType, Serializable from maubot import MessageEvent from .template import Template -from .simplepattern import SimplePattern +from .simplepattern import SimplePattern, RegexPattern -RPattern = Union[Pattern, SimplePattern] +RPattern = Union[RegexPattern, SimplePattern] @dataclass class Rule: + field: List[str] rooms: Set[RoomID] not_rooms: Set[RoomID] matches: List[RPattern] not_matches: List[RPattern] + template: Template type: Optional[EventType] + room_id: Optional[RoomID] + state_event: bool variables: Dict[str, Any] - def _check_not_match(self, body: str) -> bool: + def _check_not_match(self, evt: MessageEvent, data: str) -> bool: for pattern in self.not_matches: - if pattern.search(body): + pattern_data = self._get_value(evt, pattern.field) if pattern.field else data + if pattern.search(pattern_data): return True return False + @staticmethod + def _get_value(evt: MessageEvent, field: List[str]) -> str: + data = evt + for part in field: + try: + data = evt[part] + except KeyError: + return "" + if isinstance(data, (str, int)): + return str(data) + elif isinstance(data, Serializable): + return json.dumps(data.serialize()) + elif isinstance(data, (dict, list)): + return json.dumps(data) + else: + return str(data) + def match(self, evt: MessageEvent) -> Optional[Match]: if len(self.rooms) > 0 and evt.room_id not in self.rooms: return None elif evt.room_id in self.not_rooms: return None + data = self._get_value(evt, self.field) for pattern in self.matches: - match = pattern.search(evt.content.body) + pattern_data = self._get_value(evt, pattern.field) if pattern.field else data + match = pattern.search(pattern_data) if match: - if self._check_not_match(evt.content.body): + if self._check_not_match(evt, data): return None return match return None @@ -62,5 +86,10 @@ class Rule: **{str(i): val for i, val in enumerate(match.groups())}, **match.groupdict(), } + room_id = self.room_id or evt.room_id + event_type = self.type or self.template.type content = self.template.execute(evt=evt, rule_vars=self.variables, extra_vars=extra_vars) - await evt.client.send_message_event(evt.room_id, self.type or self.template.type, content) + if self.state_event: + await evt.client.send_state_event(room_id, event_type, content) + else: + await evt.client.send_message_event(room_id, event_type, content) diff --git a/reactbot/simplepattern.py b/reactbot/simplepattern.py index d9e74e1..c64b09b 100644 --- a/reactbot/simplepattern.py +++ b/reactbot/simplepattern.py @@ -1,4 +1,4 @@ -# reminder - A maubot plugin that reacts to messages that match predefined rules. +# reactbot - A maubot plugin that reacts to messages that match predefined rules. # Copyright (C) 2019 Tulir Asokan # # This program is free software: you can redistribute it and/or modify @@ -13,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Callable, List, Dict, Optional +from typing import Callable, List, Dict, Optional, Pattern, Match import re @@ -27,15 +27,30 @@ class BlankMatch: return {} +class RegexPattern: + pattern: Pattern + field: Optional[List[str]] + + def __init__(self, pattern: Pattern, field: Optional[List[str]] = None) -> None: + self.pattern = pattern + self.field = field + + def search(self, val: str) -> Match: + return self.pattern.search(val) + + class SimplePattern: _ptm = BlankMatch() matcher: Callable[[str], bool] + field: Optional[List[str]] ignorecase: bool - def __init__(self, matcher: Callable[[str], bool], ignorecase: bool) -> None: + def __init__(self, matcher: Callable[[str], bool], ignorecase: bool, + field: Optional[List[str]] = None) -> None: self.matcher = matcher self.ignorecase = ignorecase + self.field = field def search(self, val: str) -> BlankMatch: if self.ignorecase: @@ -44,8 +59,8 @@ class SimplePattern: return self._ptm @staticmethod - def compile(pattern: str, flags: re.RegexFlag = re.RegexFlag(0), force_raw: bool = False - ) -> Optional['SimplePattern']: + def compile(pattern: str, flags: re.RegexFlag = re.RegexFlag(0), force_raw: bool = False, + field: Optional[List[str]] = None) -> Optional['SimplePattern']: ignorecase = flags == re.IGNORECASE s_pattern = pattern.lower() if ignorecase else pattern esc = "" @@ -54,13 +69,15 @@ class SimplePattern: first, last = pattern[0], pattern[-1] if first == '^' and last == '$' and (force_raw or esc == f"\\^{pattern[1:-1]}\\$"): s_pattern = s_pattern[1:-1] - return SimplePattern(lambda val: val == s_pattern, ignorecase=ignorecase) + return SimplePattern(lambda val: val == s_pattern, ignorecase=ignorecase, field=field) elif first == '^' and (force_raw or esc == f"\\^{pattern[1:]}"): s_pattern = s_pattern[1:] - return SimplePattern(lambda val: val.startswith(s_pattern), ignorecase=ignorecase) + return SimplePattern(lambda val: val.startswith(s_pattern), ignorecase=ignorecase, + field=field) elif last == '$' and (force_raw or esc == f"{pattern[:-1]}\\$"): s_pattern = s_pattern[:-1] - return SimplePattern(lambda val: val.endswith(s_pattern), ignorecase=ignorecase) + return SimplePattern(lambda val: val.endswith(s_pattern), ignorecase=ignorecase, + field=field) elif force_raw or esc == pattern: - return SimplePattern(lambda val: s_pattern in val, ignorecase=ignorecase) + return SimplePattern(lambda val: s_pattern in val, ignorecase=ignorecase, field=field) return None diff --git a/reactbot/template.py b/reactbot/template.py index 13ee593..3dab95d 100644 --- a/reactbot/template.py +++ b/reactbot/template.py @@ -1,4 +1,4 @@ -# reminder - A maubot plugin that reacts to messages that match predefined rules. +# reactbot - A maubot plugin that reacts to messages that match predefined rules. # Copyright (C) 2019 Tulir Asokan # # This program is free software: you can redistribute it and/or modify