Compare commits
27 commits
Author | SHA1 | Date | |
---|---|---|---|
|
7fd6dd1a3c | ||
|
3ca366fea9 | ||
|
3507b3b63a | ||
|
e6949c3509 | ||
|
a3baf06ca0 | ||
|
299cfdff46 | ||
|
feb1a37e11 | ||
|
a1e5d2c87d | ||
|
e22fc9e3c1 | ||
|
d146f34f72 | ||
|
3964aa6f12 | ||
|
16e4b8e6d8 | ||
|
45e22185dc | ||
|
873cae5821 | ||
|
28d6b05913 | ||
|
05e479bb88 | ||
|
e89a5773d8 | ||
|
821e670fd5 | ||
|
b213481d7d | ||
|
0790b429b3 | ||
|
8353e43e30 | ||
|
45ee715dc3 | ||
|
217351d141 | ||
|
50141c8a92 | ||
|
2ad417da70 | ||
|
5a332b13af | ||
|
4cb003f048 |
15 changed files with 341 additions and 108 deletions
24
.github/workflows/python-lint.yml
vendored
Normal file
24
.github/workflows/python-lint.yml
vendored
Normal file
|
@ -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
|
3
.gitlab-ci.yml
Normal file
3
.gitlab-ci.yml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
include:
|
||||||
|
- project: 'maubot/maubot'
|
||||||
|
file: '/.gitlab-ci-plugin.yml'
|
19
.pre-commit-config.yaml
Normal file
19
.pre-commit-config.yaml
Normal file
|
@ -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: ^reactbot/.*\.pyi?$
|
||||||
|
- repo: https://github.com/PyCQA/isort
|
||||||
|
rev: 5.12.0
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
files: ^reactbot/.*\.pyi?$
|
10
README.md
10
README.md
|
@ -7,6 +7,11 @@ A [maubot](https://github.com/maubot/maubot) that responds to messages that matc
|
||||||
and an image response for "alot".
|
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.
|
* [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.
|
||||||
|
* [samples/thread.yaml](samples/thread.yaml) has an example of replying in a thread.
|
||||||
|
|
||||||
## Config format
|
## Config format
|
||||||
### Templates
|
### Templates
|
||||||
|
@ -15,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.
|
* `content` - The event content. Either an object or jinja2 template that produces JSON.
|
||||||
* `variables` - A key-value map of variables.
|
* `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
|
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
|
will be parsed as JSON. The content jinja2 template will get `event` just like
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
maubot: 0.1.0
|
maubot: 0.1.0
|
||||||
id: xyz.maubot.reactbot
|
id: xyz.maubot.reactbot
|
||||||
version: 2.0.0
|
version: 2.2.0
|
||||||
license: AGPL-3.0-or-later
|
license: AGPL-3.0-or-later
|
||||||
modules:
|
modules:
|
||||||
- reactbot
|
- reactbot
|
||||||
|
|
11
pyproject.toml
Normal file
11
pyproject.toml
Normal file
|
@ -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"]
|
|
@ -13,17 +13,15 @@
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Type, Tuple, Dict
|
from typing import Dict, Tuple, Type
|
||||||
from itertools import chain
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from attr import dataclass
|
from attr import dataclass
|
||||||
|
|
||||||
from mautrix.types import EventType, MessageType, UserID, RoomID
|
from maubot import MessageEvent, Plugin
|
||||||
from mautrix.util.config import BaseProxyConfig
|
|
||||||
|
|
||||||
from maubot import Plugin, MessageEvent
|
|
||||||
from maubot.handlers import event
|
from maubot.handlers import event
|
||||||
|
from mautrix.types import EventType, MessageType, RoomID, UserID
|
||||||
|
from mautrix.util.config import BaseProxyConfig
|
||||||
|
|
||||||
from .config import Config, ConfigError
|
from .config import Config, ConfigError
|
||||||
|
|
||||||
|
@ -67,19 +65,22 @@ class ReactBot(Plugin):
|
||||||
self.config.parse_data()
|
self.config.parse_data()
|
||||||
except ConfigError:
|
except ConfigError:
|
||||||
self.log.exception("Failed to load config")
|
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.max = self.config["antispam.user.max"]
|
||||||
fi.delay = self.config["antispam.user.delay"]
|
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.max = self.config["antispam.room.max"]
|
||||||
fi.delay = self.config["antispam.room.delay"]
|
fi.delay = self.config["antispam.room.delay"]
|
||||||
|
|
||||||
def _make_flood_info(self, for_type: str) -> 'FloodInfo':
|
def _make_flood_info(self, for_type: str) -> "FloodInfo":
|
||||||
return FloodInfo(max=self.config[f"antispam.{for_type}.max"],
|
return FloodInfo(
|
||||||
delay=self.config[f"antispam.{for_type}.delay"],
|
max=self.config[f"antispam.{for_type}.max"],
|
||||||
count=0, last_message=0)
|
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:
|
try:
|
||||||
return flood_map[key]
|
return flood_map[key]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
@ -87,8 +88,10 @@ class ReactBot(Plugin):
|
||||||
return fi
|
return fi
|
||||||
|
|
||||||
def is_flood(self, evt: MessageEvent) -> bool:
|
def is_flood(self, evt: MessageEvent) -> bool:
|
||||||
return (self._get_flood_info(self.user_flood, evt.sender, "user").bump()
|
return (
|
||||||
or self._get_flood_info(self.room_flood, evt.room_id, "room").bump())
|
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)
|
@event.on(EventType.ROOM_MESSAGE)
|
||||||
async def event_handler(self, evt: MessageEvent) -> None:
|
async def event_handler(self, evt: MessageEvent) -> None:
|
||||||
|
@ -102,5 +105,5 @@ class ReactBot(Plugin):
|
||||||
try:
|
try:
|
||||||
await rule.execute(evt, match)
|
await rule.execute(evt, match)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception(f"Failed to execute {name}")
|
self.log.exception(f"Failed to execute {name} in {evt.room_id}")
|
||||||
return
|
return
|
||||||
|
|
|
@ -13,17 +13,18 @@
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import List, Union, Dict, Any
|
from typing import Any, Dict, List, Union
|
||||||
import re
|
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
|
from mautrix.types import EventType
|
||||||
|
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
|
||||||
|
|
||||||
|
from .rule import RPattern, Rule
|
||||||
from .simplepattern import SimplePattern
|
from .simplepattern import SimplePattern
|
||||||
from .template import Template
|
from .template import Template
|
||||||
from .rule import Rule, RPattern
|
|
||||||
|
|
||||||
InputPattern = Union[str, Dict[str, str]]
|
InputPattern = Union[str, Dict[str, str]]
|
||||||
|
|
||||||
|
@ -44,28 +45,36 @@ class Config(BaseProxyConfig):
|
||||||
|
|
||||||
def parse_data(self) -> None:
|
def parse_data(self) -> None:
|
||||||
self.default_flags = re.RegexFlag(0)
|
self.default_flags = re.RegexFlag(0)
|
||||||
|
self.templates = {}
|
||||||
|
self.rules = {}
|
||||||
|
|
||||||
self.default_flags = self._get_flags(self["default_flags"])
|
self.default_flags = self._get_flags(self["default_flags"])
|
||||||
self.templates = {name: self._make_template(name, tpl)
|
self.templates = {
|
||||||
for name, tpl in self["templates"].items()}
|
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.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:
|
def _make_rule(self, name: str, rule: Dict[str, Any]) -> Rule:
|
||||||
try:
|
try:
|
||||||
return Rule(rooms=set(rule.get("rooms", [])),
|
return Rule(
|
||||||
matches=self._compile_all(rule["matches"]),
|
rooms=set(rule.get("rooms", [])),
|
||||||
not_matches=self._compile_all(rule.get("not_matches", [])),
|
not_rooms=set(rule.get("not_rooms", [])),
|
||||||
type=EventType.find(rule["type"]) if "type" in rule else None,
|
matches=self._compile_all(rule["matches"]),
|
||||||
template=self.templates[rule["template"]],
|
not_matches=self._compile_all(rule.get("not_matches", [])),
|
||||||
variables=self._parse_variables(rule))
|
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:
|
except Exception as e:
|
||||||
raise ConfigError(f"Failed to load {name}") from e
|
raise ConfigError(f"Failed to load {name}") from e
|
||||||
|
|
||||||
def _make_template(self, name: str, tpl: Dict[str, Any]) -> Template:
|
def _make_template(self, name: str, tpl: Dict[str, Any]) -> Template:
|
||||||
try:
|
try:
|
||||||
return Template(type=EventType.find(tpl.get("type", "m.room.message")),
|
return Template(
|
||||||
variables=self._parse_variables(tpl),
|
type=EventType.find(tpl.get("type", "m.room.message")),
|
||||||
content=self._parse_content(tpl.get("content", None))).init()
|
variables=self._parse_variables(tpl),
|
||||||
|
content=self._parse_content(tpl.get("content", None)),
|
||||||
|
).init()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ConfigError(f"Failed to load {name}") from e
|
raise ConfigError(f"Failed to load {name}") from e
|
||||||
|
|
||||||
|
@ -87,16 +96,24 @@ class Config(BaseProxyConfig):
|
||||||
return re.compile(pattern, flags=flags)
|
return re.compile(pattern, flags=flags)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_variables(data: Dict[str, Any]) -> Dict[str, JinjaTemplate]:
|
def _parse_variables(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
return {name: JinjaTemplate(var_tpl) for name, var_tpl
|
return {
|
||||||
in data.get("variables", {}).items()}
|
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
|
@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:
|
if not content:
|
||||||
return {}
|
return {}
|
||||||
elif isinstance(content, str):
|
elif isinstance(content, str):
|
||||||
return JinjaTemplate(content)
|
return JinjaStringTemplate(content)
|
||||||
return content
|
return content
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
@ -13,17 +13,15 @@
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Optional, Match, Dict, List, Set, Union, Pattern
|
from typing import Any, Dict, List, Match, Optional, Pattern, Set, Union
|
||||||
|
|
||||||
from attr import dataclass
|
from attr import dataclass
|
||||||
from jinja2 import Template as JinjaTemplate
|
|
||||||
|
|
||||||
from mautrix.types import RoomID, EventType
|
|
||||||
|
|
||||||
from maubot import MessageEvent
|
from maubot import MessageEvent
|
||||||
|
from mautrix.types import EventType, RoomID
|
||||||
|
|
||||||
from .template import Template
|
|
||||||
from .simplepattern import SimplePattern
|
from .simplepattern import SimplePattern
|
||||||
|
from .template import OmitValue, Template
|
||||||
|
|
||||||
RPattern = Union[Pattern, SimplePattern]
|
RPattern = Union[Pattern, SimplePattern]
|
||||||
|
|
||||||
|
@ -31,11 +29,12 @@ RPattern = Union[Pattern, SimplePattern]
|
||||||
@dataclass
|
@dataclass
|
||||||
class Rule:
|
class Rule:
|
||||||
rooms: Set[RoomID]
|
rooms: Set[RoomID]
|
||||||
|
not_rooms: Set[RoomID]
|
||||||
matches: List[RPattern]
|
matches: List[RPattern]
|
||||||
not_matches: List[RPattern]
|
not_matches: List[RPattern]
|
||||||
template: Template
|
template: Template
|
||||||
type: Optional[EventType]
|
type: Optional[EventType]
|
||||||
variables: Dict[str, JinjaTemplate]
|
variables: Dict[str, Any]
|
||||||
|
|
||||||
def _check_not_match(self, body: str) -> bool:
|
def _check_not_match(self, body: str) -> bool:
|
||||||
for pattern in self.not_matches:
|
for pattern in self.not_matches:
|
||||||
|
@ -46,6 +45,8 @@ class Rule:
|
||||||
def match(self, evt: MessageEvent) -> Optional[Match]:
|
def match(self, evt: MessageEvent) -> Optional[Match]:
|
||||||
if len(self.rooms) > 0 and evt.room_id not in self.rooms:
|
if len(self.rooms) > 0 and evt.room_id not in self.rooms:
|
||||||
return None
|
return None
|
||||||
|
elif evt.room_id in self.not_rooms:
|
||||||
|
return None
|
||||||
for pattern in self.matches:
|
for pattern in self.matches:
|
||||||
match = pattern.search(evt.content.body)
|
match = pattern.search(evt.content.body)
|
||||||
if match:
|
if match:
|
||||||
|
@ -55,7 +56,10 @@ class Rule:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def execute(self, evt: MessageEvent, match: Match) -> None:
|
async def execute(self, evt: MessageEvent, match: Match) -> None:
|
||||||
content = self.template.execute(evt=evt, rule_vars=self.variables,
|
extra_vars = {
|
||||||
extra_vars={str(i): val for i, val in
|
"0": match.group(0),
|
||||||
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)
|
||||||
await evt.client.send_message_event(evt.room_id, self.type or self.template.type, content)
|
await evt.client.send_message_event(evt.room_id, self.type or self.template.type, content)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# reminder - A maubot plugin that reacts to messages that match predefined rules.
|
# 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
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -13,50 +13,82 @@
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Callable, List, Optional
|
from typing import Callable, Dict, List, NamedTuple, Optional
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
class BlankMatch:
|
class SimpleMatch(NamedTuple):
|
||||||
@staticmethod
|
value: str
|
||||||
def groups() -> List[str]:
|
|
||||||
return []
|
def groups(self) -> List[str]:
|
||||||
|
return [self.value]
|
||||||
|
|
||||||
|
def group(self, group: int) -> Optional[str]:
|
||||||
|
if group == 0:
|
||||||
|
return self.value
|
||||||
|
return None
|
||||||
|
|
||||||
|
def groupdict(self) -> Dict[str, str]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def matcher_equals(val: str, pattern: str) -> bool:
|
||||||
|
return val == pattern
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
class SimplePattern:
|
||||||
_ptm = BlankMatch()
|
matcher: SimpleMatcherFunc
|
||||||
|
pattern: str
|
||||||
matcher: Callable[[str], bool]
|
|
||||||
ignorecase: bool
|
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.matcher = matcher
|
||||||
|
self.pattern = pattern
|
||||||
self.ignorecase = ignorecase
|
self.ignorecase = ignorecase
|
||||||
|
|
||||||
def search(self, val: str) -> BlankMatch:
|
def search(self, val: str) -> SimpleMatch:
|
||||||
if self.ignorecase:
|
if self.ignorecase:
|
||||||
val = val.lower()
|
val = val.lower()
|
||||||
if self.matcher(val):
|
if self.matcher(val, self.pattern):
|
||||||
return self._ptm
|
return SimpleMatch(self.pattern)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def compile(pattern: str, flags: re.RegexFlag = re.RegexFlag(0), force_raw: bool = False
|
def compile(
|
||||||
) -> Optional['SimplePattern']:
|
pattern: str, flags: re.RegexFlag = re.RegexFlag(0), force_raw: bool = False
|
||||||
|
) -> Optional["SimplePattern"]:
|
||||||
ignorecase = flags == re.IGNORECASE
|
ignorecase = flags == re.IGNORECASE
|
||||||
s_pattern = pattern.lower() if ignorecase else pattern
|
s_pattern = pattern.lower() if ignorecase else pattern
|
||||||
esc = ""
|
esc = ""
|
||||||
if not force_raw:
|
if not force_raw:
|
||||||
esc = re.escape(pattern)
|
esc = re.escape(pattern)
|
||||||
first, last = pattern[0], pattern[-1]
|
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]
|
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:]}"):
|
elif first == "^" and (force_raw or esc == f"\\^{pattern[1:]}"):
|
||||||
s_pattern = s_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]}\\$"):
|
elif last == "$" and (force_raw or esc == f"{pattern[:-1]}\\$"):
|
||||||
s_pattern = s_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:
|
elif force_raw or esc == pattern:
|
||||||
return SimplePattern(lambda val: s_pattern in val, ignorecase=ignorecase)
|
func = matcher_contains
|
||||||
return None
|
else:
|
||||||
|
# Not a simple pattern
|
||||||
|
return None
|
||||||
|
return SimplePattern(matcher=func, pattern=s_pattern, ignorecase=ignorecase)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# reminder - A maubot plugin that reacts to messages that match predefined rules.
|
# 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
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -13,16 +13,17 @@
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Union, Dict, List, Tuple, Any
|
from typing import Any, Dict, List, Tuple, Union
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
import json
|
|
||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from attr import dataclass
|
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
|
from mautrix.types import Event, EventType
|
||||||
|
|
||||||
|
|
||||||
class Key(str):
|
class Key(str):
|
||||||
|
@ -30,6 +31,11 @@ class Key(str):
|
||||||
|
|
||||||
|
|
||||||
variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}")
|
variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}")
|
||||||
|
OmitValue = object()
|
||||||
|
|
||||||
|
global_vars = {
|
||||||
|
"omit": OmitValue,
|
||||||
|
}
|
||||||
|
|
||||||
Index = Union[str, int, Key]
|
Index = Union[str, int, Key]
|
||||||
|
|
||||||
|
@ -37,12 +43,12 @@ Index = Union[str, int, Key]
|
||||||
@dataclass
|
@dataclass
|
||||||
class Template:
|
class Template:
|
||||||
type: EventType
|
type: EventType
|
||||||
variables: Dict[str, JinjaTemplate]
|
variables: Dict[str, Any]
|
||||||
content: Union[Dict[str, Any], JinjaTemplate]
|
content: Union[Dict[str, Any], JinjaStringTemplate]
|
||||||
|
|
||||||
_variable_locations: List[Tuple[Index, ...]] = None
|
_variable_locations: List[Tuple[Index, ...]] = None
|
||||||
|
|
||||||
def init(self) -> 'Template':
|
def init(self) -> "Template":
|
||||||
self._variable_locations = []
|
self._variable_locations = []
|
||||||
self._map_variable_locations((), self.content)
|
self._map_variable_locations((), self.content)
|
||||||
return self
|
return self
|
||||||
|
@ -53,11 +59,11 @@ class Template:
|
||||||
self._map_variable_locations((*path, i), v)
|
self._map_variable_locations((*path, i), v)
|
||||||
elif isinstance(data, dict):
|
elif isinstance(data, dict):
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
if variable_regex.match(k):
|
if variable_regex.search(k):
|
||||||
self._variable_locations.append((*path, Key(k)))
|
self._variable_locations.append((*path, Key(k)))
|
||||||
self._map_variable_locations((*path, k), v)
|
self._map_variable_locations((*path, k), v)
|
||||||
elif isinstance(data, str):
|
elif isinstance(data, str):
|
||||||
if variable_regex.match(data):
|
if variable_regex.search(data):
|
||||||
self._variable_locations.append(path)
|
self._variable_locations.append(path)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -68,20 +74,29 @@ class Template:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _replace_variables(tpl: str, variables: Dict[str, Any]) -> str:
|
def _replace_variables(tpl: str, variables: Dict[str, Any]) -> str:
|
||||||
for match in variable_regex.finditer(tpl):
|
full_var_match = variable_regex.fullmatch(tpl)
|
||||||
val = variables[match.group(1)]
|
if full_var_match:
|
||||||
if match.start() == 0 and match.end() == len(tpl):
|
# Whole field is a single variable, just return the value to allow non-string types.
|
||||||
# Whole field is a single variable, just return the value to allow non-string types.
|
return variables[full_var_match.group(1)]
|
||||||
return val
|
return variable_regex.sub(lambda match: str(variables[match.group(1)]), tpl)
|
||||||
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(
|
||||||
) -> Dict[str, Any]:
|
self, evt: Event, rule_vars: Dict[str, Any], extra_vars: Dict[str, str]
|
||||||
variables = {**{name: template.render(event=evt)
|
) -> Dict[str, Any]:
|
||||||
for name, template in chain(self.variables.items(), rule_vars.items())},
|
variables = extra_vars
|
||||||
**extra_vars}
|
for name, template in chain(rule_vars.items(), self.variables.items()):
|
||||||
if isinstance(self.content, JinjaTemplate):
|
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, JinjaStringTemplate):
|
||||||
raw_json = self.content.render(event=evt, **variables)
|
raw_json = self.content.render(event=evt, **variables)
|
||||||
return json.loads(raw_json)
|
return json.loads(raw_json)
|
||||||
content = copy.deepcopy(self.content)
|
content = copy.deepcopy(self.content)
|
||||||
|
@ -92,5 +107,9 @@ class Template:
|
||||||
key = str(key)
|
key = str(key)
|
||||||
data[self._replace_variables(key, variables)] = data.pop(key)
|
data[self._replace_variables(key, variables)] = data.pop(key)
|
||||||
else:
|
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
|
return content
|
||||||
|
|
15
samples/nitter.yaml
Normal file
15
samples/nitter.yaml
Normal file
|
@ -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
|
25
samples/random-reaction.yaml
Normal file
25
samples/random-reaction.yaml
Normal file
|
@ -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:
|
||||||
|
- 🤔
|
||||||
|
- 🧐
|
||||||
|
- 🤨
|
|
@ -10,16 +10,20 @@ templates:
|
||||||
default_flags:
|
default_flags:
|
||||||
- ignorecase
|
- ignorecase
|
||||||
|
|
||||||
|
antispam:
|
||||||
|
room:
|
||||||
|
max: 1
|
||||||
|
delay: 60
|
||||||
|
user:
|
||||||
|
max: 2
|
||||||
|
delay: 60
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
linux:
|
linux:
|
||||||
matches:
|
matches:
|
||||||
- linux
|
- linux
|
||||||
not_matches:
|
not_matches:
|
||||||
- pattern: gnu+linux
|
- gnu
|
||||||
raw: true
|
|
||||||
- pattern: gnu/linux
|
|
||||||
raw: true
|
|
||||||
- gnu plus linux
|
|
||||||
- kernel
|
- kernel
|
||||||
template: plaintext_notice
|
template: plaintext_notice
|
||||||
variables:
|
variables:
|
||||||
|
@ -143,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".
|
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.
|
The hypocrisy of calling these powers "rights" is starting to make the World "Intellectual Property" Organization embarrassed.
|
||||||
lamp:
|
lamp:
|
||||||
matches: [(\s|^)lamp]
|
matches: [(\s|^)lamp(\s|$)]
|
||||||
template: plaintext_notice
|
template: plaintext_notice
|
||||||
variables:
|
variables:
|
||||||
message: |
|
message: |
|
||||||
|
@ -176,7 +180,7 @@ rules:
|
||||||
message: |
|
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.
|
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:
|
pc:
|
||||||
matches: [(\s|^)pcs?]
|
matches: [(\s|^)pcs?(\s|$)]
|
||||||
template: plaintext_notice
|
template: plaintext_notice
|
||||||
variables:
|
variables:
|
||||||
message: |
|
message: |
|
||||||
|
@ -196,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.)
|
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."
|
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:
|
powerpoint:
|
||||||
matches: [powerpoint, \sppt]
|
matches: [powerpoint, \sppt(\s|$)]
|
||||||
template: plaintext_notice
|
template: plaintext_notice
|
||||||
variables:
|
variables:
|
||||||
message: |
|
message: |
|
||||||
|
@ -236,7 +240,7 @@ rules:
|
||||||
message: |
|
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.
|
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:
|
arch:
|
||||||
matches: [(^|\s)arch]
|
matches: (^|\s)arch($|\s)
|
||||||
template: plaintext_notice
|
template: plaintext_notice
|
||||||
variables:
|
variables:
|
||||||
message: |
|
message: |
|
||||||
|
@ -325,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.
|
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.
|
“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:
|
consume:
|
||||||
matches: [consume\s]
|
matches: [(^|\s)consume(\s|$)]
|
||||||
template: plaintext_notice
|
template: plaintext_notice
|
||||||
variables:
|
variables:
|
||||||
message: |
|
message: |
|
||||||
|
@ -375,7 +379,7 @@ rules:
|
||||||
message: |
|
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.
|
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:
|
google:
|
||||||
matches: [google\\s]
|
matches: [(^|\s)google(\s|$)]
|
||||||
template: plaintext_notice
|
template: plaintext_notice
|
||||||
variables:
|
variables:
|
||||||
message: |
|
message: |
|
||||||
|
@ -415,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.
|
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.
|
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.
|
In addition, a US judge, presiding over a trial for copyright infringement, recognized that “piracy” and “theft” are smear-words.
|
||||||
|
|
||||||
|
|
50
samples/thread.yaml
Normal file
50
samples/thread.yaml
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
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
|
||||||
|
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:
|
||||||
|
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
|
Loading…
Add table
Add a link
Reference in a new issue