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
This commit is contained in:
Ben 2023-10-27 13:46:58 +02:00
parent 425c39a733
commit a0279c12e6
Signed by: ben
GPG key ID: 0F54A7ED232D3319
13 changed files with 1087 additions and 368 deletions

224
.gitignore vendored
View file

@ -1,225 +1,3 @@
config.yml config.yml
.idea/
# 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
__pycache__/ __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.9 (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="jdk" jdkName="Python 3.9 (webhook-matrix-notifier)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

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,6 +1,6 @@
# Webhook Matrix Notifier # Webhook Matrix Notifier
Takes notifications via webhook, checks a secret and notifies a [Matrix](https://matrix.org) channel. 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. Listens to HTTP only. Should be used behind a reverse-proxy with HTTPS.
An example configuration is at `config.yml.example` and the program always reads the configuration file `config.yml`. An example configuration is at `config.yml.example` and the program always reads the configuration file `config.yml`.
@ -14,8 +14,8 @@ Then, send a POST request using curl.
### GitLab ### GitLab
``` ```
export CHANNEL_ENC=`python3 -c 'from urllib.parse import quote_plus; print(quote_plus("#channel:matrix.org"))'` 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?channel=${CHANNEL_ENC}" -H "X-Gitlab-Event: Push Hook" -H "X-Gitlab-Token: 123" -H "Content-Type: application/json" --data-binary @./testrequest_gitlab.json 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 `config.yml` The `X-Gitlab-Token` must correspond to the secret provided in `config.yml`
@ -23,9 +23,9 @@ The `X-Gitlab-Token` must correspond to the secret provided in `config.yml`
### Prometheus ### Prometheus
``` ```
export CHANNEL_ENC=`python3 -c 'from urllib.parse import quote_plus; print(quote_plus("#channel:matrix.org"))'` export URLQUOTED_ROOM=`python3 -c 'from urllib.parse import quote_plus; print(quote_plus("#room:matrix.org"))'`
export WMN_SECRET=`python3 -c 'from urllib.parse import quote_plus; print(quote_plus("123"))'` 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=${WMN_SECRET}&channel=${CHANNEL_ENC}" -H "Content-Type: application/json" --data-binary @./testrequest_prometheus.json 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 here. The secret must be passed as a URI parameter here.

View file

@ -1,4 +0,0 @@
PyYAML>=5.1,<6
flask[async]>=2.0.0,<3
matrix-nio[e2e]>=0.18.0,<1
python-dateutil~=2.8.2

View file

@ -16,9 +16,10 @@
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER # 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 # 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. # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import logging
import os import os
import pathlib import pathlib
import sys
from typing import Optional, Dict, Any, Tuple from typing import Optional, Dict, Any, Tuple
import nio import nio
@ -28,17 +29,17 @@ Cfg = Dict[str, Any]
ErrorResponseTuple = Tuple[str, int] ErrorResponseTuple = Tuple[str, int]
def format_response(error: "nio.ErrorResponse") -> ErrorResponseTuple: def format_response(error_response: "nio.ErrorResponse") -> ErrorResponseTuple:
""" """
:returns: tuple to be interpreted as (body, status), see Flask.make_response :returns: tuple to be interpreted as (body, status), see Flask.make_response
:rtype: ErrorResponseTuple :rtype: ErrorResponseTuple
""" """
print("matrix_error was called with", error, file=sys.stderr, flush=True) logging.warning("format_response was called with error %s", error_response)
if error.status_code: if error_response.status_code:
status = int(error.status_code) status = int(error_response.status_code)
else: else:
status = 500 status = 500
return f"Error from Matrix: {error.message}", status return f"Error from Matrix: {error_response.message}", status
class MatrixException(Exception): class MatrixException(Exception):
@ -71,10 +72,10 @@ def save_configuration(configuration: Cfg):
async def client_login(configuration: Cfg) -> nio.AsyncClient: async def client_login(configuration: Cfg) -> nio.AsyncClient:
""" """
:exception MatrixException: if the matrix server returns an error. :exception MatrixException: if the Matrix server returns an error.
:param configuration: the configuration object to load login data from. :param configuration: the configuration object to load login data from.
:type configuration: Cfg :type configuration: Cfg
:return: the matrix client. :return: the Matrix client.
:rtype: nio.AsyncClient :rtype: nio.AsyncClient
""" """
client = nio.AsyncClient( client = nio.AsyncClient(
@ -105,7 +106,7 @@ async def send_message(
html: Optional[str] = None, html: Optional[str] = None,
) -> nio.RoomSendResponse: ) -> nio.RoomSendResponse:
""" """
:exception MatrixException: if the matrix server returns an error. :exception MatrixException: if the Matrix server returns an error.
:param client: the client to operate on. :param client: the client to operate on.
:param room_id: the room to send the message to. :param room_id: the room to send the message to.
:param text: the text to send. :param text: the text to send.
@ -135,11 +136,11 @@ async def send_message(
async def resolve_room(client: nio.AsyncClient, room: str) -> str: async def resolve_room(client: nio.AsyncClient, room: str) -> str:
""" """
Takes a room alias or room id and always returns a resolved room id. Takes a room alias or room id and always returns a resolved room id.
:exception MatrixException: if the matrix server returns an error. :exception MatrixException: if the Matrix server returns an error.
:exception RuntimeError: if the passed room string cannot be handled. :exception RuntimeError: if the passed room string cannot be handled.
:param client: the client to operate on. :param client: the client to operate on.
:param room: the room to resolve. :param room: the room to resolve.
:returns: the room's matrix id, starting with a "!". :returns: the room's Matrix id, starting with a "!".
:rtype: str :rtype: str
""" """

View file

@ -20,6 +20,7 @@
import argparse import argparse
import asyncio import asyncio
import logging
import re import re
import sys import sys
@ -47,11 +48,15 @@ async def main():
username: ... username: ...
password: "..." password: "..."
""" """
logging.basicConfig()
cfg = load_configuration() cfg = load_configuration()
parser = argparse.ArgumentParser(description="Notify a matrix channel.") parser = argparse.ArgumentParser(description="Notify a Matrix room.")
parser.add_argument( parser.add_argument(
"-c", "--channel", required=True, help="the channel to send the message to" "-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( parser.add_argument(
"-t", "-t",
@ -61,19 +66,27 @@ async def main():
choices=("m.text", "m.notice"), choices=("m.text", "m.notice"),
default="m.text", default="m.text",
) )
parser.add_argument("text", help="the text message to send to the channel") parser.add_argument("text", help="the text message to send to the room")
parser.add_argument( parser.add_argument(
"html", nargs="?", help="the html message to send to the channel" "html", nargs="?", help="the html message to send to the room"
) )
args = parser.parse_args() args = parser.parse_args()
if room_pattern.fullmatch(args.channel) is None: if not args.channel and not args.room:
print("ERROR: Couldn't parse channel as a matrix channel", file=sys.stderr) 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) sys.exit(1)
client = await client_login(cfg) client = await client_login(cfg)
try: try:
room_id = await resolve_room(client=client, room=args.channel) room_id = await resolve_room(client=client, room=room)
response = await client.join(room_id=room_id) response = await client.join(room_id=room_id)
if isinstance(response, nio.ErrorResponse): if isinstance(response, nio.ErrorResponse):
raise MatrixException(response) raise MatrixException(response)
@ -90,10 +103,9 @@ async def main():
response = await send_message( response = await send_message(
client=client, room_id=room_id, text=args.text, msgtype=args.type client=client, room_id=room_id, text=args.text, msgtype=args.type
) )
print("Message sent.", file=sys.stderr, flush=True)
finally: finally:
await client.close() await client.close()
print(response.event_id) logging.info("Message sent. %s", response.event_id)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -18,9 +18,8 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import json import json
import logging
import re import re
import sys
import traceback
from datetime import datetime from datetime import datetime
from typing import Tuple, Optional, Dict, Any from typing import Tuple, Optional, Dict, Any
@ -41,8 +40,10 @@ from .common import (
RequestArgs = MultiDict[str, str] RequestArgs = MultiDict[str, str]
app = Flask(__name__) logging.basicConfig()
application = app
# application is the wsgi variable name
application = Flask(__name__)
# Not going to care for specifics like the underscore. # Not going to care for specifics like the underscore.
# Generally match room alias or id [!#]anything:example.com with unicode support. # Generally match room alias or id [!#]anything:example.com with unicode support.
@ -51,49 +52,31 @@ room_pattern = re.compile(r"^[!#]\w+:[\w\-.]+$")
def check_token(configuration: Cfg, token: str): def check_token(configuration: Cfg, token: str):
if token != configuration["secret"]: if token != configuration["secret"]:
print( logging.warning("request denied (401): check_token failed, because token did not match")
"check_token failed, because token did not match",
file=sys.stderr,
flush=True,
)
abort(401) abort(401)
async def get_a_room(client: nio.AsyncClient, request_args: RequestArgs) -> str: 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.""" """Takes a nio.AsyncClient and the request args to return a room id."""
if "channel" not in request_args: if "channel" not in request_args and "room" not in request_args:
print( logging.warning("request denied (400): get_a_room failed, because room was not in request args")
"get_a_room failed, because channel was not in request args",
file=sys.stderr,
flush=True,
)
abort(400) abort(400)
room = request_args.get("channel") room = request_args.get("channel", "")
room = request_args.get("room", room)
if not room: if not room:
print( logging.warning("request denied (400): get_a_room failed, because room was empty")
"get_a_room failed, because channel was empty",
file=sys.stderr,
flush=True,
)
abort(400) abort(400)
# sanitize input # sanitize input
if room_pattern.fullmatch(room) is None: if room_pattern.fullmatch(room) is None:
print( logging.warning("request denied (400): get_a_room failed, because room '%s' did not match room pattern '%s'", room, room_pattern)
"get_a_room failed, because channel",
room,
"did not match room pattern",
room_pattern,
file=sys.stderr,
flush=True,
)
abort(400) abort(400)
try: try:
return await resolve_room(client=client, room=room) return await resolve_room(client=client, room=room)
except MatrixException as error: except MatrixException as error:
abort(app.make_response(error.format_response())) abort(application.make_response(error.format_response()))
def get_msg_type(request_args: RequestArgs): def get_msg_type(request_args: RequestArgs):
@ -103,13 +86,7 @@ def get_msg_type(request_args: RequestArgs):
if msgtype in ["m.text", "m.notice"]: if msgtype in ["m.text", "m.notice"]:
return msgtype return msgtype
else: else:
print( logging.warning("request denied (400): get_msg_type failed, because msgtype '%s' is not known", msgtype)
"get_msg_type failed, because msgtype",
msgtype,
"is not known",
file=sys.stderr,
flush=True,
)
abort(400) abort(400)
@ -199,7 +176,7 @@ async def process_gitlab_request():
return format_response(response) return format_response(response)
except MatrixException as error: except MatrixException as error:
abort(app.make_response(error.format_response())) abort(application.make_response(error.format_response()))
finally: finally:
await client.close() await client.close()
@ -286,7 +263,7 @@ async def process_jenkins_request():
) )
except MatrixException as error: except MatrixException as error:
abort(app.make_response(error.format_response())) abort(application.make_response(error.format_response()))
finally: finally:
await client.close() await client.close()
@ -387,11 +364,7 @@ async def process_prometheus_request():
cfg = load_configuration() cfg = load_configuration()
secret = request.args.get("secret") secret = request.args.get("secret")
if secret != cfg["secret"]: if secret != cfg["secret"]:
print( logging.warning("check_token failed, because token did not match")
"check_token failed, because token did not match",
file=sys.stderr,
flush=True,
)
abort(401) abort(401)
try: try:
@ -428,12 +401,10 @@ async def process_prometheus_request():
room_id=room_id, room_id=room_id,
text="Error parsing data in prometheus request", text="Error parsing data in prometheus request",
) )
print("Error parsing JSON and forming message:", file=sys.stderr) logging.exception("Error parsing JSON and forming message")
traceback.print_exc()
print(file=sys.stderr, flush=True)
return "Error parsing JSON and forming message", 500 return "Error parsing JSON and forming message", 500
except MatrixException as error: except MatrixException as error:
abort(app.make_response(error.format_response())) abort(application.make_response(error.format_response()))
finally: finally:
await client.close() await client.close()
@ -441,7 +412,7 @@ async def process_prometheus_request():
return "", 204 return "", 204
@app.post("/matrix") @application.post("/matrix")
async def notify(): async def notify():
if "X-Gitlab-Token" in request.headers: if "X-Gitlab-Token" in request.headers:
return await process_gitlab_request() return await process_gitlab_request()