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'