From a65037713c47806f6db2fc29c6278a016f53d447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge-Mikael=20Nordg=C3=A5rd?= Date: Mon, 19 Aug 2024 02:18:27 +0200 Subject: [PATCH] Added webhooks handling --- README.md | 4 +- base-config.yaml | 33 +++++++-- devops_bot.py | 174 ++++++++++++++++++++++++++++++++++++++++++++++- maubot.yaml | 5 +- 4 files changed, 204 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0667799..1bebb5f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # Devops Bot -A bot made for use with the matrix chat and communications system that pulls information from a gitea instance. For now it only retrieves and makes issues in the issue database, -but future functions will also include running automated tests through another (probably -custom) API and displaying the results of the tests in the chat. +A bot made for use with the matrix chat and communications system that pulls information from a gitea instance. For now it only retrieves and makes issues in the issue database, and display push messages to the repository via webhooks. But future functions will also include running automated tests through another (probably custom) API and displaying the results of the tests in the chat. ## License diff --git a/base-config.yaml b/base-config.yaml index 573f10b..6651ab1 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -1,11 +1,13 @@ gitea_instance: git.arcticsoftware.no gitea_token: ABC123ABC1ABC123ABC1ABC123ABC1ABC123ABC1ABC123ABC1ABC123ABC123A +webhook_token: ABC123ABC1ABC123ABC1ABC123ABC1ABC123ABC1ABC123ABC1ABC123ABC123A gitea_owner: someowner gitea_repo: somerepo gitea_bug_label: 1 gitea_feature_label: 1 feature_issue_room: '!someroom:matrix.server.com' bug_issue_room: '!someotherrroom:matrix.server.com' +webhook_announcement_room: '!announcementroom:matrix.server.com' about_text: | Jeg er ment som en bro mellom Arctic Software sitt kodedeponi og matrix chatten. Du kan sende forskjellige kommandoer til meg og @@ -44,7 +46,7 @@ about_text: | Eksempel: "!sak 83"

- Denne botten er lagd/kodet av Helge-Mikael Nordgård og + Denne botten er lagd/utviklet av Helge-Mikael Nordgård og eventuelle spørsmål om bruk/utvikling av den kan rettes til meg per mail.

@@ -71,10 +73,15 @@ issue_disclaimer_text: | Denne saken ble videresendt fra dev bot fra [matrise serveren](https://chat.zuul.no) Brukeren ({nick}) som opprettet saken, kan kontaktes der ({userId}) +issue_details_text: | + 🚩 Sak nr. {saksnr} 🚩 ({type}) +

{title}

+
{description}
+ Saken på kodedeponiet kan leses 🔗 her command_new_help: | - **"!Ny" Kommando hjelp:** + **"!ny" Kommando hjelp:** - Skriv ny [type] "[tittel]" "[Beskrivelse]" for å opprette en ny sak. + Skriv !ny [type] "[tittel]" "[Beskrivelse]" for å opprette en ny sak. **Eksempel:** ```!ny bug "Jeg fant en bug" "Dette er en lengre beskrivelse av buggen"``` @@ -90,5 +97,23 @@ command_pm_help: | Har sendt deg en PM med informasjon om meg selv og hvordan du sender kommandoer til meg command_pm_success: | Har sendt deg en PM med resultatet fra forespørselen din +command_case_error: | + **"!sak" Kommando hjelp:** + + Skriv !sak [saksnr] for å hente informasjon om en sak fra kodedeponiet + + **Eksempel:** + + ```!sak 44``` + + For å hente informasjon om sak med saksnr 44 + + {error} command_success: | - Forespørselen din ble utført. {result} \ No newline at end of file + Forespørselen din ble utført. {result} +webhook_announce_push: | +

🛠️ Push til kodedeponi {repo} 🛠️

+ + {user} pushet ny kode til {branch} med melding:

+ +
{message}
\ No newline at end of file diff --git a/devops_bot.py b/devops_bot.py index 69f5f8b..b013354 100644 --- a/devops_bot.py +++ b/devops_bot.py @@ -4,8 +4,11 @@ import json import time import requests import asyncio +import hmac +import hashlib import shlex import aiohttp +from aiohttp.web import Request, Response, json_response from mautrix.client import Client, InternalEventType, MembershipEventDispatcher, SyncStream from mautrix.types import (Event, StateEvent, EventID, UserID, EventType, @@ -13,27 +16,32 @@ from mautrix.types import (Event, StateEvent, EventID, UserID, EventType, Membership) from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper from maubot import Plugin, MessageEvent -from maubot.handlers import command, event +from maubot.handlers import command, event, web class Config(BaseProxyConfig): def do_update(self, helper: ConfigUpdateHelper) -> None: helper.copy("gitea_instance") helper.copy("gitea_token") + helper.copy("webhook_token") helper.copy("gitea_owner") helper.copy("gitea_repo") helper.copy("gitea_bug_label") helper.copy("gitea_feature_label") helper.copy("feature_issue_room") helper.copy("bug_issue_room") + helper.copy("webhook_announcement_room") helper.copy("about_text") helper.copy("issue_bug_text") helper.copy("issue_feature_text") helper.copy("issue_disclaimer_text") + helper.copy("issue_details_text") helper.copy("command_pm_error") helper.copy("command_pm_help") helper.copy("command_pm_success") helper.copy("command_new_help") + helper.copy("command_case_error") helper.copy("command_success") + helper.copy("webhook_announce_push") class DevopsBot(Plugin): async def start(self) -> None: @@ -48,6 +56,9 @@ class DevopsBot(Plugin): def g_token(self) -> str: return self.config["gitea_token"] + def g_wh_token(self) -> str: + return self.config["webhook_token"] + def g_owner(self) -> str: return self.config["gitea_owner"] @@ -63,6 +74,9 @@ class DevopsBot(Plugin): def g_bug_room(self) -> str: return self.config["bug_issue_room"] + def g_webhook_room(self) -> str: + return self.config["webhook_announcement_room"] + def g_feature_room(self) -> str: return self.config["feature_issue_room"] @@ -77,6 +91,9 @@ class DevopsBot(Plugin): def g_disclaimer(self) -> str: return self.config["issue_disclaimer_text"] + + def g_issue_details(self) -> str: + return self.config["issue_details_text"] def g_c_self_error(self) -> str: return self.config["command_pm_error"] @@ -90,8 +107,14 @@ class DevopsBot(Plugin): def g_c_new_help(self) -> str: return self.config["command_new_help"] + def g_c_case_error(self) -> str: + return self.config["command_case_error"] + def g_c_command_success(self) -> str: return self.config["command_success"] + + def g_w_announce_push(self) -> str: + return self.config["webhook_announce_push"] # Match all the registered pm rooms in pm_rooms with # the username and return the room id @@ -143,6 +166,106 @@ class DevopsBot(Plugin): async with session.post(url, headers=headers, json=issue_data) as response: response.raise_for_status() return await response.json() + + # Fetch a single issue from the configured repository + async def get_issue(self, case) -> list: + url = f"https://{self.g_instance()}/api/v1/repos/{self.g_owner()}/{self.g_repo()}/issues/{case}" + headers = { + "Authorization": f"token {self.g_token()}", + "Accept": "application/json" + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + response.raise_for_status() + return await response.json() + + @command.new(name="sak", aliases=["case"]) + @command.argument("saksnr", pass_raw=False, required=False) + async def sak(self, evt: MessageEvent, saksnr: str = None) -> None: + # We want to see the commands ran in a open room, so + # we can get a sense of it's usage + if evt.room_id in self.pm_rooms: + await evt.reply(self.g_c_self_error()) + return + + # Check correct formatting of the arguments first + if not saksnr: + await evt.reply(self.g_c_case_error().format( + error="Feil: saksnr er påkrevd" + )) + return + elif not saksnr.isdigit(): + await evt.reply(self.g_c_case_error().format( + error="Feil: saksnr må være et heltall" + )) + return + elif not int(saksnr) > 0: + await evt.reply(self.g_c_case_error().format( + error="Feil: saksnr må ha et tall høyere enn 0" + )) + return + + try: + issue = await self.get_issue(saksnr) + + # Determine what type of issue we're dealing with + issueType = 'Ukjent' + + for label in issue["labels"]: + if label["id"] == self.g_bug_label(): + issueType = 'Bug' + break + if label["id"] == self.g_feature_label(): + issueType = 'Forbedring' + break + + if issueType == 'Bug': + issueContent = self.g_issue_details().format( + saksnr=issue["number"], + type="🪲 Bug", + title=issue["title"], + description=issue["body"], + link=issue["html_url"] + ) + elif issueType == 'Forbedring': + issueContent = self.g_issue_details().format( + saksnr=issue["number"], + type="🎫 Forbedring", + title=issue["title"], + description=issue["body"], + link=issue["html_url"] + ) + else: + issueContent = self.g_issue_details().format( + saksnr=issue["number"], + type="❓ Ukjent", + title=issue["title"], + description=issue["body"], + link=issue["html_url"] + ) + + # Check if the bot already has a PM session with the user + # so we don't stack up invites to new rooms for each + # command output + pm_room = await self.has_pm_room(evt.sender) + + if pm_room != 'NONE': + await self.client.send_text(pm_room, html=issueContent) + else: + pm_room = await self.client.create_room(is_direct=True, invitees=[evt.sender]) + self.pm_rooms.add(pm_room) + await self.client.send_text(pm_room, html=issueContent) + + await evt.reply(self.g_c_self_success()) + except aiohttp.ClientError as e: + await evt.reply(f"Feil ved forespørsel: {e}") + except aiohttp.ContentTypeError as e: + await evt.reply(f"Feil ved syntaktisk analyse av JSON forespørsel: {e}") + except KeyError as e: + await evt.reply(f"En feil oppstod ved forsøk på å hente saksdata fra ny sak: {e}") + except Exception as e: + await evt.reply(f"En uventet feil oppstod: {e}") @command.new(name="ny", aliases=["new"]) @command.argument("args", pass_raw=True, required=False) @@ -267,7 +390,7 @@ class DevopsBot(Plugin): # command output pm_room = await self.has_pm_room(evt.sender) - if pm_room is not 'NONE': + if pm_room != 'NONE': await self.client.send_text(pm_room, html=output) else: pm_room = await self.client.create_room(is_direct=True, invitees=[evt.sender]) @@ -300,7 +423,7 @@ class DevopsBot(Plugin): # command output pm_room = await self.has_pm_room(evt.sender) - if pm_room is not 'NONE': + if pm_room != 'NONE': await self.client.send_text(pm_room, html=pm) else: pm_room = await self.client.create_room(is_direct=True, invitees=[evt.sender]) @@ -308,6 +431,51 @@ class DevopsBot(Plugin): await self.client.send_text(pm_room, html=pm) await evt.reply(self.g_c_self_help()) + + @web.post("/webhook") + async def post_webhook(self, request: Request) -> Response: + content_type = request.headers.get('Content-Type', '').lower().strip() + if 'application/json' not in content_type: + return Response(status=415, text="Unsupported or no media type in header") + + try: + payload = await request.text() + except Exception as e: + self.log.warning(msg=f"Devbot recieved invalid payload {str(e)}") + return Response(status=400, text="Bad request") + + if not payload: + return Response(status=400, text="Bad request") + + header_signature = request.headers.get('X-Gitea-Signature', '') + + if not header_signature: + self.log.warning(msg=f"Devbot got a signature without gitea header signature") + return Response(status=400, text="Bad request") + + payload_signature = hmac.new(self.g_wh_token().encode(), payload.encode(), hashlib.sha256).hexdigest() + + if header_signature != payload_signature: + self.log.warning(msg=f"Devbot got a webhook signal with an invalid signature") + return Response(status=403, text="Forbidden") + + try: + decoded = json.loads(payload) + except json.JSONDecodeError as e: + self.log.warning(msg=f"Devbot cannot parse the payload from json - {str(e)}") + return Response(status=400, text="Bad request") + + event_type = request.headers.get("X-Gitea-Event") + + if event_type == 'push': + await self.client.send_notice(self.g_webhook_room(), html=self.g_w_announce_push().format( + repo=decoded['repository']['full_name'], + user=decoded['pusher']['full_name'], + branch=decoded['ref'].split('/')[-1], + message=decoded['commits'][0]['message'] + )) + + return Response(status=200, text="Request OK") @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: diff --git a/maubot.yaml b/maubot.yaml index 14cf361..02c59d3 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: no.arcticsoftware.devopsbot -version: 0.1.34 +version: 0.1.4 license: AGPL-3.0-or-later modules: - devops_bot @@ -8,4 +8,5 @@ config: true extra_files: - base-config.yaml main_class: DevopsBot -database: false \ No newline at end of file +database: false +webapp: true \ No newline at end of file