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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Format: `<type>(<scope>): <subject>`
## 7. Cog Standards

### Initialization
All Cogs **MUST** accept the `bot` instance in `__init__`.
All Cogs **MUST** accept the `bot` instance in `__init__`. The use of the global `capy_discord.instance` is **deprecated** and should not be used in new code.

```python
class MyCog(commands.Cog):
Expand Down
22 changes: 20 additions & 2 deletions capy_discord/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
from typing import Optional, TYPE_CHECKING
from __future__ import annotations

import warnings
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from capy_discord.bot import Bot

instance: Optional["Bot"] = None
instance: Bot | None = None

_instance: Bot | None = None


def __getattr__(name: str) -> object:
if name == "instance":
warnings.warn(
"capy_discord.instance is deprecated. Use dependency injection.",
DeprecationWarning,
stacklevel=2,
)
return _instance

msg = f"module {__name__!r} has no attribute {name!r}"
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
raise AttributeError(msg)
6 changes: 4 additions & 2 deletions capy_discord/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ def main() -> None:
"""Main function to run the application."""
setup_logging(settings.log_level)

capy_discord.instance = Bot(command_prefix=[settings.prefix, "!"], intents=discord.Intents.all())
capy_discord.instance.run(settings.token, log_handler=None)
# Global bot instance (DEPRECATED: Use Dependency Injection instead).
# We assign to _instance so that accessing .instance triggers the deprecation warning in __init__.py
capy_discord._instance = Bot(command_prefix=[settings.prefix, "!"], intents=discord.Intents.all())
capy_discord._instance.run(settings.token, log_handler=None)


main()
58 changes: 56 additions & 2 deletions capy_discord/bot.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,72 @@
import logging

from discord.ext.commands import AutoShardedBot
import discord
from discord import app_commands
from discord.ext import commands

from capy_discord.errors import UserFriendlyError
from capy_discord.ui.embeds import error_embed
from capy_discord.utils import EXTENSIONS


class Bot(AutoShardedBot):
class Bot(commands.AutoShardedBot):
"""Bot class for Capy Discord."""

async def setup_hook(self) -> None:
"""Run before the bot starts."""
self.log = logging.getLogger(__name__)
self.tree.on_error = self.on_tree_error # type: ignore
await self.load_extensions()

def _get_logger_for_command(
self, command: app_commands.Command | app_commands.ContextMenu | commands.Command | None
) -> logging.Logger:
if command and hasattr(command, "module") and command.module:
return logging.getLogger(command.module)
return self.log

async def on_tree_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError) -> None:
"""Handle errors in slash commands."""
# Unpack CommandInvokeError to get the original exception
actual_error = error
if isinstance(error, app_commands.CommandInvokeError):
actual_error = error.original

if isinstance(actual_error, UserFriendlyError):
embed = error_embed(description=actual_error.user_message)
if interaction.response.is_done():
await interaction.followup.send(embed=embed, ephemeral=True)
else:
await interaction.response.send_message(embed=embed, ephemeral=True)
return

# Generic error handling
logger = self._get_logger_for_command(interaction.command)
logger.exception("Slash command error: %s", error)
Comment on lines +43 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The logged error message uses the wrapper exception instead of the unwrapped actual_error, which may reduce log clarity.

In on_tree_error and on_command_error you unpack CommandInvokeError into actual_error, but still log error. Please switch the format argument to actual_error so the underlying exception type/message is visible, e.g.:

logger.exception("Slash command error: %s", actual_error)

The same applies to the prefix command handler.

Suggested implementation:

        # Generic error handling
        logger = self._get_logger_for_command(interaction.command)
        logger.exception("Slash command error: %s", actual_error)
        embed = error_embed(description="An unexpected error occurred. Please try again later.")

You should also update the prefix command handler (on_command_error) in the same way. Wherever you have a pattern roughly like:

actual_error = error.original
logger.exception("Command error: %s", error)

change it to:

actual_error = error.original
logger.exception("Command error: %s", actual_error)

so that the underlying exception type and message are logged consistently for both slash and prefix commands.

embed = error_embed(description="An unexpected error occurred. Please try again later.")
if interaction.response.is_done():
await interaction.followup.send(embed=embed, ephemeral=True)
else:
await interaction.response.send_message(embed=embed, ephemeral=True)

async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None:
"""Handle errors in prefix commands."""
# Unpack CommandInvokeError
actual_error = error
if isinstance(error, commands.CommandInvokeError):
actual_error = error.original

if isinstance(actual_error, UserFriendlyError):
embed = error_embed(description=actual_error.user_message)
await ctx.send(embed=embed)
return

# Generic error handling
logger = self._get_logger_for_command(ctx.command)
logger.exception("Command error: %s", error)
embed = error_embed(description="An unexpected error occurred. Please try again later.")
await ctx.send(embed=embed)

async def load_extensions(self) -> None:
"""Load all enabled extensions."""
for extension in EXTENSIONS:
Expand Down
22 changes: 22 additions & 0 deletions capy_discord/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
class CapyError(Exception):
"""Base exception class for all Capy Discord errors."""

pass


class UserFriendlyError(CapyError):
"""An exception that can be safely displayed to the user.

Attributes:
user_message (str): The message to display to the user.
"""

def __init__(self, message: str, user_message: str) -> None:
"""Initialize the error.

Args:
message: Internal log message.
user_message: User-facing message.
"""
super().__init__(message)
self.user_message = user_message
31 changes: 31 additions & 0 deletions capy_discord/exts/tools/_error_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import discord
from discord import app_commands
from discord.ext import commands

from capy_discord.errors import UserFriendlyError


class ErrorTest(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot

@app_commands.command(name="error-test", description="Trigger various error types for verification")
@app_commands.choices(
error_type=[
app_commands.Choice(name="generic", value="generic"),
app_commands.Choice(name="user-friendly", value="user-friendly"),
]
)
async def error_test(self, _interaction: discord.Interaction, error_type: str) -> None:
if error_type == "generic":
raise ValueError("Generic error") # noqa: TRY003
if error_type == "user-friendly":
raise UserFriendlyError("Log", "User message")

@commands.command(name="error-test")
async def error_test_command(self, _ctx: commands.Context) -> None:
raise RuntimeError("Test Exception") # noqa: TRY003


async def setup(bot: commands.Bot) -> None:
await bot.add_cog(ErrorTest(bot))
16 changes: 5 additions & 11 deletions capy_discord/exts/tools/ping.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,11 @@ def __init__(self, bot: commands.Bot) -> None:
@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(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)

await interaction.response.send_message(embed=embed)

except Exception:
self.log.exception("/ping attempted user")
await interaction.response.send_message("We're sorry, this interaction failed. Please contact an admin.")
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)
await interaction.response.send_message(embed=embed)


async def setup(bot: commands.Bot) -> None:
Expand Down
87 changes: 33 additions & 54 deletions capy_discord/exts/tools/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,67 +53,46 @@ async def _sync_commands(self) -> tuple[list[app_commands.AppCommand], list[app_
@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)."""
try:
if spec in [".", "guild"]:
if ctx.guild is None:
await ctx.send("This command must be used in a guild.")
return
# Instant sync to current guild
ctx.bot.tree.copy_global_to(guild=ctx.guild)
synced = await ctx.bot.tree.sync(guild=ctx.guild)
description = f"Synced {len(synced)} commands to **current guild**."
elif spec == "clear":
if ctx.guild is None:
await ctx.send("This command must be used in a guild.")
return
# Clear guild commands
ctx.bot.tree.clear_commands(guild=ctx.guild)
await ctx.bot.tree.sync(guild=ctx.guild)
description = "Cleared commands for **current guild**."
else:
# 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)

except Exception:
self.log.exception("!sync attempted with error")
await ctx.send("Sync failed. Check logs.")
if spec in [".", "guild"]:
if ctx.guild is None:
await ctx.send("This command must be used in a guild.")
return
# Instant sync to current guild
ctx.bot.tree.copy_global_to(guild=ctx.guild)
synced = await ctx.bot.tree.sync(guild=ctx.guild)
description = f"Synced {len(synced)} commands to **current guild**."
elif spec == "clear":
if ctx.guild is None:
await ctx.send("This command must be used in a guild.")
return
# Clear guild commands
ctx.bot.tree.clear_commands(guild=ctx.guild)
await ctx.bot.tree.sync(guild=ctx.guild)
description = "Cleared commands for **current guild**."
else:
# 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)

@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:
await interaction.response.defer(ephemeral=True)
await interaction.response.defer(ephemeral=True)

global_synced, guild_synced = await self._sync_commands()
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.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.",
ephemeral=True,
)
else:
await interaction.followup.send(
"We're sorry, this interaction failed. Please contact an admin.",
ephemeral=True,
)
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.followup.send(description)


async def setup(bot: commands.Bot) -> None:
Expand Down
4 changes: 2 additions & 2 deletions capy_discord/ui/embeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
STATUS_IGNORED = discord.Color.greyple()


def error_embed(title: str, description: str) -> discord.Embed:
def error_embed(title: str = "❌ Error", description: str = "") -> discord.Embed:
"""Create an error status embed.

Args:
title: The title of the embed.
title: The title of the embed. Defaults to "❌ Error".
description: The description of the embed.

Returns:
Expand Down
23 changes: 13 additions & 10 deletions capy_discord/ui/views.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import logging
from typing import Any, cast
from typing import cast

import discord
from discord import ui
from discord.utils import MISSING

from capy_discord.ui.embeds import error_embed


class BaseView(ui.View):
Expand All @@ -24,12 +27,12 @@ async def on_error(self, interaction: discord.Interaction, error: Exception, ite
"""Handle errors raised in view items."""
self.log.error("Error in view %s item %s: %s", self, item, error, exc_info=error)

err_msg = "❌ **Something went wrong!**\nThe error has been logged for the developers."
embed = error_embed(description="Something went wrong!\nThe error has been logged for the developers.")

if interaction.response.is_done():
await interaction.followup.send(err_msg, ephemeral=True)
await interaction.followup.send(embed=embed, ephemeral=True)
else:
await interaction.response.send_message(err_msg, ephemeral=True)
await interaction.response.send_message(embed=embed, ephemeral=True)

async def on_timeout(self) -> None:
"""Disable all items and update the message on timeout."""
Expand All @@ -49,18 +52,18 @@ def disable_all_items(self) -> None:
"""Disable all interactive items in the view."""
for item in self.children:
if hasattr(item, "disabled"):
cast("Any", item).disabled = True
cast("ui.Button | ui.Select", item).disabled = True

async def reply( # noqa: PLR0913
self,
interaction: discord.Interaction,
content: str | None = None,
embed: discord.Embed | None = None,
embeds: list[discord.Embed] = discord.utils.MISSING,
file: discord.File = discord.utils.MISSING,
files: list[discord.File] = discord.utils.MISSING,
embed: discord.Embed = MISSING,
embeds: list[discord.Embed] = MISSING,
file: discord.File = MISSING,
files: list[discord.File] = MISSING,
ephemeral: bool = False,
allowed_mentions: discord.AllowedMentions = discord.utils.MISSING,
allowed_mentions: discord.AllowedMentions = MISSING,
) -> None:
"""Send a message with this view and automatically track the message."""
await interaction.response.send_message(
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dev = [
"uv>=0.9.10",
"taskipy>=1.14.1",
"pytest>=9.0.1",
"pytest-asyncio>=0.25.0",
"pytest-xdist>=3.8.0",
"pytest-cov>=7.0.0",
"coverage>=7.12.0",
Expand Down
Loading