diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000..e613008 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,72 @@ +name: Bug report +description: Report a bug to help us improve the bot +title: "[BUG] Concise description of the issue" +labels: bug +assignees: + - Zalk0 + - gylfirst + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + - type: input + id: title + attributes: + label: Bug Title + description: A clear and concise title of what the bug is. + placeholder: Bug title + + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of what the bug is. + placeholder: Describe the bug + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: | + Steps to reproduce the behavior: + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + placeholder: Steps to reproduce the bug + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. + placeholder: Expected behavior + + - type: textarea + id: actual + attributes: + label: Actual behavior + description: A clear and concise description of what actually happened. + placeholder: Actual behavior + + - type: input + id: environment + attributes: + label: Environment + description: | + Provide details about the environment running the bot: + - OS and version: [e.g. Windows, MacOS, Linux] + - Technology: [e.g. Docker, Git clone] + - Python version: [e.g. 3.9.5] (if not using Docker) + - Bot version [e.g. Docker image tag or git commit hash] + placeholder: Environment details + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add any other context about the problem here. + placeholder: Additional context diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..3d7dcd9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,41 @@ +name: Feature request +description: Suggest an idea for this project +title: "[Feature Request]: Concise description of the feature" +labels: enhancement +assignees: + - Zalk0 + - gylfirst + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to open a feature request! Please provide us with the following information: + + - type: input + id: feature + attributes: + label: Feature description + description: A clear and concise description of what the feature is. + placeholder: Describe the feature you would like to see + + - type: textarea + id: motivation + attributes: + label: Motivation + description: Please explain why this feature should be implemented and how it would be used. + placeholder: Explain the motivation behind this feature request + + - type: textarea + id: alternatives + attributes: + label: Alternatives + description: Describe any alternative solutions or features you've considered. + placeholder: Describe any alternative solutions or features + + - type: input + id: additional-context + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + placeholder: Add any other context or screenshots diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6d04d6..67fa286 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.7.3 + rev: v0.8.0 hooks: # Run the linter. - id: ruff diff --git a/chouette/commands/skyblock.py b/chouette/commands/skyblock.py index c643b97..53356b0 100644 --- a/chouette/commands/skyblock.py +++ b/chouette/commands/skyblock.py @@ -115,6 +115,8 @@ async def spider(self, interaction: discord.Interaction[ChouetteBot]) -> None: @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é") + # Cooldown 1 use per 60 seconds + @app_commands.checks.cooldown(rate=1, per=60) async def link( self, interaction: discord.Interaction[ChouetteBot], pseudo: str, profile: str | None ): diff --git a/chouette/commands_list.py b/chouette/commands_list.py index f91993a..0605004 100644 --- a/chouette/commands_list.py +++ b/chouette/commands_list.py @@ -50,7 +50,7 @@ async def on_command_error( bot_perms = ", ".join(error.missing_permissions) interaction.client.bot_logger.error( f"{interaction.client.user} is missing {bot_perms} " - f"to do {interaction.command.name} in #{interaction.channel}" + f"to do /{interaction.command.qualified_name} in #{interaction.channel}" ) if len(error.missing_permissions) == 1: await interaction.response.send_message( @@ -67,7 +67,7 @@ async def on_command_error( user_perms = ", ".join(error.missing_permissions) interaction.client.bot_logger.error( f"{interaction.user} is missing {user_perms} " - f"to do {interaction.command.name} in #{interaction.channel}" + f"to do /{interaction.command.qualified_name} in #{interaction.channel}" ) if len(error.missing_permissions) == 1: await interaction.response.send_message( @@ -80,9 +80,19 @@ async def on_command_error( ephemeral=True, ) return + if isinstance(error, discord.app_commands.CommandOnCooldown): + interaction.client.bot_logger.error( + f"{interaction.user} tried to use /{interaction.command.qualified_name} in " + f"#{interaction.channel.name} but the command is in cooldown for {error.retry_after:.0f}s", + ) + await interaction.response.send_message( + f"Vous devez attendre {error.retry_after:.0f} secondes avant de réutiliser cette commande.", + ephemeral=True, + ) + return if isinstance(error, discord.app_commands.CheckFailure): interaction.client.bot_logger.error( - f"{interaction.user} tried to do {interaction.command.name} " + f"{interaction.user} tried to do /{interaction.command.qualified_name} " f"in #{interaction.channel}\n{SPACES}{error}" ) await interaction.response.send_message( diff --git a/chouette/utils/hypixel_data.py b/chouette/utils/hypixel_data.py index 3c40091..486b6b7 100644 --- a/chouette/utils/hypixel_data.py +++ b/chouette/utils/hypixel_data.py @@ -8,13 +8,14 @@ def experience_to_level( 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. - max_level: Le niveau maximum + `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_wolf`, `slayer_enderman`, `slayer_blaze`,` slayer_vampire`. + `xp_amount`: La quantité d'expérience cumulée. + `max_level`: Le niveau maximum Returns: - level: Le niveau correspondant à la quantité donnée d'expérience cumulée. + `level`: Le niveau correspondant à la quantité donnée d'expérience cumulée. + `overflow`: L'expérience restante pour atteindre le prochain niveau. """ skill_xp_data: list[int] = [ 0, @@ -187,7 +188,7 @@ def experience_to_level( elif type_xp == "slayer_spider": xp_data = slayer_xp_data[1] # Slayer Wolf/Enderman/Blaze - elif type_xp == "slayer_web": + elif type_xp in ["slayer_wolf", "slayer_enderman", "slayer_blaze"]: xp_data = slayer_xp_data[2] # Slayer Vampire elif type_xp == "slayer_vampire": @@ -200,5 +201,5 @@ def experience_to_level( return max_level, xp_amount - xp if xp_amount <= xp: previous_xp = xp_data[level - 1] - return level - 1 + (xp_amount - previous_xp) / (xp - previous_xp), None + return level - 1 + (xp_amount - previous_xp) / (xp - previous_xp), 0 return len(xp_data) - 1, xp_amount - xp_data[-1] diff --git a/chouette/utils/ranking.py b/chouette/utils/ranking.py index e5b048f..95ba1ae 100644 --- a/chouette/utils/ranking.py +++ b/chouette/utils/ranking.py @@ -1,5 +1,7 @@ +import copy import math from datetime import date +from itertools import chain import aiohttp import discord @@ -28,10 +30,23 @@ def format_number(number) -> str: return str(number) +def format_ranking_message(player: str, value: str, i: int) -> str: + """Formate le message pour les données du classement de la guilde sur Hypixel Skyblock.""" + 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}]" + return message + + 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() + new_data = copy.deepcopy(old_data) msg = "Synchro des données de la guilde sur Hypixel Skyblock pour :" async with aiohttp.ClientSession() as session: for uuid in old_data: @@ -49,7 +64,7 @@ async def update_stats(api_key: str) -> str: return msg -def parse_data(data: dict) -> tuple[dict, list]: +def parse_data(data: dict) -> dict: """Parse les données de la guilde sur Hypixel Skyblock.""" ranking = {} skills = [ @@ -60,7 +75,7 @@ def parse_data(data: dict) -> tuple[dict, list]: "enchanting", "taming", "foraging", - "carpentery", + "carpentry", "combat", "dungeoneering", ] @@ -78,43 +93,73 @@ def parse_data(data: dict) -> tuple[dict, list]: if key == "skills": for skill in skills: if skill not in ranking: - ranking[skill] = {} - ranking[skill][data[player]["pseudo"]] = value[skills.index(skill)] + ranking[skill] = {"level": {}, "overflow": {}} if "skill average" not in ranking: ranking["skill average"] = {} - ranking["skill average"][data[player]["pseudo"]] = [] + ranking["skill average"][data[player]["pseudo"]] = None # 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)] + ranking[slayer] = {"level": {}, "overflow": {}} if key == "level_cap": level_cap[0].append(value[0]) level_cap[1].append(value[1]) + # Calculate the level and overflow for each skill and slayer for each player + for player_index, player in enumerate(data): + for skill in chain(skills, slayers): + type_xp = "skill" + category = "skills" + max_level = None + skill_list = skills + + if skill == "farming": + max_level = level_cap[0][player_index] + 50 + if skill == "taming": + max_level = level_cap[1][player_index] if level_cap[1][player_index] > 50 else 50 + if skill in ["fishing", "alchemy", "carpentry", "foraging"]: + max_level = 50 + if skill == "dungeoneering": + type_xp = "dungeon" + if skill in slayers: + type_xp = f"slayer_{skill}" + category = "slayers" + skill_list = slayers + + level, overflow = experience_to_level( + type_xp, data[player][category][skill_list.index(skill)], max_level + ) + ranking[skill]["level"][data[player]["pseudo"]] = level + ranking[skill]["overflow"][data[player]["pseudo"]] = overflow + # Sorting the nested dictionaries by value + sorted_ranking: dict = ranking.copy() for category in ranking: if isinstance(ranking[category], dict): - unsorted = ranking[category] - ranking[category] = dict( - sorted(ranking[category].items(), key=lambda item: item[1], reverse=True) - ) - # In the case of farming we need to sort the level_cap too - if category == "farming": - level_cap[0] = [ - level_cap[0][list(unsorted.keys()).index(key)] for key in ranking[category] - ] - if category == "taming": - level_cap[1] = [ - level_cap[1][list(unsorted.keys()).index(key)] for key in ranking[category] - ] + # Level and Networth + if category in ["level", "networth"]: + sorted_ranking[category] = dict( + sorted(ranking[category].items(), key=lambda item: item[1], reverse=True) + ) + # Skills and slayers + if category in chain(skills, slayers): + sorted_ranking[category] = { + "level": dict( + sorted( + ranking[category]["level"].items(), + key=lambda item: (item[1], ranking[category]["overflow"][item[0]]), + reverse=True, + ) + ), + "overflow": dict(ranking[category]["overflow"].items()), + } else: - raise ValueError(f"Unknown category while sorting: {category}") - return ranking, level_cap + raise ValueError(f"Unknown category while sorting the ranking: {category}") + return sorted_ranking -def generate_ranking_message(data, category, level_cap): +def generate_ranking_message(data, category) -> list[str]: skills_list: list[str] = [ "fishing", "alchemy", @@ -123,7 +168,7 @@ def generate_ranking_message(data, category, level_cap): "enchanting", "taming", "foraging", - "carpentery", + "carpentry", "combat", "dungeoneering", ] @@ -135,76 +180,52 @@ def generate_ranking_message(data, category, level_cap): "blaze", "vampire", ] - messages = [] - if category == "skill average": - skill_average = {} - for _i, (player, value) in enumerate(data[category].items()): - skill_average[player] = sum(value) / len(value) - skill_average = dict(sorted(skill_average.items(), key=lambda item: item[1], reverse=True)) - - for i, (player, value) in enumerate(skill_average.items()): + messages: list = [] + # Level + if category == "level": + for i, (player, value) in enumerate(data[category].items()): value = f"{value:.2f}" - 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}]" + message = format_ranking_message(player, value, i) messages.append(message) - return data, messages - for i, (player, value) in enumerate(data[category].items()): - overflow = None - if category not in ("level", "networth", "skill average"): - if category in skills_list: - if category != "dungeoneering": - max_level = 60 - # Set max level to 50 for skills (alchemy, carpentery, fishing, foraging) - if category in ("alchemy", "carpentery", "fishing", "foraging"): - max_level = 50 - # Set max level to level cap for farming - if category == "farming": - max_level = level_cap[0][i] + 50 - if category == "taming": - max_level = max(min(level_cap[1][i], 60), 50) - value, overflow = experience_to_level("skill", value, max_level) - data["skill average"][player].append(math.floor(value)) - else: - value, overflow = experience_to_level("dungeon", value) - elif category in slayers_list: - if category == "zombie": - value, overflow = experience_to_level("slayer_zombie", value) - elif category == "spider": - value, overflow = experience_to_level("slayer_spider", value) - elif category == "vampire": - value, overflow = experience_to_level("slayer_vampire", value) - else: - value, overflow = experience_to_level("slayer_web", value) - else: - raise ValueError(f"Unknown category in the ranking: {category}") - + # Networth + if category == "networth": + for i, (player, value) in enumerate(data[category].items()): + value = format_number(value) + message = format_ranking_message(player, value, i) + messages.append(message) + # Skills and slayers + if category in chain(skills_list, slayers_list): + for i, (player, value) in enumerate(data[category]["level"].items()): + overflow = data[category]["overflow"][player] if overflow: value = f"{value:.0f}" overflow = math.floor(overflow) else: value = f"{value:.2f}" - - elif category == "networth": - value = 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}]" - if overflow: - message += f" (*{overflow:,}*)".replace(",", " ") - messages.append(message) - return data, messages + message = format_ranking_message(player, value, i) + if overflow: + message += f" (*{overflow:,}*)".replace(",", " ") + messages.append(message) + # Skill average + skills_avg: list[str] = skills_list.copy() + skills_avg.remove("dungeoneering") + if category == "skill average": + for player in data[category]: + total = [] + # Calculate the average of the skills + for skill in skills_avg: + total.append(math.floor(data[skill]["level"][player])) + average = math.fsum(total) / len(total) + data[category][player] = average + # Sort the skill average + data[category] = dict( + sorted(data[category].items(), key=lambda item: item[1], reverse=True) + ) + for i, (player, value) in enumerate(data[category].items()): + value = f"{value:.2f}" + message = format_ranking_message(player, value, i) + messages.append(message) + return messages async def display_ranking(img: str) -> discord.Embed: @@ -218,10 +239,10 @@ async def display_ranking(img: str) -> discord.Embed: 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 = parse_data(await load_skyblock()) + data = parse_data(await load_skyblock()) for category in data: if isinstance(data[category], dict): - data, messages = generate_ranking_message(data, category, level_cap) + messages = generate_ranking_message(data, category) ranking.add_field( name=f"**[ {category.capitalize()} ]**", value="\n".join(messages), diff --git a/chouette/utils/skyblock.py b/chouette/utils/skyblock.py index ed27c59..69a97e0 100644 --- a/chouette/utils/skyblock.py +++ b/chouette/utils/skyblock.py @@ -92,6 +92,8 @@ async def get_hypixel_player(session: ClientSession, api_key: str, uuid: str) -> 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: + if "Cloudflare" in await response.text(): + raise Exception("Fetching networth on SkyCrypt API failed because of Cloudflare") 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: @@ -163,6 +165,13 @@ async def pseudo_to_profile( return discord[1] discord = discord[1] if discord != discord_pseudo: + if discord.lower() == discord_pseudo: + return ( + "Vous avez entré le bon pseudo Discord sur Hypixel " + "mais il contient des majuscules !" + ) + if not discord.islower(): + return "Le pseudo Discord entré sur Hypixel contient des majuscules !" return "Votre pseudo Discord ne correspond pas à celui entré sur le serveur Hypixel" client.bot_logger.debug("Les pseudos Discord correspondent") diff --git a/requirements-dev.txt b/requirements-dev.txt index 9546205..6343d06 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,3 @@ -r requirements.txt pre-commit ~= 4.0 -ruff == 0.7.3 +ruff == 0.8.0 diff --git a/requirements.txt b/requirements.txt index acbdf8a..d634ec4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -aiohttp ~= 3.10.10 +aiohttp ~= 3.10.11 discord.py[speed] ~= 2.4.0 python-dotenv ~= 1.0.1 tomlkit ~= 0.13.2