Compare commits

...

10 commits

Author SHA1 Message Date
Ben 95e830e5c9
Improve README.md, mention WMN_CONFIG_PATH 2023-10-27 14:42:41 +02:00
Ben 5364860191
Add pyproject.toml, refactor notify.py
Add instructions how to run the matrix-notify command-line program.
2023-10-27 14:09:13 +02:00
Ben a0279c12e6
Move to pipenv, update dependencies + more
Replaces occurrences of channel with room (deprecates channel arguments).
Use python native logging module instead of prints.
Remove .idea from git
Simplify .gitignore
2023-10-27 13:46:58 +02:00
Ben 425c39a733
Add async Flask in Dockerfile 2021-10-28 23:22:28 +02:00
Ben 330a259be1
Refactor to wmn package/module 2021-10-28 22:58:37 +02:00
Ben 56a5ce3cef
Add exception wrapping nio.ErrorResponse
Fixes wrong usage nio.ErrorResponse as an exception.
2021-10-28 20:48:06 +02:00
Ben 3dd697755d
Replace datetime.fromisoformat with dateutil 2021-07-21 22:05:39 +02:00
Ben 91bd738331
Specify uwsgi listen port via ENV in Dockerfile 2021-06-29 13:02:25 +02:00
Ben fd10a957c9
Fix/Update Dockerfile 2021-06-28 10:39:37 +02:00
Ben 3b50125d54
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.
2021-06-28 09:56:48 +02:00
20 changed files with 1842 additions and 793 deletions

223
.gitignore vendored
View file

@ -1,225 +1,4 @@
config.yml
# Created by https://www.gitignore.io/api/python,pycharm,virtualenv
# Edit at https://www.gitignore.io/?templates=python,pycharm,virtualenv
### PyCharm ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### PyCharm Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
.idea/sonarlint
### Python ###
# Byte-compiled / optimized / DLL files
.idea/
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
### VirtualEnv ###
# Virtualenv
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
[Bb]in
[Ii]nclude
[Ll]ib
[Ll]ib64
[Ll]ocal
[Ss]cripts
pyvenv.cfg
pip-selfcheck.json
# End of https://www.gitignore.io/api/python,pycharm,virtualenv

View file

@ -1,32 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="ERROR" enabled_by_default="true">
<option name="ourVersions">
<value>
<list size="4">
<item index="0" class="java.lang.String" itemvalue="3.6" />
<item index="1" class="java.lang.String" itemvalue="3.7" />
<item index="2" class="java.lang.String" itemvalue="3.8" />
<item index="3" class="java.lang.String" itemvalue="3.9" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="E501" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N812" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyRedundantParenthesesInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
</profile>
</component>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8 (webhook-matrix-notifier)" project-jdk-type="Python SDK" />
<component name="PythonCompatibilityInspectionAdvertiser">
<option name="version" value="3" />
</component>
</project>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/webhook-matrix-notifier.iml" filepath="$PROJECT_DIR$/.idea/webhook-matrix-notifier.iml" />
</modules>
</component>
</project>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View file

@ -1,27 +1,36 @@
FROM alpine
FROM docker.io/alpine:latest
MAINTAINER Benedikt Ziemons <ben@rs485.network>
RUN apk add --no-cache uwsgi-python3 python3
# partly from https://hub.docker.com/_/python?tab=description#create-a-dockerfile-in-your-python-app-project
WORKDIR /usr/src/wmn
COPY requirements.txt ./
RUN pip3 install --no-cache-dir -r requirements.txt
RUN apk add --no-cache uwsgi-python3 python3 py3-yaml py3-pip py3-setuptools py3-matrix-nio py3-dateutil && \
pip install -U pip && \
pip install -U setuptools && \
pip install -U Flask[async]
# copy required source files
COPY wmn.py ./
COPY wmn/ /usr/local/lib/wmn/wmn
WORKDIR /run/wmn
# requires config.yml to be present at build
COPY config.yml ./
RUN chown -R 999 /run/wmn && chmod 0600 /run/wmn/config.yml
ARG WMN_UID=1000
ARG WMN_GID=1000
USER 999
RUN mkdir -p /etc/wmn && \
chmod 0700 /etc/wmn && \
chown "${WMN_UID}" /etc/wmn && \
addgroup -g "${WMN_GID}" wmn && \
adduser -s /bin/sh -u "${WMN_UID}" -G wmn -D wmn
# opens a uwsgi socket at port 3031, which is to be used by a reverse proxy
USER wmn
VOLUME /etc/wmn/config.yml
ENV WMN_CONFIG_PATH=/etc/wmn/config.yml
ARG PORT=3031
EXPOSE $PORT
ENV UWSGI_SOCKET=:$PORT
# opens a uwsgi socket at the given port, which is to be used by a reverse proxy
CMD [ "uwsgi", "--die-on-term", \
"--need-plugin", "python3", \
"--socket", "0.0.0.0:3031", \
"--wsgi-file", "/usr/src/wmn/wmn.py", \
"--module", "wmn.wmn", \
"--pythonpath", "/usr/local/lib/wmn", \
"--master", \
"--processes", "1", \
"--threads", "2" ]

View file

@ -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

15
Pipfile Normal file
View file

@ -0,0 +1,15 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
pyyaml = "*"
flask = {version = "*", extras = ["async"]}
matrix-nio = {version = "*", extras = ["e2e"]}
python-dateutil = "*"
[dev-packages]
[requires]
python_version = ">=3.9"

1012
Pipfile.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,48 @@
# 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) room.
Listens to HTTP only. Should be used behind a reverse-proxy with HTTPS.
## Configuration
An example configuration is located at `config.yml.example`.
By default the file `config.yml` in the current working directory will be used as the configuration.
To specify a different configuration file, use the environment variable `WMN_CONFIG_PATH`.
## Running the command line notifier
To notify a room with a simple text message, ensure credentials are filled out in your configuration file and run
```
python -m wmn.notify -r '!room:matrix.org' "text" "html"
```
Installing the webhook-matrix-notifier will create the shorthand script "matrix-notify" for this.
## Testing the webhook application 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 URLQUOTED_ROOM=`python3 -c 'from urllib.parse import quote_plus; print(quote_plus("#room:matrix.org"))'`
curl -i -X POST "http://localhost:5000/matrix?room=${URLQUOTED_ROOM}" -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 the configuration.
### Prometheus
```
export URLQUOTED_ROOM=`python3 -c 'from urllib.parse import quote_plus; print(quote_plus("#room:matrix.org"))'`
export URLQUOTED_SECRET=`python3 -c 'from urllib.parse import quote_plus; print(quote_plus("123"))'`
curl -i -X POST "http://localhost:5000/matrix?type=prometheus&secret=${URLQUOTED_SECRET}&room=${URLQUOTED_ROOM}" -H "Content-Type: application/json" --data-binary @./testrequest_prometheus.json
```
The secret must be passed as a URI parameter.
# 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`

15
config.yml.example Normal file
View file

@ -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: …

View file

@ -1,68 +0,0 @@
#!/usr/bin/env python3
import argparse
import re
import sys
import yaml
from matrix_client.client import MatrixClient
# 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():
"""
config.yml Example:
matrix:
server: https://matrix.org
username: ...
password: "..."
"""
with open("config.yml", "r") as ymlfile:
cfg = yaml.safe_load(ymlfile)
parser = argparse.ArgumentParser(description="Notify a matrix channel.")
parser.add_argument(
"-c", "--channel", required=True, help="the channel to send the message to"
)
parser.add_argument(
"-t",
"--type",
required=False,
help="the msgtype",
choices=("m.text", "m.notice"),
default="m.text",
)
parser.add_argument("text", help="the text message to send to the channel")
parser.add_argument(
"html", nargs="?", help="the html message to send to the channel"
)
args = parser.parse_args()
if room_pattern.fullmatch(args.channel) is None:
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)
if __name__ == "__main__":
main()

23
pyproject.toml Normal file
View file

@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools", "setuptools-scm"]
build-backend = "setuptools.build_meta"
[project]
name = "webhook_matrix_notifier"
authors = [
{name = "Benedikt Ziemons", email = "ben@rs485.network"},
]
description = "Flask webhook application for matrix notification and command-line matrix notification tool"
version = "1.1"
readme = "README.md"
requires-python = ">=3.9"
license = {text = "MIT"}
dependencies = [
"pyyaml",
"flask[async]",
"matrix-nio[e2e]",
"python-dateutil",
]
[project.scripts]
matrix-notify = "wmn.notify:main"

View file

@ -1,3 +0,0 @@
PyYAML>=5.1,<6
Flask>=1.1.1,<2
matrix-client>=0.3.2,<0.4

406
wmn.py
View file

@ -1,406 +0,0 @@
import json
import re
import sys
import traceback
import typing
from datetime import datetime
import yaml
from flask import Flask, request, abort
from matrix_client.client import MatrixClient
from matrix_client.errors import MatrixRequestError
app = Flask(__name__)
application = app
# Not going to care for specifics like the underscore.
# 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,
# 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 check_token(header_field: str):
token = request.headers.get(header_field)
if token != cfg["secret"]:
print(
"check_token failed, because token did not match",
file=sys.stderr,
flush=True,
)
abort(401)
def get_a_room():
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")
# sanitize input
if room_pattern.fullmatch(room) is None:
print(
"get_a_room failed, because channel",
room,
"did not match room pattern",
room_pattern,
file=sys.stderr,
flush=True,
)
abort(400)
return room
def get_msg_type():
if "msgtype" not in request.args:
return "m.notice"
msgtype = request.args.get("msgtype")
if msgtype in ["m.text", "m.notice"]:
return msgtype
else:
print(
"get_msg_type failed, because msgtype",
msgtype,
"is not known",
file=sys.stderr,
flush=True,
)
abort(400)
def color_format_html(color_hex: str, text: str):
return f'<font color="#{color_hex}">{text}</font>'
def iter_first_line(string: str):
return iter(map(str.rstrip, string.lstrip().splitlines(keepends=False)))
def shorten(string: str, max_len: int = 80, appendix: str = "..."):
if len(string) > max_len:
return string[: max_len - len(appendix)] + appendix
else:
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)
# see Flask.make_response, this will be interpreted as (body, status)
return f"Error from Matrix: {error.content}", error.code
def process_gitlab_request():
check_token("X-Gitlab-Token")
msgtype = get_msg_type()
room = get_a_room()
gitlab_event = request.headers.get("X-Gitlab-Event")
if gitlab_event == "Push Hook":
if request.json["total_commits_count"] < 1:
return "", 204
try:
client = MatrixClient(cfg["matrix"]["server"])
client.login(
username=cfg["matrix"]["username"], password=cfg["matrix"]["password"]
)
room = client.join_room(room_id_or_alias=room)
except MatrixRequestError as e:
return matrix_error(e)
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
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' <li><a href="{url}">{msg}</a></li>' for (msg, url) in commit_messages)
)
text_commits = "\n".join(
(f"- [{msg}]({url})" for (msg, url) in commit_messages)
)
try:
room.send_html(
f"<strong>{username} pushed {len(commit_messages)} commits{to_str}</strong><br>\n"
f"<ul>\n{html_commits}\n</ul>\n",
body=f"{username} pushed {len(commit_messages)} commits{to_str}\n{text_commits}\n",
msgtype=msgtype,
)
except MatrixRequestError as e:
return matrix_error(e)
# 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")
if jenkins_event == "Post Build Hook":
try:
client = MatrixClient(cfg["matrix"]["server"])
client.login(
username=cfg["matrix"]["username"], password=cfg["matrix"]["password"]
)
room = client.join_room(room_id_or_alias=room)
except MatrixRequestError as e:
return matrix_error(e)
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"<a href=\"{project_url}commit/{change['commitId']}\">{bare_commit_link}</a>"
else:
commit_link = bare_commit_link
return (
f"- {shorten(change_message)} {bare_commit_link} by {change['author']} at {htimestamp}",
f" <li>{shorten(change_message)} {commit_link} by {change['author']} at {htimestamp}</li>",
)
else:
dump = shorten(json.dumps(change), appendix="...}")
return (dump, dump.replace("<", "&lt;").replace(">", "&gt;"))
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:
text_change_messages, html_change_messages = (), () # it's an owl!
newline = "\n"
try:
room.send_html(
f"<p><strong>Build {build_name} on project {project_name} complete: "
f'<font color="{result_color}">{result_type}</font></strong>, '
f"{len(changes)} commits</p>\n"
""
+ (
f"<ul>\n{newline.join(html_change_messages)}\n</ul>\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 ""
),
msgtype=msgtype,
)
except MatrixRequestError as e:
return matrix_error(e)
# 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)
# 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):
_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):
_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):
match = promtime_to_isotime_pattern.match(date_string)
if match is None:
# weirdly enough, they switched to ISO primetime
return datetime.fromisoformat(date_string)
grps = list(filter(lambda x: x is not None, match.groups()))
if grps[-1] == "Z":
grps[-1] = "+00:00"
return datetime.fromisoformat("".join(grps))
def alert_title(status: str, alertname: str, generator_url: str):
if alertname:
alertname = " alert " + alertname
if status:
status_msg = status.upper() if status == "firing" else status.title()
title = status_msg + alertname
html_title = color_status_html(status, title)
elif alertname:
title = alertname
html_title = title
else:
title = ""
html_title = title
if title:
title = f"*{title}*"
if generator_url:
title = f"{title} {generator_url}"
if html_title:
html_title = f"<strong>{html_title}</strong>"
if generator_url:
html_title = f'<a href="{generator_url}">{html_title}</a>'
return title, html_title
def extract_alert_message(
alert: typing.Dict[str, typing.Any]
) -> typing.Tuple[str, str]:
"""Takes the alert object and returns (text, html) as a string tuple."""
labels = alert.get("labels", {})
severity = labels.get("severity", "")
annotations = alert.get("annotations", {})
description = annotations.get("description", "")
if not description:
description = annotations.get("summary", "")
alert_daterange = []
if "startsAt" in alert and alert["startsAt"] != "0001-01-01T00:00:00Z":
alert_start = (
parse_promtime(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 = (
parse_promtime(alert["endsAt"]).strftime("%d. %b %y %H:%M %Z").rstrip()
)
alert_daterange.append(f"ended at {alert_end}")
alert_daterange = ", ".join(alert_daterange)
title, html_title = alert_title(
status=alert.get("status", ""),
alertname=labels.get("alertname", ""),
generator_url=alert.get("generatorURL", ""),
)
if severity:
html_severity = f"Severity: {color_severity_html(severity)}"
severity = severity.upper() if severity == "critical" else severity.title()
severity = f"Severity: {severity}"
else:
html_severity = ""
html_parts = [html_title, html_severity, description, alert_daterange]
html_message = "</p>\n<p>".join(filter(bool, html_parts))
html_message = f"<p>{html_message}</p>" if html_message else ""
return (
" \n".join(filter(bool, [title, severity, description, alert_daterange])),
html_message,
)
try:
client = MatrixClient(cfg["matrix"]["server"])
client.login(
username=cfg["matrix"]["username"], password=cfg["matrix"]["password"]
)
room = client.join_room(room_id_or_alias=room)
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)
except (LookupError, ValueError, TypeError):
room.send_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)
# see Flask.make_response, this is interpreted as (body, status)
return "", 204
@app.route("/matrix", methods=("POST",))
def notify():
if "X-Gitlab-Token" in request.headers:
return process_gitlab_request()
elif "X-Jenkins-Token" in request.headers:
return process_jenkins_request()
elif "type" in request.args and request.args.get("type") == "prometheus":
return process_prometheus_request()
else:
return "Cannot determine the request's webhook cause", 400

1
wmn/__init__.py Normal file
View file

@ -0,0 +1 @@
__all__ = ["common", "notify", "wmn"]

155
wmn/common.py Normal file
View file

@ -0,0 +1,155 @@
# 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 logging
import os
import pathlib
from typing import Optional, Dict, Any, Tuple
import nio
import yaml
Cfg = Dict[str, Any]
ErrorResponseTuple = Tuple[str, int]
def format_response(error_response: "nio.ErrorResponse") -> ErrorResponseTuple:
"""
:returns: tuple to be interpreted as (body, status), see Flask.make_response
:rtype: ErrorResponseTuple
"""
logging.warning("format_response was called with error %s", error_response)
if error_response.status_code:
status = int(error_response.status_code)
else:
status = 500
return f"Error from Matrix: {error_response.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 config_path() -> pathlib.Path:
path = os.environ.get("WMN_CONFIG_PATH", "./config.yml")
path = pathlib.Path(path)
path = path.absolute().resolve()
if not path.exists():
raise RuntimeError("Cannot find config: " + path)
return path
def load_configuration() -> Cfg:
with open(str(config_path()), "r") as ymlfile:
return yaml.safe_load(ymlfile)
def save_configuration(configuration: Cfg):
with open(str(config_path()), "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")

125
wmn/notify.py Executable file
View file

@ -0,0 +1,125 @@
#!/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 logging
import re
import sys
import nio
from .common import (
client_login,
send_message,
resolve_room,
MatrixException,
load_configuration,
)
# 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 get_a_room(args):
if not args.channel and not args.room:
logging.error("Specify the Matrix room to send the message to")
sys.exit(1)
if args.room:
room = args.room
else:
room = args.channel
if room_pattern.fullmatch(room) is None:
logging.error("Could not parse Matrix room '%s'", room)
sys.exit(1)
return room
async def login_and_send(args):
cfg = load_configuration()
client = await client_login(cfg)
room = get_a_room(args)
try:
room_id = await resolve_room(client=client, room=room)
response = await client.join(room_id=room_id)
if isinstance(response, nio.ErrorResponse):
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,
)
else:
response = await send_message(
client=client, room_id=room_id, text=args.text, msgtype=args.type
)
finally:
await client.close()
logging.info("Message sent. %s", response.event_id)
def parse_arguments():
parser = argparse.ArgumentParser(description="Notify a Matrix room.")
parser.add_argument(
"-c", "--channel", required=False, help="the channel to send the message to (deprecated, use --room)"
)
parser.add_argument(
"-r", "--room", required=False, help="the Matrix room to send the message to"
)
parser.add_argument(
"-t",
"--type",
required=False,
help="the msgtype",
choices=("m.text", "m.notice"),
default="m.text",
)
parser.add_argument("text", help="the text message to send to the room")
parser.add_argument(
"html", nargs="?", help="the html message to send to the room"
)
return parser.parse_args()
def main():
"""
Config path is local config.yml by default. Specify with environment variable WMN_CONFIG_PATH.
config.yml Example:
matrix:
server: https://matrix.org
username: ...
password: "..."
"""
logging.basicConfig()
args = parse_arguments()
loop = asyncio.get_event_loop()
loop.run_until_complete(login_and_send(args))
if __name__ == "__main__":
main()

424
wmn/wmn.py Normal file
View file

@ -0,0 +1,424 @@
# 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 logging
import re
from datetime import datetime
from typing import Tuple, Optional, Dict, Any
import dateutil.parser
import nio
from flask import Flask, request, abort
from werkzeug.datastructures import MultiDict
from .common import (
client_login,
send_message,
Cfg,
resolve_room,
format_response,
load_configuration,
MatrixException,
)
RequestArgs = MultiDict[str, str]
logging.basicConfig()
# application is the wsgi variable name
application = Flask(__name__)
# Not going to care for specifics like the underscore.
# Generally match room alias or id [!#]anything:example.com with unicode support.
room_pattern = re.compile(r"^[!#]\w+:[\w\-.]+$")
def check_token(configuration: Cfg, token: str):
if token != configuration["secret"]:
logging.warning("request denied (401): check_token failed, because token did not match")
abort(401)
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 and "room" not in request_args:
logging.warning("request denied (400): get_a_room failed, because room was not in request args")
abort(400)
room = request_args.get("channel", "")
room = request_args.get("room", room)
if not room:
logging.warning("request denied (400): get_a_room failed, because room was empty")
abort(400)
# sanitize input
if room_pattern.fullmatch(room) is None:
logging.warning("request denied (400): get_a_room failed, because room '%s' did not match room pattern '%s'", room, room_pattern)
abort(400)
try:
return await resolve_room(client=client, room=room)
except MatrixException as error:
abort(application.make_response(error.format_response()))
def get_msg_type(request_args: RequestArgs):
if "msgtype" not in request_args:
return "m.notice"
msgtype = request_args.get("msgtype")
if msgtype in ["m.text", "m.notice"]:
return msgtype
else:
logging.warning("request denied (400): get_msg_type failed, because msgtype '%s' is not known", msgtype)
abort(400)
def color_format_html(color_hex: str, text: str):
return f'<font color="#{color_hex}">{text}</font>'
def iter_first_line(string: str):
return iter(map(str.rstrip, string.lstrip().splitlines(keepends=False)))
def shorten(string: str, max_len: int = 80, appendix: str = "..."):
if len(string) > max_len:
return string[: max_len - len(appendix)] + appendix
else:
return string
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")
try:
client = await client_login(cfg)
except MatrixException as error:
return error.format_response()
try:
room_id = await get_a_room(client, request.args)
msgtype = get_msg_type(request_args=request.args)
if gitlab_event == "Push Hook":
if request.json["total_commits_count"] < 1:
return "", 204
response = await client.join(room_id=room_id)
if isinstance(response, nio.ErrorResponse):
return format_response(response)
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
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' <li><a href="{url}">{msg}</a></li>'
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"<strong>{username} pushed {len(commit_messages)} commits{to_str}</strong><br>\n"
f"<ul>\n{html_commits}\n</ul>\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 format_response(response)
except MatrixException as error:
abort(application.make_response(error.format_response()))
finally:
await client.close()
# see Flask.make_response, this is interpreted as (body, status)
return "", 204
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)
try:
client = await client_login(cfg)
except MatrixException as error:
return error.format_response()
try:
room_id = await get_a_room(client, request.args)
jenkins_event = request.headers.get("X-Jenkins-Event")
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"<a href=\"{project_url}commit/{change['commitId']}\">{bare_commit_link}</a>"
else:
commit_link = bare_commit_link
return (
f"- {shorten(change_message)} {bare_commit_link} by {change['author']} at {htimestamp}",
f" <li>{shorten(change_message)} {commit_link} by {change['author']} at {htimestamp}</li>",
)
else:
dump = shorten(json.dumps(change), appendix="...}")
return (dump, dump.replace("<", "&lt;").replace(">", "&gt;"))
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:
text_change_messages, html_change_messages = (), () # it's an owl!
newline = "\n" # expressions inside f-strings cannot contain backslashes...
html_changes = (
f"<ul>\n{newline.join(html_change_messages)}\n</ul>\n"
if len(html_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"<p><strong>Build {build_name} on project {project_name} complete: "
f'<font color="{result_color}">{result_type}</font></strong>, '
f"{len(changes)} commits</p>\n"
f"{html_changes}"
),
)
except MatrixException as error:
abort(application.make_response(error.format_response()))
finally:
await client.close()
# see Flask.make_response, this is interpreted as (body, status)
return "", 204
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: 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: 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 alert_title(status: str, alertname: str, generator_url: str):
if alertname:
alertname = " alert " + alertname
if status:
status_msg = status.upper() if status == "firing" else status.title()
title = status_msg + alertname
html_title = color_status_html(status, title)
elif alertname:
title = alertname
html_title = title
else:
title = ""
html_title = title
if title:
title = f"*{title}*"
if generator_url:
title = f"{title} {generator_url}"
if html_title:
html_title = f"<strong>{html_title}</strong>"
if generator_url:
html_title = f'<a href="{generator_url}">{html_title}</a>'
return title, html_title
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", {})
severity = labels.get("severity", "")
annotations = alert.get("annotations", {})
description = annotations.get("description", "")
if not description:
description = annotations.get("summary", "")
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()
)
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()
)
alert_daterange.append(f"ended at {alert_end}")
alert_daterange = ", ".join(alert_daterange)
title, html_title = alert_title(
status=alert.get("status", ""),
alertname=labels.get("alertname", ""),
generator_url=alert.get("generatorURL", ""),
)
if severity:
html_severity = f"Severity: {color_severity_html(severity)}"
severity = severity.upper() if severity == "critical" else severity.title()
severity = f"Severity: {severity}"
else:
html_severity = ""
html_parts = [html_title, html_severity, description, alert_daterange]
html_message = "</p>\n<p>".join(filter(bool, html_parts))
html_message = f"<p>{html_message}</p>" if html_message else ""
return (
" \n".join(filter(bool, [title, severity, description, alert_daterange])),
html_message,
)
cfg = load_configuration()
secret = request.args.get("secret")
if secret != cfg["secret"]:
logging.warning("check_token failed, because token did not match")
abort(401)
try:
client = await client_login(cfg)
except MatrixException as error:
return error.format_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 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):
await send_message(
client=client,
room_id=room_id,
text="Error parsing data in prometheus request",
)
logging.exception("Error parsing JSON and forming message")
return "Error parsing JSON and forming message", 500
except MatrixException as error:
abort(application.make_response(error.format_response()))
finally:
await client.close()
# see Flask.make_response, this is interpreted as (text, status)
return "", 204
@application.post("/matrix")
async def notify():
if "X-Gitlab-Token" in request.headers:
return await process_gitlab_request()
elif "X-Jenkins-Token" in request.headers:
return await process_jenkins_request()
elif "type" in request.args and request.args.get("type") == "prometheus":
return await process_prometheus_request()
else:
return "Cannot determine the request's webhook cause", 400