From 3b50125d545f6434ab95dd51fd569c4942caa61c Mon Sep 17 00:00:00 2001 From: Benedikt Ziemons Date: Mon, 28 Jun 2021 09:56:48 +0200 Subject: [PATCH] Update to Flask 2 and matrix-nio Use asynchronously running code. Rewrite error handling and all api interactions. Add config.yml.example and more configuration options. Improve README.md. Update licenses in all files. --- .idea/misc.xml | 2 +- .idea/webhook-matrix-notifier.iml | 2 +- Dockerfile | 2 +- LICENSE | 2 +- README.md | 39 ++- config.yml.example | 15 + notify.py | 59 ++-- requirements.txt | 4 +- wmn.py | 475 ++++++++++++++++++------------ 9 files changed, 382 insertions(+), 218 deletions(-) create mode 100644 config.yml.example diff --git a/.idea/misc.xml b/.idea/misc.xml index 6d0cf62..b2486e6 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/.idea/webhook-matrix-notifier.iml b/.idea/webhook-matrix-notifier.iml index 74d515a..330b481 100644 --- a/.idea/webhook-matrix-notifier.iml +++ b/.idea/webhook-matrix-notifier.iml @@ -4,7 +4,7 @@ - + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9a851ce..0cfbb63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM alpine -RUN apk add --no-cache uwsgi-python3 python3 +RUN apk add --no-cache uwsgi-python3 python3 py3-yaml py3-flask py3-matrix-nio # partly from https://hub.docker.com/_/python?tab=description#create-a-dockerfile-in-your-python-app-project WORKDIR /usr/src/wmn diff --git a/LICENSE b/LICENSE index aa6668f..34d8cc2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2019 Benedikt Ziemons +Copyright 2019-2021 Benedikt Ziemons Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/README.md b/README.md index 342234f..77ca7b8 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,31 @@ # Webhook Matrix Notifier -Takes notifications via webhook, checks a secret and notifies a -[Matrix](https://matrix.org) channel. Listens to HTTP only. Should be used -behind a reverse-proxy with HTTPS. +Takes notifications via webhook, checks a secret and notifies a [Matrix](https://matrix.org) channel. +Listens to HTTP only. Should be used behind a reverse-proxy with HTTPS. -# Testing the Hook locally -- Start the webserver locally by `env FLASK_APP=wmn.py flask run` - - Or have your IDE do it for you -- Send a POST request using curl `curl -i -X POST "localhost:5000/matrix?channel=%21yhEUnvhAZZFKRStdXb%3Amatrix.org" -H "X-Gitlab-Event: Push Hook" -H "X-Gitlab-Token: ..." -H "Content-Type: application/json" --data-binary @./testrequest_gitlab.json` - - The part after `channel=` is the room ID which can retrieved from Matrix channels you are part of - - `%21` escapes ! in URI - - `%3A` escapes : in URI - - The `X-Gitlab-Token` must correspond to the one provided in `config.yaml` +An example configuration is at `config.yml.example` and the program always reads the configuration file `config.yml`. + + +## Testing the Hook locally + +First, start the webserver locally by `env FLASK_APP=wmn.py flask run` or have your IDE start it for you. \ +Then, send a POST request using curl. + +### GitLab + +``` +export CHANNEL_ENC=`python3 -c 'from urllib.parse import quote_plus; print(quote_plus("#channel:matrix.org"))'` +curl -i -X POST "http://localhost:5000/matrix?channel=${CHANNEL_ENC}" -H "X-Gitlab-Event: Push Hook" -H "X-Gitlab-Token: 123" -H "Content-Type: application/json" --data-binary @./testrequest_gitlab.json +``` + +The `X-Gitlab-Token` must correspond to the secret provided in `config.yml` + +### Prometheus + +``` +export CHANNEL_ENC=`python3 -c 'from urllib.parse import quote_plus; print(quote_plus("#channel:matrix.org"))'` +export WMN_SECRET=`python3 -c 'from urllib.parse import quote_plus; print(quote_plus("123"))'` +curl -i -X POST "http://localhost:5000/matrix?type=prometheus&secret=${WMN_SECRET}&channel=${CHANNEL_ENC}" -H "Content-Type: application/json" --data-binary @./testrequest_prometheus.json +``` + +The secret must be passed as a URI parameter here. diff --git a/config.yml.example b/config.yml.example new file mode 100644 index 0000000..108e9ee --- /dev/null +++ b/config.yml.example @@ -0,0 +1,15 @@ +# secret for other services to interact with this webhook +secret: "…" + +matrix: + server: https://matrix.org + username: … + + # optional + store_path: … + device_name: … + device_id: … # will be filled automatically otherwise + + # use password or token + password: "…" + token: … diff --git a/notify.py b/notify.py index c979cbd..85063ed 100755 --- a/notify.py +++ b/notify.py @@ -1,32 +1,39 @@ #!/usr/bin/env python3 +# Copyright 2019-2021 Benedikt Ziemons +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import argparse +import asyncio import re import sys +import nio import yaml -from matrix_client.client import MatrixClient + +from wmn import client_login, resolve_room, send_message # Not going to care for specifics like the underscore. # Generally match !anything:example.com with unicode support. room_pattern = re.compile(r"^!\w+:[\w\-.]+$") -def send_message(cfg, args): - client = MatrixClient(cfg["matrix"]["server"]) - client.login(username=cfg["matrix"]["username"], password=cfg["matrix"]["password"]) - room = client.join_room(room_id_or_alias=args.channel) - - if "html" in args: - body = None if len(args.text) == 0 else str(args.text) - room.send_html(html=args.html, body=body, msgtype=args.type) - else: - room.client.api.send_message( - room_id=room.room_id, text_content=args.text, msgtype=args.type - ) - - -def main(): +async def main(): """ config.yml Example: @@ -60,9 +67,23 @@ def main(): print("ERROR: Couldn't parse channel as a matrix channel", file=sys.stderr) sys.exit(1) - send_message(cfg, args) - print("Message sent.", file=sys.stderr) + client = await client_login(cfg) + try: + room_id = await resolve_room(client=client, room=args.channel) + response = await client.join(room_id=room_id) + if isinstance(response, nio.ErrorResponse): + raise response + + if "html" in args: + response = await send_message(client=client, room_id=room_id, text=(args.text or ""), msgtype=args.type, html=args.html) + else: + response = await send_message(client=client, room_id=room_id, text=args.text, msgtype=args.type) + print("Message sent.", file=sys.stderr, flush=True) + finally: + await client.close() + print(response.event_id) if __name__ == "__main__": - main() + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/requirements.txt b/requirements.txt index 4272430..7069f1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ PyYAML>=5.1,<6 -Flask>=1.1.1,<2 -matrix-client>=0.3.2,<0.4 \ No newline at end of file +flask[async]>=2.0.0,<3 +matrix-nio[e2e]>=0.18.0,<1 diff --git a/wmn.py b/wmn.py index 2c15b6e..9448e73 100644 --- a/wmn.py +++ b/wmn.py @@ -1,14 +1,37 @@ +# Copyright 2019-2021 Benedikt Ziemons +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + import json import re import sys import traceback -import typing from datetime import datetime +from typing import Tuple, Optional, Dict, Any +import nio import yaml from flask import Flask, request, abort -from matrix_client.client import MatrixClient -from matrix_client.errors import MatrixRequestError +from werkzeug.datastructures import MultiDict + +Cfg = Dict[str, Any] +ErrorResponse = Tuple[str, int] +RequestArgs = MultiDict[str, str] app = Flask(__name__) application = app @@ -17,28 +40,25 @@ application = app # Generally match room alias or id [!#]anything:example.com with unicode support. room_pattern = re.compile(r"^[!#]\w+:[\w\-.]+$") -# prometheus has to many sub-second digits in their timestamp, +# older prometheus/alertmanager versions send too many sub-second digits in their timestamp, # so we get rid of nanoseconds here promtime_to_isotime_pattern = re.compile( r"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})(\.[0-9]{6})?(?:[0-9]{3})?(Z|[+-][0-9]{2}:[0-9]{2})" ) -""" -config.yml Example: -secret: "..." -matrix: - server: https://matrix.org - username: ... - password: "..." -""" -with open("config.yml", "r") as ymlfile: - cfg = yaml.safe_load(ymlfile) +def load_configuration() -> Cfg: + with open("config.yml", "r") as ymlfile: + return yaml.safe_load(ymlfile) -def check_token(header_field: str): - token = request.headers.get(header_field) - if token != cfg["secret"]: +def save_configuration(configuration: Cfg): + with open("config.yml", "w") as ymlfile: + yaml.safe_dump(configuration, ymlfile) + + +def check_token(configuration: Cfg, token: str): + if token != configuration["secret"]: print( "check_token failed, because token did not match", file=sys.stderr, @@ -47,15 +67,39 @@ def check_token(header_field: str): abort(401) -def get_a_room(): - if "channel" not in request.args: +async def resolve_room(client: nio.AsyncClient, room: str) -> str: + """Takes a room alias or room id and always returns a resolved room id.""" + + if room.startswith("#"): + response = await client.room_resolve_alias(room_alias=room) + if isinstance(response, nio.ErrorResponse): + abort(app.make_response(matrix_error(response))) + return response.room_id + elif room.startswith("!"): + return room + else: + raise RuntimeError(f"Room {room} could not be resolved") + + +async def get_a_room(client: nio.AsyncClient, request_args: RequestArgs) -> str: + """Takes a nio.AsyncClient and the request args to return a room id.""" + + if "channel" not in request_args: print( "get_a_room failed, because channel was not in request args", file=sys.stderr, flush=True, ) abort(400) - room = request.args.get("channel") + room = request_args.get("channel") + if not room: + print( + "get_a_room failed, because channel was empty", + file=sys.stderr, + flush=True, + ) + abort(400) + # sanitize input if room_pattern.fullmatch(room) is None: print( @@ -67,13 +111,14 @@ def get_a_room(): flush=True, ) abort(400) - return room + + return await resolve_room(client=client, room=room) -def get_msg_type(): - if "msgtype" not in request.args: +def get_msg_type(request_args: RequestArgs): + if "msgtype" not in request_args: return "m.notice" - msgtype = request.args.get("msgtype") + msgtype = request_args.get("msgtype") if msgtype in ["m.text", "m.notice"]: return msgtype else: @@ -102,188 +147,240 @@ def shorten(string: str, max_len: int = 80, appendix: str = "..."): return string -def matrix_error(error: MatrixRequestError): - print("matrix_error was called with", error, file=sys.stderr) - traceback.print_exception(MatrixRequestError, error, error.__traceback__) - print(file=sys.stderr, flush=True) +async def client_login(configuration: Cfg) -> nio.AsyncClient: + client = nio.AsyncClient( + homeserver=configuration["matrix"].get("server"), + user=configuration["matrix"].get("username", ""), + device_id=configuration["matrix"].get("device_id", ""), + store_path=configuration["matrix"].get("store_path", ""), + ) + response = await client.login( + password=configuration["matrix"].get("password", None), + device_name=configuration["matrix"].get("device_name", ""), + token=configuration["matrix"].get("token", None), + ) + if isinstance(response, nio.ErrorResponse): + raise response + + if "device_id" not in configuration["matrix"]: + configuration["matrix"]["device_id"] = response.device_id + save_configuration(configuration) + return client + + +async def send_message( + client: nio.AsyncClient, + room_id: str, + text: str, + msgtype: str = "m.text", + html: Optional[str] = None +) -> nio.RoomSendResponse: + content = { + "body": text, + "msgtype": msgtype, + } + if html is not None: + content["format"] = "org.matrix.custom.html" + content["formatted_body"] = html + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content, + ignore_unverified_devices=True, + ) + if isinstance(response, nio.ErrorResponse): + raise response + return response + + +def matrix_error(error: nio.ErrorResponse) -> ErrorResponse: + print("matrix_error was called with", error, file=sys.stderr, flush=True) # see Flask.make_response, this will be interpreted as (body, status) - return f"Error from Matrix: {error.content}", error.code + if error.status_code: + status = int(error.status_code) + else: + status = 500 + return f"Error from Matrix: {error.message}", status -def process_gitlab_request(): - check_token("X-Gitlab-Token") - msgtype = get_msg_type() - room = get_a_room() +async def process_gitlab_request(): + cfg = load_configuration() + check_token(configuration=cfg, token=request.headers.get("X-Gitlab-Token")) gitlab_event = request.headers.get("X-Gitlab-Event") - if gitlab_event == "Push Hook": - if request.json["total_commits_count"] < 1: - return "", 204 + try: + client = await client_login(cfg) + except nio.ErrorResponse as response: + return matrix_error(response) - try: - client = MatrixClient(cfg["matrix"]["server"]) - client.login( - username=cfg["matrix"]["username"], password=cfg["matrix"]["password"] - ) + try: + room_id = await get_a_room(client, request.args) + msgtype = get_msg_type(request_args=request.args) - room = client.join_room(room_id_or_alias=room) - except MatrixRequestError as e: - return matrix_error(e) + if gitlab_event == "Push Hook": + if request.json["total_commits_count"] < 1: + return "", 204 - def sort_commits_by_time(commits): - return sorted(commits, key=lambda commit: commit["timestamp"]) + response = await client.join(room_id=room_id) + if isinstance(response, nio.ErrorResponse): + return matrix_error(response) - def extract_commit_info(commit): - msg = shorten( - next( - iter_first_line(commit["message"]), - "$EMPTY_COMMIT_MESSAGE - impossibruh", + def sort_commits_by_time(commits): + return sorted(commits, key=lambda commit: commit["timestamp"]) + + def extract_commit_info(commit): + msg = shorten( + next( + iter_first_line(commit["message"]), + "$EMPTY_COMMIT_MESSAGE - impossibruh", + ) ) - ) - url = commit["url"] - return msg, url + url = commit["url"] + return msg, url - username = request.json["user_name"] - project_name = request.json["project"]["name"] - if request.json["ref"].startswith("refs/heads/"): - to_str = f" to branch {request.json['ref'][len('refs/heads/'):]} on project {project_name}" - else: - to_str = f" to {project_name}" + username = request.json["user_name"] + project_name = request.json["project"]["name"] + if request.json["ref"].startswith("refs/heads/"): + to_str = f" to branch {request.json['ref'][len('refs/heads/'):]} on project {project_name}" + else: + to_str = f" to {project_name}" - commit_messages = list( - map(extract_commit_info, sort_commits_by_time(request.json["commits"])) - ) - html_commits = "\n".join( - (f'
  • {msg}
  • ' for (msg, url) in commit_messages) - ) - text_commits = "\n".join( - (f"- [{msg}]({url})" for (msg, url) in commit_messages) - ) - try: - room.send_html( - f"{username} pushed {len(commit_messages)} commits{to_str}
    \n" - f"\n", - body=f"{username} pushed {len(commit_messages)} commits{to_str}\n{text_commits}\n", - msgtype=msgtype, + commit_messages = list( + map(extract_commit_info, sort_commits_by_time(request.json["commits"])) ) - except MatrixRequestError as e: - return matrix_error(e) + html_commits = "\n".join( + (f'
  • {msg}
  • ' for (msg, url) in commit_messages) + ) + text_commits = "\n".join( + (f"- [{msg}]({url})" for (msg, url) in commit_messages) + ) + + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content={ + "msgtype": msgtype, + "format": "org.matrix.custom.html", + "formatted_body": f"{username} pushed {len(commit_messages)} commits{to_str}
    \n" + f"\n", + "body": f"{username} pushed {len(commit_messages)} commits{to_str}\n{text_commits}\n", + }, + ignore_unverified_devices=True, + ) + if isinstance(response, nio.ErrorResponse): + return matrix_error(response) + + except nio.ErrorResponse as response: + abort(app.make_response(matrix_error(response))) + finally: + await client.close() # see Flask.make_response, this is interpreted as (body, status) return "", 204 -def process_jenkins_request(): - check_token("X-Jenkins-Token") - msgtype = get_msg_type() - room = get_a_room() - jenkins_event = request.headers.get("X-Jenkins-Event") +async def process_jenkins_request(): + cfg = load_configuration() + check_token(configuration=cfg, token=request.headers.get("X-Jenkins-Token")) + msgtype = get_msg_type(request_args=request.args) - if jenkins_event == "Post Build Hook": - try: - client = MatrixClient(cfg["matrix"]["server"]) - client.login( - username=cfg["matrix"]["username"], password=cfg["matrix"]["password"] - ) + try: + client = await client_login(cfg) + except nio.ErrorResponse as response: + return matrix_error(response) - room = client.join_room(room_id_or_alias=room) - except MatrixRequestError as e: - return matrix_error(e) + try: + room_id = await get_a_room(client, request.args) + jenkins_event = request.headers.get("X-Jenkins-Event") - project_url = request.json["githubProjectUrl"] + if jenkins_event == "Post Build Hook": + project_url = request.json["githubProjectUrl"] - def extract_change_message(change): - change_message = next(iter_first_line(change["message"]), "") - if len(change_message) > 0: - htimestamp = datetime.fromtimestamp( - change["timestamp"] / 1000 - ).strftime("%d. %b %y %H:%M") - bare_commit_link = f"({shorten(change['commitId'], 7, appendix='')})" - if project_url is not None and project_url: - commit_link = f"{bare_commit_link}" + def extract_change_message(change): + change_message = next(iter_first_line(change["message"]), "") + if len(change_message) > 0: + htimestamp = datetime.fromtimestamp( + change["timestamp"] / 1000 + ).strftime("%d. %b %y %H:%M") + bare_commit_link = f"({shorten(change['commitId'], 7, appendix='')})" + if project_url is not None and project_url: + commit_link = f"{bare_commit_link}" + else: + commit_link = bare_commit_link + return ( + f"- {shorten(change_message)} {bare_commit_link} by {change['author']} at {htimestamp}", + f"
  • {shorten(change_message)} {commit_link} by {change['author']} at {htimestamp}
  • ", + ) else: - commit_link = bare_commit_link - return ( - f"- {shorten(change_message)} {bare_commit_link} by {change['author']} at {htimestamp}", - f"
  • {shorten(change_message)} {commit_link} by {change['author']} at {htimestamp}
  • ", + dump = shorten(json.dumps(change), appendix="...}") + return (dump, dump.replace("<", "<").replace(">", ">")) + + build_name = request.json["displayName"] + project_name = request.json["project"]["fullDisplayName"] + result_type = request.json["result"]["type"] + result_color = request.json["result"]["color"] + changes = request.json["changes"] + if len(changes) > 0: + text_change_messages, html_change_messages = zip( + *map(extract_change_message, changes) ) else: - dump = shorten(json.dumps(change), appendix="...}") - return (dump, dump.replace("<", "<").replace(">", ">")) + text_change_messages, html_change_messages = (), () # it's an owl! - build_name = request.json["displayName"] - project_name = request.json["project"]["fullDisplayName"] - result_type = request.json["result"]["type"] - result_color = request.json["result"]["color"] - changes = request.json["changes"] - if len(changes) > 0: - text_change_messages, html_change_messages = zip( - *map(extract_change_message, changes) + newline = "\n" # expressions inside f-strings cannot contain backslashes... + html_changes = ( + f"\n" + if len(html_change_messages) > 0 + else "" ) - else: - text_change_messages, html_change_messages = (), () # it's an owl! - - newline = "\n" - try: - room.send_html( - f"

    Build {build_name} on project {project_name} complete: " - f'{result_type}, ' - f"{len(changes)} commits

    \n" - "" - + ( - f"\n" - if len(html_change_messages) > 0 - else "" - ), - body=f"**Build {build_name} on project {project_name} complete: {result_type}**, " - f"{len(changes)} commits\n" - "" - + ( - f"{newline.join(text_change_messages)}\n" - if len(text_change_messages) > 0 - else "" + text_changes = ( + f"{newline.join(text_change_messages)}\n" + if len(text_change_messages) > 0 + else "" + ) + await send_message( + client=client, + room_id=room_id, + text=( + f"**Build {build_name} on project {project_name} complete: {result_type}**, " + f"{len(changes)} commits\n" + f"{text_changes}" ), msgtype=msgtype, + html=( + f"

    Build {build_name} on project {project_name} complete: " + f'{result_type}, ' + f"{len(changes)} commits

    \n" + f"{html_changes}" + ), ) - except MatrixRequestError as e: - return matrix_error(e) + + except nio.ErrorResponse as response: + abort(app.make_response(matrix_error(response))) + finally: + await client.close() # see Flask.make_response, this is interpreted as (body, status) return "", 204 -def process_prometheus_request(): - secret = request.args.get("secret") - if secret != cfg["secret"]: - print( - "check_token failed, because token did not match", - file=sys.stderr, - flush=True, - ) - abort(401) - - msgtype = get_msg_type() - room = get_a_room() - - if not request.json: - abort(400) - +async def process_prometheus_request(): # written for version 4 of the alertmanager webhook JSON # https://prometheus.io/docs/alerting/configuration/#webhook_config - def color_status_html(status: str, text: typing.Optional[str] = None): + def color_status_html(status: str, text: Optional[str] = None): _status_colors = {"resolved": "34A91D", "firing": "EF2929"} if text is None: text = status return color_format_html(_status_colors.get(status, "FFFFFF"), text) - def color_severity_html(severity: str, text: typing.Optional[str] = None): + def color_severity_html(severity: str, text: Optional[str] = None): _severity_colors = {"warning": "EFAC29", "critical": "EF2929"} if text is None: text = severity return color_format_html(_severity_colors.get(severity, "FFFFFF"), text) - def parse_promtime(date_string): + def parse_promtime(date_string) -> datetime: match = promtime_to_isotime_pattern.match(date_string) if match is None: # weirdly enough, they switched to ISO primetime @@ -320,9 +417,7 @@ def process_prometheus_request(): return title, html_title - def extract_alert_message( - alert: typing.Dict[str, typing.Any] - ) -> typing.Tuple[str, str]: + def extract_alert_message(alert: Dict[str, Any]) -> Tuple[str, str]: """Takes the alert object and returns (text, html) as a string tuple.""" labels = alert.get("labels", {}) @@ -367,40 +462,56 @@ def process_prometheus_request(): html_message, ) - try: - client = MatrixClient(cfg["matrix"]["server"]) - client.login( - username=cfg["matrix"]["username"], password=cfg["matrix"]["password"] + cfg = load_configuration() + secret = request.args.get("secret") + if secret != cfg["secret"]: + print( + "check_token failed, because token did not match", + file=sys.stderr, + flush=True, ) - room = client.join_room(room_id_or_alias=room) + abort(401) + + try: + client = await client_login(cfg) + except nio.ErrorResponse as response: + return matrix_error(response) + + try: + msgtype = get_msg_type(request_args=request.args) + room_id = await get_a_room(client=client, request_args=request.args) + + if not request.json: + abort(400) + try: - for body, html in map( - extract_alert_message, request.json.get("alerts", []) - ): - if html and body: - room.send_html(html=html, body=body, msgtype=msgtype) - elif body: - room.send_text(body) + for text, html in map(extract_alert_message, request.json.get("alerts", [])): + if html and text: + await send_message(client=client, room_id=room_id, text=text, msgtype=msgtype, html=html) + elif text: + await send_message(client=client, room_id=room_id, text=text, msgtype=msgtype) except (LookupError, ValueError, TypeError): - room.send_text("Error parsing data in prometheus request") + await send_message(client=client, room_id=room_id, text="Error parsing data in prometheus request") print("Error parsing JSON and forming message:", file=sys.stderr) traceback.print_exc() print(file=sys.stderr, flush=True) return "Error parsing JSON and forming message", 500 - except MatrixRequestError as e: - return matrix_error(e) + except nio.ErrorResponse as response: + abort(app.make_response(matrix_error(response))) + finally: + await client.close() - # see Flask.make_response, this is interpreted as (body, status) + # see Flask.make_response, this is interpreted as (text, status) return "", 204 -@app.route("/matrix", methods=("POST",)) -def notify(): +@app.post("/matrix") +async def notify(): if "X-Gitlab-Token" in request.headers: - return process_gitlab_request() + return await process_gitlab_request() elif "X-Jenkins-Token" in request.headers: - return process_jenkins_request() + return await process_jenkins_request() elif "type" in request.args and request.args.get("type") == "prometheus": - return process_prometheus_request() + return await process_prometheus_request() else: return "Cannot determine the request's webhook cause", 400