├── .github ├── dependabot.yml └── workflows │ ├── docker-hub.yml │ ├── entrypoint.yml │ ├── lint.yml │ ├── nix.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .mergify.yml ├── .nixignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── docs ├── release.md └── release.sh ├── flake.lock ├── flake.nix ├── matrix_webhook ├── __main__.py ├── app.py ├── conf.py ├── formatters.py ├── handler.py └── utils.py ├── poetry.lock ├── pyproject.toml ├── test.yml └── tests ├── .env ├── Dockerfile ├── __init__.py ├── example_github_push.json ├── example_gitlab_gchat.json ├── example_gitlab_teams.json ├── example_grafana.json ├── example_grafana_9x.json ├── example_grn.json ├── start.py ├── test_github.py ├── test_gitlab_gchat.py ├── test_gitlab_teams.py ├── test_grafana.py ├── test_grafana_9x.py ├── test_grafana_forward.py ├── test_grn.py └── tests.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | -------------------------------------------------------------------------------- /.github/workflows/docker-hub.yml: -------------------------------------------------------------------------------- 1 | name: Publish on Docker Hub 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | docker-hub: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: docker/metadata-action@v5 16 | id: meta 17 | with: 18 | images: nim65s/matrix-webhook 19 | - uses: docker/setup-qemu-action@v3 20 | name: Set up QEMU 21 | - uses: docker/setup-buildx-action@v3 22 | name: Set up Docker Buildx 23 | - uses: docker/login-action@v3 24 | with: 25 | username: nim65s 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | - uses: docker/build-push-action@v6 28 | with: 29 | context: . 30 | push: true 31 | tags: ${{ steps.meta.outputs.tags }} 32 | labels: ${{ steps.meta.outputs.labels }} 33 | platforms: linux/amd64,linux/arm64 34 | -------------------------------------------------------------------------------- /.github/workflows/entrypoint.yml: -------------------------------------------------------------------------------- 1 | name: Test entrypoints 2 | on: [push, pull_request] 3 | jobs: 4 | test-entrypoints: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: actions/setup-python@v5 9 | with: 10 | python-version: '3.10' 11 | - run: python -m pip install -U pip 12 | - run: python -m pip install . 13 | - run: matrix-webhook -h 14 | - run: python -m matrix_webhook -h 15 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lints 2 | on: [push, pull_request] 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - run: pipx install poetry 9 | - uses: actions/setup-python@v5 10 | with: 11 | python-version: '3.10' 12 | cache: poetry 13 | - run: poetry install --with dev --no-interaction 14 | - run: poetry run ruff format . 15 | - run: poetry run ruff check . 16 | - run: poetry run safety check --ignore 70612 17 | - run: poetry run poetry check 18 | -------------------------------------------------------------------------------- /.github/workflows/nix.yml: -------------------------------------------------------------------------------- 1 | name: "Nix CI" 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | tests: 7 | name: "Nix build on ${{ matrix.os }}" 8 | runs-on: "${{ matrix.os }}-latest" 9 | strategy: 10 | matrix: 11 | os: [ubuntu, macos] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: DeterminateSystems/nix-installer-action@main 15 | - uses: DeterminateSystems/magic-nix-cache-action@main 16 | - uses: DeterminateSystems/flake-checker-action@main 17 | - run: nix build 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release on GitHub & PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - run: pipx install poetry 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.10" 17 | cache: poetry 18 | - run: poetry publish --build -u __token__ -p ${{ secrets.PYPI_TOKEN }} 19 | - run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 20 | - run: gh release create -t "Release ${{ env.TAG}}" -n "$(awk '/## \[${{ env.TAG }}] - /{flag=1;next}/## \[/{flag=0}flag' CHANGELOG.md)" ${{ env.TAG }} dist/* 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - run: docker compose -f test.yml up --exit-code-from tests 9 | - uses: codecov/codecov-action@v5 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .env 3 | .mypy_cache 4 | coverage.xml 5 | htmlcov 6 | __pycache__ 7 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: merge automatically when CI passes and PR is approved 3 | conditions: 4 | - check-success = lint 5 | - check-success = Nix build on ubuntu 6 | - check-success = Nix build on macos 7 | - check-success = tests 8 | - check-success = test-entrypoints 9 | - or: 10 | - approved-reviews-by = nim65s 11 | - author = nim65s 12 | - author = pre-commit-ci[bot] 13 | - author = dependabot[bot] 14 | actions: 15 | merge: 16 | -------------------------------------------------------------------------------- /.nixignore: -------------------------------------------------------------------------------- 1 | *.nix 2 | .git* 3 | .nixignore 4 | .pre-commit-config.yaml 5 | Dockerfile 6 | docker-compose.yml 7 | CHANGELOG.md 8 | LICENSE 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.7 4 | hooks: 5 | - id: ruff 6 | args: 7 | - --fix 8 | - --exit-non-zero-on-fix 9 | - id: ruff-format 10 | - repo: https://github.com/nim65s/pre-commit-sort 11 | rev: v0.4.0 12 | hooks: 13 | - id: pre-commit-sort 14 | - repo: https://github.com/pappasam/toml-sort 15 | rev: v0.24.2 16 | hooks: 17 | - id: toml-sort-fix 18 | exclude: poetry.lock 19 | - repo: https://github.com/pre-commit/pre-commit-hooks 20 | rev: v5.0.0 21 | hooks: 22 | - id: check-added-large-files 23 | - id: check-ast 24 | - id: check-executables-have-shebangs 25 | - id: check-json 26 | - id: check-merge-conflict 27 | - id: check-symlinks 28 | - id: check-toml 29 | - id: check-yaml 30 | - id: debug-statements 31 | - id: destroyed-symlinks 32 | - id: detect-private-key 33 | - id: end-of-file-fixer 34 | - id: fix-byte-order-marker 35 | - id: mixed-line-ending 36 | - id: trailing-whitespace 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | - allow configuration of verbosity through environment variable `VERBOSITY` 10 | in [#169](https://github.com/nim65s/matrix-webhook/pull/169) 11 | by [@nim65s](https://github.com/nim65s) 12 | - setup mergify 13 | 14 | ## [v3.9.1] - 2024-03-09 15 | 16 | - fix release script to bump version number in flake.nix 17 | 18 | ## [v3.9.0] - 2024-03-09 19 | 20 | - add github-release-notifier formatter 21 | in [#93](https://github.com/nim65s/matrix-webhook/pull/93) 22 | by [@nrekretep](https://github.com/nrekretep) 23 | - optionally serve hooks via a UNIX domain socket 24 | in [#123](https://github.com/nim65s/matrix-webhook/pull/123) 25 | by [@timo-schluessler](https://github.com/timo-schluessler) 26 | - try to reconnect on LocalProtocolError 27 | in [#135](https://github.com/nim65s/matrix-webhook/pull/135) 28 | - flakify 29 | in [#136](https://github.com/nim65s/matrix-webhook/pull/136) 30 | - add mwe gitlab webhook formatter. Intergrations are better for now 31 | - replace black & isort by ruff 32 | - update dependencies 33 | 34 | ## [v3.8.0] - 2023-04-08 35 | 36 | - add a healthcheck for load balancers 37 | in [#77](https://github.com/nim65s/matrix-webhook/pull/77) 38 | by [@chronicc](https://github.com/chronicc) 39 | - error_map: default to 500 40 | - tools: flake8, pydocstyle, pyupgrade → ruff 41 | 42 | ## [v3.7.0] - 2023-03-08 43 | 44 | - Add support for using predefined access tokens 45 | in [#67](https://github.com/nim65s/matrix-webhook/pull/67) 46 | by [@ananace](https://github.com/ananace) 47 | 48 | ## [v3.6.0] - 2023-03-07 49 | 50 | - update docker to python 3.11 51 | - update tooling 52 | - update lints 53 | - update ci/cd 54 | 55 | ## [v3.5.0] - 2022-09-07 56 | 57 | - Add formatter for grafana 9 58 | in [#45](https://github.com/nim65s/matrix-webhook/pull/45) 59 | by [@svenseeberg](https://github.com/svenseeberg) 60 | 61 | ## [v3.4.0] - 2022-08-12 62 | 63 | - fix tests 64 | - add `matrix-webhook` script 65 | in [#25](https://github.com/nim65s/matrix-webhook/pull/25) 66 | and [#35](https://github.com/nim65s/matrix-webhook/pull/35) 67 | by [@a7p](https://github.com/a7p) 68 | - publish linux/arm64 image 69 | in [#37](https://github.com/nim65s/matrix-webhook/pull/35) 70 | by [@kusold](https://github.com/kusold) 71 | - update badges 72 | - setup dependabot 73 | - misc upgrades from poetry update, pre-commit.ci, and dependabot 74 | 75 | ## [v3.3.0] - 2022-03-04 76 | 77 | - add pyupgrade 78 | - add gitlab formatter for google chat & microsoft teams 79 | in [#21](https://github.com/nim65s/matrix-webhook/pull/21) 80 | by [@GhislainC](https://github.com/GhislainC) 81 | - join room before sending message 82 | in [#12](https://github.com/nim65s/matrix-webhook/pull/12) 83 | by [@bboehmke](https://github.com/bboehmke) 84 | 85 | ## [v3.2.1] - 2021-08-28 86 | 87 | - fix changelog 88 | 89 | ## [v3.2.0] - 2021-08-27 90 | 91 | - add github & grafana formatters 92 | - add formatted_body to bypass markdown with direct 93 | [matrix-custom-HTML](https://matrix.org/docs/spec/client_server/r0.6.1#m-room-message-msgtypes) 94 | - allow "key" to be passed as a parameter 95 | - allow to use a sha256 HMAC hex digest with the key instead of the raw key 96 | - allow "room_id" to be passed as a parameter or with the data 97 | - rename "text" to "body". 98 | - Publish releases also on github from github actions 99 | - fix tests for recent synapse docker image 100 | 101 | ## [v3.1.1] - 2021-07-18 102 | 103 | ## [v3.1.0] - 2021-07-18 104 | 105 | - Publish on PyPI & Docker Hub with Github Actions 106 | in [#10](https://github.com/nim65s/matrix-webhook/pull/10) 107 | by [@nim65s](https://github.com/nim65s) 108 | 109 | ## [v3.0.0] - 2021-07-18 110 | 111 | - Simplify code 112 | in [#1](https://github.com/nim65s/matrix-webhook/pull/1) 113 | by [@homeworkprod](https://github.com/homeworkprod) 114 | - Update aiohttp use and docs 115 | in [#5](https://github.com/nim65s/matrix-webhook/pull/5) 116 | by [@svenseeberg](https://github.com/svenseeberg) 117 | - Setup Tests, Coverage & CI ; update tooling 118 | in [#7](https://github.com/nim65s/matrix-webhook/pull/7) 119 | by [@nim65s](https://github.com/nim65s) 120 | - Setup argparse & logging 121 | in [#8](https://github.com/nim65s/matrix-webhook/pull/8) 122 | by [@nim65s](https://github.com/nim65s) 123 | - Setup packaging 124 | in [#9](https://github.com/nim65s/matrix-webhook/pull/9) 125 | by [@nim65s](https://github.com/nim65s) 126 | 127 | ## [v2.0.0] - 2020-03-14 128 | - Update to matrix-nio & aiohttp & markdown 129 | 130 | ## [v1.0.0] - 2020-02-14 131 | - First release with matrix-client & http.server 132 | 133 | [Unreleased]: https://github.com/nim65s/matrix-webhook/compare/v3.9.1...master 134 | [v3.9.1]: https://github.com/nim65s/matrix-webhook/compare/v3.9.0...v3.9.1 135 | [v3.9.0]: https://github.com/nim65s/matrix-webhook/compare/v3.8.0...v3.9.0 136 | [v3.8.0]: https://github.com/nim65s/matrix-webhook/compare/v3.7.0...v3.8.0 137 | [v3.7.0]: https://github.com/nim65s/matrix-webhook/compare/v3.6.0...v3.7.0 138 | [v3.6.0]: https://github.com/nim65s/matrix-webhook/compare/v3.5.0...v3.6.0 139 | [v3.5.0]: https://github.com/nim65s/matrix-webhook/compare/v3.4.0...v3.5.0 140 | [v3.4.0]: https://github.com/nim65s/matrix-webhook/compare/v3.3.0...v3.4.0 141 | [v3.3.0]: https://github.com/nim65s/matrix-webhook/compare/v3.2.1...v3.3.0 142 | [v3.2.1]: https://github.com/nim65s/matrix-webhook/compare/v3.2.0...v3.2.1 143 | [v3.2.0]: https://github.com/nim65s/matrix-webhook/compare/v3.1.1...v3.2.0 144 | [v3.1.1]: https://github.com/nim65s/matrix-webhook/compare/v3.1.0...v3.1.1 145 | [v3.1.0]: https://github.com/nim65s/matrix-webhook/compare/v3.0.0...v3.1.0 146 | [v3.0.0]: https://github.com/nim65s/matrix-webhook/compare/v2.0.0...v3.0.0 147 | [v2.0.0]: https://github.com/nim65s/matrix-webhook/compare/v1.0.0...v2.0.0 148 | [v1.0.0]: https://github.com/nim65s/matrix-webhook/releases/tag/v1.0.0 149 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | EXPOSE 4785 4 | 5 | RUN pip install --no-cache-dir markdown matrix-nio 6 | 7 | ADD matrix_webhook matrix_webhook 8 | 9 | ENTRYPOINT ["python", "-m", "matrix_webhook"] 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-2021 tetaneutral.net All rights reserved. 2 | 3 | BSD 2 Clause License 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Matrix Webhook 2 | 3 | [![Tests](https://github.com/nim65s/matrix-webhook/actions/workflows/test.yml/badge.svg)](https://github.com/nim65s/matrix-webhook/actions/workflows/test.yml) 4 | [![Lints](https://github.com/nim65s/matrix-webhook/actions/workflows/lint.yml/badge.svg)](https://github.com/nim65s/matrix-webhook/actions/workflows/lint.yml) 5 | [![Docker-Hub](https://github.com/nim65s/matrix-webhook/actions/workflows/docker-hub.yml/badge.svg)](https://hub.docker.com/r/nim65s/matrix-webhook) 6 | [![Release](https://github.com/nim65s/matrix-webhook/actions/workflows/release.yml/badge.svg)](https://pypi.org/project/matrix-webhook/) 7 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/nim65s/matrix-webhook/master.svg)](https://results.pre-commit.ci/latest/github/nim65s/matrix-webhook/main) 8 | 9 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 10 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json)](https://github.com/charliermarsh/ruff) 11 | [![codecov](https://codecov.io/gh/nim65s/matrix-webhook/branch/master/graph/badge.svg?token=BLGISGCYKG)](https://codecov.io/gh/nim65s/matrix-webhook) 12 | [![Maintainability](https://api.codeclimate.com/v1/badges/a0783da8c0461fe95eaf/maintainability)](https://codeclimate.com/github/nim65s/matrix-webhook/maintainability) 13 | [![PyPI version](https://badge.fury.io/py/matrix-webhook.svg)](https://badge.fury.io/py/matrix-webhook) 14 | 15 | Post a message to a matrix room with a simple HTTP POST 16 | 17 | ## Install 18 | 19 | ``` 20 | python3 -m pip install matrix-webhook 21 | # OR 22 | docker pull nim65s/matrix-webhook 23 | ``` 24 | 25 | ## Start 26 | 27 | Create a matrix user for the bot, and launch this app with the following arguments and/or environment variables 28 | (environment variables update defaults, arguments take precedence): 29 | 30 | ``` 31 | matrix-webhook -h 32 | # OR 33 | python -m matrix_webhook -h 34 | # OR 35 | poetry run matrix-webhook -h 36 | # OR 37 | nix run github:nim65s/matrix-webhook -- -h 38 | # OR 39 | docker run --rm -it nim65s/matrix-webhook -h 40 | ``` 41 | 42 | ``` 43 | usage: python -m matrix_webhook [-h] [-H HOST] [-P PORT] [-u MATRIX_URL] -i MATRIX_ID (-p MATRIX_PW | -t MATRIX_TOKEN) -k API_KEY [-v] 44 | 45 | Configuration for Matrix Webhook. 46 | 47 | options: 48 | -h, --help show this help message and exit 49 | -H HOST, --host HOST host to listen to. Default: `''`. Environment variable: `HOST` 50 | -P PORT, --port PORT port to listed to. Default: 4785. Environment variable: `PORT` 51 | -u MATRIX_URL, --matrix-url MATRIX_URL 52 | matrix homeserver url. Default: `https://matrix.org`. Environment variable: `MATRIX_URL` 53 | -i MATRIX_ID, --matrix-id MATRIX_ID 54 | matrix user-id. Required. Environment variable: `MATRIX_ID` 55 | -p MATRIX_PW, --matrix-pw MATRIX_PW 56 | matrix password. Either this or token required. Environment variable: `MATRIX_PW` 57 | -t MATRIX_TOKEN, --matrix-token MATRIX_TOKEN 58 | matrix access token. Either this or password required. Environment variable: `MATRIX_TOKEN` 59 | -k API_KEY, --api-key API_KEY 60 | shared secret to use this service. Required. Environment variable: `API_KEY` 61 | -v, --verbose increment verbosity level 62 | ``` 63 | 64 | ## Dev 65 | 66 | ``` 67 | poetry install 68 | # or python3 -m pip install --user markdown matrix-nio 69 | python3 -m matrix_webhook 70 | ``` 71 | 72 | ## Prod 73 | 74 | A `docker-compose.yml` is provided: 75 | 76 | - Use [Traefik](https://traefik.io/) on the `web` docker network, eg. with 77 | [proxyta.net](https://framagit.org/oxyta.net/proxyta.net) 78 | - Put the configuration into a `.env` file 79 | - Configure your DNS for `${CHATONS_SERVICE:-matrixwebhook}.${CHATONS_DOMAIN:-localhost}` 80 | 81 | ``` 82 | docker-compose up -d 83 | ``` 84 | 85 | ### Healthcheck 86 | 87 | For load balancers which require a healthcheck endpoint to validate the availability of the service, the `/health` path can be used. 88 | The endpoint will return a **HTTP 200** status and a json document. 89 | 90 | To the Healthcheck endpoint with Traefik and docker-compose, you can add: 91 | 92 | ```yaml 93 | services: 94 | bot: 95 | labels: 96 | traefik.http.services.matrix-webhook.loadbalancer.healthcheck.path: /health 97 | ``` 98 | 99 | ## Test / Usage 100 | 101 | ``` 102 | curl -d '{"body":"new contrib from toto: [44](http://radio.localhost/map/#44)", "key": "secret"}' \ 103 | 'http://matrixwebhook.localhost/!DPrUlnwOhBEfYwsDLh:matrix.org' 104 | ``` 105 | 106 | (or localhost:4785 without docker) 107 | 108 | ### For Github 109 | 110 | Add a JSON webhook with `?formatter=github`, and put the `API_KEY` as secret 111 | 112 | ### For Grafana 113 | 114 | Add a webhook with an URL ending with `?formatter=grafana&key=API_KEY` 115 | 116 | ### For Gitlab 117 | 118 | At the group level, Gitlab does not permit to setup webhooks. A workaround consists to use Google 119 | Chat or Microsoft Teams notification integration with a custom URL (Gitlab does not check if the url begins with the normal url of the service). 120 | 121 | #### Google Chat 122 | 123 | Add a Google Chat integration with an URL ending with `?formatter=gitlab_gchat&key=API_KEY` 124 | 125 | #### Microsoft Teams 126 | 127 | Add a Microsoft Teams integration with an URL ending with `?formatter=gitlab_teams&key=API_KEY` 128 | 129 | #### Gitlab Webhook 130 | 131 | At the project level, you can add a webhook with an URL ending with `?formatter=gitlab_webhook` and put your `API_KEY` 132 | as secret token. Not yet as pretty as other formatters, contributions welcome ! 133 | 134 | ### Github Release Notifier 135 | 136 | To receiver notifications about new releases of projects hosted at github.com you can add a matrix webhook ending with `?formatter=grn&key=API_KEY` to [Github Release Notifier (grn)](https://github.com/femtopixel/github-release-notifier). 137 | 138 | ## Test room 139 | 140 | [#matrix-webhook:tetaneutral.net](https://matrix.to/#/!DPrUlnwOhBEfYwsDLh:matrix.org) 141 | 142 | ## Unit tests 143 | 144 | ``` 145 | docker-compose -f test.yml up --exit-code-from tests --force-recreate --build 146 | ``` 147 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | networks: 4 | web: 5 | external: true 6 | 7 | services: 8 | bot: 9 | image: nim65s/matrix-webhook 10 | build: . 11 | restart: unless-stopped 12 | env_file: 13 | - .env 14 | networks: 15 | - web 16 | labels: 17 | traefik.enable: "true" 18 | traefik.http.routers.matrix-webhook.rule: "Host(`${CHATONS_SERVICE:-matrixwebhook}.${CHATONS_DOMAIN:-localhost}`)" 19 | -------------------------------------------------------------------------------- /docs/release.md: -------------------------------------------------------------------------------- 1 | # Publish a new release 2 | 3 | A github actions handle the build of the release archives, and push them to PyPI and Github Releases. 4 | To trigger it, we just need to: 5 | 6 | 1. use poetry to update the version number 7 | 2. update the changelog 8 | 3. update version number in flake.nix 9 | 4. git commit 10 | 5. git tag 11 | 6. git push 12 | 7. git push --tags 13 | 14 | 15 | For this, an helper script is provided: 16 | 17 | ```bash 18 | ./docs/release.sh [patch|minor|major|x.y.z] 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eux 2 | # ./docs/release.sh [patch|minor|major|x.y.z] 3 | 4 | [[ $(basename "$PWD") == docs ]] && cd .. 5 | 6 | 7 | OLD=$(poetry version -s) 8 | 9 | poetry version "$1" 10 | 11 | NEW=$(poetry version -s) 12 | DATE=$(date +%Y-%m-%d) 13 | 14 | sed -i "/^## \[Unreleased\]/a \\\n## [v$NEW] - $DATE" CHANGELOG.md 15 | sed -i "/^\[Unreleased\]/s/$OLD/$NEW/" CHANGELOG.md 16 | sed -i "/^\[Unreleased\]/a [v$NEW]: https://github.com/nim65s/matrix-webhook/compare/v$OLD...v$NEW" CHANGELOG.md 17 | sed -i "/version/s/$OLD/$NEW/" flake.nix 18 | 19 | git add pyproject.toml CHANGELOG.md flake.nix 20 | git commit -m "Release v$NEW" 21 | git tag -s "v$NEW" -m "Release v$NEW" 22 | git push 23 | git push --tags 24 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1710146030, 9 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1725634671, 24 | "narHash": "sha256-v3rIhsJBOMLR8e/RNWxr828tB+WywYIoajrZKFM+0Gg=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "574d1eac1c200690e27b8eb4e24887f8df7ac27c", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Post a message to a matrix room with a simple HTTP POST"; 3 | 4 | inputs = { 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 7 | }; 8 | 9 | outputs = 10 | { 11 | nixpkgs, 12 | flake-utils, 13 | ... 14 | }: 15 | flake-utils.lib.eachDefaultSystem ( 16 | system: 17 | let 18 | pkgs = nixpkgs.legacyPackages.${system}; 19 | matrix-webhook = 20 | with pkgs.python3Packages; 21 | buildPythonApplication { 22 | pname = "matrix-webhook"; 23 | version = "3.9.1"; 24 | src = pkgs.nix-gitignore.gitignoreSource [ ./.nixignore ] ./.; 25 | pyproject = true; 26 | buildInputs = [ poetry-core ]; 27 | propagatedBuildInputs = [ 28 | markdown 29 | matrix-nio 30 | ]; 31 | }; 32 | in 33 | { 34 | packages.default = matrix-webhook; 35 | apps.default = flake-utils.lib.mkApp { drv = matrix-webhook; }; 36 | devShells.default = pkgs.mkShell { 37 | inputsFrom = [ matrix-webhook ]; 38 | packages = with pkgs; [ 39 | poetry 40 | python3Packages.coverage 41 | python3Packages.httpx 42 | python3Packages.safety 43 | ]; 44 | }; 45 | } 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /matrix_webhook/__main__.py: -------------------------------------------------------------------------------- 1 | """Matrix Webhook module entrypoint.""" 2 | 3 | import logging 4 | 5 | from . import app, conf 6 | 7 | 8 | def main(): 9 | """Start everything.""" 10 | log_format = "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s" 11 | logging.basicConfig(level=50 - 10 * conf.VERBOSE, format=log_format) 12 | app.run() 13 | 14 | 15 | if __name__ == "__main__": 16 | main() 17 | -------------------------------------------------------------------------------- /matrix_webhook/app.py: -------------------------------------------------------------------------------- 1 | """Matrix Webhook app.""" 2 | 3 | import asyncio 4 | import logging 5 | from pathlib import Path 6 | from signal import SIGINT, SIGTERM 7 | 8 | from aiohttp import web 9 | 10 | from . import conf, handler, utils 11 | 12 | LOGGER = logging.getLogger("matrix_webhook.app") 13 | 14 | 15 | async def main(event): 16 | """Launch main coroutine. 17 | 18 | matrix client login & start web server 19 | """ 20 | if conf.MATRIX_PW: 21 | msg = f"Log in {conf.MATRIX_ID=} on {conf.MATRIX_URL=}" 22 | LOGGER.info(msg) 23 | await utils.CLIENT.login(conf.MATRIX_PW) 24 | else: 25 | msg = f"Restoring log in {conf.MATRIX_ID=} on {conf.MATRIX_URL=}" 26 | LOGGER.info(msg) 27 | utils.CLIENT.access_token = conf.MATRIX_TOKEN 28 | 29 | server = web.Server(handler.matrix_webhook) 30 | runner = web.ServerRunner(server) 31 | await runner.setup() 32 | msg = f"Binding on {conf.SERVER_ADDRESS=}" 33 | LOGGER.info(msg) 34 | if conf.SERVER_PATH: 35 | site = web.UnixSite(runner, conf.SERVER_PATH) 36 | else: 37 | site = web.TCPSite(runner, *conf.SERVER_ADDRESS) 38 | await site.start() 39 | 40 | if conf.SERVER_PATH: 41 | Path(conf.SERVER_PATH).chmod(0o664) 42 | 43 | # Run until we get a shutdown request 44 | await event.wait() 45 | 46 | # Cleanup 47 | await runner.cleanup() 48 | await utils.CLIENT.close() 49 | 50 | 51 | def terminate(event, signal): 52 | """Close handling stuff.""" 53 | event.set() 54 | asyncio.get_event_loop().remove_signal_handler(signal) 55 | 56 | 57 | def run(): 58 | """Launch everything.""" 59 | LOGGER.info("Starting...") 60 | loop = asyncio.get_event_loop() 61 | event = asyncio.Event() 62 | 63 | for sig in (SIGINT, SIGTERM): 64 | loop.add_signal_handler(sig, terminate, event, sig) 65 | 66 | loop.run_until_complete(main(event)) 67 | 68 | LOGGER.info("Closing...") 69 | loop.close() 70 | -------------------------------------------------------------------------------- /matrix_webhook/conf.py: -------------------------------------------------------------------------------- 1 | """Configuration for Matrix Webhook.""" 2 | 3 | import argparse 4 | import os 5 | 6 | parser = argparse.ArgumentParser(description=__doc__, prog="python -m matrix_webhook") 7 | parser.add_argument( 8 | "-H", 9 | "--host", 10 | default=os.environ.get("HOST", ""), 11 | help="host to listen to. Default: `''`. Environment variable: `HOST`", 12 | ) 13 | parser.add_argument( 14 | "-S", 15 | "--server-path", 16 | default=os.environ.get("SERVER_PATH", ""), 17 | help="unix path to listen to. Default: `''`. Environment variable: `SERVER_PATH`", 18 | ) 19 | parser.add_argument( 20 | "-P", 21 | "--port", 22 | type=int, 23 | default=os.environ.get("PORT", 4785), 24 | help="port to listed to. Default: 4785. Environment variable: `PORT`", 25 | ) 26 | parser.add_argument( 27 | "-u", 28 | "--matrix-url", 29 | default=os.environ.get("MATRIX_URL", "https://matrix.org"), 30 | help="matrix homeserver url. Default: `https://matrix.org`. " 31 | "Environment variable: `MATRIX_URL`", 32 | ) 33 | parser.add_argument( 34 | "-i", 35 | "--matrix-id", 36 | help="matrix user-id. Required. Environment variable: `MATRIX_ID`", 37 | **( 38 | {"default": os.environ["MATRIX_ID"]} 39 | if "MATRIX_ID" in os.environ 40 | else {"required": True} 41 | ), 42 | ) 43 | auth = parser.add_mutually_exclusive_group( 44 | required=all(v not in os.environ for v in ["MATRIX_PW", "MATRIX_TOKEN"]), 45 | ) 46 | auth.add_argument( 47 | "-p", 48 | "--matrix-pw", 49 | help="matrix password. Either this or token required. " 50 | "Environment variable: `MATRIX_PW`", 51 | **({"default": os.environ["MATRIX_PW"]} if "MATRIX_PW" in os.environ else {}), 52 | ) 53 | auth.add_argument( 54 | "-t", 55 | "--matrix-token", 56 | help="matrix access token. Either this or password required. " 57 | "Environment variable: `MATRIX_TOKEN`", 58 | **({"default": os.environ["MATRIX_TOKEN"]} if "MATRIX_TOKEN" in os.environ else {}), 59 | ) 60 | parser.add_argument( 61 | "-k", 62 | "--api-key", 63 | help="shared secret to use this service. Required. Environment variable: `API_KEY`", 64 | **( 65 | {"default": os.environ["API_KEY"]} 66 | if "API_KEY" in os.environ 67 | else {"required": True} 68 | ), 69 | ) 70 | parser.add_argument( 71 | "-v", 72 | "--verbose", 73 | action="count", 74 | default=int(os.environ.get("VERBOSITY", 0)), 75 | help="increment verbosity level", 76 | ) 77 | 78 | args = parser.parse_args() 79 | 80 | SERVER_ADDRESS = (args.host, args.port) 81 | SERVER_PATH = args.server_path 82 | MATRIX_URL = args.matrix_url 83 | MATRIX_ID = args.matrix_id 84 | MATRIX_PW = args.matrix_pw 85 | MATRIX_TOKEN = args.matrix_token 86 | API_KEY = args.api_key 87 | VERBOSE = args.verbose 88 | -------------------------------------------------------------------------------- /matrix_webhook/formatters.py: -------------------------------------------------------------------------------- 1 | """Formatters for matrix webhook.""" 2 | 3 | import re 4 | 5 | 6 | def grafana(data, headers): 7 | """Pretty-print a Grafana (version 8 and older) notification.""" 8 | text = "" 9 | if "ruleName" not in data and "alerts" in data: 10 | return grafana_9x(data, headers) 11 | if "title" in data: 12 | text = "#### " + data["title"] + "\n" 13 | if "message" in data: 14 | text = text + data["message"] + "\n\n" 15 | if "evalMatches" in data: 16 | for match in data["evalMatches"]: 17 | text = text + "* " + match["metric"] + ": " + str(match["value"]) + "\n" 18 | data["body"] = text 19 | return data 20 | 21 | 22 | def grafana_9x(data, headers): 23 | """Pretty-print a Grafana newer than v9.x notification.""" 24 | text = "" 25 | if "title" in data: 26 | text = "#### " + data["title"] + "\n" 27 | if "message" in data: 28 | text = text + data["message"].replace("\n", "\n\n") + "\n\n" 29 | data["body"] = text 30 | return data 31 | 32 | 33 | def github(data, headers): 34 | """Pretty-print a github notification.""" 35 | # TODO: Write nice useful formatters. This is only an example. 36 | if headers["X-GitHub-Event"] == "push": 37 | pusher, ref, a, b, c = ( 38 | data[k] for k in ["pusher", "ref", "after", "before", "compare"] 39 | ) 40 | pusher = f"[@{pusher['name']}](https://github.com/{pusher['name']})" 41 | data["body"] = f"{pusher} pushed on {ref}: [{b} → {a}]({c}):\n\n" 42 | for commit in data["commits"]: 43 | data["body"] += f"- [{commit['message']}]({commit['url']})\n" 44 | else: 45 | data["body"] = "notification from github" 46 | data["digest"] = headers["X-Hub-Signature-256"].replace("sha256=", "") 47 | return data 48 | 49 | 50 | def gitlab_gchat(data, headers): 51 | """Pretty-print a gitlab notification preformatted for Google Chat.""" 52 | data["body"] = re.sub( 53 | "<(.*?)\\|(.*?)>", 54 | "[\\2](\\1)", 55 | data["body"], 56 | flags=re.MULTILINE, 57 | ) 58 | return data 59 | 60 | 61 | def gitlab_teams(data, headers): 62 | """Pretty-print a gitlab notification preformatted for Microsoft Teams.""" 63 | body = [] 64 | for section in data["sections"]: 65 | if "text" in section.keys(): 66 | text = section["text"].split("\n\n") 67 | text = ["* " + t for t in text] 68 | body.append("\n" + " \n".join(text)) 69 | elif all( 70 | k in section.keys() 71 | for k in ("activityTitle", "activitySubtitle", "activityText") 72 | ): 73 | text = section["activityTitle"] + " " + section["activitySubtitle"] + " → " 74 | text += section["activityText"] 75 | body.append(text) 76 | 77 | data["body"] = " \n".join(body) 78 | return data 79 | 80 | 81 | def gitlab_webhook(data, headers): 82 | """Pretty-print a gitlab notification. 83 | 84 | NB: This is a work-in-progress minimal example for now 85 | """ 86 | body = [] 87 | 88 | event_name = data["event_name"] 89 | user_name = data["user_name"] 90 | project = data["project"] 91 | 92 | body.append(f"New {event_name} event") 93 | body.append(f"on [{project['name']}]({project['web_url']})") 94 | body.append(f"by {user_name}.") 95 | 96 | data["body"] = " ".join(body) 97 | if "X-Gitlab-Token" in headers: 98 | data["key"] = headers["X-Gitlab-Token"] 99 | return data 100 | 101 | 102 | def grn(data, headers): 103 | """Pretty-print a github release notifier (grn) notification.""" 104 | version, title, author, package = ( 105 | data[k] for k in ["version", "title", "author", "package_name"] 106 | ) 107 | data["body"] = ( 108 | f"### {package} - {version}\n\n{title}\n\n" 109 | f"[{author} released new version **{version}** for **{package}**]" 110 | f"(https://github.com/{package}/releases/tag/{version}).\n\n" 111 | ) 112 | 113 | return data 114 | -------------------------------------------------------------------------------- /matrix_webhook/handler.py: -------------------------------------------------------------------------------- 1 | """Matrix Webhook main request handler.""" 2 | 3 | import json 4 | import logging 5 | from hmac import HMAC 6 | from http import HTTPStatus 7 | 8 | from markdown import markdown 9 | 10 | from . import conf, formatters, utils 11 | 12 | LOGGER = logging.getLogger("matrix_webhook.handler") 13 | 14 | 15 | async def matrix_webhook(request): 16 | """Coroutine given to the server, st. it knows what to do with an HTTP request. 17 | 18 | This one handles a POST, checks its content, and forwards it to the matrix room. 19 | """ 20 | msg = f"Handling {request=}" 21 | LOGGER.debug(msg) 22 | 23 | # healthcheck 24 | if request.rel_url.path == "/health": 25 | return utils.create_json_response(HTTPStatus.OK, "OK") 26 | 27 | data_b = await request.read() 28 | 29 | try: 30 | data = json.loads(data_b.decode()) 31 | except json.decoder.JSONDecodeError: 32 | return utils.create_json_response(HTTPStatus.BAD_REQUEST, "Invalid JSON") 33 | 34 | # legacy naming 35 | if "text" in data and "body" not in data: 36 | data["body"] = data["text"] 37 | 38 | # allow key to be passed as a parameter 39 | if "key" in request.rel_url.query and "key" not in data: 40 | data["key"] = request.rel_url.query["key"] 41 | 42 | if "formatter" in request.rel_url.query: 43 | try: 44 | data = getattr(formatters, request.rel_url.query["formatter"])( 45 | data, 46 | request.headers, 47 | ) 48 | except AttributeError: 49 | return utils.create_json_response( 50 | HTTPStatus.BAD_REQUEST, 51 | "Unknown formatter", 52 | ) 53 | 54 | if "room_id" in request.rel_url.query and "room_id" not in data: 55 | data["room_id"] = request.rel_url.query["room_id"] 56 | if "room_id" not in data: 57 | data["room_id"] = request.path.lstrip("/") 58 | 59 | # If we get a good SHA-256 HMAC digest, 60 | # we can consider that the sender has the right API key 61 | if "digest" in data: 62 | if data["digest"] == HMAC(conf.API_KEY.encode(), data_b, "sha256").hexdigest(): 63 | data["key"] = conf.API_KEY 64 | else: # but if there is a wrong digest, an informative error should be provided 65 | return utils.create_json_response( 66 | HTTPStatus.UNAUTHORIZED, 67 | "Invalid SHA-256 HMAC digest", 68 | ) 69 | 70 | missing = [] 71 | for key in ["body", "key", "room_id"]: 72 | if key not in data or not data[key]: 73 | missing.append(key) 74 | if missing: 75 | return utils.create_json_response( 76 | HTTPStatus.BAD_REQUEST, 77 | f"Missing {', '.join(missing)}", 78 | ) 79 | 80 | if data["key"] != conf.API_KEY: 81 | return utils.create_json_response(HTTPStatus.UNAUTHORIZED, "Invalid API key") 82 | 83 | if "formatted_body" in data: 84 | formatted_body = data["formatted_body"] 85 | else: 86 | formatted_body = markdown(str(data["body"]), extensions=["extra"]) 87 | 88 | # try to join room first -> non none response means error 89 | resp = await utils.join_room(data["room_id"]) 90 | if resp is not None: 91 | return resp 92 | 93 | content = { 94 | "msgtype": "m.text", 95 | "body": data["body"], 96 | "format": "org.matrix.custom.html", 97 | "formatted_body": formatted_body, 98 | } 99 | return await utils.send_room_message(data["room_id"], content) 100 | -------------------------------------------------------------------------------- /matrix_webhook/utils.py: -------------------------------------------------------------------------------- 1 | """Matrix Webhook utils.""" 2 | 3 | import logging 4 | from collections import defaultdict 5 | from http import HTTPStatus 6 | 7 | from aiohttp import web 8 | from nio import AsyncClient 9 | from nio.exceptions import LocalProtocolError 10 | from nio.responses import JoinError, RoomSendError 11 | 12 | from . import conf 13 | 14 | ERROR_MAP = defaultdict( 15 | lambda: HTTPStatus.INTERNAL_SERVER_ERROR, 16 | { 17 | "M_FORBIDDEN": HTTPStatus.FORBIDDEN, 18 | "M_CONSENT_NOT_GIVEN": HTTPStatus.FORBIDDEN, 19 | }, 20 | ) 21 | LOGGER = logging.getLogger("matrix_webhook.utils") 22 | CLIENT = AsyncClient(conf.MATRIX_URL, conf.MATRIX_ID) 23 | 24 | 25 | def error_map(resp): 26 | """Map response errors to HTTP status.""" 27 | if resp.status_code == "M_UNKNOWN": 28 | # in this case, we should directly consider the HTTP status from the response 29 | # ref. https://matrix.org/docs/spec/client_server/r0.6.1#api-standards 30 | return resp.transport_response.status 31 | return ERROR_MAP[resp.status_code] 32 | 33 | 34 | def create_json_response(status, ret): 35 | """Create a JSON response.""" 36 | msg = f"Creating json response: {status=}, {ret=}" 37 | LOGGER.debug(msg) 38 | response_data = {"status": status, "ret": ret} 39 | return web.json_response(response_data, status=status) 40 | 41 | 42 | async def join_room(room_id): 43 | """Try to join the room.""" 44 | msg = f"Join room {room_id=}" 45 | LOGGER.debug(msg) 46 | 47 | for _ in range(10): 48 | try: 49 | resp = await CLIENT.join(room_id) 50 | if isinstance(resp, JoinError): 51 | if resp.status_code == "M_UNKNOWN_TOKEN": 52 | LOGGER.warning("Reconnecting") 53 | if conf.MATRIX_PW: 54 | await CLIENT.login(conf.MATRIX_PW) 55 | else: 56 | return create_json_response(error_map(resp), resp.message) 57 | else: 58 | return None 59 | except LocalProtocolError as e: 60 | msg = f"Send error: {e}" 61 | LOGGER.error(msg) 62 | LOGGER.warning("Reconnecting") 63 | if conf.MATRIX_PW: 64 | await CLIENT.login(conf.MATRIX_PW) 65 | LOGGER.warning("Trying again") 66 | return create_json_response(HTTPStatus.GATEWAY_TIMEOUT, "Homeserver not responding") 67 | 68 | 69 | async def send_room_message(room_id, content): 70 | """Send a message to a room.""" 71 | msg = f"Sending room message in {room_id=}: {content=}" 72 | LOGGER.debug(msg) 73 | 74 | for _ in range(10): 75 | try: 76 | resp = await CLIENT.room_send( 77 | room_id=room_id, 78 | message_type="m.room.message", 79 | content=content, 80 | ) 81 | if isinstance(resp, RoomSendError): 82 | if resp.status_code == "M_UNKNOWN_TOKEN": 83 | LOGGER.warning("Reconnecting") 84 | if conf.MATRIX_PW: 85 | await CLIENT.login(conf.MATRIX_PW) 86 | else: 87 | return create_json_response(error_map(resp), resp.message) 88 | else: 89 | return create_json_response(HTTPStatus.OK, "OK") 90 | except LocalProtocolError as e: 91 | msg = f"Send error: {e}" 92 | LOGGER.error(msg) 93 | LOGGER.warning("Reconnecting") 94 | if conf.MATRIX_PW: 95 | await CLIENT.login(conf.MATRIX_PW) 96 | LOGGER.warning("Trying again") 97 | return create_json_response(HTTPStatus.GATEWAY_TIMEOUT, "Homeserver not responding") 98 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "poetry.core.masonry.api" 3 | requires = ["poetry-core>=1.0.0"] 4 | 5 | [tool.poetry] 6 | authors = ["Guilhem Saurel "] 7 | description = "Post a message to a matrix room with a simple HTTP POST" 8 | homepage = "https://github.com/nim65s/matrix-webhook" 9 | license = "BSD-2-Clause" 10 | name = "matrix-webhook" 11 | readme = "README.md" 12 | repository = "https://github.com/nim65s/matrix-webhook.git" 13 | version = "3.9.1" 14 | 15 | [tool.poetry.dependencies] 16 | Markdown = "^3.6" 17 | matrix-nio = "^0.25" 18 | python = "^3.8" 19 | 20 | [tool.poetry.group.dev] 21 | optional = true 22 | 23 | [tool.poetry.group.dev.dependencies] 24 | coverage = "^7.6.1" 25 | httpx = ">=0.27.2" 26 | ruff = ">=0.6.4" 27 | safety = {allow-prereleases = true, version = "^3.2.7"} 28 | 29 | [tool.poetry.scripts] 30 | matrix-webhook = "matrix_webhook.__main__:main" 31 | 32 | [tool.poetry.urls] 33 | "changelog" = "https://github.com/nim65s/matrix-webhook/blob/master/CHANGELOG.md" 34 | 35 | [tool.ruff] 36 | target-version = "py38" 37 | 38 | [tool.ruff.lint] 39 | extend-ignore = ["D203", "D213"] 40 | extend-select = ["A", "B", "COM", "D", "EM", "EXE", "G", "I", "N", "PTH", "RET", "RUF", "UP", "W", "YTT"] 41 | 42 | [tool.tomlsort] 43 | all = true 44 | -------------------------------------------------------------------------------- /test.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | tests: 5 | build: 6 | context: . 7 | dockerfile: tests/Dockerfile 8 | entrypoint: "" 9 | env_file: 10 | - tests/.env 11 | volumes: 12 | - ./:/app 13 | -------------------------------------------------------------------------------- /tests/.env: -------------------------------------------------------------------------------- 1 | MATRIX_URL=http://tests 2 | MATRIX_ID=bot 3 | MATRIX_PW=pw 4 | API_KEY=ak 5 | -------------------------------------------------------------------------------- /tests/Dockerfile: -------------------------------------------------------------------------------- 1 | # Leverage a synapse base to be able to: 2 | # "from synapse._scripts.register_new_matrix_user import request_registration" 3 | FROM matrixdotorg/synapse 4 | 5 | # The config dir defaults to /data which is a volume made to keep data. 6 | # Here, we want to trash those (and avoid the permission issues) by using something else 7 | ENV SYNAPSE_CONFIG_DIR=/srv SYNAPSE_DATA_DIR=/srv SYNAPSE_SERVER_NAME=tests SYNAPSE_REPORT_STATS=no 8 | 9 | # Generate configuration and keys for synapse 10 | WORKDIR $SYNAPSE_CONFIG_DIR 11 | RUN chown -R 991:991 . \ 12 | && /start.py generate \ 13 | && sed -i 's=/data=/srv=;s=8008=80=;s=#sup=sup=;' homeserver.yaml \ 14 | && echo "" >> homeserver.yaml \ 15 | && echo "rc_message:" >> homeserver.yaml \ 16 | && echo " burst_count: 1000" >> homeserver.yaml \ 17 | && echo "rc_registration:" >> homeserver.yaml \ 18 | && echo " burst_count: 1000" >> homeserver.yaml \ 19 | && echo "rc_registration_token_validity:" >> homeserver.yaml \ 20 | && echo " burst_count: 1000" >> homeserver.yaml \ 21 | && echo "rc_login:" >> homeserver.yaml \ 22 | && echo " address:" >> homeserver.yaml \ 23 | && echo " burst_count: 1000" >> homeserver.yaml \ 24 | && echo " account:" >> homeserver.yaml \ 25 | && echo " burst_count: 1000" >> homeserver.yaml \ 26 | && echo " failed_attempts:" >> homeserver.yaml \ 27 | && echo " burst_count: 1000" >> homeserver.yaml \ 28 | && echo "rc_joins:" >> homeserver.yaml \ 29 | && echo " burst_count: 1000" >> homeserver.yaml \ 30 | && python -m synapse.app.homeserver --config-path homeserver.yaml --generate-keys 31 | 32 | RUN pip install --no-cache-dir markdown matrix-nio httpx coverage 33 | 34 | WORKDIR /app 35 | 36 | CMD ./tests/start.py -vvv 37 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Make this directory a valid module for unittests autodiscover to work.""" 2 | -------------------------------------------------------------------------------- /tests/example_github_push.json: -------------------------------------------------------------------------------- 1 | {"ref":"refs/heads/devel","before":"ac7d1d9647008145e9d0cf65d24744d0db4862b8","after":"4bcdb25c809391baaabc264d9309059f9f48ead2","repository":{"id":171114171,"node_id":"MDEwOlJlcG9zaXRvcnkxNzExMTQxNzE=","name":"matrix-webhook","full_name":"nim65s/matrix-webhook","private":false,"owner":{"name":"nim65s","email":"guilhem.saurel@laas.fr","login":"nim65s","id":131929,"node_id":"MDQ6VXNlcjEzMTkyOQ==","avatar_url":"https://avatars.githubusercontent.com/u/131929?v=4","gravatar_id":"","url":"https://api.github.com/users/nim65s","html_url":"https://github.com/nim65s","followers_url":"https://api.github.com/users/nim65s/followers","following_url":"https://api.github.com/users/nim65s/following{/other_user}","gists_url":"https://api.github.com/users/nim65s/gists{/gist_id}","starred_url":"https://api.github.com/users/nim65s/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/nim65s/subscriptions","organizations_url":"https://api.github.com/users/nim65s/orgs","repos_url":"https://api.github.com/users/nim65s/repos","events_url":"https://api.github.com/users/nim65s/events{/privacy}","received_events_url":"https://api.github.com/users/nim65s/received_events","type":"User","site_admin":false},"html_url":"https://github.com/nim65s/matrix-webhook","description":"Post a message to a matrix room with a simple HTTP POST","fork":false,"url":"https://github.com/nim65s/matrix-webhook","forks_url":"https://api.github.com/repos/nim65s/matrix-webhook/forks","keys_url":"https://api.github.com/repos/nim65s/matrix-webhook/keys{/key_id}","collaborators_url":"https://api.github.com/repos/nim65s/matrix-webhook/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/nim65s/matrix-webhook/teams","hooks_url":"https://api.github.com/repos/nim65s/matrix-webhook/hooks","issue_events_url":"https://api.github.com/repos/nim65s/matrix-webhook/issues/events{/number}","events_url":"https://api.github.com/repos/nim65s/matrix-webhook/events","assignees_url":"https://api.github.com/repos/nim65s/matrix-webhook/assignees{/user}","branches_url":"https://api.github.com/repos/nim65s/matrix-webhook/branches{/branch}","tags_url":"https://api.github.com/repos/nim65s/matrix-webhook/tags","blobs_url":"https://api.github.com/repos/nim65s/matrix-webhook/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/nim65s/matrix-webhook/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/nim65s/matrix-webhook/git/refs{/sha}","trees_url":"https://api.github.com/repos/nim65s/matrix-webhook/git/trees{/sha}","statuses_url":"https://api.github.com/repos/nim65s/matrix-webhook/statuses/{sha}","languages_url":"https://api.github.com/repos/nim65s/matrix-webhook/languages","stargazers_url":"https://api.github.com/repos/nim65s/matrix-webhook/stargazers","contributors_url":"https://api.github.com/repos/nim65s/matrix-webhook/contributors","subscribers_url":"https://api.github.com/repos/nim65s/matrix-webhook/subscribers","subscription_url":"https://api.github.com/repos/nim65s/matrix-webhook/subscription","commits_url":"https://api.github.com/repos/nim65s/matrix-webhook/commits{/sha}","git_commits_url":"https://api.github.com/repos/nim65s/matrix-webhook/git/commits{/sha}","comments_url":"https://api.github.com/repos/nim65s/matrix-webhook/comments{/number}","issue_comment_url":"https://api.github.com/repos/nim65s/matrix-webhook/issues/comments{/number}","contents_url":"https://api.github.com/repos/nim65s/matrix-webhook/contents/{+path}","compare_url":"https://api.github.com/repos/nim65s/matrix-webhook/compare/{base}...{head}","merges_url":"https://api.github.com/repos/nim65s/matrix-webhook/merges","archive_url":"https://api.github.com/repos/nim65s/matrix-webhook/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/nim65s/matrix-webhook/downloads","issues_url":"https://api.github.com/repos/nim65s/matrix-webhook/issues{/number}","pulls_url":"https://api.github.com/repos/nim65s/matrix-webhook/pulls{/number}","milestones_url":"https://api.github.com/repos/nim65s/matrix-webhook/milestones{/number}","notifications_url":"https://api.github.com/repos/nim65s/matrix-webhook/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/nim65s/matrix-webhook/labels{/name}","releases_url":"https://api.github.com/repos/nim65s/matrix-webhook/releases{/id}","deployments_url":"https://api.github.com/repos/nim65s/matrix-webhook/deployments","created_at":1550402971,"updated_at":"2021-07-20T22:30:52Z","pushed_at":1630087539,"git_url":"git://github.com/nim65s/matrix-webhook.git","ssh_url":"git@github.com:nim65s/matrix-webhook.git","clone_url":"https://github.com/nim65s/matrix-webhook.git","svn_url":"https://github.com/nim65s/matrix-webhook","homepage":"https://code.ffdn.org/tetaneutral.net/matrix-webhook","size":158,"stargazers_count":17,"watchers_count":17,"language":"Python","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":7,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":2,"license":{"key":"other","name":"Other","spdx_id":"NOASSERTION","url":null,"node_id":"MDc6TGljZW5zZTA="},"forks":7,"open_issues":2,"watchers":17,"default_branch":"master","stargazers":17,"master_branch":"master"},"pusher":{"name":"nim65s","email":"guilhem.saurel@laas.fr"},"sender":{"login":"nim65s","id":131929,"node_id":"MDQ6VXNlcjEzMTkyOQ==","avatar_url":"https://avatars.githubusercontent.com/u/131929?v=4","gravatar_id":"","url":"https://api.github.com/users/nim65s","html_url":"https://github.com/nim65s","followers_url":"https://api.github.com/users/nim65s/followers","following_url":"https://api.github.com/users/nim65s/following{/other_user}","gists_url":"https://api.github.com/users/nim65s/gists{/gist_id}","starred_url":"https://api.github.com/users/nim65s/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/nim65s/subscriptions","organizations_url":"https://api.github.com/users/nim65s/orgs","repos_url":"https://api.github.com/users/nim65s/repos","events_url":"https://api.github.com/users/nim65s/events{/privacy}","received_events_url":"https://api.github.com/users/nim65s/received_events","type":"User","site_admin":false},"created":false,"deleted":false,"forced":false,"base_ref":null,"compare":"https://github.com/nim65s/matrix-webhook/compare/ac7d1d964700...4bcdb25c8093","commits":[{"id":"4bcdb25c809391baaabc264d9309059f9f48ead2","tree_id":"e423e7482b0231d04dca2caafcdc48a4b064f17b","distinct":true,"message":"formatters: also get headers","timestamp":"2021-08-27T20:05:08+02:00","url":"https://github.com/nim65s/matrix-webhook/commit/4bcdb25c809391baaabc264d9309059f9f48ead2","author":{"name":"Guilhem Saurel","email":"guilhem.saurel@laas.fr","username":"nim65s"},"committer":{"name":"Guilhem Saurel","email":"guilhem.saurel@laas.fr","username":"nim65s"},"added":[],"removed":[],"modified":["README.md","matrix_webhook/formatters.py","matrix_webhook/handler.py"]}],"head_commit":{"id":"4bcdb25c809391baaabc264d9309059f9f48ead2","tree_id":"e423e7482b0231d04dca2caafcdc48a4b064f17b","distinct":true,"message":"formatters: also get headers","timestamp":"2021-08-27T20:05:08+02:00","url":"https://github.com/nim65s/matrix-webhook/commit/4bcdb25c809391baaabc264d9309059f9f48ead2","author":{"name":"Guilhem Saurel","email":"guilhem.saurel@laas.fr","username":"nim65s"},"committer":{"name":"Guilhem Saurel","email":"guilhem.saurel@laas.fr","username":"nim65s"},"added":[],"removed":[],"modified":["README.md","matrix_webhook/formatters.py","matrix_webhook/handler.py"]}} 2 | -------------------------------------------------------------------------------- /tests/example_gitlab_gchat.json: -------------------------------------------------------------------------------- 1 | {"text":"John Doe pushed to branch \u003chttps://gitlab.com/jdoe/test/commits/master|master\u003e of \u003chttps://gitlab.com/jdoe/test|John Doe / test\u003e (\u003chttps://gitlab.com/jdoe/test/compare/b76004b20503d4d506e51a670de095cc063e4707...3517b06c64c9d349e2213650d6c009db0471361e|Compare changes\u003e)\n\u003chttps://gitlab.com/jdoe/test/-/commit/3517b06c64c9d349e2213650d6c009db0471361e|3517b06c\u003e: Merge branch 'prod' into 'master' - John Doe\n\n\u003chttps://gitlab.com/jdoe/test/-/commit/1f661795b220c5fe352f391eb8de3ac4fcc6fc1d|1f661795\u003e: Merge branch 'revert-a827b196' into 'prod' - John Doe\n\n\u003chttps://gitlab.com/jdoe/test/-/commit/b76004b20503d4d506e51a670de095cc063e4707|b76004b2\u003e: Merge branch 'revert-a827b196' into 'master' - John Doe"} 2 | -------------------------------------------------------------------------------- /tests/example_gitlab_teams.json: -------------------------------------------------------------------------------- 1 | {"sections":[{"activityTitle":"John Doe pushed to branch [master](https://gitlab.com/jdoe/test/commits/master)","activitySubtitle":"in [John Doe / test](https://gitlab.com/jdoe/test)","activityText":"[Compare changes](https://gitlab.com/jdoe/test/compare/b76004b20503d4d506e51a670de095cc063e4707...3517b06c64c9d349e2213650d6c009db0471361e)","activityImage":"https://secure.gravatar.com/avatar/80\u0026d=identicon"},{"text":"[3517b06c](https://gitlab.com/jdoe/test/-/commit/3517b06c64c9d349e2213650d6c009db0471361e): Merge branch 'prod' into 'master' - John Doe\n\n[1f661795](https://gitlab.com/jdoe/test/-/commit/1f661795b220c5fe352f391eb8de3ac4fcc6fc1d): Merge branch 'revert-a827b196' into 'prod' - John Doe\n\n[b76004b2](https://gitlab.com/jdoe/test/-/commit/b76004b20503d4d506e51a670de095cc063e4707): Merge branch 'revert-a827b196' into 'master' - John Doe"}],"title":"John Doe / test","summary":"John Doe pushed to branch [master](https://gitlab.com/jdoe/test/commits/master) of [John Doe / test](https://gitlab.com/jdoe/test) ([Compare changes](https://gitlab.com/jdoe/test/compare/b76004b20503d4d506e51a670de095cc063e4707...3517b06c64c9d349e2213650d6c009db0471361e))"} 2 | -------------------------------------------------------------------------------- /tests/example_grafana.json: -------------------------------------------------------------------------------- 1 | { 2 | "dashboardId":1, 3 | "evalMatches":[ 4 | { 5 | "value":1, 6 | "metric":"Count", 7 | "tags":{} 8 | } 9 | ], 10 | "imageUrl":"https://grafana.com/assets/img/blog/mixed_styles.png", 11 | "message":"Notification Message", 12 | "orgId":1, 13 | "panelId":2, 14 | "ruleId":1, 15 | "ruleName":"Panel Title alert", 16 | "ruleUrl":"http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\u0026edit\u0026tab=alert\u0026panelId=2\u0026orgId=1", 17 | "state":"alerting", 18 | "tags":{ 19 | "tag name":"tag value" 20 | }, 21 | "title":"[Alerting] Panel Title alert" 22 | } 23 | -------------------------------------------------------------------------------- /tests/example_grafana_9x.json: -------------------------------------------------------------------------------- 1 | { 2 | "receiver": "", 3 | "status": "firing", 4 | "alerts": [ 5 | { 6 | "status": "firing", 7 | "labels": { 8 | "alertname": "TestAlert", 9 | "instance": "Grafana" 10 | }, 11 | "annotations": { 12 | "summary": "Notification test" 13 | }, 14 | "startsAt": "2022-09-07T15:00:26.722304913+02:00", 15 | "endsAt": "0001-01-01T00:00:00Z", 16 | "generatorURL": "", 17 | "fingerprint": "57c6d9296de2ad39", 18 | "silenceURL": "https://grafana.example.com/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DTestAlert&matcher=instance%3DGrafana", 19 | "dashboardURL": "", 20 | "panelURL": "", 21 | "valueString": "[ metric='foo' labels={instance=bar} value=10 ]" 22 | } 23 | ], 24 | "groupLabels": {}, 25 | "commonLabels": { 26 | "alertname": "TestAlert", 27 | "instance": "Grafana" 28 | }, 29 | "commonAnnotations": { 30 | "summary": "Notification test" 31 | }, 32 | "externalURL": "https://grafana.example.com/", 33 | "version": "1", 34 | "groupKey": "{alertname=\"TestAlert\", instance=\"Grafana\"}2022-09-07 15:00:26.722304913 +0200 CEST m=+246580.963796811", 35 | "truncatedAlerts": 0, 36 | "orgId": 1, 37 | "title": "[FIRING:1] (TestAlert Grafana)", 38 | "state": "alerting", 39 | "message": "**Firing**\n\nValue: [ metric='foo' labels={instance=bar} value=10 ]\nLabels:\n - alertname = TestAlert\n - instance = Grafana\nAnnotations:\n - summary = Notification test\nSilence: https://grafana.example.com/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DTestAlert&matcher=instance%3DGrafana\n", 40 | "key": "ak" 41 | } 42 | -------------------------------------------------------------------------------- /tests/example_grn.json: -------------------------------------------------------------------------------- 1 | { 2 | "date": [2017, 11, 13, 19, 46, 35, 0, 317, 0], 3 | "version": "0.7.2", 4 | "title": "Fixes split modules", 5 | "content": "", 6 | "media": "https://avatars0.githubusercontent.com/u/14236493?s=60&v=4", 7 | "author": "jaymoulin", 8 | "package_name": "jaymoulin/google-music-manager" 9 | } 10 | -------------------------------------------------------------------------------- /tests/start.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Entry point to start an instrumentalized bot for coverage and run tests.""" 3 | 4 | import argparse 5 | import logging 6 | from os import environ 7 | from pathlib import Path 8 | from subprocess import Popen, run 9 | from time import time 10 | from unittest import main 11 | 12 | import httpx 13 | import yaml 14 | from synapse._scripts.register_new_matrix_user import request_registration 15 | 16 | BOT_URL = "http://localhost:4785" 17 | KEY, MATRIX_URL, MATRIX_ID, MATRIX_PW = ( 18 | environ[v] for v in ["API_KEY", "MATRIX_URL", "MATRIX_ID", "MATRIX_PW"] 19 | ) 20 | FULL_ID = f"@{MATRIX_ID}:{MATRIX_URL.split('/')[2]}" 21 | LOGGER = logging.getLogger("matrix-webhook.tests.start") 22 | 23 | parser = argparse.ArgumentParser(description=__doc__) 24 | parser.add_argument( 25 | "-v", 26 | "--verbose", 27 | action="count", 28 | default=0, 29 | help="increment verbosity level", 30 | ) 31 | 32 | 33 | def bot_req( 34 | req=None, 35 | key=None, 36 | room_id=None, 37 | params=None, 38 | key_as_param=False, 39 | room_as_parameter=False, 40 | ): 41 | """Bot requests boilerplate.""" 42 | if params is None: 43 | params = {} 44 | if key is not None: 45 | if key_as_param: 46 | params["key"] = key 47 | else: 48 | req["key"] = key 49 | if room_as_parameter: 50 | params["room_id"] = room_id 51 | url = BOT_URL if room_id is None or room_as_parameter else f"{BOT_URL}/{room_id}" 52 | return httpx.post(url, params=params, json=req).json() 53 | 54 | 55 | def wait_available(url: str, key: str, timeout: int = 10) -> bool: 56 | """Wait until a service answer correctly or timeout.""" 57 | 58 | def check_json(url: str, key: str) -> bool: 59 | """Ensure a service answers with valid json including a certain key.""" 60 | try: 61 | data = httpx.get(url).json() 62 | return key in data 63 | except httpx.ConnectError: 64 | return False 65 | 66 | start = time() 67 | while True: 68 | if check_json(url, key): 69 | return True 70 | if time() > start + timeout: 71 | return False 72 | 73 | 74 | def run_and_test(): 75 | """Launch the bot and its tests.""" 76 | # Start the server, and wait for it 77 | LOGGER.info("Spawning synapse") 78 | srv = Popen( 79 | [ 80 | "python", 81 | "-m", 82 | "synapse.app.homeserver", 83 | "--config-path", 84 | "/srv/homeserver.yaml", 85 | ], 86 | ) 87 | if not wait_available(f"{MATRIX_URL}/_matrix/client/r0/login", "flows"): 88 | return False 89 | 90 | # Register a user for the bot. 91 | LOGGER.info("Registering the bot") 92 | with Path("/srv/homeserver.yaml").open() as f: 93 | secret = yaml.safe_load(f.read()).get("registration_shared_secret", None) 94 | request_registration(MATRIX_ID, MATRIX_PW, MATRIX_URL, secret, admin=True) 95 | 96 | # Start the bot, and wait for it 97 | LOGGER.info("Spawning the bot") 98 | bot = Popen(["coverage", "run", "-m", "matrix_webhook", "-vvvvv"]) 99 | if not wait_available(BOT_URL, "status"): 100 | return False 101 | 102 | # Run the main unittest module 103 | LOGGER.info("Runnig unittests") 104 | ret = main(module=None, exit=False).result.wasSuccessful() 105 | 106 | LOGGER.info("Stopping synapse") 107 | srv.terminate() 108 | 109 | # TODO Check what the bot says when the server is offline 110 | # print(bot_req({'data': 'bye'}, KEY), {'status': 200, 'ret': 'OK'}) 111 | 112 | LOGGER.info("Stopping the bot") 113 | bot.terminate() 114 | 115 | LOGGER.info("Processing coverage") 116 | for cmd in ["report", "html", "xml"]: 117 | run(["coverage", cmd]) 118 | return ret 119 | 120 | 121 | if __name__ == "__main__": 122 | args = parser.parse_args() 123 | log_format = "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s" 124 | logging.basicConfig(level=50 - 10 * args.verbose, format=log_format) 125 | exit(not run_and_test()) 126 | -------------------------------------------------------------------------------- /tests/test_github.py: -------------------------------------------------------------------------------- 1 | """Test module for grafana formatter.""" 2 | 3 | import unittest 4 | from pathlib import Path 5 | 6 | import httpx 7 | import nio 8 | 9 | from .start import BOT_URL, FULL_ID, MATRIX_ID, MATRIX_PW, MATRIX_URL 10 | 11 | SHA256 = "fd7522672889385736be8ffc86d1f8de2e15668864f49af729b5c63e5e0698c4" 12 | 13 | 14 | def headers(sha256=SHA256, event="push"): 15 | """Mock headers from github webhooks.""" 16 | return { 17 | # 'Request URL': 'https://bot.saurel.me/room?formatter=github', 18 | # 'Request method': 'POST', 19 | "Accept": "*/*", 20 | "content-type": "application/json", 21 | "User-Agent": "GitHub-Hookshot/8d33975", 22 | "X-GitHub-Delivery": "636b9b1c-0761-11ec-8a8a-5e435c5ac4f4", 23 | "X-GitHub-Event": event, 24 | "X-GitHub-Hook-ID": "311845633", 25 | "X-GitHub-Hook-Installation-Target-ID": "171114171", 26 | "X-GitHub-Hook-Installation-Target-Type": "repository", 27 | "X-Hub-Signature": "sha1=ea68fdfcb2f328aaa8f50d176f355e5d4fc95d94", 28 | "X-Hub-Signature-256": f"sha256={sha256}", 29 | } 30 | 31 | 32 | class GithubFormatterTest(unittest.IsolatedAsyncioTestCase): 33 | """Github formatter test class.""" 34 | 35 | async def test_github_notification(self): 36 | """Send a mock github webhook, and check the result.""" 37 | messages = [] 38 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 39 | 40 | await client.login(MATRIX_PW) 41 | room = await client.room_create() 42 | 43 | with Path("tests/example_github_push.json").open("rb") as f: 44 | example_github_push = f.read().strip() 45 | self.assertEqual( 46 | httpx.post( 47 | f"{BOT_URL}/{room.room_id}", 48 | params={ 49 | "formatter": "github", 50 | }, 51 | content=example_github_push, 52 | headers=headers(event="something else"), 53 | ).json(), 54 | {"status": 200, "ret": "OK"}, 55 | ) 56 | 57 | sync = await client.sync() 58 | messages = await client.room_messages(room.room_id, sync.next_batch) 59 | await client.close() 60 | 61 | message = messages.chunk[0] 62 | self.assertEqual(message.sender, FULL_ID) 63 | self.assertEqual( 64 | message.formatted_body, 65 | "

notification from github

", 66 | ) 67 | 68 | async def test_github_push(self): 69 | """Send a mock github push webhook, and check the result.""" 70 | messages = [] 71 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 72 | 73 | await client.login(MATRIX_PW) 74 | room = await client.room_create() 75 | 76 | with Path("tests/example_github_push.json").open("rb") as f: 77 | example_github_push = f.read().strip() 78 | self.assertEqual( 79 | httpx.post( 80 | f"{BOT_URL}/{room.room_id}", 81 | params={ 82 | "formatter": "github", 83 | }, 84 | content=example_github_push, 85 | headers=headers(), 86 | ).json(), 87 | {"status": 200, "ret": "OK"}, 88 | ) 89 | 90 | sync = await client.sync() 91 | messages = await client.room_messages(room.room_id, sync.next_batch) 92 | await client.close() 93 | 94 | before = "ac7d1d9647008145e9d0cf65d24744d0db4862b8" 95 | after = "4bcdb25c809391baaabc264d9309059f9f48ead2" 96 | gh = "https://github.com" 97 | expected = f'

@nim65s pushed on refs/heads/devel: ' 98 | expected += f'{before} → {after}:

\n" 102 | 103 | message = messages.chunk[0] 104 | self.assertEqual(message.sender, FULL_ID) 105 | self.assertEqual( 106 | message.formatted_body, 107 | expected, 108 | ) 109 | 110 | async def test_github_wrong_digest(self): 111 | """Send a mock github push webhook with a wrong digest.""" 112 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 113 | 114 | await client.login(MATRIX_PW) 115 | room = await client.room_create() 116 | 117 | with Path("tests/example_github_push.json").open("rb") as f: 118 | example_github_push = f.read().strip() 119 | 120 | self.assertEqual( 121 | httpx.post( 122 | f"{BOT_URL}/{room.room_id}", 123 | params={ 124 | "formatter": "github", 125 | }, 126 | content=example_github_push, 127 | headers=headers("wrong digest"), 128 | ).json(), 129 | {"status": 401, "ret": "Invalid SHA-256 HMAC digest"}, 130 | ) 131 | await client.close() 132 | -------------------------------------------------------------------------------- /tests/test_gitlab_gchat.py: -------------------------------------------------------------------------------- 1 | """Test module for gitlab "google chat" formatter.""" 2 | 3 | import unittest 4 | from pathlib import Path 5 | 6 | import httpx 7 | import nio 8 | 9 | from .start import BOT_URL, FULL_ID, KEY, MATRIX_ID, MATRIX_PW, MATRIX_URL 10 | 11 | 12 | class GitlabGchatFormatterTest(unittest.IsolatedAsyncioTestCase): 13 | """Gitlab "google chat" formatter test class.""" 14 | 15 | async def test_gitlab_gchat_body(self): 16 | """Send a markdown message, and check the result.""" 17 | messages = [] 18 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 19 | 20 | await client.login(MATRIX_PW) 21 | room = await client.room_create() 22 | 23 | with Path("tests/example_gitlab_gchat.json").open() as f: 24 | example_gitlab_gchat_request = f.read() 25 | self.assertEqual( 26 | httpx.post( 27 | f"{BOT_URL}/{room.room_id}", 28 | params={"formatter": "gitlab_gchat", "key": KEY}, 29 | content=example_gitlab_gchat_request, 30 | ).json(), 31 | {"status": 200, "ret": "OK"}, 32 | ) 33 | 34 | sync = await client.sync() 35 | messages = await client.room_messages(room.room_id, sync.next_batch) 36 | await client.close() 37 | 38 | message = messages.chunk[0] 39 | self.assertEqual(message.sender, FULL_ID) 40 | self.assertEqual( 41 | message.body, 42 | "John Doe pushed to branch [master](https://gitlab.com/jdoe/test/commits/m" 43 | + "aster) of [John Doe / test](https://gitlab.com/jdoe/test) ([Compare chan" 44 | + "ges](https://gitlab.com/jdoe/test/compare/b76004b20503d4d506e51a670de095" 45 | + "cc063e4707...3517b06c64c9d349e2213650d6c009db0471361e))\n[3517b06c](http" 46 | + "s://gitlab.com/jdoe/test/-/commit/3517b06c64c9d349e2213650d6c009db047136" 47 | + "1e): Merge branch 'prod' into 'master' - John Doe\n\n[1f661795](https://" 48 | + "gitlab.com/jdoe/test/-/commit/1f661795b220c5fe352f391eb8de3ac4fcc6fc1d):" 49 | + " Merge branch 'revert-a827b196' into 'prod' - John Doe\n\n[b76004b2](htt" 50 | + "ps://gitlab.com/jdoe/test/-/commit/b76004b20503d4d506e51a670de095cc063e4" 51 | + "707): Merge branch 'revert-a827b196' into 'master' - John Doe", 52 | ) 53 | -------------------------------------------------------------------------------- /tests/test_gitlab_teams.py: -------------------------------------------------------------------------------- 1 | """Test module for gitlab "teams" formatter.""" 2 | 3 | import unittest 4 | from pathlib import Path 5 | 6 | import httpx 7 | import nio 8 | 9 | from .start import BOT_URL, FULL_ID, KEY, MATRIX_ID, MATRIX_PW, MATRIX_URL 10 | 11 | 12 | class GitlabTeamsFormatterTest(unittest.IsolatedAsyncioTestCase): 13 | """Gitlab "teams" formatter test class.""" 14 | 15 | async def test_gitlab_teams_body(self): 16 | """Send a markdown message, and check the result.""" 17 | messages = [] 18 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 19 | 20 | await client.login(MATRIX_PW) 21 | room = await client.room_create() 22 | 23 | with Path("tests/example_gitlab_teams.json").open() as f: 24 | example_gitlab_teams_request = f.read() 25 | self.assertEqual( 26 | httpx.post( 27 | f"{BOT_URL}/{room.room_id}", 28 | params={"formatter": "gitlab_teams", "key": KEY}, 29 | content=example_gitlab_teams_request, 30 | ).json(), 31 | {"status": 200, "ret": "OK"}, 32 | ) 33 | 34 | sync = await client.sync() 35 | messages = await client.room_messages(room.room_id, sync.next_batch) 36 | await client.close() 37 | 38 | message = messages.chunk[0] 39 | self.assertEqual(message.sender, FULL_ID) 40 | self.assertEqual( 41 | message.body, 42 | "John Doe pushed to branch [master](https://gitlab.com/jdoe/test/commits" 43 | + "/master) in [John Doe / test](https://gitlab.com/jdoe/test) \u2192 [Com" 44 | + "pare changes](https://gitlab.com/jdoe/test/compare/b76004b20503d4d506e5" 45 | + "1a670de095cc063e4707...3517b06c64c9d349e2213650d6c009db0471361e) \n\n*" 46 | + " [3517b06c](https://gitlab.com/jdoe/test/-/commit/3517b06c64c9d349e2213" 47 | + "650d6c009db0471361e): Merge branch 'prod' into 'master' - John Doe \n*" 48 | + " [1f661795](https://gitlab.com/jdoe/test/-/commit/1f661795b220c5fe352f3" 49 | + "91eb8de3ac4fcc6fc1d): Merge branch 'revert-a827b196' into 'prod' - John" 50 | + " Doe \n* [b76004b2](https://gitlab.com/jdoe/test/-/commit/b76004b20503" 51 | + "d4d506e51a670de095cc063e4707): Merge branch 'revert-a827b196' into 'mas" 52 | + "ter' - John Doe", 53 | ) 54 | -------------------------------------------------------------------------------- /tests/test_grafana.py: -------------------------------------------------------------------------------- 1 | """Test module for grafana formatter. 2 | 3 | ref https://grafana.com/docs/grafana/latest/alerting/old-alerting/notifications/#webhook 4 | """ 5 | 6 | import unittest 7 | from pathlib import Path 8 | 9 | import httpx 10 | import nio 11 | 12 | from .start import BOT_URL, FULL_ID, KEY, MATRIX_ID, MATRIX_PW, MATRIX_URL 13 | 14 | 15 | class GrafanaFormatterTest(unittest.IsolatedAsyncioTestCase): 16 | """Grafana formatter test class.""" 17 | 18 | async def test_grafana_body(self): 19 | """Send a markdown message, and check the result.""" 20 | messages = [] 21 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 22 | 23 | await client.login(MATRIX_PW) 24 | room = await client.room_create() 25 | 26 | with Path("tests/example_grafana.json").open() as f: 27 | example_grafana_request = f.read() 28 | self.assertEqual( 29 | httpx.post( 30 | f"{BOT_URL}/{room.room_id}", 31 | params={"formatter": "grafana", "key": KEY}, 32 | content=example_grafana_request, 33 | ).json(), 34 | {"status": 200, "ret": "OK"}, 35 | ) 36 | 37 | sync = await client.sync() 38 | messages = await client.room_messages(room.room_id, sync.next_batch) 39 | await client.close() 40 | 41 | message = messages.chunk[0] 42 | self.assertEqual(message.sender, FULL_ID) 43 | self.assertEqual( 44 | message.body, 45 | "#### [Alerting] Panel Title alert\nNotification Message\n\n* Count: 1\n", 46 | ) 47 | -------------------------------------------------------------------------------- /tests/test_grafana_9x.py: -------------------------------------------------------------------------------- 1 | """Test module for grafana v9 formatter. 2 | 3 | ref https://grafana.com/docs/grafana/latest/alerting/old-alerting/notifications/#webhook 4 | """ 5 | 6 | import unittest 7 | from pathlib import Path 8 | 9 | import httpx 10 | import nio 11 | 12 | from .start import BOT_URL, FULL_ID, MATRIX_ID, MATRIX_PW, MATRIX_URL 13 | 14 | 15 | class Grafana9xFormatterTest(unittest.IsolatedAsyncioTestCase): 16 | """Grafana formatter test class.""" 17 | 18 | async def test_grafana_body(self): 19 | """Send a markdown message, and check the result.""" 20 | messages = [] 21 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 22 | 23 | await client.login(MATRIX_PW) 24 | room = await client.room_create() 25 | 26 | with Path("tests/example_grafana_9x.json").open() as f: 27 | example_grafana_request = f.read() 28 | self.assertEqual( 29 | httpx.post( 30 | f"{BOT_URL}/{room.room_id}", 31 | params={"formatter": "grafana_9x"}, 32 | content=example_grafana_request, 33 | ).json(), 34 | {"status": 200, "ret": "OK"}, 35 | ) 36 | 37 | sync = await client.sync() 38 | messages = await client.room_messages(room.room_id, sync.next_batch) 39 | await client.close() 40 | 41 | message = messages.chunk[0] 42 | self.assertEqual(message.sender, FULL_ID) 43 | expected_body = ( 44 | "#### [FIRING:1] (TestAlert Grafana)\n**Firing**\n\n\n\nValue: [ metr" 45 | "ic='foo' labels={instance=bar} value=10 ]\n\nLabels:\n\n - alertname " 46 | "= TestAlert\n\n - instance = Grafana\n\nAnnotations:\n\n - summary = " 47 | "Notification test\n\nSilence: https://grafana.example.com/alerting/si" 48 | "lence/new?alertmanager=grafana&matcher=alertname%3DTestAlert&matcher=" 49 | "instance%3DGrafana\n\n\n\n" 50 | ) 51 | self.assertEqual(message.body, expected_body) 52 | -------------------------------------------------------------------------------- /tests/test_grafana_forward.py: -------------------------------------------------------------------------------- 1 | """Test version 9 compatibility of grafana formatter. 2 | 3 | ref https://grafana.com/docs/grafana/latest/alerting/old-alerting/notifications/#webhook 4 | """ 5 | 6 | import unittest 7 | from pathlib import Path 8 | 9 | import httpx 10 | import nio 11 | 12 | from .start import BOT_URL, FULL_ID, MATRIX_ID, MATRIX_PW, MATRIX_URL 13 | 14 | 15 | class GrafanaForwardFormatterTest(unittest.IsolatedAsyncioTestCase): 16 | """Grafana formatter test class.""" 17 | 18 | async def test_grafana_body(self): 19 | """Send a markdown message, and check the result.""" 20 | messages = [] 21 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 22 | 23 | await client.login(MATRIX_PW) 24 | room = await client.room_create() 25 | 26 | with Path("tests/example_grafana_9x.json").open() as f: 27 | example_grafana_request = f.read() 28 | self.assertEqual( 29 | httpx.post( 30 | f"{BOT_URL}/{room.room_id}", 31 | params={"formatter": "grafana"}, 32 | content=example_grafana_request, 33 | ).json(), 34 | {"status": 200, "ret": "OK"}, 35 | ) 36 | 37 | sync = await client.sync() 38 | messages = await client.room_messages(room.room_id, sync.next_batch) 39 | await client.close() 40 | 41 | message = messages.chunk[0] 42 | self.assertEqual(message.sender, FULL_ID) 43 | expected_body = ( 44 | "#### [FIRING:1] (TestAlert Grafana)\n**Firing**\n\n\n\nValue: [ metr" 45 | "ic='foo' labels={instance=bar} value=10 ]\n\nLabels:\n\n - alertname " 46 | "= TestAlert\n\n - instance = Grafana\n\nAnnotations:\n\n - summary = " 47 | "Notification test\n\nSilence: https://grafana.example.com/alerting/si" 48 | "lence/new?alertmanager=grafana&matcher=alertname%3DTestAlert&matcher=" 49 | "instance%3DGrafana\n\n\n\n" 50 | ) 51 | self.assertEqual(message.body, expected_body) 52 | -------------------------------------------------------------------------------- /tests/test_grn.py: -------------------------------------------------------------------------------- 1 | """Test module for Github Release Notifier (grn) formatter. 2 | 3 | ref https://github.com/femtopixel/github-release-notifier 4 | """ 5 | 6 | import unittest 7 | from pathlib import Path 8 | 9 | import httpx 10 | import nio 11 | 12 | from .start import BOT_URL, FULL_ID, KEY, MATRIX_ID, MATRIX_PW, MATRIX_URL 13 | 14 | 15 | class GithubReleaseNotifierFormatterTest(unittest.IsolatedAsyncioTestCase): 16 | """GRN formatter test class.""" 17 | 18 | async def test_grn_body(self): 19 | """Send a markdown message, and check the result.""" 20 | messages = [] 21 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 22 | 23 | await client.login(MATRIX_PW) 24 | room = await client.room_create() 25 | 26 | with Path("tests/example_grn.json").open() as f: 27 | example_grn_request = f.read() 28 | self.assertEqual( 29 | httpx.post( 30 | f"{BOT_URL}/{room.room_id}", 31 | params={"formatter": "grn", "key": KEY}, 32 | content=example_grn_request, 33 | ).json(), 34 | {"status": 200, "ret": "OK"}, 35 | ) 36 | 37 | sync = await client.sync() 38 | messages = await client.room_messages(room.room_id, sync.next_batch) 39 | await client.close() 40 | 41 | message = messages.chunk[0] 42 | self.assertEqual(message.sender, FULL_ID) 43 | self.assertIn("Fixes split modules", message.body) 44 | self.assertIn("jaymoulin/google-music-manager", message.body) 45 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | """Main test module.""" 2 | 3 | import unittest 4 | 5 | import nio 6 | 7 | from .start import FULL_ID, KEY, MATRIX_ID, MATRIX_PW, MATRIX_URL, bot_req 8 | 9 | 10 | class BotTest(unittest.IsolatedAsyncioTestCase): 11 | """Main test class.""" 12 | 13 | def test_errors(self): 14 | """Check the bot's error paths.""" 15 | self.assertEqual(bot_req(), {"status": 400, "ret": "Invalid JSON"}) 16 | self.assertEqual( 17 | bot_req({"toto": 3}), 18 | {"status": 400, "ret": "Missing body, key, room_id"}, 19 | ) 20 | self.assertEqual( 21 | bot_req({"body": 3}, "wrong_key", "wrong_room"), 22 | {"status": 401, "ret": "Invalid API key"}, 23 | ) 24 | self.assertEqual( 25 | bot_req({"body": 3}, "wrong_key", "wrong_room", key_as_param=True), 26 | {"status": 401, "ret": "Invalid API key"}, 27 | ) 28 | self.assertEqual( 29 | bot_req({"body": 3}, KEY, params={"formatter": "wrong_formatter"}), 30 | {"status": 400, "ret": "Unknown formatter"}, 31 | ) 32 | # TODO: if the client from matrix_webhook has olm support, 33 | # this won't be a 403 from synapse, but a LocalProtocolError from matrix_webhook 34 | self.assertEqual( 35 | bot_req({"body": 3}, KEY, "wrong_room"), 36 | {"status": 400, "ret": "wrong_room was not legal room ID or room alias"}, 37 | ) 38 | self.assertEqual( 39 | bot_req({"body": 3}, KEY, "wrong_room", key_as_param=True), 40 | {"status": 400, "ret": "wrong_room was not legal room ID or room alias"}, 41 | ) 42 | 43 | async def test_message(self): 44 | """Send a markdown message with the old format, and check the result.""" 45 | text = "# Hello" 46 | messages = [] 47 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 48 | 49 | await client.login(MATRIX_PW) 50 | room = await client.room_create() 51 | 52 | self.assertEqual( 53 | bot_req({"text": text}, KEY, room.room_id), 54 | {"status": 200, "ret": "OK"}, 55 | ) 56 | 57 | sync = await client.sync() 58 | messages = await client.room_messages(room.room_id, sync.next_batch) 59 | await client.close() 60 | 61 | message = messages.chunk[0] 62 | self.assertEqual(message.sender, FULL_ID) 63 | self.assertEqual(message.body, text) 64 | self.assertEqual(message.formatted_body, "

Hello

") 65 | 66 | async def test_room_id_req(self): 67 | """Send a markdown message in a room given as data, and check the result.""" 68 | body = "# Hello" 69 | messages = [] 70 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 71 | 72 | await client.login(MATRIX_PW) 73 | room = await client.room_create() 74 | 75 | self.assertEqual( 76 | bot_req({"body": body, "room_id": room.room_id}, KEY, room.room_id), 77 | {"status": 200, "ret": "OK"}, 78 | ) 79 | 80 | sync = await client.sync() 81 | messages = await client.room_messages(room.room_id, sync.next_batch) 82 | await client.close() 83 | 84 | message = messages.chunk[0] 85 | self.assertEqual(message.sender, FULL_ID) 86 | self.assertEqual(message.body, body) 87 | self.assertEqual(message.formatted_body, "

Hello

") 88 | 89 | async def test_room_id_parameter(self): 90 | """Send a markdown message in a room given as parameter.""" 91 | body = "# Hello" 92 | messages = [] 93 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 94 | 95 | await client.login(MATRIX_PW) 96 | room = await client.room_create() 97 | 98 | self.assertEqual( 99 | bot_req({"body": body}, KEY, room.room_id, room_as_parameter=True), 100 | {"status": 200, "ret": "OK"}, 101 | ) 102 | 103 | sync = await client.sync() 104 | messages = await client.room_messages(room.room_id, sync.next_batch) 105 | await client.close() 106 | 107 | message = messages.chunk[0] 108 | self.assertEqual(message.sender, FULL_ID) 109 | self.assertEqual(message.body, body) 110 | self.assertEqual(message.formatted_body, "

Hello

") 111 | 112 | async def test_markdown_body(self): 113 | """Send a markdown message, and check the result.""" 114 | body = "# Hello" 115 | messages = [] 116 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 117 | 118 | await client.login(MATRIX_PW) 119 | room = await client.room_create() 120 | 121 | self.assertEqual( 122 | bot_req({"body": body}, KEY, room.room_id), 123 | {"status": 200, "ret": "OK"}, 124 | ) 125 | 126 | sync = await client.sync() 127 | messages = await client.room_messages(room.room_id, sync.next_batch) 128 | await client.close() 129 | 130 | message = messages.chunk[0] 131 | self.assertEqual(message.sender, FULL_ID) 132 | self.assertEqual(message.body, body) 133 | self.assertEqual(message.formatted_body, "

Hello

") 134 | 135 | async def test_formatted_body(self): 136 | """Send a formatted message, and check the result.""" 137 | body = "Formatted message" 138 | formatted_body = "markdownFormatted message" 139 | messages = [] 140 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 141 | 142 | await client.login(MATRIX_PW) 143 | room = await client.room_create() 144 | 145 | self.assertEqual( 146 | bot_req( 147 | {"body": body, "formatted_body": formatted_body}, 148 | KEY, 149 | room.room_id, 150 | ), 151 | {"status": 200, "ret": "OK"}, 152 | ) 153 | 154 | sync = await client.sync() 155 | messages = await client.room_messages(room.room_id, sync.next_batch) 156 | await client.close() 157 | 158 | message = messages.chunk[0] 159 | self.assertEqual(message.sender, FULL_ID) 160 | self.assertEqual(message.body, body) 161 | self.assertEqual(message.formatted_body, formatted_body) 162 | 163 | async def test_reconnect(self): 164 | """Check the reconnecting path.""" 165 | client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) 166 | await client.login(MATRIX_PW) 167 | room = await client.room_create() 168 | await client.logout(all_devices=True) 169 | await client.close() 170 | self.assertEqual( 171 | bot_req({"body": "Re"}, KEY, room.room_id), 172 | {"status": 200, "ret": "OK"}, 173 | ) 174 | 175 | async def test_healthcheck(self): 176 | """Check the healthcheck endpoint returns 200.""" 177 | self.assertEqual(bot_req(room_id="health"), {"status": 200, "ret": "OK"}) 178 | --------------------------------------------------------------------------------