From 666c2248495969c5045d60e2e89c5ee1257425f1 Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Thu, 18 May 2023 00:35:36 +0200 Subject: [PATCH 01/13] Fixed orthography and syntax --- vesta/data/lang.yml | 146 ++++++++++++++++++++++++-------------------- 1 file changed, 81 insertions(+), 65 deletions(-) diff --git a/vesta/data/lang.yml b/vesta/data/lang.yml index 4d10f44..9dc6cd3 100644 --- a/vesta/data/lang.yml +++ b/vesta/data/lang.yml @@ -2,34 +2,35 @@ fr: invalid_link: Le lien n'est pas valide invalid_image_link: Le lien de votre image n'est pas valide command_created: La commande a bien été créée ! - custom_form: Création d'une commande custom + custom_form: Création d'une commande personnalisée custom_form_keyword: Mot clé de la commande custom_form_title: Titre du message custom_form_content: Contenu du message custom_form_link: Lien custom_form_image: Image custom_form_color: Couleur de l'embed - command_already_exist: La commande existe déjà + command_already_exist: Cette commande existe déjà command_not_exist: Cette commande n'existe pas - command_deleted: La custom command à bien été supprimée + command_deleted: La commande personnalisée a bien été supprimée list_commands: Liste des commandes list_commands2: Partie 2 - too_much_commands: Il y a déjà trop de custom commands + too_much_commands: Il y a déjà trop de commandes personnalisées unexpected_error: Une erreur s'est produite - permissions_error: Vous n'avez pas les permissions pour faire cette commande - config_review: Le salon de review à bien été config - config_projects: Le salon de projets à bien été config - config_lang: La langue à bien été changée - nickname_banned: Vous avez été banni du système de rename - nickname_incorrect_title: ⚠️Pseudo incorrect ! - nickname_incorrect_description: "Ce nom n'est pas valide, merci d'entrer un nom validant la regex suivante : " - nickname_changed: Votre pseudo à été changé avec succès ! - bot_permissions_error: "Malheureusement, le bot n'a pas les permissions suffisantes pour faire cela, merci de demander à vos administrateurs de lui fournir les suivantes :" - nickname_ban: a bien été banni de la commande nickname - nickname_not_banned: n'est pas banni de la commande nickname - nickname_unban: a bien été débanni de la commande nickname - nickname_list_title: Membres bannis du nickname - presentations_not_available: Le systeme de présentation n'a pas été activé sur votre serveur + permissions_error: Vous n'avez pas les permissions pour exécuter cette commande + config_review: Le salon de revue à bien été modifié + config_projects: Le salon de projets à bien été modifié + config_coc_role: Le role à mentionner lors des parties de Clash of Code a bien été config + config_lang: La langue a bien été modifiée + nickname_banned: Vous avez été banni du système de renommage + nickname_incorrect_title: ⚠️ Pseudo incorrect ! + nickname_incorrect_description: "Ce pseudo n'est pas valide, merci d'entrer un nom respectant la regex suivante : " + nickname_changed: Votre pseudo a bien été modifié ! + bot_permissions_error: "Malheureusement, le bot n'a pas les assez de permissions pour réaliser cette action, merci de demander à vos administrateurs de lui accorder les permissions suivantes :" + nickname_ban: a bien été banni de la commande `/nickname` + nickname_not_banned: n'est pas banni de la commande `/nickname` + nickname_unban: a bien été débanni de la commande `/nickname` + nickname_list_title: Membres bannis du `/nickname` + presentations_not_available: Le système de présentation n'a pas été activé sur votre serveur presentations_banned: Vous avez été banni du système de présentation minimum_one_parameter: Merci de mettre au moins un des paramètres invalid_number: Merci d'entrez un nombre valide @@ -45,7 +46,7 @@ fr: presentation_form_description: Description du projet presentation_form_link: Lien du projet presentation_form_image: Lien de l'image - review_channel_error: Il semblerais y avoir un problème avec le salon de review + review_channel_error: Il semble qu'il y a un problème avec le salon de review presentation_sent: Votre projet sera étudié dans les plus brefs délais denied_form: Raison de refus denied_form_reason: Raison du refus @@ -58,85 +59,100 @@ fr: reason_other: Autre deny: Refuser denied_by: Refusé par - projects_channel_error: Il semblerais y avoir un problème avec le salon de projets + projects_channel_error: Il semble qu'il y a un problème avec le salon de projets accept: Accepter accepted_by: Accepté par - text_not_enough_code: "Nous préférons mettre en avant des projets matures et aboutis, avec une quantité de code suffisante. Votre projet n'est ainsi pas assez gros à notre goût. Certes, cela est très bien de faire des projets, c'est une bonne façon d'apprendre la programmation, mais il faudrait peut-être l'épaissir un peu, ou attendre un plus gros projet, avant de le partager comme ça ^^." - text_not_open_source: "Le but du salon est de promouvoir l'open source, pas de faire de la publicité. Ainsi, nous préférons refuser les projets qui ne donnent pas un lien vers un github, gitlab, ou tout autre site destiné au partage de code." - text_illegal: "Votre projet est non conformes aux règles de ce discord ou aux TOS de discord. Par conséquent, nous ne pouvons pas vous laisser poster ce projet dans le salon." + text_not_enough_code: > + Nous préférons mettre en avant des projets matures et aboutis, avec une quantité de code suffisante. + Votre projet n'est ainsi pas assez gros à notre goût. Certes, cela est très bien de faire des projets, + c'est une bonne façon d'apprendre la programmation, mais il faudrait peut-être l'épaissir un peu, + ou attendre un plus gros projet, avant de le partager comme ça ^^. + text_not_open_source: > + Le but du salon est de promouvoir l'open source, pas de faire de la publicité. + Ainsi, nous préférons refuser les projets qui ne donnent pas un lien vers un github, gitlab, + ou tout autre site destiné au partage de code. + text_illegal: > + Votre projet est non conformes aux règles de ce discord ou aux TOS de discord. + Par conséquent, nous ne pouvons pas vous laisser poster ce projet dans le salon. too_long_keyword: Le mot-clé entré est trop long - invalid_keyword: Le mot clé est invalide + invalid_keyword: Le mot-clé est invalide custom_invalid_args: Merci de fournir des arguments valides thread_project: Discussion sur le projet nick_too_long: Ce nom est trop long en: invalid_link: The link is not valid invalid_image_link: The link of the image is not valid - command_created: Command created successfully + command_created: Command successfully created custom_form: Creation of a custom command - custom_form_keyword: Keyword of the command - custom_form_title: Title of the message - custom_form_content: Content of the message + custom_form_keyword: Command keyword + custom_form_title: Message title + custom_form_content: Message content custom_form_link: Link custom_form_image: Image - custom_form_color: Color of the embed - command_already_exist: This command already exist + custom_form_color: Embed color + command_already_exist: This command already exists command_not_exist: This command does not exist command_deleted: The custom command have been deleted successfully list_commands: List of the custom commands list_commands2: Part 2 - too_much_commands: There is already too much commands - unexpected_error: An unexpected error have been raised - permissions_error: You don't have the permissions to do this command - config_review: Review channel have been configured - config_projects: Projects channel have been configured - config_lang: The lang have been updated + too_much_commands: There are already too much commands + unexpected_error: An unexpected error occurred + permissions_error: You don't have enough permissions to run this command + config_review: Review channel successfully configured + config_projects: Projects channel successfully configured + config_lang: The lang was updated nickname_banned: You have been banned from the nickname system - nickname_incorrect_title: ⚠️Incorrect Name ! - nickname_incorrect_description: "This name is not valid, please prompt something correct for the regex : " - nickname_changed: Your nickname have been updated - bot_permissions_error: "The bot don't have enough permissions to do this, please ask your administrator to add the following ones :" - nickname_ban: have been banned from the nickname command + nickname_incorrect_title: ⚠️ Incorrect nickname ! + nickname_incorrect_description: "This name is not valid, please input a nickname matching the regex: " + nickname_changed: Your nickname was updated + bot_permissions_error: "The bot isn't powerful enough to perform this action, please ask your administrator to grant the following permissions:" + nickname_ban: was banned from the nickname command nickname_not_banned: is not banned from the nickname command - nickname_unban: have been unbanned from the nickname command + nickname_unban: was unbanned from the nickname command nickname_list_title: Nickname Banned Members presentations_not_available: The presentations system is not active on this server - presentations_banned: You have been banned from the presentations system + presentations_banned: You were banned from the presentations system minimum_one_parameter: Please provide at least one argument - invalid_number: Please enter a valid number - no_result: No result found - user_result_title: Result from the research by user - presentations_ban: have been banned from the presentations system + invalid_number: Please input a valid number + no_result: No results found + user_result_title: Result from the search by user + presentations_ban: was banned from the presentations system presentations_not_banned: is not banned of the presentations system - presentations_unban: have been unbanned from the presentations system + presentations_unban: was unbanned from the presentations system presentations_list_title: Presentations System Banned Members list_page: Page presentation_form: Presentation - presentation_form_title: Name of the project - presentation_form_description: Description of the project - presentation_form_link: Link of the project - presentation_form_image: Link of the image - review_channel_error: It seems like there is a problem with the review channel + presentation_form_title: Project name + presentation_form_description: Project description + presentation_form_link: Project link + presentation_form_image: Image link + review_channel_error: It seems like there was a problem with the review channel presentation_sent: Your project will be reviewed soon - denied_form: Denied reason - denied_form_reason: Denied reason - denied_feedback_title: Your presentation has been refused - denied_feedback_content: "Reason :" + denied_form: Denial reason + denied_form_reason: Denial reason + denied_feedback_title: Your presentation was rejected + denied_feedback_content: "Reason:" denied_registered: Reason registered reason_not_enough_code: Not enough code - reason_not_open_source: Project not open-source + reason_not_open_source: Non open-source project reason_illegal: Illegal Project reason_other: Other deny: Deny denied_by: Denied by - projects_channel_error: It seems like there is a problem with the projects channel + projects_channel_error: It seems like there was a problem with the projects channel accept: Accept accepted_by: Accepted by - text_not_enough_code: "We prefer to share mature and complete projects, with a certain amount of code. Your project is not big enough to our liking. Yes, making projects is a very good way to learn about programming, but you could maybe elaborate a bit more, or wait util you do a bigger project before sharing it." - text_not_open_source: "The goal of the channel is to promote open source, not make advertisement. Therefore we must reject projects that do not contain link to any github, gitlab, or other code sharing website." - text_illegal: "Your project does not follow our rules or Discord TOS. Therefore, we cannot post your project in the channel" + text_not_enough_code: > + We prefer to share mature and complete projects, with a certain amount of code. + Your project was not considered as being big enough. Yes, making projects is a very good way to learn about programming, + but you could maybe elaborate a bit more, or wait util you make a bigger project before sharing it." + text_not_open_source: > + The goal of the channel is promoting open source, not making advertisement. + Therefore we reject projects that do not link to any github, gitlab, or other code sharing website. + text_illegal: > + Your project does not follow our policy or Discord TOS. Therefore, we cannot allow your project in the channel" too_long_keyword: This keyword is too long invalid_keyword: This keyword is not valid - custom_invalid_args: Please provide good arguments - thread_project: Talking about the project - nick_too_long: This nick is too long \ No newline at end of file + custom_invalid_args: Please provide valid arguments + thread_project: Chat about the project + nick_too_long: This nickname is too long \ No newline at end of file From b085cfd9b40b756d68130d5d05a3e7a82b601b13 Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Thu, 18 May 2023 01:28:27 +0200 Subject: [PATCH 02/13] Fixed the multiple errors in console when the bot tried to update a member and did not have permission to do so --- vesta/client.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/vesta/client.py b/vesta/client.py index 7ab391b..667ba11 100644 --- a/vesta/client.py +++ b/vesta/client.py @@ -56,13 +56,21 @@ async def command(interaction: discord.Interaction): if active: await self.tree.sync(guild=guild) - async def on_member_join(self, member): + async def on_member_join(self, member: discord.Member): logger.debug(f"Member joined : {member}!") + if not (await member.guild.fetch_member(self.user.id)).guild_permissions.manage_nicknames: + logger.debug(f"Bot doesn't have manage nicknames permission on {member.guild}") + return + if not re.match(regex_name, member.display_name): await member.edit(nick=f"{random.choice(names).capitalize()}{random.choice(adjectives).capitalize()}") - async def on_member_update(self, before, after): + async def on_member_update(self, before: discord.Member, after: discord.Member): logger.debug(f"Member update : {after}!") + if not (await after.guild.fetch_member(self.user.id)).guild_permissions.manage_nicknames: + logger.debug(f"Bot doesn't have manage nicknames permission on {after.guild}") + return + if not after.guild_permissions.manage_nicknames and not re.match(regex_name, after.display_name): await after.edit(nick=f"{random.choice(names).capitalize()}{random.choice(adjectives).capitalize()}") From a48f76f27f10acd58372208a75184599ad787de4 Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Thu, 18 May 2023 02:31:09 +0200 Subject: [PATCH 03/13] Fixed lang --- vesta/data/lang.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vesta/data/lang.yml b/vesta/data/lang.yml index 9dc6cd3..dd9bf15 100644 --- a/vesta/data/lang.yml +++ b/vesta/data/lang.yml @@ -19,7 +19,7 @@ fr: permissions_error: Vous n'avez pas les permissions pour exécuter cette commande config_review: Le salon de revue à bien été modifié config_projects: Le salon de projets à bien été modifié - config_coc_role: Le role à mentionner lors des parties de Clash of Code a bien été config + config_coc_role: Le role à mentionner lors des parties de Clash of Code a bien été configuré config_lang: La langue a bien été modifiée nickname_banned: Vous avez été banni du système de renommage nickname_incorrect_title: ⚠️ Pseudo incorrect ! @@ -100,6 +100,7 @@ en: permissions_error: You don't have enough permissions to run this command config_review: Review channel successfully configured config_projects: Projects channel successfully configured + config_coc_role: The role to mention when a Clash of Code game is started was updated config_lang: The lang was updated nickname_banned: You have been banned from the nickname system nickname_incorrect_title: ⚠️ Incorrect nickname ! From f79c7219a5725712ff030e5b3b27105dae093065 Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Thu, 18 May 2023 02:34:59 +0200 Subject: [PATCH 04/13] Started to add configuration for clash of code, with a command to ping the precised role and start a game, subject to a cooldown --- vesta/commands/__init__.py | 1 + vesta/commands/clash_of_code.py | 17 +++++++ vesta/commands/config.py | 85 ++++++++++++++++++++------------- vesta/tables/__init__.py | 3 +- vesta/tables/clash_of_code.py | 21 ++++++++ vesta/tables/guilds.py | 3 ++ 6 files changed, 95 insertions(+), 35 deletions(-) create mode 100644 vesta/commands/clash_of_code.py create mode 100644 vesta/tables/clash_of_code.py diff --git a/vesta/commands/__init__.py b/vesta/commands/__init__.py index d64d136..3974c4f 100644 --- a/vesta/commands/__init__.py +++ b/vesta/commands/__init__.py @@ -2,3 +2,4 @@ from . import presentation from . import custom from . import config +from . import clash_of_code \ No newline at end of file diff --git a/vesta/commands/clash_of_code.py b/vesta/commands/clash_of_code.py new file mode 100644 index 0000000..0bb7aae --- /dev/null +++ b/vesta/commands/clash_of_code.py @@ -0,0 +1,17 @@ +import logging + +import discord +from discord import app_commands + +from .. import vesta_client, session_maker + +logger = logging.getLogger(__name__) +session = session_maker() + +regex_clash_of_code_game = r"(https://|)(www.|)codingame.com/clashofcode/clash/.+" + +@vesta_client.tree.command(description="Invites users with the \"Clash of Code\" role to play") +@app_commands.describe(link="The link to the Clash of Code game") +async def coc(interaction: discord.Interaction, link: str): + + await interaction.response.send_message(f"Acknowledged: {link}!", ephemeral=True) \ No newline at end of file diff --git a/vesta/commands/config.py b/vesta/commands/config.py index 5816ac8..82736b3 100644 --- a/vesta/commands/config.py +++ b/vesta/commands/config.py @@ -1,3 +1,5 @@ +import typing + import discord from discord import app_commands import logging @@ -35,22 +37,9 @@ async def on_error(self, interaction: discord.Interaction, error): async def review(interaction: discord.Interaction, channel: discord.TextChannel): logger.debug(f"Command /config review {channel} used") - r = select(Guild).where(Guild.id == interaction.guild_id) - guild = session.scalar(r) - if not guild: - logger.debug(f"Add guild {interaction.guild_id} to the database") - guild = Guild(id=interaction.guild_id, name=interaction.guild.name) - session.add(guild) - - guild.review_channel = channel.id - - try: - session.commit() - except: - session.rollback() - - logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + def update(g): + g.review_channel = channel.id + await update_config_element(interaction, update) await interaction.response.send_message(lang.get("config_review", interaction.guild), ephemeral=True) @@ -60,25 +49,48 @@ async def review(interaction: discord.Interaction, channel: discord.TextChannel) async def projects(interaction: discord.Interaction, channel: discord.TextChannel): logger.debug(f"Command /config projects {channel} used") - r = select(Guild).where(Guild.id == interaction.guild_id) - guild = session.scalar(r) - if not guild: - logger.debug(f"Add guild {interaction.guild_id} to the database") - guild = Guild(id=interaction.guild_id, name=interaction.guild.name) - session.add(guild) + def update(g): + g.projects_channel = channel.id + await update_config_element(interaction, update) - guild.projects_channel = channel.id + await interaction.response.send_message(lang.get("config_projects", interaction.guild), ephemeral=True) - try: - session.commit() - except: - session.rollback() - logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) +@config_manager.command(name="coc-role", description="Set clash of code ping role") +@app_commands.rename(coc_role="role") +@app_commands.describe(coc_role="The role to ping when a game of Clash of Code is launched") +async def change_clash_of_code_role(interaction: discord.Interaction, coc_role: discord.Role): + logger.debug(f"Command /config coc-role {coc_role.name} used") - await interaction.response.send_message(lang.get("config_projects", interaction.guild), ephemeral=True) + def update(g): + g.coc_role = coc_role.id + await update_config_element(interaction, update) + + await interaction.response.send_message(lang.get("config_coc_role", interaction.guild), ephemeral=True) + +@config_manager.command(name="coc-channel", description="Set clash of code channel") +@app_commands.rename(coc_channel="channel") +@app_commands.describe(coc_channel="The channel to send clash of code messages") +async def change_clash_of_code_channel(interaction: discord.Interaction, coc_channel: discord.TextChannel): + logger.debug(f"Command /config coc-channel {coc_channel.name} used") + def update(g): + g.coc_channel = coc_channel.id + await update_config_element(interaction, update) + + await interaction.response.send_message(lang.get("config_coc_channel", interaction.guild), ephemeral=True) + +@config_manager.command(name="coc-cooldown", description="Set clash of code cooldown") +@app_commands.rename(coc_cooldown="cooldown") +@app_commands.describe(coc_cooldown="The cooldown between two clash of code games (in seconds)") +async def change_clash_of_code_cooldown(interaction: discord.Interaction, coc_cooldown: int): + logger.debug(f"Command /config coc-cooldown {coc_cooldown} used") + + def update(g): + g.coc_cooldown = coc_cooldown + await update_config_element(interaction, update) + + await interaction.response.send_message(lang.get("config_coc_cooldown", interaction.guild), ephemeral=True) @config_manager.command(name="lang", description="Set Guild Lang") @app_commands.rename(guild_lang='lang') @@ -87,6 +99,13 @@ async def projects(interaction: discord.Interaction, channel: discord.TextChanne async def change_lang(interaction: discord.Interaction, guild_lang: app_commands.Choice[str]): logger.debug(f"Command /config lang {guild_lang.value} used") + def update(g): + g.lang = guild_lang.value + await update_config_element(interaction, update) + + await interaction.response.send_message(lang.get("config_lang", interaction.guild), ephemeral=True) + +async def update_config_element(interaction: discord.Interaction, updater: typing.Callable[[Guild], None]): r = select(Guild).where(Guild.id == interaction.guild_id) guild = session.scalar(r) if not guild: @@ -94,7 +113,7 @@ async def change_lang(interaction: discord.Interaction, guild_lang: app_commands guild = Guild(id=interaction.guild_id, name=interaction.guild.name) session.add(guild) - guild.lang = guild_lang.value + updater(guild) try: session.commit() @@ -103,8 +122,6 @@ async def change_lang(interaction: discord.Interaction, guild_lang: app_commands logger.error(traceback.format_exc()) return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) - - await interaction.response.send_message(lang.get("config_lang", interaction.guild), ephemeral=True) - + pass vesta_client.tree.add_command(config_manager) diff --git a/vesta/tables/__init__.py b/vesta/tables/__init__.py index 32f221b..a368708 100644 --- a/vesta/tables/__init__.py +++ b/vesta/tables/__init__.py @@ -7,4 +7,5 @@ from .users import User from .custom_commands import CustomCommand from .guilds import Guild -from .bans import Ban \ No newline at end of file +from .bans import Ban +from .clash_of_code import ClashOfCode \ No newline at end of file diff --git a/vesta/tables/clash_of_code.py b/vesta/tables/clash_of_code.py new file mode 100644 index 0000000..17452c9 --- /dev/null +++ b/vesta/tables/clash_of_code.py @@ -0,0 +1,21 @@ +import time + +import sqlalchemy as db +from sqlalchemy.orm import relationship + +from . import Base + +class ClashOfCode(Base): + __tablename__ = "clash_of_code" + + guild_id = db.Column(db.BigInteger, db.ForeignKey("guild.id"), nullable=False) + guild = relationship("Guild") + + last_time_used = db.Column(db.DateTime, nullable=False) + + db.PrimaryKeyConstraint(guild_id) + + def is_usable(self) -> bool: + if not self.last_time_used: return True + + return time.mktime(self.last_time_used.timetuple()) + self.guild.coc_cooldown >= time.time() \ No newline at end of file diff --git a/vesta/tables/guilds.py b/vesta/tables/guilds.py index 5ef3973..903ef2d 100644 --- a/vesta/tables/guilds.py +++ b/vesta/tables/guilds.py @@ -10,6 +10,9 @@ class Guild(Base): name = db.Column(db.String(32), nullable=False) review_channel = db.Column(db.BigInteger) projects_channel = db.Column(db.BigInteger) + coc_channel = db.column(db.BigInteger) + coc_role = db.Column(db.BigInteger) + coc_cooldown = db.Column(db.Integer) lang = db.Column(db.String(2)) def __repr__(self): From 624a9425f70060b1bf41b3da5621798eaac283ac Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Thu, 18 May 2023 15:08:37 +0200 Subject: [PATCH 05/13] Started to work on the link with the codingame api and started to implement the command. --- vesta/commands/clash_of_code.py | 15 ++- vesta/commands/config.py | 12 --- vesta/exceptions/__init__.py | 0 vesta/exceptions/web_exceptions.py | 0 vesta/services/__init__.py | 0 vesta/services/clash_of_code_entities.py | 117 +++++++++++++++++++++++ vesta/services/clash_of_code_helper.py | 42 ++++++++ vesta/tables/clash_of_code.py | 15 +-- vesta/tables/guilds.py | 1 - 9 files changed, 172 insertions(+), 30 deletions(-) create mode 100644 vesta/exceptions/__init__.py create mode 100644 vesta/exceptions/web_exceptions.py create mode 100644 vesta/services/__init__.py create mode 100644 vesta/services/clash_of_code_entities.py create mode 100644 vesta/services/clash_of_code_helper.py diff --git a/vesta/commands/clash_of_code.py b/vesta/commands/clash_of_code.py index 0bb7aae..5d74b0e 100644 --- a/vesta/commands/clash_of_code.py +++ b/vesta/commands/clash_of_code.py @@ -1,17 +1,26 @@ import logging +import re import discord from discord import app_commands -from .. import vesta_client, session_maker +from .. import vesta_client, session_maker, lang logger = logging.getLogger(__name__) session = session_maker() -regex_clash_of_code_game = r"(https://|)(www.|)codingame.com/clashofcode/clash/.+" +regex_clash_of_code_game = r"^(https://|)(www.|)codingame.com/clashofcode/clash/[^/]+(/|)$" -@vesta_client.tree.command(description="Invites users with the \"Clash of Code\" role to play") +@vesta_client.tree.command(name="clash-of-code", description="Invites users with the \"Clash of Code\" role to play") @app_commands.describe(link="The link to the Clash of Code game") async def coc(interaction: discord.Interaction, link: str): + if not re.match(regex_clash_of_code_game, link): + await interaction.response.send_message( + lang.get("invalid_clash_of_code_link", interaction.guild), + ephemeral=True + ) + + + await interaction.response.send_message(f"Acknowledged: {link}!", ephemeral=True) \ No newline at end of file diff --git a/vesta/commands/config.py b/vesta/commands/config.py index 82736b3..7584722 100644 --- a/vesta/commands/config.py +++ b/vesta/commands/config.py @@ -80,18 +80,6 @@ def update(g): await interaction.response.send_message(lang.get("config_coc_channel", interaction.guild), ephemeral=True) -@config_manager.command(name="coc-cooldown", description="Set clash of code cooldown") -@app_commands.rename(coc_cooldown="cooldown") -@app_commands.describe(coc_cooldown="The cooldown between two clash of code games (in seconds)") -async def change_clash_of_code_cooldown(interaction: discord.Interaction, coc_cooldown: int): - logger.debug(f"Command /config coc-cooldown {coc_cooldown} used") - - def update(g): - g.coc_cooldown = coc_cooldown - await update_config_element(interaction, update) - - await interaction.response.send_message(lang.get("config_coc_cooldown", interaction.guild), ephemeral=True) - @config_manager.command(name="lang", description="Set Guild Lang") @app_commands.rename(guild_lang='lang') @app_commands.describe(guild_lang="The lang for the bot") diff --git a/vesta/exceptions/__init__.py b/vesta/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vesta/exceptions/web_exceptions.py b/vesta/exceptions/web_exceptions.py new file mode 100644 index 0000000..e69de29 diff --git a/vesta/services/__init__.py b/vesta/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vesta/services/clash_of_code_entities.py b/vesta/services/clash_of_code_entities.py new file mode 100644 index 0000000..e6e58d2 --- /dev/null +++ b/vesta/services/clash_of_code_entities.py @@ -0,0 +1,117 @@ +from typing import List, Optional +from dataclasses import dataclass +from enum import Enum + + +class GameMode(Enum): + """ + Represents a Clash of Code game mode + """ + + FASTEST = 0 + REVERSE = 1 + SHORTEST = 2 + + +class Role(Enum): + """ + Represents a Clash of Code player role + """ + + OWNER = 0 + STANDARD = 1 + + +@dataclass +class ClashOfCodePlayer: + """ + Represents a player in a Clash of Code game + """ + + name: str + role: Role + + def __init__(self, **kwargs): + """ + Initializes the object with the given data + + :param kwargs: The data to initialize the object with + :see hydrate + """ + self.hydrate(**kwargs) + + def hydrate(self, *, + condingamerNickname: str, + status: str) -> "ClashOfCodePlayer": + """ + Hydrates the object with the given data + + :param condingamerNickname: The player's nickname + :param status: The player's role + :return: The hydrated object for chaining convenience + """ + self.name = condingamerNickname + self.role = Role[status.upper()] or Role.STANDARD + + return self + + +@dataclass +class ClashOfCodeGame: + """ + Represents a Clash of Code game + """ + + link: str + started: bool + finished: bool + players: List[ClashOfCodePlayer] + programming_language: List[str] + modes: List[GameMode] + mode: Optional[str] + + def __init__(self, **kwargs): + """ + Initializes the object with the given data + :param kwargs: The data to initialize the object with + :see hydrate + """ + + self.hydrate(**kwargs) + + def hydrate(self, *, + publicHandle: str, + started: bool, + finished: bool, + players: List[dict], + programmingLanguages: List[str], + modes: List[str], + mode: Optional[str] = None) -> "ClashOfCodeGame": + """ + Hydrates the object with the given data + + :param publicHandle: The game id + :param started: Whether the game has started + :param finished: Whether the game has finished + :param players: The players in the game + :param programmingLanguages: The programming languages used in the game + :param modes: The possible game modes + :param mode: The current game mode. Only defined if started is True + :return: The hydrated object for chaining convenience + """ + + self.link = f"https://www.codingame.com/clashofcode/clash/{publicHandle}" + self.started = started + self.finished = finished + self.players = [ + ClashOfCodePlayer(**player) + for player in players + ] + self.programming_language = programmingLanguages + self.modes = [ + GameMode[mode.upper()] + for mode in modes + ] + self.mode = mode + + return self \ No newline at end of file diff --git a/vesta/services/clash_of_code_helper.py b/vesta/services/clash_of_code_helper.py new file mode 100644 index 0000000..90baf18 --- /dev/null +++ b/vesta/services/clash_of_code_helper.py @@ -0,0 +1,42 @@ +import requests +from vesta.services.clash_of_code_entities import * + + +class ClashOfCodeHelper: + BASE_ENDPOINT = "https://www.codingame.com/services/ClashOfCode/" + + def __init__(self): + pass + + def fetch(self, game_id) -> Optional[ClashOfCodeGame]: + """ + Retrieves a game object from the API + + :param game_id: The game id to retrieve + :return: The game object if it exists, None otherwise + """ + r = requests.post(f"{self.BASE_ENDPOINT}findClashByHandle", json=[ + game_id + ]).json() + + if r["id"] == 502 or "Clash not found" in r["message"]: + return None + + return ClashOfCodeGame(**r) + + def update(self, game: ClashOfCodeGame) -> ClashOfCodeGame: + """ + Updates the game object with the latest information from the API + + :param game: The game object to update + :return: The updated game object for chaining convenience + """ + r = requests.post(f"{self.BASE_ENDPOINT}findClashByHandle", json=[ + game.link.split("/")[-1] + ]).json() + + if r["id"] == 502 or "Clash not found" in r["message"]: + raise NameError("Clash does not exist anymore") + + game.hydrate(**r) + return game \ No newline at end of file diff --git a/vesta/tables/clash_of_code.py b/vesta/tables/clash_of_code.py index 17452c9..ead9dcf 100644 --- a/vesta/tables/clash_of_code.py +++ b/vesta/tables/clash_of_code.py @@ -1,21 +1,8 @@ -import time - import sqlalchemy as db -from sqlalchemy.orm import relationship from . import Base class ClashOfCode(Base): __tablename__ = "clash_of_code" - guild_id = db.Column(db.BigInteger, db.ForeignKey("guild.id"), nullable=False) - guild = relationship("Guild") - - last_time_used = db.Column(db.DateTime, nullable=False) - - db.PrimaryKeyConstraint(guild_id) - - def is_usable(self) -> bool: - if not self.last_time_used: return True - - return time.mktime(self.last_time_used.timetuple()) + self.guild.coc_cooldown >= time.time() \ No newline at end of file + guild_id = db.Column(db.BigInteger, nullable=False) \ No newline at end of file diff --git a/vesta/tables/guilds.py b/vesta/tables/guilds.py index 903ef2d..bff799d 100644 --- a/vesta/tables/guilds.py +++ b/vesta/tables/guilds.py @@ -12,7 +12,6 @@ class Guild(Base): projects_channel = db.Column(db.BigInteger) coc_channel = db.column(db.BigInteger) coc_role = db.Column(db.BigInteger) - coc_cooldown = db.Column(db.Integer) lang = db.Column(db.String(2)) def __repr__(self): From e141ba01c54612387c2e3d7ba1a3d11081300e6b Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Thu, 18 May 2023 17:19:08 +0200 Subject: [PATCH 06/13] Continued to work on the clash of code system, added errors and ping for command, edited the way translations work to allow nesting, and worked more on the structure of the database --- requirements.txt | 1 + vesta/commands/clash_of_code.py | 98 +++++++++++++++++++++++- vesta/data/lang.yml | 54 ++++++++++++- vesta/lang.py | 15 ++++ vesta/services/clash_of_code_entities.py | 64 +++++++++++++--- vesta/services/clash_of_code_helper.py | 31 +++++--- vesta/tables/__init__.py | 3 +- vesta/tables/clash_of_code.py | 83 +++++++++++++++++++- vesta/tables/guilds.py | 1 - 9 files changed, 318 insertions(+), 32 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9fabead..5273904 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ psycopg2-binary==2.9.3 python-dotenv==0.20.0 PyYAML==6.0 SQLAlchemy==1.4.37 +requests==2.30.0 \ No newline at end of file diff --git a/vesta/commands/clash_of_code.py b/vesta/commands/clash_of_code.py index 5d74b0e..8701f9f 100644 --- a/vesta/commands/clash_of_code.py +++ b/vesta/commands/clash_of_code.py @@ -1,26 +1,118 @@ import logging import re +from typing import Tuple, Optional import discord from discord import app_commands +from sqlalchemy import select from .. import vesta_client, session_maker, lang +from ..services.clash_of_code_helper import ClashOfCodeHelper, game_id_from_link +from ..tables import Guild +from ..tables.clash_of_code import ClashOfCodeGuildGame logger = logging.getLogger(__name__) session = session_maker() regex_clash_of_code_game = r"^(https://|)(www.|)codingame.com/clashofcode/clash/[^/]+(/|)$" + @vesta_client.tree.command(name="clash-of-code", description="Invites users with the \"Clash of Code\" role to play") @app_commands.describe(link="The link to the Clash of Code game") async def coc(interaction: discord.Interaction, link: str): if not re.match(regex_clash_of_code_game, link): await interaction.response.send_message( - lang.get("invalid_clash_of_code_link", interaction.guild), + lang.get("clash_of_code.invalid_link", interaction.guild), + ephemeral=True + ) + return + + helper = ClashOfCodeHelper() + game_id = game_id_from_link(link) + + game = helper.fetch(game_id) + if not game: + await interaction.response.send_message( + lang.get("clash_of_code.invalid_link", interaction.guild), + ephemeral=True + ) + return + + if game.started or game.finished: + await interaction.response.send_message( + lang.get("clash_of_code.game_already_started", interaction.guild), + ephemeral=True + ) + return + + r = select(ClashOfCodeGuildGame).where(Guild.id == interaction.guild_id) + guild_game: ClashOfCodeGuildGame = session.scalar(r) + + if guild_game and not guild_game.can_start_new(): + await interaction.response.send_message( + lang.get("clash_of_code.already_in_progress", interaction.guild), + ephemeral=True + ) + return + + if not guild_game: + guild_game = ClashOfCodeGuildGame(guild_id=interaction.guild_id) + session.add(guild_game) + + # (ok, guild, role, channel) = await run_checks_for(interaction) + # if not ok: return + + r = select(Guild).where(Guild.id == interaction.guild_id) + guild: Guild = session.scalar(r) + + if guild.coc_role is None: + await interaction.response.send_message( + lang.get("clash_of_code.role_not_set", interaction.guild), + ephemeral=True + ) + return + + role = interaction.guild.get_role(guild.coc_role) + if role is None: + await interaction.response.send_message( + lang.get("clash_of_code.role_not_found", interaction.guild), + ephemeral=True + ) + return + + if guild.coc_channel is None: + await interaction.response.send_message( + lang.get("clash_of_code.channel_not_set", interaction.guild), ephemeral=True ) + return + + channel = interaction.guild.get_channel(guild.coc_channel) + if channel is None: + await interaction.response.send_message( + lang.get("clash_of_code.channel_not_found", interaction.guild), + ephemeral=True + ) + return + + view = discord.ui.View() + view.add_item(discord.ui.Button( + label=lang.get("clash_of_code.game_join"), + url=game.link, + emoji="🎮" + )) + + await channel.send( + content=f"{role.mention} {lang.get('clash_of_code.game_invite')}", + embed=game.embed(), + view=view + ) - + guild_game.game_id = game_id + session.commit() + await interaction.response.send_message( + lang.get("clash_of_code.successfully_invited", interaction.guild), + ephemeral=True + ) - await interaction.response.send_message(f"Acknowledged: {link}!", ephemeral=True) \ No newline at end of file diff --git a/vesta/data/lang.yml b/vesta/data/lang.yml index dd9bf15..f02cba7 100644 --- a/vesta/data/lang.yml +++ b/vesta/data/lang.yml @@ -20,6 +20,7 @@ fr: config_review: Le salon de revue à bien été modifié config_projects: Le salon de projets à bien été modifié config_coc_role: Le role à mentionner lors des parties de Clash of Code a bien été configuré + config_coc_channel: Le salon où envoyer les invitations pour les parties de Clash of Code a bien été configuré config_lang: La langue a bien été modifiée nickname_banned: Vous avez été banni du système de renommage nickname_incorrect_title: ⚠️ Pseudo incorrect ! @@ -79,6 +80,33 @@ fr: custom_invalid_args: Merci de fournir des arguments valides thread_project: Discussion sur le projet nick_too_long: Ce nom est trop long + clash_of_code: + invalid_link: Ce lien ne semble pas lié à une partie de Clash of Code valide ! + game_already_started: La partie de Clash of Code que vous avez fourni est déjà en cours / terminée ! + already_in_progress: Une partie de Clash of Code est déjà en cours sur ce serveur ! + successfully_invited: Vous avez bien invité les autres membres du serveur à rejoindre la partie ! + all_languages: Tous les langages + role_not_set: > + Le rôle à mentionner lors des parties de Clash of Code n'a + pas été configuré sur ce serveur ! Prévenez un administrateur ! + role_not_found: > + Le configuré rôle pour être mentionné lors des parties de Clash of Code + n'a pas été trouvé ! Prévenez un administrateur ! + channel_not_set: > + Le salon où envoyer les invitations pour les parties de Clash of Code n'a + pas été configuré sur ce serveur ! Prévenez un administrateur ! + channel_not_found: > + Le configuré salon où envoyer les invitations pour les parties de Clash of Code + n'a pas été trouvé ! Prévenez un administrateur ! + started: "Commencé :" + finished: "Terminé :" + game_title: Nouvelle partie de Clash of Code ! + game_invite: Une nouvelle partie de Clash of Code a été lancée ! + game_join: Rejoindre la partie + game_modes: Modes de jeu possibles + game_mode: Mode de jeu + game_players: Joueurs + game_languages: Langages autorisés en: invalid_link: The link is not valid invalid_image_link: The link of the image is not valid @@ -101,6 +129,7 @@ en: config_review: Review channel successfully configured config_projects: Projects channel successfully configured config_coc_role: The role to mention when a Clash of Code game is started was updated + config_coc_channel: The channel where to send the invitations for the Clash of Code games was updated config_lang: The lang was updated nickname_banned: You have been banned from the nickname system nickname_incorrect_title: ⚠️ Incorrect nickname ! @@ -156,4 +185,27 @@ en: invalid_keyword: This keyword is not valid custom_invalid_args: Please provide valid arguments thread_project: Chat about the project - nick_too_long: This nickname is too long \ No newline at end of file + nick_too_long: This nickname is too long + clash_of_code: + invalid_link: This link does not seem to be a valid Clash of Code game ! + game_already_started: The Clash of Code game you provided is already started / finished ! + already_in_progress: A Clash of Code game is already in progress on this server ! + successfully_invited: You successfully invited the other members of the server to join the game ! + all_languages: All languages + role_not_set: > + The role to mention when a Clash of Code game is started was not configured on this server ! Please warn an administrator ! + role_not_found: > + The configured role to mention when a Clash of Code game is started was not found ! Please warn an administrator ! + channel_not_set: > + The channel where to send the invitations for the Clash of Code games was not configured on this server ! Please warn an administrator ! + channel_not_found: > + The configured channel where to send the invitations for the Clash of Code games was not found ! Please warn an administrator ! + started: "Started:" + finished: "Finished:" + game_title: New Clash of Code game ! + game_invite: A new Clash of Code game was started ! + game_join: Join the game + game_modes: Possible game modes + game_mode: Game mode + game_players: Players + game_languages: Allowed languages diff --git a/vesta/lang.py b/vesta/lang.py index 582553a..86a7ce0 100644 --- a/vesta/lang.py +++ b/vesta/lang.py @@ -1,15 +1,30 @@ import logging +from typing import MutableMapping from .tables import select, Guild logger = logging.getLogger(__name__) class Lang: + data: dict def __init__(self, data, session): self.data = data self.session = session + def flatten_dict(d: MutableMapping, parent_key: str = '', sep: str = '.') -> MutableMapping: + items = [] + for k, v in d.items(): + new_key = parent_key + sep + k if parent_key else k + if isinstance(v, MutableMapping): + items.extend(flatten_dict(v, new_key, sep=sep).items()) + else: + items.append((new_key, v)) + return dict(items) + + for key in self.data.keys(): + self.data[key] = flatten_dict(self.data[key]) + def get(self, item, guild): lang = "en" diff --git a/vesta/services/clash_of_code_entities.py b/vesta/services/clash_of_code_entities.py index e6e58d2..2be2271 100644 --- a/vesta/services/clash_of_code_entities.py +++ b/vesta/services/clash_of_code_entities.py @@ -2,6 +2,10 @@ from dataclasses import dataclass from enum import Enum +from discord import Embed + +from vesta import lang + class GameMode(Enum): """ @@ -41,16 +45,17 @@ def __init__(self, **kwargs): self.hydrate(**kwargs) def hydrate(self, *, - condingamerNickname: str, - status: str) -> "ClashOfCodePlayer": + codingamerNickname: str, + status: str, + **ignored) -> "ClashOfCodePlayer": """ Hydrates the object with the given data - :param condingamerNickname: The player's nickname + :param codingamerNickname: The player's nickname :param status: The player's role :return: The hydrated object for chaining convenience """ - self.name = condingamerNickname + self.name = codingamerNickname self.role = Role[status.upper()] or Role.STANDARD return self @@ -80,13 +85,14 @@ def __init__(self, **kwargs): self.hydrate(**kwargs) def hydrate(self, *, - publicHandle: str, - started: bool, - finished: bool, - players: List[dict], - programmingLanguages: List[str], - modes: List[str], - mode: Optional[str] = None) -> "ClashOfCodeGame": + publicHandle: str, + started: bool, + finished: bool, + players: List[dict], + programmingLanguages: List[str], + modes: List[str], + mode: Optional[str] = None, + **ignored) -> "ClashOfCodeGame": """ Hydrates the object with the given data @@ -114,4 +120,38 @@ def hydrate(self, *, ] self.mode = mode - return self \ No newline at end of file + return self + + def embed(self): + emojis = { + 'True': '🟢', + 'False': '🔴' + } + + description = f""" + **{lang.get("clash_of_code_game.started")}** {emojis[str(self.started)]} + **{lang.get("clash_of_code_game.finished")}** {emojis[str(self.finished)]} + """ + + embed = Embed( + title=lang.get("clash_of_code.game_title"), + url=self.link, + description=description + ) + + if not self.mode: + embed.add_field(name=lang.get("clash_of_code.game_modes"), + value=f"`{'`, `'.join([mode.name.lower() for mode in self.modes])}`") + else: + embed.add_field(name=lang.get("clash_of_code.game_mode"), + value=f"`{self.mode.lower()}`") + + embed.add_field(name=lang.get("clash_of_code.game_players"), + value=f"`{'`, `'.join([player.name for player in self.players])}`") + + languages = f"`{'`, `'.join(self.programming_language)}`" \ + if len(self.programming_language) >= 1 \ + else lang.get("clash_of_code.all_languages") + + embed.add_field(name=lang.get("clash_of_code.game_languages"), + value=languages) diff --git a/vesta/services/clash_of_code_helper.py b/vesta/services/clash_of_code_helper.py index 90baf18..47448da 100644 --- a/vesta/services/clash_of_code_helper.py +++ b/vesta/services/clash_of_code_helper.py @@ -2,11 +2,18 @@ from vesta.services.clash_of_code_entities import * +def game_id_from_link(link: str) -> str: + return link.split("/")[-1] + + class ClashOfCodeHelper: + __instance = None BASE_ENDPOINT = "https://www.codingame.com/services/ClashOfCode/" - def __init__(self): - pass + def __new__(cls, *args, **kwargs): + if ClashOfCodeHelper.__instance is None: + ClashOfCodeHelper.__instance = super(ClashOfCodeHelper, cls).__new__(cls, *args, **kwargs) + return ClashOfCodeHelper.__instance def fetch(self, game_id) -> Optional[ClashOfCodeGame]: """ @@ -17,12 +24,12 @@ def fetch(self, game_id) -> Optional[ClashOfCodeGame]: """ r = requests.post(f"{self.BASE_ENDPOINT}findClashByHandle", json=[ game_id - ]).json() + ]) - if r["id"] == 502 or "Clash not found" in r["message"]: + if r.status_code != 200: return None - return ClashOfCodeGame(**r) + return ClashOfCodeGame(**r.json()) def update(self, game: ClashOfCodeGame) -> ClashOfCodeGame: """ @@ -30,13 +37,15 @@ def update(self, game: ClashOfCodeGame) -> ClashOfCodeGame: :param game: The game object to update :return: The updated game object for chaining convenience + :raises NameError: If there was an error updating the game object """ r = requests.post(f"{self.BASE_ENDPOINT}findClashByHandle", json=[ - game.link.split("/")[-1] - ]).json() + game_id_from_link(game.link) + ]) + + if r.status_code != 200: + raise NameError("There was an error updating the game object") - if r["id"] == 502 or "Clash not found" in r["message"]: - raise NameError("Clash does not exist anymore") + game.hydrate(**r.json()) + return game - game.hydrate(**r) - return game \ No newline at end of file diff --git a/vesta/tables/__init__.py b/vesta/tables/__init__.py index a368708..d16f3e7 100644 --- a/vesta/tables/__init__.py +++ b/vesta/tables/__init__.py @@ -8,4 +8,5 @@ from .custom_commands import CustomCommand from .guilds import Guild from .bans import Ban -from .clash_of_code import ClashOfCode \ No newline at end of file +from .clash_of_code import ClashOfCodeRanking,\ + ClashOfCodeGuildGame \ No newline at end of file diff --git a/vesta/tables/clash_of_code.py b/vesta/tables/clash_of_code.py index ead9dcf..f6d87d1 100644 --- a/vesta/tables/clash_of_code.py +++ b/vesta/tables/clash_of_code.py @@ -1,8 +1,85 @@ +from typing import Optional + import sqlalchemy as db +from sqlalchemy.orm import relationship from . import Base +from .. import session_maker +from ..services.clash_of_code_entities import ClashOfCodeGame +from ..services.clash_of_code_helper import ClashOfCodeHelper + +session = session_maker() + +class ClashOfCodeGuildGame(Base): + __tablename__ = "clash_of_code_guild_game" + + guild_id = db.Column(db.BigInteger, nullable=False) + last_clash_id = db.Column(db.BigInteger) + + db.PrimaryKeyConstraint(guild_id) + + def fetch(self) -> Optional[ClashOfCodeGame]: + """ + Fetches the latest clash of code game + + :return: The latest clash of code game + """ + + helper = ClashOfCodeHelper() + + return helper.fetch(self.last_clash_id) + + def can_start_new(self) -> bool: + """ + Checks whether a new clash of code game can be started + + :return: Whether a new clash of code game can be started + """ + if self.last_clash_id is None: + return True + + entity = self.fetch() + return entity is None or entity.finished + + def __repr__(self): + return f"Clash of Code Guild Games (guild_id={self.guild_id}, last_clash_id={self.last_clash_id})" + + def forget(self) -> None: + """ + Forgets the last clash of code game + :return: None + """ + self.last_clash_id = None + session.commit() + + def start_new(self, clash_id: int) -> None: + """ + Starts a new clash of code game + + :param clash_id: The clash of code game id + :return: None + """ + self.last_clash_id = clash_id + session.commit() + +class ClashOfCodeRanking(Base): + __tablename__ = "clash_of_code_ranking" + + guild_id = db.Column(db.BigInteger, nullable=False) + times_won = db.Column(db.Integer, nullable=False) + + user_id = db.Column(db.BigInteger, db.ForeignKey("user.id"), nullable=False) + user = relationship("User") + + db.PrimaryKeyConstraint(guild_id, user_id) -class ClashOfCode(Base): - __tablename__ = "clash_of_code" + def add_win(self) -> None: + """ + Adds a win to the user's ranking and commits the change to the database + :return: None + """ + self.times_won += 1 + session.commit() - guild_id = db.Column(db.BigInteger, nullable=False) \ No newline at end of file + def __repr__(self): + return f"Clash of Code Ranking (guild_id={self.guild_id}, user_id={self.user_id}, times_won={self.times_won})" \ No newline at end of file diff --git a/vesta/tables/guilds.py b/vesta/tables/guilds.py index bff799d..6ed47f2 100644 --- a/vesta/tables/guilds.py +++ b/vesta/tables/guilds.py @@ -2,7 +2,6 @@ from . import Base - class Guild(Base): __tablename__ = "guild" From 54476dc86d3a5b2a1f8fc134f3fb21cae945d3ae Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Thu, 18 May 2023 17:19:08 +0200 Subject: [PATCH 07/13] Revert changed made to lang.py --- vesta/lang.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/vesta/lang.py b/vesta/lang.py index 86a7ce0..582553a 100644 --- a/vesta/lang.py +++ b/vesta/lang.py @@ -1,30 +1,15 @@ import logging -from typing import MutableMapping from .tables import select, Guild logger = logging.getLogger(__name__) class Lang: - data: dict def __init__(self, data, session): self.data = data self.session = session - def flatten_dict(d: MutableMapping, parent_key: str = '', sep: str = '.') -> MutableMapping: - items = [] - for k, v in d.items(): - new_key = parent_key + sep + k if parent_key else k - if isinstance(v, MutableMapping): - items.extend(flatten_dict(v, new_key, sep=sep).items()) - else: - items.append((new_key, v)) - return dict(items) - - for key in self.data.keys(): - self.data[key] = flatten_dict(self.data[key]) - def get(self, item, guild): lang = "en" From ff6e295157ddeaadcb9191bb8b0a9d830704e5d8 Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Thu, 18 May 2023 20:54:13 +0200 Subject: [PATCH 08/13] Continued to fix bugs and implement the clash of code stuff --- vesta/__init__.py | 2 +- vesta/commands/clash_of_code.py | 49 +++++++----- vesta/commands/config.py | 22 +++--- vesta/commands/custom.py | 28 +++---- vesta/commands/nickname.py | 36 ++++----- vesta/commands/presentation.py | 34 ++++---- vesta/data/lang.yml | 98 ++++++++++++------------ vesta/lang.py | 4 +- vesta/modals/custom_form.py | 54 ++++++------- vesta/modals/presentation_form.py | 30 ++++---- vesta/modals/refused_form.py | 14 ++-- vesta/services/__init__.py | 2 + vesta/services/clash_of_code_entities.py | 34 ++++---- vesta/services/clash_of_code_helper.py | 69 ++++++++--------- vesta/tables/clash_of_code.py | 5 +- vesta/tables/guilds.py | 2 +- vesta/views/review.py | 32 ++++---- 17 files changed, 262 insertions(+), 253 deletions(-) diff --git a/vesta/__init__.py b/vesta/__init__.py index b2c5340..18915aa 100644 --- a/vesta/__init__.py +++ b/vesta/__init__.py @@ -7,7 +7,7 @@ from .lang import Lang from yaml import load, Loader with open("vesta/data/lang.yml") as file: - lang = Lang(load(file.read(), Loader), session_maker) + lang_file = Lang(load(file.read(), Loader), session_maker) from .client import Vesta diff --git a/vesta/commands/clash_of_code.py b/vesta/commands/clash_of_code.py index 8701f9f..b98b979 100644 --- a/vesta/commands/clash_of_code.py +++ b/vesta/commands/clash_of_code.py @@ -1,46 +1,45 @@ import logging import re +import traceback from typing import Tuple, Optional import discord from discord import app_commands from sqlalchemy import select -from .. import vesta_client, session_maker, lang -from ..services.clash_of_code_helper import ClashOfCodeHelper, game_id_from_link +from .. import vesta_client, session_maker, lang_file +from ..services import clash_of_code_helper from ..tables import Guild -from ..tables.clash_of_code import ClashOfCodeGuildGame +from ..tables import ClashOfCodeGuildGame logger = logging.getLogger(__name__) session = session_maker() regex_clash_of_code_game = r"^(https://|)(www.|)codingame.com/clashofcode/clash/[^/]+(/|)$" - @vesta_client.tree.command(name="clash-of-code", description="Invites users with the \"Clash of Code\" role to play") @app_commands.describe(link="The link to the Clash of Code game") async def coc(interaction: discord.Interaction, link: str): if not re.match(regex_clash_of_code_game, link): await interaction.response.send_message( - lang.get("clash_of_code.invalid_link", interaction.guild), + lang_file.get("coc_invalid_link", interaction.guild), ephemeral=True ) return - helper = ClashOfCodeHelper() - game_id = game_id_from_link(link) + game_id = clash_of_code_helper.game_id_from_link(link) - game = helper.fetch(game_id) + game = clash_of_code_helper.fetch(game_id) if not game: await interaction.response.send_message( - lang.get("clash_of_code.invalid_link", interaction.guild), + lang_file.get("coc_invalid_link", interaction.guild), ephemeral=True ) return if game.started or game.finished: await interaction.response.send_message( - lang.get("clash_of_code.game_already_started", interaction.guild), + lang_file.get("coc_game_already_started", interaction.guild), ephemeral=True ) return @@ -56,6 +55,7 @@ async def coc(interaction: discord.Interaction, link: str): return if not guild_game: + logger.debug(f"Creating new guild game for guild {interaction.guild_id}") guild_game = ClashOfCodeGuildGame(guild_id=interaction.guild_id) session.add(guild_game) @@ -67,7 +67,7 @@ async def coc(interaction: discord.Interaction, link: str): if guild.coc_role is None: await interaction.response.send_message( - lang.get("clash_of_code.role_not_set", interaction.guild), + lang_file.get("coc_role_not_set", interaction.guild), ephemeral=True ) return @@ -75,14 +75,14 @@ async def coc(interaction: discord.Interaction, link: str): role = interaction.guild.get_role(guild.coc_role) if role is None: await interaction.response.send_message( - lang.get("clash_of_code.role_not_found", interaction.guild), + lang_file.get("coc_role_not_found", interaction.guild), ephemeral=True ) return if guild.coc_channel is None: await interaction.response.send_message( - lang.get("clash_of_code.channel_not_set", interaction.guild), + lang_file.get("coc_channel_not_set", interaction.guild), ephemeral=True ) return @@ -90,29 +90,38 @@ async def coc(interaction: discord.Interaction, link: str): channel = interaction.guild.get_channel(guild.coc_channel) if channel is None: await interaction.response.send_message( - lang.get("clash_of_code.channel_not_found", interaction.guild), + lang_file.get("coc_channel_not_found", interaction.guild), ephemeral=True ) return view = discord.ui.View() view.add_item(discord.ui.Button( - label=lang.get("clash_of_code.game_join"), + label=lang_file.get("coc_game_join", interaction.guild), url=game.link, emoji="🎮" )) await channel.send( - content=f"{role.mention} {lang.get('clash_of_code.game_invite')}", - embed=game.embed(), + content=f"{role.mention} {lang_file.get('coc_game_invite', interaction.guild)}", + embed=game.embed(lang_file, interaction.guild), view=view ) - guild_game.game_id = game_id - session.commit() + guild_game.last_clash_id = game_id + + try: + session.commit() + except: + session.rollback() + + logger.error(traceback.format_exc()) + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) + pass + await interaction.response.send_message( - lang.get("clash_of_code.successfully_invited", interaction.guild), + lang_file.get("coc_successfully_invited", interaction.guild), ephemeral=True ) diff --git a/vesta/commands/config.py b/vesta/commands/config.py index 7584722..5d11965 100644 --- a/vesta/commands/config.py +++ b/vesta/commands/config.py @@ -5,7 +5,7 @@ import logging import traceback -from .. import vesta_client, session_maker, lang +from .. import vesta_client, session_maker, lang_file from ..tables import Guild, select logger = logging.getLogger(__name__) @@ -19,14 +19,14 @@ async def on_error(self, interaction: discord.Interaction, error): logger.debug(f"Error {error} raised") if isinstance(error, app_commands.errors.MissingPermissions): await interaction.response.send_message( - lang.get("permissions_error", interaction.guild), ephemeral=True) + lang_file.get("permissions_error", interaction.guild), ephemeral=True) elif isinstance(error, app_commands.errors.BotMissingPermissions): await interaction.response.send_message( - lang.get("bot_permissions_error", interaction.guild) + f" {', '.join(error.missing_permissions)}", + lang_file.get("bot_permissions_error", interaction.guild) + f" {', '.join(error.missing_permissions)}", ephemeral=True) else: logger.error(traceback.format_exc()) - await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) config_manager = ConfigManager() @@ -41,7 +41,7 @@ def update(g): g.review_channel = channel.id await update_config_element(interaction, update) - await interaction.response.send_message(lang.get("config_review", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("config_review", interaction.guild), ephemeral=True) @config_manager.command(description="Set Projets Channel") @@ -53,7 +53,7 @@ def update(g): g.projects_channel = channel.id await update_config_element(interaction, update) - await interaction.response.send_message(lang.get("config_projects", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("config_projects", interaction.guild), ephemeral=True) @config_manager.command(name="coc-role", description="Set clash of code ping role") @@ -66,7 +66,7 @@ def update(g): g.coc_role = coc_role.id await update_config_element(interaction, update) - await interaction.response.send_message(lang.get("config_coc_role", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("config_coc_role", interaction.guild), ephemeral=True) @config_manager.command(name="coc-channel", description="Set clash of code channel") @app_commands.rename(coc_channel="channel") @@ -78,12 +78,12 @@ def update(g): g.coc_channel = coc_channel.id await update_config_element(interaction, update) - await interaction.response.send_message(lang.get("config_coc_channel", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("config_coc_channel", interaction.guild), ephemeral=True) @config_manager.command(name="lang", description="Set Guild Lang") @app_commands.rename(guild_lang='lang') @app_commands.describe(guild_lang="The lang for the bot") -@app_commands.choices(guild_lang=[app_commands.Choice(name=l, value=l) for l in lang.data]) +@app_commands.choices(guild_lang=[app_commands.Choice(name=l, value=l) for l in lang_file.data]) async def change_lang(interaction: discord.Interaction, guild_lang: app_commands.Choice[str]): logger.debug(f"Command /config lang {guild_lang.value} used") @@ -91,7 +91,7 @@ def update(g): g.lang = guild_lang.value await update_config_element(interaction, update) - await interaction.response.send_message(lang.get("config_lang", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("config_lang", interaction.guild), ephemeral=True) async def update_config_element(interaction: discord.Interaction, updater: typing.Callable[[Guild], None]): r = select(Guild).where(Guild.id == interaction.guild_id) @@ -109,7 +109,7 @@ async def update_config_element(interaction: discord.Interaction, updater: typin session.rollback() logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) pass vesta_client.tree.add_command(config_manager) diff --git a/vesta/commands/custom.py b/vesta/commands/custom.py index 9e5bad7..586ae30 100644 --- a/vesta/commands/custom.py +++ b/vesta/commands/custom.py @@ -4,7 +4,7 @@ import traceback import re -from .. import vesta_client, session_maker, lang +from .. import vesta_client, session_maker, lang_file from ..modals import CustomSlashForm, CustomMenuForm from ..tables import CustomCommand, select @@ -21,14 +21,14 @@ async def on_error(self, interaction: discord.Interaction, error): logger.debug(f"Error {error} raised") if isinstance(error, app_commands.errors.MissingPermissions): await interaction.response.send_message( - lang.get("permissions_error", interaction.guild), ephemeral=True) + lang_file.get("permissions_error", interaction.guild), ephemeral=True) elif isinstance(error, app_commands.errors.BotMissingPermissions): await interaction.response.send_message( - lang.get("bot_permissions_error", interaction.guild) + f" {', '.join(error.missing_permissions)}", + lang_file.get("bot_permissions_error", interaction.guild) + f" {', '.join(error.missing_permissions)}", ephemeral=True) else: logger.error(traceback.format_exc()) - await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) custom_manager = CustomManager() @@ -39,22 +39,22 @@ async def on_error(self, interaction: discord.Interaction, error): async def add(interaction: discord.Interaction, keyword: str): keyword = keyword.lower() if not re.match(custom_regex, keyword): - return await interaction.response.send_message(lang.get("invalid_keyword", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("invalid_keyword", interaction.guild), ephemeral=True) logger.debug(f"Command /custom add {keyword} used") if len(keyword) > 32: - return await interaction.response.send_message(lang.get("too_long_keyword", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("too_long_keyword", interaction.guild), ephemeral=True) r = select(CustomCommand).where(CustomCommand.guild_id == interaction.guild_id) r = r.where(CustomCommand.keyword == keyword) command = session.scalar(r) if command: - return await interaction.response.send_message(lang.get("command_already_exist", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("command_already_exist", interaction.guild), ephemeral=True) number = session.query(CustomCommand).where(CustomCommand.guild_id == interaction.guild_id).count() if number > 39: - return await interaction.response.send_message(lang.get("too_much_commands", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("too_much_commands", interaction.guild), ephemeral=True) await interaction.response.send_modal(CustomSlashForm(keyword=keyword, interaction=interaction)) @@ -70,7 +70,7 @@ async def remove(interaction: discord.Interaction, keyword: str): command = session.scalar(r) if not command: - return await interaction.response.send_message(lang.get("command_not_exist", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("command_not_exist", interaction.guild), ephemeral=True) vesta_client.tree.remove_command(keyword, guild=interaction.guild) await vesta_client.tree.sync() session.delete(command) @@ -81,17 +81,17 @@ async def remove(interaction: discord.Interaction, keyword: str): session.rollback() logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) - await interaction.response.send_message(lang.get("command_deleted", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("command_deleted", interaction.guild), ephemeral=True) await vesta_client.tree.sync(guild=interaction.guild) @custom_manager.command(name="list", description="List custom commands") async def custom_list(interaction: discord.Interaction): logger.debug(f"Command /custom list used") - list_embed = discord.Embed(title=lang.get("list_commands", interaction.guild)) - list_embed2 = discord.Embed(title=lang.get("list_commands2", interaction.guild)) + list_embed = discord.Embed(title=lang_file.get("list_commands", interaction.guild)) + list_embed2 = discord.Embed(title=lang_file.get("list_commands2", interaction.guild)) r = select(CustomCommand).where(CustomCommand.guild_id == interaction.guild_id) commands = session.scalars(r) @@ -119,7 +119,7 @@ async def create_custom(interaction: discord.Interaction, message: discord.Messa number = session.query(CustomCommand).where(CustomCommand.guild_id == interaction.guild_id).count() if number > 39: - return await interaction.response.send_message(lang.get("too_much_commands", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("too_much_commands", interaction.guild), ephemeral=True) await interaction.response.send_modal(CustomMenuForm(content=message.content, author=message.author, interaction=interaction)) diff --git a/vesta/commands/nickname.py b/vesta/commands/nickname.py index 539d53d..1fd7648 100644 --- a/vesta/commands/nickname.py +++ b/vesta/commands/nickname.py @@ -6,7 +6,7 @@ import discord from discord import app_commands -from .. import vesta_client, session_maker, lang +from .. import vesta_client, session_maker, lang_file from ..tables import select, Ban logger = logging.getLogger(__name__) @@ -26,20 +26,20 @@ async def nickname(interaction: discord.Interaction, name: str): response = session.scalar(r) if response and response.nickname_banned: return await interaction.response.send_message( - lang.get("nickname_banned", interaction.guild), + lang_file.get("nickname_banned", interaction.guild), ephemeral=True) if not interaction.user.guild_permissions.manage_nicknames and not re.match(regex_name, name): - response_embed = discord.Embed(color=int("FF4444", 16), title=lang.get("nickname_incorrect_title", interaction.guild), - description=lang.get("nickname_incorrect_description", interaction.guild) + f"`{regex_name}`") + response_embed = discord.Embed(color=int("FF4444", 16), title=lang_file.get("nickname_incorrect_title", interaction.guild), + description=lang_file.get("nickname_incorrect_description", interaction.guild) + f"`{regex_name}`") return await interaction.response.send_message(embed=response_embed, ephemeral=True) if len(name) > 32: - return await interaction.response.send_message(lang.get("nick_too_long", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("nick_too_long", interaction.guild), ephemeral=True) await interaction.user.edit(nick=name) await interaction.response.send_message( - content=lang.get("nickname_changed", interaction.guild), ephemeral=True) + content=lang_file.get("nickname_changed", interaction.guild), ephemeral=True) @nickname.error @@ -47,11 +47,11 @@ async def nickname_error(interaction: discord.Interaction, error): logger.debug(f"Error {error} raised") if isinstance(error, app_commands.errors.BotMissingPermissions): await interaction.response.send_message( - lang.get("bot_permissions_error", interaction.guild) + f" {', '.join(error.missing_permissions)}", + lang_file.get("bot_permissions_error", interaction.guild) + f" {', '.join(error.missing_permissions)}", ephemeral=True) else: logger.error(traceback.format_exc()) - await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) @app_commands.guild_only() @@ -62,14 +62,14 @@ async def on_error(self, interaction: discord.Interaction, error): logger.debug(f"Error {error} raised") if isinstance(error, app_commands.errors.MissingPermissions): await interaction.response.send_message( - lang.get("permissions_error", interaction.guild), ephemeral=True) + lang_file.get("permissions_error", interaction.guild), ephemeral=True) elif isinstance(error, app_commands.errors.BotMissingPermissions): await interaction.response.send_message( - lang.get("bot_permissions_error", interaction.guild) + f" {', '.join(error.missing_permissions)}", + lang_file.get("bot_permissions_error", interaction.guild) + f" {', '.join(error.missing_permissions)}", ephemeral=True) else: logger.error(traceback.format_exc()) - await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) nick_manage = NickManage() @@ -96,10 +96,10 @@ async def ban(interaction: discord.Interaction, user: discord.Member): session.rollback() logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) await interaction.response.send_message( - content=f"{user} " + lang.get("nickname_ban", interaction.guild)) + content=f"{user} " + lang_file.get("nickname_ban", interaction.guild)) @nick_manage.command(description="Unban a user from using the nickname command") @@ -111,7 +111,7 @@ async def unban(interaction: discord.Interaction, user: discord.Member): response = session.scalar(r) if not (response and response.nickname_banned): return await interaction.response.send_message( - content=f"{user} " + lang.get("nickname_not_banned", interaction.guild)) + content=f"{user} " + lang_file.get("nickname_not_banned", interaction.guild)) response.nickname_banned = False try: @@ -120,10 +120,10 @@ async def unban(interaction: discord.Interaction, user: discord.Member): session.rollback() logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) await interaction.response.send_message( - content=f"{user} " + lang.get("nickname_unban", interaction.guild)) + content=f"{user} " + lang_file.get("nickname_unban", interaction.guild)) @nick_manage.command(name="list", description="Show the banlist") @@ -139,8 +139,8 @@ async def banlist(interaction: discord.Interaction, page: Optional[int] = 0): for response in responses: ban_list += f"<@{response.user_id}>\n" - banned_embed = discord.Embed(title=lang.get("nickname_list_title", interaction.guild), description=ban_list) - banned_embed.set_footer(text=lang.get("list_page", interaction.guild) + f" {page}") + banned_embed = discord.Embed(title=lang_file.get("nickname_list_title", interaction.guild), description=ban_list) + banned_embed.set_footer(text=lang_file.get("list_page", interaction.guild) + f" {page}") await interaction.response.send_message(embed=banned_embed, allowed_mentions=discord.AllowedMentions().none()) diff --git a/vesta/commands/presentation.py b/vesta/commands/presentation.py index 1406a29..a9d8505 100644 --- a/vesta/commands/presentation.py +++ b/vesta/commands/presentation.py @@ -5,7 +5,7 @@ from typing import Optional -from .. import vesta_client, session_maker, lang +from .. import vesta_client, session_maker, lang_file from ..modals import PresentationForm from ..tables import Presentation, select, or_, Guild, Ban @@ -22,13 +22,13 @@ async def presentation(interaction: discord.Interaction): guild = session.scalar(r) if not guild or not guild.review_channel or not guild.projects_channel: return await interaction.response.send_message( - lang.get("presentations_not_available", interaction.guild), ephemeral=True) + lang_file.get("presentations_not_available", interaction.guild), ephemeral=True) r = select(Ban).where(Ban.user_id == interaction.user.id).where(Ban.guild_id == interaction.guild.id) response = session.scalar(r) if response and response.presentation_banned: return await interaction.response.send_message( - lang.get("presentations_banned", interaction.guild), + lang_file.get("presentations_banned", interaction.guild), ephemeral=True) await interaction.response.send_modal(PresentationForm(interaction)) @@ -41,14 +41,14 @@ async def on_error(self, interaction: discord.Interaction, error): logger.debug(f"Error {error} raised") if isinstance(error, app_commands.errors.MissingPermissions): await interaction.response.send_message( - lang.get("permissions_error", interaction.guild), ephemeral=True) + lang_file.get("permissions_error", interaction.guild), ephemeral=True) elif isinstance(error, app_commands.errors.BotMissingPermissions): await interaction.response.send_message( - lang.get("bot_permissions_error", interaction.guild) + f" {', '.join(error.missing_permissions)}", + lang_file.get("bot_permissions_error", interaction.guild) + f" {', '.join(error.missing_permissions)}", ephemeral=True) else: logger.error(traceback.format_exc()) - await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) presentation_manage = PresentationManage() @@ -60,19 +60,19 @@ async def show(interaction: discord.Interaction, research: Optional[str] = None, logger.debug(f"Command /presentationmanage show [research={research},user={user}] used") if not (research or user): return await interaction.response.send_message( - lang.get("minimum_one_parameter", interaction.guild), ephemeral=True) + lang_file.get("minimum_one_parameter", interaction.guild), ephemeral=True) if user: r = select(Presentation).where(Presentation.author_id == user.id) else: if not research.isdecimal() or int(research) > 2 ** 63 - 1: return await interaction.response.send_message( - content=lang.get("invalid_number", interaction.guild), + content=lang_file.get("invalid_number", interaction.guild), ephemeral=True) r = select(Presentation).where(or_(Presentation.id == research, Presentation.author_id == research)) presentations = session.scalars(r).all() if not len(presentations): return await interaction.response.send_message( - content=lang.get("no_result", interaction.guild), + content=lang_file.get("no_result", interaction.guild), ephemeral=True, ) if len(presentations) == 1: @@ -81,7 +81,7 @@ async def show(interaction: discord.Interaction, research: Optional[str] = None, return await interaction.response.send_message(embed=embed) embed = discord.Embed( colour=int('222222', 16), - title=lang.get("user_result_title", interaction.guild) + title=lang_file.get("user_result_title", interaction.guild) ) for presentation in presentations: emoji = '🕑' @@ -112,10 +112,10 @@ async def ban(interaction: discord.Interaction, user: discord.Member): session.rollback() logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) await interaction.response.send_message( - content=f"{user} " + lang.get("user_result_title", interaction.guild)) + content=f"{user} " + lang_file.get("user_result_title", interaction.guild)) @presentation_manage.command(description="Unban a user from submitting a presentation") @@ -127,7 +127,7 @@ async def unban(interaction: discord.Interaction, user: discord.Member): response = session.scalar(r) if not (response and response.presentation_banned): return await interaction.response.send_message( - content=f"{user} " + lang.get("presentations_not_banned", interaction.guild)) + content=f"{user} " + lang_file.get("presentations_not_banned", interaction.guild)) response.presentation_banned = False try: @@ -136,10 +136,10 @@ async def unban(interaction: discord.Interaction, user: discord.Member): session.rollback() logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) await interaction.response.send_message( - content=f"{user} " + lang.get("presentations_unban", interaction.guild)) + content=f"{user} " + lang_file.get("presentations_unban", interaction.guild)) @presentation_manage.command(name="list", description="Show the banlist") @@ -155,8 +155,8 @@ async def banlist(interaction: discord.Interaction, page: Optional[int] = 0): for result in results: ban_list += f"<@{result.user_id}>\n" - banned_embed = discord.Embed(title=lang.get("presentations_list_title", interaction.guild), description=ban_list) - banned_embed.set_footer(text=lang.get("list_page", interaction.guild) + f" {page}") + banned_embed = discord.Embed(title=lang_file.get("presentations_list_title", interaction.guild), description=ban_list) + banned_embed.set_footer(text=lang_file.get("list_page", interaction.guild) + f" {page}") await interaction.response.send_message(embed=banned_embed, allowed_mentions=discord.AllowedMentions().none()) diff --git a/vesta/data/lang.yml b/vesta/data/lang.yml index f02cba7..c826758 100644 --- a/vesta/data/lang.yml +++ b/vesta/data/lang.yml @@ -80,33 +80,32 @@ fr: custom_invalid_args: Merci de fournir des arguments valides thread_project: Discussion sur le projet nick_too_long: Ce nom est trop long - clash_of_code: - invalid_link: Ce lien ne semble pas lié à une partie de Clash of Code valide ! - game_already_started: La partie de Clash of Code que vous avez fourni est déjà en cours / terminée ! - already_in_progress: Une partie de Clash of Code est déjà en cours sur ce serveur ! - successfully_invited: Vous avez bien invité les autres membres du serveur à rejoindre la partie ! - all_languages: Tous les langages - role_not_set: > - Le rôle à mentionner lors des parties de Clash of Code n'a - pas été configuré sur ce serveur ! Prévenez un administrateur ! - role_not_found: > - Le configuré rôle pour être mentionné lors des parties de Clash of Code - n'a pas été trouvé ! Prévenez un administrateur ! - channel_not_set: > - Le salon où envoyer les invitations pour les parties de Clash of Code n'a - pas été configuré sur ce serveur ! Prévenez un administrateur ! - channel_not_found: > - Le configuré salon où envoyer les invitations pour les parties de Clash of Code - n'a pas été trouvé ! Prévenez un administrateur ! - started: "Commencé :" - finished: "Terminé :" - game_title: Nouvelle partie de Clash of Code ! - game_invite: Une nouvelle partie de Clash of Code a été lancée ! - game_join: Rejoindre la partie - game_modes: Modes de jeu possibles - game_mode: Mode de jeu - game_players: Joueurs - game_languages: Langages autorisés + coc_invalid_link: Ce lien ne semble pas lié à une partie de Clash of Code valide ! + coc_game_already_started: La partie de Clash of Code que vous avez fourni est déjà en cours / terminée ! + coc_already_in_progress: Une partie de Clash of Code est déjà en cours sur ce serveur ! + coc_successfully_invited: Vous avez bien invité les autres membres du serveur à rejoindre la partie ! + coc_all_languages: Tous les langages + coc_role_not_set: > + Le rôle à mentionner lors des parties de Clash of Code n'a + pas été configuré sur ce serveur ! Prévenez un administrateur ! + coc_role_not_found: > + Le configuré rôle pour être mentionné lors des parties de Clash of Code + n'a pas été trouvé ! Prévenez un administrateur ! + coc_channel_not_set: > + Le salon où envoyer les invitations pour les parties de Clash of Code n'a + pas été configuré sur ce serveur ! Prévenez un administrateur ! + coc_channel_not_found: > + Le configuré salon où envoyer les invitations pour les parties de Clash of Code + n'a pas été trouvé ! Prévenez un administrateur ! + coc_started: "Commencé :" + coc_finished: "Terminé :" + coc_game_title: Nouvelle partie de Clash of Code ! + coc_game_invite: Une nouvelle partie de Clash of Code a été lancée ! + coc_game_join: Rejoindre la partie + coc_game_modes: Modes de jeu possibles + coc_game_mode: Mode de jeu + coc_game_players: Joueurs + coc_game_languages: Langages autorisés en: invalid_link: The link is not valid invalid_image_link: The link of the image is not valid @@ -186,26 +185,25 @@ en: custom_invalid_args: Please provide valid arguments thread_project: Chat about the project nick_too_long: This nickname is too long - clash_of_code: - invalid_link: This link does not seem to be a valid Clash of Code game ! - game_already_started: The Clash of Code game you provided is already started / finished ! - already_in_progress: A Clash of Code game is already in progress on this server ! - successfully_invited: You successfully invited the other members of the server to join the game ! - all_languages: All languages - role_not_set: > - The role to mention when a Clash of Code game is started was not configured on this server ! Please warn an administrator ! - role_not_found: > - The configured role to mention when a Clash of Code game is started was not found ! Please warn an administrator ! - channel_not_set: > - The channel where to send the invitations for the Clash of Code games was not configured on this server ! Please warn an administrator ! - channel_not_found: > - The configured channel where to send the invitations for the Clash of Code games was not found ! Please warn an administrator ! - started: "Started:" - finished: "Finished:" - game_title: New Clash of Code game ! - game_invite: A new Clash of Code game was started ! - game_join: Join the game - game_modes: Possible game modes - game_mode: Game mode - game_players: Players - game_languages: Allowed languages + coc_invalid_link: This link does not seem to be a valid Clash of Code game ! + coc_game_already_started: The Clash of Code game you provided is already started / finished ! + coc_already_in_progress: A Clash of Code game is already in progress on this server ! + coc_successfully_invited: You successfully invited the other members of the server to join the game ! + coc_all_languages: All languages + coc_role_not_set: > + The role to mention when a Clash of Code game is started was not configured on this server ! Please warn an administrator ! + coc_role_not_found: > + The configured role to mention when a Clash of Code game is started was not found ! Please warn an administrator ! + coc_channel_not_set: > + The channel where to send the invitations for the Clash of Code games was not configured on this server ! Please warn an administrator ! + coc_channel_not_found: > + The configured channel where to send the invitations for the Clash of Code games was not found ! Please warn an administrator ! + coc_started: "Started:" + coc_finished: "Finished:" + coc_game_title: New Clash of Code game ! + coc_game_invite: A new Clash of Code game was started ! + coc_game_join: Join the game + coc_game_modes: Possible game modes + coc_game_mode: Game mode + coc_game_players: Players + coc_game_languages: Allowed languages diff --git a/vesta/lang.py b/vesta/lang.py index 582553a..a3eb3dd 100644 --- a/vesta/lang.py +++ b/vesta/lang.py @@ -1,5 +1,7 @@ import logging +import discord + from .tables import select, Guild logger = logging.getLogger(__name__) @@ -10,7 +12,7 @@ def __init__(self, data, session): self.data = data self.session = session - def get(self, item, guild): + def get(self, item: str, guild: discord.Guild): lang = "en" if guild.preferred_locale[:2] in self.data: diff --git a/vesta/modals/custom_form.py b/vesta/modals/custom_form.py index 3dc1fcd..4fabaa5 100644 --- a/vesta/modals/custom_form.py +++ b/vesta/modals/custom_form.py @@ -6,7 +6,7 @@ import discord.ui from sqlalchemy import select -from .. import session_maker, vesta_client, lang +from .. import session_maker, vesta_client, lang_file from ..tables import User, CustomCommand logger = logging.getLogger(__name__) @@ -51,12 +51,12 @@ class CustomSlashForm(discord.ui.Modal, title=""): def __init__(self, keyword, interaction): logger.debug(f"CustomSlashForm created for {interaction.user}") - self.title = lang.get("custom_form", interaction.guild) - self.command_title.label = lang.get("custom_form_title", interaction.guild) - self.command_content.label = lang.get("custom_form_content", interaction.guild) - self.command_url.label = lang.get("custom_form_link", interaction.guild) - self.command_image.label = lang.get("custom_form_image", interaction.guild) - self.command_colour.label = lang.get("custom_form_color", interaction.guild) + self.title = lang_file.get("custom_form", interaction.guild) + self.command_title.label = lang_file.get("custom_form_title", interaction.guild) + self.command_content.label = lang_file.get("custom_form_content", interaction.guild) + self.command_url.label = lang_file.get("custom_form_link", interaction.guild) + self.command_image.label = lang_file.get("custom_form_image", interaction.guild) + self.command_colour.label = lang_file.get("custom_form_color", interaction.guild) super().__init__() self.keyword = keyword @@ -67,7 +67,7 @@ async def on_submit(self, interaction: discord.Interaction): command_title = self.command_title.value.strip() command_content = self.command_content.value.strip() if not command_title or not command_content: - return await interaction.response.send_message(lang.get("custom_invalid_args", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("custom_invalid_args", interaction.guild), ephemeral=True) command_url = self.command_url.value if command_url and not re.match(http_regex, command_url): @@ -75,7 +75,7 @@ async def on_submit(self, interaction: discord.Interaction): command_url = 'https://' + command_url else: return await interaction.response.send_message( - content=lang.get("invalid_link", interaction.guild), + content=lang_file.get("invalid_link", interaction.guild), ephemeral=True, ) image_url = self.command_image.value @@ -84,7 +84,7 @@ async def on_submit(self, interaction: discord.Interaction): image_url = 'https://' + image_url else: return await interaction.response.send_message( - content=lang.get("invalid_image_link", interaction.guild), + content=lang_file.get("invalid_image_link", interaction.guild), ephemeral=True, ) r = select(User).where(User.id == interaction.user.id) @@ -115,10 +115,10 @@ async def on_submit(self, interaction: discord.Interaction): session.rollback() logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) - await interaction.response.send_message(lang.get("command_created", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("command_created", interaction.guild), ephemeral=True) @app_commands.guild_only() async def command(interaction: discord.Interaction): @@ -133,7 +133,7 @@ async def command(interaction: discord.Interaction): async def on_error(self, interaction, error): logger.error(traceback.format_exc()) - await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) class CustomMenuForm(discord.ui.Modal, title=""): @@ -170,12 +170,12 @@ class CustomMenuForm(discord.ui.Modal, title=""): def __init__(self, content, author, interaction): logger.debug(f"CustomMenuForm created for {interaction.user}") - self.title = lang.get("custom_form", interaction.guild) - self.command_keyword.label = lang.get("custom_form_keyword", interaction.guild) - self.command_title.label = lang.get("custom_form_title", interaction.guild) - self.command_url.label = lang.get("custom_form_link", interaction.guild) - self.command_image.label = lang.get("custom_form_image", interaction.guild) - self.command_colour.label = lang.get("custom_form_color", interaction.guild) + self.title = lang_file.get("custom_form", interaction.guild) + self.command_keyword.label = lang_file.get("custom_form_keyword", interaction.guild) + self.command_title.label = lang_file.get("custom_form_title", interaction.guild) + self.command_url.label = lang_file.get("custom_form_link", interaction.guild) + self.command_image.label = lang_file.get("custom_form_image", interaction.guild) + self.command_colour.label = lang_file.get("custom_form_color", interaction.guild) super().__init__() self.content = content @@ -186,11 +186,11 @@ async def on_submit(self, interaction: discord.Interaction): keyword = self.command_keyword.value.lower() if not re.match(custom_regex, keyword): - return await interaction.response.send_message(lang.get("invalid_keyword", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("invalid_keyword", interaction.guild), ephemeral=True) command_title = self.command_title.value.strip() if not command_title: - return await interaction.response.send_message(lang.get("custom_invalid_args", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("custom_invalid_args", interaction.guild), ephemeral=True) command_url = self.command_url.value if command_url and not re.match(http_regex, command_url): @@ -198,7 +198,7 @@ async def on_submit(self, interaction: discord.Interaction): command_url = 'https://' + command_url else: return await interaction.response.send_message( - content=lang.get("invalid_link", interaction.guild), + content=lang_file.get("invalid_link", interaction.guild), ephemeral=True, ) image_url = self.command_image.value @@ -207,7 +207,7 @@ async def on_submit(self, interaction: discord.Interaction): image_url = 'https://' + image_url else: return await interaction.response.send_message( - content=lang.get("invalid_image_link", interaction.guild), + content=lang_file.get("invalid_image_link", interaction.guild), ephemeral=True, ) r = select(User).where(User.id == self.author.id) @@ -222,7 +222,7 @@ async def on_submit(self, interaction: discord.Interaction): r = select(CustomCommand).where(CustomCommand.guild_id == interaction.guild_id) r = r.where(CustomCommand.keyword == keyword) if session.scalar(r): - return await interaction.response.send_message(lang.get("command_already_exist", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("command_already_exist", interaction.guild), ephemeral=True) custom_command = CustomCommand( guild_id=interaction.guild_id, keyword=keyword, @@ -241,10 +241,10 @@ async def on_submit(self, interaction: discord.Interaction): session.rollback() logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) - await interaction.response.send_message(lang.get("command_created", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("command_created", interaction.guild), ephemeral=True) @app_commands.guild_only() async def command(interaction: discord.Interaction): @@ -257,4 +257,4 @@ async def command(interaction: discord.Interaction): async def on_error(self, interaction, error): logger.error(traceback.format_exc()) - await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) diff --git a/vesta/modals/presentation_form.py b/vesta/modals/presentation_form.py index cfe2046..9a6912a 100644 --- a/vesta/modals/presentation_form.py +++ b/vesta/modals/presentation_form.py @@ -4,7 +4,7 @@ from sqlalchemy import select import logging -from .. import vesta_client, session_maker, lang +from .. import vesta_client, session_maker, lang_file from ..views import Review from ..tables import Presentation, User, Guild @@ -41,11 +41,11 @@ class PresentationForm(discord.ui.Modal, title=""): def __init__(self, interaction): logger.debug(f"PresentationForm created for {interaction.user}") - self.title = lang.get("presentation_form", interaction.guild) - self.presentation_title.label = lang.get("presentation_form_title", interaction.guild) - self.description.label = lang.get("presentation_form_description", interaction.guild) - self.link.label = lang.get("presentation_form_link", interaction.guild) - self.image_url.label = lang.get("presentation_form_image", interaction.guild) + self.title = lang_file.get("presentation_form", interaction.guild) + self.presentation_title.label = lang_file.get("presentation_form_title", interaction.guild) + self.description.label = lang_file.get("presentation_form_description", interaction.guild) + self.link.label = lang_file.get("presentation_form_link", interaction.guild) + self.image_url.label = lang_file.get("presentation_form_image", interaction.guild) super().__init__() @@ -54,7 +54,7 @@ async def on_submit(self, interaction: discord.Interaction): title = self.presentation_title.value.strip() if not title: - return await interaction.response.send_message(lang.get("custom_invalid_args", interaction.guild), ephemeral=True) + return await interaction.response.send_message(lang_file.get("custom_invalid_args", interaction.guild), ephemeral=True) link_value = self.link.value if not re.match(http_regex, link_value): @@ -62,7 +62,7 @@ async def on_submit(self, interaction: discord.Interaction): link_value = 'https://' + link_value else: return await interaction.response.send_message( - content=lang.get("invalid_link", interaction.guild), + content=lang_file.get("invalid_link", interaction.guild), ephemeral=True, ) image_url = self.image_url.value @@ -71,7 +71,7 @@ async def on_submit(self, interaction: discord.Interaction): image_url = 'https://' + image_url else: return await interaction.response.send_message( - content=lang.get("invalid_image_link", interaction.guild), + content=lang_file.get("invalid_image_link", interaction.guild), ephemeral=True, ) @@ -88,11 +88,11 @@ async def on_submit(self, interaction: discord.Interaction): r = select(Guild).where(Guild.id == interaction.guild_id) guild = session.scalar(r) if not guild or not guild.review_channel: - return await interaction.response.send_message(lang.get("review_channel_error", interaction.guild)) + return await interaction.response.send_message(lang_file.get("review_channel_error", interaction.guild)) channel = vesta_client.get_channel(guild.review_channel) if not channel: - return await interaction.response.send_message(lang.get("review_channel_error", interaction.guild)) + return await interaction.response.send_message(lang_file.get("review_channel_error", interaction.guild)) presentation = Presentation( title=self.presentation_title.value, @@ -116,19 +116,19 @@ async def on_submit(self, interaction: discord.Interaction): session.rollback() logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) - await message.create_thread(name=lang.get("thread_project", interaction.guild) + " [" + title + "]") + await message.create_thread(name=lang_file.get("thread_project", interaction.guild) + " [" + title + "]") await interaction.response.send_message( - content=lang.get("presentation_sent", interaction.guild), + content=lang_file.get("presentation_sent", interaction.guild), ephemeral=True) await view.wait() async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: await interaction.response.send_message( - content=lang.get("unexpected_error", interaction.guild), + content=lang_file.get("unexpected_error", interaction.guild), ephemeral=True, ) traceback.print_exc() diff --git a/vesta/modals/refused_form.py b/vesta/modals/refused_form.py index f1bcb36..770c2ce 100644 --- a/vesta/modals/refused_form.py +++ b/vesta/modals/refused_form.py @@ -2,7 +2,7 @@ import logging import traceback -from .. import vesta_client, lang +from .. import vesta_client, lang_file logger = logging.getLogger(__name__) @@ -16,8 +16,8 @@ class RefusedReasonForm(discord.ui.Modal, title=""): def __init__(self, presentation, interaction): logger.debug(f"RefusedReasonForm created for {interaction.user}") - self.title = lang.get("denied_form", interaction.guild) - self.reason.label = lang.get("denied_form_reason", interaction.guild) + self.title = lang_file.get("denied_form", interaction.guild) + self.reason.label = lang_file.get("denied_form_reason", interaction.guild) super().__init__() self.presentation = presentation @@ -27,14 +27,14 @@ async def on_submit(self, interaction: discord.Interaction): presentation_embed = self.presentation.embed('222222') reason_embed = discord.Embed( colour=int('ff2222', 16), - title=lang.get("denied_feedback_title", interaction.guild), - description=lang.get("denied_feedback_content", interaction.guild) + f" {self.reason.value}", + title=lang_file.get("denied_feedback_title", interaction.guild), + description=lang_file.get("denied_feedback_content", interaction.guild) + f" {self.reason.value}", ) user = await vesta_client.fetch_user(self.presentation.author_id) await user.send(embeds=[presentation_embed, reason_embed]) - await interaction.response.send_message(lang.get("denied_registered", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("denied_registered", interaction.guild), ephemeral=True) async def on_error(self, interaction, error): logger.error(traceback.format_exc()) - await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), ephemeral=True) + await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) diff --git a/vesta/services/__init__.py b/vesta/services/__init__.py index e69de29..f4916be 100644 --- a/vesta/services/__init__.py +++ b/vesta/services/__init__.py @@ -0,0 +1,2 @@ +from .clash_of_code_entities import ClashOfCodeGame, ClashOfCodePlayer, GameMode, Role +from . import clash_of_code_helper \ No newline at end of file diff --git a/vesta/services/clash_of_code_entities.py b/vesta/services/clash_of_code_entities.py index 2be2271..b1a6fb3 100644 --- a/vesta/services/clash_of_code_entities.py +++ b/vesta/services/clash_of_code_entities.py @@ -1,10 +1,11 @@ -from typing import List, Optional from dataclasses import dataclass from enum import Enum +from typing import List, Optional +import discord from discord import Embed -from vesta import lang +from vesta.tables import Guild class GameMode(Enum): @@ -122,36 +123,41 @@ def hydrate(self, *, return self - def embed(self): + def embed(self, lang_file, guild: discord.Guild): emojis = { 'True': '🟢', 'False': '🔴' } description = f""" - **{lang.get("clash_of_code_game.started")}** {emojis[str(self.started)]} - **{lang.get("clash_of_code_game.finished")}** {emojis[str(self.finished)]} + **{lang_file.get("coc_started", guild)}** {emojis[str(self.started)]} + **{lang_file.get("coc_finished", guild)}** {emojis[str(self.finished)]} """ embed = Embed( - title=lang.get("clash_of_code.game_title"), + title=lang_file.get("coc_game_title", guild), url=self.link, description=description ) if not self.mode: - embed.add_field(name=lang.get("clash_of_code.game_modes"), - value=f"`{'`, `'.join([mode.name.lower() for mode in self.modes])}`") + embed.add_field(name=lang_file.get("coc_game_modes", guild), + value=f"`{'`, `'.join([mode.name.lower() for mode in self.modes])}`", + inline=False) else: - embed.add_field(name=lang.get("clash_of_code.game_mode"), - value=f"`{self.mode.lower()}`") + embed.add_field(name=lang_file.get("coc_game_mode", guild), + value=f"`{self.mode.lower()}`", + inline=False) - embed.add_field(name=lang.get("clash_of_code.game_players"), - value=f"`{'`, `'.join([player.name for player in self.players])}`") + embed.add_field(name=lang_file.get("coc_game_players", guild), + value=f"`{'`, `'.join([player.name for player in self.players])}`", + inline=False) languages = f"`{'`, `'.join(self.programming_language)}`" \ if len(self.programming_language) >= 1 \ - else lang.get("clash_of_code.all_languages") + else lang_file.get("coc_all_languages", guild) - embed.add_field(name=lang.get("clash_of_code.game_languages"), + embed.add_field(name=lang_file.get("coc_game_languages", guild), value=languages) + + return embed diff --git a/vesta/services/clash_of_code_helper.py b/vesta/services/clash_of_code_helper.py index 47448da..6aea22d 100644 --- a/vesta/services/clash_of_code_helper.py +++ b/vesta/services/clash_of_code_helper.py @@ -1,51 +1,44 @@ +from typing import Optional + import requests -from vesta.services.clash_of_code_entities import * +from . import ClashOfCodeGame + +BASE_ENDPOINT = "https://www.codingame.com/services/ClashOfCode" def game_id_from_link(link: str) -> str: return link.split("/")[-1] +def fetch(game_id) -> Optional[ClashOfCodeGame]: + """ + Retrieves a game object from the API -class ClashOfCodeHelper: - __instance = None - BASE_ENDPOINT = "https://www.codingame.com/services/ClashOfCode/" - - def __new__(cls, *args, **kwargs): - if ClashOfCodeHelper.__instance is None: - ClashOfCodeHelper.__instance = super(ClashOfCodeHelper, cls).__new__(cls, *args, **kwargs) - return ClashOfCodeHelper.__instance - - def fetch(self, game_id) -> Optional[ClashOfCodeGame]: - """ - Retrieves a game object from the API - - :param game_id: The game id to retrieve - :return: The game object if it exists, None otherwise - """ - r = requests.post(f"{self.BASE_ENDPOINT}findClashByHandle", json=[ - game_id - ]) - - if r.status_code != 200: - return None + :param game_id: The game id to retrieve + :return: The game object if it exists, None otherwise + """ + r = requests.post(f"{BASE_ENDPOINT}/findClashByHandle", json=[ + game_id + ]) - return ClashOfCodeGame(**r.json()) + if r.status_code != 200: + return None - def update(self, game: ClashOfCodeGame) -> ClashOfCodeGame: - """ - Updates the game object with the latest information from the API + return ClashOfCodeGame(**r.json()) - :param game: The game object to update - :return: The updated game object for chaining convenience - :raises NameError: If there was an error updating the game object - """ - r = requests.post(f"{self.BASE_ENDPOINT}findClashByHandle", json=[ - game_id_from_link(game.link) - ]) +def update(game: ClashOfCodeGame) -> ClashOfCodeGame: + """ + Updates the game object with the latest information from the API - if r.status_code != 200: - raise NameError("There was an error updating the game object") + :param game: The game object to update + :return: The updated game object for chaining convenience + :raises NameError: If there was an error updating the game object + """ + r = requests.post(f"{BASE_ENDPOINT}/findClashByHandle", json=[ + game_id_from_link(game.link) + ]) - game.hydrate(**r.json()) - return game + if r.status_code != 200: + raise NameError("There was an error updating the game object") + game.hydrate(**r.json()) + return game diff --git a/vesta/tables/clash_of_code.py b/vesta/tables/clash_of_code.py index f6d87d1..618fdfa 100644 --- a/vesta/tables/clash_of_code.py +++ b/vesta/tables/clash_of_code.py @@ -5,8 +5,7 @@ from . import Base from .. import session_maker -from ..services.clash_of_code_entities import ClashOfCodeGame -from ..services.clash_of_code_helper import ClashOfCodeHelper +from ..services import ClashOfCodeGame session = session_maker() @@ -14,7 +13,7 @@ class ClashOfCodeGuildGame(Base): __tablename__ = "clash_of_code_guild_game" guild_id = db.Column(db.BigInteger, nullable=False) - last_clash_id = db.Column(db.BigInteger) + last_clash_id = db.Column(db.String(32), nullable=True) db.PrimaryKeyConstraint(guild_id) diff --git a/vesta/tables/guilds.py b/vesta/tables/guilds.py index 6ed47f2..bed4475 100644 --- a/vesta/tables/guilds.py +++ b/vesta/tables/guilds.py @@ -9,7 +9,7 @@ class Guild(Base): name = db.Column(db.String(32), nullable=False) review_channel = db.Column(db.BigInteger) projects_channel = db.Column(db.BigInteger) - coc_channel = db.column(db.BigInteger) + coc_channel = db.Column(db.BigInteger) coc_role = db.Column(db.BigInteger) lang = db.Column(db.String(2)) diff --git a/vesta/views/review.py b/vesta/views/review.py index c01972e..b55bae7 100644 --- a/vesta/views/review.py +++ b/vesta/views/review.py @@ -3,7 +3,7 @@ import logging import traceback -from .. import session_maker, vesta_client, lang +from .. import session_maker, vesta_client, lang_file from ..modals import RefusedReasonForm from ..tables import select, Guild, Presentation @@ -14,12 +14,12 @@ class DropdownReview(discord.ui.Select): def __init__(self, interaction): options = [ - discord.SelectOption(label=lang.get("reason_not_enough_code", interaction.guild), value="text_not_enough_code", description='', emoji='📝'), - discord.SelectOption(label=lang.get("reason_not_open_source", interaction.guild), value="text_not_open_source", description='', emoji='🔒'), - discord.SelectOption(label=lang.get("reason_illegal", interaction.guild), value="text_illegal", description='', emoji='👮'), - discord.SelectOption(label=lang.get("reason_other", interaction.guild), value="Other", description='', emoji='✒') + discord.SelectOption(label=lang_file.get("reason_not_enough_code", interaction.guild), value="text_not_enough_code", description='', emoji='📝'), + discord.SelectOption(label=lang_file.get("reason_not_open_source", interaction.guild), value="text_not_open_source", description='', emoji='🔒'), + discord.SelectOption(label=lang_file.get("reason_illegal", interaction.guild), value="text_illegal", description='', emoji='👮'), + discord.SelectOption(label=lang_file.get("reason_other", interaction.guild), value="Other", description='', emoji='✒') ] - super().__init__(placeholder=lang.get("deny", interaction.guild), options=options, custom_id="deny_select") + super().__init__(placeholder=lang_file.get("deny", interaction.guild), options=options, custom_id="deny_select") async def callback(self, interaction: discord.Interaction): @@ -34,8 +34,8 @@ async def callback(self, interaction: discord.Interaction): presentation_embed = presentation.embed('222222') reason_embed = discord.Embed( colour=int('ff2222', 16), - title=lang.get("denied_feedback_title", interaction.guild), - description=f"{lang.get('denied_feedback_content', interaction.guild)} {lang.get(self.values[0], interaction.guild)}") + title=lang_file.get("denied_feedback_title", interaction.guild), + description=f"{lang_file.get('denied_feedback_content', interaction.guild)} {lang_file.get(self.values[0], interaction.guild)}") user = await vesta_client.fetch_user(presentation.author_id) await user.send(embeds=[presentation_embed, reason_embed]) @@ -50,12 +50,12 @@ async def callback(self, interaction: discord.Interaction): session.rollback() logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) embed = interaction.message.embeds[0] embed.colour = int("ff2222", 16) - embed.set_footer(text=lang.get("denied_by", interaction.guild) + f" {interaction.user.display_name}") + embed.set_footer(text=lang_file.get("denied_by", interaction.guild) + f" {interaction.user.display_name}") embed.timestamp = presentation.review_date await interaction.message.edit(embed=embed, view=None) self.view.stop() @@ -64,7 +64,7 @@ async def callback(self, interaction: discord.Interaction): class AcceptReview(discord.ui.Button): def __init__(self, interaction): - super().__init__(style=discord.ButtonStyle.green, label=lang.get("accept", interaction.guild), custom_id="accept_button") + super().__init__(style=discord.ButtonStyle.green, label=lang_file.get("accept", interaction.guild), custom_id="accept_button") async def callback(self, interaction: discord.Interaction): @@ -77,11 +77,11 @@ async def callback(self, interaction: discord.Interaction): guild = session.scalar(r) if not guild or not guild.projects_channel: - return interaction.response.send_message(lang.get("projects_channel_error", interaction.guild)) + return interaction.response.send_message(lang_file.get("projects_channel_error", interaction.guild)) channel = vesta_client.get_channel(guild.projects_channel) if not channel: - return interaction.response.send_message(lang.get("projects_channel_error", interaction.guild)) + return interaction.response.send_message(lang_file.get("projects_channel_error", interaction.guild)) presentation.reviewed = True presentation.review_date = datetime.now() @@ -94,18 +94,18 @@ async def callback(self, interaction: discord.Interaction): session.rollback() logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang.get("unexpected_error", interaction.guild), + return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) embed = interaction.message.embeds[0] embed.colour = int("22ff22", 16) - embed.set_footer(text=lang.get("accepted_by", interaction.guild) + f" {interaction.user.display_name}") + embed.set_footer(text=lang_file.get("accepted_by", interaction.guild) + f" {interaction.user.display_name}") embed.timestamp = presentation.review_date await interaction.response.edit_message(embed=embed, view=None) embed.title = " ".join(embed.title.split()[1:]) message = await channel.send(embed=embed) - await message.create_thread(name=lang.get("thread_project", interaction.guild) + " [" + embed.title + "]") + await message.create_thread(name=lang_file.get("thread_project", interaction.guild) + " [" + embed.title + "]") self.view.stop() From d12790673908606442c97f8204841399b08778f0 Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Fri, 19 May 2023 00:50:55 +0200 Subject: [PATCH 09/13] Enhanced some messages, tried to add a cron to check for updates on the api, did not succeed yet, but I need to sleep sometimes so let's continue that tomorrow --- requirements.txt | 3 +- vesta/commands/clash_of_code.py | 53 +++++++++++++++------- vesta/data/lang.yml | 30 ++++++++----- vesta/lang.py | 2 +- vesta/services/clash_of_code_entities.py | 57 ++++++++++++++++-------- vesta/tables/clash_of_code.py | 18 ++++---- 6 files changed, 107 insertions(+), 56 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5273904..683b5be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ psycopg2-binary==2.9.3 python-dotenv==0.20.0 PyYAML==6.0 SQLAlchemy==1.4.37 -requests==2.30.0 \ No newline at end of file +requests==2.30.0 +APScheduler==3.10.1 \ No newline at end of file diff --git a/vesta/commands/clash_of_code.py b/vesta/commands/clash_of_code.py index b98b979..f7d07dd 100644 --- a/vesta/commands/clash_of_code.py +++ b/vesta/commands/clash_of_code.py @@ -1,22 +1,23 @@ import logging import re -import traceback -from typing import Tuple, Optional import discord +from apscheduler.triggers.interval import IntervalTrigger from discord import app_commands +from apscheduler.schedulers.asyncio import AsyncIOScheduler from sqlalchemy import select from .. import vesta_client, session_maker, lang_file from ..services import clash_of_code_helper -from ..tables import Guild from ..tables import ClashOfCodeGuildGame +from ..tables import Guild logger = logging.getLogger(__name__) session = session_maker() regex_clash_of_code_game = r"^(https://|)(www.|)codingame.com/clashofcode/clash/[^/]+(/|)$" + @vesta_client.tree.command(name="clash-of-code", description="Invites users with the \"Clash of Code\" role to play") @app_commands.describe(link="The link to the Clash of Code game") async def coc(interaction: discord.Interaction, link: str): @@ -44,12 +45,12 @@ async def coc(interaction: discord.Interaction, link: str): ) return - r = select(ClashOfCodeGuildGame).where(Guild.id == interaction.guild_id) + r = select(ClashOfCodeGuildGame).where(ClashOfCodeGuildGame.guild_id == interaction.guild.id) guild_game: ClashOfCodeGuildGame = session.scalar(r) if guild_game and not guild_game.can_start_new(): await interaction.response.send_message( - lang.get("clash_of_code.already_in_progress", interaction.guild), + lang_file.get("coc_already_in_progress", interaction.guild), ephemeral=True ) return @@ -64,6 +65,10 @@ async def coc(interaction: discord.Interaction, link: str): r = select(Guild).where(Guild.id == interaction.guild_id) guild: Guild = session.scalar(r) + if not guild: + logger.debug(f"Add guild {interaction.guild_id} to the database") + guild = Guild(id=interaction.guild_id, name=interaction.guild.name) + session.add(guild) if guild.coc_role is None: await interaction.response.send_message( @@ -102,26 +107,40 @@ async def coc(interaction: discord.Interaction, link: str): emoji="🎮" )) - await channel.send( + embed = game.embed(lang_file, interaction.guild) + embed.set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) + + announcement_message = await channel.send( content=f"{role.mention} {lang_file.get('coc_game_invite', interaction.guild)}", - embed=game.embed(lang_file, interaction.guild), + embed=embed, view=view ) guild_game.last_clash_id = game_id - - try: - session.commit() - except: - session.rollback() - - logger.error(traceback.format_exc()) - return await interaction.response.send_message(lang_file.get("unexpected_error", interaction.guild), ephemeral=True) - pass - + guild_game.start_new(game_id, announcement_message.id) await interaction.response.send_message( lang_file.get("coc_successfully_invited", interaction.guild), ephemeral=True ) + async def edit(): + logger.debug(f"Editing announcement message for guild {interaction.guild_id}") + + clash_of_code_helper.update(game) + + edited_embed = game.embed(lang_file, interaction.guild) + edited_embed.set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) + + await announcement_message.edit( + content=f"{role.mention} {lang_file.get('coc_game_invite', interaction.guild)}", + embed=edited_embed, + view=view + ) + + if game.finished: + return schedule.CancelJob + + scheduler = AsyncIOScheduler() + scheduler.add_job(edit, trigger=IntervalTrigger(seconds=30)) + scheduler.start() \ No newline at end of file diff --git a/vesta/data/lang.yml b/vesta/data/lang.yml index c826758..51ca5a8 100644 --- a/vesta/data/lang.yml +++ b/vesta/data/lang.yml @@ -1,4 +1,6 @@ fr: + general_yes: "🟩 Oui" + general_no: "🟥 Non" invalid_link: Le lien n'est pas valide invalid_image_link: Le lien de votre image n'est pas valide command_created: La commande a bien été créée ! @@ -99,14 +101,19 @@ fr: n'a pas été trouvé ! Prévenez un administrateur ! coc_started: "Commencé :" coc_finished: "Terminé :" - coc_game_title: Nouvelle partie de Clash of Code ! + coc_game_title: ⭐ Nouvelle partie de Clash of Code ! coc_game_invite: Une nouvelle partie de Clash of Code a été lancée ! coc_game_join: Rejoindre la partie - coc_game_modes: Modes de jeu possibles - coc_game_mode: Mode de jeu - coc_game_players: Joueurs - coc_game_languages: Langages autorisés + coc_game_modes: 🎮 Modes de jeu possibles + coc_game_mode: 🎮 Mode de jeu + coc_game_players: 🧩 Joueurs + coc_game_languages: ⌨️ Langages autorisés + coc_mode_fastest: ⚡ Le plus rapide + coc_mode_shortest: 📏 Le plus court + coc_mode_reverse: 🔄 Inversé en: + general_yes: "🟩 Yes" + general_no: "🟥 No" invalid_link: The link is not valid invalid_image_link: The link of the image is not valid command_created: Command successfully created @@ -200,10 +207,13 @@ en: The configured channel where to send the invitations for the Clash of Code games was not found ! Please warn an administrator ! coc_started: "Started:" coc_finished: "Finished:" - coc_game_title: New Clash of Code game ! + coc_game_title: ⭐ New Clash of Code game ! coc_game_invite: A new Clash of Code game was started ! coc_game_join: Join the game - coc_game_modes: Possible game modes - coc_game_mode: Game mode - coc_game_players: Players - coc_game_languages: Allowed languages + coc_game_modes: 🎮 Possible game modes + coc_game_mode: 🎮 Game mode + coc_game_players: 🧩 Players + coc_game_languages: ⌨️ Allowed languages + coc_mode_fastest: ⚡ Fastest + coc_mode_shortest: 📏 Shortest + coc_mode_reverse: 🔄 Reverse diff --git a/vesta/lang.py b/vesta/lang.py index a3eb3dd..4c9cc1c 100644 --- a/vesta/lang.py +++ b/vesta/lang.py @@ -30,4 +30,4 @@ def get(self, item: str, guild: discord.Guild): return self.data[lang][item] logger.error(f"Element {item} not found : guild {guild}, lang {lang}") - return "Could not load" + return f"Could not load <{item}>" diff --git a/vesta/services/clash_of_code_entities.py b/vesta/services/clash_of_code_entities.py index b1a6fb3..8ccfd07 100644 --- a/vesta/services/clash_of_code_entities.py +++ b/vesta/services/clash_of_code_entities.py @@ -1,3 +1,4 @@ +import datetime from dataclasses import dataclass from enum import Enum from typing import List, Optional @@ -35,6 +36,7 @@ class ClashOfCodePlayer: name: str role: Role + rank: int def __init__(self, **kwargs): """ @@ -48,6 +50,7 @@ def __init__(self, **kwargs): def hydrate(self, *, codingamerNickname: str, status: str, + rank: Optional[int], **ignored) -> "ClashOfCodePlayer": """ Hydrates the object with the given data @@ -58,6 +61,7 @@ def hydrate(self, *, """ self.name = codingamerNickname self.role = Role[status.upper()] or Role.STANDARD + self.rank = rank return self @@ -76,6 +80,9 @@ class ClashOfCodeGame: modes: List[GameMode] mode: Optional[str] + start_time: datetime.datetime + end_time: Optional[datetime.datetime] + def __init__(self, **kwargs): """ Initializes the object with the given data @@ -93,6 +100,8 @@ def hydrate(self, *, programmingLanguages: List[str], modes: List[str], mode: Optional[str] = None, + startTime: str, + endTime: Optional[str] = None, **ignored) -> "ClashOfCodeGame": """ Hydrates the object with the given data @@ -121,43 +130,55 @@ def hydrate(self, *, ] self.mode = mode + self.start_time = datetime.datetime.strptime(startTime, "%B %d, %Y, %I:%M:%S %p") + self.end_time = datetime.datetime.strptime(endTime, "%B %d, %Y, %I:%M:%S %p") if endTime else None + return self def embed(self, lang_file, guild: discord.Guild): emojis = { - 'True': '🟢', - 'False': '🔴' + "True": lang_file.get("general_yes", guild), + "False": lang_file.get("general_no", guild) } - description = f""" - **{lang_file.get("coc_started", guild)}** {emojis[str(self.started)]} - **{lang_file.get("coc_finished", guild)}** {emojis[str(self.finished)]} - """ - embed = Embed( title=lang_file.get("coc_game_title", guild), - url=self.link, - description=description + color=discord.Color.blurple(), ) + embed.add_field(name=lang_file.get("coc_started", guild), + value=emojis[str(self.started)], + inline=True) + embed.add_field(name=lang_file.get("coc_finished", guild), + value=emojis[str(self.finished)], + inline=True) + if not self.mode: embed.add_field(name=lang_file.get("coc_game_modes", guild), - value=f"`{'`, `'.join([mode.name.lower() for mode in self.modes])}`", + value=' - ' + "\n - ".join( + [lang_file.get(f"coc_mode_{mode.name.lower()}", guild) for mode in self.modes]), inline=False) else: embed.add_field(name=lang_file.get("coc_game_mode", guild), value=f"`{self.mode.lower()}`", inline=False) - embed.add_field(name=lang_file.get("coc_game_players", guild), - value=f"`{'`, `'.join([player.name for player in self.players])}`", - inline=False) + if not self.finished: + embed.add_field(name=lang_file.get("coc_game_players", guild), + value=f"`{'`, `'.join([player.name for player in self.players])}`", + inline=False) - languages = f"`{'`, `'.join(self.programming_language)}`" \ - if len(self.programming_language) >= 1 \ - else lang_file.get("coc_all_languages", guild) + languages = f"`{'`, `'.join(self.programming_language)}`" \ + if len(self.programming_language) >= 1 \ + else lang_file.get("coc_all_languages", guild) - embed.add_field(name=lang_file.get("coc_game_languages", guild), - value=languages) + embed.add_field(name=lang_file.get("coc_game_languages", guild), + value=languages) + else: + # winner is player with the best "rank" field + winner = sorted(self.players, key=lambda player: player.rank)[0] + embed.add_field(name=lang_file.get("coc_game_winner", guild), + value=f"`{winner.name}`", + inline=False) return embed diff --git a/vesta/tables/clash_of_code.py b/vesta/tables/clash_of_code.py index 618fdfa..e4ec947 100644 --- a/vesta/tables/clash_of_code.py +++ b/vesta/tables/clash_of_code.py @@ -5,17 +5,16 @@ from . import Base from .. import session_maker -from ..services import ClashOfCodeGame +from ..services import ClashOfCodeGame, clash_of_code_helper session = session_maker() class ClashOfCodeGuildGame(Base): __tablename__ = "clash_of_code_guild_game" - guild_id = db.Column(db.BigInteger, nullable=False) - last_clash_id = db.Column(db.String(32), nullable=True) - - db.PrimaryKeyConstraint(guild_id) + guild_id = db.Column(db.BigInteger, nullable=False, primary_key=True) + last_clash_id = db.Column(db.String(511), nullable=True) + announcement_message_id = db.Column(db.BigInteger, nullable=True) def fetch(self) -> Optional[ClashOfCodeGame]: """ @@ -24,9 +23,7 @@ def fetch(self) -> Optional[ClashOfCodeGame]: :return: The latest clash of code game """ - helper = ClashOfCodeHelper() - - return helper.fetch(self.last_clash_id) + return clash_of_code_helper.fetch(self.last_clash_id) def can_start_new(self) -> bool: """ @@ -49,16 +46,19 @@ def forget(self) -> None: :return: None """ self.last_clash_id = None + self.announcement_message_id = None session.commit() - def start_new(self, clash_id: int) -> None: + def start_new(self, clash_id: str, message_id: int) -> None: """ Starts a new clash of code game :param clash_id: The clash of code game id + :param message_id: :return: None """ self.last_clash_id = clash_id + self.announcement_message_id = message_id session.commit() class ClashOfCodeRanking(Base): From 3df4875bc5fdc506852d13f85f1f02cedf37a436 Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Sat, 20 May 2023 00:24:09 +0200 Subject: [PATCH 10/13] Fixed the bugs, working loop and resuming correctly, and fixed some style issues, still need some work to get clean code =D --- requirements.txt | 3 +- vesta/client.py | 3 + vesta/commands/clash_of_code.py | 79 ++++++--------------- vesta/data/lang.yml | 30 ++++---- vesta/services/__init__.py | 2 +- vesta/services/clash_of_code_entities.py | 52 +++++++------- vesta/services/clash_of_code_helper.py | 88 +++++++++++++++++++++++- vesta/tables/clash_of_code.py | 4 +- 8 files changed, 158 insertions(+), 103 deletions(-) diff --git a/requirements.txt b/requirements.txt index 683b5be..5273904 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,4 @@ psycopg2-binary==2.9.3 python-dotenv==0.20.0 PyYAML==6.0 SQLAlchemy==1.4.37 -requests==2.30.0 -APScheduler==3.10.1 \ No newline at end of file +requests==2.30.0 \ No newline at end of file diff --git a/vesta/client.py b/vesta/client.py index 667ba11..cf8e1cf 100644 --- a/vesta/client.py +++ b/vesta/client.py @@ -6,6 +6,7 @@ from discord import app_commands from . import session_maker +from .services import clash_of_code_helper from .tables import CustomCommand, select logger = logging.getLogger(__name__) @@ -27,6 +28,8 @@ def __init__(self, *, intents: discord.Intents): async def on_ready(self): logger.info(f"Logged on as {self.user}") + clash_of_code_helper.resume_update_loops() + for com in self.tree.get_commands(): logger.debug(f"Globals {com} name : {com.name}") diff --git a/vesta/commands/clash_of_code.py b/vesta/commands/clash_of_code.py index f7d07dd..3382f49 100644 --- a/vesta/commands/clash_of_code.py +++ b/vesta/commands/clash_of_code.py @@ -2,13 +2,12 @@ import re import discord -from apscheduler.triggers.interval import IntervalTrigger from discord import app_commands -from apscheduler.schedulers.asyncio import AsyncIOScheduler from sqlalchemy import select from .. import vesta_client, session_maker, lang_file -from ..services import clash_of_code_helper +from ..services import clash_of_code_helper, State +from ..services.clash_of_code_helper import start_update_loop from ..tables import ClashOfCodeGuildGame from ..tables import Guild @@ -22,37 +21,25 @@ @app_commands.describe(link="The link to the Clash of Code game") async def coc(interaction: discord.Interaction, link: str): if not re.match(regex_clash_of_code_game, link): - await interaction.response.send_message( - lang_file.get("coc_invalid_link", interaction.guild), - ephemeral=True - ) + await _send_error(interaction, "coc_invalid_link") return game_id = clash_of_code_helper.game_id_from_link(link) - game = clash_of_code_helper.fetch(game_id) + if not game: - await interaction.response.send_message( - lang_file.get("coc_invalid_link", interaction.guild), - ephemeral=True - ) + await _send_error(interaction, "coc_invalid_link") return - if game.started or game.finished: - await interaction.response.send_message( - lang_file.get("coc_game_already_started", interaction.guild), - ephemeral=True - ) + if game.state != State.PENDING: + await _send_error(interaction, "coc_game_already_started") return r = select(ClashOfCodeGuildGame).where(ClashOfCodeGuildGame.guild_id == interaction.guild.id) guild_game: ClashOfCodeGuildGame = session.scalar(r) if guild_game and not guild_game.can_start_new(): - await interaction.response.send_message( - lang_file.get("coc_already_in_progress", interaction.guild), - ephemeral=True - ) + await _send_error(interaction, "coc_already_in_progress") return if not guild_game: @@ -60,9 +47,6 @@ async def coc(interaction: discord.Interaction, link: str): guild_game = ClashOfCodeGuildGame(guild_id=interaction.guild_id) session.add(guild_game) - # (ok, guild, role, channel) = await run_checks_for(interaction) - # if not ok: return - r = select(Guild).where(Guild.id == interaction.guild_id) guild: Guild = session.scalar(r) if not guild: @@ -71,33 +55,21 @@ async def coc(interaction: discord.Interaction, link: str): session.add(guild) if guild.coc_role is None: - await interaction.response.send_message( - lang_file.get("coc_role_not_set", interaction.guild), - ephemeral=True - ) + await _send_error(interaction, "coc_role_not_set") return role = interaction.guild.get_role(guild.coc_role) if role is None: - await interaction.response.send_message( - lang_file.get("coc_role_not_found", interaction.guild), - ephemeral=True - ) + await _send_error(interaction, "coc_role_not_found") return if guild.coc_channel is None: - await interaction.response.send_message( - lang_file.get("coc_channel_not_set", interaction.guild), - ephemeral=True - ) + await _send_error(interaction, "coc_channel_not_set") return channel = interaction.guild.get_channel(guild.coc_channel) if channel is None: - await interaction.response.send_message( - lang_file.get("coc_channel_not_found", interaction.guild), - ephemeral=True - ) + await _send_error(interaction, "coc_channel_not_found") return view = discord.ui.View() @@ -117,30 +89,19 @@ async def coc(interaction: discord.Interaction, link: str): ) guild_game.last_clash_id = game_id - guild_game.start_new(game_id, announcement_message.id) + guild_game.announcement_message_id = announcement_message.id + session.commit() await interaction.response.send_message( lang_file.get("coc_successfully_invited", interaction.guild), ephemeral=True ) - async def edit(): - logger.debug(f"Editing announcement message for guild {interaction.guild_id}") - - clash_of_code_helper.update(game) - - edited_embed = game.embed(lang_file, interaction.guild) - edited_embed.set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) + start_update_loop(message=announcement_message, guild=interaction.guild) - await announcement_message.edit( - content=f"{role.mention} {lang_file.get('coc_game_invite', interaction.guild)}", - embed=edited_embed, - view=view - ) - if game.finished: - return schedule.CancelJob - - scheduler = AsyncIOScheduler() - scheduler.add_job(edit, trigger=IntervalTrigger(seconds=30)) - scheduler.start() \ No newline at end of file +async def _send_error(interaction: discord.Interaction, key: str): + await interaction.response.send_message( + "❌ " + lang_file.get(key, interaction.guild), + ephemeral=True + ) diff --git a/vesta/data/lang.yml b/vesta/data/lang.yml index 51ca5a8..aba9f47 100644 --- a/vesta/data/lang.yml +++ b/vesta/data/lang.yml @@ -99,15 +99,18 @@ fr: coc_channel_not_found: > Le configuré salon où envoyer les invitations pour les parties de Clash of Code n'a pas été trouvé ! Prévenez un administrateur ! - coc_started: "Commencé :" - coc_finished: "Terminé :" + coc_game_state: État de la partie + coc_game_state_pending: ⏳ En attente + coc_game_state_running: ⚡ En cours + coc_game_state_finished: 🏁 Terminée coc_game_title: ⭐ Nouvelle partie de Clash of Code ! coc_game_invite: Une nouvelle partie de Clash of Code a été lancée ! coc_game_join: Rejoindre la partie - coc_game_modes: 🎮 Modes de jeu possibles - coc_game_mode: 🎮 Mode de jeu - coc_game_players: 🧩 Joueurs - coc_game_languages: ⌨️ Langages autorisés + coc_game_modes: "Modes de jeu possibles :" + coc_game_mode: "Mode de jeu :" + coc_game_players: "Joueurs :" + coc_game_winner: "Gagnant :" + coc_game_languages: "Langages autorisés :" coc_mode_fastest: ⚡ Le plus rapide coc_mode_shortest: 📏 Le plus court coc_mode_reverse: 🔄 Inversé @@ -205,15 +208,18 @@ en: The channel where to send the invitations for the Clash of Code games was not configured on this server ! Please warn an administrator ! coc_channel_not_found: > The configured channel where to send the invitations for the Clash of Code games was not found ! Please warn an administrator ! - coc_started: "Started:" - coc_finished: "Finished:" + coc_game_state: "Game state:" + coc_game_state_pending: ⏳ Pending + coc_game_state_running: ⚡ Running + coc_game_state_finished: 🏁 Finished coc_game_title: ⭐ New Clash of Code game ! coc_game_invite: A new Clash of Code game was started ! coc_game_join: Join the game - coc_game_modes: 🎮 Possible game modes - coc_game_mode: 🎮 Game mode - coc_game_players: 🧩 Players - coc_game_languages: ⌨️ Allowed languages + coc_game_modes: "Possible game modes:" + coc_game_mode: "Game mode:" + coc_game_players: "Players:" + coc_game_winner: "Winner:" + coc_game_languages: "Allowed languages:" coc_mode_fastest: ⚡ Fastest coc_mode_shortest: 📏 Shortest coc_mode_reverse: 🔄 Reverse diff --git a/vesta/services/__init__.py b/vesta/services/__init__.py index f4916be..6e3bffd 100644 --- a/vesta/services/__init__.py +++ b/vesta/services/__init__.py @@ -1,2 +1,2 @@ -from .clash_of_code_entities import ClashOfCodeGame, ClashOfCodePlayer, GameMode, Role +from .clash_of_code_entities import ClashOfCodeGame, ClashOfCodePlayer, GameMode, Role, State from . import clash_of_code_helper \ No newline at end of file diff --git a/vesta/services/clash_of_code_entities.py b/vesta/services/clash_of_code_entities.py index 8ccfd07..58e0f08 100644 --- a/vesta/services/clash_of_code_entities.py +++ b/vesta/services/clash_of_code_entities.py @@ -18,6 +18,8 @@ class GameMode(Enum): REVERSE = 1 SHORTEST = 2 + def __repr__(self): + return f"coc_mode_{self.name.lower()}" class Role(Enum): """ @@ -27,6 +29,14 @@ class Role(Enum): OWNER = 0 STANDARD = 1 +class State(Enum): + """ + Represents a Clash of Code game state + """ + + PENDING = 0 + RUNNING = 1 + FINISHED = 2 @dataclass class ClashOfCodePlayer: @@ -73,12 +83,11 @@ class ClashOfCodeGame: """ link: str - started: bool - finished: bool + state: State players: List[ClashOfCodePlayer] programming_language: List[str] modes: List[GameMode] - mode: Optional[str] + mode: Optional[GameMode] start_time: datetime.datetime end_time: Optional[datetime.datetime] @@ -92,6 +101,9 @@ def __init__(self, **kwargs): self.hydrate(**kwargs) + @property + def id(self): return self.link.split("/")[-1] + def hydrate(self, *, publicHandle: str, started: bool, @@ -117,8 +129,7 @@ def hydrate(self, *, """ self.link = f"https://www.codingame.com/clashofcode/clash/{publicHandle}" - self.started = started - self.finished = finished + self.state = State.FINISHED if finished else State.RUNNING if started else State.PENDING self.players = [ ClashOfCodePlayer(**player) for player in players @@ -128,7 +139,7 @@ def hydrate(self, *, GameMode[mode.upper()] for mode in modes ] - self.mode = mode + self.mode = GameMode[mode.upper()] self.start_time = datetime.datetime.strptime(startTime, "%B %d, %Y, %I:%M:%S %p") self.end_time = datetime.datetime.strptime(endTime, "%B %d, %Y, %I:%M:%S %p") if endTime else None @@ -136,34 +147,31 @@ def hydrate(self, *, return self def embed(self, lang_file, guild: discord.Guild): - emojis = { - "True": lang_file.get("general_yes", guild), - "False": lang_file.get("general_no", guild) - } - embed = Embed( title=lang_file.get("coc_game_title", guild), color=discord.Color.blurple(), ) - embed.add_field(name=lang_file.get("coc_started", guild), - value=emojis[str(self.started)], - inline=True) - embed.add_field(name=lang_file.get("coc_finished", guild), - value=emojis[str(self.finished)], + embed.add_field(name=lang_file.get("coc_game_state", guild), + value=lang_file.get(f"coc_game_state_{self.state.name.lower()}", guild), inline=True) if not self.mode: embed.add_field(name=lang_file.get("coc_game_modes", guild), value=' - ' + "\n - ".join( - [lang_file.get(f"coc_mode_{mode.name.lower()}", guild) for mode in self.modes]), + [lang_file.get(repr(self.mode), guild) for mode in self.modes]), inline=False) else: embed.add_field(name=lang_file.get("coc_game_mode", guild), - value=f"`{self.mode.lower()}`", + value=lang_file.get(repr(self.mode), guild), inline=False) - if not self.finished: + if self.state == State.FINISHED: + winner = sorted(self.players, key=lambda player: player.rank)[0] + embed.add_field(name=lang_file.get("coc_game_winner", guild), + value=f"`{winner.name}`", + inline=False) + else: embed.add_field(name=lang_file.get("coc_game_players", guild), value=f"`{'`, `'.join([player.name for player in self.players])}`", inline=False) @@ -174,11 +182,5 @@ def embed(self, lang_file, guild: discord.Guild): embed.add_field(name=lang_file.get("coc_game_languages", guild), value=languages) - else: - # winner is player with the best "rank" field - winner = sorted(self.players, key=lambda player: player.rank)[0] - embed.add_field(name=lang_file.get("coc_game_winner", guild), - value=f"`{winner.name}`", - inline=False) return embed diff --git a/vesta/services/clash_of_code_helper.py b/vesta/services/clash_of_code_helper.py index 6aea22d..444f7c5 100644 --- a/vesta/services/clash_of_code_helper.py +++ b/vesta/services/clash_of_code_helper.py @@ -1,10 +1,16 @@ +import asyncio +import logging +import uuid from typing import Optional +import discord import requests +from sqlalchemy import select -from . import ClashOfCodeGame +from . import ClashOfCodeGame, State BASE_ENDPOINT = "https://www.codingame.com/services/ClashOfCode" +logger = logging.getLogger(__name__) def game_id_from_link(link: str) -> str: return link.split("/")[-1] @@ -34,7 +40,7 @@ def update(game: ClashOfCodeGame) -> ClashOfCodeGame: :raises NameError: If there was an error updating the game object """ r = requests.post(f"{BASE_ENDPOINT}/findClashByHandle", json=[ - game_id_from_link(game.link) + game.id ]) if r.status_code != 200: @@ -42,3 +48,81 @@ def update(game: ClashOfCodeGame) -> ClashOfCodeGame: game.hydrate(**r.json()) return game + +def resume_update_loops() -> None: + """ + Resumes all update loops for all guilds + + :return: Nothing + """ + from .. import session_maker, vesta_client + from ..tables import ClashOfCodeGuildGame, Guild + session = session_maker() + + logger.debug(f"Resuming update loops for all guilds") + + r = select(ClashOfCodeGuildGame) + guild_games: list[ClashOfCodeGuildGame] = session.execute(r).scalars().all() + + for guild_game in guild_games: + guild = vesta_client.get_guild(guild_game.guild_id) + + if not guild: + continue + + r = select(Guild).where(Guild.id == guild.id) + guild_table: Guild = session.scalar(r) + + if not guild_table: + continue + + announcement_channel = guild.get_channel(guild_table.coc_channel) + if not announcement_channel: + continue + + message = announcement_channel.get_partial_message(guild_game.announcement_message_id) + if not message: + continue + + start_update_loop(message, guild) + +def start_update_loop(message: discord.Message, guild: discord.Guild) -> None: + """ + Starts an update loop for the given message and guild to + always match the latest information from the API and the + message content + + :param message: The message to update + :param guild: The guild to update the data for + :return: Nothing + """ + from .. import session_maker, lang_file + from ..tables import ClashOfCodeGuildGame + session = session_maker() + + uid = str(uuid.uuid4())[0:5] + + logger.debug(f"[{uid}] Starting update loop for {message.id} in {guild.id}") + + r = select(ClashOfCodeGuildGame).where(ClashOfCodeGuildGame.guild_id == guild.id) + guild_game: ClashOfCodeGuildGame = session.scalar(r) + fetched_game: ClashOfCodeGame = guild_game.fetch() + + # Start a cron that runs every 15 seconds asynchronously + # This will update the message with the latest information + # about the game + async def update_loop(game: ClashOfCodeGame): + while True: + game = update(game) + await message.edit(embed=game.embed(lang_file, guild)) + + if game.state == State.FINISHED: + logger.debug(f"[{uid}] Game {game.id} has finished, deleting from database") + + session.delete(guild_game) + session.commit() + break + + await asyncio.sleep(10) + + asyncio.create_task(update_loop(fetched_game)) \ No newline at end of file diff --git a/vesta/tables/clash_of_code.py b/vesta/tables/clash_of_code.py index e4ec947..239130a 100644 --- a/vesta/tables/clash_of_code.py +++ b/vesta/tables/clash_of_code.py @@ -5,7 +5,7 @@ from . import Base from .. import session_maker -from ..services import ClashOfCodeGame, clash_of_code_helper +from ..services import ClashOfCodeGame, clash_of_code_helper, State session = session_maker() @@ -35,7 +35,7 @@ def can_start_new(self) -> bool: return True entity = self.fetch() - return entity is None or entity.finished + return entity is None or entity.state == State.FINISHED def __repr__(self): return f"Clash of Code Guild Games (guild_id={self.guild_id}, last_clash_id={self.last_clash_id})" From b91c8aee6d8bb2a12163e13da0e87f8a4b41961a Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Sat, 20 May 2023 00:38:48 +0200 Subject: [PATCH 11/13] Removed the ranking as it is not used yet, and the unused methods --- vesta/tables/__init__.py | 3 +- vesta/tables/clash_of_code.py | 64 ----------------------------------- 2 files changed, 1 insertion(+), 66 deletions(-) diff --git a/vesta/tables/__init__.py b/vesta/tables/__init__.py index d16f3e7..4387aa9 100644 --- a/vesta/tables/__init__.py +++ b/vesta/tables/__init__.py @@ -8,5 +8,4 @@ from .custom_commands import CustomCommand from .guilds import Guild from .bans import Ban -from .clash_of_code import ClashOfCodeRanking,\ - ClashOfCodeGuildGame \ No newline at end of file +from .clash_of_code import ClashOfCodeGuildGame \ No newline at end of file diff --git a/vesta/tables/clash_of_code.py b/vesta/tables/clash_of_code.py index 239130a..4e8a592 100644 --- a/vesta/tables/clash_of_code.py +++ b/vesta/tables/clash_of_code.py @@ -16,69 +16,5 @@ class ClashOfCodeGuildGame(Base): last_clash_id = db.Column(db.String(511), nullable=True) announcement_message_id = db.Column(db.BigInteger, nullable=True) - def fetch(self) -> Optional[ClashOfCodeGame]: - """ - Fetches the latest clash of code game - - :return: The latest clash of code game - """ - - return clash_of_code_helper.fetch(self.last_clash_id) - - def can_start_new(self) -> bool: - """ - Checks whether a new clash of code game can be started - - :return: Whether a new clash of code game can be started - """ - if self.last_clash_id is None: - return True - - entity = self.fetch() - return entity is None or entity.state == State.FINISHED - def __repr__(self): return f"Clash of Code Guild Games (guild_id={self.guild_id}, last_clash_id={self.last_clash_id})" - - def forget(self) -> None: - """ - Forgets the last clash of code game - :return: None - """ - self.last_clash_id = None - self.announcement_message_id = None - session.commit() - - def start_new(self, clash_id: str, message_id: int) -> None: - """ - Starts a new clash of code game - - :param clash_id: The clash of code game id - :param message_id: - :return: None - """ - self.last_clash_id = clash_id - self.announcement_message_id = message_id - session.commit() - -class ClashOfCodeRanking(Base): - __tablename__ = "clash_of_code_ranking" - - guild_id = db.Column(db.BigInteger, nullable=False) - times_won = db.Column(db.Integer, nullable=False) - - user_id = db.Column(db.BigInteger, db.ForeignKey("user.id"), nullable=False) - user = relationship("User") - - db.PrimaryKeyConstraint(guild_id, user_id) - - def add_win(self) -> None: - """ - Adds a win to the user's ranking and commits the change to the database - :return: None - """ - self.times_won += 1 - session.commit() - - def __repr__(self): - return f"Clash of Code Ranking (guild_id={self.guild_id}, user_id={self.user_id}, times_won={self.times_won})" \ No newline at end of file From da1fd32ba40fbce3879618dd6776f1bce298122b Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Sat, 20 May 2023 00:39:37 +0200 Subject: [PATCH 12/13] Clarified messages, added some doc, added some more consistency --- vesta/commands/clash_of_code.py | 2 +- vesta/data/lang.yml | 2 +- vesta/services/clash_of_code_entities.py | 28 ++++++++++++++++++------ vesta/services/clash_of_code_helper.py | 13 ++++++----- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/vesta/commands/clash_of_code.py b/vesta/commands/clash_of_code.py index 3382f49..797dffe 100644 --- a/vesta/commands/clash_of_code.py +++ b/vesta/commands/clash_of_code.py @@ -79,7 +79,7 @@ async def coc(interaction: discord.Interaction, link: str): emoji="🎮" )) - embed = game.embed(lang_file, interaction.guild) + embed = game.embed(interaction.guild) embed.set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) announcement_message = await channel.send( diff --git a/vesta/data/lang.yml b/vesta/data/lang.yml index aba9f47..3fb0bfd 100644 --- a/vesta/data/lang.yml +++ b/vesta/data/lang.yml @@ -99,7 +99,7 @@ fr: coc_channel_not_found: > Le configuré salon où envoyer les invitations pour les parties de Clash of Code n'a pas été trouvé ! Prévenez un administrateur ! - coc_game_state: État de la partie + coc_game_state: "État de la partie :" coc_game_state_pending: ⏳ En attente coc_game_state_running: ⚡ En cours coc_game_state_finished: 🏁 Terminée diff --git a/vesta/services/clash_of_code_entities.py b/vesta/services/clash_of_code_entities.py index 58e0f08..70644b3 100644 --- a/vesta/services/clash_of_code_entities.py +++ b/vesta/services/clash_of_code_entities.py @@ -38,6 +38,12 @@ class State(Enum): RUNNING = 1 FINISHED = 2 + def __repr__(self): + """ + Gives the translation key for the state + """ + return f"coc_game_state_{self.name.lower()}" + @dataclass class ClashOfCodePlayer: """ @@ -139,38 +145,46 @@ def hydrate(self, *, GameMode[mode.upper()] for mode in modes ] - self.mode = GameMode[mode.upper()] + self.mode = GameMode[mode.upper()] if mode else None self.start_time = datetime.datetime.strptime(startTime, "%B %d, %Y, %I:%M:%S %p") self.end_time = datetime.datetime.strptime(endTime, "%B %d, %Y, %I:%M:%S %p") if endTime else None return self - def embed(self, lang_file, guild: discord.Guild): + def embed(self, guild: discord.Guild): + """ + Builds a legible embed to display on discord + + :param guild: + :return: + """ + from .. import lang_file + embed = Embed( title=lang_file.get("coc_game_title", guild), color=discord.Color.blurple(), ) embed.add_field(name=lang_file.get("coc_game_state", guild), - value=lang_file.get(f"coc_game_state_{self.state.name.lower()}", guild), + value=lang_file.get(repr(self.state), guild), inline=True) if not self.mode: embed.add_field(name=lang_file.get("coc_game_modes", guild), value=' - ' + "\n - ".join( - [lang_file.get(repr(self.mode), guild) for mode in self.modes]), + [lang_file.get(repr(mode), guild) for mode in self.modes]), inline=False) else: embed.add_field(name=lang_file.get("coc_game_mode", guild), value=lang_file.get(repr(self.mode), guild), - inline=False) + inline=True) if self.state == State.FINISHED: winner = sorted(self.players, key=lambda player: player.rank)[0] embed.add_field(name=lang_file.get("coc_game_winner", guild), - value=f"`{winner.name}`", - inline=False) + value=f"🏆 {winner.name}", + inline=True) else: embed.add_field(name=lang_file.get("coc_game_players", guild), value=f"`{'`, `'.join([player.name for player in self.players])}`", diff --git a/vesta/services/clash_of_code_helper.py b/vesta/services/clash_of_code_helper.py index 444f7c5..c1541ff 100644 --- a/vesta/services/clash_of_code_helper.py +++ b/vesta/services/clash_of_code_helper.py @@ -108,13 +108,16 @@ def start_update_loop(message: discord.Message, guild: discord.Guild) -> None: guild_game: ClashOfCodeGuildGame = session.scalar(r) fetched_game: ClashOfCodeGame = guild_game.fetch() - # Start a cron that runs every 15 seconds asynchronously - # This will update the message with the latest information - # about the game - async def update_loop(game: ClashOfCodeGame): + async def update_loop(game: ClashOfCodeGame) -> None: + """ + The update loop that runs every 10 seconds to update the message + + :param game: The game object to update + :return: Nothing + """ while True: game = update(game) - await message.edit(embed=game.embed(lang_file, guild)) + await message.edit(embed=game.embed(guild)) if game.state == State.FINISHED: logger.debug(f"[{uid}] Game {game.id} has finished, deleting from database") From 065b1dba6e9cc1c3f9f0a77d0391c7a5042b481f Mon Sep 17 00:00:00 2001 From: Tom BUTIN Date: Sat, 20 May 2023 01:48:51 +0200 Subject: [PATCH 13/13] Fixed all the bugs, added some more information on the embed, cleaned up mess and globally made something clean. => Ready for review --- vesta/commands/clash_of_code.py | 117 +++++++++++++---------- vesta/exceptions/__init__.py | 1 + vesta/exceptions/command_exceptions.py | 14 +++ vesta/exceptions/web_exceptions.py | 0 vesta/services/clash_of_code_entities.py | 19 +++- vesta/services/clash_of_code_helper.py | 5 +- vesta/tables/clash_of_code.py | 12 ++- 7 files changed, 113 insertions(+), 55 deletions(-) create mode 100644 vesta/exceptions/command_exceptions.py delete mode 100644 vesta/exceptions/web_exceptions.py diff --git a/vesta/commands/clash_of_code.py b/vesta/commands/clash_of_code.py index 797dffe..b1b9c90 100644 --- a/vesta/commands/clash_of_code.py +++ b/vesta/commands/clash_of_code.py @@ -1,11 +1,13 @@ import logging import re +from typing import Tuple import discord from discord import app_commands from sqlalchemy import select from .. import vesta_client, session_maker, lang_file +from ..exceptions import CommandRuntimeError from ..services import clash_of_code_helper, State from ..services.clash_of_code_helper import start_update_loop from ..tables import ClashOfCodeGuildGame @@ -19,89 +21,108 @@ @vesta_client.tree.command(name="clash-of-code", description="Invites users with the \"Clash of Code\" role to play") @app_commands.describe(link="The link to the Clash of Code game") -async def coc(interaction: discord.Interaction, link: str): +async def clash_of_code(interaction: discord.Interaction, link: str): if not re.match(regex_clash_of_code_game, link): await _send_error(interaction, "coc_invalid_link") return + try: + (game, game_id) = _get_game(link) + guild_game = _get_guild_game(interaction) + (guild, role, channel) = _get_guild(interaction) + except CommandRuntimeError as e: + await _send_error(interaction, e.message) + return + + view = discord.ui.View() + view.add_item(discord.ui.Button( + label=lang_file.get("coc_game_join", interaction.guild), + url=game.link, + emoji="🎮" + )) + + embed = game.embed(interaction.guild) + embed.set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) + + announcement_message = await channel.send( + content=f"{role.mention} {lang_file.get('coc_game_invite', interaction.guild)}", + embed=embed, + view=view + ) + + guild_game.last_clash_id = game_id + guild_game.announcement_message_id = announcement_message.id + session.commit() + + await interaction.response.send_message( + lang_file.get("coc_successfully_invited", interaction.guild), + ephemeral=True + ) + + start_update_loop(message=announcement_message, guild=interaction.guild) + + +def _get_game(link: str): game_id = clash_of_code_helper.game_id_from_link(link) game = clash_of_code_helper.fetch(game_id) if not game: - await _send_error(interaction, "coc_invalid_link") - return - + raise CommandRuntimeError("coc_invalid_link") if game.state != State.PENDING: - await _send_error(interaction, "coc_game_already_started") - return + raise CommandRuntimeError("coc_game_already_started") + + return game, game_id + +def _get_guild_game(interaction: discord.Interaction) -> ClashOfCodeGuildGame: r = select(ClashOfCodeGuildGame).where(ClashOfCodeGuildGame.guild_id == interaction.guild.id) guild_game: ClashOfCodeGuildGame = session.scalar(r) if guild_game and not guild_game.can_start_new(): - await _send_error(interaction, "coc_already_in_progress") - return + raise CommandRuntimeError("coc_already_in_progress") if not guild_game: logger.debug(f"Creating new guild game for guild {interaction.guild_id}") guild_game = ClashOfCodeGuildGame(guild_id=interaction.guild_id) session.add(guild_game) + return guild_game + + +def _get_guild(interaction: discord.Interaction) -> Tuple[Guild, discord.Role, discord.TextChannel]: r = select(Guild).where(Guild.id == interaction.guild_id) guild: Guild = session.scalar(r) + if not guild: logger.debug(f"Add guild {interaction.guild_id} to the database") guild = Guild(id=interaction.guild_id, name=interaction.guild.name) session.add(guild) - if guild.coc_role is None: - await _send_error(interaction, "coc_role_not_set") - return + return guild, _get_role(guild, interaction), _get_channel(guild, interaction) - role = interaction.guild.get_role(guild.coc_role) - if role is None: - await _send_error(interaction, "coc_role_not_found") - return - - if guild.coc_channel is None: - await _send_error(interaction, "coc_channel_not_set") - return - channel = interaction.guild.get_channel(guild.coc_channel) - if channel is None: - await _send_error(interaction, "coc_channel_not_found") - return +def _get_role(guild: Guild, interaction: discord.Interaction) -> discord.Role: + if not guild.coc_role: + raise CommandRuntimeError("coc_role_not_set") - view = discord.ui.View() - view.add_item(discord.ui.Button( - label=lang_file.get("coc_game_join", interaction.guild), - url=game.link, - emoji="🎮" - )) + role = interaction.guild.get_role(guild.coc_role) + if not role: + raise CommandRuntimeError("coc_role_not_found") - embed = game.embed(interaction.guild) - embed.set_author(name=interaction.user.name, icon_url=interaction.user.avatar.url) + return role - announcement_message = await channel.send( - content=f"{role.mention} {lang_file.get('coc_game_invite', interaction.guild)}", - embed=embed, - view=view - ) - guild_game.last_clash_id = game_id - guild_game.announcement_message_id = announcement_message.id - session.commit() +def _get_channel(guild: Guild, interaction: discord.Interaction) -> discord.TextChannel: + if not guild.coc_channel: + raise CommandRuntimeError("coc_channel_not_set") - await interaction.response.send_message( - lang_file.get("coc_successfully_invited", interaction.guild), - ephemeral=True - ) + channel = interaction.guild.get_channel(guild.coc_channel) + if not channel: + raise CommandRuntimeError("coc_channel_not_found") - start_update_loop(message=announcement_message, guild=interaction.guild) + return channel async def _send_error(interaction: discord.Interaction, key: str): - await interaction.response.send_message( - "❌ " + lang_file.get(key, interaction.guild), - ephemeral=True - ) + msg = lang_file.get(key, interaction.guild) + await interaction.response.send_message(f"❌ {msg}", ephemeral=True) diff --git a/vesta/exceptions/__init__.py b/vesta/exceptions/__init__.py index e69de29..0370bb6 100644 --- a/vesta/exceptions/__init__.py +++ b/vesta/exceptions/__init__.py @@ -0,0 +1 @@ +from .command_exceptions import CommandRuntimeError \ No newline at end of file diff --git a/vesta/exceptions/command_exceptions.py b/vesta/exceptions/command_exceptions.py new file mode 100644 index 0000000..08861b6 --- /dev/null +++ b/vesta/exceptions/command_exceptions.py @@ -0,0 +1,14 @@ +class CommandRuntimeError(Exception): + """ + Exception raised when a command fails to run. + """ + + message: str + def __init__(self, message: str): + """ + Initializes the exception with the given message. + + :param message: The reason why the command failed to run as a language key + """ + self.message = message + super().__init__(message) \ No newline at end of file diff --git a/vesta/exceptions/web_exceptions.py b/vesta/exceptions/web_exceptions.py deleted file mode 100644 index e69de29..0000000 diff --git a/vesta/services/clash_of_code_entities.py b/vesta/services/clash_of_code_entities.py index 70644b3..d0f46b6 100644 --- a/vesta/services/clash_of_code_entities.py +++ b/vesta/services/clash_of_code_entities.py @@ -6,8 +6,6 @@ import discord from discord import Embed -from vesta.tables import Guild - class GameMode(Enum): """ @@ -44,6 +42,16 @@ def __repr__(self): """ return f"coc_game_state_{self.name.lower()}" + def __str__(self): + """ + Gives the emoji for the state + """ + return { + State.PENDING: "🕒", + State.RUNNING: "🔥", + State.FINISHED: "🏁" + }[self] + @dataclass class ClashOfCodePlayer: """ @@ -53,6 +61,7 @@ class ClashOfCodePlayer: name: str role: Role rank: int + state: State def __init__(self, **kwargs): """ @@ -67,6 +76,7 @@ def hydrate(self, *, codingamerNickname: str, status: str, rank: Optional[int], + testSessionStatus: Optional[str] = None, **ignored) -> "ClashOfCodePlayer": """ Hydrates the object with the given data @@ -78,6 +88,9 @@ def hydrate(self, *, self.name = codingamerNickname self.role = Role[status.upper()] or Role.STANDARD self.rank = rank + self.state = State.PENDING if testSessionStatus is None\ + else State.FINISHED if testSessionStatus == "COMPLETED" \ + else State.RUNNING return self @@ -187,7 +200,7 @@ def embed(self, guild: discord.Guild): inline=True) else: embed.add_field(name=lang_file.get("coc_game_players", guild), - value=f"`{'`, `'.join([player.name for player in self.players])}`", + value=f"`{'`, `'.join([f'{str(player.state)} {player.name}' for player in self.players])}`", inline=False) languages = f"`{'`, `'.join(self.programming_language)}`" \ diff --git a/vesta/services/clash_of_code_helper.py b/vesta/services/clash_of_code_helper.py index c1541ff..3697926 100644 --- a/vesta/services/clash_of_code_helper.py +++ b/vesta/services/clash_of_code_helper.py @@ -5,6 +5,7 @@ import discord import requests +from discord.utils import MISSING from sqlalchemy import select from . import ClashOfCodeGame, State @@ -96,7 +97,7 @@ def start_update_loop(message: discord.Message, guild: discord.Guild) -> None: :param guild: The guild to update the data for :return: Nothing """ - from .. import session_maker, lang_file + from .. import session_maker from ..tables import ClashOfCodeGuildGame session = session_maker() @@ -117,7 +118,7 @@ async def update_loop(game: ClashOfCodeGame) -> None: """ while True: game = update(game) - await message.edit(embed=game.embed(guild)) + await message.edit(embed=game.embed(guild), view=None if game.state == State.FINISHED else MISSING) if game.state == State.FINISHED: logger.debug(f"[{uid}] Game {game.id} has finished, deleting from database") diff --git a/vesta/tables/clash_of_code.py b/vesta/tables/clash_of_code.py index 4e8a592..9ccdace 100644 --- a/vesta/tables/clash_of_code.py +++ b/vesta/tables/clash_of_code.py @@ -1,11 +1,10 @@ from typing import Optional import sqlalchemy as db -from sqlalchemy.orm import relationship from . import Base from .. import session_maker -from ..services import ClashOfCodeGame, clash_of_code_helper, State +from ..services import clash_of_code_helper, ClashOfCodeGame session = session_maker() @@ -16,5 +15,14 @@ class ClashOfCodeGuildGame(Base): last_clash_id = db.Column(db.String(511), nullable=True) announcement_message_id = db.Column(db.BigInteger, nullable=True) + def fetch(self) -> Optional[ClashOfCodeGame]: + """ + Fetches the latest clash of code game + + :return: The latest clash of code game + """ + + return clash_of_code_helper.fetch(self.last_clash_id) + def __repr__(self): return f"Clash of Code Guild Games (guild_id={self.guild_id}, last_clash_id={self.last_clash_id})"