From 6b5d6e6e8745e1e17fbbbd2e93039fedbf874ba2 Mon Sep 17 00:00:00 2001 From: Guilhem Saurel Date: Fri, 27 Aug 2021 23:47:07 +0200 Subject: [PATCH] formatters: add github --- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 3 +- matrix_webhook/formatters.py | 17 ++++++++ matrix_webhook/handler.py | 15 ++++++- tests/example_github_push.json | 1 + tests/example_grafana.json | 22 +++++++++++ tests/test_github.py | 71 ++++++++++++++++++++++++++++++++++ tests/test_grafana.py | 36 ++++------------- tests/tests.py | 2 +- 9 files changed, 136 insertions(+), 33 deletions(-) create mode 100644 tests/example_github_push.json create mode 100644 tests/example_grafana.json create mode 100644 tests/test_github.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e8ad61..e09667c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: hooks: - id: pydocstyle args: - - --ignore=D200,D212 + - --ignore=D200,D203,D212 - repo: https://github.com/PyCQA/flake8 rev: 3.9.2 hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md index f75b3db..ede170d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- add grafana formatter +- add github & grafana formatters - add formatted_body to bypass markdown with direct [matrix-custom-HTML](https://matrix.org/docs/spec/client_server/r0.6.1#m-room-message-msgtypes) - allow "key" to be passed as a parameter +- allow to use a sha256 HMAC hex digest with the key instead of the raw key - allow "room_id" to be passed as a parameter or with the data - rename "text" to "body". - Publish releases also on github from github actions diff --git a/matrix_webhook/formatters.py b/matrix_webhook/formatters.py index bec0979..55ba43d 100644 --- a/matrix_webhook/formatters.py +++ b/matrix_webhook/formatters.py @@ -13,3 +13,20 @@ def grafana(data, headers): text = text + "* " + match["metric"] + ": " + str(match["value"]) + "\n" data["body"] = text return data + + +def github(data, headers): + """Pretty-print a github notification.""" + # TODO: Write nice useful formatters. This is only an example. + if headers["X-GitHub-Event"] == "push": + pusher, ref, a, b, c = [ + data[k] for k in ["pusher", "ref", "after", "before", "compare"] + ] + pusher = f"[{pusher['name']}](https://github.com/{pusher['name']})" + data["body"] = f"@{pusher} pushed on {ref}: [{b} → {a}]({c}):\n\n" + for commit in data["commits"]: + data["body"] += f"- [{commit['message']}]({commit['url']})\n" + else: + data["body"] = "notification from github" + data["digest"] = headers["X-Hub-Signature-256"].replace("sha256=", "") + return data diff --git a/matrix_webhook/handler.py b/matrix_webhook/handler.py index ee48710..1f1ac5f 100644 --- a/matrix_webhook/handler.py +++ b/matrix_webhook/handler.py @@ -3,6 +3,7 @@ import json import logging from http import HTTPStatus +from hmac import HMAC from markdown import markdown @@ -18,10 +19,10 @@ async def matrix_webhook(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() + data_b = await request.read() try: - data = json.loads(data.decode()) + data = json.loads(data_b.decode()) except json.decoder.JSONDecodeError: return utils.create_json_response(HTTPStatus.BAD_REQUEST, "Invalid JSON") @@ -48,6 +49,16 @@ async def matrix_webhook(request): if "room_id" not in data: data["room_id"] = request.path.lstrip("/") + # If we get a good SHA-256 HMAC digest, + # we can consider that the sender has the right API key + if "digest" in data: + if data["digest"] == HMAC(conf.API_KEY.encode(), data_b, "sha256").hexdigest(): + data["key"] = conf.API_KEY + else: # but if there is a wrong digest, an informative error should be provided + return utils.create_json_response( + HTTPStatus.UNAUTHORIZED, "Invalid SHA-256 HMAC digest" + ) + missing = [] for key in ["body", "key", "room_id"]: if key not in data or not data[key]: diff --git a/tests/example_github_push.json b/tests/example_github_push.json new file mode 100644 index 0000000..6be68fa --- /dev/null +++ b/tests/example_github_push.json @@ -0,0 +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"]}} diff --git a/tests/example_grafana.json b/tests/example_grafana.json new file mode 100644 index 0000000..a768bc0 --- /dev/null +++ b/tests/example_grafana.json @@ -0,0 +1,22 @@ +{ + "dashboardId":1, + "evalMatches":[ + { + "value":1, + "metric":"Count", + "tags":{} + } + ], + "imageUrl":"https://grafana.com/assets/img/blog/mixed_styles.png", + "message":"Notification Message", + "orgId":1, + "panelId":2, + "ruleId":1, + "ruleName":"Panel Title alert", + "ruleUrl":"http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\u0026edit\u0026tab=alert\u0026panelId=2\u0026orgId=1", + "state":"alerting", + "tags":{ + "tag name":"tag value" + }, + "title":"[Alerting] Panel Title alert" +} diff --git a/tests/test_github.py b/tests/test_github.py new file mode 100644 index 0000000..30158e5 --- /dev/null +++ b/tests/test_github.py @@ -0,0 +1,71 @@ +"""Test module for grafana formatter.""" + +import unittest + +import httpx +import nio + +from .start import BOT_URL, FULL_ID, MATRIX_ID, MATRIX_PW, MATRIX_URL + +SHA256 = "fd7522672889385736be8ffc86d1f8de2e15668864f49af729b5c63e5e0698c4" +EXAMPLE_GITHUB_REQUEST_HEADERS = { + # 'Request URL': 'https://bot.saurel.me/room?formatter=github', + # 'Request method': 'POST', + "Accept": "*/*", + "content-type": "application/json", + "User-Agent": "GitHub-Hookshot/8d33975", + "X-GitHub-Delivery": "636b9b1c-0761-11ec-8a8a-5e435c5ac4f4", + "X-GitHub-Event": "push", + "X-GitHub-Hook-ID": "311845633", + "X-GitHub-Hook-Installation-Target-ID": "171114171", + "X-GitHub-Hook-Installation-Target-Type": "repository", + "X-Hub-Signature": "sha1=ea68fdfcb2f328aaa8f50d176f355e5d4fc95d94", + "X-Hub-Signature-256": f"sha256={SHA256}", +} + + +class GithubFormatterTest(unittest.IsolatedAsyncioTestCase): + """Github formatter test class.""" + + async def test_github_body(self): + """Send a markdown message, and check the result.""" + messages = [] + client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) + + await client.login(MATRIX_PW) + room = await client.room_create() + + with open("tests/example_github_push.json", "rb") as f: + example_github_push = f.read().strip() + + self.assertEqual( + httpx.post( + f"{BOT_URL}/{room.room_id}", + params={ + "formatter": "github", + }, + content=example_github_push, + headers=EXAMPLE_GITHUB_REQUEST_HEADERS, + ).json(), + {"status": 200, "ret": "OK"}, + ) + + sync = await client.sync() + messages = await client.room_messages(room.room_id, sync.next_batch) + await client.close() + + before = "ac7d1d9647008145e9d0cf65d24744d0db4862b8" + after = "4bcdb25c809391baaabc264d9309059f9f48ead2" + GH = "https://github.com" + expected = f'

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

\n" + + message = messages.chunk[0] + self.assertEqual(message.sender, FULL_ID) + self.assertEqual( + message.formatted_body, + expected, + ) diff --git a/tests/test_grafana.py b/tests/test_grafana.py index 33c080c..6cd04cf 100644 --- a/tests/test_grafana.py +++ b/tests/test_grafana.py @@ -1,4 +1,8 @@ -"""Test module for grafana formatter.""" +""" +Test module for grafana formatter. + +ref https://grafana.com/docs/grafana/latest/alerting/old-alerting/notifications/#webhook +""" import unittest @@ -7,32 +11,6 @@ import nio from .start import BOT_URL, FULL_ID, KEY, MATRIX_ID, MATRIX_PW, MATRIX_URL -# ref https://grafana.com/docs/grafana/latest/alerting/old-alerting/notifications/#webhook -EXAMPLE_GRAFANA_REQUEST = """ -{ - "dashboardId":1, - "evalMatches":[ - { - "value":1, - "metric":"Count", - "tags":{} - } - ], - "imageUrl":"https://grafana.com/assets/img/blog/mixed_styles.png", - "message":"Notification Message", - "orgId":1, - "panelId":2, - "ruleId":1, - "ruleName":"Panel Title alert", - "ruleUrl":"http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\u0026edit\u0026tab=alert\u0026panelId=2\u0026orgId=1", - "state":"alerting", - "tags":{ - "tag name":"tag value" - }, - "title":"[Alerting] Panel Title alert" -} -""" - class GrafanaFormatterTest(unittest.IsolatedAsyncioTestCase): """Grafana formatter test class.""" @@ -45,11 +23,13 @@ class GrafanaFormatterTest(unittest.IsolatedAsyncioTestCase): await client.login(MATRIX_PW) room = await client.room_create() + with open("tests/example_grafana.json") as f: + example_grafana_request = f.read() self.assertEqual( httpx.post( f"{BOT_URL}/{room.room_id}", params={"formatter": "grafana", "key": KEY}, - content=EXAMPLE_GRAFANA_REQUEST, + content=example_grafana_request, ).json(), {"status": 200, "ret": "OK"}, ) diff --git a/tests/tests.py b/tests/tests.py index 01993c3..aa5991c 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -86,7 +86,7 @@ class BotTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(message.formatted_body, "

Hello

") async def test_room_id_parameter(self): - """Send a markdown message in a room given as parameter, and check the result.""" + """Send a markdown message in a room given as parameter.""" body = "# Hello" messages = [] client = nio.AsyncClient(MATRIX_URL, MATRIX_ID)