commit c78eb822825d5c1bcd601a47e5c82b1b0b4ad8af Author: Christopher Arndt Date: Sat Jul 22 16:22:35 2023 +0200 Initial commit Signed-off-by: Christopher Arndt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dec3a5e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +testconfig.json +.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dacc42c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +testconfig.json +.env +*.py[co] +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..deac353 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-alpine +RUN python3 -m pip --no-cache-dir install markdown matrix-nio +ADD matrixchat-notify.py /bin/ +ADD matrixchat-notify-config.json /etc/ +RUN chmod +x /bin/matrixchat-notify.py +ENTRYPOINT ["/bin/matrixchat-notify.py", "-c", "/etc/matrixchat-notify-config.json"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f4de6b --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# drone-matrixchat-notify + +A [drone.io] [plugin] to send notifications to Matrix chat rooms from +CI pipeline steps. + +Example pipeline configuration: + +```yaml +kind: pipeline +type: docker +name: default + +steps: +- name: build + image: alpine + commands: + - ./build + +- name: notify + image: spotlightkid/drone-matrixchat-notify + settings: + homeserver: 'https://matrix.org' + roomid: '!xxxxxx@matrix.org' + userid: '@drone-bot@matrix.org' + password: + from_secret: drone-bot-pw + template: '${DRONE_REPO} ${DRONE_COMMIT_SHA} ${DRONE_BUILD_STATUS}' +``` + +# Configuration settings + +* `accesstoken` + + Access token to use for authentication instead of `password`. Either an + access token or a password is required. + +* `deviceid` + + Device ID to send with access token. + +* `devicename` + + Device name to send with access token. + +* `homeserver` *(default:* `https://matrix.org`*)* + + The Matrix homeserver URL. + +* `markdown` + + If set to `yes`, `y`, `true` or `on`, the message resulting from template + substtution is considered to be in Markdown format and will be rendered to + HTML and sent as a formatted message with `org.matrix.custom.html` format. + +* `password` + + Password to use for authenticating the user set with `userid`. Either a + password or an access token is required. + +* `roomid` *(required)* + + ID of matrix chat room to send messages to (ID, not alias). + +* `template` *(default:* `${DRONE_BUILD_STATUS}`*)* + + The message template. Valid placeholders of the form `${PLACEHOLDER}` will + be substituted with the values of the matching environment variables. + + See this [reference] for environment variables available drone.io in CI + pipelines. + +* `userid` *(required)* + + ID of user on homeserver to send message as (ID, not username). + + +[drone.io]: https://drone.io/ +[plugin]: https://docs.drone.io/plugins/overview/ +[reference]: https://docs.drone.io/pipeline/environment/reference/ diff --git a/matrixchat-notify-config.json b/matrixchat-notify-config.json new file mode 100644 index 0000000..bbb7d24 --- /dev/null +++ b/matrixchat-notify-config.json @@ -0,0 +1,5 @@ +{ + "homeserver": "https://matrix.org", + "template": "${DRONE_BUILD_STATUS}", + "markdown": "no" +} diff --git a/matrixchat-notify.py b/matrixchat-notify.py new file mode 100755 index 0000000..f5578ba --- /dev/null +++ b/matrixchat-notify.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Notify of drone.io CI pipeline results on Matrix chat. + +Requires: + +* +* Optional: + +""" + +import argparse +import asyncio +import logging +import json +import os +import sys +from distutils.util import strtobool +from os.path import exists, isdir, join +from string import Template + +from nio import AsyncClient, LoginResponse + +PROG = "matrixchat-notify" +CONFIG_FILENAME = f"{PROG}-config.json" +DEFAULT_TEMPLATE = "${DRONE_BUILD_STATUS}" +DEFAULT_HOMESERVER = "https://matrix.org" +log = logging.getLogger(PROG) +SETTINGS_KEYS = ( + "accesstoken", + "deviceid", + "devicename", + "homeserver", + "markdown", + "password", + "roomid", + "template", + "userid", +) + + +def tobool(s): + try: + return strtobool(s) + except ValueError: + return False + + +def read_config_from_file(filename): + config = {} + + if exists(filename): + with open(filename) as fp: + config = json.load(fp) + + for setting in SETTINGS_KEYS: + val = os.getenv("PLUGIN_" + setting.upper()) + + if val is not None: + config[setting] = val + + if not config.get(setting): + log.debug(f"Configuration setting '{setting}' not set or empty in config.") + + return config + + +async def send_notification(config, message): + token = config.get("accesstoken") + device_id = config.get("deviceid") + homeserver = config.get("homeserver", DEFAULT_HOMESERVER) + + client = AsyncClient(homeserver, config["userid"]) + log.debug("Created AsyncClient: %r", client) + + if token and device_id: + log.debug("Using access token for authentication.") + client.access_token = token + client.device_id = device_id + else: + log.debug("Trying to log in with password...") + resp = await client.login(config["password"], device_name=config.get("devicename")) + + # check that we logged in succesfully + if isinstance(resp, LoginResponse): + log.debug("Matrix login successful.") + log.debug("Access token: %s", resp.access_token) + log.debug("Device ID: %s", resp.device_id) + else: + log.error(f"Failed to log in: {resp}") + await client.close() + return + + if isinstance(message, dict): + message.setdefault("msgtype", "m.notice") + resp = await client.room_send( + config["roomid"], message_type="m.room.message", content=message + ) + else: + resp = await client.room_send( + config["roomid"], + message_type="m.room.message", + content={"msgtype": "m.notice", "body": message}, + ) + + log.info( + "Sent notification message to %s. Response status: %s", + homeserver, + resp.transport_response.status, + ) + await client.close() + + +def main(args=None): + ap = argparse.ArgumentParser(prog=PROG, description=__doc__.splitlines()[0]) + ap.add_argument( + "-c", + "--config", + metavar="PATH", + default=CONFIG_FILENAME, + help="Configuration file path (default: '%(default)s')", + ) + ap.add_argument( + "-d", + "--dry-run", + action="store_true", + help="Don't send notification message, only print it.", + ) + ap.add_argument( + "-m", + "--render-markdown", + action="store_true", + help="Message is in Markdown format and will be rendered to HTML.", + ) + ap.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable debug level logging.", + ) + + args = ap.parse_args(args) + + logging.basicConfig( + 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"), + ) + + try: + config = read_config_from_file(args.config) + except Exception as exc: + return f"Could not parse configuration: {exc}" + + template = config.get("template", DEFAULT_TEMPLATE) + message = Template(template).safe_substitute(os.environ) + + if tobool(config.get("markdown")) or args.render_markdown: + log.debug("Rendering markdown message to HTML.") + try: + import markdown + + formatted = markdown.markdown(message) + except: ## noqa + 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 config.get("userid"): + return "userid not found in configuration." + + if not config.get("roomid"): + return "roomid not found in configuration." + + if not config.get("password") and not config.get("accesstoken"): + return "No password or accesstoken found in configuration." + + try: + log.debug("Sending notification to Matrix chat...") + asyncio.run(send_notification(config, message)) + except KeyboardInterrupt: + log.info("Interrupted.") + else: + print(message) + + +if __name__ == "__main__": + sys.exit(main() or 0)