diff --git a/common.py b/common.py new file mode 100644 index 0000000..822efc8 --- /dev/null +++ b/common.py @@ -0,0 +1,144 @@ +# Copyright 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 sys +from typing import Optional, Dict, Any, Tuple + +import nio +import yaml + +Cfg = Dict[str, Any] +ErrorResponseTuple = Tuple[str, int] + + +def format_response(error: "nio.ErrorResponse") -> ErrorResponseTuple: + """ + :returns: tuple to be interpreted as (body, status), see Flask.make_response + :rtype: ErrorResponseTuple + """ + print("matrix_error was called with", error, file=sys.stderr, flush=True) + if error.status_code: + status = int(error.status_code) + else: + status = 500 + return f"Error from Matrix: {error.message}", status + + +class MatrixException(Exception): + def __init__(self, response: "nio.ErrorResponse"): + super(MatrixException, self).__init__("Error from Matrix: " + response.message) + self.response = response + + def format_response(self) -> ErrorResponseTuple: + return format_response(self.response) + + +def load_configuration() -> Cfg: + with open("config.yml", "r") as ymlfile: + return yaml.safe_load(ymlfile) + + +def save_configuration(configuration: Cfg): + with open("config.yml", "w") as ymlfile: + yaml.safe_dump(configuration, ymlfile) + + +async def client_login(configuration: Cfg) -> nio.AsyncClient: + """ + :exception MatrixException: if the matrix server returns an error. + :param configuration: the configuration object to load login data from. + :type configuration: Cfg + :return: the matrix client. + :rtype: 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 MatrixException(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: + """ + :exception MatrixException: if the matrix server returns an error. + :param client: the client to operate on. + :param room_id: the room to send the message to. + :param text: the text to send. + :param msgtype: the message type to use. By default this is "m.text". + :param html: optional html string to send with the message. + :return: a room send response, which is never an nio.ErrorResponse. + :rtype: 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 MatrixException(response) + return response + + +async def resolve_room(client: nio.AsyncClient, room: str) -> str: + """ + Takes a room alias or room id and always returns a resolved room id. + :exception MatrixException: if the matrix server returns an error. + :exception RuntimeError: if the passed room string cannot be handled. + :param client: the client to operate on. + :param room: the room to resolve. + :returns: the room's matrix id, starting with a "!". + :rtype: str + """ + + if room.startswith("#"): + response = await client.room_resolve_alias(room_alias=room) + if isinstance(response, nio.ErrorResponse): + raise MatrixException(response) + return response.room_id + elif room.startswith("!"): + return room + else: + raise RuntimeError(f"Room {room} could not be resolved") diff --git a/notify.py b/notify.py index 85063ed..5042d8e 100755 --- a/notify.py +++ b/notify.py @@ -26,7 +26,7 @@ import sys import nio import yaml -from wmn import client_login, resolve_room, send_message +from common import client_login, send_message, resolve_room, MatrixException # Not going to care for specifics like the underscore. # Generally match !anything:example.com with unicode support. @@ -72,12 +72,20 @@ async def main(): 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 + raise MatrixException(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) + 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) + 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() diff --git a/wmn.py b/wmn.py index f0d4df0..992603d 100644 --- a/wmn.py +++ b/wmn.py @@ -25,13 +25,20 @@ from datetime import datetime from typing import Tuple, Optional, Dict, Any import nio -import yaml from flask import Flask, request, abort from werkzeug.datastructures import MultiDict import dateutil.parser -Cfg = Dict[str, Any] -ErrorResponse = Tuple[str, int] +from common import ( + client_login, + send_message, + Cfg, + resolve_room, + format_response, + load_configuration, + MatrixException, +) + RequestArgs = MultiDict[str, str] app = Flask(__name__) @@ -42,16 +49,6 @@ application = app room_pattern = re.compile(r"^[!#]\w+:[\w\-.]+$") -def load_configuration() -> Cfg: - with open("config.yml", "r") as ymlfile: - return yaml.safe_load(ymlfile) - - -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( @@ -62,20 +59,6 @@ def check_token(configuration: Cfg, token: str): abort(401) -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.""" @@ -107,7 +90,10 @@ async def get_a_room(client: nio.AsyncClient, request_args: RequestArgs) -> str: ) abort(400) - return await resolve_room(client=client, room=room) + try: + return await resolve_room(client=client, room=room) + except MatrixException as error: + abort(app.make_response(error.format_response())) def get_msg_type(request_args: RequestArgs): @@ -142,62 +128,6 @@ def shorten(string: str, max_len: int = 80, appendix: str = "..."): return string -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) - if error.status_code: - status = int(error.status_code) - else: - status = 500 - return f"Error from Matrix: {error.message}", status - - async def process_gitlab_request(): cfg = load_configuration() check_token(configuration=cfg, token=request.headers.get("X-Gitlab-Token")) @@ -205,8 +135,8 @@ async def process_gitlab_request(): try: client = await client_login(cfg) - except nio.ErrorResponse as response: - return matrix_error(response) + except MatrixException as error: + return error.format_response() try: room_id = await get_a_room(client, request.args) @@ -218,7 +148,7 @@ async def process_gitlab_request(): response = await client.join(room_id=room_id) if isinstance(response, nio.ErrorResponse): - return matrix_error(response) + return format_response(response) def sort_commits_by_time(commits): return sorted(commits, key=lambda commit: commit["timestamp"]) @@ -244,7 +174,10 @@ async def process_gitlab_request(): map(extract_commit_info, sort_commits_by_time(request.json["commits"])) ) html_commits = "\n".join( - (f'
  • {msg}
  • ' for (msg, url) in commit_messages) + ( + f'
  • {msg}
  • ' + for (msg, url) in commit_messages + ) ) text_commits = "\n".join( (f"- [{msg}]({url})" for (msg, url) in commit_messages) @@ -263,10 +196,10 @@ async def process_gitlab_request(): ignore_unverified_devices=True, ) if isinstance(response, nio.ErrorResponse): - return matrix_error(response) + return format_response(response) - except nio.ErrorResponse as response: - abort(app.make_response(matrix_error(response))) + except MatrixException as error: + abort(app.make_response(error.format_response())) finally: await client.close() @@ -281,8 +214,8 @@ async def process_jenkins_request(): try: client = await client_login(cfg) - except nio.ErrorResponse as response: - return matrix_error(response) + except MatrixException as error: + return error.format_response() try: room_id = await get_a_room(client, request.args) @@ -297,7 +230,9 @@ async def process_jenkins_request(): htimestamp = datetime.fromtimestamp( change["timestamp"] / 1000 ).strftime("%d. %b %y %H:%M") - bare_commit_link = f"({shorten(change['commitId'], 7, appendix='')})" + 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: @@ -350,8 +285,8 @@ async def process_jenkins_request(): ), ) - except nio.ErrorResponse as response: - abort(app.make_response(matrix_error(response))) + except MatrixException as error: + abort(app.make_response(error.format_response())) finally: await client.close() @@ -415,12 +350,16 @@ async def process_prometheus_request(): alert_daterange = [] if "startsAt" in alert and alert["startsAt"] != "0001-01-01T00:00:00Z": alert_start = ( - dateutil.parser.isoparse(alert["startsAt"]).strftime("%d. %b %y %H:%M %Z").rstrip() + dateutil.parser.isoparse(alert["startsAt"]) + .strftime("%d. %b %y %H:%M %Z") + .rstrip() ) alert_daterange.append(f"started at {alert_start}") if "endsAt" in alert and alert["endsAt"] != "0001-01-01T00:00:00Z": alert_end = ( - dateutil.parser.isoparse(alert["endsAt"]).strftime("%d. %b %y %H:%M %Z").rstrip() + dateutil.parser.isoparse(alert["endsAt"]) + .strftime("%d. %b %y %H:%M %Z") + .rstrip() ) alert_daterange.append(f"ended at {alert_end}") alert_daterange = ", ".join(alert_daterange) @@ -457,8 +396,8 @@ async def process_prometheus_request(): try: client = await client_login(cfg) - except nio.ErrorResponse as response: - return matrix_error(response) + except MatrixException as error: + return error.format_response() try: msgtype = get_msg_type(request_args=request.args) @@ -468,19 +407,33 @@ async def process_prometheus_request(): abort(400) try: - for text, html in map(extract_alert_message, request.json.get("alerts", [])): + 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) + 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) + await send_message( + client=client, room_id=room_id, text=text, msgtype=msgtype + ) except (LookupError, ValueError, TypeError): - await send_message(client=client, room_id=room_id, 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 nio.ErrorResponse as response: - abort(app.make_response(matrix_error(response))) + except MatrixException as error: + abort(app.make_response(error.format_response())) finally: await client.close()