1
0
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:
Luis Hebendanz 2024-06-27 18:21:20 +02:00
parent ef9b733631
commit c26b7e0a0a
13 changed files with 450 additions and 45 deletions

1
pkgs/matrix-bot/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.json

View File

@ -10,3 +10,4 @@ from matrix_bot import main # NOQA
if __name__ == "__main__":
main()

View File

@ -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;

View File

@ -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()

View File

@ -1,4 +1,4 @@
from .main import main
from . import main
if __name__ == "__main__":
main()
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View 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

View 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()}"
)

View 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

View File

@ -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()

View 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

View File

@ -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]

View File

@ -16,10 +16,7 @@ let
]);
in
mkShell {
buildInputs = [
ruff
] ++ devshellTestDeps;
buildInputs = [ ruff ] ++ devshellTestDeps;
PYTHONBREAKPOINT = "ipdb.set_trace";