Compare commits

..

No commits in common. "master" and "v0.1.0" have entirely different histories.

3 changed files with 37 additions and 235 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 bleach jinja2 markdown matrix-nio RUN python3 -m pip --no-cache-dir install 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

106
README.md
View File

@ -1,14 +1,7 @@
# drone-matrixchat-notify # drone-matrixchat-notify
[![MIT License](https://img.shields.io/github/license/SpotlightKid/drone-matrixchat-notify?label=License)](https://github.com/SpotlightKid/drone-matrixchat-notify/blob/master/LICENSE) A [drone.io] [plugin] to send notifications to Matrix chat rooms from
[![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) CI pipeline steps.
[![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:
@ -31,54 +24,16 @@ steps:
userid: '@drone-bot@matrix.org' userid: '@drone-bot@matrix.org'
password: password:
from_secret: drone-bot-pw from_secret: drone-bot-pw
markdown: 'yes' template: '${DRONE_REPO} ${DRONE_COMMIT_SHA} ${DRONE_BUILD_STATUS}'
template: |
`${DRONE_REPO}` build #${DRONE_BUILD_NUMBER} status: **${DRONE_BUILD_STATUS}**
${DRONE_PULL_REQUEST_TITLE}](${DRONE_COMMIT_LINK})
``` ```
## Configuration settings ## Configuration settings
### Required * `accesstoken`
* `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.
@ -91,59 +46,34 @@ 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`, `t`, `on` or `1`, the message resulting from If set to `yes`, `y`, `true` or `on`, the message resulting from template
template substtution is considered to be in Markdown format and will be substtution is considered to be in Markdown format and will be rendered to
rendered to HTML and sent as a formatted message with the format set to HTML and sent as a formatted message with `org.matrix.custom.html` format.
`org.matrix.custom.html`.
Using this feature requires the `markdown` and `bleach` Python modules to * `password`
be available (they are installed by default in the plugin's docker image).
* `markdown_extensions` *(default:* `admonition, extra, sane_lists, smarty`) Password to use for authenticating the user set with `userid`. Either a
password or an access token is required.
Comma-separated list of enabled Markdown extensions. See this * `roomid` *(required)*
[list of extensions] for valid extension names. Including an invalid
extension name in this list will disable Markdown rendering.
* `pass_environment` *(default:* `DRONE_*`*)* ID of matrix chat room to send messages to (ID, not alias).
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 (example: `${DRONE_REPO}`) will be The message template. Valid placeholders of the form `${PLACEHOLDER}` will
substituted with the values of the matching environment variables (subject be substituted with the values of the matching environment variables.
to filtering according to the `pass_environment` setting).
See this [reference] for environment variables available in drone.io CI See this [reference] for environment variables available drone.io in 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,16 +3,13 @@
Requires: Requires:
* <https://pypi.org/project/matrix-nio> * <https://github.com/poljar/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
@ -25,59 +22,14 @@ from nio import AsyncClient, LoginResponse
PROG = "matrixchat-notify" PROG = "matrixchat-notify"
CONFIG_FILENAME = f"{PROG}-config.json" CONFIG_FILENAME = f"{PROG}-config.json"
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_MARKDOWN_EXTENSIONS = "admonition, extra, sane_lists, smarty"
DEFAULT_PASS_ENVIRONMENT = ["DRONE_*"]
DEFAULT_TEMPLATE = "${DRONE_BUILD_STATUS}" DEFAULT_TEMPLATE = "${DRONE_BUILD_STATUS}"
DEFAULT_HOMESERVER = "https://matrix.org"
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",
@ -88,7 +40,7 @@ log = logging.getLogger(PROG)
def tobool(s): def tobool(s):
try: try:
return strtobool(str(s)) return strtobool(s)
except ValueError: except ValueError:
return False return False
@ -158,78 +110,6 @@ 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(
@ -245,17 +125,6 @@ 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",
@ -272,7 +141,9 @@ def main(args=None):
args = ap.parse_args(args) args = ap.parse_args(args)
logging.basicConfig( logging.basicConfig(
level="DEBUG" if args.verbose else os.environ.get("PLUGIN_LOG_LEVEL", "INFO").upper(), level=getattr(
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"),
) )
@ -281,22 +152,23 @@ 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}"
if args.pass_environment is not None: template = config.get("template", DEFAULT_TEMPLATE)
# Security feature: if any environment names/patterns are passed via -e|--pass-environment message = Template(template).safe_substitute(os.environ)
# 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:
message = render_markdown(message, config) import markdown
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"):