diff --git a/modules/dgn-chatops/.envrc b/modules/dgn-chatops/.envrc new file mode 100644 index 0000000..1d953f4 --- /dev/null +++ b/modules/dgn-chatops/.envrc @@ -0,0 +1 @@ +use nix diff --git a/modules/dgn-chatops/default.nix b/modules/dgn-chatops/default.nix index 2e86d47..d3b030d 100644 --- a/modules/dgn-chatops/default.nix +++ b/modules/dgn-chatops/default.nix @@ -43,7 +43,12 @@ let python3 = pkgs.python311; python3Pkgs = python3.pkgs; ircrobots = python3Pkgs.callPackage ./ircrobots.nix { }; - ps = python3Pkgs.makePythonPath [ ircrobots ]; + tortoise-orm = python3Pkgs.callPackage ./tortoise-orm.nix { }; + ps = python3Pkgs.makePythonPath [ + ircrobots + tortoise-orm + python3Pkgs.aiohttp + ]; in { options.dgn-chatops = { diff --git a/modules/dgn-chatops/pypika-tortoise.nix b/modules/dgn-chatops/pypika-tortoise.nix new file mode 100644 index 0000000..1982f5b --- /dev/null +++ b/modules/dgn-chatops/pypika-tortoise.nix @@ -0,0 +1,31 @@ +{ + lib, + buildPythonPackage, + fetchFromGitHub, + poetry-core, +}: + +buildPythonPackage rec { + pname = "pypika-tortoise"; + version = "0.1.6"; + pyproject = true; + + src = fetchFromGitHub { + owner = "tortoise"; + repo = "pypika-tortoise"; + rev = "v${version}"; + hash = "sha256-xx5FUMHh6413fsvwrEA+Q0tBmJWy00h5O6YijvrJyCE="; + }; + + build-system = [ poetry-core ]; + + pythonImportsCheck = [ "pypika" ]; + + meta = { + description = ""; + homepage = "https://github.com/tortoise/pypika-tortoise"; + changelog = "https://github.com/tortoise/pypika-tortoise/blob/${src.rev}/CHANGELOG.md"; + license = lib.licenses.asl20; + maintainers = with lib.maintainers; [ raitobezarius ]; + }; +} diff --git a/modules/dgn-chatops/pyproject.toml b/modules/dgn-chatops/pyproject.toml new file mode 100644 index 0000000..84c8c15 --- /dev/null +++ b/modules/dgn-chatops/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "takumi" +version = "1.0.0" +authors = [ + { name = "Ryan Lahfa", email = "ryan@dgnum.eu" }, +] +description = "Fully automatic day-to-day operations at DGNum" +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] + +[project.urls] +Homepage = "https://git.dgnum.eu/DGNum/infrastructure" +Issues = "https://git.dgnum.eu/DGNum/infrastructure/issues" diff --git a/modules/dgn-chatops/shell.nix b/modules/dgn-chatops/shell.nix new file mode 100644 index 0000000..cf74478 --- /dev/null +++ b/modules/dgn-chatops/shell.nix @@ -0,0 +1,29 @@ +{ + pkgs ? import { }, + python3 ? pkgs.python3, +}: +let + takumi = python3.pkgs.buildPythonPackage rec { + pname = "takumi"; + version = "1.0.0"; + pyproject = true; + + src = ./.; + + build-system = [ python3.pkgs.hatchling ]; + + dependencies = [ + (python3.pkgs.callPackage ./ircrobots.nix { }) + (python3.pkgs.callPackage ./tortoise-orm.nix { }) + python3.pkgs.aiohttp + ]; + + postInstall = '' + mkdir -p $out/bin + cp -v takumi.py $out/bin/takumi.py + chmod +x $out/bin/takumi.py + wrapProgram $out/bin/takumi.py --prefix PYTHONPATH : "$PYTHONPATH" + ''; + }; +in +pkgs.mkShell { packages = [ takumi ]; } diff --git a/modules/dgn-chatops/takumi.py b/modules/dgn-chatops/takumi.py index 4959517..3efd8a3 100644 --- a/modules/dgn-chatops/takumi.py +++ b/modules/dgn-chatops/takumi.py @@ -1,20 +1,106 @@ +#!/usr/bin/env python3 import asyncio -from irctokens import build, Line -from ircrobots import Bot as BaseBot -from ircrobots import Server as BaseServer -from ircrobots import ConnectionParams +from irctokens.line import build, Line +from ircrobots.bot import Bot as BaseBot +from ircrobots.server import Server as BaseServer +from ircrobots.params import ConnectionParams + +import aiohttp + +BRIDGE_NICKNAME = "hermes" SERVERS = [ ("dgnum", "irc.dgnum.eu") ] +TEAMS = { + "fai": ("tomate", "elias", "JeMaGius", "Luj", "catvayor", "Raito"), + "marketing": ("cst1", "elias"), + "bureau": ("Raito", "JeMaGius", "Luj", "gdd") +} + +# times format is 0700-29092024 +TRIGGER = '!' +async def create_meet(title: str, times: list[str], timezone: str = "UTC") -> str: + async with aiohttp.ClientSession() as session: + payload = { + 'name': title, + 'times': times, + 'timezone': timezone + } + async with session.post('https://api.meet.dgnum.eu/event', json=payload) as response: + response.raise_for_status() + id = (await response.json()).get('id') + if not id: + raise RuntimeError('No ID attributed to a meet') + return f'https://meet.dgnum.eu/{id}' + +def expand_times(times: list[str]) -> list[str]: + expanded = [] + # TODO: verify the date exist in the calendar + # TODO: verify that we don't write any duplicates. + for time in times: + if '-' not in time: + for i in range(7, 20): + expanded.append(f'{i:02}00-{time}') + else: + expanded.append(time) + return expanded + +def bridge_stripped(possible_command: str, origin_nick: str) -> str | None: + if origin_nick.lower() == BRIDGE_NICKNAME: + stripped_user = possible_command.split(':')[1].lstrip() + return stripped_user if stripped_user.startswith(TRIGGER) else None + else: + return possible_command if possible_command.startswith(TRIGGER) else None + class Server(BaseServer): + def extract_valid_command(self, line: Line) -> str | None: + me = self.nickname_lower + if line.command == "PRIVMSG" and \ + self.has_channel(line.params[0]) and \ + line.hostmask is not None and \ + self.casefold(line.hostmask.nickname) != me and \ + self.has_user(line.hostmask.nickname): + return bridge_stripped(line.params[1], line.hostmask.nickname) + + async def line_read(self, line: Line): print(f"{self.name} < {line.format()}") if line.command == "001": print(f"connected to {self.isupport.network}") await self.send(build("JOIN", ["#dgnum-bridge-test"])) + + # In case `!probe_meet <team> <time_1> <time_2> … <time_N> [<timezone>]` + if (command := self.extract_valid_command(line)) is not None: + text = command.lstrip(TRIGGER) + if text.startswith('probe_meet'): + args = text.split(' ') + if len(args) < 4: + await self.send(build("PRIVMSG", [line.params[0], "usage is !probe_meet <title> <team> <time_1> [<time_2> <time_3> … <time_N>] ; time is in [00-hour-]DDMMYYYY format."])) + return + + title, team = args[1], args[2] + print(f"creating meet '{title}' for team '{team}'") + try: + times = expand_times(args[3:]) + link = await create_meet(title, times) + if team not in TEAMS: + await self.send(build("PRIVMSG", [line.params[0], f"team {team} does not exist"])) + return + + targets = TEAMS[team] + ping_mentions = ', '.join(targets) + await self.send(build("PRIVMSG", [line.params[0], f'{ping_mentions} {link}'])) + except ValueError as e: + print(e) + await self.send(build("PRIVMSG", [line.params[0], "time format is [00-hour-]DDMMYYYY, hour is optional, by default it's 07:00 to 19:00 in Europe/Paris timezone"])) + except aiohttp.ClientError as e: + print(e) + await self.send(build("PRIVMSG", [line.params[0], "failed to create the meet on meet.dgnum.eu, API error, check the logs"])) + + async def line_send(self, line: Line): print(f"{self.name} > {line.format()}") diff --git a/modules/dgn-chatops/tortoise-orm.nix b/modules/dgn-chatops/tortoise-orm.nix new file mode 100644 index 0000000..05c9580 --- /dev/null +++ b/modules/dgn-chatops/tortoise-orm.nix @@ -0,0 +1,71 @@ +{ + lib, + buildPythonPackage, + fetchFromGitHub, + poetry-core, + aiosqlite, + iso8601, + callPackage, + pytz, + ciso8601, + orjson, + uvloop, + aiomysql, + asyncmy, + asyncpg, + psycopg, + pydantic, + pythonRelaxDepsHook, +}: + +buildPythonPackage rec { + pname = "tortoise-orm"; + version = "0.21.6"; + pyproject = true; + + src = fetchFromGitHub { + owner = "tortoise"; + repo = "tortoise-orm"; + rev = version; + hash = "sha256-Gu7MSJbPjaGUN6tmHwkmx7Bdy/+V1wZjmTCQrTDDPkw="; + }; + + buildInputs = [ pythonRelaxDepsHook ]; + + pythonRelaxDeps = [ + "aiosqlite" + "iso8601" + ]; + + build-system = [ poetry-core ]; + + dependencies = [ + aiosqlite + iso8601 + pydantic + (callPackage ./pypika-tortoise.nix { }) + pytz + ]; + + optional-dependencies = { + accel = [ + ciso8601 + orjson + uvloop + ]; + aiomysql = [ aiomysql ]; + asyncmy = [ asyncmy ]; + asyncpg = [ asyncpg ]; + psycopg = [ psycopg ]; + }; + + pythonImportsCheck = [ "tortoise" ]; + + meta = { + description = ""; + homepage = "https://github.com/tortoise/tortoise-orm"; + changelog = "https://github.com/tortoise/tortoise-orm/blob/${src.rev}/CHANGELOG.rst"; + license = lib.licenses.asl20; + maintainers = with lib.maintainers; [ raitobezarius ]; + }; +}