Compare commits
10 commits
9927a67a86
...
95e830e5c9
Author | SHA1 | Date | |
---|---|---|---|
Ben | 95e830e5c9 | ||
Ben | 5364860191 | ||
Ben | a0279c12e6 | ||
Ben | 425c39a733 | ||
Ben | 330a259be1 | ||
Ben | 56a5ce3cef | ||
Ben | 3dd697755d | ||
Ben | 91bd738331 | ||
Ben | fd10a957c9 | ||
Ben | 3b50125d54 |
223
.gitignore
vendored
223
.gitignore
vendored
|
@ -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
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
41
Dockerfile
41
Dockerfile
|
@ -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" ]
|
||||
|
|
2
LICENSE
2
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
|
||||
|
|
15
Pipfile
Normal file
15
Pipfile
Normal 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
1012
Pipfile.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
56
README.md
56
README.md
|
@ -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
15
config.yml.example
Normal 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: …
|
68
notify.py
68
notify.py
|
@ -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
23
pyproject.toml
Normal 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"
|
|
@ -1,3 +0,0 @@
|
|||
PyYAML>=5.1,<6
|
||||
Flask>=1.1.1,<2
|
||||
matrix-client>=0.3.2,<0.4
|
406
wmn.py
406
wmn.py
|
@ -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("<", "<").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:
|
||||
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
1
wmn/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
__all__ = ["common", "notify", "wmn"]
|
155
wmn/common.py
Normal file
155
wmn/common.py
Normal 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
125
wmn/notify.py
Executable 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
424
wmn/wmn.py
Normal 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("<", "<").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:
|
||||
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
|
Loading…
Reference in a new issue