2023-07-22 16:22:35 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
"""Notify of drone.io CI pipeline results on Matrix chat.
|
|
|
|
|
|
|
|
Requires:
|
|
|
|
|
2023-07-23 23:09:55 +02:00
|
|
|
* <https://pypi.org/project/matrix-nio>
|
|
|
|
* Optional: <https://pypi.org/project/bleach/>
|
2023-07-24 01:07:32 +02:00
|
|
|
* Optional: <https://pypi.org/project/Jinja2/>
|
2023-07-22 16:22:35 +02:00
|
|
|
* Optional: <https://pypi.org/project/markdown/>
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
import asyncio
|
2023-07-23 19:34:25 +02:00
|
|
|
import fnmatch
|
2023-07-22 16:22:35 +02:00
|
|
|
import json
|
2023-07-22 22:03:18 +02:00
|
|
|
import logging
|
2023-07-22 16:22:35 +02:00
|
|
|
import os
|
|
|
|
import sys
|
|
|
|
from distutils.util import strtobool
|
2023-07-22 22:03:18 +02:00
|
|
|
from os.path import exists
|
2023-07-22 16:22:35 +02:00
|
|
|
from string import Template
|
|
|
|
|
|
|
|
from nio import AsyncClient, LoginResponse
|
|
|
|
|
|
|
|
PROG = "matrixchat-notify"
|
|
|
|
CONFIG_FILENAME = f"{PROG}-config.json"
|
2023-07-23 23:54:03 +02:00
|
|
|
DEFAULT_ALLOWED_ATTRS = {
|
|
|
|
"*": ["class"],
|
|
|
|
"a": ["href", "title"],
|
|
|
|
"abbr": ["title"],
|
|
|
|
"acronym": ["title"],
|
|
|
|
"img": ["alt", "src"],
|
|
|
|
}
|
|
|
|
DEFAULT_ALLOWED_TAGS = {
|
|
|
|
"a",
|
|
|
|
"abbr",
|
|
|
|
"acronym",
|
|
|
|
"b",
|
|
|
|
"blockquote",
|
|
|
|
"code",
|
2023-07-23 23:09:55 +02:00
|
|
|
"dd",
|
|
|
|
"div",
|
|
|
|
"dl",
|
|
|
|
"dt",
|
2023-07-23 23:54:03 +02:00
|
|
|
"em",
|
2023-07-23 23:09:55 +02:00
|
|
|
"h1",
|
|
|
|
"h2",
|
|
|
|
"h3",
|
|
|
|
"h4",
|
|
|
|
"h5",
|
|
|
|
"h6",
|
2023-07-23 23:54:03 +02:00
|
|
|
"i",
|
|
|
|
"li",
|
|
|
|
"ol",
|
2023-07-23 23:09:55 +02:00
|
|
|
"p",
|
|
|
|
"span",
|
2023-07-23 23:54:03 +02:00
|
|
|
"strong",
|
2023-07-23 23:09:55 +02:00
|
|
|
"table",
|
|
|
|
"td",
|
|
|
|
"th",
|
|
|
|
"thead",
|
|
|
|
"tr",
|
2023-07-23 23:54:03 +02:00
|
|
|
"ul",
|
2023-07-23 23:09:55 +02:00
|
|
|
}
|
2023-07-22 16:22:35 +02:00
|
|
|
DEFAULT_HOMESERVER = "https://matrix.org"
|
2023-07-23 23:09:55 +02:00
|
|
|
DEFAULT_MARKDOWN_EXTENSIONS = "admonition, extra, sane_lists, smarty"
|
2023-07-23 19:34:25 +02:00
|
|
|
DEFAULT_PASS_ENVIRONMENT = ["DRONE_*"]
|
|
|
|
DEFAULT_TEMPLATE = "${DRONE_BUILD_STATUS}"
|
2023-07-22 16:22:35 +02:00
|
|
|
SETTINGS_KEYS = (
|
2023-07-23 23:09:55 +02:00
|
|
|
"allowed_tags",
|
|
|
|
"allowed_attrs",
|
2023-07-22 16:22:35 +02:00
|
|
|
"accesstoken",
|
|
|
|
"deviceid",
|
|
|
|
"devicename",
|
|
|
|
"homeserver",
|
2023-07-24 01:07:32 +02:00
|
|
|
"jinja",
|
2023-07-22 16:22:35 +02:00
|
|
|
"markdown",
|
2023-07-23 23:09:55 +02:00
|
|
|
"markdown_extensions",
|
2023-07-23 19:34:25 +02:00
|
|
|
"pass_environment",
|
2023-07-22 16:22:35 +02:00
|
|
|
"password",
|
|
|
|
"roomid",
|
|
|
|
"template",
|
|
|
|
"userid",
|
|
|
|
)
|
2023-07-22 22:03:18 +02:00
|
|
|
log = logging.getLogger(PROG)
|
2023-07-22 16:22:35 +02:00
|
|
|
|
|
|
|
|
|
|
|
def tobool(s):
|
|
|
|
try:
|
2023-07-24 01:07:32 +02:00
|
|
|
return strtobool(str(s))
|
2023-07-22 16:22:35 +02:00
|
|
|
except ValueError:
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
2023-07-22 22:03:18 +02:00
|
|
|
def read_config_from_file_and_env(filename):
|
2023-07-22 16:22:35 +02:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
2023-07-24 01:07:32 +02:00
|
|
|
def get_template_context(config):
|
|
|
|
pass_environment = config.get("pass_environment", [])
|
2023-07-23 19:34:25 +02:00
|
|
|
|
|
|
|
if not isinstance(pass_environment, list):
|
|
|
|
pass_environment = [pass_environment]
|
|
|
|
|
|
|
|
patterns = []
|
|
|
|
for value in pass_environment:
|
2023-07-24 01:07:32 +02:00
|
|
|
# expand any comma-separated names/patterns
|
2023-07-23 19:34:25 +02:00
|
|
|
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))
|
|
|
|
|
2023-07-24 01:07:32 +02:00
|
|
|
return {name: os.environ[name] for name in tuple(filtered_names)}
|
|
|
|
|
|
|
|
|
|
|
|
def render_message(config):
|
|
|
|
context = get_template_context(config)
|
2023-07-23 19:34:25 +02:00
|
|
|
template = config.get("template", DEFAULT_TEMPLATE)
|
2023-07-24 01:07:32 +02:00
|
|
|
|
|
|
|
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)
|
2023-07-23 19:34:25 +02:00
|
|
|
|
|
|
|
|
2023-07-23 23:09:55 +02:00
|
|
|
def render_markdown(message, config):
|
2023-07-23 23:54:03 +02:00
|
|
|
import bleach
|
2023-07-23 19:34:25 +02:00
|
|
|
import markdown
|
|
|
|
|
2023-07-23 23:09:55 +02:00
|
|
|
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",
|
|
|
|
}
|
2023-07-23 19:34:25 +02:00
|
|
|
|
|
|
|
|
2023-07-22 16:22:35 +02:00
|
|
|
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.",
|
|
|
|
)
|
2023-07-23 19:34:25 +02:00
|
|
|
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_*')."
|
|
|
|
),
|
|
|
|
)
|
2023-07-22 16:22:35 +02:00
|
|
|
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(
|
2023-07-23 18:02:03 +02:00
|
|
|
level="DEBUG" if args.verbose else os.environ.get("PLUGIN_LOG_LEVEL", "INFO").upper(),
|
2023-07-22 16:22:35 +02:00
|
|
|
format=os.environ.get("PLUGIN_LOG_FORMAT", "%(levelname)s: %(message)s"),
|
|
|
|
)
|
|
|
|
|
|
|
|
try:
|
2023-07-22 22:03:18 +02:00
|
|
|
config = read_config_from_file_and_env(args.config)
|
2023-07-22 16:22:35 +02:00
|
|
|
except Exception as exc:
|
|
|
|
return f"Could not parse configuration: {exc}"
|
|
|
|
|
2023-07-23 19:34:25 +02:00
|
|
|
if args.pass_environment is not None:
|
|
|
|
# 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)
|
2023-07-22 16:22:35 +02:00
|
|
|
|
|
|
|
if tobool(config.get("markdown")) or args.render_markdown:
|
|
|
|
log.debug("Rendering markdown message to HTML.")
|
|
|
|
try:
|
2023-07-23 23:09:55 +02:00
|
|
|
message = render_markdown(message, config)
|
2023-07-22 16:22:35 +02:00
|
|
|
except: ## noqa
|
|
|
|
log.exception("Failed to render message with markdown.")
|
|
|
|
|
|
|
|
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)
|