forked from clan/clan-core
matrix-bot: Init working sending and receiving
matrix-bot: Code cleanup matrix-bot: Code cleanup# matrix-bot: Code cleanup# matrix-bot: Ping on review neede Add .gitignore Working user ping
This commit is contained in:
parent
ef9b733631
commit
c26b7e0a0a
1
pkgs/matrix-bot/.gitignore
vendored
Normal file
1
pkgs/matrix-bot/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
*.json
|
|
@ -10,3 +10,4 @@ from matrix_bot import main # NOQA
|
|||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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()
|
||||
main()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from .main import main
|
||||
from . import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
|
BIN
pkgs/matrix-bot/matrix_bot/avatar.png
Normal file
BIN
pkgs/matrix-bot/matrix_bot/avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 105 KiB |
81
pkgs/matrix-bot/matrix_bot/bot.py
Normal file
81
pkgs/matrix-bot/matrix_bot/bot.py
Normal file
|
@ -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
|
89
pkgs/matrix-bot/matrix_bot/gitea.py
Normal file
89
pkgs/matrix-bot/matrix_bot/gitea.py
Normal file
|
@ -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()}"
|
||||
)
|
31
pkgs/matrix-bot/matrix_bot/locked_open.py
Normal file
31
pkgs/matrix-bot/matrix_bot/locked_open.py
Normal file
|
@ -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
|
|
@ -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])
|
||||
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()
|
||||
|
|
80
pkgs/matrix-bot/matrix_bot/matrix.py
Normal file
80
pkgs/matrix-bot/matrix_bot/matrix.py
Normal file
|
@ -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"<a href='https://matrix.to/#/{user_id}'>{user_id}</a>"
|
||||
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
|
|
@ -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]
|
||||
|
|
|
@ -16,10 +16,7 @@ let
|
|||
]);
|
||||
in
|
||||
mkShell {
|
||||
buildInputs = [
|
||||
|
||||
ruff
|
||||
] ++ devshellTestDeps;
|
||||
buildInputs = [ ruff ] ++ devshellTestDeps;
|
||||
|
||||
PYTHONBREAKPOINT = "ipdb.set_trace";
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user