From 273110347cbb930a09aa27e285b485de650fc570 Mon Sep 17 00:00:00 2001 From: FirePlank <44502537+FirePlank@users.noreply.github.com> Date: Tue, 1 Jul 2025 00:36:31 +0300 Subject: [PATCH 1/6] feat: Improves Clash of Code session management Adds functionality for creating, joining, starting, and ending Clash of Code sessions directly within Discord. This change introduces commands to initiate and manage sessions, including role assignment for participants and integration with the Codingame API for clash creation and management. It also adds dynamic cooldowns, error handling, and UI enhancements. --- bot/config.py | 1 + bot/extensions/clashofcode/commands.py | 113 +++++++++++++-- bot/extensions/clashofcode/utils.py | 7 +- bot/extensions/clashofcode/views.py | 87 ++++++++++- bot/models/migrations/006_up__configs.sql | 1 - example.env | 1 + poetry.lock | 169 ++++++++++++++-------- pyproject.toml | 4 +- 8 files changed, 306 insertions(+), 77 deletions(-) diff --git a/bot/config.py b/bot/config.py index 3710a6c0..a04e5176 100644 --- a/bot/config.py +++ b/bot/config.py @@ -41,6 +41,7 @@ class CoC(BaseModel): channel_id: int message_id: int role_id: int + session_role_id: int session_cookie: str diff --git a/bot/extensions/clashofcode/commands.py b/bot/extensions/clashofcode/commands.py index 7d44499c..38500c86 100644 --- a/bot/extensions/clashofcode/commands.py +++ b/bot/extensions/clashofcode/commands.py @@ -1,26 +1,35 @@ +import asyncio import logging +import time import discord from aiohttp import ContentTypeError from codingame.http import HTTPError -from discord import app_commands +from discord import app_commands, ui +from discord.app_commands import Cooldown from discord.ext import commands from bot import core from bot.config import settings from bot.extensions.clashofcode.utils import coc_client, coc_helper -from bot.extensions.clashofcode.views import CreateCocView +from bot.extensions.clashofcode.views import CocMessageView, CreateCocView, em log = logging.getLogger(__name__) +def new_coc_cooldown(interaction: discord.Interaction) -> Cooldown | None: + """Dynamic cooldown for the /coc new command.""" + if settings.moderation.staff_role_id in [role.id for role in interaction.user.roles]: + return None + return Cooldown(1, 60 * 15) + + @app_commands.default_permissions(administrator=True) class ClashOfCode(commands.GroupCog, group_name="coc"): def __init__(self, bot: core.DiscordBot): self.bot = bot - self._create_coc_view = CreateCocView(timeout=None) - self.bot.add_view(self._create_coc_view) + self.bot.add_view(CocMessageView()) async def cog_load(self): await coc_client.login(remember_me_cookie=settings.coc.session_cookie) @@ -34,14 +43,43 @@ async def interaction_check(self, interaction: core.InteractionType): return True @app_commands.command() + @app_commands.checks.dynamic_cooldown(new_coc_cooldown, key=lambda i: i.guild_id) async def new(self, interaction: core.InteractionType): - """Create a new coc for the session""" + """Creates a new Clash of Code session.""" + if coc_helper.session: + return await interaction.response.send_message("A session is already active.", ephemeral=True) - await interaction.response.send_message( - "Select the programming languages and modes you want to use", - ephemeral=True, - view=self._create_coc_view, + await interaction.response.defer() + + coc_helper.session = True + coc_helper.host = interaction.user + + role = interaction.guild.get_role(settings.coc.session_role_id) + if not role: + coc_helper.session = False + coc_helper.host = None + return await interaction.followup.send("The session role could not be found.", ephemeral=True) + coc_helper.session_role = role + + try: + await interaction.user.add_roles(role) + except discord.HTTPException as e: + log.error(f"Failed to add role to {interaction.user.display_name}", exc_info=e) + + ping_role = interaction.guild.get_role(settings.coc.role_id) + + view = CocMessageView() + msg = await interaction.channel.send( + ( + f"**Hey, {ping_role.mention}, {interaction.user.mention} is hosting a Clash Of Code session!**\n" + f"The host can use the 'Create Game' button to start a clash. Everyone else can join the session to get pinged!" + ), + allowed_mentions=discord.AllowedMentions(roles=True, users=True), + view=view, ) + coc_helper.message = msg + + await interaction.followup.send("Session created!", ephemeral=True) @app_commands.command() async def start(self, interaction: core.InteractionType): @@ -68,7 +106,7 @@ async def start(self, interaction: core.InteractionType): await interaction.response.send_message( "Clash started!", - ephemeral=False, + ephemeral=True, ) @app_commands.command() @@ -78,11 +116,64 @@ async def end(self, interaction: core.InteractionType): if not coc_helper.session: return await interaction.response.send_message("There is no active clash of code session", ephemeral=True) + staff_role = interaction.guild.get_role(settings.moderation.staff_role_id) + is_staff = staff_role in interaction.user.roles + + if coc_helper.host != interaction.user and not is_staff: + return await interaction.response.send_message( + "Only the session host or a staff member can end the session.", ephemeral=True + ) + + if coc_helper.message: + try: + view = CocMessageView() + button = discord.utils.get(view.children, custom_id=CocMessageView.JOIN_LEAVE_CUSTOM_ID) + button.disabled = True + await coc_helper.message.edit(view=view) + except discord.HTTPException as e: + log.error("Failed to edit message to disable button", exc_info=e) + + if coc_helper.session_role: + for member in coc_helper.session_role.members: + try: + await member.remove_roles(coc_helper.session_role) + except discord.HTTPException as e: + log.error(f"Failed to remove role from {member.display_name}", exc_info=e) + coc_helper.last_clash = 0 coc_helper.session = False coc_helper.clash = None + coc_helper.session_role = None + coc_helper.message = None + coc_helper.host = None + coc_helper.languages = None + coc_helper.modes = None + coc_helper.handle = None - return await interaction.response.send_message( + await interaction.response.send_message( f"Clash session has been closed by {interaction.user.mention}. See you later", allowed_mentions=discord.AllowedMentions(users=True), ) + + async def cog_app_command_error(self, interaction: core.InteractionType, error: app_commands.AppCommandError): + if isinstance(error, app_commands.CommandOnCooldown): + seconds = int(error.retry_after) + if seconds < 60: + time_str = f"{seconds} second{'s' if seconds != 1 else ''}" + else: + minutes = seconds // 60 + remaining_seconds = seconds % 60 + time_str = f"{minutes} minute{'s' if minutes != 1 else ''}" + if remaining_seconds > 0: + time_str += f" and {remaining_seconds} second{'s' if remaining_seconds != 1 else ''}" + + await interaction.response.send_message( + f"This command is on cooldown. Please try again in {time_str}.", + ephemeral=True, + ) + else: + log.error("An unhandled error occurred in the Clash of Code cog.", exc_info=error) + + +async def setup(bot: core.DiscordBot): + await bot.add_cog(ClashOfCode(bot=bot)) diff --git a/bot/extensions/clashofcode/utils.py b/bot/extensions/clashofcode/utils.py index 494e2d36..c35f7663 100644 --- a/bot/extensions/clashofcode/utils.py +++ b/bot/extensions/clashofcode/utils.py @@ -6,7 +6,12 @@ def __init__(self): self.session = False self.clash = None self.last_clash = 0 - + self.host: discord.Member | None = None + self.session_role: discord.Role | None = None + self.message: discord.Message | None = None + self.languages: list[str] | None = None + self.modes: list[str] | None = None + self.handle: str | None = None coc_helper = ClashOfCodeHelper() coc_client = codingame.Client(is_async=True) diff --git a/bot/extensions/clashofcode/views.py b/bot/extensions/clashofcode/views.py index 8a13d49e..ddcd5527 100644 --- a/bot/extensions/clashofcode/views.py +++ b/bot/extensions/clashofcode/views.py @@ -21,6 +21,73 @@ def em(mode: str, players: str): return embed +async def resolve_member(guild: discord.Guild, name: str) -> discord.Member | None: + """Case-insensitive search for a member in a guild by name or display name.""" + return discord.utils.find( + lambda m: m.display_name.lower() == name.lower() or m.name.lower() == name.lower(), guild.members + ) + + +class CocMessageView(ui.View): + JOIN_LEAVE_CUSTOM_ID = "extensions:clashofcode:join_leave" + CREATE_GAME_CUSTOM_ID = "extensions:clashofcode:create_game" + + def __init__(self): + super().__init__(timeout=None) + + @discord.ui.button( + label="Join/Leave Session", + style=discord.ButtonStyle.primary, + custom_id=JOIN_LEAVE_CUSTOM_ID, + ) + async def join_leave_session(self, interaction: core.InteractionType, button: ui.Button): + if not coc_helper.session: + button.disabled = True + await interaction.response.edit_message(view=self) + return await interaction.followup.send("This clash of code session has already ended.", ephemeral=True) + + role = coc_helper.session_role + if not role: + await interaction.response.send_message("The session role could not be found.", ephemeral=True) + return + + if role in interaction.user.roles: + try: + await interaction.user.remove_roles(role) + await interaction.response.send_message("You have left the session.", ephemeral=True) + except discord.HTTPException: + await interaction.response.send_message("Failed to leave the session.", ephemeral=True) + else: + try: + await interaction.user.add_roles(role) + await interaction.response.send_message("You have joined the session.", ephemeral=True) + except discord.HTTPException: + await interaction.response.send_message("Failed to join the session.", ephemeral=True) + + @discord.ui.button( + label="Create Game", + style=discord.ButtonStyle.success, + custom_id=CREATE_GAME_CUSTOM_ID, + ) + async def create_game(self, interaction: core.InteractionType, button: ui.Button): + is_staff = settings.moderation.staff_role_id in [role.id for role in interaction.user.roles] + if interaction.user != coc_helper.host and not is_staff: + return await interaction.response.send_message( + "Only the session host or a staff member can create a game.", ephemeral=True + ) + + if coc_helper.handle: + return await interaction.response.send_message( + "A game has already been created for this session.", ephemeral=True + ) + + await interaction.response.send_message( + "Select the programming languages and modes you want to use for the game", + ephemeral=True, + view=CreateCocView(), + ) + + class CreateCocView(ui.View): LANGUAGES_CUSTOM_ID = "extensions:clashofcode:languages" MODES_CUSTOM_ID = "extensions:clashofcode:modes" @@ -78,9 +145,15 @@ async def select_modes(self, interaction: core.InteractionType, _select: ui.Sele await interaction.response.defer() @discord.ui.button( - label="Create Clash of Code", style=discord.ButtonStyle.green, emoji="📝", custom_id=CREATE_CUSTOM_ID, row=3 + label="Create Game", style=discord.ButtonStyle.green, emoji="📝", custom_id=CREATE_CUSTOM_ID, row=3 ) async def create_coc(self, interaction: core.InteractionType, _button: ui.Button): + is_staff = settings.moderation.staff_role_id in [role.id for role in interaction.user.roles] + if interaction.user != coc_helper.host and not is_staff: + return await interaction.response.send_message( + "Only the session host or a staff member can create a game.", ephemeral=True + ) + selected_languages = discord.utils.get(self.children, custom_id=self.LANGUAGES_CUSTOM_ID).values selected_modes = discord.utils.get(self.children, custom_id=self.MODES_CUSTOM_ID).values @@ -103,6 +176,7 @@ async def create_coc(self, interaction: core.InteractionType, _button: ui.Button [coc_client.codingamer.id, selected_languages, selected_modes], ) handle = data["publicHandle"] + coc_helper.handle = handle msg = await interaction.channel.send( ( @@ -110,7 +184,7 @@ async def create_coc(self, interaction: core.InteractionType, _button: ui.Button f"Mode{'s' if len(selected_modes) > 1 else ''}: {', '.join(selected_modes).title()}\n" f"Programming languages: {', '.join(selected_languages) if selected_languages else 'All'}\n" f"Join here: \n" - f"{f'<@&{settings.coc.role_id}>' if not coc_helper.session else ''}" + f"<@&{settings.coc.session_role_id}>" ), allowed_mentions=discord.AllowedMentions(users=True, roles=True), ) @@ -128,8 +202,9 @@ async def create_coc(self, interaction: core.InteractionType, _button: ui.Button tries -= 1 if not clash.started: - coc_helper.session = False - await msg.reply("Canceling clash due to lack of participants.") + coc_helper.clash = None + coc_helper.handle = None + await msg.reply("Canceling clash due to lack of participants.", view=CocMessageView()) try: await coc_client.request( @@ -195,6 +270,8 @@ async def create_coc(self, interaction: core.InteractionType, _button: ui.Button players = [player.pseudo for player in clash.players if player.id != coc_client.codingamer.id] await start_message.edit(embed=em(clash.mode, ", ".join(players))) + coc_helper.clash = None + coc_helper.handle = None coc_helper.session = False embed = discord.Embed( @@ -211,4 +288,4 @@ async def create_coc(self, interaction: core.InteractionType, _button: ui.Button ), ) - await msg.reply(embed=embed) + await msg.reply(embed=embed, view=CocMessageView()) \ No newline at end of file diff --git a/bot/models/migrations/006_up__configs.sql b/bot/models/migrations/006_up__configs.sql index af619dc8..f2de5e58 100644 --- a/bot/models/migrations/006_up__configs.sql +++ b/bot/models/migrations/006_up__configs.sql @@ -14,4 +14,3 @@ ALTER TABLE migrations ALTER COLUMN version SET NOT NULL; ALTER TABLE migrations ALTER COLUMN direction SET NOT NULL; ALTER TABLE migrations ALTER COLUMN name SET NOT NULL; ALTER TABLE migrations ALTER COLUMN timestamp SET NOT NULL; -ALTER TABLE migrations ADD PRIMARY KEY (id); diff --git a/example.env b/example.env index 9a2e5fa6..0f27701e 100644 --- a/example.env +++ b/example.env @@ -25,6 +25,7 @@ CHALLENGES__WINNER_ROLE_ID= COC__CHANNEL_ID= COC__MESSAGE_ID= COC__ROLE_ID= +COC__SESSION_ROLE_ID= COC__SESSION_COOKIE= # --- Postgres diff --git a/poetry.lock b/poetry.lock index 30198fc3..0b09eaa7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -169,69 +169,78 @@ description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" groups = ["main", "dev"] +markers = "python_version == \"3.10\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, ] -markers = {main = "python_version < \"3.12.0\"", dev = "python_version < \"3.11\""} [[package]] name = "asyncpg" -version = "0.29.0" +version = "0.30.0" description = "An asyncio PostgreSQL driver" optional = false python-versions = ">=3.8.0" groups = ["main"] files = [ - {file = "asyncpg-0.29.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72fd0ef9f00aeed37179c62282a3d14262dbbafb74ec0ba16e1b1864d8a12169"}, - {file = "asyncpg-0.29.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52e8f8f9ff6e21f9b39ca9f8e3e33a5fcdceaf5667a8c5c32bee158e313be385"}, - {file = "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e6823a7012be8b68301342ba33b4740e5a166f6bbda0aee32bc01638491a22"}, - {file = "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746e80d83ad5d5464cfbf94315eb6744222ab00aa4e522b704322fb182b83610"}, - {file = "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ff8e8109cd6a46ff852a5e6bab8b0a047d7ea42fcb7ca5ae6eaae97d8eacf397"}, - {file = "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97eb024685b1d7e72b1972863de527c11ff87960837919dac6e34754768098eb"}, - {file = "asyncpg-0.29.0-cp310-cp310-win32.whl", hash = "sha256:5bbb7f2cafd8d1fa3e65431833de2642f4b2124be61a449fa064e1a08d27e449"}, - {file = "asyncpg-0.29.0-cp310-cp310-win_amd64.whl", hash = "sha256:76c3ac6530904838a4b650b2880f8e7af938ee049e769ec2fba7cd66469d7772"}, - {file = "asyncpg-0.29.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4900ee08e85af01adb207519bb4e14b1cae8fd21e0ccf80fac6aa60b6da37b4"}, - {file = "asyncpg-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a65c1dcd820d5aea7c7d82a3fdcb70e096f8f70d1a8bf93eb458e49bfad036ac"}, - {file = "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b52e46f165585fd6af4863f268566668407c76b2c72d366bb8b522fa66f1870"}, - {file = "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc600ee8ef3dd38b8d67421359779f8ccec30b463e7aec7ed481c8346decf99f"}, - {file = "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:039a261af4f38f949095e1e780bae84a25ffe3e370175193174eb08d3cecab23"}, - {file = "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6feaf2d8f9138d190e5ec4390c1715c3e87b37715cd69b2c3dfca616134efd2b"}, - {file = "asyncpg-0.29.0-cp311-cp311-win32.whl", hash = "sha256:1e186427c88225ef730555f5fdda6c1812daa884064bfe6bc462fd3a71c4b675"}, - {file = "asyncpg-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfe73ffae35f518cfd6e4e5f5abb2618ceb5ef02a2365ce64f132601000587d3"}, - {file = "asyncpg-0.29.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6011b0dc29886ab424dc042bf9eeb507670a3b40aece3439944006aafe023178"}, - {file = "asyncpg-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b544ffc66b039d5ec5a7454667f855f7fec08e0dfaf5a5490dfafbb7abbd2cfb"}, - {file = "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d84156d5fb530b06c493f9e7635aa18f518fa1d1395ef240d211cb563c4e2364"}, - {file = "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54858bc25b49d1114178d65a88e48ad50cb2b6f3e475caa0f0c092d5f527c106"}, - {file = "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bde17a1861cf10d5afce80a36fca736a86769ab3579532c03e45f83ba8a09c59"}, - {file = "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:37a2ec1b9ff88d8773d3eb6d3784dc7e3fee7756a5317b67f923172a4748a175"}, - {file = "asyncpg-0.29.0-cp312-cp312-win32.whl", hash = "sha256:bb1292d9fad43112a85e98ecdc2e051602bce97c199920586be83254d9dafc02"}, - {file = "asyncpg-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:2245be8ec5047a605e0b454c894e54bf2ec787ac04b1cb7e0d3c67aa1e32f0fe"}, - {file = "asyncpg-0.29.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0009a300cae37b8c525e5b449233d59cd9868fd35431abc470a3e364d2b85cb9"}, - {file = "asyncpg-0.29.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cad1324dbb33f3ca0cd2074d5114354ed3be2b94d48ddfd88af75ebda7c43cc"}, - {file = "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:012d01df61e009015944ac7543d6ee30c2dc1eb2f6b10b62a3f598beb6531548"}, - {file = "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000c996c53c04770798053e1730d34e30cb645ad95a63265aec82da9093d88e7"}, - {file = "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e0bfe9c4d3429706cf70d3249089de14d6a01192d617e9093a8e941fea8ee775"}, - {file = "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:642a36eb41b6313ffa328e8a5c5c2b5bea6ee138546c9c3cf1bffaad8ee36dd9"}, - {file = "asyncpg-0.29.0-cp38-cp38-win32.whl", hash = "sha256:a921372bbd0aa3a5822dd0409da61b4cd50df89ae85150149f8c119f23e8c408"}, - {file = "asyncpg-0.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:103aad2b92d1506700cbf51cd8bb5441e7e72e87a7b3a2ca4e32c840f051a6a3"}, - {file = "asyncpg-0.29.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5340dd515d7e52f4c11ada32171d87c05570479dc01dc66d03ee3e150fb695da"}, - {file = "asyncpg-0.29.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e17b52c6cf83e170d3d865571ba574577ab8e533e7361a2b8ce6157d02c665d3"}, - {file = "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f100d23f273555f4b19b74a96840aa27b85e99ba4b1f18d4ebff0734e78dc090"}, - {file = "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48e7c58b516057126b363cec8ca02b804644fd012ef8e6c7e23386b7d5e6ce83"}, - {file = "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f9ea3f24eb4c49a615573724d88a48bd1b7821c890c2effe04f05382ed9e8810"}, - {file = "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8d36c7f14a22ec9e928f15f92a48207546ffe68bc412f3be718eedccdf10dc5c"}, - {file = "asyncpg-0.29.0-cp39-cp39-win32.whl", hash = "sha256:797ab8123ebaed304a1fad4d7576d5376c3a006a4100380fb9d517f0b59c1ab2"}, - {file = "asyncpg-0.29.0-cp39-cp39-win_amd64.whl", hash = "sha256:cce08a178858b426ae1aa8409b5cc171def45d4293626e7aa6510696d46decd8"}, - {file = "asyncpg-0.29.0.tar.gz", hash = "sha256:d1c49e1f44fffafd9a55e1a9b101590859d881d639ea2922516f5d9c512d354e"}, + {file = "asyncpg-0.30.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfb4dd5ae0699bad2b233672c8fc5ccbd9ad24b89afded02341786887e37927e"}, + {file = "asyncpg-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc1f62c792752a49f88b7e6f774c26077091b44caceb1983509edc18a2222ec0"}, + {file = "asyncpg-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3152fef2e265c9c24eec4ee3d22b4f4d2703d30614b0b6753e9ed4115c8a146f"}, + {file = "asyncpg-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7255812ac85099a0e1ffb81b10dc477b9973345793776b128a23e60148dd1af"}, + {file = "asyncpg-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:578445f09f45d1ad7abddbff2a3c7f7c291738fdae0abffbeb737d3fc3ab8b75"}, + {file = "asyncpg-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c42f6bb65a277ce4d93f3fba46b91a265631c8df7250592dd4f11f8b0152150f"}, + {file = "asyncpg-0.30.0-cp310-cp310-win32.whl", hash = "sha256:aa403147d3e07a267ada2ae34dfc9324e67ccc4cdca35261c8c22792ba2b10cf"}, + {file = "asyncpg-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb622c94db4e13137c4c7f98834185049cc50ee01d8f657ef898b6407c7b9c50"}, + {file = "asyncpg-0.30.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5e0511ad3dec5f6b4f7a9e063591d407eee66b88c14e2ea636f187da1dcfff6a"}, + {file = "asyncpg-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:915aeb9f79316b43c3207363af12d0e6fd10776641a7de8a01212afd95bdf0ed"}, + {file = "asyncpg-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c198a00cce9506fcd0bf219a799f38ac7a237745e1d27f0e1f66d3707c84a5a"}, + {file = "asyncpg-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3326e6d7381799e9735ca2ec9fd7be4d5fef5dcbc3cb555d8a463d8460607956"}, + {file = "asyncpg-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:51da377487e249e35bd0859661f6ee2b81db11ad1f4fc036194bc9cb2ead5056"}, + {file = "asyncpg-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc6d84136f9c4d24d358f3b02be4b6ba358abd09f80737d1ac7c444f36108454"}, + {file = "asyncpg-0.30.0-cp311-cp311-win32.whl", hash = "sha256:574156480df14f64c2d76450a3f3aaaf26105869cad3865041156b38459e935d"}, + {file = "asyncpg-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:3356637f0bd830407b5597317b3cb3571387ae52ddc3bca6233682be88bbbc1f"}, + {file = "asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e"}, + {file = "asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a"}, + {file = "asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3"}, + {file = "asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737"}, + {file = "asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a"}, + {file = "asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af"}, + {file = "asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e"}, + {file = "asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305"}, + {file = "asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70"}, + {file = "asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3"}, + {file = "asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33"}, + {file = "asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4"}, + {file = "asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4"}, + {file = "asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba"}, + {file = "asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590"}, + {file = "asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e"}, + {file = "asyncpg-0.30.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:29ff1fc8b5bf724273782ff8b4f57b0f8220a1b2324184846b39d1ab4122031d"}, + {file = "asyncpg-0.30.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64e899bce0600871b55368b8483e5e3e7f1860c9482e7f12e0a771e747988168"}, + {file = "asyncpg-0.30.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b290f4726a887f75dcd1b3006f484252db37602313f806e9ffc4e5996cfe5cb"}, + {file = "asyncpg-0.30.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f86b0e2cd3f1249d6fe6fd6cfe0cd4538ba994e2d8249c0491925629b9104d0f"}, + {file = "asyncpg-0.30.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:393af4e3214c8fa4c7b86da6364384c0d1b3298d45803375572f415b6f673f38"}, + {file = "asyncpg-0.30.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fd4406d09208d5b4a14db9a9dbb311b6d7aeeab57bded7ed2f8ea41aeef39b34"}, + {file = "asyncpg-0.30.0-cp38-cp38-win32.whl", hash = "sha256:0b448f0150e1c3b96cb0438a0d0aa4871f1472e58de14a3ec320dbb2798fb0d4"}, + {file = "asyncpg-0.30.0-cp38-cp38-win_amd64.whl", hash = "sha256:f23b836dd90bea21104f69547923a02b167d999ce053f3d502081acea2fba15b"}, + {file = "asyncpg-0.30.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f4e83f067b35ab5e6371f8a4c93296e0439857b4569850b178a01385e82e9ad"}, + {file = "asyncpg-0.30.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5df69d55add4efcd25ea2a3b02025b669a285b767bfbf06e356d68dbce4234ff"}, + {file = "asyncpg-0.30.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3479a0d9a852c7c84e822c073622baca862d1217b10a02dd57ee4a7a081f708"}, + {file = "asyncpg-0.30.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26683d3b9a62836fad771a18ecf4659a30f348a561279d6227dab96182f46144"}, + {file = "asyncpg-0.30.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1b982daf2441a0ed314bd10817f1606f1c28b1136abd9e4f11335358c2c631cb"}, + {file = "asyncpg-0.30.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1c06a3a50d014b303e5f6fc1e5f95eb28d2cee89cf58384b700da621e5d5e547"}, + {file = "asyncpg-0.30.0-cp39-cp39-win32.whl", hash = "sha256:1b11a555a198b08f5c4baa8f8231c74a366d190755aa4f99aacec5970afe929a"}, + {file = "asyncpg-0.30.0-cp39-cp39-win_amd64.whl", hash = "sha256:8b684a3c858a83cd876f05958823b68e8d14ec01bb0c0d14a6704c5bf9711773"}, + {file = "asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851"}, ] [package.dependencies] -async-timeout = {version = ">=4.0.3", markers = "python_version < \"3.12.0\""} +async-timeout = {version = ">=4.0.3", markers = "python_version < \"3.11.0\""} [package.extras] -docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] -test = ["flake8 (>=6.1,<7.0)", "uvloop (>=0.15.3) ; platform_system != \"Windows\" and python_version < \"3.12.0\""] +docs = ["Sphinx (>=8.1.3,<8.2.0)", "sphinx-rtd-theme (>=1.2.2)"] +gssauth = ["gssapi ; platform_system != \"Windows\"", "sspilib ; platform_system == \"Windows\""] +test = ["distro (>=1.9.0,<1.10.0)", "flake8 (>=6.1,<7.0)", "flake8-pyi (>=24.1.0,<24.2.0)", "gssapi ; platform_system == \"Linux\"", "k5test ; platform_system == \"Linux\"", "mypy (>=1.8.0,<1.9.0)", "sspilib ; platform_system == \"Windows\"", "uvloop (>=0.15.3) ; platform_system != \"Windows\" and python_version < \"3.14.0\""] [[package]] name = "attrs" @@ -253,6 +262,50 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] +[[package]] +name = "audioop-lts" +version = "0.2.1" +description = "LTS Port of Python audioop" +optional = false +python-versions = ">=3.13" +groups = ["main"] +markers = "python_version >= \"3.13\"" +files = [ + {file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a"}, + {file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e"}, + {file = "audioop_lts-0.2.1-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:4a8dd6a81770f6ecf019c4b6d659e000dc26571b273953cef7cd1d5ce2ff3ae6"}, + {file = "audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cd3c0b6f2ca25c7d2b1c3adeecbe23e65689839ba73331ebc7d893fcda7ffe"}, + {file = "audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff3f97b3372c97782e9c6d3d7fdbe83bce8f70de719605bd7ee1839cd1ab360a"}, + {file = "audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a351af79edefc2a1bd2234bfd8b339935f389209943043913a919df4b0f13300"}, + {file = "audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aeb6f96f7f6da80354330470b9134d81b4cf544cdd1c549f2f45fe964d28059"}, + {file = "audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c589f06407e8340e81962575fcffbba1e92671879a221186c3d4662de9fe804e"}, + {file = "audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fbae5d6925d7c26e712f0beda5ed69ebb40e14212c185d129b8dfbfcc335eb48"}, + {file = "audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_i686.whl", hash = "sha256:d2d5434717f33117f29b5691fbdf142d36573d751716249a288fbb96ba26a281"}, + {file = "audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f626a01c0a186b08f7ff61431c01c055961ee28769591efa8800beadd27a2959"}, + {file = "audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:05da64e73837f88ee5c6217d732d2584cf638003ac72df124740460531e95e47"}, + {file = "audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:56b7a0a4dba8e353436f31a932f3045d108a67b5943b30f85a5563f4d8488d77"}, + {file = "audioop_lts-0.2.1-cp313-abi3-win32.whl", hash = "sha256:6e899eb8874dc2413b11926b5fb3857ec0ab55222840e38016a6ba2ea9b7d5e3"}, + {file = "audioop_lts-0.2.1-cp313-abi3-win_amd64.whl", hash = "sha256:64562c5c771fb0a8b6262829b9b4f37a7b886c01b4d3ecdbae1d629717db08b4"}, + {file = "audioop_lts-0.2.1-cp313-abi3-win_arm64.whl", hash = "sha256:c45317debeb64002e980077642afbd977773a25fa3dfd7ed0c84dccfc1fafcb0"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3827e3fce6fee4d69d96a3d00cd2ab07f3c0d844cb1e44e26f719b34a5b15455"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:161249db9343b3c9780ca92c0be0d1ccbfecdbccac6844f3d0d44b9c4a00a17f"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5b7b4ff9de7a44e0ad2618afdc2ac920b91f4a6d3509520ee65339d4acde5abf"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72e37f416adb43b0ced93419de0122b42753ee74e87070777b53c5d2241e7fab"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534ce808e6bab6adb65548723c8cbe189a3379245db89b9d555c4210b4aaa9b6"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2de9b6fb8b1cf9f03990b299a9112bfdf8b86b6987003ca9e8a6c4f56d39543"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f24865991b5ed4b038add5edbf424639d1358144f4e2a3e7a84bc6ba23e35074"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bdb3b7912ccd57ea53197943f1bbc67262dcf29802c4a6df79ec1c715d45a78"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:120678b208cca1158f0a12d667af592e067f7a50df9adc4dc8f6ad8d065a93fb"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:54cd4520fc830b23c7d223693ed3e1b4d464997dd3abc7c15dce9a1f9bd76ab2"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:d6bd20c7a10abcb0fb3d8aaa7508c0bf3d40dfad7515c572014da4b979d3310a"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:f0ed1ad9bd862539ea875fb339ecb18fcc4148f8d9908f4502df28f94d23491a"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e1af3ff32b8c38a7d900382646e91f2fc515fd19dea37e9392275a5cbfdbff63"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:f51bb55122a89f7a0817d7ac2319744b4640b5b446c4c3efcea5764ea99ae509"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f0f2f336aa2aee2bce0b0dcc32bbba9178995454c7b979cf6ce086a8801e14c7"}, + {file = "audioop_lts-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:78bfb3703388c780edf900be66e07de5a3d4105ca8e8720c5c4d67927e0b15d0"}, + {file = "audioop_lts-0.2.1.tar.gz", hash = "sha256:e81268da0baa880431b68b1308ab7257eb33f356e57a5f9b1f915dfb13dd1387"}, +] + [[package]] name = "beautifulsoup4" version = "4.13.3" @@ -510,23 +563,25 @@ files = [ [[package]] name = "discord-py" -version = "2.3.2" +version = "2.5.2" description = "A Python wrapper for the Discord API" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "discord.py-2.3.2-py3-none-any.whl", hash = "sha256:9da4679fc3cb10c64b388284700dc998663e0e57328283bbfcfc2525ec5960a6"}, - {file = "discord.py-2.3.2.tar.gz", hash = "sha256:4560f70f2eddba7e83370ecebd237ac09fbb4980dc66507482b0c0e5b8f76b9c"}, + {file = "discord_py-2.5.2-py3-none-any.whl", hash = "sha256:81f23a17c50509ffebe0668441cb80c139e74da5115305f70e27ce821361295a"}, + {file = "discord_py-2.5.2.tar.gz", hash = "sha256:01cd362023bfea1a4a1d43f5280b5ef00cad2c7eba80098909f98bf28e578524"}, ] [package.dependencies] aiohttp = ">=3.7.4,<4" +audioop-lts = {version = "*", markers = "python_version >= \"3.13\""} [package.extras] -docs = ["sphinx (==4.4.0)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport", "typing-extensions (>=4.3,<5)"] -speed = ["Brotli", "aiodns (>=1.1)", "cchardet (==2.1.7) ; python_version < \"3.10\"", "orjson (>=3.5.4)"] -test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)"] +dev = ["black (==22.6)", "typing_extensions (>=4.3,<5)"] +docs = ["imghdr-lts (==1.0.0) ; python_version >= \"3.13\"", "sphinx (==4.4.0)", "sphinx-inline-tabs (==2023.4.21)", "sphinxcontrib-applehelp (==1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (==2.0.1)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)", "sphinxcontrib-websupport (==1.2.4)", "sphinxcontrib_trio (==1.1.2)", "typing-extensions (>=4.3,<5)"] +speed = ["Brotli", "aiodns (>=1.1) ; sys_platform != \"win32\"", "cchardet (==2.1.7) ; python_version < \"3.10\"", "orjson (>=3.5.4)", "zstandard (>=0.23.0)"] +test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)", "tzdata ; sys_platform == \"win32\""] voice = ["PyNaCl (>=1.3.0,<1.6)"] [[package]] @@ -1505,7 +1560,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -1552,7 +1607,7 @@ files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] -markers = {dev = "python_version < \"3.11\""} +markers = {dev = "python_version == \"3.10\""} [[package]] name = "tzdata" @@ -1720,4 +1775,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "339ce1f68b3b33905537b1db4e49ee992c8d481ee8fc1f052cccbf6fc68ae385" +content-hash = "c31d141948a3e33c13adf8dc4cec283cfd49c96189a8ce306e70d0c3e4d582e3" diff --git a/pyproject.toml b/pyproject.toml index 5e4c73f9..871fa011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,8 +11,8 @@ bot = "cli:main" [tool.poetry.dependencies] python = "^3.10" click = "^8.1.3" -asyncpg = "0.29.0" -discord-py = "2.3.2" +asyncpg = "^0.30.0" +discord-py = "^2.5.2" python-dateutil = "^2.8.2" pydantic = {extras = ["dotenv"], version = "^1.10.7"} jishaku = {extras = ["procinfo", "profiling"], version = "^2.5.1"} From 4f8b2b0f5ee37630d723562691836458119f6b6c Mon Sep 17 00:00:00 2001 From: HETHAT Date: Tue, 1 Jul 2025 11:14:47 +0100 Subject: [PATCH 2/6] linting --- bot/extensions/clashofcode/commands.py | 9 ++++----- bot/extensions/clashofcode/utils.py | 6 ++++++ bot/extensions/clashofcode/views.py | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/bot/extensions/clashofcode/commands.py b/bot/extensions/clashofcode/commands.py index 38500c86..b929cb9b 100644 --- a/bot/extensions/clashofcode/commands.py +++ b/bot/extensions/clashofcode/commands.py @@ -1,18 +1,16 @@ -import asyncio import logging -import time import discord from aiohttp import ContentTypeError from codingame.http import HTTPError -from discord import app_commands, ui +from discord import app_commands from discord.app_commands import Cooldown from discord.ext import commands from bot import core from bot.config import settings from bot.extensions.clashofcode.utils import coc_client, coc_helper -from bot.extensions.clashofcode.views import CocMessageView, CreateCocView, em +from bot.extensions.clashofcode.views import CocMessageView log = logging.getLogger(__name__) @@ -72,7 +70,8 @@ async def new(self, interaction: core.InteractionType): msg = await interaction.channel.send( ( f"**Hey, {ping_role.mention}, {interaction.user.mention} is hosting a Clash Of Code session!**\n" - f"The host can use the 'Create Game' button to start a clash. Everyone else can join the session to get pinged!" + f"The host can use the 'Create Game' button to start a clash. " + f"Everyone else can join the session to get pinged!" ), allowed_mentions=discord.AllowedMentions(roles=True, users=True), view=view, diff --git a/bot/extensions/clashofcode/utils.py b/bot/extensions/clashofcode/utils.py index c35f7663..46a57a14 100644 --- a/bot/extensions/clashofcode/utils.py +++ b/bot/extensions/clashofcode/utils.py @@ -1,5 +1,10 @@ +import typing + import codingame +if typing.TYPE_CHECKING: + import discord + class ClashOfCodeHelper: def __init__(self): @@ -13,6 +18,7 @@ def __init__(self): self.modes: list[str] | None = None self.handle: str | None = None + coc_helper = ClashOfCodeHelper() coc_client = codingame.Client(is_async=True) # fmt: off diff --git a/bot/extensions/clashofcode/views.py b/bot/extensions/clashofcode/views.py index ddcd5527..92a6f55b 100644 --- a/bot/extensions/clashofcode/views.py +++ b/bot/extensions/clashofcode/views.py @@ -288,4 +288,4 @@ async def create_coc(self, interaction: core.InteractionType, _button: ui.Button ), ) - await msg.reply(embed=embed, view=CocMessageView()) \ No newline at end of file + await msg.reply(embed=embed, view=CocMessageView()) From 92b1ad545ee0ed0976d8985a92496a724413e58a Mon Sep 17 00:00:00 2001 From: HETHAT Date: Tue, 1 Jul 2025 11:33:34 +0100 Subject: [PATCH 3/6] fix issue #316 --- bot/models/migrations/006_up__configs.sql | 7 ------- 1 file changed, 7 deletions(-) diff --git a/bot/models/migrations/006_up__configs.sql b/bot/models/migrations/006_up__configs.sql index f2de5e58..f2eda076 100644 --- a/bot/models/migrations/006_up__configs.sql +++ b/bot/models/migrations/006_up__configs.sql @@ -7,10 +7,3 @@ CREATE TABLE IF NOT EXISTS guild_configs custom_role_log_channel_id BIGINT, divider_role_id BIGINT ); - - -ALTER TABLE migrations ALTER COLUMN id SET NOT NULL; -ALTER TABLE migrations ALTER COLUMN version SET NOT NULL; -ALTER TABLE migrations ALTER COLUMN direction SET NOT NULL; -ALTER TABLE migrations ALTER COLUMN name SET NOT NULL; -ALTER TABLE migrations ALTER COLUMN timestamp SET NOT NULL; From fd074bceeff493099450d34bdcc44f9fbc6b1b98 Mon Sep 17 00:00:00 2001 From: HETHAT Date: Tue, 1 Jul 2025 16:05:51 +0100 Subject: [PATCH 4/6] use subgroups instead of buttons --- bot/extensions/clashofcode/commands.py | 118 ++++++++++++++++--------- bot/extensions/clashofcode/utils.py | 1 - bot/extensions/clashofcode/views.py | 62 ++++--------- 3 files changed, 92 insertions(+), 89 deletions(-) diff --git a/bot/extensions/clashofcode/commands.py b/bot/extensions/clashofcode/commands.py index b929cb9b..8a1e0e9c 100644 --- a/bot/extensions/clashofcode/commands.py +++ b/bot/extensions/clashofcode/commands.py @@ -10,7 +10,7 @@ from bot import core from bot.config import settings from bot.extensions.clashofcode.utils import coc_client, coc_helper -from bot.extensions.clashofcode.views import CocMessageView +from bot.extensions.clashofcode.views import CocMessageView, CreateCocView log = logging.getLogger(__name__) @@ -40,8 +40,12 @@ async def interaction_check(self, interaction: core.InteractionType): return False return True - @app_commands.command() - @app_commands.checks.dynamic_cooldown(new_coc_cooldown, key=lambda i: i.guild_id) + coc_session = app_commands.Group(name="session", description="Session commands") + + @coc_session.command() + @app_commands.checks.dynamic_cooldown( + new_coc_cooldown, key=lambda i: settings.moderation.staff_role_id in [role.id for role in i.user.roles] + ) async def new(self, interaction: core.InteractionType): """Creates a new Clash of Code session.""" if coc_helper.session: @@ -57,7 +61,6 @@ async def new(self, interaction: core.InteractionType): coc_helper.session = False coc_helper.host = None return await interaction.followup.send("The session role could not be found.", ephemeral=True) - coc_helper.session_role = role try: await interaction.user.add_roles(role) @@ -67,10 +70,9 @@ async def new(self, interaction: core.InteractionType): ping_role = interaction.guild.get_role(settings.coc.role_id) view = CocMessageView() - msg = await interaction.channel.send( + msg = await interaction.followup.send( ( f"**Hey, {ping_role.mention}, {interaction.user.mention} is hosting a Clash Of Code session!**\n" - f"The host can use the 'Create Game' button to start a clash. " f"Everyone else can join the session to get pinged!" ), allowed_mentions=discord.AllowedMentions(roles=True, users=True), @@ -78,37 +80,7 @@ async def new(self, interaction: core.InteractionType): ) coc_helper.message = msg - await interaction.followup.send("Session created!", ephemeral=True) - - @app_commands.command() - async def start(self, interaction: core.InteractionType): - """Starts the current clash""" - - if not coc_helper.session: - return await interaction.response.send_message("There is no active clash of code session", ephemeral=True) - - if coc_helper.clash.started: - return await interaction.response.send_message("The clash has already started", ephemeral=True) - - try: - await coc_client.request( - "ClashOfCode", "startClashByHandle", [coc_client.codingamer.id, coc_helper.clash.public_handle] - ) - except HTTPError as e: - log.info("Handled error in /coc start : %s\n%s", e.reason, e.data) - return await interaction.response.send_message( - "An error occurred while starting the clash. Please try again later", ephemeral=True - ) - except ContentTypeError: - # Issue with the codingame library always assuming the response is JSON - pass - - await interaction.response.send_message( - "Clash started!", - ephemeral=True, - ) - - @app_commands.command() + @coc_session.command() async def end(self, interaction: core.InteractionType): """Ends the current coc session""" @@ -123,6 +95,8 @@ async def end(self, interaction: core.InteractionType): "Only the session host or a staff member can end the session.", ephemeral=True ) + await interaction.response.defer() + if coc_helper.message: try: view = CocMessageView() @@ -132,28 +106,86 @@ async def end(self, interaction: core.InteractionType): except discord.HTTPException as e: log.error("Failed to edit message to disable button", exc_info=e) - if coc_helper.session_role: - for member in coc_helper.session_role.members: + session_role = interaction.guild.get_role(settings.coc.session_role_id) + if session_role: + for member in session_role.members: try: - await member.remove_roles(coc_helper.session_role) + await member.remove_roles(session_role) except discord.HTTPException as e: log.error(f"Failed to remove role from {member.display_name}", exc_info=e) coc_helper.last_clash = 0 coc_helper.session = False coc_helper.clash = None - coc_helper.session_role = None coc_helper.message = None coc_helper.host = None coc_helper.languages = None coc_helper.modes = None coc_helper.handle = None - await interaction.response.send_message( + await interaction.followup.send( f"Clash session has been closed by {interaction.user.mention}. See you later", allowed_mentions=discord.AllowedMentions(users=True), ) + coc_game = app_commands.Group(name="game", description="Game commands") + + @coc_game.command() + async def create(self, interaction: core.InteractionType): + """Creates a new Clash of Code game.""" + + if not coc_helper.session: + return await interaction.response.send_message( + "There is no active clash of code session. Do `/coc session new` to start one.", ephemeral=True + ) + + is_staff = settings.moderation.staff_role_id in [role.id for role in interaction.user.roles] + if interaction.user != coc_helper.host and not is_staff: + return await interaction.response.send_message( + "Only the session host or a staff member can create a game.", ephemeral=True + ) + + if coc_helper.handle: + return await interaction.response.send_message( + "A game has already been created for this session.", ephemeral=True + ) + + await interaction.response.send_message( + "Select the programming languages and modes you want to use for the game", + ephemeral=True, + view=CreateCocView(), + ) + + @coc_game.command() + async def start(self, interaction: core.InteractionType): + """Starts the current clash game""" + + if not coc_helper.handle: + return await interaction.response.send_message("There is no active clash of code game", ephemeral=True) + + if coc_helper.clash.started: + return await interaction.response.send_message("The clash has already started", ephemeral=True) + + await interaction.response.defer() + + try: + await coc_client.request( + "ClashOfCode", "startClashByHandle", [coc_client.codingamer.id, coc_helper.clash.public_handle] + ) + except HTTPError as e: + log.info("Handled error in /coc start : %s\n%s", e.reason, e.data) + return await interaction.followup.send_message( + "An error occurred while starting the clash. Please try again later", ephemeral=True + ) + except ContentTypeError: + # Issue with the codingame library always assuming the response is JSON + pass + + await interaction.followup.send_message( + "Clash started!", + ephemeral=True, + ) + async def cog_app_command_error(self, interaction: core.InteractionType, error: app_commands.AppCommandError): if isinstance(error, app_commands.CommandOnCooldown): seconds = int(error.retry_after) @@ -171,7 +203,7 @@ async def cog_app_command_error(self, interaction: core.InteractionType, error: ephemeral=True, ) else: - log.error("An unhandled error occurred in the Clash of Code cog.", exc_info=error) + raise error async def setup(bot: core.DiscordBot): diff --git a/bot/extensions/clashofcode/utils.py b/bot/extensions/clashofcode/utils.py index 46a57a14..81b3ae8f 100644 --- a/bot/extensions/clashofcode/utils.py +++ b/bot/extensions/clashofcode/utils.py @@ -12,7 +12,6 @@ def __init__(self): self.clash = None self.last_clash = 0 self.host: discord.Member | None = None - self.session_role: discord.Role | None = None self.message: discord.Message | None = None self.languages: list[str] | None = None self.modes: list[str] | None = None diff --git a/bot/extensions/clashofcode/views.py b/bot/extensions/clashofcode/views.py index 92a6f55b..f12404d5 100644 --- a/bot/extensions/clashofcode/views.py +++ b/bot/extensions/clashofcode/views.py @@ -5,7 +5,6 @@ import discord from aiohttp import ContentTypeError from codingame.http import HTTPError -from discord import ui from bot import core from bot.config import settings @@ -28,9 +27,8 @@ async def resolve_member(guild: discord.Guild, name: str) -> discord.Member | No ) -class CocMessageView(ui.View): +class CocMessageView(discord.ui.View): JOIN_LEAVE_CUSTOM_ID = "extensions:clashofcode:join_leave" - CREATE_GAME_CUSTOM_ID = "extensions:clashofcode:create_game" def __init__(self): super().__init__(timeout=None) @@ -40,55 +38,32 @@ def __init__(self): style=discord.ButtonStyle.primary, custom_id=JOIN_LEAVE_CUSTOM_ID, ) - async def join_leave_session(self, interaction: core.InteractionType, button: ui.Button): + async def join_leave_session(self, interaction: core.InteractionType, button: discord.ui.Button): if not coc_helper.session: button.disabled = True await interaction.response.edit_message(view=self) return await interaction.followup.send("This clash of code session has already ended.", ephemeral=True) - role = coc_helper.session_role - if not role: + session_role = interaction.guild.get_role(settings.coc.session_role_id) + if not session_role: await interaction.response.send_message("The session role could not be found.", ephemeral=True) return - if role in interaction.user.roles: + if session_role in interaction.user.roles: try: - await interaction.user.remove_roles(role) + await interaction.user.remove_roles(session_role) await interaction.response.send_message("You have left the session.", ephemeral=True) except discord.HTTPException: await interaction.response.send_message("Failed to leave the session.", ephemeral=True) else: try: - await interaction.user.add_roles(role) + await interaction.user.add_roles(session_role) await interaction.response.send_message("You have joined the session.", ephemeral=True) except discord.HTTPException: await interaction.response.send_message("Failed to join the session.", ephemeral=True) - @discord.ui.button( - label="Create Game", - style=discord.ButtonStyle.success, - custom_id=CREATE_GAME_CUSTOM_ID, - ) - async def create_game(self, interaction: core.InteractionType, button: ui.Button): - is_staff = settings.moderation.staff_role_id in [role.id for role in interaction.user.roles] - if interaction.user != coc_helper.host and not is_staff: - return await interaction.response.send_message( - "Only the session host or a staff member can create a game.", ephemeral=True - ) - if coc_helper.handle: - return await interaction.response.send_message( - "A game has already been created for this session.", ephemeral=True - ) - - await interaction.response.send_message( - "Select the programming languages and modes you want to use for the game", - ephemeral=True, - view=CreateCocView(), - ) - - -class CreateCocView(ui.View): +class CreateCocView(discord.ui.View): LANGUAGES_CUSTOM_ID = "extensions:clashofcode:languages" MODES_CUSTOM_ID = "extensions:clashofcode:modes" SELECT_ALL_CUSTOM_ID = "extensions:clashofcode:select_all" @@ -101,13 +76,13 @@ class CreateCocView(ui.View): options=[discord.SelectOption(label=lang, default=True) for lang in languages], max_values=len(languages), ) - async def select_languages(self, interaction: core.InteractionType, _select: ui.Select): + async def select_languages(self, interaction: core.InteractionType, _select: discord.ui.Select): await interaction.response.defer() @discord.ui.button( label="Select All", style=discord.ButtonStyle.blurple, emoji="🟢", custom_id=SELECT_ALL_CUSTOM_ID ) - async def select_all_languages(self, interaction: core.InteractionType, _button: ui.Button): + async def select_all_languages(self, interaction: core.InteractionType, _button: discord.ui.Button): modes_select = discord.utils.get(self.children, custom_id=self.MODES_CUSTOM_ID) for option in modes_select.options: option.default = not modes_select.values or option.label in modes_select.values @@ -120,7 +95,7 @@ async def select_all_languages(self, interaction: core.InteractionType, _button: @discord.ui.button( label="Deselect All", style=discord.ButtonStyle.blurple, emoji="🔴", custom_id=DESELECT_ALL_CUSTOM_ID ) - async def deselect_all_languages(self, interaction: core.InteractionType, _button: ui.Button): + async def deselect_all_languages(self, interaction: core.InteractionType, _button: discord.ui.Button): languages_select = discord.utils.get(self.children, custom_id=self.LANGUAGES_CUSTOM_ID) modes_select = discord.utils.get(self.children, custom_id=self.MODES_CUSTOM_ID) for option in languages_select.options: @@ -141,13 +116,13 @@ async def deselect_all_languages(self, interaction: core.InteractionType, _butto options=[discord.SelectOption(label=mode, default=True) for mode in modes], max_values=len(modes), ) - async def select_modes(self, interaction: core.InteractionType, _select: ui.Select): + async def select_modes(self, interaction: core.InteractionType, _select: discord.ui.Select): await interaction.response.defer() @discord.ui.button( label="Create Game", style=discord.ButtonStyle.green, emoji="📝", custom_id=CREATE_CUSTOM_ID, row=3 ) - async def create_coc(self, interaction: core.InteractionType, _button: ui.Button): + async def create_coc(self, interaction: core.InteractionType, _button: discord.ui.Button): is_staff = settings.moderation.staff_role_id in [role.id for role in interaction.user.roles] if interaction.user != coc_helper.host and not is_staff: return await interaction.response.send_message( @@ -166,9 +141,8 @@ async def create_coc(self, interaction: core.InteractionType, _button: ui.Button await interaction.delete_original_response() # A session already exists, and it was recently created - if coc_helper.session and coc_helper.last_clash + 60 * 5 > int(time.time()): - await interaction.followup.send("A session has already been made!", ephemeral=True) - return + if coc_helper.handle and coc_helper.last_clash + 60 * 5 > int(time.time()): + return await interaction.followup.send("A game has already been created!", ephemeral=True) data = await coc_client.request( "ClashOfCode", @@ -189,7 +163,6 @@ async def create_coc(self, interaction: core.InteractionType, _button: ui.Button allowed_mentions=discord.AllowedMentions(users=True, roles=True), ) - coc_helper.session = True coc_helper.last_clash = time.time() clash = await coc_client.get_clash_of_code(handle) @@ -204,7 +177,7 @@ async def create_coc(self, interaction: core.InteractionType, _button: ui.Button if not clash.started: coc_helper.clash = None coc_helper.handle = None - await msg.reply("Canceling clash due to lack of participants.", view=CocMessageView()) + await msg.reply("Canceling clash due to lack of participants.") try: await coc_client.request( @@ -272,7 +245,6 @@ async def create_coc(self, interaction: core.InteractionType, _button: ui.Button coc_helper.clash = None coc_helper.handle = None - coc_helper.session = False embed = discord.Embed( title="**Clash finished**", @@ -288,4 +260,4 @@ async def create_coc(self, interaction: core.InteractionType, _button: ui.Button ), ) - await msg.reply(embed=embed, view=CocMessageView()) + await msg.reply(embed=embed) From aef084e8ccfba9cb9b878e9a8f374e5a2f52a540 Mon Sep 17 00:00:00 2001 From: HETHAT Date: Wed, 2 Jul 2025 00:44:23 +0100 Subject: [PATCH 5/6] add a command to assign/remove coc role --- bot/extensions/clashofcode/commands.py | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/bot/extensions/clashofcode/commands.py b/bot/extensions/clashofcode/commands.py index 8a1e0e9c..c65a40ba 100644 --- a/bot/extensions/clashofcode/commands.py +++ b/bot/extensions/clashofcode/commands.py @@ -186,6 +186,38 @@ async def start(self, interaction: core.InteractionType): ephemeral=True, ) + @app_commands.command() + @app_commands.checks.cooldown(1, 10.0) + async def ping(self, interaction: core.InteractionType): + """Assigns/Removes coc notification role.""" + + ping_role = interaction.guild.get_role(settings.coc.role_id) + + if not ping_role: + return await interaction.response.send_message("The CoC notification role was not found.") + + await interaction.response.defer(ephemeral=True) + + if ping_role in interaction.user.roles: + try: + await interaction.user.remove_roles(ping_role) + await interaction.followup.send( + "Clash of Code session notifications **disabled**.\n" + "The CoC role has been removed — you will no longer receive session alerts.", + ephemeral=True, + ) + except discord.HTTPException as e: + log.error(f"Failed to remove role from {interaction.user.display_name}", exc_info=e) + else: + try: + await interaction.user.add_roles(ping_role) + await interaction.followup.send( + "Clash of Code session notifications **enabled**.\nYou've been assigned the CoC role.", + ephemeral=True, + ) + except discord.HTTPException as e: + log.error(f"Failed to add role to {interaction.user.display_name}", exc_info=e) + async def cog_app_command_error(self, interaction: core.InteractionType, error: app_commands.AppCommandError): if isinstance(error, app_commands.CommandOnCooldown): seconds = int(error.retry_after) From a0698be6d55c8457136a3acfe570e7dcb9e93540 Mon Sep 17 00:00:00 2001 From: HETHAT Date: Wed, 2 Jul 2025 01:01:18 +0100 Subject: [PATCH 6/6] pin message at session start and unpin on end --- bot/extensions/clashofcode/commands.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/bot/extensions/clashofcode/commands.py b/bot/extensions/clashofcode/commands.py index c65a40ba..8c24263b 100644 --- a/bot/extensions/clashofcode/commands.py +++ b/bot/extensions/clashofcode/commands.py @@ -78,6 +78,13 @@ async def new(self, interaction: core.InteractionType): allowed_mentions=discord.AllowedMentions(roles=True, users=True), view=view, ) + try: + await msg.pin() + except discord.NotFound: + pass # Somehow it got deleted ? just ignore it + except discord.HTTPException as e: + log.error(f"Failed to pin coc session message {msg.id}", exc_info=e) + coc_helper.message = msg @coc_session.command() @@ -114,6 +121,13 @@ async def end(self, interaction: core.InteractionType): except discord.HTTPException as e: log.error(f"Failed to remove role from {member.display_name}", exc_info=e) + try: + await coc_helper.message.unpin() + except discord.NotFound: + pass # Somehow it got deleted ? just ignore it + except discord.HTTPException as e: + log.error(f"Failed to unpin coc session message {coc_helper.message.id}", exc_info=e) + coc_helper.last_clash = 0 coc_helper.session = False coc_helper.clash = None