From 5cf0ad209ffb326cc1bb5726f2fd24b979336b4f Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Sat, 31 Jan 2026 00:41:42 -0500 Subject: [PATCH 1/3] refactor(tools): standardize cog initialization and fix guild sync - Refactor Sync and Ping cogs to accept bot instance via __init__ - Remove global capy_discord.instance usage in favor of self.bot - Add debug_guild_id to Settings for guild-specific commands - Fix /sync to sync both global and debug guild commands - Add hotswap command for live extension reload - Add admin permission check to /sync slash command --- capy_discord/config.py | 1 + capy_discord/exts/tools/hotswap.py | 127 +++++++++++++++++++++++++++++ capy_discord/exts/tools/ping.py | 12 ++- capy_discord/exts/tools/sync.py | 74 ++++++++++++----- 4 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 capy_discord/exts/tools/hotswap.py diff --git a/capy_discord/config.py b/capy_discord/config.py index 8918cad..205c1ab 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -20,6 +20,7 @@ class Settings(EnvConfig): log_level: int = logging.INFO prefix: str = "/" token: str = "" + debug_guild_id: int | None = None settings = Settings() diff --git a/capy_discord/exts/tools/hotswap.py b/capy_discord/exts/tools/hotswap.py new file mode 100644 index 0000000..4797997 --- /dev/null +++ b/capy_discord/exts/tools/hotswap.py @@ -0,0 +1,127 @@ +import logging +from typing import Literal + +import discord +from discord import app_commands, ui +from discord.ext import commands + +from capy_discord.config import settings +from capy_discord.ui.views import BaseView +from capy_discord.utils.embeds import error_embed, success_embed +from capy_discord.utils.extensions import walk_extensions + +log = logging.getLogger(__name__) + + +class HotswapSelect(ui.Select): + """Dropdown for selecting extensions to hotswap.""" + + def __init__(self, extensions: list[str], action: str) -> None: + """Initialize the HotswapSelect dropdown.""" + self.action = action + options = [discord.SelectOption(label=ext, value=ext) for ext in extensions] + super().__init__( + placeholder=f"Select an extension to {action}...", + min_values=1, + max_values=1, + options=options, + ) + + async def callback(self, interaction: discord.Interaction) -> None: + """Handle the selection and perform the requested action.""" + cog_name = self.values[0] + bot = interaction.client + + if not isinstance(bot, commands.Bot): + log.error("Interaction client is not a commands.Bot instance.") + return + + try: + if self.action == "reload": + await bot.reload_extension(cog_name) + elif self.action == "load": + await bot.load_extension(cog_name) + elif self.action == "unload": + await bot.unload_extension(cog_name) + + await interaction.response.send_message( + embed=success_embed( + f"Extension {self.action.capitalize()}ed", + f"Successfully {self.action}ed `{cog_name}`.", + ), + ephemeral=True, + ) + except Exception as e: + log.exception("Failed to %s extension %s", self.action, cog_name) + await interaction.response.send_message( + embed=error_embed( + f"Failed to {self.action.capitalize()} Extension", + f"An error occurred while {self.action}ing `{cog_name}`: `{e}`", + ), + ephemeral=True, + ) + + +class HotswapView(BaseView): + """View for hotswapping extensions.""" + + def __init__(self, extensions: list[str], action: str, *, timeout: float | None = 180) -> None: + """Initialize the HotswapView.""" + super().__init__(timeout=timeout) + self.add_item(HotswapSelect(extensions, action)) + + +class HotswapCog(commands.Cog): + """Cog for reloading, loading, and unloading extensions at runtime.""" + + def __init__(self, bot: commands.Bot) -> None: + """Initialize the HotswapCog.""" + self.bot = bot + + def get_unloaded_cogs(self) -> list[str]: + """Get a list of cogs that are currently not loaded.""" + all_extensions = set(walk_extensions()) + loaded_extensions = set(self.bot.extensions.keys()) + return sorted(all_extensions - loaded_extensions) + + @app_commands.command(name="hotswap", description="Reload, load, or unload bot extensions.") + @app_commands.describe(action="The action to perform") + @app_commands.checks.has_permissions(administrator=True) + async def hotswap( + self, + interaction: discord.Interaction, + action: Literal["reload", "load", "unload"], + ) -> None: + """Handle the /hotswap command.""" + if action == "reload": + # Prevent self-reload to avoid potentially breaking the hotswap command during use + extensions = [ext for ext in self.bot.extensions if ext != "capy_discord.exts.tools.hotswap"] + if not extensions: + await interaction.response.send_message("No extensions are currently loaded.", ephemeral=True) + return + elif action == "load": + extensions = self.get_unloaded_cogs() + if not extensions: + await interaction.response.send_message("All available extensions are already loaded.", ephemeral=True) + return + else: # unload + extensions = [ext for ext in self.bot.extensions if ext != "capy_discord.exts.tools.hotswap"] + if not extensions: + await interaction.response.send_message("No extensions are currently loaded.", ephemeral=True) + return + + # Sort extensions for better UX + extensions.sort() + + # Discord select menu limit is 25 options + view = HotswapView(extensions[:25], action) + await view.reply(interaction, f"Select an extension to {action}:", ephemeral=True) + + # If debug_guild_id is set, restrict to that guild + if settings.debug_guild_id: + hotswap = app_commands.guilds(discord.Object(id=settings.debug_guild_id))(hotswap) + + +async def setup(bot: commands.Bot) -> None: + """Load the HotswapCog.""" + await bot.add_cog(HotswapCog(bot)) diff --git a/capy_discord/exts/tools/ping.py b/capy_discord/exts/tools/ping.py index 1391282..a43b1b3 100644 --- a/capy_discord/exts/tools/ping.py +++ b/capy_discord/exts/tools/ping.py @@ -1,3 +1,8 @@ +"""Ping command cog. + +This module provides a simple ping command to check bot latency. +""" + import logging import discord @@ -8,8 +13,9 @@ class Ping(commands.Cog): """Cog for ping command.""" - def __init__(self) -> None: + def __init__(self, bot: commands.Bot) -> None: """Initialize the Ping cog.""" + self.bot = bot self.log = logging.getLogger(__name__) self.log.info("Ping cog initialized") @@ -17,7 +23,7 @@ def __init__(self) -> None: async def ping(self, interaction: discord.Interaction) -> None: """Respond with the bot's latency.""" try: - latency = round(interaction.client.latency * 1000) # in ms + latency = round(self.bot.latency * 1000) # in ms message = f"Pong! {latency} ms Latency!" embed = discord.Embed(title="Ping", description=message) self.log.info("/ping invoked user: %s guild: %s", interaction.user.id, interaction.guild_id) @@ -31,4 +37,4 @@ async def ping(self, interaction: discord.Interaction) -> None: async def setup(bot: commands.Bot) -> None: """Set up the Ping cog.""" - await bot.add_cog(Ping()) + await bot.add_cog(Ping(bot)) diff --git a/capy_discord/exts/tools/sync.py b/capy_discord/exts/tools/sync.py index 2a0e92b..f5191d1 100644 --- a/capy_discord/exts/tools/sync.py +++ b/capy_discord/exts/tools/sync.py @@ -4,6 +4,7 @@ - Manual sync via command - Slash command sync - Global sync +- Debug guild sync (when DEBUG_GUILD_ID is configured) """ import logging @@ -12,28 +13,43 @@ from discord import app_commands from discord.ext import commands -import capy_discord +from capy_discord.config import settings class Sync(commands.Cog): """Cog for synchronizing application commands.""" - def __init__(self) -> None: + def __init__(self, bot: commands.Bot) -> None: """Initialize the Sync cog.""" + self.bot = bot self.log = logging.getLogger(__name__) self.log.info("Sync cog initialized") - async def _sync_commands(self) -> list[discord.app_commands.AppCommand]: - """Synchronize commands with Discord.""" - if capy_discord.instance is None: - self.log.error("Bot instance is None during sync") - return [] + async def _sync_commands(self) -> tuple[list[app_commands.AppCommand], list[app_commands.AppCommand] | None]: + """Synchronize commands with Discord. + + Returns: + A tuple of (global_commands, guild_commands). + guild_commands is None if no debug_guild_id is configured. + """ + # Sync global commands + global_synced: list[app_commands.AppCommand] = await self.bot.tree.sync() + self.log.info("Synced %d global commands: %s", len(global_synced), [c.name for c in global_synced]) + + # Sync debug guild if configured (for guild-specific commands like /hotswap) + guild_synced: list[app_commands.AppCommand] | None = None + if settings.debug_guild_id: + guild = discord.Object(id=settings.debug_guild_id) + guild_synced = await self.bot.tree.sync(guild=guild) + self.log.info( + "Synced %d commands to debug guild %s: %s", + len(guild_synced), + settings.debug_guild_id, + [c.name for c in guild_synced], + ) + + return global_synced, guild_synced - synced_commands: list[discord.app_commands.AppCommand] = await capy_discord.instance.tree.sync() - self.log.info("_sync_commands internal: %s", synced_commands) - return synced_commands - - # * admin locked command @commands.command(name="sync", hidden=True) async def sync(self, ctx: commands.Context[commands.Bot], spec: str | None = None) -> None: """Sync commands manually with "!" prefix (owner only).""" @@ -55,9 +71,11 @@ async def sync(self, ctx: commands.Context[commands.Bot], spec: str | None = Non await ctx.bot.tree.sync(guild=ctx.guild) description = "Cleared commands for **current guild**." else: - # Global sync - synced = await ctx.bot.tree.sync() - description = f"Synced {len(synced)} commands **globally** (may take 1h)." + # Global sync + debug guild sync + global_synced, guild_synced = await self._sync_commands() + description = f"Synced {len(global_synced)} commands **globally** (may take 1h)." + if guild_synced is not None: + description += f"\nSynced {len(guild_synced)} commands to **debug guild** (instant)." self.log.info("!sync invoked by %s: %s", ctx.author.id, description) await ctx.send(description) @@ -66,26 +84,38 @@ async def sync(self, ctx: commands.Context[commands.Bot], spec: str | None = Non self.log.exception("!sync attempted with error") await ctx.send("Sync failed. Check logs.") - # * this should be owner/admin only in prod @app_commands.command(name="sync", description="Sync application commands") + @app_commands.checks.has_permissions(administrator=True) async def sync_slash(self, interaction: discord.Interaction) -> None: """Sync commands via slash command.""" try: - synced = await self._sync_commands() - description = f"Synced {len(synced)} commands: {[cmd.name for cmd in synced]}" + await interaction.response.defer(ephemeral=True) + + global_synced, guild_synced = await self._sync_commands() + + description = f"Synced {len(global_synced)} global commands: {[cmd.name for cmd in global_synced]}" + if guild_synced is not None: + description += ( + f"\nSynced {len(guild_synced)} debug guild commands: {[cmd.name for cmd in guild_synced]}" + ) + self.log.info("/sync invoked user: %s guild: %s", interaction.user.id, interaction.guild_id) - await interaction.response.send_message(description) + await interaction.followup.send(description) except Exception: self.log.exception("/sync attempted user with error") if not interaction.response.is_done(): await interaction.response.send_message( - "We're sorry, this interaction failed. Please contact an admin." + "We're sorry, this interaction failed. Please contact an admin.", + ephemeral=True, ) else: - await interaction.followup.send("We're sorry, this interaction failed. Please contact an admin.") + await interaction.followup.send( + "We're sorry, this interaction failed. Please contact an admin.", + ephemeral=True, + ) async def setup(bot: commands.Bot) -> None: """Set up the Sync cog.""" - await bot.add_cog(Sync()) + await bot.add_cog(Sync(bot)) From 5df40fa0bf1798c85423f4fde41b3fc6534c3efb Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Sat, 31 Jan 2026 00:42:50 -0500 Subject: [PATCH 2/3] docs: add git commit guidelines and cog initialization pattern --- AGENTS.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 389fd57..76c8242 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,3 +132,37 @@ To run arbitrary scripts or commands within the environment: ```bash uv run python path/to/script.py ``` + +## 8. Git Commit Guidelines + +### Pre-Commit Hooks + +This project uses pre-commit hooks for linting. If a hook fails during commit: + +1. **DO NOT** use `git commit --no-verify` to bypass hooks. +2. **DO** run `uv run task lint` manually to verify and fix issues. +3. If `uv run task lint` passes but the hook still fails (e.g., executable not found), there is likely an environment issue with the pre-commit config that needs to be fixed. + +### Cog Initialization Pattern + +All Cogs **MUST** accept the `bot` instance as an argument in their `__init__` method: + +```python +# CORRECT +class MyCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(MyCog(bot)) + +# INCORRECT - Do not use global instance or omit bot argument +class MyCog(commands.Cog): + def __init__(self) -> None: # Missing bot! + pass +``` + +This ensures: +- Proper dependency injection +- Testability (can pass mock bot) +- No reliance on global state From e23590142c9802aa3de314d4760f85d594b3dd56 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Sat, 31 Jan 2026 00:53:09 -0500 Subject: [PATCH 3/3] cleanup(hotwap): removed hotswap for better version history --- capy_discord/exts/tools/hotswap.py | 127 ----------------------------- 1 file changed, 127 deletions(-) delete mode 100644 capy_discord/exts/tools/hotswap.py diff --git a/capy_discord/exts/tools/hotswap.py b/capy_discord/exts/tools/hotswap.py deleted file mode 100644 index 4797997..0000000 --- a/capy_discord/exts/tools/hotswap.py +++ /dev/null @@ -1,127 +0,0 @@ -import logging -from typing import Literal - -import discord -from discord import app_commands, ui -from discord.ext import commands - -from capy_discord.config import settings -from capy_discord.ui.views import BaseView -from capy_discord.utils.embeds import error_embed, success_embed -from capy_discord.utils.extensions import walk_extensions - -log = logging.getLogger(__name__) - - -class HotswapSelect(ui.Select): - """Dropdown for selecting extensions to hotswap.""" - - def __init__(self, extensions: list[str], action: str) -> None: - """Initialize the HotswapSelect dropdown.""" - self.action = action - options = [discord.SelectOption(label=ext, value=ext) for ext in extensions] - super().__init__( - placeholder=f"Select an extension to {action}...", - min_values=1, - max_values=1, - options=options, - ) - - async def callback(self, interaction: discord.Interaction) -> None: - """Handle the selection and perform the requested action.""" - cog_name = self.values[0] - bot = interaction.client - - if not isinstance(bot, commands.Bot): - log.error("Interaction client is not a commands.Bot instance.") - return - - try: - if self.action == "reload": - await bot.reload_extension(cog_name) - elif self.action == "load": - await bot.load_extension(cog_name) - elif self.action == "unload": - await bot.unload_extension(cog_name) - - await interaction.response.send_message( - embed=success_embed( - f"Extension {self.action.capitalize()}ed", - f"Successfully {self.action}ed `{cog_name}`.", - ), - ephemeral=True, - ) - except Exception as e: - log.exception("Failed to %s extension %s", self.action, cog_name) - await interaction.response.send_message( - embed=error_embed( - f"Failed to {self.action.capitalize()} Extension", - f"An error occurred while {self.action}ing `{cog_name}`: `{e}`", - ), - ephemeral=True, - ) - - -class HotswapView(BaseView): - """View for hotswapping extensions.""" - - def __init__(self, extensions: list[str], action: str, *, timeout: float | None = 180) -> None: - """Initialize the HotswapView.""" - super().__init__(timeout=timeout) - self.add_item(HotswapSelect(extensions, action)) - - -class HotswapCog(commands.Cog): - """Cog for reloading, loading, and unloading extensions at runtime.""" - - def __init__(self, bot: commands.Bot) -> None: - """Initialize the HotswapCog.""" - self.bot = bot - - def get_unloaded_cogs(self) -> list[str]: - """Get a list of cogs that are currently not loaded.""" - all_extensions = set(walk_extensions()) - loaded_extensions = set(self.bot.extensions.keys()) - return sorted(all_extensions - loaded_extensions) - - @app_commands.command(name="hotswap", description="Reload, load, or unload bot extensions.") - @app_commands.describe(action="The action to perform") - @app_commands.checks.has_permissions(administrator=True) - async def hotswap( - self, - interaction: discord.Interaction, - action: Literal["reload", "load", "unload"], - ) -> None: - """Handle the /hotswap command.""" - if action == "reload": - # Prevent self-reload to avoid potentially breaking the hotswap command during use - extensions = [ext for ext in self.bot.extensions if ext != "capy_discord.exts.tools.hotswap"] - if not extensions: - await interaction.response.send_message("No extensions are currently loaded.", ephemeral=True) - return - elif action == "load": - extensions = self.get_unloaded_cogs() - if not extensions: - await interaction.response.send_message("All available extensions are already loaded.", ephemeral=True) - return - else: # unload - extensions = [ext for ext in self.bot.extensions if ext != "capy_discord.exts.tools.hotswap"] - if not extensions: - await interaction.response.send_message("No extensions are currently loaded.", ephemeral=True) - return - - # Sort extensions for better UX - extensions.sort() - - # Discord select menu limit is 25 options - view = HotswapView(extensions[:25], action) - await view.reply(interaction, f"Select an extension to {action}:", ephemeral=True) - - # If debug_guild_id is set, restrict to that guild - if settings.debug_guild_id: - hotswap = app_commands.guilds(discord.Object(id=settings.debug_guild_id))(hotswap) - - -async def setup(bot: commands.Bot) -> None: - """Load the HotswapCog.""" - await bot.add_cog(HotswapCog(bot))