From 4cb003f048446f3f2f58cd97d93212c3876bfa45 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Jun 2019 15:21:25 +0300 Subject: [PATCH 01/27] 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 02/27] 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 03/27] 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 04/27] 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 05/27] 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 06/27] 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 07/27] 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 08/27] 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 09/27] 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 10/27] 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 11/27] 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 12/27] 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 13/27] 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 14/27] 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 15/27] 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 16/27] 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 17/27] 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 18/27] 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 19/27] 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 20/27] 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 21/27] 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 22/27] 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 23/27] 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 24/27] 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 25/27] 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 26/27] 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 27/27] 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. -