Compare commits
10 Commits
Author | SHA1 | Date |
---|---|---|
Christopher Arndt | 8315fee398 | |
Christopher Arndt | 37ba136b5c | |
Christopher Arndt | 70a70531be | |
Christopher Arndt | d6e24aa359 | |
Christopher Arndt | 92d85d31bf | |
Christopher Arndt | 1e374c61e6 | |
Christopher Arndt | 5c5cd14030 | |
Christopher Arndt | db7edd5dae | |
Christopher Arndt | da31628d86 | |
Christopher Arndt | d3579b6e0f |
|
@ -1,5 +1,5 @@
|
||||||
FROM python:3.11-alpine
|
FROM python:3.11-alpine
|
||||||
RUN python3 -m pip --no-cache-dir install markdown matrix-nio
|
RUN python3 -m pip --no-cache-dir install bleach jinja2 markdown matrix-nio
|
||||||
ADD matrixchat-notify.py /bin/
|
ADD matrixchat-notify.py /bin/
|
||||||
ADD matrixchat-notify-config.json /etc/
|
ADD matrixchat-notify-config.json /etc/
|
||||||
RUN chmod +x /bin/matrixchat-notify.py
|
RUN chmod +x /bin/matrixchat-notify.py
|
||||||
|
|
108
README.md
108
README.md
|
@ -1,7 +1,14 @@
|
||||||
# drone-matrixchat-notify
|
# drone-matrixchat-notify
|
||||||
|
|
||||||
A [drone.io] [plugin] to send notifications to Matrix chat rooms from
|
[![MIT License](https://img.shields.io/github/license/SpotlightKid/drone-matrixchat-notify?label=License)](https://github.com/SpotlightKid/drone-matrixchat-notify/blob/master/LICENSE)
|
||||||
CI pipeline steps.
|
[![GitHub tag (with filter)](https://img.shields.io/github/v/tag/SpotlightKid/drone-matrixchat-notify?filter=v*.*.*&logo=github&label=Latest%20version)](https://github.com/SpotlightKid/drone-matrixchat-notify/tags)
|
||||||
|
[![Docker image version](https://img.shields.io/docker/v/spotlightkid/drone-matrixchat-notify?logo=docker&label=Docker+image)](https://hub.docker.com/r/spotlightkid/drone-matrixchat-notify)
|
||||||
|
[![GitHub stars](https://img.shields.io/github/stars/SpotlightKid/drone-matrixchat-notify?logo=github&label=GitHub)](https://github.com/SpotlightKid/drone-matrixchat-notify)
|
||||||
|
[![GitLab stars](https://img.shields.io/gitlab/stars/SpotlightKid%2Fdrone-matrixchat-notify?logo=gitlab&label=GitLab)](https://gitlab.com/SpotlightKid/drone-matrixchat-notify)
|
||||||
|
[![GitHub issues](https://img.shields.io/github/issues/SpotlightKid/drone-matrixchat-notify?logo=github&label=Issues)](https://github.com/SpotlightKid/drone-matrixchat-notify/issues)
|
||||||
|
|
||||||
|
A [drone.io] [plugin] to send notifications to Matrix chat rooms from CI
|
||||||
|
pipeline steps. Supports *Jinja* message templates and *Markdown* rendering.
|
||||||
|
|
||||||
Example pipeline configuration:
|
Example pipeline configuration:
|
||||||
|
|
||||||
|
@ -24,16 +31,54 @@ steps:
|
||||||
userid: '@drone-bot@matrix.org'
|
userid: '@drone-bot@matrix.org'
|
||||||
password:
|
password:
|
||||||
from_secret: drone-bot-pw
|
from_secret: drone-bot-pw
|
||||||
template: '${DRONE_REPO} ${DRONE_COMMIT_SHA} ${DRONE_BUILD_STATUS}'
|
markdown: 'yes'
|
||||||
|
template: |
|
||||||
|
`${DRONE_REPO}` build #${DRONE_BUILD_NUMBER} status: **${DRONE_BUILD_STATUS}**
|
||||||
|
|
||||||
|
${DRONE_PULL_REQUEST_TITLE}](${DRONE_COMMIT_LINK})
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration settings
|
## Configuration settings
|
||||||
|
|
||||||
* `accesstoken`
|
### Required
|
||||||
|
|
||||||
|
* `roomid` *(required)*
|
||||||
|
|
||||||
|
ID of matrix chat room to send messages to (ID, not alias).
|
||||||
|
|
||||||
|
* `userid` *(required)*
|
||||||
|
|
||||||
|
Matrix user ID on homeserver to send message as (ID, not username).
|
||||||
|
|
||||||
|
* `password` *(required)*
|
||||||
|
|
||||||
|
Password to use for authenticating the user set with `userid`. Either a
|
||||||
|
password or an access token is required.
|
||||||
|
|
||||||
|
* `accesstoken` *(required)*
|
||||||
|
|
||||||
Access token to use for authentication instead of `password`. Either an
|
Access token to use for authentication instead of `password`. Either an
|
||||||
access token or a password is required.
|
access token or a password is required.
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
|
||||||
|
* `allowed_attrs` *(default:* [`DEFAULT_ALLOWED_ATTRS`]*)*
|
||||||
|
|
||||||
|
List or string with comma-separated list of HTML attribute names or
|
||||||
|
dict mapping tag names to lists of attributes names.
|
||||||
|
|
||||||
|
See the bleach documentation on [allowed attributes] for more information.
|
||||||
|
|
||||||
|
* `allowed_tags` *(default:* [`DEFAULT_ALLOWED_TAGS`]*)*
|
||||||
|
|
||||||
|
List or set or string with comma-separated list of HTML tag names. HTML
|
||||||
|
tags not included will be stripped from the HTML output generated by
|
||||||
|
rendering a Markdown message template.
|
||||||
|
|
||||||
|
Note that the default list does not include any tags, which allow to load
|
||||||
|
external resources when the generated HTML is displayed, notably `img`
|
||||||
|
is not included.
|
||||||
|
|
||||||
* `deviceid`
|
* `deviceid`
|
||||||
|
|
||||||
Device ID to send with access token.
|
Device ID to send with access token.
|
||||||
|
@ -46,34 +91,59 @@ steps:
|
||||||
|
|
||||||
The Matrix homeserver URL.
|
The Matrix homeserver URL.
|
||||||
|
|
||||||
|
* `jinja`
|
||||||
|
|
||||||
|
If set to `yes`, `y`, `true`, `t`, `on` or `1`, the message template is
|
||||||
|
rendered with the [Jinja] templating engine (instead of performing simple
|
||||||
|
placeholder substitution). The template context is controlled by the
|
||||||
|
`pass_environment` setting, same as with non-Jinja templates, but
|
||||||
|
placeholders use a different syntax (example: `{{DRONE_REPO}}`), so the
|
||||||
|
`template` setting should be changed to be a valid Jinja template string
|
||||||
|
when this is enabled.
|
||||||
|
|
||||||
|
Using this feature requires the `jinja2` Python module to be available
|
||||||
|
(it is installed by default in the plugin's docker image).
|
||||||
|
|
||||||
* `markdown`
|
* `markdown`
|
||||||
|
|
||||||
If set to `yes`, `y`, `true` or `on`, the message resulting from template
|
If set to `yes`, `y`, `true`, `t`, `on` or `1`, the message resulting from
|
||||||
substtution is considered to be in Markdown format and will be rendered to
|
template substtution is considered to be in Markdown format and will be
|
||||||
HTML and sent as a formatted message with `org.matrix.custom.html` format.
|
rendered to HTML and sent as a formatted message with the format set to
|
||||||
|
`org.matrix.custom.html`.
|
||||||
|
|
||||||
* `password`
|
Using this feature requires the `markdown` and `bleach` Python modules to
|
||||||
|
be available (they are installed by default in the plugin's docker image).
|
||||||
|
|
||||||
Password to use for authenticating the user set with `userid`. Either a
|
* `markdown_extensions` *(default:* `admonition, extra, sane_lists, smarty`)
|
||||||
password or an access token is required.
|
|
||||||
|
|
||||||
* `roomid` *(required)*
|
Comma-separated list of enabled Markdown extensions. See this
|
||||||
|
[list of extensions] for valid extension names. Including an invalid
|
||||||
|
extension name in this list will disable Markdown rendering.
|
||||||
|
|
||||||
ID of matrix chat room to send messages to (ID, not alias).
|
* `pass_environment` *(default:* `DRONE_*`*)*
|
||||||
|
|
||||||
|
Comma-separated white-list of environment variable names or name patterns.
|
||||||
|
Patterns are shell-glob style patterns and case-sensitive.
|
||||||
|
|
||||||
|
Only environment variables matching any of the given names or patterns will
|
||||||
|
be available as valid placeholders in the message template.
|
||||||
|
|
||||||
* `template` *(default:* `${DRONE_BUILD_STATUS}`*)*
|
* `template` *(default:* `${DRONE_BUILD_STATUS}`*)*
|
||||||
|
|
||||||
The message template. Valid placeholders of the form `${PLACEHOLDER}` will
|
The message template. Valid placeholders (example: `${DRONE_REPO}`) will be
|
||||||
be substituted with the values of the matching environment variables.
|
substituted with the values of the matching environment variables (subject
|
||||||
|
to filtering according to the `pass_environment` setting).
|
||||||
|
|
||||||
See this [reference] for environment variables available drone.io in CI
|
See this [reference] for environment variables available in drone.io CI
|
||||||
pipelines.
|
pipelines.
|
||||||
|
|
||||||
* `userid` *(required)*
|
|
||||||
|
|
||||||
ID of user on homeserver to send message as (ID, not username).
|
|
||||||
|
|
||||||
|
|
||||||
|
[`DEFAULT_ALLOWED_ATTRS`]: https://github.com/SpotlightKid/drone-matrixchat-notify/blob/master/matrixchat-notify.py#L28
|
||||||
|
[`DEFAULT_ALLOWED_TAGS`]: https://github.com/SpotlightKid/drone-matrixchat-notify/blob/master/matrixchat-notify.py#L35
|
||||||
|
[allowed attributes]: https://bleach.readthedocs.io/en/latest/clean.html#allowed-attributes-attributes
|
||||||
[drone.io]: https://drone.io/
|
[drone.io]: https://drone.io/
|
||||||
|
[jinja]: https://jinja.palletsprojects.com/
|
||||||
|
[list of extensions]: https://python-markdown.github.io/extensions/
|
||||||
[plugin]: https://docs.drone.io/plugins/overview/
|
[plugin]: https://docs.drone.io/plugins/overview/
|
||||||
[reference]: https://docs.drone.io/pipeline/environment/reference/
|
[reference]: https://docs.drone.io/pipeline/environment/reference/
|
||||||
|
|
|
@ -3,13 +3,16 @@
|
||||||
|
|
||||||
Requires:
|
Requires:
|
||||||
|
|
||||||
* <https://github.com/poljar/matrix-nio>
|
* <https://pypi.org/project/matrix-nio>
|
||||||
|
* Optional: <https://pypi.org/project/bleach/>
|
||||||
|
* Optional: <https://pypi.org/project/Jinja2/>
|
||||||
* Optional: <https://pypi.org/project/markdown/>
|
* Optional: <https://pypi.org/project/markdown/>
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import fnmatch
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
@ -22,14 +25,59 @@ from nio import AsyncClient, LoginResponse
|
||||||
|
|
||||||
PROG = "matrixchat-notify"
|
PROG = "matrixchat-notify"
|
||||||
CONFIG_FILENAME = f"{PROG}-config.json"
|
CONFIG_FILENAME = f"{PROG}-config.json"
|
||||||
DEFAULT_TEMPLATE = "${DRONE_BUILD_STATUS}"
|
DEFAULT_ALLOWED_ATTRS = {
|
||||||
|
"*": ["class"],
|
||||||
|
"a": ["href", "title"],
|
||||||
|
"abbr": ["title"],
|
||||||
|
"acronym": ["title"],
|
||||||
|
"img": ["alt", "src"],
|
||||||
|
}
|
||||||
|
DEFAULT_ALLOWED_TAGS = {
|
||||||
|
"a",
|
||||||
|
"abbr",
|
||||||
|
"acronym",
|
||||||
|
"b",
|
||||||
|
"blockquote",
|
||||||
|
"code",
|
||||||
|
"dd",
|
||||||
|
"div",
|
||||||
|
"dl",
|
||||||
|
"dt",
|
||||||
|
"em",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"i",
|
||||||
|
"li",
|
||||||
|
"ol",
|
||||||
|
"p",
|
||||||
|
"span",
|
||||||
|
"strong",
|
||||||
|
"table",
|
||||||
|
"td",
|
||||||
|
"th",
|
||||||
|
"thead",
|
||||||
|
"tr",
|
||||||
|
"ul",
|
||||||
|
}
|
||||||
DEFAULT_HOMESERVER = "https://matrix.org"
|
DEFAULT_HOMESERVER = "https://matrix.org"
|
||||||
|
DEFAULT_MARKDOWN_EXTENSIONS = "admonition, extra, sane_lists, smarty"
|
||||||
|
DEFAULT_PASS_ENVIRONMENT = ["DRONE_*"]
|
||||||
|
DEFAULT_TEMPLATE = "${DRONE_BUILD_STATUS}"
|
||||||
SETTINGS_KEYS = (
|
SETTINGS_KEYS = (
|
||||||
|
"allowed_tags",
|
||||||
|
"allowed_attrs",
|
||||||
"accesstoken",
|
"accesstoken",
|
||||||
"deviceid",
|
"deviceid",
|
||||||
"devicename",
|
"devicename",
|
||||||
"homeserver",
|
"homeserver",
|
||||||
|
"jinja",
|
||||||
"markdown",
|
"markdown",
|
||||||
|
"markdown_extensions",
|
||||||
|
"pass_environment",
|
||||||
"password",
|
"password",
|
||||||
"roomid",
|
"roomid",
|
||||||
"template",
|
"template",
|
||||||
|
@ -40,7 +88,7 @@ log = logging.getLogger(PROG)
|
||||||
|
|
||||||
def tobool(s):
|
def tobool(s):
|
||||||
try:
|
try:
|
||||||
return strtobool(s)
|
return strtobool(str(s))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -110,6 +158,78 @@ async def send_notification(config, message):
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_template_context(config):
|
||||||
|
pass_environment = config.get("pass_environment", [])
|
||||||
|
|
||||||
|
if not isinstance(pass_environment, list):
|
||||||
|
pass_environment = [pass_environment]
|
||||||
|
|
||||||
|
patterns = []
|
||||||
|
for value in pass_environment:
|
||||||
|
# expand any comma-separated names/patterns
|
||||||
|
if "," in value:
|
||||||
|
patterns.extend([p.strip() for p in value.split(",") if p.strip()])
|
||||||
|
else:
|
||||||
|
patterns.append(value)
|
||||||
|
|
||||||
|
env_names = tuple(os.environ)
|
||||||
|
filtered_names = set()
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
filtered_names.update(fnmatch.filter(env_names, pattern))
|
||||||
|
|
||||||
|
return {name: os.environ[name] for name in tuple(filtered_names)}
|
||||||
|
|
||||||
|
|
||||||
|
def render_message(config):
|
||||||
|
context = get_template_context(config)
|
||||||
|
template = config.get("template", DEFAULT_TEMPLATE)
|
||||||
|
|
||||||
|
if tobool(config.get("jinja")):
|
||||||
|
try:
|
||||||
|
from jinja2.sandbox import SandboxedEnvironment
|
||||||
|
|
||||||
|
env = SandboxedEnvironment()
|
||||||
|
return env.from_string(template).render(context)
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Could not render Jinja2 template: %s", exc)
|
||||||
|
return template
|
||||||
|
else:
|
||||||
|
return Template(template).safe_substitute(context)
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(message, config):
|
||||||
|
import bleach
|
||||||
|
import markdown
|
||||||
|
|
||||||
|
allowed_attrs = config.get("allowed_attrs", DEFAULT_ALLOWED_ATTRS)
|
||||||
|
allowed_tags = config.get("allowed_tags", DEFAULT_ALLOWED_TAGS)
|
||||||
|
extensions = config.get("markdown_extensions", DEFAULT_MARKDOWN_EXTENSIONS)
|
||||||
|
|
||||||
|
if isinstance(allowed_attrs, str):
|
||||||
|
allowed_attrs = [attr.strip() for attr in allowed_attrs.split(",") if attr.strip()]
|
||||||
|
|
||||||
|
if isinstance(allowed_tags, str):
|
||||||
|
allowed_tags = [tag.strip() for tag in allowed_tags.split(",") if tag.strip()]
|
||||||
|
|
||||||
|
if isinstance(extensions, str):
|
||||||
|
extensions = [ext.strip() for ext in extensions.split(",") if ext.strip()]
|
||||||
|
|
||||||
|
try:
|
||||||
|
md = markdown.Markdown(extensions=extensions)
|
||||||
|
except (AttributeError, ImportError, TypeError) as exc:
|
||||||
|
log.error("Could not instantiate Markdown formatter: %s", exc)
|
||||||
|
return message
|
||||||
|
|
||||||
|
return {
|
||||||
|
"formatted_body": bleach.clean(
|
||||||
|
md.convert(message), tags=allowed_tags, attributes=allowed_attrs, strip=True
|
||||||
|
),
|
||||||
|
"body": message,
|
||||||
|
"format": "org.matrix.custom.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def main(args=None):
|
def main(args=None):
|
||||||
ap = argparse.ArgumentParser(prog=PROG, description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(prog=PROG, description=__doc__.splitlines()[0])
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
|
@ -125,6 +245,17 @@ def main(args=None):
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Don't send notification message, only print it.",
|
help="Don't send notification message, only print it.",
|
||||||
)
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"-e",
|
||||||
|
"--pass-environment",
|
||||||
|
nargs="*",
|
||||||
|
help=(
|
||||||
|
"Comma-separated white-list of environment variable names or name patterns. Only "
|
||||||
|
"environment variables matching any of the given names or patterns will be available "
|
||||||
|
"as valid placeholders in the message template. "
|
||||||
|
"Accepts shell glob patterns and may be passed more than once (default: 'DRONE_*')."
|
||||||
|
),
|
||||||
|
)
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"-m",
|
"-m",
|
||||||
"--render-markdown",
|
"--render-markdown",
|
||||||
|
@ -141,9 +272,7 @@ def main(args=None):
|
||||||
args = ap.parse_args(args)
|
args = ap.parse_args(args)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(
|
level="DEBUG" if args.verbose else os.environ.get("PLUGIN_LOG_LEVEL", "INFO").upper(),
|
||||||
logging, "DEBUG" if args.verbose else os.environ.get("PLUGIN_LOG_LEVEL", "INFO")
|
|
||||||
),
|
|
||||||
format=os.environ.get("PLUGIN_LOG_FORMAT", "%(levelname)s: %(message)s"),
|
format=os.environ.get("PLUGIN_LOG_FORMAT", "%(levelname)s: %(message)s"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -152,23 +281,22 @@ def main(args=None):
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Could not parse configuration: {exc}"
|
return f"Could not parse configuration: {exc}"
|
||||||
|
|
||||||
template = config.get("template", DEFAULT_TEMPLATE)
|
if args.pass_environment is not None:
|
||||||
message = Template(template).safe_substitute(os.environ)
|
# Security feature: if any environment names/patterns are passed via -e|--pass-environment
|
||||||
|
# options, they completely replace any given via the config or environment.
|
||||||
|
config["pass_environment"] = args.pass_environment
|
||||||
|
|
||||||
|
if "pass_environment" not in config:
|
||||||
|
config["pass_environment"] = DEFAULT_PASS_ENVIRONMENT
|
||||||
|
|
||||||
|
message = render_message(config)
|
||||||
|
|
||||||
if tobool(config.get("markdown")) or args.render_markdown:
|
if tobool(config.get("markdown")) or args.render_markdown:
|
||||||
log.debug("Rendering markdown message to HTML.")
|
log.debug("Rendering markdown message to HTML.")
|
||||||
try:
|
try:
|
||||||
import markdown
|
message = render_markdown(message, config)
|
||||||
|
|
||||||
formatted = markdown.markdown(message)
|
|
||||||
except: ## noqa
|
except: ## noqa
|
||||||
log.exception("Failed to render message with markdown.")
|
log.exception("Failed to render message with markdown.")
|
||||||
return 1
|
|
||||||
|
|
||||||
body = message
|
|
||||||
message = {"formatted_body": formatted}
|
|
||||||
message["body"] = body
|
|
||||||
message["format"] = "org.matrix.custom.html"
|
|
||||||
|
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
if not config.get("userid"):
|
if not config.get("userid"):
|
||||||
|
|
Loading…
Reference in New Issue