Compare commits

..

1 commit

Author SHA1 Message Date
Tulir Asokan
fb214d8f0b Add experimental support for rules matching different event fields 2021-04-08 20:28:01 +03:00
15 changed files with 183 additions and 320 deletions

View file

@ -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

View file

@ -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"

View file

@ -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?$

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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"],
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,
)
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

View file

@ -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", [])),
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")),
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()
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)
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()
}
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

View file

@ -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)

View file

@ -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 SimplePattern(lambda val: s_pattern in val, ignorecase=ignorecase, field=field)
return None
return SimplePattern(matcher=func, pattern=s_pattern, ignorecase=ignorecase)

View file

@ -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:
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 variables[full_var_match.group(1)]
return variable_regex.sub(lambda match: str(variables[match.group(1)]), tpl)
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]
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

View file

@ -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

View file

@ -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:
- 🤔
- 🧐
- 🤨

View file

@ -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.

View file

@ -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