From f1593056502d68792a2dd9939c59c7d8bbddc732 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 01:49:05 +0300 Subject: [PATCH 01/50] Over-engineer to support arbitrary response contents --- base-config.yaml | 41 ++++++++++++-- maubot.yaml | 2 +- reactbot.py | 142 ++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 154 insertions(+), 31 deletions(-) diff --git a/base-config.yaml b/base-config.yaml index 0908d5e..48c1b30 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -1,5 +1,38 @@ +templates: + reaction: + type: m.reaction + variables: + react_to_event: "{{event.content.get_reply_to() or event.event_id}}" + content: + m.relates_to: + rel_type: m.annotation + event_id: $${react_to_event} + key: $${reaction} + jesari: + type: m.room.message + content: + msgtype: m.image + body: putkiteippi.gif + url: "mxc://maunium.net/LNjeTZvDEaUdQAROvWGHLLDi" + info: + mimetype: image/gif + width: 1280 + height: 535 + size: 7500893 + thumbnail_url: "mxc://maunium.net/xdhlegZQgGwlMRzBfhNxyEfb" + thumbnail_info: + mimetype: image/png + width: 800 + height: 334 + size: 417896 + rules: -- rooms: ["!FPUfgzXYWTKgIrwKxW:matrix.org"] - matches: [^TWIM] - react_to_reply: true - reaction: 🍪 + twim_cookies: + rooms: ["!FPUfgzXYWTKgIrwKxW:matrix.org"] + matches: [^TWIM] + template: reaction + variables: + reaction: 🍪 + jesari: + matches: [jesari] + template: jesari diff --git a/maubot.yaml b/maubot.yaml index 873e733..f004ef2 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: xyz.maubot.reactbot -version: 1.0.0 +version: 2.0.0+dev license: AGPL-3.0-or-later modules: - reactbot diff --git a/reactbot.py b/reactbot.py index 31782c0..6506a7d 100644 --- a/reactbot.py +++ b/reactbot.py @@ -13,40 +13,120 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Pattern, List, Set, Type -from attr import dataclass +from typing import NewType, Optional, Pattern, Match, Union, Dict, List, Tuple, Set, Type, Any +from itertools import chain +import copy import re -from mautrix.types import RoomID, EventType +from attr import dataclass +from jinja2 import Template as JinjaTemplate + +from mautrix.types import RoomID, EventType, Event from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper -from maubot import Plugin, MessageEvent +from maubot import Plugin, MessageEvent, Client from maubot.handlers import event class Config(BaseProxyConfig): def do_update(self, helper: ConfigUpdateHelper) -> None: helper.copy("rules") + helper.copy("templates") + + +variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}") + + +class Key(str): + pass + + +Index = NewType("Index", Union[str, int, Key]) + + +@dataclass +class Template: + type: EventType + variables: Dict[str, JinjaTemplate] + content: Dict[str, Any] + + _variable_locations: List[Tuple[Index, ...]] = None + + def init(self) -> 'Template': + self._variable_locations = [] + self._map_variable_locations((), self.content) + return self + + def _map_variable_locations(self, path: Tuple[Index, ...], data: Any) -> None: + if isinstance(data, list): + for i, v in enumerate(data): + self._map_variable_locations((*path, i), v) + elif isinstance(data, dict): + for k, v in data.items(): + if variable_regex.match(k): + self._variable_locations.append((*path, Key(k))) + self._map_variable_locations((*path, k), v) + elif isinstance(data, str): + if variable_regex.match(data): + self._variable_locations.append(path) + + @classmethod + def _recurse(cls, content: Any, path: Tuple[Index, ...]) -> Any: + if len(path) == 0: + return content + return cls._recurse(content[path[0]], path[1:]) + + @staticmethod + def _replace_variables(tpl: str, variables: Dict[str, Any]) -> str: + for match in variable_regex.finditer(tpl): + val = variables[match.group(1)] + tpl = tpl[:match.start()] + val + tpl[match.end():] + return tpl + + def execute(self, evt: Event, rule_vars: Dict[str, JinjaTemplate], extra_vars: Dict[str, str] + ) -> Dict[str, Any]: + variables = {**{name: template.render(event=evt) + for name, template in chain(self.variables.items(), rule_vars.items())}, + **extra_vars} + content = copy.deepcopy(self.content) + for path in self._variable_locations: + data: Dict[str, Any] = self._recurse(content, path[:1]) + key = path[-1] + if isinstance(key, Key): + key = str(key) + data[self._replace_variables(key, variables)] = data.pop(key) + else: + data[key] = self._replace_variables(data[key], variables) + return content @dataclass class Rule: rooms: Set[RoomID] matches: List[Pattern] - reaction: str - react_to_reply: bool + template: Template + type: Optional[EventType] + variables: Dict[str, JinjaTemplate] - def is_match(self, evt: MessageEvent) -> bool: - if evt.room_id not in self.rooms: - return False - for match in self.matches: - if match.match(evt.content.body): - return True - return False + def match(self, evt: MessageEvent) -> Optional[Match]: + if len(self.rooms) > 0 and evt.room_id not in self.rooms: + return None + for pattern in self.matches: + match = pattern.match(evt.content.body) + if match: + return match + return None + + async def execute(self, evt: MessageEvent, match: Match) -> None: + content = self.template.execute(evt=evt, rule_vars=self.variables, + extra_vars={str(i): val for i, val in + enumerate(match.groups())}) + await evt.client.send_message_event(evt.room_id, self.type or self.template.type, content) class ReactBot(Plugin): - rules: List[Rule] + rules: Dict[str, Rule] + templates: Dict[str, Template] @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: @@ -54,22 +134,32 @@ class ReactBot(Plugin): async def start(self) -> None: await super().start() - self.rules = [] + self.rules = {} + self.templates = {} self.on_external_config_update() def on_external_config_update(self) -> None: self.config.load_and_update() - self.rules = [Rule(rooms=set(rule.get("rooms", [])), - matches=[re.compile(match) for match in rule.get("matches")], - reaction=rule.get("reaction", "\U0001F44D"), - react_to_reply=rule.get("react_to_reply", False)) - for rule in self.config["rules"]] + self.templates = {name: Template(type=EventType.find(tpl.get("type", "m.room.message")), + variables={name: JinjaTemplate(var_tpl) for name, var_tpl + in tpl.get("variables", {}).items()}, + content=tpl.get("content", {})).init() + for name, tpl in self.config["templates"].items()} + self.rules = {name: Rule(rooms=set(rule.get("rooms", [])), + matches=[re.compile(match) for match in rule.get("matches")], + type=EventType.find(rule["type"]) if "type" in rule else None, + template=self.templates[rule["template"]], + variables={name: JinjaTemplate(template) for name, template + in rule.get("variables", {}).items()}) + for name, rule in self.config["rules"].items()} @event.on(EventType.ROOM_MESSAGE) async def echo_handler(self, evt: MessageEvent) -> None: - for rule in self.rules: - if rule.is_match(evt): - if rule.react_to_reply and evt.content.get_reply_to(): - await self.client.react(evt.room_id, evt.content.get_reply_to(), rule.reaction) - else: - await evt.react(rule.reaction) + for name, rule in self.rules.items(): + match = rule.match(evt) + if match is not None: + try: + await rule.execute(evt, match) + except Exception: + self.log.exception(f"Failed to execute {name}") + return From 4aa98b888bc2895b8dddba555d9650d8ffeeb5dd Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 01:53:44 +0300 Subject: [PATCH 02/50] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c004c16..d491d0f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ # reactbot -A simple [maubot](https://github.com/maubot/maubot) that reacts to messages that match predefined rules. +A simple [maubot](https://github.com/maubot/maubot) that responds to messages that match predefined rules. From 1245e6540af72d9ce945dfe1782a645c1d7eba00 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 01:57:15 +0300 Subject: [PATCH 03/50] Replace jesari with alot in example config --- base-config.yaml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/base-config.yaml b/base-config.yaml index 48c1b30..a63f3b6 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -8,23 +8,23 @@ templates: rel_type: m.annotation event_id: $${react_to_event} key: $${reaction} - jesari: + alot: type: m.room.message content: msgtype: m.image - body: putkiteippi.gif - url: "mxc://maunium.net/LNjeTZvDEaUdQAROvWGHLLDi" + body: image.png + url: "mxc://maunium.net/eFnyRdgJOHlKXCxzoKPQbwLV" info: - mimetype: image/gif - width: 1280 - height: 535 - size: 7500893 - thumbnail_url: "mxc://maunium.net/xdhlegZQgGwlMRzBfhNxyEfb" + mimetype: image/png + w: 680 + h: 510 + size: 247492 + thumbnail_url: "mxc://maunium.net/PMxffxMfcUZeWeeYMDCdghBG" thumbnail_info: + w: 680 + h: 510 mimetype: image/png - width: 800 - height: 334 - size: 417896 + size: 233763 rules: twim_cookies: @@ -33,6 +33,6 @@ rules: template: reaction variables: reaction: 🍪 - jesari: - matches: [jesari] - template: jesari + alot: + matches: [alot] + template: alot From d237d3bbc9f2b37caaeca3370f38300d56f26160 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 02:02:02 +0300 Subject: [PATCH 04/50] Ignore own events and fix initial pattern match --- reactbot.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/reactbot.py b/reactbot.py index 6506a7d..7c7090d 100644 --- a/reactbot.py +++ b/reactbot.py @@ -112,7 +112,7 @@ class Rule: if len(self.rooms) > 0 and evt.room_id not in self.rooms: return None for pattern in self.matches: - match = pattern.match(evt.content.body) + match = pattern.search(evt.content.body) if match: return match return None @@ -154,7 +154,9 @@ class ReactBot(Plugin): for name, rule in self.config["rules"].items()} @event.on(EventType.ROOM_MESSAGE) - async def echo_handler(self, evt: MessageEvent) -> None: + async def event_handler(self, evt: MessageEvent) -> None: + if evt.sender == self.client.mxid: + return for name, rule in self.rules.items(): match = rule.match(evt) if match is not None: From d0e9aad1ff073dca673c301d66ab6b9bbaea49da Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 02:08:30 +0300 Subject: [PATCH 05/50] Remove unused imports --- reactbot.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/reactbot.py b/reactbot.py index 7c7090d..99c6d57 100644 --- a/reactbot.py +++ b/reactbot.py @@ -24,7 +24,7 @@ from jinja2 import Template as JinjaTemplate from mautrix.types import RoomID, EventType, Event from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper -from maubot import Plugin, MessageEvent, Client +from maubot import Plugin, MessageEvent from maubot.handlers import event @@ -34,15 +34,14 @@ class Config(BaseProxyConfig): helper.copy("templates") -variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}") - - class Key(str): pass Index = NewType("Index", Union[str, int, Key]) +variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}") + @dataclass class Template: From 0fcf7a83199b9cedd048264e1801ef38dca6be37 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 02:12:09 +0300 Subject: [PATCH 06/50] Make loading templates and rules cleaner --- reactbot.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/reactbot.py b/reactbot.py index 99c6d57..2a588f4 100644 --- a/reactbot.py +++ b/reactbot.py @@ -137,19 +137,28 @@ class ReactBot(Plugin): self.templates = {} self.on_external_config_update() + @staticmethod + def _parse_variables(data: Dict[str, Any]) -> Dict[str, JinjaTemplate]: + return {name: JinjaTemplate(var_tpl) for name, var_tpl + in data.get("variables", {}).items()} + + def _make_template(self, tpl: Dict[str, Any]) -> Template: + return Template(type=EventType.find(tpl.get("type", "m.room.message")), + variables=self._parse_variables(tpl), + content=tpl.get("content", {})).init() + + def _make_rule(self, rule: Dict[str, Any]) -> Rule: + return Rule(rooms=set(rule.get("rooms", [])), + matches=[re.compile(match) for match in rule.get("matches")], + type=EventType.find(rule["type"]) if "type" in rule else None, + template=self.templates[rule["template"]], + variables=self._parse_variables(rule)) + def on_external_config_update(self) -> None: self.config.load_and_update() - self.templates = {name: Template(type=EventType.find(tpl.get("type", "m.room.message")), - variables={name: JinjaTemplate(var_tpl) for name, var_tpl - in tpl.get("variables", {}).items()}, - content=tpl.get("content", {})).init() + self.templates = {name: self._make_template(tpl) for name, tpl in self.config["templates"].items()} - self.rules = {name: Rule(rooms=set(rule.get("rooms", [])), - matches=[re.compile(match) for match in rule.get("matches")], - type=EventType.find(rule["type"]) if "type" in rule else None, - template=self.templates[rule["template"]], - variables={name: JinjaTemplate(template) for name, template - in rule.get("variables", {}).items()}) + self.rules = {name: self._make_rule(rule) for name, rule in self.config["rules"].items()} @event.on(EventType.ROOM_MESSAGE) From 2c54aa395a621d7e24babdf5b0105c37e6c07a97 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 02:56:44 +0300 Subject: [PATCH 07/50] Fix mistake and handle errors --- reactbot.py | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/reactbot.py b/reactbot.py index 2a588f4..c391f19 100644 --- a/reactbot.py +++ b/reactbot.py @@ -89,7 +89,7 @@ class Template: **extra_vars} content = copy.deepcopy(self.content) for path in self._variable_locations: - data: Dict[str, Any] = self._recurse(content, path[:1]) + data: Dict[str, Any] = self._recurse(content, path[:-1]) key = path[-1] if isinstance(key, Key): key = str(key) @@ -123,6 +123,10 @@ class Rule: await evt.client.send_message_event(evt.room_id, self.type or self.template.type, content) +class ConfigError(Exception): + pass + + class ReactBot(Plugin): rules: Dict[str, Rule] templates: Dict[str, Template] @@ -142,24 +146,33 @@ class ReactBot(Plugin): return {name: JinjaTemplate(var_tpl) for name, var_tpl in data.get("variables", {}).items()} - def _make_template(self, tpl: Dict[str, Any]) -> Template: - return Template(type=EventType.find(tpl.get("type", "m.room.message")), - variables=self._parse_variables(tpl), - content=tpl.get("content", {})).init() + def _make_template(self, name: str, tpl: Dict[str, Any]) -> Template: + try: + return Template(type=EventType.find(tpl.get("type", "m.room.message")), + variables=self._parse_variables(tpl), + content=tpl.get("content", {})).init() + except Exception as e: + raise ConfigError(f"Failed to load {name}") from e - def _make_rule(self, rule: Dict[str, Any]) -> Rule: - return Rule(rooms=set(rule.get("rooms", [])), - matches=[re.compile(match) for match in rule.get("matches")], - type=EventType.find(rule["type"]) if "type" in rule else None, - template=self.templates[rule["template"]], - variables=self._parse_variables(rule)) + def _make_rule(self, name: str, rule: Dict[str, Any]) -> Rule: + try: + return Rule(rooms=set(rule.get("rooms", [])), + matches=[re.compile(match) for match in rule.get("matches")], + type=EventType.find(rule["type"]) if "type" in rule else None, + template=self.templates[rule["template"]], + variables=self._parse_variables(rule)) + except Exception as e: + raise ConfigError(f"Failed to load {name}") from e def on_external_config_update(self) -> None: self.config.load_and_update() - self.templates = {name: self._make_template(tpl) - for name, tpl in self.config["templates"].items()} - self.rules = {name: self._make_rule(rule) - for name, rule in self.config["rules"].items()} + try: + self.templates = {name: self._make_template(name, tpl) + for name, tpl in self.config["templates"].items()} + self.rules = {name: self._make_rule(name, rule) + for name, rule in self.config["rules"].items()} + except ConfigError: + self.log.exception("Failed to load config") @event.on(EventType.ROOM_MESSAGE) async def event_handler(self, evt: MessageEvent) -> None: From 3992db4464d8d4eb5a777a8286e4362e4b14ee54 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 03:08:03 +0300 Subject: [PATCH 08/50] Make non-regex matching faster --- reactbot.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/reactbot.py b/reactbot.py index c391f19..966936f 100644 --- a/reactbot.py +++ b/reactbot.py @@ -13,7 +13,8 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import NewType, Optional, Pattern, Match, Union, Dict, List, Tuple, Set, Type, Any +from typing import (NewType, Optional, Callable, Pattern, Match, Union, Dict, List, Tuple, Set, + Type, Any) from itertools import chain import copy import re @@ -34,10 +35,36 @@ class Config(BaseProxyConfig): helper.copy("templates") +class ConfigError(Exception): + pass + + class Key(str): pass +class BlankMatch: + @staticmethod + def groups() -> List[str]: + return [] + + +class SimplePattern: + _ptm = BlankMatch() + + matcher: Callable[[str], bool] + + def __init__(self, matcher: Callable[[str], bool]) -> None: + self.matcher = matcher + + def match(self, val: str) -> BlankMatch: + if self.matcher(val): + return self._ptm + + +RMatch = Union[Match, BlankMatch] +RPattern = Union[Pattern, SimplePattern] + Index = NewType("Index", Union[str, int, Key]) variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}") @@ -102,7 +129,8 @@ class Template: @dataclass class Rule: rooms: Set[RoomID] - matches: List[Pattern] + matches: List[RPattern] + not_matches: List[RPattern] template: Template type: Optional[EventType] variables: Dict[str, JinjaTemplate] @@ -123,10 +151,6 @@ class Rule: await evt.client.send_message_event(evt.room_id, self.type or self.template.type, content) -class ConfigError(Exception): - pass - - class ReactBot(Plugin): rules: Dict[str, Rule] templates: Dict[str, Template] @@ -154,10 +178,30 @@ class ReactBot(Plugin): except Exception as e: raise ConfigError(f"Failed to load {name}") from e + @staticmethod + def _compile(pattern: str) -> RPattern: + esc = re.escape(pattern) + if esc == pattern: + return SimplePattern(lambda val: pattern in val) + elif pattern[0] == '^' and esc == f"\\^{pattern}": + pattern = pattern[1:] + return SimplePattern(lambda val: val.startswith(pattern)) + elif pattern[-1] == '$' and esc == f"{pattern}\\$": + pattern = pattern[:-1] + return SimplePattern(lambda val: val.endswith(pattern)) + elif pattern[0] == '^' and pattern[-1] == '$' and esc == f"\\^{pattern}\\$": + pattern = pattern[1:-1] + return SimplePattern(lambda val: val == pattern) + return re.compile(pattern) + + def _compile_all(self, patterns: List[str]) -> List[RPattern]: + return [self._compile(pattern) for pattern in patterns] + def _make_rule(self, name: str, rule: Dict[str, Any]) -> Rule: try: return Rule(rooms=set(rule.get("rooms", [])), - matches=[re.compile(match) for match in rule.get("matches")], + matches=self._compile_all(rule["matches"]), + 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)) From 98c3bfb252de7df60e33e9f51281eef454049932 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 03:45:00 +0300 Subject: [PATCH 09/50] Add support for regex flags --- base-config.yaml | 3 ++ reactbot.py | 81 ++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/base-config.yaml b/base-config.yaml index a63f3b6..a523deb 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -26,6 +26,9 @@ templates: mimetype: image/png size: 233763 +default_flags: +- ignorecase + rules: twim_cookies: rooms: ["!FPUfgzXYWTKgIrwKxW:matrix.org"] diff --git a/reactbot.py b/reactbot.py index 966936f..87472bc 100644 --- a/reactbot.py +++ b/reactbot.py @@ -33,6 +33,7 @@ class Config(BaseProxyConfig): def do_update(self, helper: ConfigUpdateHelper) -> None: helper.copy("rules") helper.copy("templates") + helper.copy("default_flags") class ConfigError(Exception): @@ -53,11 +54,15 @@ class SimplePattern: _ptm = BlankMatch() matcher: Callable[[str], bool] + ignorecase: bool - def __init__(self, matcher: Callable[[str], bool]) -> None: + def __init__(self, matcher: Callable[[str], bool], ignorecase: bool) -> None: self.matcher = matcher + self.ignorecase = ignorecase - def match(self, val: str) -> BlankMatch: + def search(self, val: str) -> BlankMatch: + if self.ignorecase: + val = val.lower() if self.matcher(val): return self._ptm @@ -135,12 +140,20 @@ class Rule: type: Optional[EventType] variables: Dict[str, JinjaTemplate] + def _check_not_match(self, body: str) -> bool: + for pattern in self.not_matches: + if pattern.search(body): + return True + return False + def match(self, evt: MessageEvent) -> Optional[Match]: if len(self.rooms) > 0 and evt.room_id not in self.rooms: return None for pattern in self.matches: match = pattern.search(evt.content.body) if match: + if self._check_not_match(evt.content.body): + return None return match return None @@ -154,6 +167,7 @@ class Rule: class ReactBot(Plugin): rules: Dict[str, Rule] templates: Dict[str, Template] + default_flags: re.RegexFlag @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: @@ -178,21 +192,52 @@ class ReactBot(Plugin): except Exception as e: raise ConfigError(f"Failed to load {name}") from e - @staticmethod - def _compile(pattern: str) -> RPattern: - esc = re.escape(pattern) - if esc == pattern: - return SimplePattern(lambda val: pattern in val) - elif pattern[0] == '^' and esc == f"\\^{pattern}": - pattern = pattern[1:] - return SimplePattern(lambda val: val.startswith(pattern)) - elif pattern[-1] == '$' and esc == f"{pattern}\\$": - pattern = pattern[:-1] - return SimplePattern(lambda val: val.endswith(pattern)) - elif pattern[0] == '^' and pattern[-1] == '$' and esc == f"\\^{pattern}\\$": - pattern = pattern[1:-1] - return SimplePattern(lambda val: val == pattern) - return re.compile(pattern) + def _get_flags(self, flags: str) -> re.RegexFlag: + output = self.default_flags + for flag in flags: + flag = flag.lower() + if flag == "i" or flag == "ignorecase": + output |= re.IGNORECASE + elif flag == "s" or flag == "dotall": + output |= re.DOTALL + elif flag == "x" or flag == "verbose": + output |= re.VERBOSE + elif flag == "m" or flag == "multiline": + output |= re.MULTILINE + elif flag == "l" or flag == "locale": + output |= re.LOCALE + elif flag == "u" or flag == "unicode": + output |= re.UNICODE + elif flag == "a" or flag == "ascii": + output |= re.ASCII + return output + + def _compile(self, pattern: str) -> RPattern: + flags = self.default_flags + raw = False + if isinstance(pattern, dict): + flags = self._get_flags(pattern.get("flags", "")) + pattern = pattern["pattern"] + raw = pattern.get("raw", False) + if not flags or flags == re.IGNORECASE: + ignorecase = flags == re.IGNORECASE + s_pattern = pattern.lower() if ignorecase else pattern + esc = "" + if not raw: + esc = re.escape(pattern) + first, last = pattern[0], pattern[-1] + if first == '^' and last == '$' and (raw or esc == f"\\^{pattern[1:-1]}\\$"): + s_pattern = s_pattern[1:-1] + return SimplePattern(lambda val: val == s_pattern, ignorecase=ignorecase) + elif first == '^' and (raw or esc == f"\\^{pattern[1:]}"): + s_pattern = s_pattern[1:] + return SimplePattern(lambda val: val.startswith(s_pattern), ignorecase=ignorecase) + elif last == '$' and (raw or esc == f"{pattern[:-1]}\\$"): + s_pattern = s_pattern[:-1] + return SimplePattern(lambda val: val.endswith(s_pattern), ignorecase=ignorecase) + elif raw or esc == pattern: + return SimplePattern(lambda val: s_pattern in val, ignorecase=ignorecase) + return re.compile(pattern, flags=flags) def _compile_all(self, patterns: List[str]) -> List[RPattern]: return [self._compile(pattern) for pattern in patterns] @@ -211,6 +256,8 @@ class ReactBot(Plugin): def on_external_config_update(self) -> None: self.config.load_and_update() try: + self.default_flags = re.RegexFlag(0) + self.default_flags = self._get_flags(self.config["default_flags"]) self.templates = {name: self._make_template(name, tpl) for name, tpl in self.config["templates"].items()} self.rules = {name: self._make_rule(name, rule) From 9a0f6da774ba0b6113752dc3e122dcc925462152 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 12:03:56 +0300 Subject: [PATCH 10/50] Fix mistake in pattern raw flag --- reactbot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reactbot.py b/reactbot.py index 87472bc..c1812ee 100644 --- a/reactbot.py +++ b/reactbot.py @@ -217,8 +217,8 @@ class ReactBot(Plugin): raw = False if isinstance(pattern, dict): flags = self._get_flags(pattern.get("flags", "")) - pattern = pattern["pattern"] raw = pattern.get("raw", False) + pattern = pattern["pattern"] if not flags or flags == re.IGNORECASE: ignorecase = flags == re.IGNORECASE s_pattern = pattern.lower() if ignorecase else pattern From 47e1d5d7ec102052e4d4b57a86e366593a30ebf1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 12:05:10 +0300 Subject: [PATCH 11/50] Add sample configs for jesaribot and stallman bot --- samples/jesari.yaml | 26 +++ samples/stallman.yaml | 416 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 samples/jesari.yaml create mode 100644 samples/stallman.yaml diff --git a/samples/jesari.yaml b/samples/jesari.yaml new file mode 100644 index 0000000..b05dee4 --- /dev/null +++ b/samples/jesari.yaml @@ -0,0 +1,26 @@ +templates: + jesari: + type: m.room.message + content: + msgtype: m.image + body: putkiteippi.gif + url: mxc://maunium.net/LNjeTZvDEaUdQAROvWGHLLDi + info: + mimetype: image/gif + w: 1280 + h: 535 + size: 7500893 + thumbnail_url: mxc://maunium.net/xdhlegZQgGwlMRzBfhNxyEfb + thumbnail_info: + mimetype: image/png + w: 800 + h: 334 + size: 417896 + +default_flags: +- ignorecase + +rules: + jesari: + matches: [jesaro?i] + template: jesari diff --git a/samples/stallman.yaml b/samples/stallman.yaml new file mode 100644 index 0000000..470bdde --- /dev/null +++ b/samples/stallman.yaml @@ -0,0 +1,416 @@ +templates: + plaintext_notice: + type: m.room.message + content: + msgtype: m.notice + body: $${message} + +default_flags: +- ignorecase + +rules: + linux: + matches: + - linux + not_matches: + - pattern: gnu+linux + raw: true + - pattern: gnu/linux + raw: true + - gnu plus linux + - kernel + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. What you're referring to as Linux, is in fact, GNU/Linux, or as I've recently taken to calling it, GNU plus Linux. Linux is not an operating system unto itself, but rather another free component of a fully functioning GNU system made useful by the GNU corelibs, shell utilities and vital system components comprising a full OS as defined by POSIX. + Many computer users run a modified version of the GNU system every day, without realizing it. Through a peculiar turn of events, the version of GNU which is widely used today is often called "Linux", and many of its users are not aware that it is basically the GNU system, developed by the GNU Project. + There really is a Linux, and these people are using it, but it is just a part of the system they use. Linux is the kernel: the program in the system that allocates the machine's resources to the other programs that you run. The kernel is an essential part of an operating system, but useless by itself; it can only function in the context of a complete operating system. Linux is normally used in combination with the GNU operating system: the whole system is basically GNU with Linux added, or GNU/Linux. All the so-called "Linux" distributions are really distributions of GNU/Linux. + bsdstyle: + matches: [bsd( |-)style] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The expression "BSD-style license" leads to confusion because it lumps together licenses that have important differences. For instance, the original BSD license with the advertising clause is incompatible with the GNU General Public License, but the revised BSD license is compatible with the GPL. + To avoid confusion, it is best to name the specific license in question and avoid the vague term "BSD-style." + cloudcomp: + matches: [cloud computing, the cloud] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The term "cloud computing" is a marketing buzzword with no clear meaning. It is used for a range of different activities whose only common characteristic is that they use the Internet for something beyond transmitting files. Thus, the term is a nexus of confusion. If you base your thinking on it, your thinking will be vague. + When thinking about or responding to a statement someone else has made using this term, the first step is to clarify the topic. Which kind of activity is the statement really about, and what is a good, clear term for that activity? Once the topic is clear, the discussion can head for a useful conclusion. + Curiously, Larry Ellison, a proprietary software developer, also noted the vacuity of the term "cloud computing." He decided to use the term anyway because, as a proprietary software developer, he isn't motivated by the same ideals as we are. + One of the many meanings of "cloud computing" is storing your data in online services. That exposes you to surveillance. + Another meaning (which overlaps that but is not the same thing) is Software as a Service, which denies you control over your computing. + Another meaning is renting a remote physical server, or virtual server. These can be ok under certain circumstances. + closed: + matches: [closed source] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Describing nonfree software as "closed" clearly refers to the term "open source". In the free software movement, we do not want to be confused with the open source camp, so we are careful to avoid saying things that would encourage people to lump us in with them. For instance, we avoid describing nonfree software as "closed". We call it "nonfree" or "proprietary". + commercial: + matches: [commercial] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Please don't use "commercial" as a synonym for "nonfree." That confuses two entirely different issues. + A program is commercial if it is developed as a business activity. A commercial program can be free or nonfree, depending on its manner of distribution. Likewise, a program developed by a school or an individual can be free or nonfree, depending on its manner of distribution. The two questions--what sort of entity developed the program and what freedom its users have--are independent. + In the first decade of the free software movement, free software packages were almost always noncommercial; the components of the GNU/Linux operating system were developed by individuals or by nonprofit organizations such as the FSF and universities. Later, in the 1990s, free commercial software started to appear. + Free commercial software is a contribution to our community, so we should encourage it. But people who think that "commercial" means "nonfree" will tend to think that the "free commercial" combination is self-contradictory, and dismiss the possibility. Let's be careful not to use the word "commercial" in that way. + consumer: + matches: [consumer] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The term "consumer," when used to refer to computer users, is loaded with assumptions we should reject. Playing a digital recording, or running a program, does not consume it. + The terms "producer" and "consumer" come from economic theory, and bring with them its narrow perspective and misguided assumptions. These tend to warp your thinking. + In addition, describing the users of software as "consumers" presumes a narrow role for them: it regards them as sheep that passively graze on what others make available to them. + This kind of thinking leads to travesties like the CBDTPA "Consumer Broadband and Digital Television Promotion Act" which would require copying restriction facilities in every digital device. If all the users do is "consume," then why should they mind? + The shallow economic conception of users as "consumers" tends to go hand in hand with the idea that published works are mere "content." + To describe people who are not limited to passive use of works, we suggest terms such as "individuals" and "citizens". + content: + matches: [content] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. If you want to describe a feeling of comfort and satisfaction, by all means say you are "content," but using the word as a noun to describe written and other works of authorship adopts an attitude you might rather avoid. It regards these works as a commodity whose purpose is to fill a box and make money. In effect, it disparages the works themselves. + Those who use this term are often the publishers that push for increased copyright power in the name of the authors ("creators," as they say) of the works. The term "content" reveals their real attitude towards these works and their authors. (See Courtney Love's open letter to Steve Case and search for "content provider" in that page. Alas, Ms. Love is unaware that the term "intellectual property" is also biased and confusing.) + However, as long as other people use the term "content provider", political dissidents can well call themselves "malcontent providers". + The term "content management" takes the prize for vacuity. "Content" means "some sort of information," and "management" in this context means "doing something with it." So a "content management system" is a system for doing something to some sort of information. Nearly all programs fit that description. + In most cases, that term really refers to a system for updating pages on a web site. For that, we recommend the term "web site revision system" (WRS). + digital_goods: + matches: [digital goods] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The term "digital goods," as applied to copies of works of authorship, erroneously identifies them with physical goods--which cannot be copied, and which therefore have to be manufactured and sold. + digital_locks: + matches: [digital locks] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. "Digital locks" is used to refer to Digital Restrictions Management by some who criticize it. The problem with this term is that it fails to show what's wrong with the practice. + Locks are not necessarily an injustice. You probably own several locks, and their keys or codes as well; you may find them useful or troublesome, but either way they don't oppress you, because you can open and close them. + DRM is like a lock placed on you by someone else, who refuses to give you the key -- in other words, like handcuffs. Therefore, we call them "digital handcuffs", not "digital locks". + A number of campaigns have chosen the unwise term "digital locks"; therefore, to correct the mistake, we must work firmly against it. We may support a campaign that criticizes "digital locks", because we might agree with the substance; but when we do, we always state our rejection of that term and conspicuously say "digital handcuffs" so as to set a better example. + drm: + matches: [drm, digital rights management] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. "Digital Rights Management" refers to technical schemes designed to impose restrictions on computer users. The use of the word "rights" in this term is propaganda, designed to lead you unawares into seeing the issue from the viewpoint of the few that impose the restrictions, and ignoring that of the general public on whom these restrictions are imposed. + Good alternatives include "Digital Restrictions Management," and "digital handcuffs." + eco: + matches: [ecosystem] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. It is a mistake to describe the free software community, or any human community, as an "ecosystem," because that word implies the absence of ethical judgment. + The term "ecosystem" implicitly suggests an attitude of nonjudgmental observation: don't ask how what should happen, just study and explain what does happen. In an ecosystem, some organisms consume other organisms. We do not ask whether it is fair for an owl to eat a mouse or for a mouse to eat a plant, we only observe that they do so. Species' populations grow or shrink according to the conditions; this is neither right nor wrong, merely an ecological phenomenon. + By contrast, beings that adopt an ethical stance towards their surroundings can decide to preserve things that, on their own, might vanish--such as civil society, democracy, human rights, peace, public health, clean air and water, endangered species, traditional arts…and computer users' freedom. + freeware: + matches: [freeware] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Please don't use the term "freeware" as a synonym for "free software." The term "freeware" was used often in the 1980s for programs released only as executables, with source code not available. Today it has no particular agreed-on definition. + When using languages other than English, please avoid borrowing English terms such as "free software" or "freeware." It is better to translate the term "free software" into your language. + By using a word in your own language, you show that you are really referring to freedom and not just parroting some mysterious foreign marketing concept. The reference to freedom may at first seem strange or disturbing to your compatriots, but once they see that it means exactly what it says, they will really understand what the issue is. + give: + matches: [give away software] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. It's misleading to use the term "give away" to mean "distribute a program as free software." This locution has the same problem as "for free": it implies the issue is price, not freedom. One way to avoid the confusion is to say "release as free software." + hacker: + matches: [hacker] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. A hacker is someone who enjoys playful cleverness--not necessarily with computers. The programmers in the old MIT free software community of the 60s and 70s referred to themselves as hackers. Around 1980, journalists who discovered the hacker community mistakenly took the term to mean "security breaker." + Please don't spread this mistake. People who break security are "crackers." + ip: + matches: [intellectual property] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Publishers and lawyers like to describe copyright as "intellectual property"--a term also applied to patents, trademarks, and other more obscure areas of law. These laws have so little in common, and differ so much, that it is ill-advised to generalize about them. It is best to talk specifically about "copyright," or about "patents," or about "trademarks." + The term "intellectual property" carries a hidden assumption--that the way to think about all these disparate issues is based on an analogy with physical objects, and our conception of them as physical property. + When it comes to copying, this analogy disregards the crucial difference between material objects and information: information can be copied and shared almost effortlessly, while material objects can't be. + To avoid spreading unnecessary bias and confusion, it is best to adopt a firm policy not to speak or even think in terms of "intellectual property". + The hypocrisy of calling these powers "rights" is starting to make the World "Intellectual Property" Organization embarrassed. + lamp: + matches: [(\s|^)lamp] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. "LAMP" stands for "Linux, Apache, MySQL and PHP"--a common combination of software to use on a web server, except that "Linux" in this context really refers to the GNU/Linux system. So instead of "LAMP" it should be "GLAMP": "GNU, Linux, Apache, MySQL and PHP." + market: + matches: [software market] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. It is misleading to describe the users of free software, or the software users in general, as a "market." + This is not to say there is no room for markets in the free software community. If you have a free software support business, then you have clients, and you trade with them in a market. As long as you respect their freedom, we wish you success in your market. + But the free software movement is a social movement, not a business, and the success it aims for is not a market success. We are trying to serve the public by giving it freedom--not competing to draw business away from a rival. To equate this campaign for freedom to a business' efforts for mere success is to deny the importance of freedom and legitimize proprietary software. + monetize: + matches: [monetize] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The natural meaning of "monetize" is "convert into money". If you make something and then convert it into money, that means there is nothing left except money, so nobody but you has gained anything, and you contribute nothing to the world. + By contrast, a productive and ethical business does not convert all of its product into money. Part of it is a contribution to the rest of the world. + mp3: + matches: [mp3 player] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. In the late 1990s it became feasible to make portable, solid-state digital audio players. Most support the patented MP3 codec, but not all. Some support the patent-free audio codecs Ogg Vorbis and FLAC, and may not even support MP3-encoded files at all, precisely to avoid these patents. To call such players "MP3 players" is not only confusing, it also puts MP3 in an undeserved position of privilege which encourages people to continue using that vulnerable format. We suggest the terms "digital audio player," or simply "audio player" if context permits. + open: + matches: [open source] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Please avoid using the term "open" or "open source" as a substitute for "free software". Those terms refer to a different position based on different values. Free software is a political movement; open source is a development model. When referring to the open source position, using its name is appropriate; but please do not use it to label us or our work--that leads people to think we share those views. + pc: + matches: [(\s|^)pcs?] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. It's OK to use the abbreviation "PC" to refer to a certain kind of computer hardware, but please don't use it with the implication that the computer is running Microsoft Windows. If you install GNU/Linux on the same computer, it is still a PC. + The term "WC" has been suggested for a computer running Windows. + ps: + matches: [photoshop] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Please avoid using the term "photoshop" as a verb, meaning any kind of photo manipulation or image editing in general. Photoshop is just the name of one particular image editing program, which should be avoided since it is proprietary. There are plenty of free programs for editing images, such as the GIMP. + piracy: + matches: [piracy, pirate] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Publishers often refer to copying they don't approve of as "piracy." In this way, they imply that it is ethically equivalent to attacking ships on the high seas, kidnapping and murdering the people on them. Based on such propaganda, they have procured laws in most of the world to forbid copying in most (or sometimes all) circumstances. (They are still pressuring to make these prohibitions more complete.) + If you don't believe that copying not approved by the publisher is just like kidnapping and murder, you might prefer not to use the word "piracy" to describe it. Neutral terms such as "unauthorized copying" (or "prohibited copying" for the situation where it is illegal) are available for use instead. Some of us might even prefer to use a positive term such as "sharing information with your neighbor." + powerpoint: + matches: [powerpoint, \sppt] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Please avoid using the term "PowerPoint" to mean any kind of slide presentation. "PowerPoint" is just the name of one particular proprietary program to make presentations, and there are plenty of free program for presentations, such as TeX's beamer class and OpenOffice.org's Impress. + protection: + matches: [protection] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Publishers' lawyers love to use the term "protection" to describe copyright. This word carries the implication of preventing destruction or suffering; therefore, it encourages people to identify with the owner and publisher who benefit from copyright, rather than with the users who are restricted by it. + It is easy to avoid "protection" and use neutral terms instead. For example, instead of saying, "Copyright protection lasts a very long time," you can say, "Copyright lasts a very long time." + If you want to criticize copyright instead of supporting it, you can use the term "copyright restrictions." Thus, you can say, "Copyright restrictions last a very long time." + The term "protection" is also used to describe malicious features. For instance, "copy protection" is a feature that interferes with copying. From the user's point of view, this is obstruction. So we could call that malicious feature "copy obstruction." More often it is called Digital Restrictions Management (DRM)--see the Defective by Design campaign. + sellsoft: + matches: [sell software, selling software] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The term "sell software" is ambiguous. Strictly speaking, exchanging a copy of a free program for a sum of money is selling; but people usually associate the term "sell" with proprietary restrictions on the subsequent use of the software. You can be more precise, and prevent confusion, by saying either "distributing copies of a program for a fee" or "imposing proprietary restrictions on the use of a program," depending on what you mean. + softwareindustry: + matches: [software industry] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The term "software industry" encourages people to imagine that software is always developed by a sort of factory and then delivered to "consumers." The free software community shows this is not the case. Software businesses exist, and various businesses develop free and/or nonfree software, but those that develop free software are not run like factories. + The term "industry" is being used as propaganda by advocates of software patents. They call software development "industry" and then try to argue that this means it should be subject to patent monopolies. The European Parliament, rejecting software patents in 2003, voted to define "industry" as "automated production of material goods." + trustedcomp: + matches: [trusted computing] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. "Trusted computing" is the proponents' name for a scheme to redesign computers so that application developers can trust your computer to obey them instead of you. From their point of view, it is "trusted"; from your point of view, it is "treacherous." + vendor: + matches: [vendor] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Please don't use the term "vendor" to refer generally to anyone that develops or packages software. Many programs are developed in order to sell copies, and their developers are therefore their vendors; this even includes some free software packages. However, many programs are developed by volunteers or organizations which do not intend to sell copies. These developers are not vendors. Likewise, only some of the packagers of GNU/Linux distributions are vendors. We recommend the general term "supplier" instead. + arch: + matches: [(^|\s)arch] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Arch has the two usual problems: there's no clear policy about what software can be included, and nonfree blobs are shipped with their kernel. Arch also has no policy about not distributing nonfree software through their normal channels. + centos: + matches: [centos] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. We're not aware of problems in CentOS aside from the two usual ones: there's no clear policy about what software can be included, and nonfree blobs are shipped with the kernel. Of course, with no firm policy in place, there might be other nonfree software included that we missed. + debian: + matches: [debian] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Debian's Social Contract states the goal of making Debian entirely free software, and Debian conscientiously keeps nonfree software out of the official Debian system. However, Debian also provides a repository of nonfree software. According to the project, this software is "not part of the Debian system," but the repository is hosted on many of the project's main servers, and people can readily learn about these nonfree packages by browsing Debian's online package database. + There is also a "contrib" repository; its packages are free, but some of them exist to load separately distributed proprietary programs. This too is not thoroughly separated from the main Debian distribution. + Previous releases of Debian included nonfree blobs with the kernel. With the release of Debian 6.0 ("squeeze") in February 2011, these blobs have been moved out of the main distribution to separate packages in the nonfree repository. However, the problem partly remains: the installer in some cases recommends these nonfree firmware files for the peripherals on the machine. + fedora: + matches: [fedora] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Fedora does have a clear policy about what can be included in the distribution, and it seems to be followed carefully. The policy requires that most software and all fonts be available under a free license, but makes an exception for certain kinds of nonfree firmware. Unfortunately, the decision to allow that firmware in the policy keeps Fedora from meeting the free system distribution guidelines. + seal: + matches: [(fuck|screw|go away|die)\s?(you)?\s?(linux|stallman|gpl|rms|richard|linus), + '((linux|stallman|gpl|rms|richard|linus) pls go|Shut your filthy hippy + mouth,? (stallman|rms|richard|linus))', (linux|stallman|gpl|rms|richard|linus)\s+is\s+] + template: plaintext_notice + variables: + message: | + What the fuck did you just fucking say about me, you little proprietary bitch? I'll have you know I graduated top of my class in the FSF, and I've been involved in numerous secret raids on Apple patents, and I have over 300 confirmed bug fixes. I am trained in Free Software Evangelizing and I'm the top code contributer for the entire GNU HURD. You are nothing to me but just another compile time error. I will wipe you the fuck out with precision the likes of which has never been seen before on this Earth, mark my fucking words. You think you can get away with saying that shit to me over the Internet? Think again, fucker. As we speak I am building a GUI using GTK+ and your IP is being traced right now so you better prepare for the storm, maggot. The storm that wipes out the pathetic little thing you call your life. You're fucking dead, kid. I can be anywhere, anytime, and I can decompile you in over seven hundred ways, and that's just with my Model M. Not only am I extensively trained in EMACS, but I have access to the entire arsenal of LISP functions and I will use it to its full extent to wipe your miserable ass off the face of the continent, you little shit. If only you could have known what unholy retribution your little "clever" comment was about to bring down upon you, maybe you would have held your fucking tongue. But you couldn't, you didn't, and now you're paying the price, you goddamn idiot. I will shit Freedom all over you and you will drown in it. + gentoo: + matches: [gentoo] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. I have a low opinion of Gentoo GNU/Linux. + Gentoo is a GNU/Linux distribution, but its developers don't recognize this; they call it "Gentoo Linux". That means they are treating me and the GNU Project disresepectfully. + More importantly, Gentoo steers the user towards nonfree programs, which is why it is not one of our recognized free distros. + mandriva: + matches: [mandriva] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Mandriva does have a stated policy about what can be included in the main system. It's based on Fedora's, which means that it also allows certain kinds of nonfree firmware to be included. On top of that, it permits software released under the original Artistic License to be included, even though that's a nonfree license. + Mandriva also provides nonfree software through dedicated repositories. + opensuse: + matches: [opensuse] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. OpenSUSE offers its users access to a repository of nonfree software. This is an instance of how "open" is weaker than "free". + redhat: + matches: [redhat, red hat, rhel] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Red Hat's enterprise distribution primarily follows the same licensing policies as Fedora, with one exception. Thus, we don't endorse it for the same reasons. In addition to those, Red Hat has no policy against making nonfree software available for the system through supplementary distribution channels. + slackware: + matches: [slackware] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Slackware has the two usual problems: there's no clear policy about what software can be included, and nonfree blobs are included in the kernel. It also ships with the nonfree image-viewing program xv. Of course, with no firm policy in place, there might be other nonfree software included that we missed. + ubuntu: + matches: [ubuntu] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Ubuntu provides specific repositories of nonfree software, and Canonical expressly promotes and recommends nonfree software under the Ubuntu name in some of their distribution channels. Ubuntu offers the option to install only free packages, which means it also offers the option to install nonfree packages too. In addition, the version of the kernel, included in Ubuntu contains firmware blobs. + Ubuntu's trademark policy prohibits commercial redistribution of exact copies of Ubuntu, denying an important freedom. + bsd: + matches: [freebsd, openbsd, netbsd, bsd] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. FreeBSD, NetBSD, and OpenBSD all include instructions for obtaining nonfree programs in their ports system. In addition, their kernels include nonfree firmware blobs. + Nonfree firmware programs used with the kernel, are called "blobs", and that's how we use the term. In BSD parlance, the term "blob" means something else: a nonfree driver. OpenBSD and perhaps other BSD distributions (called "projects" by BSD developers) have the policy of not including those. That is the right policy, as regards drivers; but when the developers say these distributions "contain no blobs", it causes a misunderstanding. They are not talking about firmware blobs. + No BSD distribution has policies against proprietary binary-only firmware that might be loaded even by free drivers. + compensation: + matches: [compensation] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. To speak of “compensation for authors” in connection with copyright carries the assumptions that (1) copyright exists for the sake of authors and (2) whenever we read something, we take on a debt to the author which we must then repay. The first assumption is simply false, and the second is outrageous. + “Compensating the rights-holders” adds a further swindle: you're supposed to imagine that means paying the authors, and occasionally it does, but most of the time it means a subsidy for the same publishing companies that are pushing unjust laws on us. + consume: + matches: [consume\s] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. “Consume” refers to what we do with food: we ingest it, after which the food as such no longer exists. By analogy, we employ the same word for other products whose use uses them up. Applying it to durable goods, such as clothing or appliances, is a stretch. Applying it to published works (programs, recordings on a disk or in a file, books on paper or in a file), whose nature is to last indefinitely and which can be run, played or read any number of times, is simply an error. Playing a recording, or running a program, does not consume it. + The term “consume” is associated with the economics of uncopiable material products, and leads people to transfer its conclusions unconsciously to copiable digital works — an error that proprietary software developers (and other publishers) dearly wish to encourage. Their twisted viewpoint comes through clearly in this article, which also refers to publications as “content.” + The narrow thinking associated with the idea that we “consume content” paves the way for laws such as the DMCA that forbid users to break the Digital Restrictions Management (DRM) facilities in digital devices. If users think what they do with these devices is “consume,” they may see such restrictions as natural. + It also encourages the acceptation of “streaming” services, which use DRM to limit use of digital recordings to a form that fits the word “consume.” + Why is this perverse usage spreading? Some may feel that the term sounds sophisticated; if that attracts you, rejecting it with cogent reasons can appear even more sophisticated. Others may be acting from business interests (their own, or their employers'). Their use of the term in prestigious forums gives the impression that it's the “correct” term. + To speak of “consuming” music, fiction, or any other artistic works is to treat them as products rather than as art. If you don't want to spread that attitude, you would do well to reject using the term “consume” for them. We recommend saying that someone “experiences” an artistic work or a work stating a point of view, and that someone “uses” a practical work. + creativecommons: + matches: [creative commons] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The most important licensing characteristic of a work is whether it is free. Creative Commons publishes seven licenses; three are free (CC BY, CC BY-SA and CC0) and the rest are nonfree. Thus, to describe a work as “Creative Commons licensed” fails to say whether it is free, and suggests that the question is not important. The statement may be accurate, but the omission is harmful. + To encourage people to pay attention to the most important distinction, always specify which Creative Commons license is used, as in “licensed under CC BY-SA.” If you don't know which license a certain work uses, find out and then make your statement. + creator: + matches: [creator] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The term “creator” as applied to authors implicitly compares them to a deity (“the creator”). The term is used by publishers to elevate authors' moral standing above that of ordinary people in order to justify giving them increased copyright power, which the publishers can then exercise in their name. We recommend saying “author” instead. However, in many cases “copyright holder” is what you really mean. These two terms are not equivalent: often the copyright holder is not the author. + floss: + matches: [floss] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The term “FLOSS,” meaning “Free/Libre and Open Source Software,” was coined as a way to be neutral between free software and open source. If neutrality is your goal, “FLOSS” is the best way to be neutral. But if you want to show you stand for freedom, don't use a neutral term. + forfree: + matches: [for free] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. If you want to say that a program is free software, please don't say that it is available “for free.” That term specifically means “for zero price.” Free software is a matter of freedom, not price. + Free software copies are often available for free—for example, by downloading via FTP. But free software copies are also available for a price on CD-ROMs; meanwhile, proprietary software copies are occasionally available for free in promotions, and some proprietary packages are normally available at no charge to certain users. + To avoid confusion, you can say that the program is available “as free software.” + foss: + matches: [foss] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The term “FOSS,” meaning “Free and Open Source Software,” was coined as a way to be neutral between free software and open source, but it doesn't really do that. If neutrality is your goal, “FLOSS” is better. But if you want to show you stand for freedom, don't use a neutral term. + freelyavailable: + matches: [freely available] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Don't use “freely available software” as a synonym for “free software.” The terms are not equivalent. Software is “freely available” if anyone can easily get a copy. “Free software” is defined in terms of the freedom of users that have a copy of it. These are answers to different questions. + google: + matches: [google\\s] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Please avoid using the term “google” as a verb, meaning to search for something on the internet. “Google” is just the name of one particular search engine among others. We suggest to use the term “web search” instead. Try to use a search engine that respects your privacy; DuckDuckGo claims not to track its users, although we cannot confirm this. + saas: + matches: [saas, software as a service] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. We used to say that SaaS (short for “Software as a Service”) is an injustice, but then we found that there was a lot of variation in people's understanding of which activities count as SaaS. So we switched to a new term, “Service as a Software Substitute” or “SaaSS.” This term has two advantages: it wasn't used before, so our definition is the only one, and it explains what the injustice consists of. + In Spanish we continue to use the term “software como servicio” because the joke of “software como ser vicio” is too good to give up. + sharingeconomy: + matches: [sharing economy] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The term “sharing economy” is not a good way to refer to services such as Uber and Airbnb that arrange business transactions between people. We use the term “sharing” to refer to noncommercial cooperation, including noncommercial redistribution of exact copies of published works. Stretching the word “sharing” to include these transactions undermines its meaning, so we don't use it in this context. + A more suitable term for businesses like Uber is the “piecework service economy.” + skype: + matches: [skype] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Please avoid using the term “Skype” as a verb, meaning any kind of video communication or telephony over the Internet in general. “Skype” is just the name of one particular proprietary program, one that spies on its users. If you want to make video and voice calls over the Internet in a way that respects both your freedom and your privacy, try one of the numerous free Skype replacements. + sourcemodel: + matches: [source model] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. Wikipedia uses the term “source model” in a confused and ambiguous way. Ostensibly it refers to how a program's source is distributed, but the text confuses this with the development methodology. It distinguishes “open source” and ”shared source” as answers, but they overlap — Microsoft uses the latter as a marketing term to cover a range of practices, some of which are “open source”. Thus, this term really conveys no coherent information, but it provides an opportunity to say “open source” in pages describing free software programs. + theft: + matches: [theft] + template: plaintext_notice + variables: + message: | + I'd just like to interject for one moment. The supporters of a too-strict, repressive form of copyright often use words like “stolen” and “theft” to refer to copyright infringement. This is spin, but they would like you to take it for objective truth. + Under the US legal system, copyright infringement is not theft. Laws about theft are not applicable to copyright infringement. The supporters of repressive copyright are making an appeal to authority—and misrepresenting what authority says. + Unauthorized copying is forbidden by copyright law in many circumstances (not all!), but being forbidden doesn't make it wrong. In general, laws don't define right and wrong. Laws, at their best, attempt to implement justice. If the laws (the implementation) don't fit our ideas of right and wrong (the spec), the laws are what should change. + In addition, a US judge, presiding over a trial for copyright infringement, recognized that “piracy” and “theft” are smear-words. + From 3900b04dba7483003c8bd374429da8414bb5412d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 12:14:15 +0300 Subject: [PATCH 12/50] Allow non-string variables --- reactbot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/reactbot.py b/reactbot.py index c1812ee..41f7de9 100644 --- a/reactbot.py +++ b/reactbot.py @@ -111,6 +111,9 @@ class Template: def _replace_variables(tpl: str, variables: Dict[str, Any]) -> str: for match in variable_regex.finditer(tpl): val = variables[match.group(1)] + if match.start() == 0 and match.end() == len(tpl): + # Whole field is a single variable, just return the value to allow non-string types. + return val tpl = tpl[:match.start()] + val + tpl[match.end():] return tpl From 4d5a3a681ce9d28caa7e9c5f7d09e7677d1c4af3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 13:10:53 +0300 Subject: [PATCH 13/50] Add support for raw json template as content and add docs --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ reactbot.py | 36 +++++++++++++++++++++++++++--------- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d491d0f..57410ea 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ # reactbot A simple [maubot](https://github.com/maubot/maubot) that responds to messages that match predefined rules. + +## Config format +### Templates +Templates contain the actual event type and content to be sent. +* `type` - The Matrix event type to send +* `content` - The event content. Either an object or jinja2 template that produces JSON. +* `variables` - A key-value map of variables. + +Variables are parsed as jinja2 templates and get the maubot event object in `event`. + +If the content is a string, it'll be parsed as a jinja2 template and the output +will be parsed as JSON. The content jinja2 template will get `event` just like +variable templates, but it will also get all of the variables. + +If the content is an object, that object is what will be sent as the content. +The object can contain variables using a custom syntax: All instances of +`$${variablename}` will be replaced with the value matching `variablename`. +This works in object keys and values and list items. If a key/value/item only +consists of a variable insertion, the variable may be of any type. If there's +something else than the variable, the variable will be concatenated using `+`, +which means it should be a string. + +### Default flags +Default regex flags. Most Python regex flags are available. +See [docs](https://docs.python.org/3/library/re.html#re.A). + +Most relevant flags: +* `i` / `ignorecase` - Case-insensitive matching. +* `s` / `dotall` - Make `.` match any character at all, including newline. +* `x` / `verbose` - Ignore comments and whitespace in regex. +* `m` / `multiline` - When specified, `^` and `$` match the start and end of + line respectively instead of start and end of whole string. + +### Rules +Rules have five fields. Only `matches` and `template` are required. +* `rooms` - The list of rooms where the rule should apply. + If empty, the rule will apply to all rooms the bot is in. +* `matches` - The regex or list of regexes to match. +* `template` - The name of the template to use. +* `variables` - A key-value map of variables to extend or override template variables. + Like with template variables, the values are parsed as Jinja2 templates. + +The regex(es) in `matches` can either be simple strings containing the pattern, +or objects containing additional info: +* `pattern` - The regex to match. +* `flags` - Regex flags (replaces default flags). +* `raw` - Whether or not the regex should be forced to be raw. + +If `raw` is `true` OR the pattern contains no special regex characters other +than `^` at the start and/or `$` at the end, the pattern will be considered +"raw". Raw patterns don't use regex, but instead use faster string operators +(equality, starts/endwith, contains). Patterns with the `multiline` flag will +never be converted into raw patterns implicitly. diff --git a/reactbot.py b/reactbot.py index 41f7de9..0b75b4e 100644 --- a/reactbot.py +++ b/reactbot.py @@ -16,6 +16,7 @@ from typing import (NewType, Optional, Callable, Pattern, Match, Union, Dict, List, Tuple, Set, Type, Any) from itertools import chain +import json import copy import re @@ -69,6 +70,7 @@ class SimplePattern: RMatch = Union[Match, BlankMatch] RPattern = Union[Pattern, SimplePattern] +InputPattern = Union[str, Dict[str, str]] Index = NewType("Index", Union[str, int, Key]) @@ -79,7 +81,7 @@ variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}") class Template: type: EventType variables: Dict[str, JinjaTemplate] - content: Dict[str, Any] + content: Union[Dict[str, Any], JinjaTemplate] _variable_locations: List[Tuple[Index, ...]] = None @@ -122,6 +124,9 @@ class Template: variables = {**{name: template.render(event=evt) for name, template in chain(self.variables.items(), rule_vars.items())}, **extra_vars} + if isinstance(self.content, JinjaTemplate): + raw_json = self.content.render(event=evt, **variables) + return json.loads(raw_json) content = copy.deepcopy(self.content) for path in self._variable_locations: data: Dict[str, Any] = self._recurse(content, path[:-1]) @@ -187,16 +192,26 @@ class ReactBot(Plugin): return {name: JinjaTemplate(var_tpl) for name, var_tpl in data.get("variables", {}).items()} + def _parse_content(self, content: Union[Dict[str, Any], str] + ) -> Union[Dict[str, Any], JinjaTemplate]: + if not content: + return {} + elif isinstance(content, str): + return JinjaTemplate(content) + return content + def _make_template(self, name: str, tpl: Dict[str, Any]) -> Template: try: return Template(type=EventType.find(tpl.get("type", "m.room.message")), variables=self._parse_variables(tpl), - content=tpl.get("content", {})).init() + content=self._parse_content(tpl.get("content", None))).init() except Exception as e: raise ConfigError(f"Failed to load {name}") from e - def _get_flags(self, flags: str) -> re.RegexFlag: - output = self.default_flags + def _get_flags(self, flags: Union[str, List[str]]) -> re.RegexFlag: + if not flags: + return self.default_flags + output = re.RegexFlag(0) for flag in flags: flag = flag.lower() if flag == "i" or flag == "ignorecase": @@ -215,14 +230,14 @@ class ReactBot(Plugin): output |= re.ASCII return output - def _compile(self, pattern: str) -> RPattern: + def _compile(self, pattern: InputPattern) -> RPattern: flags = self.default_flags - raw = False + raw = None if isinstance(pattern, dict): flags = self._get_flags(pattern.get("flags", "")) raw = pattern.get("raw", False) pattern = pattern["pattern"] - if not flags or flags == re.IGNORECASE: + if raw is not False and (not flags & re.MULTILINE or raw is True): ignorecase = flags == re.IGNORECASE s_pattern = pattern.lower() if ignorecase else pattern esc = "" @@ -242,8 +257,11 @@ class ReactBot(Plugin): return SimplePattern(lambda val: s_pattern in val, ignorecase=ignorecase) return re.compile(pattern, flags=flags) - def _compile_all(self, patterns: List[str]) -> List[RPattern]: - return [self._compile(pattern) for pattern in patterns] + def _compile_all(self, patterns: Union[InputPattern, List[InputPattern]]) -> List[RPattern]: + if isinstance(patterns, list): + return [self._compile(pattern) for pattern in patterns] + else: + return [self._compile(patterns)] def _make_rule(self, name: str, rule: Dict[str, Any]) -> Rule: try: From f2ac1a9c8d1359db8e1d324ea1221d7c87e7542f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 13:13:03 +0300 Subject: [PATCH 14/50] Link to samples in README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 57410ea..1d033ba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ # reactbot A simple [maubot](https://github.com/maubot/maubot) that responds to messages that match predefined rules. +## Samples +* The [base config](base-config.yaml) contains a cookie reaction for TWIM submissions + in [#thisweekinmatrix:matrix.org](https://matrix.to/#/#thisweekinmatrix:matrix.org) + and an image response for "alot". +* [samples/jesari.yaml](samples/jesari.yaml) contains a replacement for [jesaribot](https://github.com/maubot/jesaribot) +* [samples/stallman.yaml](samples/stallman.yaml) contains a Stallman interject bot. + ## Config format ### Templates Templates contain the actual event type and content to be sent. From 44ae0529be0420b21f4df9e71d44983ddc9ea31a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 13:13:43 +0300 Subject: [PATCH 15/50] Fix readme: the bot is not simple anymore --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d033ba..fe2f899 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # reactbot -A simple [maubot](https://github.com/maubot/maubot) that responds to messages that match predefined rules. +A [maubot](https://github.com/maubot/maubot) that responds to messages that match predefined rules. ## Samples * The [base config](base-config.yaml) contains a cookie reaction for TWIM submissions From c5c2590a969e721b17236ad08f19156e9466a888 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 13:43:10 +0300 Subject: [PATCH 16/50] Split into many files --- reactbot.py | 300 -------------------------------------- reactbot/__init__.py | 1 + reactbot/bot.py | 54 +++++++ reactbot/config.py | 121 +++++++++++++++ reactbot/rule.py | 61 ++++++++ reactbot/simplepattern.py | 62 ++++++++ reactbot/template.py | 96 ++++++++++++ 7 files changed, 395 insertions(+), 300 deletions(-) delete mode 100644 reactbot.py create mode 100644 reactbot/__init__.py create mode 100644 reactbot/bot.py create mode 100644 reactbot/config.py create mode 100644 reactbot/rule.py create mode 100644 reactbot/simplepattern.py create mode 100644 reactbot/template.py diff --git a/reactbot.py b/reactbot.py deleted file mode 100644 index 0b75b4e..0000000 --- a/reactbot.py +++ /dev/null @@ -1,300 +0,0 @@ -# reminder - 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 -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from typing import (NewType, Optional, Callable, Pattern, Match, Union, Dict, List, Tuple, Set, - Type, Any) -from itertools import chain -import json -import copy -import re - -from attr import dataclass -from jinja2 import Template as JinjaTemplate - -from mautrix.types import RoomID, EventType, Event -from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper - -from maubot import Plugin, MessageEvent -from maubot.handlers import event - - -class Config(BaseProxyConfig): - def do_update(self, helper: ConfigUpdateHelper) -> None: - helper.copy("rules") - helper.copy("templates") - helper.copy("default_flags") - - -class ConfigError(Exception): - pass - - -class Key(str): - pass - - -class BlankMatch: - @staticmethod - def groups() -> List[str]: - return [] - - -class SimplePattern: - _ptm = BlankMatch() - - matcher: Callable[[str], bool] - ignorecase: bool - - def __init__(self, matcher: Callable[[str], bool], ignorecase: bool) -> None: - self.matcher = matcher - self.ignorecase = ignorecase - - def search(self, val: str) -> BlankMatch: - if self.ignorecase: - val = val.lower() - if self.matcher(val): - return self._ptm - - -RMatch = Union[Match, BlankMatch] -RPattern = Union[Pattern, SimplePattern] -InputPattern = Union[str, Dict[str, str]] - -Index = NewType("Index", Union[str, int, Key]) - -variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}") - - -@dataclass -class Template: - type: EventType - variables: Dict[str, JinjaTemplate] - content: Union[Dict[str, Any], JinjaTemplate] - - _variable_locations: List[Tuple[Index, ...]] = None - - def init(self) -> 'Template': - self._variable_locations = [] - self._map_variable_locations((), self.content) - return self - - def _map_variable_locations(self, path: Tuple[Index, ...], data: Any) -> None: - if isinstance(data, list): - for i, v in enumerate(data): - self._map_variable_locations((*path, i), v) - elif isinstance(data, dict): - for k, v in data.items(): - if variable_regex.match(k): - self._variable_locations.append((*path, Key(k))) - self._map_variable_locations((*path, k), v) - elif isinstance(data, str): - if variable_regex.match(data): - self._variable_locations.append(path) - - @classmethod - def _recurse(cls, content: Any, path: Tuple[Index, ...]) -> Any: - if len(path) == 0: - return content - return cls._recurse(content[path[0]], path[1:]) - - @staticmethod - def _replace_variables(tpl: str, variables: Dict[str, Any]) -> str: - for match in variable_regex.finditer(tpl): - val = variables[match.group(1)] - if match.start() == 0 and match.end() == len(tpl): - # Whole field is a single variable, just return the value to allow non-string types. - return val - tpl = tpl[:match.start()] + val + tpl[match.end():] - return tpl - - def execute(self, evt: Event, rule_vars: Dict[str, JinjaTemplate], extra_vars: Dict[str, str] - ) -> Dict[str, Any]: - variables = {**{name: template.render(event=evt) - for name, template in chain(self.variables.items(), rule_vars.items())}, - **extra_vars} - if isinstance(self.content, JinjaTemplate): - raw_json = self.content.render(event=evt, **variables) - return json.loads(raw_json) - content = copy.deepcopy(self.content) - for path in self._variable_locations: - data: Dict[str, Any] = self._recurse(content, path[:-1]) - key = path[-1] - if isinstance(key, Key): - key = str(key) - data[self._replace_variables(key, variables)] = data.pop(key) - else: - data[key] = self._replace_variables(data[key], variables) - return content - - -@dataclass -class Rule: - rooms: Set[RoomID] - matches: List[RPattern] - not_matches: List[RPattern] - template: Template - type: Optional[EventType] - variables: Dict[str, JinjaTemplate] - - def _check_not_match(self, body: str) -> bool: - for pattern in self.not_matches: - if pattern.search(body): - return True - return False - - def match(self, evt: MessageEvent) -> Optional[Match]: - if len(self.rooms) > 0 and evt.room_id not in self.rooms: - return None - for pattern in self.matches: - match = pattern.search(evt.content.body) - if match: - if self._check_not_match(evt.content.body): - return None - return match - return None - - async def execute(self, evt: MessageEvent, match: Match) -> None: - content = self.template.execute(evt=evt, rule_vars=self.variables, - extra_vars={str(i): val for i, val in - enumerate(match.groups())}) - await evt.client.send_message_event(evt.room_id, self.type or self.template.type, content) - - -class ReactBot(Plugin): - rules: Dict[str, Rule] - templates: Dict[str, Template] - default_flags: re.RegexFlag - - @classmethod - def get_config_class(cls) -> Type[BaseProxyConfig]: - return Config - - async def start(self) -> None: - await super().start() - self.rules = {} - self.templates = {} - self.on_external_config_update() - - @staticmethod - def _parse_variables(data: Dict[str, Any]) -> Dict[str, JinjaTemplate]: - return {name: JinjaTemplate(var_tpl) for name, var_tpl - in data.get("variables", {}).items()} - - def _parse_content(self, content: Union[Dict[str, Any], str] - ) -> Union[Dict[str, Any], JinjaTemplate]: - if not content: - return {} - elif isinstance(content, str): - return JinjaTemplate(content) - return content - - def _make_template(self, name: str, tpl: Dict[str, Any]) -> Template: - try: - return Template(type=EventType.find(tpl.get("type", "m.room.message")), - variables=self._parse_variables(tpl), - content=self._parse_content(tpl.get("content", None))).init() - except Exception as e: - raise ConfigError(f"Failed to load {name}") from e - - def _get_flags(self, flags: Union[str, List[str]]) -> re.RegexFlag: - if not flags: - return self.default_flags - output = re.RegexFlag(0) - for flag in flags: - flag = flag.lower() - if flag == "i" or flag == "ignorecase": - output |= re.IGNORECASE - elif flag == "s" or flag == "dotall": - output |= re.DOTALL - elif flag == "x" or flag == "verbose": - output |= re.VERBOSE - elif flag == "m" or flag == "multiline": - output |= re.MULTILINE - elif flag == "l" or flag == "locale": - output |= re.LOCALE - elif flag == "u" or flag == "unicode": - output |= re.UNICODE - elif flag == "a" or flag == "ascii": - output |= re.ASCII - return output - - def _compile(self, pattern: InputPattern) -> RPattern: - flags = self.default_flags - raw = None - if isinstance(pattern, dict): - flags = self._get_flags(pattern.get("flags", "")) - raw = pattern.get("raw", False) - pattern = pattern["pattern"] - if raw is not False and (not flags & re.MULTILINE or raw is True): - ignorecase = flags == re.IGNORECASE - s_pattern = pattern.lower() if ignorecase else pattern - esc = "" - if not raw: - esc = re.escape(pattern) - first, last = pattern[0], pattern[-1] - if first == '^' and last == '$' and (raw or esc == f"\\^{pattern[1:-1]}\\$"): - s_pattern = s_pattern[1:-1] - return SimplePattern(lambda val: val == s_pattern, ignorecase=ignorecase) - elif first == '^' and (raw or esc == f"\\^{pattern[1:]}"): - s_pattern = s_pattern[1:] - return SimplePattern(lambda val: val.startswith(s_pattern), ignorecase=ignorecase) - elif last == '$' and (raw or esc == f"{pattern[:-1]}\\$"): - s_pattern = s_pattern[:-1] - return SimplePattern(lambda val: val.endswith(s_pattern), ignorecase=ignorecase) - elif raw or esc == pattern: - return SimplePattern(lambda val: s_pattern in val, ignorecase=ignorecase) - return re.compile(pattern, flags=flags) - - def _compile_all(self, patterns: Union[InputPattern, List[InputPattern]]) -> List[RPattern]: - if isinstance(patterns, list): - return [self._compile(pattern) for pattern in patterns] - else: - return [self._compile(patterns)] - - def _make_rule(self, name: str, rule: Dict[str, Any]) -> Rule: - try: - return Rule(rooms=set(rule.get("rooms", [])), - matches=self._compile_all(rule["matches"]), - 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)) - except Exception as e: - raise ConfigError(f"Failed to load {name}") from e - - def on_external_config_update(self) -> None: - self.config.load_and_update() - try: - self.default_flags = re.RegexFlag(0) - self.default_flags = self._get_flags(self.config["default_flags"]) - self.templates = {name: self._make_template(name, tpl) - for name, tpl in self.config["templates"].items()} - self.rules = {name: self._make_rule(name, rule) - for name, rule in self.config["rules"].items()} - except ConfigError: - self.log.exception("Failed to load config") - - @event.on(EventType.ROOM_MESSAGE) - async def event_handler(self, evt: MessageEvent) -> None: - if evt.sender == self.client.mxid: - return - for name, rule in self.rules.items(): - match = rule.match(evt) - if match is not None: - try: - await rule.execute(evt, match) - except Exception: - self.log.exception(f"Failed to execute {name}") - return diff --git a/reactbot/__init__.py b/reactbot/__init__.py new file mode 100644 index 0000000..d295bd8 --- /dev/null +++ b/reactbot/__init__.py @@ -0,0 +1 @@ +from .bot import ReactBot diff --git a/reactbot/bot.py b/reactbot/bot.py new file mode 100644 index 0000000..b2fb3f1 --- /dev/null +++ b/reactbot/bot.py @@ -0,0 +1,54 @@ +# reminder - 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 +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Type + +from mautrix.types import EventType +from mautrix.util.config import BaseProxyConfig + +from maubot import Plugin, MessageEvent +from maubot.handlers import event + +from .config import Config, ConfigError + + +class ReactBot(Plugin): + @classmethod + def get_config_class(cls) -> Type[BaseProxyConfig]: + return Config + + async def start(self) -> None: + await super().start() + self.on_external_config_update() + + def on_external_config_update(self) -> None: + self.config.load_and_update() + try: + self.config.parse_data() + except ConfigError: + self.log.exception("Failed to load config") + + @event.on(EventType.ROOM_MESSAGE) + async def event_handler(self, evt: MessageEvent) -> None: + if evt.sender == self.client.mxid: + return + for name, rule in self.config.rules.items(): + match = rule.match(evt) + if match is not None: + try: + await rule.execute(evt, match) + except Exception: + self.log.exception(f"Failed to execute {name}") + return diff --git a/reactbot/config.py b/reactbot/config.py new file mode 100644 index 0000000..754b51b --- /dev/null +++ b/reactbot/config.py @@ -0,0 +1,121 @@ +# reminder - 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 +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import List, Union, Dict, Any +import re + +from jinja2 import Template as JinjaTemplate + +from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper +from mautrix.types import EventType + +from .simplepattern import SimplePattern +from .template import Template +from .rule import Rule, RPattern + +InputPattern = Union[str, Dict[str, str]] + + +class Config(BaseProxyConfig): + rules: Dict[str, Rule] + templates: Dict[str, Template] + default_flags: re.RegexFlag + + def do_update(self, helper: ConfigUpdateHelper) -> None: + helper.copy("rules") + helper.copy("templates") + helper.copy("default_flags") + + def parse_data(self) -> None: + self.default_flags = re.RegexFlag(0) + self.default_flags = self._get_flags(self["default_flags"]) + self.templates = {name: self._make_template(name, tpl) + for name, tpl in self["templates"].items()} + self.rules = {name: self._make_rule(name, rule) + for name, rule in self["rules"].items()} + + def _make_rule(self, name: str, rule: Dict[str, Any]) -> Rule: + try: + return Rule(rooms=set(rule.get("rooms", [])), + matches=self._compile_all(rule["matches"]), + 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)) + except Exception as e: + raise ConfigError(f"Failed to load {name}") from e + + def _make_template(self, name: str, tpl: Dict[str, Any]) -> Template: + try: + return Template(type=EventType.find(tpl.get("type", "m.room.message")), + variables=self._parse_variables(tpl), + content=self._parse_content(tpl.get("content", None))).init() + except Exception as e: + raise ConfigError(f"Failed to load {name}") from e + + def _compile_all(self, patterns: Union[InputPattern, List[InputPattern]]) -> List[RPattern]: + if isinstance(patterns, list): + return [self._compile(pattern) for pattern in patterns] + else: + return [self._compile(patterns)] + + def _compile(self, pattern: InputPattern) -> RPattern: + flags = self.default_flags + raw = None + if isinstance(pattern, dict): + flags = self._get_flags(pattern["flags"]) if "flags" in pattern else flags + raw = pattern.get("raw", False) + 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) + + @staticmethod + def _parse_variables(data: Dict[str, Any]) -> Dict[str, JinjaTemplate]: + return {name: JinjaTemplate(var_tpl) for name, var_tpl + in data.get("variables", {}).items()} + + @staticmethod + def _parse_content(content: Union[Dict[str, Any], str]) -> Union[Dict[str, Any], JinjaTemplate]: + if not content: + return {} + elif isinstance(content, str): + return JinjaTemplate(content) + return content + + @staticmethod + def _get_flags(flags: Union[str, List[str]]) -> re.RegexFlag: + output = re.RegexFlag(0) + for flag in flags: + flag = flag.lower() + if flag == "i" or flag == "ignorecase": + output |= re.IGNORECASE + elif flag == "s" or flag == "dotall": + output |= re.DOTALL + elif flag == "x" or flag == "verbose": + output |= re.VERBOSE + elif flag == "m" or flag == "multiline": + output |= re.MULTILINE + elif flag == "l" or flag == "locale": + output |= re.LOCALE + elif flag == "u" or flag == "unicode": + output |= re.UNICODE + elif flag == "a" or flag == "ascii": + output |= re.ASCII + return output + + +class ConfigError(Exception): + pass diff --git a/reactbot/rule.py b/reactbot/rule.py new file mode 100644 index 0000000..49220c1 --- /dev/null +++ b/reactbot/rule.py @@ -0,0 +1,61 @@ +# reminder - 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 +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# 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 + +from attr import dataclass +from jinja2 import Template as JinjaTemplate + +from mautrix.types import RoomID, EventType + +from maubot import MessageEvent + +from .template import Template +from .simplepattern import SimplePattern + +RPattern = Union[Pattern, SimplePattern] + + +@dataclass +class Rule: + rooms: Set[RoomID] + matches: List[RPattern] + not_matches: List[RPattern] + template: Template + type: Optional[EventType] + variables: Dict[str, JinjaTemplate] + + def _check_not_match(self, body: str) -> bool: + for pattern in self.not_matches: + if pattern.search(body): + return True + return False + + def match(self, evt: MessageEvent) -> Optional[Match]: + if len(self.rooms) > 0 and evt.room_id not in self.rooms: + return None + for pattern in self.matches: + match = pattern.search(evt.content.body) + if match: + if self._check_not_match(evt.content.body): + return None + return match + return None + + async def execute(self, evt: MessageEvent, match: Match) -> None: + content = self.template.execute(evt=evt, rule_vars=self.variables, + extra_vars={str(i): val for i, val in + enumerate(match.groups())}) + await evt.client.send_message_event(evt.room_id, self.type or self.template.type, content) diff --git a/reactbot/simplepattern.py b/reactbot/simplepattern.py new file mode 100644 index 0000000..4b30890 --- /dev/null +++ b/reactbot/simplepattern.py @@ -0,0 +1,62 @@ +# reminder - 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 +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# 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, Optional +import re + + +class BlankMatch: + @staticmethod + def groups() -> List[str]: + return [] + + +class SimplePattern: + _ptm = BlankMatch() + + matcher: Callable[[str], bool] + ignorecase: bool + + def __init__(self, matcher: Callable[[str], bool], ignorecase: bool) -> None: + self.matcher = matcher + self.ignorecase = ignorecase + + def search(self, val: str) -> BlankMatch: + if self.ignorecase: + val = val.lower() + if self.matcher(val): + return self._ptm + + @staticmethod + def compile(pattern: str, flags: re.RegexFlag = re.RegexFlag(0), force_raw: bool = False + ) -> Optional['SimplePattern']: + ignorecase = flags == re.IGNORECASE + s_pattern = pattern.lower() if ignorecase else pattern + esc = "" + if not force_raw: + esc = re.escape(pattern) + 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) + 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) + 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) + elif force_raw or esc == pattern: + return SimplePattern(lambda val: s_pattern in val, ignorecase=ignorecase) + return None diff --git a/reactbot/template.py b/reactbot/template.py new file mode 100644 index 0000000..a581bf8 --- /dev/null +++ b/reactbot/template.py @@ -0,0 +1,96 @@ +# reminder - 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 +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Union, Dict, List, Tuple, Any +from itertools import chain +import json +import copy +import re + +from attr import dataclass +from jinja2 import Template as JinjaTemplate + +from mautrix.types import EventType, Event + + +class Key(str): + pass + + +variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}") + +Index = Union[str, int, Key] + + +@dataclass +class Template: + type: EventType + variables: Dict[str, JinjaTemplate] + content: Union[Dict[str, Any], JinjaTemplate] + + _variable_locations: List[Tuple[Index, ...]] = None + + def init(self) -> 'Template': + self._variable_locations = [] + self._map_variable_locations((), self.content) + return self + + def _map_variable_locations(self, path: Tuple[Index, ...], data: Any) -> None: + if isinstance(data, list): + for i, v in enumerate(data): + self._map_variable_locations((*path, i), v) + elif isinstance(data, dict): + for k, v in data.items(): + if variable_regex.match(k): + self._variable_locations.append((*path, Key(k))) + self._map_variable_locations((*path, k), v) + elif isinstance(data, str): + if variable_regex.match(data): + self._variable_locations.append(path) + + @classmethod + def _recurse(cls, content: Any, path: Tuple[Index, ...]) -> Any: + if len(path) == 0: + return content + return cls._recurse(content[path[0]], path[1:]) + + @staticmethod + def _replace_variables(tpl: str, variables: Dict[str, Any]) -> str: + for match in variable_regex.finditer(tpl): + val = variables[match.group(1)] + if match.start() == 0 and match.end() == len(tpl): + # Whole field is a single variable, just return the value to allow non-string types. + return val + tpl = tpl[:match.start()] + val + tpl[match.end():] + return tpl + + def execute(self, evt: Event, rule_vars: Dict[str, JinjaTemplate], extra_vars: Dict[str, str] + ) -> Dict[str, Any]: + variables = {**{name: template.render(event=evt) + for name, template in chain(self.variables.items(), rule_vars.items())}, + **extra_vars} + if isinstance(self.content, JinjaTemplate): + raw_json = self.content.render(event=evt, **variables) + return json.loads(raw_json) + content = copy.deepcopy(self.content) + for path in self._variable_locations: + data: Dict[str, Any] = self._recurse(content, path[:-1]) + key = path[-1] + if isinstance(key, Key): + key = str(key) + data[self._replace_variables(key, variables)] = data.pop(key) + else: + data[key] = self._replace_variables(data[key], variables) + return content From b3ebb9159810e5572b8ceabdb54196a42f06591c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 13:44:43 +0300 Subject: [PATCH 17/50] Add attribution for stallman sample --- samples/stallman.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/samples/stallman.yaml b/samples/stallman.yaml index 470bdde..edfc194 100644 --- a/samples/stallman.yaml +++ b/samples/stallman.yaml @@ -1,3 +1,5 @@ +# Messages from https://github.com/interwho/stallman-bot (MIT license) + templates: plaintext_notice: type: m.room.message From 54cee71497e317d90364e6e70d507effe0316c08 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 13:45:20 +0300 Subject: [PATCH 18/50] Add missing dot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fe2f899..049a400 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A [maubot](https://github.com/maubot/maubot) that responds to messages that matc * The [base config](base-config.yaml) contains a cookie reaction for TWIM submissions in [#thisweekinmatrix:matrix.org](https://matrix.to/#/#thisweekinmatrix:matrix.org) and an image response for "alot". -* [samples/jesari.yaml](samples/jesari.yaml) contains a replacement for [jesaribot](https://github.com/maubot/jesaribot) +* [samples/jesari.yaml](samples/jesari.yaml) contains a replacement for [jesaribot](https://github.com/maubot/jesaribot). * [samples/stallman.yaml](samples/stallman.yaml) contains a Stallman interject bot. ## Config format From 8e16575d3b55c6c6079bc4639b14131aa5dba7a1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 13:57:57 +0300 Subject: [PATCH 19/50] Only allow m.text and m.emote as input --- reactbot/bot.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/reactbot/bot.py b/reactbot/bot.py index b2fb3f1..03f57c7 100644 --- a/reactbot/bot.py +++ b/reactbot/bot.py @@ -13,9 +13,9 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Type +from typing import Type, Tuple -from mautrix.types import EventType +from mautrix.types import EventType, MessageType from mautrix.util.config import BaseProxyConfig from maubot import Plugin, MessageEvent @@ -25,6 +25,8 @@ from .config import Config, ConfigError class ReactBot(Plugin): + allowed_msgtypes: Tuple[MessageType, ...] = (MessageType.TEXT, MessageType.EMOTE) + @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: return Config @@ -42,7 +44,7 @@ class ReactBot(Plugin): @event.on(EventType.ROOM_MESSAGE) async def event_handler(self, evt: MessageEvent) -> None: - if evt.sender == self.client.mxid: + if evt.sender == self.client.mxid or evt.content.msgtype not in self.allowed_msgtypes: return for name, rule in self.config.rules.items(): match = rule.match(evt) From 85a7967888f6d4afd3b1a798e452974f5b2b8384 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 14:14:28 +0300 Subject: [PATCH 20/50] Add initial flood prevention --- base-config.yaml | 4 ++++ reactbot/bot.py | 35 +++++++++++++++++++++++++++++++++-- reactbot/config.py | 2 ++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/base-config.yaml b/base-config.yaml index a523deb..27c6a78 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -29,6 +29,10 @@ templates: default_flags: - ignorecase +antispam: + max: 2 + delay: 60 + rules: twim_cookies: rooms: ["!FPUfgzXYWTKgIrwKxW:matrix.org"] diff --git a/reactbot/bot.py b/reactbot/bot.py index 03f57c7..eea1aea 100644 --- a/reactbot/bot.py +++ b/reactbot/bot.py @@ -13,9 +13,12 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Type, Tuple +from typing import Type, Tuple, Dict +import time -from mautrix.types import EventType, MessageType +from attr import dataclass + +from mautrix.types import EventType, MessageType, UserID, RoomID from mautrix.util.config import BaseProxyConfig from maubot import Plugin, MessageEvent @@ -24,8 +27,27 @@ from maubot.handlers import event from .config import Config, ConfigError +@dataclass +class FloodInfo: + rb: 'ReactBot' + count: int + last_message: int + + def bump(self) -> bool: + now = int(time.time()) + if self.last_message + self.rb.config["antispam.delay"] < now: + self.count = 0 + self.count += 1 + if self.count > self.rb.config["antispam.max"]: + return True + self.last_message = now + return False + + class ReactBot(Plugin): allowed_msgtypes: Tuple[MessageType, ...] = (MessageType.TEXT, MessageType.EMOTE) + user_flood: Dict[UserID, FloodInfo] + room_flood: Dict[RoomID, FloodInfo] @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: @@ -33,6 +55,8 @@ class ReactBot(Plugin): async def start(self) -> None: await super().start() + self.user_flood = {} + self.room_flood = {} self.on_external_config_update() def on_external_config_update(self) -> None: @@ -42,6 +66,11 @@ class ReactBot(Plugin): except ConfigError: self.log.exception("Failed to load config") + def is_flood(self, evt: MessageEvent) -> bool: + uf = self.user_flood.setdefault(evt.sender, FloodInfo(rb=self, count=0, last_message=0)) + rf = self.room_flood.setdefault(evt.room_id, FloodInfo(rb=self, count=0, last_message=0)) + return uf.bump() or rf.bump() + @event.on(EventType.ROOM_MESSAGE) async def event_handler(self, evt: MessageEvent) -> None: if evt.sender == self.client.mxid or evt.content.msgtype not in self.allowed_msgtypes: @@ -49,6 +78,8 @@ class ReactBot(Plugin): for name, rule in self.config.rules.items(): match = rule.match(evt) if match is not None: + if self.is_flood(evt): + return try: await rule.execute(evt, match) except Exception: diff --git a/reactbot/config.py b/reactbot/config.py index 754b51b..29d02b6 100644 --- a/reactbot/config.py +++ b/reactbot/config.py @@ -37,6 +37,8 @@ class Config(BaseProxyConfig): helper.copy("rules") helper.copy("templates") helper.copy("default_flags") + helper.copy("antispam.max") + helper.copy("antispam.delay") def parse_data(self) -> None: self.default_flags = re.RegexFlag(0) From b26f9cf6ebd4472224266fbdd0f5cc1e84ff3971 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 14:20:25 +0300 Subject: [PATCH 21/50] Separate per-user and per-room flood limits --- base-config.yaml | 8 ++++++-- reactbot/bot.py | 24 ++++++++++++++++++------ reactbot/config.py | 6 ++++-- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/base-config.yaml b/base-config.yaml index 27c6a78..f99e872 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -30,8 +30,12 @@ default_flags: - ignorecase antispam: - max: 2 - delay: 60 + room: + max: 1 + delay: 60 + user: + max: 2 + delay: 60 rules: twim_cookies: diff --git a/reactbot/bot.py b/reactbot/bot.py index eea1aea..a0f17c8 100644 --- a/reactbot/bot.py +++ b/reactbot/bot.py @@ -29,16 +29,17 @@ from .config import Config, ConfigError @dataclass class FloodInfo: - rb: 'ReactBot' + max: int + delay: int count: int last_message: int def bump(self) -> bool: now = int(time.time()) - if self.last_message + self.rb.config["antispam.delay"] < now: + if self.last_message + self.delay < now: self.count = 0 self.count += 1 - if self.count > self.rb.config["antispam.max"]: + if self.count > self.max: return True self.last_message = now return False @@ -66,10 +67,21 @@ class ReactBot(Plugin): except ConfigError: self.log.exception("Failed to load config") + def _make_flood_info(self, for_type: str) -> 'FloodInfo': + return FloodInfo(max=self.config[f"antispam.{for_type}.max"], + delay=self.config[f"antispam.{for_type}.delay"], + count=0, last_message=0) + + def _get_flood_info(self, flood_map: dict, key: str, for_type: str) -> 'FloodInfo': + try: + return flood_map[key] + except KeyError: + fi = flood_map[key] = self._make_flood_info(for_type) + return fi + def is_flood(self, evt: MessageEvent) -> bool: - uf = self.user_flood.setdefault(evt.sender, FloodInfo(rb=self, count=0, last_message=0)) - rf = self.room_flood.setdefault(evt.room_id, FloodInfo(rb=self, count=0, last_message=0)) - return uf.bump() or rf.bump() + return (self._get_flood_info(self.user_flood, evt.sender, "user").bump() + or self._get_flood_info(self.room_flood, evt.room_id, "room").bump()) @event.on(EventType.ROOM_MESSAGE) async def event_handler(self, evt: MessageEvent) -> None: diff --git a/reactbot/config.py b/reactbot/config.py index 29d02b6..379dead 100644 --- a/reactbot/config.py +++ b/reactbot/config.py @@ -37,8 +37,10 @@ class Config(BaseProxyConfig): helper.copy("rules") helper.copy("templates") helper.copy("default_flags") - helper.copy("antispam.max") - helper.copy("antispam.delay") + helper.copy("antispam.user.max") + helper.copy("antispam.user.delay") + helper.copy("antispam.room.max") + helper.copy("antispam.room.delay") def parse_data(self) -> None: self.default_flags = re.RegexFlag(0) From d5c0aa9e0213c09e8da40afd722d4343aaea28a7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 14:27:32 +0300 Subject: [PATCH 22/50] Update existing flood limits when editing config --- reactbot/bot.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/reactbot/bot.py b/reactbot/bot.py index a0f17c8..484ab38 100644 --- a/reactbot/bot.py +++ b/reactbot/bot.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import Type, Tuple, Dict +from itertools import chain import time from attr import dataclass @@ -66,6 +67,12 @@ class ReactBot(Plugin): self.config.parse_data() except ConfigError: self.log.exception("Failed to load config") + for fi in self.user_flood.items(): + fi.max = self.config["antispam.user.max"] + fi.delay = self.config["antispam.user.delay"] + for fi in self.room_flood.items(): + fi.max = self.config["antispam.room.max"] + fi.delay = self.config["antispam.room.delay"] def _make_flood_info(self, for_type: str) -> 'FloodInfo': return FloodInfo(max=self.config[f"antispam.{for_type}.max"], From e7558dc00e64f880ab5dcf242a246d44ddf7902b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 14:33:13 +0300 Subject: [PATCH 23/50] Bump version to 2.0.0 --- maubot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maubot.yaml b/maubot.yaml index f004ef2..7e7be88 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: xyz.maubot.reactbot -version: 2.0.0+dev +version: 2.0.0 license: AGPL-3.0-or-later modules: - reactbot From 4cb003f048446f3f2f58cd97d93212c3876bfa45 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 15:21:25 +0300 Subject: [PATCH 24/50] Simplify linux not_matches in stallman sample --- samples/stallman.yaml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/samples/stallman.yaml b/samples/stallman.yaml index edfc194..b1a1376 100644 --- a/samples/stallman.yaml +++ b/samples/stallman.yaml @@ -10,16 +10,20 @@ templates: default_flags: - ignorecase +antispam: + room: + max: 1 + delay: 60 + user: + max: 2 + delay: 60 + rules: linux: matches: - linux not_matches: - - pattern: gnu+linux - raw: true - - pattern: gnu/linux - raw: true - - gnu plus linux + - gnu - kernel template: plaintext_notice variables: From 5a332b13afa4d25319f574c70ab236dea62f5c1e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 15:26:25 +0300 Subject: [PATCH 25/50] Ignore archive and other words that start with arch --- samples/stallman.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/stallman.yaml b/samples/stallman.yaml index b1a1376..f4dd5e3 100644 --- a/samples/stallman.yaml +++ b/samples/stallman.yaml @@ -240,7 +240,7 @@ rules: message: | I'd just like to interject for one moment. Please don't use the term "vendor" to refer generally to anyone that develops or packages software. Many programs are developed in order to sell copies, and their developers are therefore their vendors; this even includes some free software packages. However, many programs are developed by volunteers or organizations which do not intend to sell copies. These developers are not vendors. Likewise, only some of the packagers of GNU/Linux distributions are vendors. We recommend the general term "supplier" instead. arch: - matches: [(^|\s)arch] + matches: (^|\s)arch($|\s) template: plaintext_notice variables: message: | From 2ad417da70bbd1518909825393cd8c2fdba99215 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 15:33:59 +0300 Subject: [PATCH 26/50] Improve stallman regexes more --- samples/stallman.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/samples/stallman.yaml b/samples/stallman.yaml index f4dd5e3..6ec3c3d 100644 --- a/samples/stallman.yaml +++ b/samples/stallman.yaml @@ -147,7 +147,7 @@ rules: To avoid spreading unnecessary bias and confusion, it is best to adopt a firm policy not to speak or even think in terms of "intellectual property". The hypocrisy of calling these powers "rights" is starting to make the World "Intellectual Property" Organization embarrassed. lamp: - matches: [(\s|^)lamp] + matches: [(\s|^)lamp(\s|$)] template: plaintext_notice variables: message: | @@ -180,7 +180,7 @@ rules: message: | I'd just like to interject for one moment. Please avoid using the term "open" or "open source" as a substitute for "free software". Those terms refer to a different position based on different values. Free software is a political movement; open source is a development model. When referring to the open source position, using its name is appropriate; but please do not use it to label us or our work--that leads people to think we share those views. pc: - matches: [(\s|^)pcs?] + matches: [(\s|^)pcs?(\s|$)] template: plaintext_notice variables: message: | @@ -200,7 +200,7 @@ rules: I'd just like to interject for one moment. Publishers often refer to copying they don't approve of as "piracy." In this way, they imply that it is ethically equivalent to attacking ships on the high seas, kidnapping and murdering the people on them. Based on such propaganda, they have procured laws in most of the world to forbid copying in most (or sometimes all) circumstances. (They are still pressuring to make these prohibitions more complete.) If you don't believe that copying not approved by the publisher is just like kidnapping and murder, you might prefer not to use the word "piracy" to describe it. Neutral terms such as "unauthorized copying" (or "prohibited copying" for the situation where it is illegal) are available for use instead. Some of us might even prefer to use a positive term such as "sharing information with your neighbor." powerpoint: - matches: [powerpoint, \sppt] + matches: [powerpoint, \sppt(\s|$)] template: plaintext_notice variables: message: | @@ -329,7 +329,7 @@ rules: I'd just like to interject for one moment. To speak of “compensation for authors” in connection with copyright carries the assumptions that (1) copyright exists for the sake of authors and (2) whenever we read something, we take on a debt to the author which we must then repay. The first assumption is simply false, and the second is outrageous. “Compensating the rights-holders” adds a further swindle: you're supposed to imagine that means paying the authors, and occasionally it does, but most of the time it means a subsidy for the same publishing companies that are pushing unjust laws on us. consume: - matches: [consume\s] + matches: [(^|\s)consume(\s|$)] template: plaintext_notice variables: message: | @@ -379,7 +379,7 @@ rules: message: | I'd just like to interject for one moment. Don't use “freely available software” as a synonym for “free software.” The terms are not equivalent. Software is “freely available” if anyone can easily get a copy. “Free software” is defined in terms of the freedom of users that have a copy of it. These are answers to different questions. google: - matches: [google\\s] + matches: [(^|\s)google(\s|$)] template: plaintext_notice variables: message: | From 50141c8a9225a5d564be813c90647c9cdb5748e7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 28 Jul 2019 22:10:04 +0300 Subject: [PATCH 27/50] Add .gitlab-ci.yml --- .gitlab-ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..c649b91 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,27 @@ +image: dock.mau.dev/maubot/maubot + +stages: +- build + +variables: + PYTHONPATH: /opt/maubot + +build: + stage: build + except: + - tags + script: + - python3 -m maubot.cli build -o xyz.maubot.$CI_PROJECT_NAME-$CI_COMMIT_REF_NAME-$CI_COMMIT_SHORT_SHA.mbp + artifacts: + paths: + - "*.mbp" + +build tags: + stage: build + only: + - tags + script: + - python3 -m maubot.cli build -o xyz.maubot.$CI_PROJECT_NAME-$CI_COMMIT_TAG.mbp + artifacts: + paths: + - "*.mbp" From 217351d14197cf6f35c63cfa94a0be6529cd21da Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 31 Jul 2019 19:28:15 +0300 Subject: [PATCH 28/50] Add option to exclude specific rooms from a rule --- reactbot/config.py | 1 + reactbot/rule.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/reactbot/config.py b/reactbot/config.py index 379dead..1f37570 100644 --- a/reactbot/config.py +++ b/reactbot/config.py @@ -53,6 +53,7 @@ class Config(BaseProxyConfig): def _make_rule(self, name: str, rule: Dict[str, Any]) -> Rule: try: return Rule(rooms=set(rule.get("rooms", [])), + not_rooms=set(rule.get("not_rooms", [])), matches=self._compile_all(rule["matches"]), not_matches=self._compile_all(rule.get("not_matches", [])), type=EventType.find(rule["type"]) if "type" in rule else None, diff --git a/reactbot/rule.py b/reactbot/rule.py index 49220c1..441ffb7 100644 --- a/reactbot/rule.py +++ b/reactbot/rule.py @@ -31,6 +31,7 @@ RPattern = Union[Pattern, SimplePattern] @dataclass class Rule: rooms: Set[RoomID] + not_rooms: Set[RoomID] matches: List[RPattern] not_matches: List[RPattern] template: Template @@ -46,6 +47,8 @@ class Rule: 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 for pattern in self.matches: match = pattern.search(evt.content.body) if match: From 45ee715dc3546e438a490b4df4baf339d391888f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 17 Oct 2019 19:09:58 +0300 Subject: [PATCH 29/50] Fix or break config parsing --- reactbot/bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reactbot/bot.py b/reactbot/bot.py index 484ab38..37bf465 100644 --- a/reactbot/bot.py +++ b/reactbot/bot.py @@ -67,10 +67,10 @@ class ReactBot(Plugin): self.config.parse_data() except ConfigError: self.log.exception("Failed to load config") - for fi in self.user_flood.items(): + for fi in self.user_flood.values(): fi.max = self.config["antispam.user.max"] fi.delay = self.config["antispam.user.delay"] - for fi in self.room_flood.items(): + for fi in self.room_flood.values(): fi.max = self.config["antispam.room.max"] fi.delay = self.config["antispam.room.delay"] From 8353e43e30abb1cd54485c85434c54866580e66d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 27 Aug 2020 15:03:32 +0300 Subject: [PATCH 30/50] Fix using variables in the middle of template strings --- reactbot/template.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reactbot/template.py b/reactbot/template.py index a581bf8..09967b9 100644 --- a/reactbot/template.py +++ b/reactbot/template.py @@ -53,11 +53,11 @@ class Template: self._map_variable_locations((*path, i), v) elif isinstance(data, dict): for k, v in data.items(): - if variable_regex.match(k): + if variable_regex.search(k): self._variable_locations.append((*path, Key(k))) self._map_variable_locations((*path, k), v) elif isinstance(data, str): - if variable_regex.match(data): + if variable_regex.search(data): self._variable_locations.append(path) @classmethod From 0790b429b35a039e38601bc7d4871a9bd3a3bc7f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 30 Sep 2020 22:21:41 +0300 Subject: [PATCH 31/50] Bump version to 2.1.0 --- maubot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maubot.yaml b/maubot.yaml index 7e7be88..16d0e3f 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: xyz.maubot.reactbot -version: 2.0.0 +version: 2.1.0 license: AGPL-3.0-or-later modules: - reactbot From b213481d7dad5ba4c8ec3d1c05756ac54c016149 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 11 Dec 2020 19:59:19 +0200 Subject: [PATCH 32/50] Expose named capture groups and earlier variables in jinja variables (ref #5) --- reactbot/bot.py | 1 - reactbot/config.py | 8 +++++--- reactbot/rule.py | 12 +++++++----- reactbot/simplepattern.py | 6 +++++- reactbot/template.py | 11 +++++++---- 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/reactbot/bot.py b/reactbot/bot.py index 37bf465..b893110 100644 --- a/reactbot/bot.py +++ b/reactbot/bot.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import Type, Tuple, Dict -from itertools import chain import time from attr import dataclass diff --git a/reactbot/config.py b/reactbot/config.py index 1f37570..8cb9ba5 100644 --- a/reactbot/config.py +++ b/reactbot/config.py @@ -88,9 +88,11 @@ class Config(BaseProxyConfig): return re.compile(pattern, flags=flags) @staticmethod - def _parse_variables(data: Dict[str, Any]) -> Dict[str, JinjaTemplate]: - return {name: JinjaTemplate(var_tpl) for name, var_tpl - in data.get("variables", {}).items()} + def _parse_variables(data: Dict[str, Any]) -> Dict[str, Any]: + return {name: (JinjaTemplate(var_tpl) + if isinstance(var_tpl, str) and var_tpl.startswith("{{") + else var_tpl) + for name, var_tpl in data.get("variables", {}).items()} @staticmethod def _parse_content(content: Union[Dict[str, Any], str]) -> Union[Dict[str, Any], JinjaTemplate]: diff --git a/reactbot/rule.py b/reactbot/rule.py index 441ffb7..f7703d2 100644 --- a/reactbot/rule.py +++ b/reactbot/rule.py @@ -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 Optional, Match, Dict, List, Set, Union, Pattern +from typing import Optional, Match, Dict, List, Set, Union, Pattern, Any from attr import dataclass from jinja2 import Template as JinjaTemplate @@ -36,7 +36,7 @@ class Rule: not_matches: List[RPattern] template: Template type: Optional[EventType] - variables: Dict[str, JinjaTemplate] + variables: Dict[str, Any] def _check_not_match(self, body: str) -> bool: for pattern in self.not_matches: @@ -58,7 +58,9 @@ class Rule: return None async def execute(self, evt: MessageEvent, match: Match) -> None: - content = self.template.execute(evt=evt, rule_vars=self.variables, - extra_vars={str(i): val for i, val in - enumerate(match.groups())}) + extra_vars = { + **{str(i): val for i, val in enumerate(match.groups())}, + **match.groupdict(), + } + 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) diff --git a/reactbot/simplepattern.py b/reactbot/simplepattern.py index 4b30890..d9e74e1 100644 --- a/reactbot/simplepattern.py +++ b/reactbot/simplepattern.py @@ -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, Optional +from typing import Callable, List, Dict, Optional import re @@ -22,6 +22,10 @@ class BlankMatch: def groups() -> List[str]: return [] + @staticmethod + def groupdict() -> Dict[str, str]: + return {} + class SimplePattern: _ptm = BlankMatch() diff --git a/reactbot/template.py b/reactbot/template.py index 09967b9..8b003ac 100644 --- a/reactbot/template.py +++ b/reactbot/template.py @@ -37,7 +37,7 @@ Index = Union[str, int, Key] @dataclass class Template: type: EventType - variables: Dict[str, JinjaTemplate] + variables: Dict[str, Any] content: Union[Dict[str, Any], JinjaTemplate] _variable_locations: List[Tuple[Index, ...]] = None @@ -78,9 +78,12 @@ class Template: def execute(self, evt: Event, rule_vars: Dict[str, JinjaTemplate], extra_vars: Dict[str, str] ) -> Dict[str, Any]: - variables = {**{name: template.render(event=evt) - for name, template in chain(self.variables.items(), rule_vars.items())}, - **extra_vars} + variables = extra_vars + for name, template in chain(rule_vars.items(), self.variables.items()): + if isinstance(template, JinjaTemplate): + variables[name] = template.render(event=evt, variables=variables) + else: + variables[name] = template if isinstance(self.content, JinjaTemplate): raw_json = self.content.render(event=evt, **variables) return json.loads(raw_json) From 821e670fd5d19f37ae26a7a9b3594a2131ba19c4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 11 Dec 2020 20:14:23 +0200 Subject: [PATCH 33/50] Fix type hint --- reactbot/template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reactbot/template.py b/reactbot/template.py index 8b003ac..13ee593 100644 --- a/reactbot/template.py +++ b/reactbot/template.py @@ -76,7 +76,7 @@ class Template: tpl = tpl[:match.start()] + val + tpl[match.end():] return tpl - def execute(self, evt: Event, rule_vars: Dict[str, JinjaTemplate], extra_vars: Dict[str, str] + def execute(self, evt: Event, rule_vars: Dict[str, Any], extra_vars: Dict[str, str] ) -> Dict[str, Any]: variables = extra_vars for name, template in chain(rule_vars.items(), self.variables.items()): From e89a5773d805b43f820a2ebe5ff4eaca4204a329 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 14 Jun 2021 12:52:05 +0300 Subject: [PATCH 34/50] Fix substituting multiple variables in templates. Fixes #6 --- reactbot/template.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/reactbot/template.py b/reactbot/template.py index 13ee593..7643ca4 100644 --- a/reactbot/template.py +++ b/reactbot/template.py @@ -1,5 +1,5 @@ # reminder - A maubot plugin that reacts to messages that match predefined rules. -# Copyright (C) 2019 Tulir Asokan +# 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 @@ -68,13 +68,11 @@ class Template: @staticmethod def _replace_variables(tpl: str, variables: Dict[str, Any]) -> str: - for match in variable_regex.finditer(tpl): - val = variables[match.group(1)] - if match.start() == 0 and match.end() == len(tpl): - # Whole field is a single variable, just return the value to allow non-string types. - return val - tpl = tpl[:match.start()] + val + tpl[match.end():] - return tpl + full_var_match = variable_regex.fullmatch(tpl) + if full_var_match: + # Whole field is a single variable, just return the value to allow non-string types. + return variables[full_var_match.group(1)] + return variable_regex.sub(lambda match: str(variables[match.group(1)]), tpl) def execute(self, evt: Event, rule_vars: Dict[str, Any], extra_vars: Dict[str, str] ) -> Dict[str, Any]: From 05e479bb8839e8807509a1ebf4a42dfc22829035 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 16 Jun 2021 22:24:52 +0300 Subject: [PATCH 35/50] Add example for random reactions --- README.md | 2 ++ samples/random-reaction.yaml | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 samples/random-reaction.yaml diff --git a/README.md b/README.md index 049a400..947e429 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ A [maubot](https://github.com/maubot/maubot) that responds to messages that matc and an image response for "alot". * [samples/jesari.yaml](samples/jesari.yaml) contains a replacement for [jesaribot](https://github.com/maubot/jesaribot). * [samples/stallman.yaml](samples/stallman.yaml) contains a Stallman interject bot. +* [samples/random-reaction.yaml](samples/random-reaction.yaml) has an example of + a randomized reaction to matching messages. ## Config format ### Templates diff --git a/samples/random-reaction.yaml b/samples/random-reaction.yaml new file mode 100644 index 0000000..4784bef --- /dev/null +++ b/samples/random-reaction.yaml @@ -0,0 +1,25 @@ +templates: + random_reaction: + type: m.reaction + variables: + react_to_event: '{{event.event_id}}' + reaction: '{{ variables.reaction_choices | random }}' + content: + m.relates_to: + rel_type: m.annotation + event_id: $${react_to_event} + key: $${reaction} + +default_flags: +- ignorecase + +rules: + random: + matches: + - hmm + template: random_reaction + variables: + reaction_choices: + - 🤔 + - 🧐 + - 🤨 From 28d6b05913962ecd9a5eca085a540cb2859f48c1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 18 Jun 2021 21:45:15 +0300 Subject: [PATCH 36/50] Log room ID in errors --- reactbot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reactbot/bot.py b/reactbot/bot.py index b893110..5868dda 100644 --- a/reactbot/bot.py +++ b/reactbot/bot.py @@ -101,5 +101,5 @@ class ReactBot(Plugin): try: await rule.execute(evt, match) except Exception: - self.log.exception(f"Failed to execute {name}") + self.log.exception(f"Failed to execute {name} in {evt.room_id}") return From 873cae58211c919fd9361c6e962b0c874d2ff0bf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 18 Jul 2021 19:41:33 +0300 Subject: [PATCH 37/50] Fix indexes of capture groups ${{0}} now means the whole match and ${{1}} is the first capture group. --- reactbot/rule.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/reactbot/rule.py b/reactbot/rule.py index f7703d2..b97b350 100644 --- a/reactbot/rule.py +++ b/reactbot/rule.py @@ -59,7 +59,8 @@ class Rule: async def execute(self, evt: MessageEvent, match: Match) -> None: extra_vars = { - **{str(i): val for i, val in enumerate(match.groups())}, + "0": match.group(0), + **{str(i+1): val for i, val in enumerate(match.groups())}, **match.groupdict(), } content = self.template.execute(evt=evt, rule_vars=self.variables, extra_vars=extra_vars) From 45e22185dcb34d45a3d66d17642437713ac6a3cf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 18 Jul 2021 19:45:35 +0300 Subject: [PATCH 38/50] Add capture group example --- README.md | 2 ++ samples/nitter.yaml | 15 +++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 samples/nitter.yaml diff --git a/README.md b/README.md index 947e429..fef4f88 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ A [maubot](https://github.com/maubot/maubot) that responds to messages that matc * [samples/stallman.yaml](samples/stallman.yaml) contains a Stallman interject bot. * [samples/random-reaction.yaml](samples/random-reaction.yaml) has an example of a randomized reaction to matching messages. +* [samples/nitter.yaml](samples/nitter.yaml) has an example of matching tweet links + and responding with a corresponding nitter.net link. ## Config format ### Templates diff --git a/samples/nitter.yaml b/samples/nitter.yaml new file mode 100644 index 0000000..3cfb856 --- /dev/null +++ b/samples/nitter.yaml @@ -0,0 +1,15 @@ +templates: + nitter: + type: m.room.message + content: + msgtype: m.text + body: https://nitter.net/$${1}/status/$${2} + +default_flags: +- ignorecase + +rules: + twitter: + matches: + - https://twitter.com/(.+?)/status/(\d+) + template: nitter From 16e4b8e6d86f3130b141e4b751a0d146951dd909 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 21 Jul 2021 15:09:29 +0300 Subject: [PATCH 39/50] Fix simple patterns throwing errors --- reactbot/simplepattern.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/reactbot/simplepattern.py b/reactbot/simplepattern.py index d9e74e1..4d3e2c3 100644 --- a/reactbot/simplepattern.py +++ b/reactbot/simplepattern.py @@ -22,6 +22,10 @@ class BlankMatch: def groups() -> List[str]: return [] + @staticmethod + def group(group: int) -> str: + return "" + @staticmethod def groupdict() -> Dict[str, str]: return {} From 3964aa6f1219a1c08e4c3ea4106117ea42d21d1f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 28 Jul 2021 20:21:44 +0300 Subject: [PATCH 40/50] Add support for capturing the match in simple matches --- reactbot/simplepattern.py | 69 ++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/reactbot/simplepattern.py b/reactbot/simplepattern.py index 4d3e2c3..f40d7ce 100644 --- a/reactbot/simplepattern.py +++ b/reactbot/simplepattern.py @@ -1,5 +1,5 @@ # reminder - A maubot plugin that reacts to messages that match predefined rules. -# Copyright (C) 2019 Tulir Asokan +# 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,39 +13,59 @@ # # 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, NamedTuple import re -class BlankMatch: - @staticmethod - def groups() -> List[str]: - return [] +class SimpleMatch(NamedTuple): + value: str - @staticmethod - def group(group: int) -> str: - return "" + def groups(self) -> List[str]: + return [self.value] - @staticmethod - def groupdict() -> Dict[str, str]: + def group(self, group: int) -> Optional[str]: + if group == 0: + return self.value + return None + + def groupdict(self) -> Dict[str, str]: return {} -class SimplePattern: - _ptm = BlankMatch() +def matcher_equals(val: str, pattern: str) -> bool: + return val == pattern - matcher: Callable[[str], bool] + +def matcher_startswith(val: str, pattern: str) -> bool: + return val.startswith(pattern) + + +def matcher_endswith(val: str, pattern: str) -> bool: + return val.endswith(pattern) + + +def matcher_contains(val: str, pattern: str) -> bool: + return pattern in val + + +SimpleMatcherFunc = Callable[[str, str], bool] + + +class SimplePattern: + matcher: SimpleMatcherFunc + pattern: str ignorecase: bool - def __init__(self, matcher: Callable[[str], bool], ignorecase: bool) -> None: + def __init__(self, matcher: SimpleMatcherFunc, pattern: str, ignorecase: bool) -> None: self.matcher = matcher + self.pattern = pattern self.ignorecase = ignorecase - def search(self, val: str) -> BlankMatch: + def search(self, val: str) -> SimpleMatch: if self.ignorecase: val = val.lower() - if self.matcher(val): - return self._ptm + if self.matcher(val, self.pattern): + return SimpleMatch(self.pattern) @staticmethod def compile(pattern: str, flags: re.RegexFlag = re.RegexFlag(0), force_raw: bool = False @@ -58,13 +78,16 @@ 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) + func = matcher_equals 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) + func = matcher_startswith 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) + func = matcher_endswith elif force_raw or esc == pattern: - return SimplePattern(lambda val: s_pattern in val, ignorecase=ignorecase) - return None + func = matcher_contains + else: + # Not a simple pattern + return None + return SimplePattern(matcher=func, pattern=s_pattern, ignorecase=ignorecase) From d146f34f724c81995bf578ea971a829563782737 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 18 Aug 2021 00:03:06 +0300 Subject: [PATCH 41/50] Bump version to 2.2.0 --- maubot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maubot.yaml b/maubot.yaml index 16d0e3f..13f6a9e 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: xyz.maubot.reactbot -version: 2.1.0 +version: 2.2.0 license: AGPL-3.0-or-later modules: - reactbot From e22fc9e3c15e0acbf226c3dda09a32c0384cd796 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 28 Nov 2021 15:35:43 +0200 Subject: [PATCH 42/50] Update CI artifact expiry --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c649b91..45ef06b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,7 @@ build: artifacts: paths: - "*.mbp" + expire_in: 365 days build tags: stage: build @@ -25,3 +26,4 @@ build tags: artifacts: paths: - "*.mbp" + expire_in: never From a1e5d2c87d84db64eb94e48b964039ea9af1655c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 24 Feb 2022 22:58:34 +0200 Subject: [PATCH 43/50] Set empty dicts to avoid additional errors when config is invalid --- reactbot/config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/reactbot/config.py b/reactbot/config.py index 8cb9ba5..efbf9ae 100644 --- a/reactbot/config.py +++ b/reactbot/config.py @@ -44,6 +44,9 @@ class Config(BaseProxyConfig): def parse_data(self) -> None: self.default_flags = re.RegexFlag(0) + self.templates = {} + self.rules = {} + self.default_flags = self._get_flags(self["default_flags"]) self.templates = {name: self._make_template(name, tpl) for name, tpl in self["templates"].items()} From feb1a37e114c6068c13712917dee4523e20d78e1 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 19 Jun 2022 14:27:31 +0300 Subject: [PATCH 44/50] Move CI script to main maubot repo --- .gitlab-ci.yml | 32 +++----------------------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 45ef06b..7c690ef 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,29 +1,3 @@ -image: dock.mau.dev/maubot/maubot - -stages: -- build - -variables: - PYTHONPATH: /opt/maubot - -build: - stage: build - except: - - tags - script: - - python3 -m maubot.cli build -o xyz.maubot.$CI_PROJECT_NAME-$CI_COMMIT_REF_NAME-$CI_COMMIT_SHORT_SHA.mbp - artifacts: - paths: - - "*.mbp" - expire_in: 365 days - -build tags: - stage: build - only: - - tags - script: - - python3 -m maubot.cli build -o xyz.maubot.$CI_PROJECT_NAME-$CI_COMMIT_TAG.mbp - artifacts: - paths: - - "*.mbp" - expire_in: never +include: +- project: 'maubot/maubot' + file: '/.gitlab-ci-plugin.yml' From 299cfdff460ac9412451b19807ad122e97243208 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 5 Oct 2023 21:28:13 +0300 Subject: [PATCH 45/50] Add example of replying in thread Fixes #10 --- samples/thread.yaml | 54 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 samples/thread.yaml diff --git a/samples/thread.yaml b/samples/thread.yaml new file mode 100644 index 0000000..fd03c44 --- /dev/null +++ b/samples/thread.yaml @@ -0,0 +1,54 @@ +templates: + always_in_thread: + type: m.room.message + variables: + thread_parent: '{{event.content.get_thread_parent() or event.event_id}}' + event_id: '{{event.event_id}}' + content: + msgtype: m.text + body: $${text} + m.relates_to: + rel_type: m.thread + event_id: $${thread_parent} + is_falling_back: true + m.in_reply_to: + event_id: $${event_id} + # Reply in thread if the message is already in a thread, otherwise use a normal reply. + # This currently requires using a jinja template as the content instead of a normal yaml map. + thread_or_reply: + type: m.room.message + content: | + { + "msgtype": "m.text", + "body": "{{ text }}", + "m.relates_to": { + {% if event.content.get_thread_parent() %} + "rel_type": "m.thread", + "event_id": "{{ event.content.get_thread_parent() }}", + "is_falling_back": true, + {% endif %} + "m.in_reply_to": { + "event_id": "{{ event.event_id }}" + } + } + } + +antispam: + room: + max: 60 + delay: 60 + user: + max: 60 + delay: 60 + +rules: + thread: + matches: [^!thread$] + template: always_in_thread + variables: + text: meow 3:< + maybe_thread: + matches: [^!thread --maybe$] + template: thread_or_reply + variables: + text: meow >:3 From a3baf06ca0c700a07af0d9d0d4ea6c436debe105 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 5 Oct 2023 21:54:11 +0300 Subject: [PATCH 46/50] Allow non-string types in variable templates and add magic omit value --- reactbot/config.py | 9 +++++---- reactbot/rule.py | 3 +-- reactbot/template.py | 25 +++++++++++++++++++------ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/reactbot/config.py b/reactbot/config.py index efbf9ae..83e2283 100644 --- a/reactbot/config.py +++ b/reactbot/config.py @@ -16,7 +16,8 @@ from typing import List, Union, Dict, Any import re -from jinja2 import Template as JinjaTemplate +from jinja2 import Template as JinjaStringTemplate +from jinja2.nativetypes import NativeTemplate as JinjaNativeTemplate from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper from mautrix.types import EventType @@ -92,17 +93,17 @@ class Config(BaseProxyConfig): @staticmethod def _parse_variables(data: Dict[str, Any]) -> Dict[str, Any]: - return {name: (JinjaTemplate(var_tpl) + return {name: (JinjaNativeTemplate(var_tpl) if isinstance(var_tpl, str) and var_tpl.startswith("{{") else var_tpl) for name, var_tpl in data.get("variables", {}).items()} @staticmethod - def _parse_content(content: Union[Dict[str, Any], str]) -> Union[Dict[str, Any], JinjaTemplate]: + def _parse_content(content: Union[Dict[str, Any], str]) -> Union[Dict[str, Any], JinjaStringTemplate]: if not content: return {} elif isinstance(content, str): - return JinjaTemplate(content) + return JinjaStringTemplate(content) return content @staticmethod diff --git a/reactbot/rule.py b/reactbot/rule.py index b97b350..51a8bee 100644 --- a/reactbot/rule.py +++ b/reactbot/rule.py @@ -16,13 +16,12 @@ from typing import Optional, Match, Dict, List, Set, Union, Pattern, Any from attr import dataclass -from jinja2 import Template as JinjaTemplate from mautrix.types import RoomID, EventType from maubot import MessageEvent -from .template import Template +from .template import Template, OmitValue from .simplepattern import SimplePattern RPattern = Union[Pattern, SimplePattern] diff --git a/reactbot/template.py b/reactbot/template.py index 7643ca4..f150a59 100644 --- a/reactbot/template.py +++ b/reactbot/template.py @@ -20,7 +20,8 @@ import copy import re from attr import dataclass -from jinja2 import Template as JinjaTemplate +from jinja2 import Template as JinjaStringTemplate +from jinja2.nativetypes import Template as JinjaNativeTemplate from mautrix.types import EventType, Event @@ -30,6 +31,11 @@ class Key(str): variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}") +OmitValue = object() + +global_vars = { + "omit": OmitValue, +} Index = Union[str, int, Key] @@ -38,7 +44,7 @@ Index = Union[str, int, Key] class Template: type: EventType variables: Dict[str, Any] - content: Union[Dict[str, Any], JinjaTemplate] + content: Union[Dict[str, Any], JinjaStringTemplate] _variable_locations: List[Tuple[Index, ...]] = None @@ -78,11 +84,14 @@ class Template: ) -> Dict[str, Any]: variables = extra_vars for name, template in chain(rule_vars.items(), self.variables.items()): - if isinstance(template, JinjaTemplate): - variables[name] = template.render(event=evt, variables=variables) + if isinstance(template, JinjaNativeTemplate): + rendered_var = template.render(event=evt, variables=variables, **global_vars) + if not isinstance(rendered_var, (str, int, list, tuple, dict, bool)) and rendered_var is not None and rendered_var is not OmitValue: + rendered_var = str(rendered_var) + variables[name] = rendered_var else: variables[name] = template - if isinstance(self.content, JinjaTemplate): + if isinstance(self.content, JinjaStringTemplate): raw_json = self.content.render(event=evt, **variables) return json.loads(raw_json) content = copy.deepcopy(self.content) @@ -93,5 +102,9 @@ class Template: key = str(key) data[self._replace_variables(key, variables)] = data.pop(key) else: - data[key] = self._replace_variables(data[key], variables) + replaced_data = self._replace_variables(data[key], variables) + if replaced_data is OmitValue: + del data[key] + else: + data[key] = replaced_data return content From e6949c3509cafec5e008ac12279d05fb677e2832 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 5 Oct 2023 22:06:05 +0300 Subject: [PATCH 47/50] Simplify thread example using new native typed templates --- samples/thread.yaml | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/samples/thread.yaml b/samples/thread.yaml index fd03c44..b963332 100644 --- a/samples/thread.yaml +++ b/samples/thread.yaml @@ -17,21 +17,17 @@ templates: # This currently requires using a jinja template as the content instead of a normal yaml map. thread_or_reply: type: m.room.message - content: | - { - "msgtype": "m.text", - "body": "{{ text }}", - "m.relates_to": { - {% if event.content.get_thread_parent() %} - "rel_type": "m.thread", - "event_id": "{{ event.content.get_thread_parent() }}", - "is_falling_back": true, - {% endif %} - "m.in_reply_to": { - "event_id": "{{ event.event_id }}" - } - } - } + variables: + relates_to: | + {{ + {"rel_type": "m.thread", "event_id": event.content.get_thread_parent(), "is_falling_back": True, "m.in_reply_to": {"event_id": event.event_id}} + if event.content.get_thread_parent() + else {"m.in_reply_to": {"event_id": event.event_id}} + }} + content: + msgtype: m.text + body: $${text} + m.relates_to: $${relates_to} antispam: room: From 3507b3b63af80a9f6e01c2a333c4120a19a296d3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 5 Oct 2023 22:07:20 +0300 Subject: [PATCH 48/50] Update README.md --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fef4f88..682b881 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A [maubot](https://github.com/maubot/maubot) that responds to messages that matc a randomized reaction to matching messages. * [samples/nitter.yaml](samples/nitter.yaml) has an example of matching tweet links and responding with a corresponding nitter.net link. +* [samples/thread.yaml](samples/thread.yaml) has an example of replying in a thread. ## Config format ### Templates @@ -19,7 +20,10 @@ Templates contain the actual event type and content to be sent. * `content` - The event content. Either an object or jinja2 template that produces JSON. * `variables` - A key-value map of variables. -Variables are parsed as jinja2 templates and get the maubot event object in `event`. +Variables that start with `{{` are parsed as jinja2 templates and get the +maubot event object in `event`. As of v3, variables are parsed using jinja2's +[native types mode](https://jinja.palletsprojects.com/en/3.1.x/nativetypes/), +which means the output can be a non-string type. If the content is a string, it'll be parsed as a jinja2 template and the output will be parsed as JSON. The content jinja2 template will get `event` just like From 3ca366fea9810ea7806bf506307ffd51f4af180f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 5 Oct 2023 22:22:10 +0300 Subject: [PATCH 49/50] Blacken and isort code, add pre-commit and CI linting --- .github/workflows/python-lint.yml | 24 ++++++++++++++ .pre-commit-config.yaml | 19 +++++++++++ pyproject.toml | 11 +++++++ reactbot/bot.py | 28 +++++++++------- reactbot/config.py | 54 ++++++++++++++++++------------- reactbot/rule.py | 9 +++--- reactbot/simplepattern.py | 13 ++++---- reactbot/template.py | 19 +++++++---- 8 files changed, 125 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/python-lint.yml create mode 100644 .pre-commit-config.yaml create mode 100644 pyproject.toml diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml new file mode 100644 index 0000000..ea32a95 --- /dev/null +++ b/.github/workflows/python-lint.yml @@ -0,0 +1,24 @@ +name: Python lint + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + - uses: isort/isort-action@master + with: + sortPaths: "./reactbot" + - uses: psf/black@stable + with: + src: "./reactbot" + - name: pre-commit + run: | + pip install pre-commit + pre-commit run -av trailing-whitespace + pre-commit run -av end-of-file-fixer + pre-commit run -av check-added-large-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..91bcb81 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + exclude_types: [markdown] + - id: end-of-file-fixer + - id: check-added-large-files + - repo: https://github.com/psf/black + rev: 23.9.1 + hooks: + - id: black + language_version: python3 + files: ^rss/.*\.pyi?$ + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + files: ^rss/.*\.pyi?$ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3e608c9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.isort] +profile = "black" +force_to_top = "typing" +from_first = true +combine_as_imports = true +known_first_party = ["mautrix", "maubot"] +line_length = 99 + +[tool.black] +line-length = 99 +target-version = ["py38"] diff --git a/reactbot/bot.py b/reactbot/bot.py index 5868dda..a31fa31 100644 --- a/reactbot/bot.py +++ b/reactbot/bot.py @@ -13,16 +13,15 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Type, Tuple, Dict +from typing import Dict, Tuple, Type import time from attr import dataclass -from mautrix.types import EventType, MessageType, UserID, RoomID -from mautrix.util.config import BaseProxyConfig - -from maubot import Plugin, MessageEvent +from maubot import MessageEvent, Plugin from maubot.handlers import event +from mautrix.types import EventType, MessageType, RoomID, UserID +from mautrix.util.config import BaseProxyConfig from .config import Config, ConfigError @@ -73,12 +72,15 @@ class ReactBot(Plugin): fi.max = self.config["antispam.room.max"] fi.delay = self.config["antispam.room.delay"] - def _make_flood_info(self, for_type: str) -> 'FloodInfo': - return FloodInfo(max=self.config[f"antispam.{for_type}.max"], - delay=self.config[f"antispam.{for_type}.delay"], - count=0, last_message=0) + def _make_flood_info(self, for_type: str) -> "FloodInfo": + return FloodInfo( + max=self.config[f"antispam.{for_type}.max"], + delay=self.config[f"antispam.{for_type}.delay"], + count=0, + last_message=0, + ) - def _get_flood_info(self, flood_map: dict, key: str, for_type: str) -> 'FloodInfo': + def _get_flood_info(self, flood_map: dict, key: str, for_type: str) -> "FloodInfo": try: return flood_map[key] except KeyError: @@ -86,8 +88,10 @@ class ReactBot(Plugin): return fi def is_flood(self, evt: MessageEvent) -> bool: - return (self._get_flood_info(self.user_flood, evt.sender, "user").bump() - or self._get_flood_info(self.room_flood, evt.room_id, "room").bump()) + return ( + self._get_flood_info(self.user_flood, evt.sender, "user").bump() + or self._get_flood_info(self.room_flood, evt.room_id, "room").bump() + ) @event.on(EventType.ROOM_MESSAGE) async def event_handler(self, evt: MessageEvent) -> None: diff --git a/reactbot/config.py b/reactbot/config.py index 83e2283..b264a6b 100644 --- a/reactbot/config.py +++ b/reactbot/config.py @@ -13,18 +13,18 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import List, Union, Dict, Any +from typing import Any, Dict, List, Union import re from jinja2 import Template as JinjaStringTemplate from jinja2.nativetypes import NativeTemplate as JinjaNativeTemplate -from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper from mautrix.types import EventType +from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper +from .rule import RPattern, Rule from .simplepattern import SimplePattern from .template import Template -from .rule import Rule, RPattern InputPattern = Union[str, Dict[str, str]] @@ -49,28 +49,32 @@ class Config(BaseProxyConfig): self.rules = {} self.default_flags = self._get_flags(self["default_flags"]) - self.templates = {name: self._make_template(name, tpl) - for name, tpl in self["templates"].items()} - self.rules = {name: self._make_rule(name, rule) - for name, rule in self["rules"].items()} + self.templates = { + name: self._make_template(name, tpl) for name, tpl in self["templates"].items() + } + self.rules = {name: self._make_rule(name, rule) for name, rule in self["rules"].items()} def _make_rule(self, name: str, rule: Dict[str, Any]) -> Rule: try: - return Rule(rooms=set(rule.get("rooms", [])), - not_rooms=set(rule.get("not_rooms", [])), - matches=self._compile_all(rule["matches"]), - 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)) + return Rule( + rooms=set(rule.get("rooms", [])), + not_rooms=set(rule.get("not_rooms", [])), + matches=self._compile_all(rule["matches"]), + 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), + ) except Exception as e: raise ConfigError(f"Failed to load {name}") from e def _make_template(self, name: str, tpl: Dict[str, Any]) -> Template: try: - return Template(type=EventType.find(tpl.get("type", "m.room.message")), - variables=self._parse_variables(tpl), - content=self._parse_content(tpl.get("content", None))).init() + return Template( + type=EventType.find(tpl.get("type", "m.room.message")), + variables=self._parse_variables(tpl), + content=self._parse_content(tpl.get("content", None)), + ).init() except Exception as e: raise ConfigError(f"Failed to load {name}") from e @@ -93,13 +97,19 @@ class Config(BaseProxyConfig): @staticmethod def _parse_variables(data: Dict[str, Any]) -> Dict[str, Any]: - return {name: (JinjaNativeTemplate(var_tpl) - if isinstance(var_tpl, str) and var_tpl.startswith("{{") - else var_tpl) - for name, var_tpl in data.get("variables", {}).items()} + return { + name: ( + JinjaNativeTemplate(var_tpl) + if isinstance(var_tpl, str) and var_tpl.startswith("{{") + else var_tpl + ) + for name, var_tpl in data.get("variables", {}).items() + } @staticmethod - def _parse_content(content: Union[Dict[str, Any], str]) -> Union[Dict[str, Any], JinjaStringTemplate]: + def _parse_content( + content: Union[Dict[str, Any], str] + ) -> Union[Dict[str, Any], JinjaStringTemplate]: if not content: return {} elif isinstance(content, str): diff --git a/reactbot/rule.py b/reactbot/rule.py index 51a8bee..07e714d 100644 --- a/reactbot/rule.py +++ b/reactbot/rule.py @@ -13,16 +13,15 @@ # # 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 Any, Dict, List, Match, Optional, Pattern, Set, Union from attr import dataclass -from mautrix.types import RoomID, EventType - from maubot import MessageEvent +from mautrix.types import EventType, RoomID -from .template import Template, OmitValue from .simplepattern import SimplePattern +from .template import OmitValue, Template RPattern = Union[Pattern, SimplePattern] @@ -59,7 +58,7 @@ class Rule: async def execute(self, evt: MessageEvent, match: Match) -> None: extra_vars = { "0": match.group(0), - **{str(i+1): val for i, val in enumerate(match.groups())}, + **{str(i + 1): val for i, val in enumerate(match.groups())}, **match.groupdict(), } content = self.template.execute(evt=evt, rule_vars=self.variables, extra_vars=extra_vars) diff --git a/reactbot/simplepattern.py b/reactbot/simplepattern.py index f40d7ce..e2ed6a3 100644 --- a/reactbot/simplepattern.py +++ b/reactbot/simplepattern.py @@ -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, NamedTuple +from typing import Callable, Dict, List, NamedTuple, Optional import re @@ -68,21 +68,22 @@ class SimplePattern: return SimpleMatch(self.pattern) @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 + ) -> Optional["SimplePattern"]: ignorecase = flags == re.IGNORECASE s_pattern = pattern.lower() if ignorecase else pattern esc = "" if not force_raw: esc = re.escape(pattern) first, last = pattern[0], pattern[-1] - if first == '^' and last == '$' and (force_raw or esc == f"\\^{pattern[1:-1]}\\$"): + if first == "^" and last == "$" and (force_raw or esc == f"\\^{pattern[1:-1]}\\$"): s_pattern = s_pattern[1:-1] func = matcher_equals - elif first == '^' and (force_raw or esc == f"\\^{pattern[1:]}"): + elif first == "^" and (force_raw or esc == f"\\^{pattern[1:]}"): s_pattern = s_pattern[1:] func = matcher_startswith - elif last == '$' and (force_raw or esc == f"{pattern[:-1]}\\$"): + elif last == "$" and (force_raw or esc == f"{pattern[:-1]}\\$"): s_pattern = s_pattern[:-1] func = matcher_endswith elif force_raw or esc == pattern: diff --git a/reactbot/template.py b/reactbot/template.py index f150a59..8ab96a5 100644 --- a/reactbot/template.py +++ b/reactbot/template.py @@ -13,17 +13,17 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Union, Dict, List, Tuple, Any +from typing import Any, Dict, List, Tuple, Union from itertools import chain -import json import copy +import json import re from attr import dataclass from jinja2 import Template as JinjaStringTemplate from jinja2.nativetypes import Template as JinjaNativeTemplate -from mautrix.types import EventType, Event +from mautrix.types import Event, EventType class Key(str): @@ -48,7 +48,7 @@ class Template: _variable_locations: List[Tuple[Index, ...]] = None - def init(self) -> 'Template': + def init(self) -> "Template": self._variable_locations = [] self._map_variable_locations((), self.content) return self @@ -80,13 +80,18 @@ class Template: return variables[full_var_match.group(1)] return variable_regex.sub(lambda match: str(variables[match.group(1)]), tpl) - def execute(self, evt: Event, rule_vars: Dict[str, Any], extra_vars: Dict[str, str] - ) -> Dict[str, Any]: + def execute( + self, evt: Event, rule_vars: Dict[str, Any], extra_vars: Dict[str, str] + ) -> Dict[str, Any]: variables = extra_vars for name, template in chain(rule_vars.items(), self.variables.items()): if isinstance(template, JinjaNativeTemplate): rendered_var = template.render(event=evt, variables=variables, **global_vars) - if not isinstance(rendered_var, (str, int, list, tuple, dict, bool)) and rendered_var is not None and rendered_var is not OmitValue: + if ( + not isinstance(rendered_var, (str, int, list, tuple, dict, bool)) + and rendered_var is not None + and rendered_var is not OmitValue + ): rendered_var = str(rendered_var) variables[name] = rendered_var else: From 7fd6dd1a3c49cc338e036b3c894bd0e11559cd84 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 5 Oct 2023 22:31:27 +0300 Subject: [PATCH 50/50] Fix linting --- .pre-commit-config.yaml | 4 ++-- samples/stallman.yaml | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 91bcb81..2d79b6c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,9 +11,9 @@ repos: hooks: - id: black language_version: python3 - files: ^rss/.*\.pyi?$ + files: ^reactbot/.*\.pyi?$ - repo: https://github.com/PyCQA/isort rev: 5.12.0 hooks: - id: isort - files: ^rss/.*\.pyi?$ + files: ^reactbot/.*\.pyi?$ diff --git a/samples/stallman.yaml b/samples/stallman.yaml index 6ec3c3d..c97c5d8 100644 --- a/samples/stallman.yaml +++ b/samples/stallman.yaml @@ -419,4 +419,3 @@ rules: Under the US legal system, copyright infringement is not theft. Laws about theft are not applicable to copyright infringement. The supporters of repressive copyright are making an appeal to authority—and misrepresenting what authority says. Unauthorized copying is forbidden by copyright law in many circumstances (not all!), but being forbidden doesn't make it wrong. In general, laws don't define right and wrong. Laws, at their best, attempt to implement justice. If the laws (the implementation) don't fit our ideas of right and wrong (the spec), the laws are what should change. In addition, a US judge, presiding over a trial for copyright infringement, recognized that “piracy” and “theft” are smear-words. -