From 9773de16398c0540a0acbc46f7753ed46b8175e3 Mon Sep 17 00:00:00 2001 From: Fruktus Date: Tue, 2 Dec 2025 20:02:57 +0100 Subject: [PATCH 1/2] feat: add bot commands for tournament management --- server/src/QRServer/config.py | 6 ++ server/src/QRServer/db/connector.py | 25 +++++ server/src/QRServer/discord/bot.py | 147 ++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) diff --git a/server/src/QRServer/config.py b/server/src/QRServer/config.py index e40c5ae9..5d8e9e0f 100644 --- a/server/src/QRServer/config.py +++ b/server/src/QRServer/config.py @@ -205,6 +205,12 @@ def __init__(self): cli_args=[], description='Maximum number of aliases per user', default_value=1) + self.discord_bot_channel_tournament_notifications_id = ConfigKey( + config=self, + name='discord.bot.channel_tournament_notifications.id', + cli_args=[], + description='Discord Channel ID for tournament notifications such as tournament start, round start or other', + default_value='') def get_key(self, name: str): by_name = self.keys_by_name() diff --git a/server/src/QRServer/db/connector.py b/server/src/QRServer/db/connector.py index d091acde..379efb70 100644 --- a/server/src/QRServer/db/connector.py +++ b/server/src/QRServer/db/connector.py @@ -562,6 +562,31 @@ async def list_tournaments(self) -> list[Tournament]: )) return result + async def get_tournament_by_name(self, tournament_name: str) -> Tournament | None: + c = await self.conn.cursor() + await c.execute( + "select id, name, created_by_dc_id, tournament_msg_dc_id," + " required_matches_per_duel, created_at," + " started_at, finished_at" + " from tournaments" + " where name = ?", + (tournament_name,) + ) + + row = await c.fetchone() + if row is None: + return None + return Tournament( + tournament_id=row[0], + name=row[1], + created_by_dc_id=row[2], + tournament_msg_dc_id=row[3], + required_matches_per_duel=row[4], + created_at=datetime.fromtimestamp(row[5], tz=timezone.utc), + started_at=datetime.fromtimestamp(row[6], tz=timezone.utc) if row[6] else None, + finished_at=datetime.fromtimestamp(row[7], tz=timezone.utc) if row[7] else None, + ) + async def list_tournament_users(self, tournament_id: str) -> list[DbUser] | None: c = await self.conn.cursor() await c.execute( diff --git a/server/src/QRServer/discord/bot.py b/server/src/QRServer/discord/bot.py index 358ad970..3cb2ff19 100644 --- a/server/src/QRServer/discord/bot.py +++ b/server/src/QRServer/discord/bot.py @@ -59,6 +59,34 @@ async def claim(interaction, username: str): async def reset_password(interaction, username: str): await self._reset_password(interaction, username) + @self.tree.command(name="create_tournament", description="Create tournament") + @discord.app_commands.describe(tournament_name="Name of the tournament", dc_msg_id="Tournament registration message", required_matches_per_duel="The amount of matches that are needed to qualify for round") + async def create_tournament(interaction, tournament_name: str, dc_msg_id: str, required_matches_per_duel): + await self._create_tournament(interaction, tournament_name, dc_msg_id) + create_tournament.default_permissions = discord.Permissions(permissions=0) + + @self.tree.command(name="join_tournament", description="Join tournament with specified account") + @discord.app_commands.describe(tournament_name="Name of the tournament to join", username="The username to add as participant", required_matches_per_duel="Total valid matches that are needed to proceed in tournament") + async def join_tournament(interaction, tournament_name: str, username: str, required_matches_per_duel: int): + await self._join_tournament(interaction, tournament_name, username) + + @self.tree.command(name="leave_tournament", description="Leave tournament with specified account") + @discord.app_commands.describe(tournament_name="Name of the tournament to leave", username="The username to add as participant") + async def leave_tournament(interaction, tournament_name: str, username: str): + await self._leave_tournament(interaction, tournament_name, username) + + @self.tree.command(name="start_tournament", description="Start tournament") + @discord.app_commands.describe(tournament_name="Name of the tournament") + async def start_tournament(interaction, tournament_name: str): + await self._start_tournament(interaction, tournament_name) + start_tournament.default_permissions = discord.Permissions(permissions=0) + + @self.tree.command(name="start_tournament_round", description="Start tournament's next round") + @discord.app_commands.describe(tournament_name="Name of the tournament", active_until="Date until which matches are accepted in the isoformat. Ex: 2021-07-27T16:02:08.070557") + async def start_tournament_round(interaction, tournament_name: str, active_until: str): + await self._start_tournament_round(interaction, tournament_name, active_until) + start_tournament_round.default_permissions = discord.Permissions(permissions=0) + @self.client.event async def on_ready(): await self._on_ready() @@ -326,3 +354,122 @@ async def _send_user_notification(self, message: str) -> None: log.warning( 'User notifications channel not found or not accepting messages: ' + self.user_notifications_channel_id) + + async def _create_tournament(self, interaction, tournament_name: str, tournament_msg_dc_id: str, required_matches_per_duel: int) -> None: + """ + Creates an empty tournament tied to specific owner and registration message. + The message can be used for interactions registration, but is not at the moment. + + The command may fail if the tournament_name is already in use. + """ + result = await self.connector.create_tournament(tournament_name, tournament_msg_dc_id, required_matches_per_duel) + if not result: + await interaction.response.send_message( + f"Tournament creation failed. Ensure that the tournament name is unique.", + ephemeral=True) + return + else: + await interaction.response.send_message( + f"Tournament created. ID: {result}", + ephemeral=True) + # TODO should we send tournament_created notification? + + + async def _join_tournament(self, interaction, tournament_name: str, username: str) -> None: + """ + Allows discord user with a valid QR account to join the tournament (if it did not already start). + The username is Quadradius account username, since one Discord user may have many QR usernames. + + The command may fail if the tournamend_name is invalid, the username does not belong to the callee (or is banned/invalid), + if it was already registered, or the tournament has already began. + """ + user = await self.connector.get_user_by_username(username) + # TODO include bans here as well + if not user or user.discord_user_id != interaction.user.id: # Do not disclose whether account exists or not + await interaction.response.send_message( + f"Failed to authorize the user account - The username is invalid (incorrect or banned)", + ephemeral=True) + return + + tournament = await self.connector.get_tournament_by_name(tournament_name) + if not tournament: + await interaction.response.send_message( + "Failed to join the tournament - tournament does not exist", + ephemeral=True) + return + + result = await self.connector.add_participant(tournament_id=tournament.tournament_id, user_id=user.user_id) + if not result: + await interaction.response.send_message( + "Failed to join the tournament - Already Joined", + ephemeral=True) + return + await interaction.response.send_message( + f"Joined the tournament: {tournament.name}. Await the tournament start.", + ephemeral=True) + + async def _leave_tournament(self, interaction, tournament_name: str, username: str) -> None: + """ + Allows discord user with a valid QR account to leave the tournament (if it did not already start). + The username is Quadradius account username, since one Discord user may have many QR usernames. + + The command may fail if the tournamend_name is invalid, the username does not belong to the callee (or is banned/invalid), + if it was not registered, or the tournament has already began. + """ + user = await self.connector.get_user_by_username(username) + # TODO include bans here as well + if not user or user.discord_user_id != interaction.user.id: # Do not disclose whether account exists or not + await interaction.response.send_message( + f"Failed to authorize the user account - The username is invalid (incorrect or banned)", + ephemeral=True) + return + + tournament = await self.connector.get_tournament_by_name(tournament_name) + if not tournament: + await interaction.response.send_message( + "Failed to leave the tournament - tournament does not exist", + ephemeral=True) + return + + result = await self.connector.remove_participant(tournament_id=tournament.tournament_id, user_id=user.user_id) + if not result: + await interaction.response.send_message( + "Failed to leave the tournament - Already not present", + ephemeral=True) + return + await interaction.response.send_message( + f"Left the tournament: {tournament.name}.", + ephemeral=True) + + async def _start_tournament(self, interaction, tournament_name: str, active_until: str) -> None: + """ + Allows the creator of the tournament to start it, which automatically creates random duels between the participants. + Afterwards, no changes in participants are allowed. + """ + tournament = await self.connector.get_tournament_by_name(tournament_name) + if not tournament or tournament.created_by_dc_id != interaction.user.id: + await interaction.response.send_message( + "Failed to start the tournament - tournament with given ID is not valid for given user", + ephemeral=True) + return + + result = await self.connector.start_tournament(tournament_id=tournament.tournament_id) + if not result: + await interaction.response.send_message( + "Failed to start the tournament - Already started", + ephemeral=True) + return + + # TODO generate initial duels active until active_until + # await interaction.response.send_message( + # f"Left the tournament: {tournament.name}.", + # ephemeral=True) + + async def _start_tournament_round(self, interaction, tournament_name: str, active_until: str) -> None: + """ + After the tournament is started, initially the first round of duels is pre-generated. + This command allows the owner (or admins) to start the next round with the given deadline. + + This command **will close any pending rounds and start a new one** even if the previous round is still pending. + """ + pass \ No newline at end of file From 767b7924909021749e6cce463134abc2522d6be3 Mon Sep 17 00:00:00 2001 From: Fruktus Date: Thu, 4 Dec 2025 19:26:56 +0100 Subject: [PATCH 2/2] tmp (do not merge): cache --- server/src/QRServer/common/utils.py | 38 +++- server/src/QRServer/db/connector.py | 51 +++-- server/src/QRServer/discord/bot.py | 285 ++++++++++++++++++++++------ server/tests/test_discord_bot.py | 21 +- 4 files changed, 309 insertions(+), 86 deletions(-) diff --git a/server/src/QRServer/common/utils.py b/server/src/QRServer/common/utils.py index 4056bddd..67bcb525 100644 --- a/server/src/QRServer/common/utils.py +++ b/server/src/QRServer/common/utils.py @@ -1,7 +1,16 @@ import hashlib -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import secrets import string +from itertools import zip_longest + + +def tz_aoe() -> timezone: + """ + Returns: + 'Anywhere on Earth' timezone + """ + return timezone(timedelta(hours=-12), name='AOE') def is_guest(username: str, password: str) -> bool: @@ -17,9 +26,9 @@ def is_guest(username: str, password: str) -> bool: def make_month_dates(month: int, year: int) -> tuple[datetime, datetime]: """ - Returns: - a tuple consisting of the first day of the given month and year, and \ - the first day of the next month (incrementing year if needed) + Returns: + a tuple consisting of the first day of the given month and year, and \ + the first day of the next month (incrementing year if needed) """ return \ datetime(year, month, 1, tzinfo=timezone.utc), \ @@ -36,8 +45,8 @@ def generate_random_password(length: int) -> str: def make_month_dates_range(start_date: datetime, end_date: datetime) -> list[datetime]: """ - Returns: - a list of datetimes for each month (with day = 1) between the start_date and end_date (both inclusive) + Returns: + a list of datetimes for each month (with day = 1) between the start_date and end_date (both inclusive) """ if start_date > end_date: return [] @@ -76,3 +85,20 @@ def calculate_new_ratings(winner_rating: int, loser_rating: int, k_factor: int = new_loser_rating = loser_rating + k_factor * (0 - expected_score_2) return (round(new_winner_rating), round(new_loser_rating)) + + +def pairwise(seq: list[any]) -> list[tuple[any, any]]: + """ + Group elements from a sequence into consecutive pairs. + + Elements are taken two at a time in order: + [a, b, c, d] -> [(a, b), (c, d)] + + If the sequence contains an odd number of elements, the final pair + is padded with `None`: + [a, b, c] -> [(a, b), (c, None)] + + Returns: + A list of 2-tuples containing consecutive elements from `seq`. + """ + return list(zip_longest(seq[::2], seq[1::2])) diff --git a/server/src/QRServer/db/connector.py b/server/src/QRServer/db/connector.py index 379efb70..4b427b0e 100644 --- a/server/src/QRServer/db/connector.py +++ b/server/src/QRServer/db/connector.py @@ -562,31 +562,6 @@ async def list_tournaments(self) -> list[Tournament]: )) return result - async def get_tournament_by_name(self, tournament_name: str) -> Tournament | None: - c = await self.conn.cursor() - await c.execute( - "select id, name, created_by_dc_id, tournament_msg_dc_id," - " required_matches_per_duel, created_at," - " started_at, finished_at" - " from tournaments" - " where name = ?", - (tournament_name,) - ) - - row = await c.fetchone() - if row is None: - return None - return Tournament( - tournament_id=row[0], - name=row[1], - created_by_dc_id=row[2], - tournament_msg_dc_id=row[3], - required_matches_per_duel=row[4], - created_at=datetime.fromtimestamp(row[5], tz=timezone.utc), - started_at=datetime.fromtimestamp(row[6], tz=timezone.utc) if row[6] else None, - finished_at=datetime.fromtimestamp(row[7], tz=timezone.utc) if row[7] else None, - ) - async def list_tournament_users(self, tournament_id: str) -> list[DbUser] | None: c = await self.conn.cursor() await c.execute( @@ -683,7 +658,7 @@ async def remove_participant(self, tournament_id, user_id) -> bool: await self.conn.commit() return bool(c.rowcount) - async def add_duel(self, tournament_id: str, duel_idx: int, active_until: datetime, + async def add_duel(self, tournament_id: str, duel_idx: int, active_until: datetime | None, user1_id: str | None, user2_id: str | None) -> bool: """ Returns: @@ -702,9 +677,33 @@ async def add_duel(self, tournament_id: str, duel_idx: int, active_until: dateti ( tournament_id, duel_idx, + int(active_until.timestamp()) if active_until else None, + user1_id, + user2_id, + ) + ) + await self.conn.commit() + return bool(c.rowcount) + + async def update_duel(self, tournament_id: str, duel_idx: int, active_until: datetime, + user1_id: str | None, user2_id: str | None) -> bool: + """ + Returns: + bool: True if succesfully updated the duel + """ + c = await self.conn.cursor() + await c.execute( + "update tournament_duels" + " set active_until = ?," + " user1_id = ?," + " user2_id = ?" + " where tournament_id = ? and duel_idx = ?", + ( int(active_until.timestamp()), user1_id, user2_id, + tournament_id, + duel_idx, ) ) await self.conn.commit() diff --git a/server/src/QRServer/discord/bot.py b/server/src/QRServer/discord/bot.py index 3cb2ff19..d4309b82 100644 --- a/server/src/QRServer/discord/bot.py +++ b/server/src/QRServer/discord/bot.py @@ -1,8 +1,11 @@ +from datetime import datetime import logging +from random import shuffle import re from hashlib import md5 from QRServer.common import utils +from QRServer.common.full_binary_tree_indexer import FullBinaryTreeIndexer from QRServer.config import Config from QRServer.db.connector import DbConnector import discord @@ -60,33 +63,43 @@ async def reset_password(interaction, username: str): await self._reset_password(interaction, username) @self.tree.command(name="create_tournament", description="Create tournament") - @discord.app_commands.describe(tournament_name="Name of the tournament", dc_msg_id="Tournament registration message", required_matches_per_duel="The amount of matches that are needed to qualify for round") - async def create_tournament(interaction, tournament_name: str, dc_msg_id: str, required_matches_per_duel): - await self._create_tournament(interaction, tournament_name, dc_msg_id) + @discord.app_commands.describe( + tournament_name="Name of the tournament", + dc_msg_id="Tournament registration message", + required_matches_per_duel="The amount of matches that are needed to qualify for round") + async def create_tournament(interaction, tournament_name: str, dc_msg_id: str, required_matches_per_duel: int): + await self._create_tournament(interaction, tournament_name, dc_msg_id, required_matches_per_duel) create_tournament.default_permissions = discord.Permissions(permissions=0) - @self.tree.command(name="join_tournament", description="Join tournament with specified account") - @discord.app_commands.describe(tournament_name="Name of the tournament to join", username="The username to add as participant", required_matches_per_duel="Total valid matches that are needed to proceed in tournament") - async def join_tournament(interaction, tournament_name: str, username: str, required_matches_per_duel: int): - await self._join_tournament(interaction, tournament_name, username) - - @self.tree.command(name="leave_tournament", description="Leave tournament with specified account") - @discord.app_commands.describe(tournament_name="Name of the tournament to leave", username="The username to add as participant") - async def leave_tournament(interaction, tournament_name: str, username: str): - await self._leave_tournament(interaction, tournament_name, username) - @self.tree.command(name="start_tournament", description="Start tournament") - @discord.app_commands.describe(tournament_name="Name of the tournament") - async def start_tournament(interaction, tournament_name: str): - await self._start_tournament(interaction, tournament_name) + @discord.app_commands.describe( + tournament_id="Id of the tournament", + active_until="Date until which matches for first round are accepted, in the isoformat." + " Ex: 2021-07-27T16:02:08.070557") + async def start_tournament(interaction, tournament_name: str, active_until: str): + await self._start_tournament(interaction, tournament_name, active_until) start_tournament.default_permissions = discord.Permissions(permissions=0) - + @self.tree.command(name="start_tournament_round", description="Start tournament's next round") - @discord.app_commands.describe(tournament_name="Name of the tournament", active_until="Date until which matches are accepted in the isoformat. Ex: 2021-07-27T16:02:08.070557") + @discord.app_commands.describe( + tournament_name="Name of the tournament", + active_until="Date until which matches are accepted in the isoformat. Ex: 2021-07-27T16:02:08.070557") async def start_tournament_round(interaction, tournament_name: str, active_until: str): await self._start_tournament_round(interaction, tournament_name, active_until) start_tournament_round.default_permissions = discord.Permissions(permissions=0) + @self.tree.command(name="join_tournament", + description="Join currently active tournament with specified account") + @discord.app_commands.describe(username="The username to add as participant") + async def join_tournament(interaction, username: str): + await self._join_tournament(interaction, username) + + @self.tree.command(name="leave_tournament", + description="Leave currently active tournament with specified account") + @discord.app_commands.describe(username="The username to remove as participant") + async def leave_tournament(interaction, username: str): + await self._leave_tournament(interaction, username) + @self.client.event async def on_ready(): await self._on_ready() @@ -355,17 +368,19 @@ async def _send_user_notification(self, message: str) -> None: 'User notifications channel not found or not accepting messages: ' + self.user_notifications_channel_id) - async def _create_tournament(self, interaction, tournament_name: str, tournament_msg_dc_id: str, required_matches_per_duel: int) -> None: + async def _create_tournament(self, interaction, tournament_name: str, tournament_msg_dc_id: str, + required_matches_per_duel: int) -> None: """ Creates an empty tournament tied to specific owner and registration message. The message can be used for interactions registration, but is not at the moment. The command may fail if the tournament_name is already in use. """ - result = await self.connector.create_tournament(tournament_name, tournament_msg_dc_id, required_matches_per_duel) + result = await self.connector.create_tournament(tournament_name, interaction.user.id, + tournament_msg_dc_id, required_matches_per_duel) if not result: await interaction.response.send_message( - f"Tournament creation failed. Ensure that the tournament name is unique.", + "Tournament creation failed. Ensure that the tournament name is unique.", ephemeral=True) return else: @@ -374,102 +389,266 @@ async def _create_tournament(self, interaction, tournament_name: str, tournament ephemeral=True) # TODO should we send tournament_created notification? - - async def _join_tournament(self, interaction, tournament_name: str, username: str) -> None: + async def _join_tournament(self, interaction, username: str) -> None: """ Allows discord user with a valid QR account to join the tournament (if it did not already start). The username is Quadradius account username, since one Discord user may have many QR usernames. - The command may fail if the tournamend_name is invalid, the username does not belong to the callee (or is banned/invalid), + The command may fail if the tournamend_name is invalid, + the username does not belong to the callee (or is banned/invalid), if it was already registered, or the tournament has already began. """ user = await self.connector.get_user_by_username(username) # TODO include bans here as well if not user or user.discord_user_id != interaction.user.id: # Do not disclose whether account exists or not await interaction.response.send_message( - f"Failed to authorize the user account - The username is invalid (incorrect or banned)", + "Failed to authorize the user account - The username is invalid (incorrect or banned)", ephemeral=True) return - - tournament = await self.connector.get_tournament_by_name(tournament_name) - if not tournament: + + tournaments = await self.connector.list_tournaments() + active_tournament = None + for tournament in tournaments: + if not tournament.started_at: + active_tournament = active_tournament + + if not active_tournament: await interaction.response.send_message( - "Failed to join the tournament - tournament does not exist", + "No tournaments are currently accepting registration", ephemeral=True) return - - result = await self.connector.add_participant(tournament_id=tournament.tournament_id, user_id=user.user_id) + + result = await self.connector.add_participant(tournament_id=active_tournament.tournament_id, + user_id=user.user_id) if not result: await interaction.response.send_message( "Failed to join the tournament - Already Joined", ephemeral=True) return await interaction.response.send_message( - f"Joined the tournament: {tournament.name}. Await the tournament start.", + f"Joined the tournament: {active_tournament.name}. Await the tournament start.", ephemeral=True) - async def _leave_tournament(self, interaction, tournament_name: str, username: str) -> None: + async def _leave_tournament(self, interaction, username: str) -> None: """ Allows discord user with a valid QR account to leave the tournament (if it did not already start). The username is Quadradius account username, since one Discord user may have many QR usernames. - The command may fail if the tournamend_name is invalid, the username does not belong to the callee (or is banned/invalid), - if it was not registered, or the tournament has already began. + The command may fail if the tournamend_name is invalid, the username does not belong to the callee + (or is banned/invalid), if it was not registered, or the tournament has already began. """ user = await self.connector.get_user_by_username(username) # TODO include bans here as well if not user or user.discord_user_id != interaction.user.id: # Do not disclose whether account exists or not await interaction.response.send_message( - f"Failed to authorize the user account - The username is invalid (incorrect or banned)", + "Failed to authorize the user account - The username is invalid (incorrect or banned)", ephemeral=True) return - - tournament = await self.connector.get_tournament_by_name(tournament_name) - if not tournament: + + tournaments = await self.connector.list_tournaments() + active_tournament = None + for tournament in tournaments: + if not tournament.started_at: + active_tournament = active_tournament + + if not active_tournament: await interaction.response.send_message( - "Failed to leave the tournament - tournament does not exist", + "There are no tournaments which you could leave right now", ephemeral=True) return - - result = await self.connector.remove_participant(tournament_id=tournament.tournament_id, user_id=user.user_id) + + result = await self.connector.remove_participant(tournament_id=active_tournament.tournament_id, + user_id=user.user_id) if not result: await interaction.response.send_message( "Failed to leave the tournament - Already not present", ephemeral=True) return await interaction.response.send_message( - f"Left the tournament: {tournament.name}.", + f"Left the tournament: {active_tournament.name}.", ephemeral=True) async def _start_tournament(self, interaction, tournament_name: str, active_until: str) -> None: """ - Allows the creator of the tournament to start it, which automatically creates random duels between the participants. - Afterwards, no changes in participants are allowed. + Allows the creator of the tournament to start it. + The creator still needs to start round to create random duels between the participants. + After the tournament is started no changes in participants are allowed. """ + try: + active_until = self._parse_date(active_until) + except ValueError as e: + await interaction.response.send_message( + f"Failed to start the tournament - active_until date could not be parsed: {e}", + ephemeral=True) + return + tournament = await self.connector.get_tournament_by_name(tournament_name) if not tournament or tournament.created_by_dc_id != interaction.user.id: await interaction.response.send_message( "Failed to start the tournament - tournament with given ID is not valid for given user", ephemeral=True) return - + result = await self.connector.start_tournament(tournament_id=tournament.tournament_id) if not result: await interaction.response.send_message( "Failed to start the tournament - Already started", ephemeral=True) return - - # TODO generate initial duels active until active_until - # await interaction.response.send_message( - # f"Left the tournament: {tournament.name}.", - # ephemeral=True) + + # Generate the initial pairings + tournament_participants = await self.connector.list_participants(tournament.tournament_id) + shuffle(tournament_participants) + participant_pairs = utils.pairwise(tournament_participants) + + # Generate empty duels + indexer = FullBinaryTreeIndexer(len(tournament_participants)) + for i in range(indexer.get_node_count()): + await self.connector.add_duel(tournament.tournament_id, i, active_until=None, user1_id=None, user2_id=None) + + # Populate the duels + await self._start_round_from_participants(tournament.tournament_id, indexer.levels, participant_pairs, + active_until, tournament.required_matches_per_duel) + + await interaction.response.send_message( + "Tournament was successfully started", + ephemeral=True) async def _start_tournament_round(self, interaction, tournament_name: str, active_until: str) -> None: """ After the tournament is started, initially the first round of duels is pre-generated. This command allows the owner (or admins) to start the next round with the given deadline. - This command **will close any pending rounds and start a new one** even if the previous round is still pending. + This command **will close any pending rounds and start a new one**, + even if the previous round is still pending. + The unfinished duels will be ignored. + """ + try: + active_until = self._parse_date(active_until) + except ValueError as e: + await interaction.response.send_message( + f"Failed to start the round - active_until date could not be parsed: {e}", + ephemeral=True) + return + + tournament = await self.connector.get_tournament_by_name(tournament_name) + if not tournament or tournament.created_by_dc_id != interaction.user.id: + await interaction.response.send_message( + "Failed to start the round - tournament with given ID is not valid for given user", + ephemeral=True) + return + + tournament_participants_count = len(await self.connector.list_participants(tournament.tournament_id)) + + indexer = FullBinaryTreeIndexer(tournament_participants_count) + + first_nodes = [nodes[0] for level in range(indexer.levels) for nodes in indexer[level]] + + # Get all existing duels - they are filled from lower to higher idx + + # Figure out their level (keep in mind that some may have been empty!) - + # for example get participants, and compare first node idx from each level with the ones in db + # if active_until is set, that level was active + duels = await self.connector.list_duels(tournament.tournament_id) + current_level_nodes = [] + for idx, node_idx in enumerate(first_nodes): + if duels[node_idx].active_until is not None: + current_level_nodes = indexer.get_nodes_at_level(idx) + + # Get the winners from each duel (if someone had None as opponent, then they advance for free) + # TODO + + # Generate new duels (update the next level) + for idx, participants in enumerate(participant_pairs): + participant1 = participants[0] + participant2 = participants[1] + await self.connector.update_duel( + tournament.tournament_id, initial_duels[idx], active_until, participant1.user_id, participant2.user_id) + # TODO (if we want to) we can DM users to let them know that the round has began, who is their opponent + # (probably in-game username + discord id or smth) and how much time they have to play how many matches + # ex: + # user = await bot.fetch_user(user_id) # get user via user_id, and then their discord user id and use it here + # await user.send("hello") + + await interaction.response.send_message( + "Tournament was successfully started", + ephemeral=True) + + # Older comments 1: + # This command assumes that the previous round has ended, if it did not, it will fail. + # If it succeeded, then new pairings (duels) will be generated by it and broadcasted to players. + + # TODO: + # assuming the tree goes left -> right: + # get all winners from the order left -> right (or replace them with None if no winner) + # + # If the player from new bracket got non-None opponent, notify that they need to play with them to advance + # otherwise tell them that they got a free pass to next round + # (their opponent was None - no matches, no valid matches etc. tldr opponent did not qualify to advance) + + # Get tournament participants (list[DbUser]) + # tournament_participants = await self.connector.list_tournament_users(tournament.tournament_id) + # ... + # Shuffle the list + # ... + # Create the duels + # ... + # if someone has no pair, notify them that they got lucky and they got a free pass + # notify everyone else who is their opponent and how much time they have + + pass + + async def _start_round_from_participants(self, tournament_id: str, level: int, new_participant_pairs: list, + active_until: datetime, required_matches_per_duel: int) -> None: + """ + Populates given level of tournament duels tree using the provided participant pairs list. + Does not re-order the participants in any way, inserts them from lower tree index to higher + in the provided order. + """ + tournament_participants_count = len(await self.connector.list_participants(tournament_id)) + + indexer = FullBinaryTreeIndexer(tournament_participants_count) + + # Fill out the duels + duels_to_update = indexer.get_nodes_at_level(level - 1) + for idx, participants in enumerate(new_participant_pairs): + participant1 = participants[0] + participant2 = participants[1] + await self.connector.update_duel( + tournament_id, duels_to_update[idx], active_until, participant1.user_id, participant2.user_id) + + # Notify users about new duel + user1 = await self.connector.get_user(participant1.user_id) + user2 = await self.connector.get_user(participant2.user_id) + + try: + dc_user1 = await self.client.fetch_user(user1.discord_user_id) + await dc_user1.send( + "### Tournament Duel" + "- You have new duel!" + f"- Your opponent is: <@{user2.discord_user_id}>" + f"- Valid matches required: **{required_matches_per_duel}" + f"- Deadline: **{active_until}**" + ) + except Exception as e: + log.warning(f'Failed to send duel notification for user "{user1.username}". Error: {e}') + + try: + dc_user2 = await self.client.fetch_user(user2.discord_user_id) + await dc_user2.send( + "### Tournament Duel" + "- You have new duel!" + f"- Your opponent is: <@{user1.discord_user_id}>" + f"- Valid matches required: **{required_matches_per_duel}" + f"- Deadline: **{active_until}**" + ) + except Exception as e: + log.warning(f'Failed to send duel notification for "{user2.username}". Error: {e}') + + @staticmethod + def _parse_date(date_str: str) -> datetime: + """ + Raises: + ValueError if the provided string could not be parsed """ - pass \ No newline at end of file + return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=utils.tz_aoe()) diff --git a/server/tests/test_discord_bot.py b/server/tests/test_discord_bot.py index abf40d95..00aa0025 100644 --- a/server/tests/test_discord_bot.py +++ b/server/tests/test_discord_bot.py @@ -4,7 +4,8 @@ from QRServer.config import Config from QRServer.db.connector import DbConnector from QRServer.db.password import password_verify -from QRServer.discord.bot import DiscordBot +from QRServer.discord.bot import DiscordBot, DiscordException +import pytest class DiscordBotTest(unittest.IsolatedAsyncioTestCase): @@ -30,6 +31,24 @@ async def asyncSetUp(self) -> None: async def asyncTearDown(self): await self.conn.close() + async def test_raises_when_no_token(self): + config = Config() + config.set('discord.bot.guild_id', '123') + config.set('discord.bot.max_aliases', 1) + config.set('discord.bot.channel_user_notifications.id', '111') + + with pytest.raises(DiscordException): + DiscordBot(config, self.conn) + + async def test_raises_when_no_guild_id(self): + config = Config() + config.set('discord.bot.token', 'test_token') + config.set('discord.bot.max_aliases', 1) + config.set('discord.bot.channel_user_notifications.id', '111') + + with self.assertRaises(DiscordException): + await DiscordBot(config, self.conn) + async def test_register_new_user(self): with patch('QRServer.discord.bot.utils.generate_random_password', return_value='123asd4567'): self.bot._send_user_notification = AsyncMock()