Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions capy_discord/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Settings(EnvConfig):
log_level: int = logging.INFO
prefix: str = "/"
token: str = ""
debug_guild_id: int | None = None


settings = Settings()
12 changes: 9 additions & 3 deletions capy_discord/exts/tools/ping.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
"""Ping command cog.

This module provides a simple ping command to check bot latency.
"""

import logging

import discord
Expand All @@ -8,16 +13,17 @@
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")

@app_commands.command(name="ping", description="Shows the bot's latency")
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)
Expand All @@ -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))
74 changes: 52 additions & 22 deletions capy_discord/exts/tools/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- Manual sync via command
- Slash command sync
- Global sync
- Debug guild sync (when DEBUG_GUILD_ID is configured)
"""

import logging
Expand All @@ -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)."""
Expand All @@ -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)
Expand All @@ -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))