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/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..4b427b0e 100644 --- a/server/src/QRServer/db/connector.py +++ b/server/src/QRServer/db/connector.py @@ -658,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: @@ -677,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 358ad970..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 @@ -59,6 +62,44 @@ 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: 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="start_tournament", description="Start tournament") + @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") + 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() @@ -326,3 +367,288 @@ 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, interaction.user.id, + tournament_msg_dc_id, required_matches_per_duel) + if not result: + await interaction.response.send_message( + "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, 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( + "Failed to authorize the user account - The username is invalid (incorrect or banned)", + ephemeral=True) + return + + 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( + "No tournaments are currently accepting registration", + ephemeral=True) + return + + 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: {active_tournament.name}. Await the tournament start.", + ephemeral=True) + + 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. + """ + 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( + "Failed to authorize the user account - The username is invalid (incorrect or banned)", + ephemeral=True) + return + + 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( + "There are no tournaments which you could leave right now", + ephemeral=True) + return + + 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: {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. + 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 + + # 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. + 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 + """ + 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()