diff --git a/README.md b/README.md index d491d0f..57410ea 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ # reactbot A simple [maubot](https://github.com/maubot/maubot) that responds to messages that match predefined rules. + +## Config format +### Templates +Templates contain the actual event type and content to be sent. +* `type` - The Matrix event type to send +* `content` - The event content. Either an object or jinja2 template that produces JSON. +* `variables` - A key-value map of variables. + +Variables are parsed as jinja2 templates and get the maubot event object in `event`. + +If the content is a string, it'll be parsed as a jinja2 template and the output +will be parsed as JSON. The content jinja2 template will get `event` just like +variable templates, but it will also get all of the variables. + +If the content is an object, that object is what will be sent as the content. +The object can contain variables using a custom syntax: All instances of +`$${variablename}` will be replaced with the value matching `variablename`. +This works in object keys and values and list items. If a key/value/item only +consists of a variable insertion, the variable may be of any type. If there's +something else than the variable, the variable will be concatenated using `+`, +which means it should be a string. + +### Default flags +Default regex flags. Most Python regex flags are available. +See [docs](https://docs.python.org/3/library/re.html#re.A). + +Most relevant flags: +* `i` / `ignorecase` - Case-insensitive matching. +* `s` / `dotall` - Make `.` match any character at all, including newline. +* `x` / `verbose` - Ignore comments and whitespace in regex. +* `m` / `multiline` - When specified, `^` and `$` match the start and end of + line respectively instead of start and end of whole string. + +### Rules +Rules have five fields. Only `matches` and `template` are required. +* `rooms` - The list of rooms where the rule should apply. + If empty, the rule will apply to all rooms the bot is in. +* `matches` - The regex or list of regexes to match. +* `template` - The name of the template to use. +* `variables` - A key-value map of variables to extend or override template variables. + Like with template variables, the values are parsed as Jinja2 templates. + +The regex(es) in `matches` can either be simple strings containing the pattern, +or objects containing additional info: +* `pattern` - The regex to match. +* `flags` - Regex flags (replaces default flags). +* `raw` - Whether or not the regex should be forced to be raw. + +If `raw` is `true` OR the pattern contains no special regex characters other +than `^` at the start and/or `$` at the end, the pattern will be considered +"raw". Raw patterns don't use regex, but instead use faster string operators +(equality, starts/endwith, contains). Patterns with the `multiline` flag will +never be converted into raw patterns implicitly. diff --git a/reactbot.py b/reactbot.py index 41f7de9..0b75b4e 100644 --- a/reactbot.py +++ b/reactbot.py @@ -16,6 +16,7 @@ from typing import (NewType, Optional, Callable, Pattern, Match, Union, Dict, List, Tuple, Set, Type, Any) from itertools import chain +import json import copy import re @@ -69,6 +70,7 @@ class SimplePattern: RMatch = Union[Match, BlankMatch] RPattern = Union[Pattern, SimplePattern] +InputPattern = Union[str, Dict[str, str]] Index = NewType("Index", Union[str, int, Key]) @@ -79,7 +81,7 @@ variable_regex = re.compile(r"\$\${([0-9A-Za-z-_]+)}") class Template: type: EventType variables: Dict[str, JinjaTemplate] - content: Dict[str, Any] + content: Union[Dict[str, Any], JinjaTemplate] _variable_locations: List[Tuple[Index, ...]] = None @@ -122,6 +124,9 @@ class Template: variables = {**{name: template.render(event=evt) for name, template in chain(self.variables.items(), rule_vars.items())}, **extra_vars} + if isinstance(self.content, JinjaTemplate): + raw_json = self.content.render(event=evt, **variables) + return json.loads(raw_json) content = copy.deepcopy(self.content) for path in self._variable_locations: data: Dict[str, Any] = self._recurse(content, path[:-1]) @@ -187,16 +192,26 @@ class ReactBot(Plugin): return {name: JinjaTemplate(var_tpl) for name, var_tpl in data.get("variables", {}).items()} + def _parse_content(self, content: Union[Dict[str, Any], str] + ) -> Union[Dict[str, Any], JinjaTemplate]: + if not content: + return {} + elif isinstance(content, str): + return JinjaTemplate(content) + return content + def _make_template(self, name: str, tpl: Dict[str, Any]) -> Template: try: return Template(type=EventType.find(tpl.get("type", "m.room.message")), variables=self._parse_variables(tpl), - content=tpl.get("content", {})).init() + content=self._parse_content(tpl.get("content", None))).init() except Exception as e: raise ConfigError(f"Failed to load {name}") from e - def _get_flags(self, flags: str) -> re.RegexFlag: - output = self.default_flags + def _get_flags(self, flags: Union[str, List[str]]) -> re.RegexFlag: + if not flags: + return self.default_flags + output = re.RegexFlag(0) for flag in flags: flag = flag.lower() if flag == "i" or flag == "ignorecase": @@ -215,14 +230,14 @@ class ReactBot(Plugin): output |= re.ASCII return output - def _compile(self, pattern: str) -> RPattern: + def _compile(self, pattern: InputPattern) -> RPattern: flags = self.default_flags - raw = False + raw = None if isinstance(pattern, dict): flags = self._get_flags(pattern.get("flags", "")) raw = pattern.get("raw", False) pattern = pattern["pattern"] - if not flags or flags == re.IGNORECASE: + if raw is not False and (not flags & re.MULTILINE or raw is True): ignorecase = flags == re.IGNORECASE s_pattern = pattern.lower() if ignorecase else pattern esc = "" @@ -242,8 +257,11 @@ class ReactBot(Plugin): return SimplePattern(lambda val: s_pattern in val, ignorecase=ignorecase) return re.compile(pattern, flags=flags) - def _compile_all(self, patterns: List[str]) -> List[RPattern]: - return [self._compile(pattern) for pattern in patterns] + def _compile_all(self, patterns: Union[InputPattern, List[InputPattern]]) -> List[RPattern]: + if isinstance(patterns, list): + return [self._compile(pattern) for pattern in patterns] + else: + return [self._compile(patterns)] def _make_rule(self, name: str, rule: Dict[str, Any]) -> Rule: try: