From 2b641ba17f25ebed0da57cebd9ac62f915901451 Mon Sep 17 00:00:00 2001 From: FirePlank <44502537+FirePlank@users.noreply.github.com> Date: Sat, 25 Nov 2023 22:35:37 +0200 Subject: [PATCH 1/4] Added automatic chess tournament creation --- bot/config.py | 8 +++ bot/extensions/chess/__init__.py | 7 ++ bot/extensions/chess/tasks.py | 115 +++++++++++++++++++++++++++++++ cli.py | 1 + example.env | 6 ++ 5 files changed, 137 insertions(+) create mode 100644 bot/extensions/chess/__init__.py create mode 100644 bot/extensions/chess/tasks.py diff --git a/bot/config.py b/bot/config.py index 260dc16b..82d8c544 100644 --- a/bot/config.py +++ b/bot/config.py @@ -102,6 +102,13 @@ class YouTube(BaseModel): role_id: int +class Chess(BaseModel): + channel_id: int + role_id: int + team_id: str + access_token: str + + class Settings(BaseSettings): aoc: AoC bot: Bot @@ -117,6 +124,7 @@ class Settings(BaseSettings): errors: ErrorHandling custom_roles: CustomRoles youtube: YouTube + chess: Chess class Config: env_file = ".env" diff --git a/bot/extensions/chess/__init__.py b/bot/extensions/chess/__init__.py new file mode 100644 index 00000000..2c83833f --- /dev/null +++ b/bot/extensions/chess/__init__.py @@ -0,0 +1,7 @@ +from bot.core import DiscordBot + +from .tasks import ChessTasks + + +async def setup(bot: DiscordBot) -> None: + await bot.add_cog(ChessTasks(bot=bot)) diff --git a/bot/extensions/chess/tasks.py b/bot/extensions/chess/tasks.py new file mode 100644 index 00000000..2c0000b0 --- /dev/null +++ b/bot/extensions/chess/tasks.py @@ -0,0 +1,115 @@ +import asyncio +import datetime +import json + +import discord +from discord.ext import commands, tasks + +from bot import core +from bot.config import settings +from bot.services import http + +CHESS_HEADERS = {"Authorization": f"Bearer {settings.chess.access_token}"} + + +class ChessTasks(commands.Cog): + """Tasks for Chess functions""" + + def __init__(self, bot: core.DiscordBot): + self.bot = bot + self.medals = ["🥇", "🥈", "🥉"] + self.time_controls = { + "60+0": ("Blitz", "180+2", 5), + "180+2": ("Rapid", "600+0", 5), + "600+0": ("Bullet", "60+0", 7), + } + self.check_events.start() + + def cog_unload(self) -> None: + self.check_events.cancel() + + @property + def channel(self) -> discord.TextChannel | None: + return self.bot.get_channel(settings.chess.channel_id) + + @tasks.loop(minutes=2) + async def check_events(self): + """Checks for new events on the server""" + + for event in self.channel.guild.scheduled_events: + if event.location != f"<#{self.channel.id}>": + continue + + until = event.start_time.timestamp() - datetime.datetime.now().timestamp() + if not (58 < until / 60 < 60): + continue + + async with http.session.get( + f"https://lichess.org/api/team/{settings.chess.team_id}/swiss", + headers=CHESS_HEADERS, + params={"max": 1}, + raise_for_status=True, + ) as resp: + try: + tournament = json.loads(await resp.content.read()) + except json.JSONDecodeError: + tournament = {"clock": {"limit": 60, "increment": 0}} + + time_control = self.time_controls.get( + f'{tournament["clock"]["limit"]}+{tournament["clock"]["increment"]}', ("Blitz", "180+2", 5) + ) + time = time_control[1].split("+") + + async with http.session.post( + f"https://lichess.org/api/swiss/new/{settings.chess.team_id}", + headers=CHESS_HEADERS, + data={ + "name": f"Weekly {time_control[0]} Tournament", + "clock.limit": time[0], + "clock.increment": time[1], + "nbRounds": time_control[2], + "startsAt": int(event.start_time.timestamp() * 1000), + }, + raise_for_status=True, + ) as resp: + tournament = await resp.json() + + await self.channel.send( + f"<@&{settings.chess.role_id}> {time_control[0]} chess tourney in less than an hour! " + "Click the link below to join!\n" + f"****\n\n" + "Prizes:\n" + "🥇 2500 pancakes 🥞\n" + "🥈 1000 pancakes 🥞\n" + "🥉 500 pancakes 🥞", + allowed_mentions=discord.AllowedMentions(roles=True), + ) + + await asyncio.sleep(until) + while True: + async with http.session.get( + f"https://lichess.org/api/swiss/{tournament['id']}", headers=CHESS_HEADERS + ) as resp: + tournament = await resp.json() + + if tournament["status"] == "finished": + break + await asyncio.sleep(10) + + async with http.session.get( + f"https://lichess.org/api/swiss/{tournament['id']}/results", headers=CHESS_HEADERS, params={"nb": 3} + ) as resp: + results = await resp.text() + players = [json.loads(result) for result in results.split("\n") if result] + + await self.channel.send( + "The tournament has ended! See you next time!\n\n" + "Winners:\n" + + "\n".join(f"{self.medals[i]} **{player['username']}**" for i, player in enumerate(players[:3])) + + "\n\n(If you are one of the winners, please let us know by verifying your lichess username.)" + ) + + @check_events.error + async def on_check_error(self, _error: Exception): + """Logs any errors that occur during the check_events task""" + await self.bot.on_error("check_chess_events") diff --git a/cli.py b/cli.py index b151094b..3c9f895e 100644 --- a/cli.py +++ b/cli.py @@ -131,6 +131,7 @@ async def main(ctx): extensions = ( "jishaku", "bot.extensions.challenges", + "bot.extensions.chess", "bot.extensions.readthedocs", "bot.extensions.suggestions", "bot.extensions.github", diff --git a/example.env b/example.env index f0d5222f..6846ce0e 100644 --- a/example.env +++ b/example.env @@ -72,3 +72,9 @@ CUSTOM_ROLES__DIVIDER_ROLE_ID=0 YOUTUBE__CHANNEL_ID=UC4JX40jDee_tINbkjycV4Sg YOUTUBE__TEXT_CHANNEL_ID=0 YOUTUBE__ROLE_ID=0 + +# --- Chess +CHESS__CHANNEL_ID=0 +CHESS__ROLE_ID=0 +CHESS__TEAM_ID=twt-chess-club +CHESS__ACCESS_TOKEN=0 # https://lichess.org/account/oauth/token/create?scopes[]=tournament:write&description=TWT+Discord+Bot From fc1223ef8e1618b38af4c5843f4c292b9f6c8ec1 Mon Sep 17 00:00:00 2001 From: FirePlank <44502537+FirePlank@users.noreply.github.com> Date: Mon, 27 Nov 2023 17:04:40 +0200 Subject: [PATCH 2/4] Added functionality to edit the event once the tournament has been created --- bot/extensions/chess/tasks.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/extensions/chess/tasks.py b/bot/extensions/chess/tasks.py index 2c0000b0..aa961a49 100644 --- a/bot/extensions/chess/tasks.py +++ b/bot/extensions/chess/tasks.py @@ -85,6 +85,11 @@ async def check_events(self): allowed_mentions=discord.AllowedMentions(roles=True), ) + await event.edit( + name=f"Weekly {time_control[0]} Tournament", + location=f"https://lichess.org/swiss/{tournament['id']}", + ) + await asyncio.sleep(until) while True: async with http.session.get( From c148230f5f1122d830492223078d5c228287dd78 Mon Sep 17 00:00:00 2001 From: FirePlank <44502537+FirePlank@users.noreply.github.com> Date: Tue, 5 Dec 2023 17:50:06 +0200 Subject: [PATCH 3/4] did the suggested changes --- bot/extensions/chess/__init__.py | 2 + bot/extensions/chess/events.py | 68 ++++++++++++++ bot/extensions/chess/tasks.py | 151 ++++++++++++------------------- bot/extensions/chess/utils.py | 74 +++++++++++++++ cli.py | 2 - 5 files changed, 200 insertions(+), 97 deletions(-) create mode 100644 bot/extensions/chess/events.py create mode 100644 bot/extensions/chess/utils.py diff --git a/bot/extensions/chess/__init__.py b/bot/extensions/chess/__init__.py index 2c83833f..aa656bb7 100644 --- a/bot/extensions/chess/__init__.py +++ b/bot/extensions/chess/__init__.py @@ -1,7 +1,9 @@ from bot.core import DiscordBot +from .events import ChessEvents from .tasks import ChessTasks async def setup(bot: DiscordBot) -> None: await bot.add_cog(ChessTasks(bot=bot)) + await bot.add_cog(ChessEvents(bot=bot)) diff --git a/bot/extensions/chess/events.py b/bot/extensions/chess/events.py new file mode 100644 index 00000000..49e65d45 --- /dev/null +++ b/bot/extensions/chess/events.py @@ -0,0 +1,68 @@ +import asyncio +from collections import defaultdict + +import discord +from discord.ext import commands + +from bot import core +from bot.config import settings +from bot.extensions.chess.tasks import ChessTasks + + +class ChessEvents(commands.Cog): + """Events for Chess functions""" + + def __init__(self, bot: core.DiscordBot): + self.bot = bot + self.tasks: dict[int, list[tuple[asyncio.Task, discord.ScheduledEvent]]] = defaultdict(list) + + def cog_load(self) -> None: + for guild in self.bot.guilds: + for event in guild.scheduled_events: + if event.location != f"<#{settings.chess.channel_id}>": + continue + + if any(e.id == event.id for task, e in self.tasks[guild.id]): + continue + + print("adding task") + + self.tasks[guild.id].append((asyncio.create_task(ChessTasks(self.bot).run_task(event)), event)) + + @commands.Cog.listener() + async def on_scheduled_event_create(self, event: discord.ScheduledEvent): + if event.location != f"<#{settings.chess.channel_id}>": + return + + self.tasks[event.guild.id].append((asyncio.create_task(ChessTasks(self.bot).run_task(event)), event)) + + @commands.Cog.listener() + async def on_scheduled_event_delete(self, event: discord.ScheduledEvent): + if event.location != f"<#{settings.chess.channel_id}>": + return + + for task, e in self.tasks[event.guild.id]: + if e.id == event.id: + task.cancel() + self.tasks[event.guild.id].remove((task, e)) + break + + @commands.Cog.listener() + async def on_scheduled_event_update(self, before: discord.ScheduledEvent, after: discord.ScheduledEvent): + if before.location != f"<#{settings.chess.channel_id}>": + return + + for task, e in self.tasks[before.guild.id]: + if e.id == before.id: + if after.location.startswith("https://lichess.org/swiss/"): + self.tasks[before.guild.id].remove((task, e)) + break + + task.cancel() + self.tasks[before.guild.id].remove((task, e)) + + if after.location != f"<#{settings.chess.channel_id}>": + return + + self.tasks[before.guild.id].append((asyncio.create_task(ChessTasks(self.bot).run_task(after)), after)) + break diff --git a/bot/extensions/chess/tasks.py b/bot/extensions/chess/tasks.py index aa961a49..29a6c4fc 100644 --- a/bot/extensions/chess/tasks.py +++ b/bot/extensions/chess/tasks.py @@ -1,15 +1,12 @@ import asyncio import datetime -import json import discord -from discord.ext import commands, tasks +from discord.ext import commands from bot import core from bot.config import settings -from bot.services import http - -CHESS_HEADERS = {"Authorization": f"Bearer {settings.chess.access_token}"} +from bot.extensions.chess.utils import lichess class ChessTasks(commands.Cog): @@ -23,98 +20,62 @@ def __init__(self, bot: core.DiscordBot): "180+2": ("Rapid", "600+0", 5), "600+0": ("Bullet", "60+0", 7), } - self.check_events.start() - - def cog_unload(self) -> None: - self.check_events.cancel() @property def channel(self) -> discord.TextChannel | None: return self.bot.get_channel(settings.chess.channel_id) - @tasks.loop(minutes=2) - async def check_events(self): - """Checks for new events on the server""" - - for event in self.channel.guild.scheduled_events: - if event.location != f"<#{self.channel.id}>": - continue - - until = event.start_time.timestamp() - datetime.datetime.now().timestamp() - if not (58 < until / 60 < 60): - continue - - async with http.session.get( - f"https://lichess.org/api/team/{settings.chess.team_id}/swiss", - headers=CHESS_HEADERS, - params={"max": 1}, - raise_for_status=True, - ) as resp: - try: - tournament = json.loads(await resp.content.read()) - except json.JSONDecodeError: - tournament = {"clock": {"limit": 60, "increment": 0}} - - time_control = self.time_controls.get( - f'{tournament["clock"]["limit"]}+{tournament["clock"]["increment"]}', ("Blitz", "180+2", 5) - ) - time = time_control[1].split("+") - - async with http.session.post( - f"https://lichess.org/api/swiss/new/{settings.chess.team_id}", - headers=CHESS_HEADERS, - data={ - "name": f"Weekly {time_control[0]} Tournament", - "clock.limit": time[0], - "clock.increment": time[1], - "nbRounds": time_control[2], - "startsAt": int(event.start_time.timestamp() * 1000), - }, - raise_for_status=True, - ) as resp: - tournament = await resp.json() - - await self.channel.send( - f"<@&{settings.chess.role_id}> {time_control[0]} chess tourney in less than an hour! " - "Click the link below to join!\n" - f"****\n\n" - "Prizes:\n" - "🥇 2500 pancakes 🥞\n" - "🥈 1000 pancakes 🥞\n" - "🥉 500 pancakes 🥞", - allowed_mentions=discord.AllowedMentions(roles=True), - ) - - await event.edit( - name=f"Weekly {time_control[0]} Tournament", - location=f"https://lichess.org/swiss/{tournament['id']}", - ) - - await asyncio.sleep(until) - while True: - async with http.session.get( - f"https://lichess.org/api/swiss/{tournament['id']}", headers=CHESS_HEADERS - ) as resp: - tournament = await resp.json() - - if tournament["status"] == "finished": - break - await asyncio.sleep(10) - - async with http.session.get( - f"https://lichess.org/api/swiss/{tournament['id']}/results", headers=CHESS_HEADERS, params={"nb": 3} - ) as resp: - results = await resp.text() - players = [json.loads(result) for result in results.split("\n") if result] - - await self.channel.send( - "The tournament has ended! See you next time!\n\n" - "Winners:\n" - + "\n".join(f"{self.medals[i]} **{player['username']}**" for i, player in enumerate(players[:3])) - + "\n\n(If you are one of the winners, please let us know by verifying your lichess username.)" - ) - - @check_events.error - async def on_check_error(self, _error: Exception): - """Logs any errors that occur during the check_events task""" - await self.bot.on_error("check_chess_events") + async def run_task(self, event: discord.ScheduledEvent): + """Creates the chess tournament when it's time based on the event start time.""" + + until = (event.start_time - datetime.timedelta(hours=1)).replace(tzinfo=None) + if until > datetime.datetime.utcnow(): + await asyncio.sleep((until - datetime.datetime.utcnow()).total_seconds()) + else: + return + + tournament = await lichess.get_team_tournament() + time_control = self.time_controls.get( + f"{tournament.clock.limit}+{tournament.clock.increment}", ("Blitz", "180+2", 5) + ) + time = time_control[1].split("+") + + tournament = await lichess.create_tournament( + f"Weekly {time_control[0]} Tournament", + int(event.start_time.timestamp() * 1000), + int(time[1]), + int(time[0]), + time_control[2], + ) + + await self.channel.send( + f"<@&{settings.chess.role_id}> {time_control[0]} chess tourney in less than an hour! " + "Click the link below to join!\n" + f"****\n\n" + "Prizes:\n" + "🥇 2500 pancakes 🥞\n" + "🥈 1000 pancakes 🥞\n" + "🥉 500 pancakes 🥞", + allowed_mentions=discord.AllowedMentions(roles=True), + ) + + await event.edit( + name=f"Weekly {time_control[0]} Tournament", + location=f"https://lichess.org/swiss/{tournament.id}", + ) + + await asyncio.sleep((event.start_time.replace(tzinfo=None) - datetime.datetime.utcnow()).total_seconds()) + while True: + tournament = await lichess.get_tournament(tournament.id) + if tournament.status == "finished": + break + + await asyncio.sleep(10) + + players = await lichess.get_tournament_results(tournament.id) + await self.channel.send( + "The tournament has ended! See you next time!\n\n" + "Winners:\n" + + "\n".join(f"{self.medals[i]} **{player['username']}**" for i, player in enumerate(players[:3])) + + "\n\n(If you are one of the winners, please let us know by verifying your lichess username.)" + ) diff --git a/bot/extensions/chess/utils.py b/bot/extensions/chess/utils.py new file mode 100644 index 00000000..9af08599 --- /dev/null +++ b/bot/extensions/chess/utils.py @@ -0,0 +1,74 @@ +import json + +import pydantic +from pydantic import BaseModel + +from bot.config import settings +from bot.services import http + + +class Clock(BaseModel): + increment: int + limit: int + + +class Tournament(BaseModel): + id: str + status: str + clock: Clock + + +class LichessAPI: + def __init__(self, token: str, team_id: str): + self.headers = {"Authorization": f"Bearer {token}"} + self.team = team_id + + async def get_team_tournament(self) -> Tournament: + async with http.session.get( + f"https://lichess.org/api/team/{self.team}/swiss", + headers=self.headers, + params={"max": 1}, + raise_for_status=True, + ) as resp: + try: + return Tournament.parse_obj(await resp.json(content_type="application/x-ndjson")) + except pydantic.error_wrappers.ValidationError: + return Tournament(id="0", status="finished", clock={"limit": 60, "increment": 0}) + + async def get_tournament(self, tournament_id: str) -> Tournament: + async with http.session.get( + f"https://lichess.org/api/swiss/{tournament_id}", + headers=self.headers, + raise_for_status=True, + ) as resp: + return Tournament.parse_obj(await resp.json()) + + async def get_tournament_results(self, tournament_id: str) -> list[dict]: + async with http.session.get( + f"https://lichess.org/api/swiss/{tournament_id}/results", + headers=self.headers, + params={"nb": 3}, + raise_for_status=True, + ) as resp: + results = await resp.text() + return [json.loads(result) for result in results.split("\n") if result] + + async def create_tournament( + self, name: str, start_time: int, increment: int, limit: int, rounds: int + ) -> Tournament: + async with http.session.post( + f"https://lichess.org/api/swiss/new/{self.team}", + headers=self.headers, + data={ + "name": name, + "clock.limit": limit, + "clock.increment": increment, + "nbRounds": rounds, + "startsAt": start_time, + }, + raise_for_status=True, + ) as resp: + return Tournament.parse_obj(await resp.json()) + + +lichess = LichessAPI(settings.chess.access_token, settings.chess.team_id) diff --git a/cli.py b/cli.py index 7823987e..cb122bae 100644 --- a/cli.py +++ b/cli.py @@ -133,8 +133,6 @@ async def main(ctx): "bot.extensions.adventofcode", "bot.extensions.challenges", "bot.extensions.chess", - "bot.extensions.readthedocs", - "bot.extensions.suggestions", "bot.extensions.github", "bot.extensions.levelling", "bot.extensions.persistent_roles", From ff8ecbf7b05d1883a520604ca789b51ddf8b2295 Mon Sep 17 00:00:00 2001 From: FirePlank <44502537+FirePlank@users.noreply.github.com> Date: Tue, 5 Dec 2023 17:51:50 +0200 Subject: [PATCH 4/4] removed print --- bot/extensions/chess/events.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/extensions/chess/events.py b/bot/extensions/chess/events.py index 49e65d45..606a0599 100644 --- a/bot/extensions/chess/events.py +++ b/bot/extensions/chess/events.py @@ -25,8 +25,6 @@ def cog_load(self) -> None: if any(e.id == event.id for task, e in self.tasks[guild.id]): continue - print("adding task") - self.tasks[guild.id].append((asyncio.create_task(ChessTasks(self.bot).run_task(event)), event)) @commands.Cog.listener()