diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9dcb006 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +- Simplify code + in [#1](https://github.com/nim65s/matrix-webhook/pull/1) + by [@homeworkprod](https://github.com/homeworkprod) +- Update aiohttp use and docs + in [#5](https://github.com/nim65s/matrix-webhook/pull/5) + by [@svenseeberg](https://github.com/svenseeberg) +- Setup Tests, Coverage & CI ; update tooling + in [#7](https://github.com/nim65s/matrix-webhook/pull/7) + by [@nim65s](https://github.com/nim65s) +- Setup argparse & logging + in [#8](https://github.com/nim65s/matrix-webhook/pull/8) + by [@nim65s](https://github.com/nim65s) +- Setup packaging + in [#9](https://github.com/nim65s/matrix-webhook/pull/9) + by [@nim65s](https://github.com/nim65s) + +## [1.0.0] - 2020-03-14 +- Update to matrix-nio & aiohttp & markdown + +## [1.0.0] - 2020-02-14 +- First release with matrix-client & http.server + +[Unreleased]: https://github.com/nim65s/matrix-webhook/compare/v2.0.0...devel +[2.0.0]: https://github.com/nim65s/matrix-webhook/compare/v1.0.0...v2.0.0 +[1.0.0]: https://github.com/nim65s/matrix-webhook/releases/tag/v1.0.0 diff --git a/Dockerfile b/Dockerfile index da6ffa2..2eb8198 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,6 @@ EXPOSE 4785 RUN pip install --no-cache-dir markdown matrix-nio -ADD matrix_webhook.py / +ADD matrix_webhook matrix_webhook -CMD /matrix_webhook.py +ENTRYPOINT ["python", "-m", "matrix_webhook"] diff --git a/LICENSE b/LICENSE index 939f05c..8960340 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019-2020 tetaneutral.net All rights reserved. +Copyright (c) 2019-2021 tetaneutral.net All rights reserved. BSD 2 Clause License diff --git a/README.md b/README.md index 02ebc4d..d9517ed 100644 --- a/README.md +++ b/README.md @@ -7,27 +7,59 @@ Post a message to a matrix room with a simple HTTP POST -## Configuration +## Install -Create a matrix user for the bot, make it join the rooms you want it to talk into, and then set the following -environment variables: +``` +python3 -m pip install matrix-webhook +# OR +docker pull nim65s/matrix-webhook +``` + +## Start + +Create a matrix user for the bot, make it join the rooms you want it to talk into, and launch it with the following +arguments or environment variables: + +``` +python -m matrix_webhook -h +# OR +docker run --rm -it nim65s/matrix-webhook -h +``` + +``` +usage: python -m matrix_webhook [-h] [-H HOST] [-P PORT] [-u MATRIX_URL] -i MATRIX_ID -p MATRIX_PW -k API_KEY [-v] + +Configuration for Matrix Webhook. + + +optional arguments: + -h, --help show this help message and exit + -H HOST, --host HOST host to listen to. Default: `''`. Environment variable: `HOST` + -P PORT, --port PORT port to listed to. Default: 4785. Environment variable: `PORT` + -u MATRIX_URL, --matrix-url MATRIX_URL + matrix homeserver url. Default: `https://matrix.org`. Environment variable: `MATRIX_URL` + -i MATRIX_ID, --matrix-id MATRIX_ID + matrix user-id. Required. Environment variable: `MATRIX_ID` + -p MATRIX_PW, --matrix-pw MATRIX_PW + matrix password. Required. Environment variable: `MATRIX_PW` + -k API_KEY, --api-key API_KEY + shared secret to use this service. Required. Environment variable: `API_KEY` + -v, --verbose increment verbosity level +``` -- `MATRIX_URL`: the url of the matrix homeserver -- `MATRIX_ID`: the user id of the bot on this server -- `MATRIX_PW`: the password for this user -- `API_KEY`: a secret to share with the users of the service -- `HOST`: HOST to listen on, all interfaces if `''` (default). -- `PORT`: PORT to listed on, default to 4785. ## Dev ``` -pip3 install --user markdown matrix-nio -./matrix_webhook.py +poetry install +# or python3 -m pip install --user markdown matrix-nio +python3 -m matrix_webhook ``` ## Prod +A `docker-compose.yml` is provided: + - Use [Traefik](https://traefik.io/) on the `web` docker network, eg. with [proxyta.net](https://framagit.org/oxyta.net/proxyta.net) - Put the configuration into a `.env` file @@ -47,4 +79,10 @@ curl -d '{"text":"new contrib from toto: [44](http://radio.localhost/map/#44)", ## Test room -[#matrix-webhook:tetaneutral.net](https://matrix.to/#/!DPrUlnwOhBEfYwsDLh:matrix.org?via=laas.fr&via=tetaneutral.net&via=aen.im) +#matrix-webhook:tetaneutral.net](https://matrix.to/#/!DPrUlnwOhBEfYwsDLh:matrix.org?via=laas.fr&via=tetaneutral.net&via=aen.im) + +## Unit tests + +``` +docker-compose -f test.yml up --exit-code-from tests --force-recreate --build +``` diff --git a/matrix_webhook.py b/matrix_webhook.py deleted file mode 100755 index f754680..0000000 --- a/matrix_webhook.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 -""" -Matrix Webhook. - -Post a message to a matrix room with a simple HTTP POST -""" - -import argparse -import asyncio -import json -import logging -import os -from http import HTTPStatus -from signal import SIGINT, SIGTERM - -from aiohttp import web -from markdown import markdown -from nio import AsyncClient -from nio.exceptions import LocalProtocolError - -parser = argparse.ArgumentParser(description=__doc__) -parser.add_argument( - "-H", - "--host", - default=os.environ.get("HOST", ""), - help="host to listen to. Default: `''`. Environment variable: `HOST`", -) -parser.add_argument( - "-P", - "--port", - type=int, - default=os.environ.get("PORT", 4785), - help="port to listed to. Default: 4785. Environment variable: `PORT`", -) -parser.add_argument( - "-u", - "--matrix-url", - default=os.environ.get("MATRIX_URL", "https://matrix.org"), - help="matrix homeserver url. Default: `https://matrix.org`. Environment variable: `MATRIX_URL`", -) -parser.add_argument( - "-i", - "--matrix-id", - help="matrix user-id. Required. Environment variable: `MATRIX_ID`", - **( - {"default": os.environ["MATRIX_ID"]} - if "MATRIX_ID" in os.environ - else {"required": True} - ), -) -parser.add_argument( - "-p", - "--matrix-pw", - help="matrix password. Required. Environment variable: `MATRIX_PW`", - **( - {"default": os.environ["MATRIX_PW"]} - if "MATRIX_PW" in os.environ - else {"required": True} - ), -) -parser.add_argument( - "-k", - "--api-key", - help="shared secret to use this service. Required. Environment variable: `API_KEY`", - **( - {"default": os.environ["API_KEY"]} - if "API_KEY" in os.environ - else {"required": True} - ), -) -parser.add_argument( - "-v", "--verbose", action="count", default=0, help="increment verbosity level" -) - -args = parser.parse_args() -logging.basicConfig(level=50 - 10 * args.verbose) - -SERVER_ADDRESS = (args.host, args.port) -MATRIX_URL = args.matrix_url -MATRIX_ID = args.matrix_id -MATRIX_PW = args.matrix_pw -API_KEY = args.api_key -CLIENT = AsyncClient(args.matrix_url, args.matrix_id) - - -async def handler(request): - """ - Coroutine given to the server, st. it knows what to do with an HTTP request. - - This one handles a POST, checks its content, and forwards it to the matrix room. - """ - logging.debug(f"Handling {request=}") - data = await request.read() - - try: - data = json.loads(data.decode()) - except json.decoder.JSONDecodeError: - return create_json_response(HTTPStatus.BAD_REQUEST, "Invalid JSON") - - if not all(key in data for key in ["text", "key"]): - return create_json_response( - HTTPStatus.BAD_REQUEST, "Missing text and/or API key property" - ) - - if data["key"] != API_KEY: - return create_json_response(HTTPStatus.UNAUTHORIZED, "Invalid API key") - - room_id = request.path[1:] - content = { - "msgtype": "m.text", - "body": data["text"], - "format": "org.matrix.custom.html", - "formatted_body": markdown(str(data["text"]), extensions=["extra"]), - } - try: - await send_room_message(room_id, content) - except LocalProtocolError as e: # Connection lost, try another login - logging.error(f"Send error: {e}") - logging.warning("Reconnecting and trying again") - await CLIENT.login(MATRIX_PW) - await send_room_message(room_id, content) - - return create_json_response(HTTPStatus.OK, "OK") - - -def create_json_response(status, ret): - """Create a JSON response.""" - logging.debug(f"Creating json response: {status=}, {ret=}") - response_data = {"status": status, "ret": ret} - return web.json_response(response_data, status=status) - - -async def send_room_message(room_id, content): - """Send a message to a room.""" - logging.debug(f"Sending room message in {room_id=}: {content=}") - return await CLIENT.room_send( - room_id=room_id, message_type="m.room.message", content=content - ) - - -async def main(event): - """ - Launch main coroutine. - - matrix client login & start web server - """ - logging.info(f"Log in {MATRIX_ID=} on {MATRIX_URL=}") - await CLIENT.login(MATRIX_PW) - - server = web.Server(handler) - runner = web.ServerRunner(server) - await runner.setup() - logging.info(f"Binding on {SERVER_ADDRESS=}") - site = web.TCPSite(runner, *SERVER_ADDRESS) - await site.start() - - # Run until we get a shutdown request - await event.wait() - - # Cleanup - await runner.cleanup() - await CLIENT.close() - - -def terminate(event, signal): - """Close handling stuff.""" - event.set() - asyncio.get_event_loop().remove_signal_handler(signal) - - -def run(): - """Launch everything.""" - logging.info("Matrix Webhook starting...") - loop = asyncio.get_event_loop() - event = asyncio.Event() - - for sig in (SIGINT, SIGTERM): - loop.add_signal_handler(sig, terminate, event, sig) - - loop.run_until_complete(main(event)) - - logging.info("Matrix Webhook closing...") - loop.close() - - -if __name__ == "__main__": - run() diff --git a/matrix_webhook/__main__.py b/matrix_webhook/__main__.py new file mode 100644 index 0000000..2b4a7d7 --- /dev/null +++ b/matrix_webhook/__main__.py @@ -0,0 +1,143 @@ +""" +Matrix Webhook. + +Post a message to a matrix room with a simple HTTP POST +""" + +import asyncio +import json +import logging +from http import HTTPStatus +from signal import SIGINT, SIGTERM + +from aiohttp import web +from markdown import markdown +from nio import AsyncClient +from nio.exceptions import LocalProtocolError +from nio.responses import RoomSendError + +from . import conf + +ERROR_MAP = {"M_FORBIDDEN": HTTPStatus.FORBIDDEN} + +CLIENT = AsyncClient(conf.MATRIX_URL, conf.MATRIX_ID) +LOGGER = logging.getLogger("matrix-webhook") + + +async def handler(request): + """ + Coroutine given to the server, st. it knows what to do with an HTTP request. + + This one handles a POST, checks its content, and forwards it to the matrix room. + """ + LOGGER.debug(f"Handling {request=}") + data = await request.read() + + try: + data = json.loads(data.decode()) + except json.decoder.JSONDecodeError: + return create_json_response(HTTPStatus.BAD_REQUEST, "Invalid JSON") + + if not all(key in data for key in ["text", "key"]): + return create_json_response( + HTTPStatus.BAD_REQUEST, "Missing text and/or API key property" + ) + + if data["key"] != conf.API_KEY: + return create_json_response(HTTPStatus.UNAUTHORIZED, "Invalid API key") + + room_id = request.path[1:] + content = { + "msgtype": "m.text", + "body": data["text"], + "format": "org.matrix.custom.html", + "formatted_body": markdown(str(data["text"]), extensions=["extra"]), + } + for _ in range(10): + try: + resp = await send_room_message(room_id, content) + if isinstance(resp, RoomSendError): + if resp.status_code == "M_UNKNOWN_TOKEN": + LOGGER.warning("Reconnecting") + await CLIENT.login(conf.MATRIX_PW) + else: + return create_json_response( + ERROR_MAP[resp.status_code], resp.message + ) + else: + break + except LocalProtocolError as e: + LOGGER.error(f"Send error: {e}") + LOGGER.warning("Trying again") + else: + return create_json_response( + HTTPStatus.GATEWAY_TIMEOUT, "Homeserver not responding" + ) + + return create_json_response(HTTPStatus.OK, "OK") + + +def create_json_response(status, ret): + """Create a JSON response.""" + LOGGER.debug(f"Creating json response: {status=}, {ret=}") + response_data = {"status": status, "ret": ret} + return web.json_response(response_data, status=status) + + +async def send_room_message(room_id, content): + """Send a message to a room.""" + LOGGER.debug(f"Sending room message in {room_id=}: {content=}") + return await CLIENT.room_send( + room_id=room_id, message_type="m.room.message", content=content + ) + + +async def main(event): + """ + Launch main coroutine. + + matrix client login & start web server + """ + LOGGER.info(f"Log in {conf.MATRIX_ID=} on {conf.MATRIX_URL=}") + await CLIENT.login(conf.MATRIX_PW) + + server = web.Server(handler) + runner = web.ServerRunner(server) + await runner.setup() + LOGGER.info(f"Binding on {conf.SERVER_ADDRESS=}") + site = web.TCPSite(runner, *conf.SERVER_ADDRESS) + await site.start() + + # Run until we get a shutdown request + await event.wait() + + # Cleanup + await runner.cleanup() + await CLIENT.close() + + +def terminate(event, signal): + """Close handling stuff.""" + event.set() + asyncio.get_event_loop().remove_signal_handler(signal) + + +def run(): + """Launch everything.""" + LOGGER.info("Starting...") + loop = asyncio.get_event_loop() + event = asyncio.Event() + + for sig in (SIGINT, SIGTERM): + loop.add_signal_handler(sig, terminate, event, sig) + + loop.run_until_complete(main(event)) + + LOGGER.info("Closing...") + loop.close() + + +if __name__ == "__main__": + log_format = "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s" + logging.basicConfig(level=50 - 10 * conf.VERBOSE, format=log_format) + run() diff --git a/matrix_webhook/conf.py b/matrix_webhook/conf.py new file mode 100644 index 0000000..fd39e02 --- /dev/null +++ b/matrix_webhook/conf.py @@ -0,0 +1,66 @@ +"""Configuration for Matrix Webhook.""" +import argparse +import os + +parser = argparse.ArgumentParser(description=__doc__, prog="python -m matrix_webhook") +parser.add_argument( + "-H", + "--host", + default=os.environ.get("HOST", ""), + help="host to listen to. Default: `''`. Environment variable: `HOST`", +) +parser.add_argument( + "-P", + "--port", + type=int, + default=os.environ.get("PORT", 4785), + help="port to listed to. Default: 4785. Environment variable: `PORT`", +) +parser.add_argument( + "-u", + "--matrix-url", + default=os.environ.get("MATRIX_URL", "https://matrix.org"), + help="matrix homeserver url. Default: `https://matrix.org`. Environment variable: `MATRIX_URL`", +) +parser.add_argument( + "-i", + "--matrix-id", + help="matrix user-id. Required. Environment variable: `MATRIX_ID`", + **( + {"default": os.environ["MATRIX_ID"]} + if "MATRIX_ID" in os.environ + else {"required": True} + ), +) +parser.add_argument( + "-p", + "--matrix-pw", + help="matrix password. Required. Environment variable: `MATRIX_PW`", + **( + {"default": os.environ["MATRIX_PW"]} + if "MATRIX_PW" in os.environ + else {"required": True} + ), +) +parser.add_argument( + "-k", + "--api-key", + help="shared secret to use this service. Required. Environment variable: `API_KEY`", + **( + {"default": os.environ["API_KEY"]} + if "API_KEY" in os.environ + else {"required": True} + ), +) +parser.add_argument( + "-v", "--verbose", action="count", default=0, help="increment verbosity level" +) + +args = parser.parse_args() + +SERVER_ADDRESS = (args.host, args.port) +MATRIX_URL = args.matrix_url +MATRIX_ID = args.matrix_id +MATRIX_PW = args.matrix_pw +API_KEY = args.api_key +VERBOSE = args.verbose diff --git a/poetry.lock b/poetry.lock index fcf852f..c600d93 100644 --- a/poetry.lock +++ b/poetry.lock @@ -87,7 +87,7 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (> [[package]] name = "black" -version = "21.6b0" +version = "21.7b0" description = "The uncompromising code formatter." category = "dev" optional = false @@ -99,7 +99,7 @@ click = ">=7.1.2" mypy-extensions = ">=0.4.3" pathspec = ">=0.8.1,<1" regex = ">=2020.1.8" -toml = ">=0.10.1" +tomli = ">=0.2.6,<2.0.0" [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -301,7 +301,7 @@ testing = ["coverage", "pyyaml"] [[package]] name = "matrix-nio" -version = "0.18.3" +version = "0.18.4" description = "A Python Matrix client library, designed according to sans I/O principles." category = "main" optional = false @@ -320,7 +320,7 @@ pycryptodome = ">=3.10.1,<4.0.0" unpaddedbase64 = ">=2.1.0,<3.0.0" [package.extras] -e2e = ["python-olm (>=3.1.3,<4.0.0)", "peewee (>=3.14.4,<4.0.0)", "cachetools (>=4.2.1,<5.0.0)", "atomicwrites (>=1.4.0,<2.0.0)"] +e2e = ["atomicwrites (>=1.4.0,<2.0.0)", "cachetools (>=4.2.1,<5.0.0)", "peewee (>=3.14.4,<4.0.0)", "python-olm (>=3.1.3,<4.0.0)"] [[package]] name = "mccabe" @@ -348,11 +348,11 @@ python-versions = "*" [[package]] name = "pathspec" -version = "0.8.1" +version = "0.9.0" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [[package]] name = "pycodestyle" @@ -463,12 +463,12 @@ optional = false python-versions = "*" [[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" +name = "tomli" +version = "1.0.4" +description = "A lil' TOML parser" category = "dev" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.6" [[package]] name = "typing-extensions" @@ -568,8 +568,8 @@ attrs = [ {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] black = [ - {file = "black-21.6b0-py3-none-any.whl", hash = "sha256:dfb8c5a069012b2ab1e972e7b908f5fb42b6bbabcba0a788b86dc05067c7d9c7"}, - {file = "black-21.6b0.tar.gz", hash = "sha256:dc132348a88d103016726fe360cb9ede02cecf99b76e3660ce6c596be132ce04"}, + {file = "black-21.7b0-py3-none-any.whl", hash = "sha256:1c7aa6ada8ee864db745b22790a32f94b2795c253a75d6d9b5e439ff10d23116"}, + {file = "black-21.7b0.tar.gz", hash = "sha256:c8373c6491de9362e39271630b65b964607bc5c79c83783547d76c839b3aa219"}, ] certifi = [ {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, @@ -696,8 +696,8 @@ markdown = [ {file = "Markdown-3.3.4.tar.gz", hash = "sha256:31b5b491868dcc87d6c24b7e3d19a0d730d59d3e46f4eea6430a321bed387a49"}, ] matrix-nio = [ - {file = "matrix-nio-0.18.3.tar.gz", hash = "sha256:7f2e92f5b219367e47824bfe8bd2b1a06ce83ae28956f112dd3c2112a4d27085"}, - {file = "matrix_nio-0.18.3-py3-none-any.whl", hash = "sha256:a28653f96760b045c7edc53b645872cf2facc1639dc8cf56d748cd5e54ed2d3d"}, + {file = "matrix-nio-0.18.4.tar.gz", hash = "sha256:e5f0a62ff66474f5c56dc40c3eb3c74a29943800589ae6947ea224c288f3ab41"}, + {file = "matrix_nio-0.18.4-py3-none-any.whl", hash = "sha256:7ea00ae362a3621624b8ff463a2b06cb945ffa12e2f3919cae5321d06285a361"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -747,8 +747,8 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] pathspec = [ - {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, - {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, ] pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, @@ -880,9 +880,9 @@ snowballstemmer = [ {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, ] -toml = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +tomli = [ + {file = "tomli-1.0.4-py3-none-any.whl", hash = "sha256:0713b16ff91df8638a6a694e295c8159ab35ba93e3424a626dd5226d386057be"}, + {file = "tomli-1.0.4.tar.gz", hash = "sha256:be670d0d8d7570fd0ea0113bd7bb1ba3ac6706b4de062cc4c952769355c9c268"}, ] typing-extensions = [ {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, diff --git a/tests/Dockerfile b/tests/Dockerfile index 0ff4cb2..42b60b9 100644 --- a/tests/Dockerfile +++ b/tests/Dockerfile @@ -17,4 +17,4 @@ RUN pip install --no-cache-dir markdown matrix-nio httpx coverage WORKDIR /app -CMD ./tests/start.py +CMD ./tests/start.py -vvv diff --git a/tests/start.py b/tests/start.py index 27c184c..3df3418 100755 --- a/tests/start.py +++ b/tests/start.py @@ -1,6 +1,8 @@ #!/usr/bin/env python """Entry point to start an instrumentalized bot for coverage and run tests.""" +import argparse +import logging from os import environ from subprocess import Popen, run from time import time @@ -15,6 +17,12 @@ KEY, MATRIX_URL, MATRIX_ID, MATRIX_PW = ( environ[v] for v in ["API_KEY", "MATRIX_URL", "MATRIX_ID", "MATRIX_PW"] ) FULL_ID = f'@{MATRIX_ID}:{MATRIX_URL.split("/")[2]}' +LOGGER = logging.getLogger("matrix-webhook.tests.start") + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument( + "-v", "--verbose", action="count", default=0, help="increment verbosity level" +) def bot_req(req=None, key=None, room_id=None): @@ -47,6 +55,7 @@ def wait_available(url: str, key: str, timeout: int = 10) -> bool: def run_and_test(): """Launch the bot and its tests.""" # Start the server, and wait for it + LOGGER.info("Spawning synapse") srv = Popen( [ "python", @@ -60,29 +69,38 @@ def run_and_test(): return False # Register a user for the bot. + LOGGER.info("Registering the bot") with open("/srv/homeserver.yaml") as f: secret = yaml.safe_load(f.read()).get("registration_shared_secret", None) request_registration(MATRIX_ID, MATRIX_PW, MATRIX_URL, secret, admin=True) # Start the bot, and wait for it - bot = Popen(["coverage", "run", "matrix_webhook.py"]) + LOGGER.info("Spawning the bot") + bot = Popen(["coverage", "run", "-m", "matrix_webhook", "-vvvvv"]) if not wait_available(BOT_URL, "status"): return False # Run the main unittest module + LOGGER.info("Runnig unittests") ret = main(module=None, exit=False).result.wasSuccessful() + LOGGER.info("Stopping synapse") srv.terminate() # TODO Check what the bot says when the server is offline # print(bot_req({'text': 'bye'}, KEY), {'status': 200, 'ret': 'OK'}) + LOGGER.info("Stopping the bot") bot.terminate() + LOGGER.info("Processing coverage") for cmd in ["report", "html", "xml"]: run(["coverage", cmd]) return ret if __name__ == "__main__": + args = parser.parse_args() + log_format = "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s" + logging.basicConfig(level=50 - 10 * args.verbose, format=log_format) exit(not run_and_test()) diff --git a/tests/tests.py b/tests/tests.py index f6ec6f9..c92fb95 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -20,9 +20,11 @@ class BotTest(unittest.IsolatedAsyncioTestCase): self.assertEqual( bot_req({"text": 3, "key": None}), {"status": 401, "ret": "Invalid API key"} ) - - # TODO: we are not sending to a real room, so this should not be "OK" - self.assertEqual(bot_req({"text": 3}, KEY), {"status": 200, "ret": "OK"}) + # TODO: if the client from matrix_webhook has olm support, this won't be a 403 from synapse, + # but a LocalProtocolError from matrix_webhook + self.assertEqual( + bot_req({"text": 3}, KEY), {"status": 403, "ret": "Unknown room"} + ) async def test_message(self): """Send a markdown message, and check the result.""" @@ -45,3 +47,15 @@ class BotTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(message.sender, FULL_ID) self.assertEqual(message.body, text) self.assertEqual(message.formatted_body, "

Hello

") + + async def test_reconnect(self): + """Check the reconnecting path.""" + client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) + await client.login(MATRIX_PW) + room = await client.room_create() + await client.logout(all_devices=True) + await client.close() + self.assertEqual( + bot_req({"text": "Re"}, KEY, room.room_id), + {"status": 200, "ret": "OK"}, + )