Compare commits

..

10 Commits

Author SHA1 Message Date
Christopher Arndt 8315fee398 docs: improve config example
separate sections for required/optional config settings

Signed-off-by: Christopher Arndt <chris@chrisarndt.de>
2023-07-24 15:43:57 +02:00
Christopher Arndt 37ba136b5c docs: fix minor readme typo
Signed-off-by: Christopher Arndt <chris@chrisarndt.de>
2023-07-24 14:20:46 +02:00
Christopher Arndt 70a70531be docs: add readme badges and fix source code line references
Signed-off-by: Christopher Arndt <chris@chrisarndt.de>
2023-07-24 14:17:04 +02:00
Christopher Arndt d6e24aa359 feat: support rendering message template with Jinja
Add config setting to enable Jinja templates

Signed-off-by: Christopher Arndt <chris@chrisarndt.de>
2023-07-24 01:07:32 +02:00
Christopher Arndt 92d85d31bf fix: do not require bleach module by default
Signed-off-by: Christopher Arndt <chris@chrisarndt.de>
2023-07-23 23:54:03 +02:00
Christopher Arndt 1e374c61e6 fix: order of setting descriptions in readme
Signed-off-by: Christopher Arndt <chris@chrisarndt.de>
2023-07-23 23:15:08 +02:00
Christopher Arndt 5c5cd14030 feat: support markdown extension and bleach generated HTML
Add config settings for enabled markdown extensions and allowed tags and attributes in HTML output with sensible defaults

Signed-off-by: Christopher Arndt <chris@chrisarndt.de>
2023-07-23 23:09:55 +02:00
Christopher Arndt db7edd5dae feat: add environment white-list config setting and cmdline option
See description in readme for details

Signed-off-by: Christopher Arndt <chris@chrisarndt.de>
2023-07-23 20:28:33 +02:00
Christopher Arndt da31628d86 feat: log level from environment is recognised case-insensitively
Signed-off-by: Christopher Arndt <chris@chrisarndt.de>
2023-07-23 18:02:03 +02:00
Christopher Arndt d3579b6e0f docs: minor readme word order fix
Signed-off-by: Christopher Arndt <chris@chrisarndt.de>
2023-07-22 22:13:59 +02:00
3 changed files with 235 additions and 37 deletions

View File

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

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

View File

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