Compare commits
1 commit
master
...
custom-fie
Author | SHA1 | Date | |
---|---|---|---|
|
fb214d8f0b |
15 changed files with 183 additions and 320 deletions
24
.github/workflows/python-lint.yml
vendored
24
.github/workflows/python-lint.yml
vendored
|
@ -1,24 +0,0 @@
|
|||
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
|
|
@ -1,3 +1,27 @@
|
|||
include:
|
||||
- project: 'maubot/maubot'
|
||||
file: '/.gitlab-ci-plugin.yml'
|
||||
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"
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
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,11 +7,6 @@ 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.
|
||||
* [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
|
||||
|
@ -20,10 +15,7 @@ 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 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.
|
||||
Variables are parsed as jinja2 templates and get the maubot event object in `event`.
|
||||
|
||||
If the content is a string, it'll be parsed as a jinja2 template and the output
|
||||
will be parsed as JSON. The content jinja2 template will get `event` just like
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
maubot: 0.1.0
|
||||
id: xyz.maubot.reactbot
|
||||
version: 2.2.0
|
||||
version: 2.1.0
|
||||
license: AGPL-3.0-or-later
|
||||
modules:
|
||||
- reactbot
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
[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"]
|
|
@ -1,4 +1,4 @@
|
|||
# reminder - A maubot plugin that reacts to messages that match predefined rules.
|
||||
# reactbot - A maubot plugin that reacts to messages that match predefined rules.
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
|
@ -13,16 +13,17 @@
|
|||
#
|
||||
# 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/>.
|
||||
from typing import Dict, Tuple, Type
|
||||
from typing import Type, Tuple, Dict
|
||||
import time
|
||||
|
||||
from attr import dataclass
|
||||
|
||||
from maubot import MessageEvent, Plugin
|
||||
from maubot.handlers import event
|
||||
from mautrix.types import EventType, MessageType, RoomID, UserID
|
||||
from mautrix.types import EventType, MessageType, UserID, RoomID
|
||||
from mautrix.util.config import BaseProxyConfig
|
||||
|
||||
from maubot import Plugin, MessageEvent
|
||||
from maubot.handlers import event
|
||||
|
||||
from .config import Config, ConfigError
|
||||
|
||||
|
||||
|
@ -48,6 +49,7 @@ class ReactBot(Plugin):
|
|||
allowed_msgtypes: Tuple[MessageType, ...] = (MessageType.TEXT, MessageType.EMOTE)
|
||||
user_flood: Dict[UserID, FloodInfo]
|
||||
room_flood: Dict[RoomID, FloodInfo]
|
||||
config: Config
|
||||
|
||||
@classmethod
|
||||
def get_config_class(cls) -> Type[BaseProxyConfig]:
|
||||
|
@ -72,15 +74,12 @@ 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:
|
||||
|
@ -88,10 +87,8 @@ 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:
|
||||
|
@ -104,6 +101,6 @@ class ReactBot(Plugin):
|
|||
return
|
||||
try:
|
||||
await rule.execute(evt, match)
|
||||
except Exception:
|
||||
self.log.exception(f"Failed to execute {name} in {evt.room_id}")
|
||||
except Exception as e:
|
||||
self.log.warning(f"Failed to execute {name}: {e}")
|
||||
return
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# reminder - A maubot plugin that reacts to messages that match predefined rules.
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
# reactbot - A maubot plugin that reacts to messages that match predefined rules.
|
||||
# Copyright (C) 2021 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -13,18 +13,17 @@
|
|||
#
|
||||
# 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/>.
|
||||
from typing import Any, Dict, List, Union
|
||||
from typing import List, Union, Dict, Any
|
||||
import re
|
||||
|
||||
from jinja2 import Template as JinjaStringTemplate
|
||||
from jinja2.nativetypes import NativeTemplate as JinjaNativeTemplate
|
||||
from jinja2 import Template as JinjaTemplate
|
||||
|
||||
from mautrix.types import EventType
|
||||
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
|
||||
from mautrix.types import EventType
|
||||
|
||||
from .rule import RPattern, Rule
|
||||
from .simplepattern import SimplePattern
|
||||
from .simplepattern import SimplePattern, RegexPattern
|
||||
from .template import Template
|
||||
from .rule import Rule, RPattern
|
||||
|
||||
InputPattern = Union[str, Dict[str, str]]
|
||||
|
||||
|
@ -45,36 +44,30 @@ 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()
|
||||
}
|
||||
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),
|
||||
field=rule.get("field", ["content", "body"]))
|
||||
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
|
||||
|
||||
|
@ -87,33 +80,30 @@ class Config(BaseProxyConfig):
|
|||
def _compile(self, pattern: InputPattern) -> RPattern:
|
||||
flags = self.default_flags
|
||||
raw = None
|
||||
field = None
|
||||
if isinstance(pattern, dict):
|
||||
flags = self._get_flags(pattern["flags"]) if "flags" in pattern else flags
|
||||
raw = pattern.get("raw", False)
|
||||
field = pattern.get("field", None)
|
||||
pattern = pattern["pattern"]
|
||||
if raw is not False and (not flags & re.MULTILINE or raw is True):
|
||||
return SimplePattern.compile(pattern, flags, raw) or re.compile(pattern, flags=flags)
|
||||
return re.compile(pattern, flags=flags)
|
||||
return (SimplePattern.compile(pattern, flags, raw, field=field)
|
||||
or RegexPattern(re.compile(pattern, flags=flags), field=field))
|
||||
return RegexPattern(re.compile(pattern, flags=flags), field=field)
|
||||
|
||||
@staticmethod
|
||||
def _parse_variables(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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: (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], JinjaStringTemplate]:
|
||||
def _parse_content(content: Union[Dict[str, Any], str]) -> Union[Dict[str, Any], JinjaTemplate]:
|
||||
if not content:
|
||||
return {}
|
||||
elif isinstance(content, str):
|
||||
return JinjaStringTemplate(content)
|
||||
return JinjaTemplate(content)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# reminder - A maubot plugin that reacts to messages that match predefined rules.
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
# reactbot - A maubot plugin that reacts to messages that match predefined rules.
|
||||
# Copyright (C) 2021 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -13,53 +13,83 @@
|
|||
#
|
||||
# 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/>.
|
||||
from typing import Any, Dict, List, Match, Optional, Pattern, Set, Union
|
||||
from typing import Optional, Match, Dict, List, Set, Union, Any
|
||||
import json
|
||||
|
||||
from attr import dataclass
|
||||
|
||||
from mautrix.types import RoomID, EventType, Serializable
|
||||
|
||||
from maubot import MessageEvent
|
||||
from mautrix.types import EventType, RoomID
|
||||
|
||||
from .simplepattern import SimplePattern
|
||||
from .template import OmitValue, Template
|
||||
from .template import Template
|
||||
from .simplepattern import SimplePattern, RegexPattern
|
||||
|
||||
RPattern = Union[Pattern, SimplePattern]
|
||||
RPattern = Union[RegexPattern, SimplePattern]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
field: List[str]
|
||||
rooms: Set[RoomID]
|
||||
not_rooms: Set[RoomID]
|
||||
matches: List[RPattern]
|
||||
not_matches: List[RPattern]
|
||||
|
||||
template: Template
|
||||
type: Optional[EventType]
|
||||
room_id: Optional[RoomID]
|
||||
state_event: bool
|
||||
variables: Dict[str, Any]
|
||||
|
||||
def _check_not_match(self, body: str) -> bool:
|
||||
def _check_not_match(self, evt: MessageEvent, data: str) -> bool:
|
||||
for pattern in self.not_matches:
|
||||
if pattern.search(body):
|
||||
pattern_data = self._get_value(evt, pattern.field) if pattern.field else data
|
||||
if pattern.search(pattern_data):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _get_value(evt: MessageEvent, field: List[str]) -> str:
|
||||
data = evt
|
||||
for part in field:
|
||||
try:
|
||||
data = evt[part]
|
||||
except KeyError:
|
||||
return ""
|
||||
if isinstance(data, (str, int)):
|
||||
return str(data)
|
||||
elif isinstance(data, Serializable):
|
||||
return json.dumps(data.serialize())
|
||||
elif isinstance(data, (dict, list)):
|
||||
return json.dumps(data)
|
||||
else:
|
||||
return str(data)
|
||||
|
||||
def match(self, evt: MessageEvent) -> Optional[Match]:
|
||||
if len(self.rooms) > 0 and evt.room_id not in self.rooms:
|
||||
return None
|
||||
elif evt.room_id in self.not_rooms:
|
||||
return None
|
||||
data = self._get_value(evt, self.field)
|
||||
for pattern in self.matches:
|
||||
match = pattern.search(evt.content.body)
|
||||
pattern_data = self._get_value(evt, pattern.field) if pattern.field else data
|
||||
match = pattern.search(pattern_data)
|
||||
if match:
|
||||
if self._check_not_match(evt.content.body):
|
||||
if self._check_not_match(evt, data):
|
||||
return None
|
||||
return match
|
||||
return None
|
||||
|
||||
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): val for i, val in enumerate(match.groups())},
|
||||
**match.groupdict(),
|
||||
}
|
||||
room_id = self.room_id or evt.room_id
|
||||
event_type = self.type or self.template.type
|
||||
content = self.template.execute(evt=evt, rule_vars=self.variables, extra_vars=extra_vars)
|
||||
await evt.client.send_message_event(evt.room_id, self.type or self.template.type, content)
|
||||
if self.state_event:
|
||||
await evt.client.send_state_event(room_id, event_type, content)
|
||||
else:
|
||||
await evt.client.send_message_event(room_id, event_type, content)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# reminder - A maubot plugin that reacts to messages that match predefined rules.
|
||||
# Copyright (C) 2021 Tulir Asokan
|
||||
# reactbot - A maubot plugin that reacts to messages that match predefined rules.
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -13,82 +13,71 @@
|
|||
#
|
||||
# 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/>.
|
||||
from typing import Callable, Dict, List, NamedTuple, Optional
|
||||
from typing import Callable, List, Dict, Optional, Pattern, Match
|
||||
import re
|
||||
|
||||
|
||||
class SimpleMatch(NamedTuple):
|
||||
value: str
|
||||
class BlankMatch:
|
||||
@staticmethod
|
||||
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]:
|
||||
@staticmethod
|
||||
def groupdict() -> Dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def matcher_equals(val: str, pattern: str) -> bool:
|
||||
return val == pattern
|
||||
class RegexPattern:
|
||||
pattern: Pattern
|
||||
field: Optional[List[str]]
|
||||
|
||||
def __init__(self, pattern: Pattern, field: Optional[List[str]] = None) -> None:
|
||||
self.pattern = pattern
|
||||
self.field = field
|
||||
|
||||
def 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]
|
||||
def search(self, val: str) -> Match:
|
||||
return self.pattern.search(val)
|
||||
|
||||
|
||||
class SimplePattern:
|
||||
matcher: SimpleMatcherFunc
|
||||
pattern: str
|
||||
_ptm = BlankMatch()
|
||||
|
||||
matcher: Callable[[str], bool]
|
||||
field: Optional[List[str]]
|
||||
ignorecase: bool
|
||||
|
||||
def __init__(self, matcher: SimpleMatcherFunc, pattern: str, ignorecase: bool) -> None:
|
||||
def __init__(self, matcher: Callable[[str], bool], ignorecase: bool,
|
||||
field: Optional[List[str]] = None) -> None:
|
||||
self.matcher = matcher
|
||||
self.pattern = pattern
|
||||
self.ignorecase = ignorecase
|
||||
self.field = field
|
||||
|
||||
def search(self, val: str) -> SimpleMatch:
|
||||
def search(self, val: str) -> BlankMatch:
|
||||
if self.ignorecase:
|
||||
val = val.lower()
|
||||
if self.matcher(val, self.pattern):
|
||||
return SimpleMatch(self.pattern)
|
||||
if self.matcher(val):
|
||||
return self._ptm
|
||||
|
||||
@staticmethod
|
||||
def compile(
|
||||
pattern: str, flags: re.RegexFlag = re.RegexFlag(0), force_raw: bool = False
|
||||
) -> Optional["SimplePattern"]:
|
||||
def compile(pattern: str, flags: re.RegexFlag = re.RegexFlag(0), force_raw: bool = False,
|
||||
field: Optional[List[str]] = None) -> Optional['SimplePattern']:
|
||||
ignorecase = flags == re.IGNORECASE
|
||||
s_pattern = pattern.lower() if ignorecase else pattern
|
||||
esc = ""
|
||||
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:]}"):
|
||||
return SimplePattern(lambda val: val == s_pattern, ignorecase=ignorecase, field=field)
|
||||
elif first == '^' and (force_raw or esc == f"\\^{pattern[1:]}"):
|
||||
s_pattern = s_pattern[1:]
|
||||
func = matcher_startswith
|
||||
elif last == "$" and (force_raw or esc == f"{pattern[:-1]}\\$"):
|
||||
return SimplePattern(lambda val: val.startswith(s_pattern), ignorecase=ignorecase,
|
||||
field=field)
|
||||
elif last == '$' and (force_raw or esc == f"{pattern[:-1]}\\$"):
|
||||
s_pattern = s_pattern[:-1]
|
||||
func = matcher_endswith
|
||||
return SimplePattern(lambda val: val.endswith(s_pattern), ignorecase=ignorecase,
|
||||
field=field)
|
||||
elif force_raw or esc == pattern:
|
||||
func = matcher_contains
|
||||
else:
|
||||
# Not a simple pattern
|
||||
return None
|
||||
return SimplePattern(matcher=func, pattern=s_pattern, ignorecase=ignorecase)
|
||||
return SimplePattern(lambda val: s_pattern in val, ignorecase=ignorecase, field=field)
|
||||
return None
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# reminder - A maubot plugin that reacts to messages that match predefined rules.
|
||||
# Copyright (C) 2021 Tulir Asokan
|
||||
# reactbot - A maubot plugin that reacts to messages that match predefined rules.
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -13,17 +13,16 @@
|
|||
#
|
||||
# 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/>.
|
||||
from typing import Any, Dict, List, Tuple, Union
|
||||
from typing import Union, Dict, List, Tuple, Any
|
||||
from itertools import chain
|
||||
import copy
|
||||
import json
|
||||
import copy
|
||||
import re
|
||||
|
||||
from attr import dataclass
|
||||
from jinja2 import Template as JinjaStringTemplate
|
||||
from jinja2.nativetypes import Template as JinjaNativeTemplate
|
||||
from jinja2 import Template as JinjaTemplate
|
||||
|
||||
from mautrix.types import Event, EventType
|
||||
from mautrix.types import EventType, Event
|
||||
|
||||
|
||||
class Key(str):
|
||||
|
@ -31,11 +30,6 @@ class Key(str):
|
|||
|
||||
|
||||
variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}")
|
||||
OmitValue = object()
|
||||
|
||||
global_vars = {
|
||||
"omit": OmitValue,
|
||||
}
|
||||
|
||||
Index = Union[str, int, Key]
|
||||
|
||||
|
@ -44,11 +38,11 @@ Index = Union[str, int, Key]
|
|||
class Template:
|
||||
type: EventType
|
||||
variables: Dict[str, Any]
|
||||
content: Union[Dict[str, Any], JinjaStringTemplate]
|
||||
content: Union[Dict[str, Any], JinjaTemplate]
|
||||
|
||||
_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
|
||||
|
@ -74,29 +68,23 @@ class Template:
|
|||
|
||||
@staticmethod
|
||||
def _replace_variables(tpl: str, variables: Dict[str, Any]) -> str:
|
||||
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)
|
||||
for match in variable_regex.finditer(tpl):
|
||||
val = variables[match.group(1)]
|
||||
if match.start() == 0 and match.end() == len(tpl):
|
||||
# Whole field is a single variable, just return the value to allow non-string types.
|
||||
return val
|
||||
tpl = tpl[:match.start()] + val + tpl[match.end():]
|
||||
return tpl
|
||||
|
||||
def execute(
|
||||
self, evt: Event, rule_vars: Dict[str, 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
|
||||
):
|
||||
rendered_var = str(rendered_var)
|
||||
variables[name] = rendered_var
|
||||
if isinstance(template, JinjaTemplate):
|
||||
variables[name] = template.render(event=evt, variables=variables)
|
||||
else:
|
||||
variables[name] = template
|
||||
if isinstance(self.content, JinjaStringTemplate):
|
||||
if isinstance(self.content, JinjaTemplate):
|
||||
raw_json = self.content.render(event=evt, **variables)
|
||||
return json.loads(raw_json)
|
||||
content = copy.deepcopy(self.content)
|
||||
|
@ -107,9 +95,5 @@ class Template:
|
|||
key = str(key)
|
||||
data[self._replace_variables(key, variables)] = data.pop(key)
|
||||
else:
|
||||
replaced_data = self._replace_variables(data[key], variables)
|
||||
if replaced_data is OmitValue:
|
||||
del data[key]
|
||||
else:
|
||||
data[key] = replaced_data
|
||||
data[key] = self._replace_variables(data[key], variables)
|
||||
return content
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
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
|
|
@ -1,25 +0,0 @@
|
|||
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:
|
||||
- 🤔
|
||||
- 🧐
|
||||
- 🤨
|
|
@ -419,3 +419,4 @@ 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.
|
||||
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
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…
Reference in a new issue