diff --git a/pkgs/matrix-bot/.gitignore b/pkgs/matrix-bot/.gitignore new file mode 100644 index 00000000..a6c57f5f --- /dev/null +++ b/pkgs/matrix-bot/.gitignore @@ -0,0 +1 @@ +*.json diff --git a/pkgs/matrix-bot/bin/bot b/pkgs/matrix-bot/bin/bot index 7d09bcc9..bd72f9a6 100755 --- a/pkgs/matrix-bot/bin/bot +++ b/pkgs/matrix-bot/bin/bot @@ -10,3 +10,4 @@ from matrix_bot import main # NOQA if __name__ == "__main__": main() + diff --git a/pkgs/matrix-bot/default.nix b/pkgs/matrix-bot/default.nix index 61b3a9be..834bd413 100644 --- a/pkgs/matrix-bot/default.nix +++ b/pkgs/matrix-bot/default.nix @@ -1,17 +1,17 @@ -{ python3, setuptools, mautrix, ... }: - +{ + python3, + setuptools, + matrix-nio, + ... +}: let - pythonDependencies = [ - mautrix - ]; + pythonDependencies = [ matrix-nio ]; runtimeDependencies = [ ]; - testDependencies = - runtimeDependencies ++ [ - ]; + testDependencies = pythonDependencies ++ runtimeDependencies ++ [ ]; in python3.pkgs.buildPythonApplication { @@ -19,9 +19,7 @@ python3.pkgs.buildPythonApplication { src = ./.; format = "pyproject"; - nativeBuildInputs = [ - setuptools - ]; + nativeBuildInputs = [ setuptools ]; propagatedBuildInputs = pythonDependencies; diff --git a/pkgs/matrix-bot/matrix_bot/__init__.py b/pkgs/matrix-bot/matrix_bot/__init__.py index b727ca63..49d0925e 100644 --- a/pkgs/matrix-bot/matrix_bot/__init__.py +++ b/pkgs/matrix-bot/matrix_bot/__init__.py @@ -1,4 +1,120 @@ -from .main import main +import argparse +import asyncio +import logging +import os +import sys +from pathlib import Path + +from matrix_bot.custom_logger import setup_logging +from matrix_bot.gitea import GiteaData +from matrix_bot.main import bot_main +from matrix_bot.matrix import MatrixData + +log = logging.getLogger(__name__) + +curr_dir = Path(__file__).parent + + +def create_parser(prog: str | None = None) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=prog, + description="A gitea bot for matrix", + formatter_class=argparse.RawTextHelpFormatter, + ) + + parser.add_argument( + "--debug", + help="Enable debug logging", + action="store_true", + default=False, + ) + + parser.add_argument( + "--server", + help="The matrix server to connect to", + default="https://matrix.clan.lol", + ) + + parser.add_argument( + "--user", + help="The matrix user to connect as", + default="@clan-bot:clan.lol", + ) + + parser.add_argument( + "--avatar", + help="The path to the image to use as the avatar", + default=curr_dir / "avatar.png", + ) + + parser.add_argument( + "--repo-owner", + help="The owner of gitea the repository", + default="clan", + ) + parser.add_argument( + "--repo-name", + help="The name of the repository", + default="clan-core", + ) + + parser.add_argument( + "--matrix-room", + help="The matrix room to join", + default="#bot-test:gchq.icu", + ) + + parser.add_argument( + "--gitea-url", + help="The gitea url to connect to", + default="https://git.clan.lol", + ) + + parser.add_argument( + "--trigger-labels", + help="The labels that trigger the bot", + default=["needs-review"], + nargs="+", + ) + + return parser + + +def main() -> None: + parser = create_parser() + args = parser.parse_args() + + if args.debug: + setup_logging(logging.DEBUG, root_log_name=__name__.split(".")[0]) + log.debug("Debug log activated") + else: + setup_logging(logging.INFO, root_log_name=__name__.split(".")[0]) + + password = os.getenv("MATRIX_PASSWORD") + if not password: + log.error("No password provided set the MATRIX_PASSWORD environment variable") + + matrix = MatrixData( + server=args.server, + user=args.user, + avatar=args.avatar, + room=args.matrix_room, + password=password, + ) + + gitea = GiteaData( + url=args.gitea_url, + owner=args.repo_owner, + repo=args.repo_name, + trigger_labels=args.trigger_labels, + access_token=os.getenv("GITEA_ACCESS_TOKEN"), + ) + + try: + asyncio.run(bot_main(matrix, gitea)) + except KeyboardInterrupt: + print("User Interrupt", file=sys.stderr) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/pkgs/matrix-bot/matrix_bot/__main__.py b/pkgs/matrix-bot/matrix_bot/__main__.py index b727ca63..868d99ef 100644 --- a/pkgs/matrix-bot/matrix_bot/__main__.py +++ b/pkgs/matrix-bot/matrix_bot/__main__.py @@ -1,4 +1,4 @@ -from .main import main +from . import main if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/pkgs/matrix-bot/matrix_bot/avatar.png b/pkgs/matrix-bot/matrix_bot/avatar.png new file mode 100644 index 00000000..e9f5cd47 Binary files /dev/null and b/pkgs/matrix-bot/matrix_bot/avatar.png differ diff --git a/pkgs/matrix-bot/matrix_bot/bot.py b/pkgs/matrix-bot/matrix_bot/bot.py new file mode 100644 index 00000000..45d0d649 --- /dev/null +++ b/pkgs/matrix-bot/matrix_bot/bot.py @@ -0,0 +1,81 @@ +import logging + +log = logging.getLogger(__name__) +import time +from pathlib import Path +import json +import aiohttp +from nio import ( + AsyncClient, + JoinResponse, + JoinedMembersResponse, + MatrixRoom, + RoomMessageText, +) + +from matrix_bot.gitea import ( + GiteaData, + PullState, + fetch_pull_requests, + fetch_repo_labels, +) + +from .locked_open import read_locked_file, write_locked_file +from .matrix import MatrixData, send_message + + +async def message_callback(room: MatrixRoom, event: RoomMessageText) -> None: + print( + f"Message received in room {room.display_name}\n" + f"{room.user_name(event.sender)} | {event.body}" + ) + + +async def bot_run( + client: AsyncClient, + http: aiohttp.ClientSession, + matrix: MatrixData, + gitea: GiteaData, +) -> None: + # If you made a new room and haven't joined as that user, you can use + room: JoinResponse = await client.join(matrix.room) + + users: JoinedMembersResponse = await client.joined_members(room.room_id) + + if not users.transport_response.ok: + raise Exception(f"Failed to get users {users}") + + for user in users.members: + print(f"User: {user.user_id} {user.display_name}") + + labels = await fetch_repo_labels(gitea, http) + label_ids: list[int] = [] + for label in labels: + if label["name"] in gitea.trigger_labels: + label_ids.append(label["id"]) + + tstart = time.time() + pulls = await fetch_pull_requests(gitea, http, limit=50, state=PullState.ALL) + + last_updated_path = Path("last_updated.json") + last_updated = read_locked_file(last_updated_path) + + for pull in pulls: + if pull["requested_reviewers"] and pull["mergeable"]: + if last_updated == {}: + last_updated = pull + elif pull["updated_at"] < last_updated["updated_at"]: + last_updated = pull + else: + continue + log.info(f"Pull request {pull['title']} needs review") + message = f"Pull request {pull['title']} needs review\n{pull['html_url']}" + await send_message(client, room, message, user_ids=["@qubasa:gchq.icu"]) + + write_locked_file(last_updated_path, last_updated) + + tend = time.time() + tdiff = round(tend - tstart) + log.debug(f"Time taken: {tdiff}s") + + # await client.sync_forever(timeout=30000) # milliseconds diff --git a/pkgs/matrix-bot/matrix_bot/gitea.py b/pkgs/matrix-bot/matrix_bot/gitea.py new file mode 100644 index 00000000..22e0a644 --- /dev/null +++ b/pkgs/matrix-bot/matrix_bot/gitea.py @@ -0,0 +1,89 @@ +import logging + +log = logging.getLogger(__name__) + + +from dataclasses import dataclass, field +from enum import Enum + +import aiohttp + + +@dataclass +class GiteaData: + url: str + owner: str + repo: str + access_token: str | None = None + trigger_labels: list[str] = field(default_factory=list) + + +def endpoint_url(gitea: GiteaData, endpoint: str) -> str: + return f"{gitea.url}/api/v1/repos/{gitea.owner}/{gitea.repo}/{endpoint}" + + +async def fetch_repo_labels( + gitea: GiteaData, + session: aiohttp.ClientSession, +) -> list[dict]: + """ + Fetch labels from a Gitea repository. + + Returns: + list: List of labels in the repository. + """ + url = endpoint_url(gitea, "labels") + headers = {"Accept": "application/vnd.github.v3+json"} + if gitea.access_token: + headers["Authorization"] = f"token {gitea.access_token}" + + async with session.get(url, headers=headers) as response: + if response.status == 200: + labels = await response.json() + return labels + else: + # You may want to handle different statuses differently + raise Exception( + f"Failed to fetch labels: {response.status}, {await response.text()}" + ) + + +class PullState(Enum): + OPEN = "open" + CLOSED = "closed" + ALL = "all" + + +async def fetch_pull_requests( + gitea: GiteaData, + session: aiohttp.ClientSession, + *, + limit: int, + state: PullState, + label_ids: list[int] = [], +) -> list[dict]: + """ + Fetch pull requests from a Gitea repository. + + Returns: + list: List of pull requests. + """ + # You can use the same pattern as fetch_repo_labels + url = endpoint_url(gitea, "pulls") + params = { + "state": state.value, + "sort": "recentupdate", + "limit": limit, + "labels": label_ids, + } + headers = {"accept": "application/json"} + + async with session.get(url, params=params, headers=headers) as response: + if response.status == 200: + labels = await response.json() + return labels + else: + # You may want to handle different statuses differently + raise Exception( + f"Failed to fetch labels: {response.status}, {await response.text()}" + ) diff --git a/pkgs/matrix-bot/matrix_bot/locked_open.py b/pkgs/matrix-bot/matrix_bot/locked_open.py new file mode 100644 index 00000000..8c64a0c6 --- /dev/null +++ b/pkgs/matrix-bot/matrix_bot/locked_open.py @@ -0,0 +1,31 @@ +import fcntl +import json +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + + +@contextmanager +def locked_open(filename: str | Path, mode: str = "r") -> Generator: + """ + This is a context manager that provides an advisory write lock on the file specified by `filename` when entering the context, and releases the lock when leaving the context. The lock is acquired using the `fcntl` module's `LOCK_EX` flag, which applies an exclusive write lock to the file. + """ + with open(filename, mode) as fd: + fcntl.flock(fd, fcntl.LOCK_EX) + yield fd + fcntl.flock(fd, fcntl.LOCK_UN) + + +def write_locked_file(path: Path, data: dict[str, Any]) -> None: + with locked_open(path, "w+") as f: + f.write(json.dumps(data, indent=4)) + + +def read_locked_file(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with locked_open(path, "r") as f: + content: str = f.read() + parsed: list[dict] = json.loads(content) + return parsed diff --git a/pkgs/matrix-bot/matrix_bot/main.py b/pkgs/matrix-bot/matrix_bot/main.py index 63df1908..2e7cbf0e 100644 --- a/pkgs/matrix-bot/matrix_bot/main.py +++ b/pkgs/matrix-bot/matrix_bot/main.py @@ -1,36 +1,47 @@ -import argparse import logging -import sys +from pathlib import Path -from matrix_bot.custom_logger import setup_logging +import aiohttp + +from matrix_bot.gitea import GiteaData +from matrix_bot.matrix import MatrixData log = logging.getLogger(__name__) +curr_dir = Path(__file__).parent -def create_parser(prog: str | None = None) -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog=prog, - description="A gitea bot for matrix", - formatter_class=argparse.RawTextHelpFormatter, - ) +from nio import ( + AsyncClient, + ProfileGetAvatarResponse, + RoomMessageText, +) - parser.add_argument( - "--debug", - help="Enable debug logging", - action="store_true", - default=False, - ) +from matrix_bot.bot import bot_run, message_callback +from matrix_bot.matrix import set_avatar, upload_image - return parser -def main(): - parser = create_parser() - args = parser.parse_args() - if len(sys.argv) == 1: - parser.print_help() +async def bot_main( + matrix: MatrixData, + gitea: GiteaData, +) -> None: + log.info(f"Connecting to {matrix.server} as {matrix.user}") + client = AsyncClient(matrix.server, matrix.user) + client.add_event_callback(message_callback, RoomMessageText) - if args.debug: - setup_logging(logging.DEBUG, root_log_name=__name__.split(".")[0]) - log.debug("Debug log activated") + log.info(await client.login(matrix.password)) + + avatar: ProfileGetAvatarResponse = await client.get_avatar() + if not avatar.avatar_url: + mxc_url = await upload_image(client, matrix.avatar) + log.info(f"Uploaded avatar to {mxc_url}") + await set_avatar(client, mxc_url) else: - setup_logging(logging.INFO, root_log_name=__name__.split(".")[0]) \ No newline at end of file + log.info(f"Bot already has an avatar {avatar.avatar_url}") + + try: + async with aiohttp.ClientSession() as session: + await bot_run(client, session, matrix, gitea) + except Exception as e: + log.exception(e) + finally: + await client.close() diff --git a/pkgs/matrix-bot/matrix_bot/matrix.py b/pkgs/matrix-bot/matrix_bot/matrix.py new file mode 100644 index 00000000..4d4c6dce --- /dev/null +++ b/pkgs/matrix-bot/matrix_bot/matrix.py @@ -0,0 +1,80 @@ +import logging +from pathlib import Path + +log = logging.getLogger(__name__) + +from dataclasses import dataclass + +from nio import ( + AsyncClient, + JoinResponse, + ProfileSetAvatarResponse, + RoomSendResponse, + UploadResponse, +) + + +async def upload_image(client: AsyncClient, image_path: str) -> str: + with open(image_path, "rb") as image_file: + response: UploadResponse + response, _ = await client.upload(image_file, content_type="image/png") + if not response.transport_response.ok: + raise Exception(f"Failed to upload image {response}") + return response.content_uri # This is the MXC URL + + +async def set_avatar(client: AsyncClient, mxc_url: str) -> None: + response: ProfileSetAvatarResponse + response = await client.set_avatar(mxc_url) + if not response.transport_response.ok: + raise Exception(f"Failed to set avatar {response}") + + +from nio import AsyncClient + + +async def send_message( + client: AsyncClient, + room: JoinResponse, + message: str, + user_ids: list[str] | None = None, +) -> None: + """ + Send a message in a Matrix room, optionally mentioning users. + """ + # If user_ids are provided, format the message to mention them + if user_ids: + mention_list = ", ".join( + [ + f"{user_id}" + for user_id in user_ids + ] + ) + body = f"{mention_list}: {message}" + formatted_body = f"{mention_list}: {message}" + else: + body = message + formatted_body = message + + content = { + "msgtype": "m.text", + "format": "org.matrix.custom.html", + "body": body, + "formatted_body": formatted_body, + } + + res: RoomSendResponse = await client.room_send( + room_id=room.room_id, message_type="m.room.message", content=content + ) + + if not res.transport_response.ok: + raise Exception(f"Failed to send message {res}") + + +@dataclass +class MatrixData: + server: str + user: str + avatar: Path + password: str + room: str diff --git a/pkgs/matrix-bot/pyproject.toml b/pkgs/matrix-bot/pyproject.toml index a21466a0..e94402b7 100644 --- a/pkgs/matrix-bot/pyproject.toml +++ b/pkgs/matrix-bot/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "matrix-bot" description = "matrix bot for release messages from git commits" dynamic = ["version"] -scripts = { clan = "matrix_bot:main" } +scripts = { mbot = "matrix_bot:main" } license = {text = "MIT"} [project.urls] diff --git a/pkgs/matrix-bot/shell.nix b/pkgs/matrix-bot/shell.nix index 690857fd..85d32492 100644 --- a/pkgs/matrix-bot/shell.nix +++ b/pkgs/matrix-bot/shell.nix @@ -16,10 +16,7 @@ let ]); in mkShell { - buildInputs = [ - - ruff - ] ++ devshellTestDeps; + buildInputs = [ ruff ] ++ devshellTestDeps; PYTHONBREAKPOINT = "ipdb.set_trace";