diff --git a/.env.example b/.env.example index 064bd27..3345b85 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,4 @@ HYPIXEL_KEY= HYPIXEL_GUILD_ID= HYPIXEL_GUILD_NAME= HYPIXEL_GUILD_ROLE= +HYPIXEL_RANK_CHANNEL= diff --git a/.github/workflows/docker-image.yaml b/.github/workflows/docker-image.yaml index 2f58c2f..14ec0d3 100644 --- a/.github/workflows/docker-image.yaml +++ b/.github/workflows/docker-image.yaml @@ -22,7 +22,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: push: true build-args: version=v0.0.${{ github.run_number }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c56ac6..94967b9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.4.5 + rev: v0.4.10 hooks: # Run the linter. - id: ruff diff --git a/chouette/__main__.py b/chouette/__main__.py index 07ed4c5..5df9e35 100644 --- a/chouette/__main__.py +++ b/chouette/__main__.py @@ -6,7 +6,8 @@ from chouette.bot import ChouetteBot -def main(): +def main() -> None: + """Fonction principale du bot.""" # Load the .env values if a .env file exists if Path(".env").is_file(): load_dotenv() diff --git a/chouette/bot.py b/chouette/bot.py index e378599..fd182e8 100644 --- a/chouette/bot.py +++ b/chouette/bot.py @@ -10,10 +10,11 @@ from chouette.utils.version import get_version -# Create a class of the bot class ChouetteBot(discord.Client): - # Initialization when class is called - def __init__(self): + """Classe principale du bot ChouetteBot.""" + + def __init__(self) -> None: + """Initialise la classe ChouetteBot.""" # Associate the env variables to the bot self.config = os.environ @@ -64,7 +65,8 @@ def __init__(self): # First declaration to be able to add commands to the guild self.my_guild = discord.Object(int(self.config["GUILD_ID"])) - async def setup_hook(self): + async def setup_hook(self) -> None: + """Initialise le bot.""" # Log the current running version self.bot_logger.info(await get_version()) @@ -75,8 +77,8 @@ async def setup_hook(self): # Start web server await self.start_server() - # Wait until bot is ready - async def on_ready(self): + async def on_ready(self) -> None: + """Fonction appelée lorsque le bot est prêt.""" # Waits until internal cache is ready await self.wait_until_ready() @@ -90,8 +92,8 @@ async def on_ready(self): self.bot_logger.info(f"{self.user} is now online and ready!") self.bot_logger.info(f"Number of servers I'm in : {len(self.guilds)}") - # To react to messages sent in channels bot has access to - async def on_message(self, message: discord.Message): + async def on_message(self, message: discord.Message) -> None: + """Fonction appelée lorsqu'un message est envoyé dans les salons auxquels il a accès.""" # Ignore messages from bots including self if message.author.bot: return @@ -119,12 +121,13 @@ async def on_message(self, message: discord.Message): self.bot_logger.info(f'{self.user} responded to {author}: "{response[0]}"') async def is_team_member_or_owner(self, author: discord.User) -> bool: + """Vérifie si l'auteur est membre de l'équipe ou le propriétaire de l'application.""" if self.application.team: return author.id in [member.id for member in self.application.team.members] return author.id == self.application.owner.id - # Add a basic HTTP server to check if the bot is up - async def start_server(self): + async def start_server(self) -> None: + """Démarre un serveur HTTP pour vérifier si le bot est en ligne.""" # Set a logger for the webserver web_logger = logging.getLogger("web") # Don't want to spam logs with site access @@ -138,13 +141,15 @@ async def start_server(self): } # Remove the Server header and apply the headers - async def _default_headers(req: web.Request, res: web.StreamResponse): + async def _default_headers(req: web.Request, res: web.StreamResponse) -> None: + """Applique les headers par défaut à la réponse.""" if "Server" in res.headers: del res.headers["Server"] res.headers.update(headers) # This is the response - async def handler(req: web.Request): + async def handler(req: web.Request) -> web.Response: + """Réponse du serveur web.""" return web.Response(text=f"{self.user.name} is up") app = web.Application() diff --git a/chouette/commands/admin.py b/chouette/commands/admin.py index e09aa9d..72a34c8 100644 --- a/chouette/commands/admin.py +++ b/chouette/commands/admin.py @@ -9,17 +9,15 @@ from chouette.bot import ChouetteBot -# Check if user is an admin of the bot # Maybe add server admins later -async def is_admin(interaction: discord.Interaction[ChouetteBot]): +async def is_admin(interaction: discord.Interaction[ChouetteBot]) -> bool: + """Vérifie si l'utilisateur est un admin du bot.""" return await interaction.client.is_team_member_or_owner(interaction.user) -# Command to publish a message from admins by the bot @app_commands.check(is_admin) -@app_commands.command(name="whisper", description="Whisper an admin message") -async def whisper(interaction: discord.Interaction[ChouetteBot], message: str): - await interaction.channel.send( - f"{interaction.client.user.name} wants to say this message: {message}" - ) +@app_commands.command(name="whisper", description="Chuchotte un message") +async def whisper(interaction: discord.Interaction[ChouetteBot], message: str) -> None: + """Chcuchotte un message par le bot, utilisable seulement pour les admins du bot.""" + await interaction.channel.send(f"{interaction.client.user.name} veut dire : {message}") await interaction.response.send_message("Commande réussie", ephemeral=True, delete_after=2) diff --git a/chouette/commands/birthdays.py b/chouette/commands/birthdays.py index cf55d5b..944e197 100644 --- a/chouette/commands/birthdays.py +++ b/chouette/commands/birthdays.py @@ -21,15 +21,17 @@ class InvalidBirthdayDate(app_commands.AppCommandError): pass -# Define command group based on the Group class class Birthday(app_commands.Group): - # Set command group name and description - def __init__(self): + """Classe qui permet de gérer les anniversaires""" + + def __init__(self) -> None: + """Initialise la classe Birthday""" super().__init__(name="birthday", description="Commandes pour gérer les anniversaires") async def on_error( self, interaction: Interaction[ChouetteBot], error: app_commands.AppCommandError ) -> None: + """Gère les erreurs lors de l'exécution des commandes.""" if isinstance(error, InvalidBirthdayDate): interaction.client.bot_logger.info( f"{interaction.user} entered an invalid date as his birthday" @@ -38,7 +40,6 @@ async def on_error( "Vous n'avez pas entré une date d'anniversaire valide", ephemeral=True ) - # Make a command to add a birthday @app_commands.command( name="add", description="Permet d'enregistrer son anniversaire", @@ -46,7 +47,8 @@ async def on_error( @app_commands.describe(day="Nombre entier", month="Nombre entier", year="Nombre entier") async def add( self, interaction: Interaction[ChouetteBot], day: int, month: int, year: int | None - ): + ) -> None: + """Ajoute l'anniversaire de l'utilisateur dans la base de données.""" try: birth_date = await check_date(day, month, year) except ValueError as e: @@ -68,7 +70,6 @@ async def add( ephemeral=True, ) - # Make a command to modify the birthday @app_commands.command( name="modify", description="Permet de modifier son anniversaire enregistré", @@ -76,7 +77,8 @@ async def add( @app_commands.describe(day="Nombre entier", month="Nombre entier", year="Nombre entier") async def modify( self, interaction: Interaction[ChouetteBot], day: int, month: int, year: int | None - ): + ) -> None: + """Modifie l'anniversaire de l'utilisateur dans la base de données.""" try: birth_date = await check_date(day, month, year) except ValueError as e: @@ -97,12 +99,12 @@ async def modify( ephemeral=True, ) - # Make a command to remove the birthday @app_commands.command( name="remove", description="Permet de supprimer son anniversaire enregistré", ) - async def remove(self, interaction: Interaction[ChouetteBot]): + async def remove(self, interaction: Interaction[ChouetteBot]) -> None: + """Supprimer l'anniversaire de l'utilisateur de la base de données.""" user_id = str(interaction.user.id) birthdays = await load_birthdays() if user_id in birthdays: @@ -116,12 +118,12 @@ async def remove(self, interaction: Interaction[ChouetteBot]): ephemeral=True, ) - # Make a command to list all birthdays listed in database, sorted by date @app_commands.command( name="list", description="Liste les anniversaires enregistrés", ) - async def list(self, interaction: Interaction[ChouetteBot]): + async def list(self, interaction: Interaction[ChouetteBot]) -> None: + """Liste les anniversaires enregistrés dans la base de données triés par date.""" msg = f"Voici les anniversaires de {interaction.guild.name}\n" birthdays = sorted( (await load_birthdays()).items(), key=lambda x: x[1].get("birthday").replace(4) diff --git a/chouette/commands/misc.py b/chouette/commands/misc.py index 23fef22..5defdd6 100644 --- a/chouette/commands/misc.py +++ b/chouette/commands/misc.py @@ -13,30 +13,34 @@ from chouette.bot import ChouetteBot -# Make a LaTeX command -@app_commands.command(name="latex", description="Render a LaTeX equation") -async def latex(interaction: discord.Interaction[ChouetteBot], equation: str): +@app_commands.command(name="latex", description="Fait le rendu d'une équation LaTeX") +async def latex(interaction: discord.Interaction[ChouetteBot], equation: str) -> None: + """Fait le rendu d'une équation LaTeX et envoie la réponse sous forme d'image.""" await interaction.response.send_message(file=await latex_render(equation)) -# Make the roll command -@app_commands.command(name="roll", description="Roll a die") -async def die_roll(interaction: discord.Interaction[ChouetteBot]): +@app_commands.command(name="roll", description="Lance un dé") +async def die_roll(interaction: discord.Interaction[ChouetteBot]) -> None: + """Lance un dé et affiche le résultat.""" await interaction.response.send_message(f"{random.randint(1, 6)} \N{GAME DIE}") -# Make the ping command -@app_commands.command(name="ping", description="Test the ping of the bot") -async def ping(interaction: discord.Interaction[ChouetteBot]): +@app_commands.command(name="ping", description="Test la latence du bot") +async def ping(interaction: discord.Interaction[ChouetteBot]) -> None: + """Test la latence du bot.""" await interaction.response.send_message( - f"Pong! In {round(interaction.client.latency * 1000)}ms" + f"Pong ! En {round(interaction.client.latency * 1000)}ms" ) -# Make a cheh command -@app_commands.command(name="cheh", description="Cheh somebody") -async def cheh(interaction: discord.Interaction[ChouetteBot], user: discord.Member): - # Check if the user to cheh is the bot or the user sending the command +@app_commands.command(name="cheh", description="Cheh quelqu'un") +async def cheh(interaction: discord.Interaction[ChouetteBot], user: discord.Member) -> None: + """ + Envoie le gif du Cheh à quelqu'un. + + On vérifie l'utilisateur qui a été mentionné, si c'est le bot, on envoie un message d'erreur. + Si c'est l'utilisateur qui a fait la commande, on envoie **FEUR**. + """ if user == interaction.client.user: await interaction.response.send_message("Vous ne pouvez pas me **Cheh** !") elif user == interaction.user: @@ -47,49 +51,55 @@ async def cheh(interaction: discord.Interaction[ChouetteBot], user: discord.Memb await interaction.channel.send(cheh_gif) -# Make a simple context menu application to pin/unpin @app_commands.guild_only @app_commands.checks.bot_has_permissions(manage_messages=True) -@app_commands.context_menu(name="Pin/Unpin") -async def pin(interaction: discord.Interaction[ChouetteBot], message: discord.Message): +@app_commands.context_menu(name="Epingler/Déséingler") +async def pin(interaction: discord.Interaction[ChouetteBot], message: discord.Message) -> None: + """Épingle ou désépingle un message.""" if message.pinned: await message.unpin() - await interaction.response.send_message("The message has been unpinned!", ephemeral=True) + await interaction.response.send_message("Le message a été désépinglé !", ephemeral=True) else: await message.pin() - await interaction.response.send_message("The message has been pinned!", ephemeral=True) + await interaction.response.send_message("Le message a été épinglé !", ephemeral=True) -# Make a context menu command to delete messages @app_commands.guild_only @app_commands.default_permissions(manage_messages=True) @app_commands.checks.bot_has_permissions( manage_messages=True, read_message_history=True, read_messages=True ) @app_commands.checks.has_permissions(manage_messages=True) -@app_commands.context_menu(name="Delete until here") -async def delete(interaction: discord.Interaction[ChouetteBot], message: discord.Message): +@app_commands.context_menu(name="Supprime jusqu'ici") +async def delete(interaction: discord.Interaction[ChouetteBot], message: discord.Message) -> None: + """Supprime les messages jusqu'à celui-ci (inclus).""" await interaction.response.defer(ephemeral=True, thinking=True) last_id = interaction.channel.last_message_id def is_msg(msg: discord.Message) -> bool: + """ + Vérifie si le message est dans l'intervalle (dernier message ↔ message sélectionné). + + On décale les IDs de 22 bits pour obtenir le timestamp du message. + """ return (message.id >> 22) <= (msg.id >> 22) <= (last_id >> 22) del_msg = await message.channel.purge(bulk=True, reason="Admin used bulk delete", check=is_msg) await interaction.followup.send(f"{len(del_msg)} messages supprimés !") -# Make a bot information command -@app_commands.command(name="info", description="Display bot infos") -async def info(interaction: discord.Interaction[ChouetteBot]): +@app_commands.command(name="info", description="Affiche les informations du bot") +async def info(interaction: discord.Interaction[ChouetteBot]) -> None: + """Affiche les informations du bot.""" creators = "Zalko & Gylfirst" last_update = await get_last_update() github_link = "https://github.com/Zalk0/ChouetteBot-discord" dockerhub_link = "https://hub.docker.com/r/gylfirst/chouettebot" await interaction.response.send_message( - f"Discord Bot created by: {creators}\n\n" - f"Project developed in our free time. You can ask for features on GitHub.\n" - f"[Source code](<{github_link}>)\n" - f"[Docker image](<{dockerhub_link}>)\n\n" - f"Last update: {last_update}" + f"Bot Discord créé par : {creators}\n\n" + "Projet développé pendant notre temps libre. " + "Vous pouvez demander des fonctionnalités sur GitHub.\n" + f"[Code source](<{github_link}>)\n" + f"[Image Docker](<{dockerhub_link}>)\n\n" + f"Dernière mise à jour : {last_update}" ) diff --git a/chouette/commands/skyblock.py b/chouette/commands/skyblock.py index 167dfca..c5179e5 100644 --- a/chouette/commands/skyblock.py +++ b/chouette/commands/skyblock.py @@ -7,24 +7,23 @@ import discord from discord import app_commands +from chouette.utils.skyblock import pseudo_to_profile from chouette.utils.skyblock_guild import check if TYPE_CHECKING: from chouette.bot import ChouetteBot -# Define command group based on the Group class class Skyblock(app_commands.Group): - # Set command group name and description - def __init__(self): - super().__init__(name="skyblock", description="Hypixel Skyblock related commands") + """Classe qui permet de gérer le Skyblock d'Hypixel.""" - # Make a command to check the version of mods for Hypixel Skyblock - @app_commands.command( - name="mods", - description="Check the latest release of the most popular mods for the Hypixel Skyblock", - ) - async def mods(self, interaction: discord.Interaction[ChouetteBot]): + def __init__(self) -> None: + """Initialise la classe Skyblock.""" + super().__init__(name="skyblock", description="Commandes relatives au Skyblock d'Hypixel") + + @app_commands.command(name="mods") + async def mods(self, interaction: discord.Interaction[ChouetteBot]) -> None: + """Vérifie les dernières mises à jour des mods populaires du Skyblock d'Hypixel.""" await interaction.response.defer(thinking=True) api_github = "https://api.github.com/repos" api_modrinth = "https://api.modrinth.com/v2" @@ -40,23 +39,40 @@ async def mods(self, interaction: discord.Interaction[ChouetteBot]): async with session.get(f"{api_github}/Skytils/SkytilsMod/releases/latest") as response: skytils = await response.json() await interaction.followup.send( - "The latest releases are:\n" + "Les dernières mises à jour sont :\n" f"- Dungeons-Guide: `{dungeonsguide['tag_name'].replace('v', '')}` " - f"[link]({dungeonsguide['assets'][0]['browser_download_url']})\n" + f"[lien]({dungeonsguide['assets'][0]['browser_download_url']})\n" f"- NotEnoughUpdates: `{notenoughupdates['version_number']}` " - f"[link]({notenoughupdates['files'][0]['url']})\n" + f"[lien]({notenoughupdates['files'][0]['url']})\n" f"- SkyblockAddons (forked by Fix3dll): `{skyblockaddons['version_number']}` " - f"[link]({skyblockaddons['files'][0]['url']})\n" + f"[lien]({skyblockaddons['files'][0]['url']})\n" f"- Skytils: `{skytils['tag_name'].replace('v', '')}` " - f"[link]({skytils['assets'][0]['browser_download_url']})" + f"[lien]({skytils['assets'][0]['browser_download_url']})" ) - # Make a command to check if it's raining in Spider's Den in Hypixel Skyblock - @app_commands.command( - name="spider_rain", - description="Show the time until the next rain and thunderstorm", - ) - async def spider(self, interaction: discord.Interaction[ChouetteBot]): + @app_commands.command(name="tuto") + async def tuto(self, interaction: discord.Interaction[ChouetteBot]) -> None: + """Donne le lien du tutoriel pour débuter sur le Skyblock d'Hypixel.""" + repo_url = "https://github.com/gylfirst/HowToSkyblock" + strip_color = discord.Colour.from_str(value="#DAA520") # HTML name: GoldenRod + embed_tuto = discord.Embed( + title="Tutoriel pour débuter sur le Skyblock d'Hypixel", + description="Ce tutoriel est écrit pour que les débutants puissent jouer au Skyblock d'Hypixel facilement.", + color=strip_color, + ) + embed_tuto.set_author(name="Gylfirst", url="https://github.com/gylfirst") + embed_tuto.set_thumbnail(url="https://hypixel.net/attachments/1608783256403-png.2210524") + embed_tuto.add_field( + name="Comment utiliser le tutoriel ?", + value=f"Il suffit de cliquer sur le lien ci-dessous pour accéder au tutoriel.\n{repo_url}", + inline=False, + ) + embed_tuto.set_footer(text="HowToSkyblock") + await interaction.response.send_message(embed=embed_tuto) + + @app_commands.command(name="spider_rain") + async def spider(self, interaction: discord.Interaction[ChouetteBot]) -> None: + """Indique le temps de la prochaine pluie ou orage sur Spider's Den.""" utc_last_thunderstorm = round( datetime(2023, 3, 27, 1, 45, 56, tzinfo=timezone.utc).timestamp() ) @@ -66,22 +82,22 @@ async def spider(self, interaction: discord.Interaction[ChouetteBot]): rain = thunderstorm % (3850 + 1000) if rain <= 3850: next_rain = time_now + 3850 - rain - rain_msg = f"The next rain will be " + rain_msg = f"TLa prochaine pluie sera " else: rain_duration = time_now + 3850 + 1000 - rain - rain_msg = f"The rain will end " + rain_msg = f"La pluie s'arrêtera " if thunderstorm <= (3850 * 4 + 1000 * 3): next_thunderstorm = time_now + (3850 * 4 + 1000 * 3) - thunderstorm - thunderstorm_msg = f"The next thunderstorm will be " + thunderstorm_msg = f"Le prochain orage sera " else: thunderstorm_duration = time_now + (3850 * 4 + 1000 * 4) - thunderstorm - thunderstorm_msg = f"The thunderstorm will end " + thunderstorm_msg = f"Le prochain orage s'arrêtera " await interaction.response.send_message(f"{rain_msg}\n{thunderstorm_msg}") - # Make a command to check if the user is in the guild in-game - @app_commands.command(name="guild", description="Give a role if in the guild in-game") + @app_commands.command(name="guild") @app_commands.rename(pseudo="pseudo_mc") - async def in_guild(self, interaction: discord.Interaction[ChouetteBot], pseudo: str): + async def in_guild(self, interaction: discord.Interaction[ChouetteBot], pseudo: str) -> None: + """Donne un rôle sur le Discord si le joueur est dans la guilde.""" if interaction.user.get_role(int(interaction.client.config["HYPIXEL_GUILD_ROLE"])): await interaction.response.send_message("Vous avez déjà le rôle !") return @@ -97,3 +113,25 @@ async def in_guild(self, interaction: discord.Interaction[ChouetteBot], pseudo: await interaction.followup.send("Vous avez été assigné le rôle de membre !") else: await interaction.followup.send(checked) + + @app_commands.command(name="link") + @app_commands.rename(pseudo="pseudo_mc") + @app_commands.describe(pseudo="Ton pseudo Minecraft", profile="Ton profil Skyblock préféré") + async def link( + self, interaction: discord.Interaction[ChouetteBot], pseudo: str, profile: str | None + ): + """Lie le profil Hypixel Skyblock du joueur.""" + await interaction.response.defer(ephemeral=True, thinking=True) + discord_pseudo = interaction.user.name + async with aiohttp.ClientSession() as session: + profile_name = await pseudo_to_profile( + session, interaction.client.config["HYPIXEL_KEY"], discord_pseudo, pseudo, profile + ) + if isinstance(profile_name, str): + interaction.client.bot_logger.error(profile_name) + await interaction.followup.send(f"Il y a eu une erreur :\n`{profile_name}`") + return + await interaction.followup.send( + f"Vous êtes bien connecté et le profil " + f"{profile_name.get('profile')} a été enregistré." + ) diff --git a/chouette/commands_list.py b/chouette/commands_list.py index 97a1fa8..f91993a 100644 --- a/chouette/commands_list.py +++ b/chouette/commands_list.py @@ -27,9 +27,8 @@ SPACES = " " * 38 -# List of commands to add to the command tree -async def commands(client: ChouetteBot): - # Add the commands to the Tree +async def commands(client: ChouetteBot) -> None: + """Ajoute les commandes à l'arbre des commandes.""" for command in COMMANDS_LIST: client.tree.add_command(command) @@ -43,7 +42,8 @@ async def commands(client: ChouetteBot): @client.tree.error async def on_command_error( interaction: discord.Interaction[ChouetteBot], error: discord.app_commands.AppCommandError - ): + ) -> None: + """Gère les erreurs lors de l'exécution des commandes.""" if interaction.response.is_done(): return if isinstance(error, discord.app_commands.BotMissingPermissions): @@ -54,12 +54,12 @@ async def on_command_error( ) if len(error.missing_permissions) == 1: await interaction.response.send_message( - f"I am missing this permission: {bot_perms}", + f"Je n'ai pas cette permission : {bot_perms}", ephemeral=True, ) else: await interaction.response.send_message( - f"I am missing these permissions: {bot_perms}", + f"Je n'ai pas cette permission : {bot_perms}", ephemeral=True, ) return @@ -71,12 +71,12 @@ async def on_command_error( ) if len(error.missing_permissions) == 1: await interaction.response.send_message( - f"You are missing this permission: {user_perms}", + f"Vous n'avez pas ces permissions : {user_perms}", ephemeral=True, ) else: await interaction.response.send_message( - f"You are missing these permissions: {user_perms}", + f"Vous n'avez pas ces permissions : {user_perms}", ephemeral=True, ) return @@ -86,11 +86,11 @@ async def on_command_error( f"in #{interaction.channel}\n{SPACES}{error}" ) await interaction.response.send_message( - "You're not allowed to use this command!", ephemeral=True + "Vous n'êtes pas autorisé à exécuter cette commande !", ephemeral=True ) return await interaction.response.send_message( - f"{error}\nThis error is not caught, please signal it!", + f"{error}\nCette erreur n'a pas été récupérée, signalez la !", ephemeral=True, ) interaction.client.bot_logger.error(error) diff --git a/chouette/responses.py b/chouette/responses.py index df49688..aa207b3 100644 --- a/chouette/responses.py +++ b/chouette/responses.py @@ -17,6 +17,12 @@ async def responses( message: str, author: discord.User, ) -> tuple[str, bool]: + """ + Vérifie si le message de l'utilisateur correspond à une réponse spécifique : + On vérifie si le message se termine par "quoi" pour répondre **FEUR**. + On vérifie si le message contient des $$ pour afficher une équation LaTeX. + On vérifie si le message est égal à la mention du bot suivi de "sync" pour synchroniser les commandes slash. + """ # Checks if a message ends with quoi if "".join(filter(str.isalpha, message)).lower().endswith("quoi"): return "**FEUR**", False @@ -40,7 +46,7 @@ async def responses( await client.tree.sync() for guild in client.guilds: await client.tree.sync(guild=guild) - return "Successfully synced the slash commands!", True + return "Les commandes slash ont été synchronisées avec succès !", True except discord.app_commands.CommandSyncFailure as e: client.bot_logger.error(e) return str(e), True diff --git a/chouette/tasks.py b/chouette/tasks.py index 8cea0c9..0714fcd 100644 --- a/chouette/tasks.py +++ b/chouette/tasks.py @@ -7,6 +7,7 @@ from discord.ext import tasks from chouette.utils.birthdays import calculate_age, load_birthdays +from chouette.utils.ranking import display_ranking, update_stats if TYPE_CHECKING: from chouette.bot import ChouetteBot @@ -23,19 +24,23 @@ TIMEZONE = timezone.utc -async def tasks_list(client: ChouetteBot): - # Loop to send message every 2 hours for pokeroll in utc time (default) +async def tasks_list(client: ChouetteBot) -> None: + """Liste des tâches à effectuer pour le bot.""" + + # Send message every 2 hours for pokeroll in utc time (default) @tasks.loop(time=[time(t) for t in range(0, 24, 2)]) - async def poke_ping(): + async def poke_ping() -> None: + """Envoie un message pour le pokeroll.""" guild = client.get_guild(int(client.config["GUILD_ID"])) dresseurs = guild.get_role(int(client.config["POKE_ROLE"])) pokeball = client.get_emoji(int(client.config["POKEBALL_EMOJI"])) msg_poke = f"{dresseurs.mention} C'est l'heure d'attraper des pokémons {pokeball}" await client.get_channel(int(client.config["POKE_CHANNEL"])).send(msg_poke) - # Loop to check if it's someone's birthday every day at 8am in local time + # Check if it's someone's birthday every day at 8am in local time @tasks.loop(time=time(8, tzinfo=TIMEZONE)) - async def check_birthdays(): + async def check_birthdays() -> None: + """Vérifie si c'est l'anniversaire de quelqu'un.""" guild = client.get_guild(int(client.config["GUILD_ID"])) role = guild.get_role(int(client.config["BIRTHDAY_ROLE"])) for member in role.members: @@ -59,6 +64,25 @@ async def check_birthdays(): ) await client.get_channel(int(client.config["BIRTHDAY_CHANNEL"])).send(msg_birthday) + # Display the ranking for Hypixel Skyblock guild every month on the 1st at 8am in local time + @tasks.loop(time=time(8, tzinfo=TIMEZONE)) + async def skyblock_guild_ranking() -> None: + """Affiche le classement de la guilde Hypixel Skyblock.""" + if date.today().day == 1: + guild = client.get_guild(int(client.config["HYPIXEL_GUILD_ID"])) + member = guild.get_role(int(client.config["HYPIXEL_GUILD_ROLE"])) + api_key = client.config["HYPIXEL_KEY"] + update_message = await update_stats(api_key=api_key) + client.bot_logger.info(update_message) + if not guild.icon: + icon_url = "https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/636e0a69f118df70ad7828d4_icon_clyde_blurple_RGB.svg" + else: + icon_url = guild.icon.url + await client.get_channel(int(client.config["HYPIXEL_RANK_CHANNEL"])).send( + f"||{member.mention}||", embed=await display_ranking(img=icon_url) + ) + # Start loop poke_ping.start() check_birthdays.start() + skyblock_guild_ranking.start() diff --git a/chouette/utils/birthdays.py b/chouette/utils/birthdays.py index 2954d99..714d8ec 100644 --- a/chouette/utils/birthdays.py +++ b/chouette/utils/birthdays.py @@ -6,18 +6,18 @@ BIRTHDAY_FILE = Path("data", "birthdays.toml") -# Function to load the birthday file async def load_birthdays() -> dict: + """Charge les anniversaires depuis un fichier TOML depuis le disque.""" return await data_read(BIRTHDAY_FILE) -# Save birthdays in a TOML file -async def save_birthdays(birthdays: dict): +async def save_birthdays(birthdays: dict) -> None: + """Sauvegarde les anniversaires dans un fichier TOML sur le disque.""" await data_write(birthdays, BIRTHDAY_FILE) -# Function to verify if the given year is valid async def check_date(day: int, month: int, year: int) -> date: + """Vérifie si la date est valide et la retourne.""" if not year: return date(4, month, day) if year < 1900 or year > date.today().year: @@ -25,13 +25,32 @@ async def check_date(day: int, month: int, year: int) -> date: return date(year, month, day) -# Function to calculate the age async def calculate_age(year: int) -> int: + """Calcule l'âge de la personne en fonction de son année de naissance.""" return date.today().year - year if year != 4 else None -# Function to convert a datetime object to Discord timestamp async def datetime_to_timestamp(birthday: date) -> str: + """Convertit une date donnée en timestamp Discord.""" birthday_dt = datetime.fromisoformat(str(birthday)) unix_timestamp = birthday_dt.timestamp() return f"" + + +async def month_to_str(month: int) -> str: + """Convertit un numéro de mois en français.""" + months: list[str] = [ + "Janvier", + "Février", + "Mars", + "Avril", + "Mai", + "Juin", + "Juillet", + "Août", + "Septembre", + "Octobre", + "Novembre", + "Décembre", + ] + return months[month - 1] diff --git a/chouette/utils/data_io.py b/chouette/utils/data_io.py index b9abee5..133fa7d 100644 --- a/chouette/utils/data_io.py +++ b/chouette/utils/data_io.py @@ -7,7 +7,7 @@ def _get_lock(file: Path) -> Lock: - """Return the lock associated to a path and creates it if it does not exist.""" + """Retourne le verrou associé à un chemin et le crée s'il n'existe pas.""" for path, lock in FILE_LOCKS.items(): if path == file: return lock @@ -16,6 +16,7 @@ def _get_lock(file: Path) -> Lock: def _file_read(file: Path) -> dict: + """Lit un fichier TOML et retourne un dictionnaire.""" try: return tomlkit.parse(file.read_text()) except FileNotFoundError: @@ -23,16 +24,17 @@ def _file_read(file: Path) -> dict: async def data_read(file: Path) -> dict: - """In another thread, read a TOML file and returns a dict.""" + """Dans un autre thread, lit un fichier TOML et retourne un dictionnaire.""" async with _get_lock(file): return await to_thread(_file_read, file) def _file_write(data: dict, file: Path) -> None: + """Écrit un dictionnaire dans un fichier TOML.""" file.write_text(tomlkit.dumps(data)) async def data_write(data: dict, file: Path) -> None: - """In another thread, write a dict to a TOML file.""" + """Dans un autre thread, écrit un dictionnaire dans un fichier TOML.""" async with _get_lock(file): await to_thread(_file_write, data, file) diff --git a/chouette/utils/github_api.py b/chouette/utils/github_api.py index 50d4949..bb051c1 100644 --- a/chouette/utils/github_api.py +++ b/chouette/utils/github_api.py @@ -1,8 +1,8 @@ import aiohttp -# Function to get the last information about main commit with GitHub API -async def get_last_update(): +async def get_last_update() -> str: + """Récupère la date du dernier commit sur le dépôt principal de ChouetteBot.""" async with ( aiohttp.ClientSession() as session, session.get( diff --git a/chouette/utils/hypixel_data.py b/chouette/utils/hypixel_data.py new file mode 100644 index 0000000..ed64720 --- /dev/null +++ b/chouette/utils/hypixel_data.py @@ -0,0 +1,202 @@ +def experience_to_level(type_xp: str, xp_amount: float) -> float: + """ + Calcule le niveau correspondant à une quantité donnée d'expérience cumulative. + + Args: + type_xp: Le type d'expérience pour lequel calculer le niveau (compétence, type de slayer, donjon). + Pour `slayer_type`, utilisez l'un des suivants: slayer_zombie, slayer_spider, slayer_web, slayer_vampire. + xp_amount: La quantité d'expérience cumulée. + + Returns: + level: Le niveau correspondant à la quantité donnée d'expérience cumulée. + """ + skill_xp_data: list[tuple[int, int]] = [ + (0, 0), + (1, 50), + (2, 175), + (3, 375), + (4, 675), + (5, 1175), + (6, 1925), + (7, 2925), + (8, 4425), + (9, 6425), + (10, 9925), + (11, 14925), + (12, 22425), + (13, 32425), + (14, 47425), + (15, 67425), + (16, 97425), + (17, 147425), + (18, 222425), + (19, 322425), + (20, 522425), + (21, 822425), + (22, 1222425), + (23, 1722425), + (24, 2322425), + (25, 3022425), + (26, 3822425), + (27, 4722425), + (28, 5722425), + (29, 6822425), + (30, 8022425), + (31, 9322425), + (32, 10722425), + (33, 12222425), + (34, 13822425), + (35, 15522425), + (36, 17322425), + (37, 19222425), + (38, 21222425), + (39, 23322425), + (40, 25522425), + (41, 27822425), + (42, 30222425), + (43, 32722425), + (44, 35322425), + (45, 38072425), + (46, 40972425), + (47, 44072425), + (48, 47472425), + (49, 51172425), + (50, 55172425), + (51, 59472425), + (52, 64072425), + (53, 68972425), + (54, 74122425), + (55, 79672425), + (56, 85472425), + (57, 91572425), + (58, 97572425), + (59, 104672425), + (60, 111672425), + ] + dungeon_xp_data: list[tuple[int, int]] = [ + (0, 0), + (1, 50), + (2, 125), + (3, 235), + (4, 395), + (5, 625), + (6, 955), + (7, 1425), + (8, 2095), + (9, 3045), + (10, 4385), + (11, 6275), + (12, 8940), + (13, 12700), + (14, 17960), + (15, 25340), + (16, 35640), + (17, 50040), + (18, 70040), + (19, 97640), + (20, 135640), + (21, 188140), + (22, 259640), + (23, 356640), + (24, 488640), + (25, 668640), + (26, 911640), + (27, 1239640), + (28, 1684640), + (29, 2284640), + (30, 3084640), + (31, 4149640), + (32, 5559640), + (33, 7459640), + (34, 9959640), + (35, 13259640), + (36, 17559640), + (37, 23159640), + (38, 30359640), + (39, 39559640), + (40, 51559640), + (41, 66559640), + (42, 85559640), + (43, 109559640), + (44, 139559640), + (45, 177559640), + (46, 225559640), + (47, 285559640), + (48, 360559640), + (49, 453559640), + (50, 569809640), + ] + slayer_xp_data: list[list[tuple[int, int]]] = [ + [ + (0, 0), + (1, 5), + (2, 15), + (3, 200), + (4, 1000), + (5, 5000), + (6, 20000), + (7, 100000), + (8, 400000), + (9, 1000000), + ], + [ + (0, 0), + (1, 5), + (2, 25), + (3, 200), + (4, 1000), + (5, 5000), + (6, 20000), + (7, 100000), + (8, 400000), + (9, 1000000), + ], + [ + (0, 0), + (1, 10), + (2, 30), + (3, 250), + (4, 1500), + (5, 5000), + (6, 20000), + (7, 100000), + (8, 400000), + (9, 1000000), + ], + [ + (0, 0), + (1, 20), + (2, 75), + (3, 240), + (4, 840), + (5, 2400), + ], + ] + + # Skill XP data + if type_xp == "skill": + xp_data = skill_xp_data + # Dungeon XP data + elif type_xp == "dungeon": + xp_data = dungeon_xp_data + # Slayer XP data + # Slayer Zombie + elif type_xp == "slayer_zombie": + xp_data = slayer_xp_data[0] + # Slayer Spider + elif type_xp == "slayer_spider": + xp_data = slayer_xp_data[1] + # Slayer Wolf/Enderman/Blaze + elif type_xp == "slayer_web": + xp_data = slayer_xp_data[2] + # Slayer Vampire + elif type_xp == "slayer_vampire": + xp_data = slayer_xp_data[3] + else: + raise ValueError(f"Unknown type of XP: {type_xp}") + + for i, (level, xp) in enumerate(xp_data): + if xp_amount <= xp: + previous_xp = xp_data[i - 1][1] + return level - 1 + (xp_amount - previous_xp) / (xp - previous_xp) + return xp_data[-1][0] diff --git a/chouette/utils/latex_render.py b/chouette/utils/latex_render.py index f8ad94c..cb757ca 100644 --- a/chouette/utils/latex_render.py +++ b/chouette/utils/latex_render.py @@ -5,8 +5,8 @@ # Make a LaTeX rendering function using an online equation renderer : https://latex.codecogs.com/ -# Then send the image as a file async def latex_render(equation: str) -> discord.File: + """Fait le rendu d'une équation LaTeX et le renvoie sous forme de fichier.""" options = r"\dpi{200} \bg_black \color[RGB]{240, 240, 240} \pagecolor[RGB]{49, 51, 56}" # bg_black is for putting a black background (custom command of the site) # instead of the transparent one. Then a custom background color can be used with pagecolor. @@ -17,8 +17,8 @@ async def latex_render(equation: str) -> discord.File: return discord.File(BytesIO(response_content), filename="equation.png") -# Make a LaTeX process function to use when LaTeX maths is inserted in a message -async def latex_process(message: str): +async def latex_process(message: str) -> discord.File: + """Traite un message contenant des équations LaTeX et le renvoie sous forme de fichier.""" message = await latex_replace(message) parts = message.split("$") equation = r"\\" @@ -37,9 +37,8 @@ async def latex_process(message: str): return await latex_render(equation.replace(r" \textrm{}", "")) -# LaTeX replace accents and special characters to commands -# TODO: Add all the symbols that may appear async def latex_replace(message: str) -> str: + """Remplace les caractères spéciaux par des commandes LaTeX.""" return ( message.replace(r"ù", r"\`u") .replace(r"é", r"\'e") @@ -58,4 +57,70 @@ async def latex_replace(message: str) -> str: .replace(r"Ê", r"\^E") .replace(r"ç", r"\c c") .replace(r"Ç", r"\c C") + .replace(r"ô", r"\^o") + .replace(r"Ô", r"\^O") + .replace(r"û", r"\^u") + .replace(r"Û", r"\^U") + .replace(r"ë", r"\"e") + .replace(r"Ë", r"\"E") + .replace(r"ü", r"\"u") + .replace(r"Ü", r"\"U") + .replace(r"ÿ", r"\"y") + .replace(r"Ÿ", r"\"Y") + .replace(r"ñ", r"\~n") + .replace(r"Ñ", r"\~N") + .replace(r"¡", r"\!") + .replace(r"¿", r"\?") + .replace(r"«", r"\guillemotleft") + .replace(r"»", r"\guillemotright") + .replace(r"“", r"\textquotedblleft") + .replace(r"”", r"\textquotedblright") + .replace(r"‘", r"\textquoteleft") + .replace(r"’", r"\textquoteright") + .replace(r"–", r"\textendash") + .replace(r"—", r"\textemdash") + .replace(r"…", r"\ldots") + .replace(r"‰", r"\textperthousand") + .replace(r"€", r"\euro") + .replace(r"£", r"\pounds") + .replace(r"¢", r"\cent") + .replace(r"¥", r"\yen") + .replace(r"§", r"\S") + .replace(r"¶", r"\P") + .replace(r"†", r"\dag") + .replace(r"‡", r"\ddag") + .replace(r"°", r"\degree") + .replace(r"µ", r"\micro") + .replace(r"®", r"\textregistered") + .replace(r"©", r"\textcopyright") + .replace(r"™", r"\texttrademark") + .replace(r"†", r"\textdagger") + .replace(r"‡", r"\textdaggerdbl") + .replace(r"•", r"\textbullet") + .replace(r"·", r"\textperiodcentered") + .replace(r"…", r"\textellipsis") + .replace(r"′", r"\textprime") + .replace(r"″", r"\textdoubleprime") + .replace(r"‴", r"\texttripleprime") + .replace(r"⁗", r"\textquadrupleprime") + .replace(r"⁰", r"\textsuperscript{0}") + .replace(r"¹", r"\textsuperscript{1}") + .replace(r"²", r"\textsuperscript{2}") + .replace(r"³", r"\textsuperscript{3}") + .replace(r"⁴", r"\textsuperscript{4}") + .replace(r"⁵", r"\textsuperscript{5}") + .replace(r"⁶", r"\textsuperscript{6}") + .replace(r"⁷", r"\textsuperscript{7}") + .replace(r"⁸", r"\textsuperscript{8}") + .replace(r"⁹", r"\textsuperscript{9}") + .replace(r"₀", r"\textsubscript{0}") + .replace(r"₁", r"\textsubscript{1}") + .replace(r"₂", r"\textsubscript{2}") + .replace(r"₃", r"\textsubscript{3}") + .replace(r"₄", r"\textsubscript{4}") + .replace(r"₅", r"\textsubscript{5}") + .replace(r"₆", r"\textsubscript{6}") + .replace(r"₇", r"\textsubscript{7}") + .replace(r"₈", r"\textsubscript{8}") + .replace(r"₉", r"\textsubscript{9}") ) diff --git a/chouette/utils/ranking.py b/chouette/utils/ranking.py new file mode 100644 index 0000000..05a7d86 --- /dev/null +++ b/chouette/utils/ranking.py @@ -0,0 +1,186 @@ +from datetime import date + +import aiohttp +import discord + +from chouette.utils.birthdays import month_to_str +from chouette.utils.hypixel_data import experience_to_level +from chouette.utils.skyblock import get_profile, get_stats, load_skyblock, save_skyblock + +SPACES = " " * 38 + + +async def format_number(number) -> str: + """Permet de formater un nombre en K, M ou B.""" + if number >= 1_000_000_000: + return f"{number / 1_000_000_000:.2f}B" + if number >= 1_000_000: + return f"{number / 1_000_000:.0f}M" + if number >= 1_000: + return f"{number / 1_000:.0f}K" + return str(number) + + +async def update_stats(api_key: str) -> str: + """Crée le classement de la guilde sur Hypixel Skyblock.""" + old_data = await load_skyblock() + new_data = old_data.copy() + msg = "Synchro des données de la guilde sur Hypixel Skyblock pour :" + async with aiohttp.ClientSession() as session: + for uuid in old_data: + pseudo = old_data.get(uuid).get("pseudo") + profile_name = old_data.get(uuid).get("profile") + profile = await get_profile(session, api_key, uuid, profile_name) + if not profile[0]: + raise Exception("Error while updating stats") + profile = profile[1] + new_data.get(uuid).update(await get_stats(session, pseudo, uuid, profile)) + msg += f"\n{SPACES}- {pseudo} sur le profil {profile_name}" + await save_skyblock(new_data) + # TODO: handle comparison using old and new data (see issue #56) + return msg + + +async def parse_data(data: dict) -> tuple[dict, list]: + """Parse les données de la guilde sur Hypixel Skyblock.""" + ranking = {} + skills = [ + "fishing", + "alchemy", + "mining", + "farming", + "enchanting", + "taming", + "foraging", + "carpentery", + "combat", + "dungeoneering", + ] + slayers = ["zombie", "spider", "wolf", "enderman", "blaze", "vampire"] + level_cap = [] + + for player in data: + for key, value in data[player].items(): + # Handle 'level' and 'networth' + if key == "level" or key == "networth": + if key not in ranking: + ranking[key] = {} + ranking[key][data[player]["pseudo"]] = value + # Handle 'skills' + if key == "skills": + for skill in skills: + if skill not in ranking: + ranking[skill] = {} + ranking[skill][data[player]["pseudo"]] = value[skills.index(skill)] + # Handle 'slayers' + if key == "slayers": + for slayer in slayers: + if slayer not in ranking: + ranking[slayer] = {} + ranking[slayer][data[player]["pseudo"]] = value[slayers.index(slayer)] + if key == "level_cap": + level_cap.append(value[0]) + # TODO: for taming + # -> level_cap.append(value[1]) + + # Sorting the nested dictionaries by value + for category in ranking: + if isinstance(ranking[category], dict): + ranking[category] = dict( + sorted(ranking[category].items(), key=lambda item: item[1], reverse=True) + ) + elif category == "skills" or category == "slayers": + for subcategory in ranking[category]: + ranking[category][subcategory] = dict( + sorted( + ranking[category][subcategory].items(), + key=lambda item: item[1], + reverse=True, + ) + ) + return ranking, level_cap + + +async def generate_ranking_message(data, category, level_cap): + skills_list: list[str] = [ + "fishing", + "alchemy", + "mining", + "farming", + "enchanting", + "taming", + "foraging", + "carpentery", + "combat", + "dungeoneering", + ] + slayers_list: list[str] = [ + "zombie", + "spider", + "wolf", + "enderman", + "blaze", + "vampire", + ] + messages = [] + for i, (player, value) in enumerate(data[category].items()): + if category != "level" and category != "networth": + if category in skills_list: + if category != "dungeoneering": + value = experience_to_level(type_xp="skill", xp_amount=value) + else: + value = experience_to_level(type_xp="dungeon", xp_amount=value) + # Set max level to 50 for skills (alchemy, carpentery, fishing, foraging) + if category in ("alchemy", "carpentery", "fishing", "foraging", "taming"): + value = min(value, 50.00) + # Set max level to level cap for farming + if category == "farming": + value = min(value, level_cap[0] + 50) + # TODO: if taming -> level_cap[1] + 50 + elif category in slayers_list: + if category == "zombie": + value = experience_to_level(type_xp="slayer_zombie", xp_amount=value) + elif category == "spider": + value = experience_to_level(type_xp="slayer_spider", xp_amount=value) + elif category == "vampire": + value = experience_to_level(type_xp="slayer_vampire", xp_amount=value) + else: + value = experience_to_level(type_xp="slayer_web", xp_amount=value) + else: + raise ValueError(f"Unknown category: {category}") + value = f"{value:.2f}" + elif category == "networth": + value = await format_number(value) + if i == 0: + message = f"\N{FIRST PLACE MEDAL} **{player}** ({value})" + elif i == 1: + message = f"\N{SECOND PLACE MEDAL} **{player}** ({value})" + elif i == 2: + message = f"\N{THIRD PLACE MEDAL} **{player}** ({value})" + else: + message = f"\N{MEDIUM BLACK CIRCLE} **{player}** ({value})" + messages.append(message) + return messages + + +async def display_ranking(img: str) -> discord.Embed: + """Affiche le classement de la guilde sur Hypixel Skyblock.""" + month = await month_to_str(date.today().month) + year = date.today().year + ranking = discord.Embed( + title=f"Classement du mois de {month} {year}", + description="Voici le classement de la guilde sur Hypixel Skyblock.", + color=discord.Colour.from_rgb(0, 170, 255), + ) + ranking.set_footer(text="\N{WHITE HEAVY CHECK MARK} Mis à jour le 1er de chaque mois à 8h00") + data, level_cap = await parse_data(await load_skyblock()) + for category in data: + if isinstance(data[category], dict): + messages = await generate_ranking_message(data, category, level_cap) + ranking.add_field( + name=f"**[ {category.capitalize()} ]**", + value="\n".join(messages), + inline=False, + ) + ranking.set_thumbnail(url=img) + return ranking diff --git a/chouette/utils/skyblock.py b/chouette/utils/skyblock.py new file mode 100644 index 0000000..e523c29 --- /dev/null +++ b/chouette/utils/skyblock.py @@ -0,0 +1,168 @@ +from pathlib import Path + +from aiohttp import ClientSession + +from chouette.utils.data_io import data_read, data_write + +SKYBLOCK_FILE = Path("data", "skyblock.toml") +HYPIXEL_API = "https://api.hypixel.net/v2/" + + +async def load_skyblock() -> dict: + """Charge les données du Skyblock à partir du disque.""" + return await data_read(SKYBLOCK_FILE) + + +async def save_skyblock(skyblock: dict) -> None: + """Sauvegarde les données du Skyblock sur le disque.""" + await data_write(skyblock, SKYBLOCK_FILE) + + +async def minecraft_uuid(session: ClientSession, pseudo: str) -> tuple[bool, str]: + """Retourne l'UUID d'un joueur Minecraft avec l'API Mojang.""" + async with session.get( + f"https://api.mojang.com/users/profiles/minecraft/{pseudo}" + ) as response: + json: dict = await response.json() + if response.status != 200: + return False, json.get("errorMessage") + return True, json.get("id") + + +async def hypixel_discord(session: ClientSession, api_key: str, uuid: str) -> tuple[bool, str]: + """Retourne le pseudo Discord lié à un compte Hypixel.""" + async with session.get( + f"{HYPIXEL_API}player", params={"key": api_key, "uuid": uuid} + ) as response: + json: dict = await response.json() + if response.status != 200: + return False, json.get("cause") + if not json.get("player").get("socialMedia", {}).get("links", {}).get("DISCORD", ""): + return False, "Vous n'avez pas associé votre compte Discord à Hypixel" + return True, json.get("player").get("socialMedia").get("links").get("DISCORD") + + +async def selected_profile( + session: ClientSession, api_key: str, uuid: str +) -> tuple[bool, dict | str]: + """Retourne le profil Skyblock sélectionné d'un joueur.""" + async with session.get( + f"{HYPIXEL_API}skyblock/profiles", params={"key": api_key, "uuid": uuid} + ) as response: + json: dict = await response.json() + if response.status != 200: + return False, json.get("cause") + profiles = json.get("profiles") + for profile in profiles: + if profile.get("selected"): + if profile.get("game_mode") == "bingo": + return False, "Bingo profile selected" + return True, profile + return False, json.get("cause") if not json.get("success") else "No profile" + + +async def get_profile( + session: ClientSession, api_key: str, uuid: str, name: str +) -> tuple[bool, dict | str]: + """Retourne le profil Skyblock d'un joueur avec un nom spécifique.""" + async with session.get( + f"{HYPIXEL_API}skyblock/profiles", params={"key": api_key, "uuid": uuid} + ) as response: + json: dict = await response.json() + if response.status != 200: + return False, json.get("cause") + profiles = json.get("profiles") + for profile in profiles: + if profile.get("cute_name") == name: + return True, profile + return False, "No profile with this name" + + +async def get_networth(session: ClientSession, pseudo: str, profile_id: str) -> float: + """Retourne la fortune d'un joueur Skyblock à l'aide de l'API SkyCrypt.""" + async with session.get(f"https://sky.shiiyu.moe/api/v2/profile/{pseudo}") as response: + json: dict = await response.json() + if response.status != 200 and json.get("error") == "Player has no SkyBlock profiles.": + async with session.get(f"https://sky.shiiyu.moe/stats/{pseudo}") as response_error: + if response_error.status != 200: + raise Exception("Error while fetching networth") + return await get_networth(session, pseudo, profile_id) + if response.status != 200: + raise Exception("Error while fetching networth") + return json.get("profiles").get(profile_id).get("data").get("networth").get("networth", 0) + + +async def get_stats(session, pseudo, uuid, profile) -> dict[str, float]: + """Retourne les statistiques d'un joueur Skyblock avec l'API.""" + info = profile.get("members").get(uuid) + level: float = (info.get("leveling").get("experience")) / 100 + networth = await get_networth(session, pseudo, profile.get("profile_id")) + skill = info.get("player_data").get("experience") + skills: tuple[float, float, float, float, float, float, float, float, float, float] = ( + skill.get("SKILL_FISHING", 0), + skill.get("SKILL_ALCHEMY", 0), + skill.get("SKILL_MINING", 0), + skill.get("SKILL_FARMING", 0), + skill.get("SKILL_ENCHANTING", 0), + skill.get("SKILL_TAMING", 0), + skill.get("SKILL_FORAGING", 0), + skill.get("SKILL_CARPENTRY", 0), + skill.get("SKILL_COMBAT", 0), + info.get("dungeons").get("dungeon_types").get("catacombs").get("experience", 0), + ) + slayer = info.get("slayer").get("slayer_bosses") + slayers: tuple[int, int, int, int, int, int] = ( + slayer.get("zombie", {}).get("xp", 0), + slayer.get("spider", {}).get("xp", 0), + slayer.get("wolf", {}).get("xp", 0), + slayer.get("enderman", {}).get("xp", 0), + slayer.get("blaze", {}).get("xp", 0), + slayer.get("vampire", {}).get("xp", 0), + ) + level_cap: tuple[int] = ( + info.get("jacobs_contest", {}).get("perks", {}).get("farming_level_cap", 0), + # TODO: add one day taming cap (when api is cool) + ) + return { + "level": level, + "networth": networth, + "skills": skills, + "slayers": slayers, + "level_cap": level_cap, + } + + +async def pseudo_to_profile( + session: ClientSession, api_key: str, discord_pseudo: str, pseudo: str, name: str | None +) -> dict | str: + """Retourne le profil d'un joueur Skyblock avec l'API.""" + uuid = await minecraft_uuid(session, pseudo) + if not uuid[0]: + # TODO: better handling + return uuid[1] + uuid = uuid[1] + + discord = await hypixel_discord(session, api_key, uuid) + if not discord[0]: + # TODO: better handling + return discord[1] + discord = discord[1] + if discord != discord_pseudo: + return "Votre pseudo Discord ne correspond pas à celui entré sur le serveur Hypixel" + + if name: + profile = await get_profile(session, api_key, uuid, name) + else: + profile = await selected_profile(session, api_key, uuid) + if not profile[0]: + # TODO: better handling + return profile[1] + profile = profile[1] + + info = {uuid: {"discord": discord, "pseudo": pseudo, "profile": profile.get("cute_name")}} + info.get(uuid).update(await get_stats(session, pseudo, uuid, profile)) + file_content = await load_skyblock() + if file_content.get(uuid, {}).get("profile", "") != profile.get("profile_id"): + file_content.update(info) + await save_skyblock(file_content) + return info.get(uuid) diff --git a/chouette/utils/skyblock_guild.py b/chouette/utils/skyblock_guild.py index cbeb66f..7af5e42 100644 --- a/chouette/utils/skyblock_guild.py +++ b/chouette/utils/skyblock_guild.py @@ -2,40 +2,19 @@ import aiohttp +from chouette.utils.skyblock import hypixel_discord, minecraft_uuid + api_hypixel = "https://api.hypixel.net/v2/" -# Function to return response from url async def fetch(session, url, params=None): + """Effectue une requête GET asynchrone à l'URL spécifiée avec les paramètres donnés, sous forme JSON.""" async with session.get(url, params=params) as response: return await response.json() -# Function to return Hypixel discord with their API -async def return_discord_hypixel(session, uuid, token_hypixel): - response = await fetch(session, f"{api_hypixel}player", {"key": token_hypixel, "uuid": uuid}) - try: - return response["player"]["socialMedia"]["links"]["DISCORD"] - except Exception: - if response["success"] == "true": - return 0 - return None - - -# Function to return player uuid from Mojang API with pseudo -async def return_uuid(session, pseudo): - response = await fetch(session, f"https://api.mojang.com/users/profiles/minecraft/{pseudo}") - try: - return response["id"] - except Exception: - # This Minecraft pseudo doesn't exist - if response["errorMessage"] == f"Couldn't find any profile with name {pseudo}": - return 0 - return None - - -# Function to return the player guild from Hypixel API async def return_guild(session, name, token_hypixel): + """Récupère les informations d'une guilde Hypixel avec l'API.""" response = await fetch(session, f"{api_hypixel}guild", {"key": token_hypixel, "name": name}) try: return response["guild"] @@ -43,27 +22,28 @@ async def return_guild(session, name, token_hypixel): return None -# Function to know if the player is in a Hypixel guild -async def is_in_guild(session, uuid, g_name, token_hypixel): +async def is_in_guild(session, uuid, g_name, token_hypixel) -> bool | None: + """Vérifie si un joueur est dans une guilde Hypixel.""" guild = await return_guild(session, g_name, token_hypixel) if guild is None: return None return any(member["uuid"] == uuid for member in guild["members"]) -# Function to check some information async def check(pseudo, guild, discord): + """Vérifie des informations sur un joueur Minecraft. + On vérifie si le pseudo Minecraft existe, si le pseudo Discord correspond à celui associé à Hypixel, + et si le joueur est dans une guilde Hypixel.""" token_hypixel = getenv("HYPIXEL_KEY") async with aiohttp.ClientSession() as session: - uuid = await return_uuid(session, pseudo) - if uuid == 0: - return f"Il n'y a pas de compte Minecraft avec ce pseudo : {pseudo}" - if uuid is None: - return "Something wrong happened" - - discord_mc = await return_discord_hypixel(session, uuid, token_hypixel) - if discord_mc == 0: - return "Vous n'avez pas entré de pseudo Discord sur le serveur Hypixel" + uuid = await minecraft_uuid(session, pseudo) + if not uuid[0]: + return uuid[1] + uuid = uuid[1] + + discord_mc = await hypixel_discord(session, token_hypixel, uuid) + if not discord_mc[0]: + return discord_mc[1] if discord_mc != discord: return "Votre pseudo Discord ne correspond pas à celui entré sur le serveur Hypixel" if discord_mc is None: diff --git a/chouette/utils/version.py b/chouette/utils/version.py index c7220db..576db0d 100644 --- a/chouette/utils/version.py +++ b/chouette/utils/version.py @@ -4,8 +4,8 @@ SPACES = " " * 38 -# Generate the message to log the bot running version async def get_version() -> str: + """Génère un message avec les informations de version.""" msg = "Version information:\n" # Check if the environnement is Docker or not diff --git a/pyproject.toml b/pyproject.toml index ef08483..3a449de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ line-length = 99 [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "RET", "SIM", "RUF", "T20", "ERA", "ASYNC"] ignore = ["E501"] +allowed-confusables = ["‘", "’", "–", "′"] [tool.ruff.format] indent-style = "space" diff --git a/requirements-dev.txt b/requirements-dev.txt index fa3ff46..c34bc28 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,3 @@ -r requirements.txt pre-commit ~= 3.7 -ruff == 0.4.5 +ruff == 0.4.10 diff --git a/requirements.txt b/requirements.txt index a6559e7..a6b0714 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ aiohttp ~= 3.9.5 -discord.py[speed] ~= 2.3.2 +discord.py[speed] ~= 2.4.0 python-dotenv ~= 1.0.1 tomlkit ~= 0.12.5 tzdata; os_name == "nt"