diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index 42d2c0e5c..a8ad52f3e 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -51,7 +51,7 @@ from .enums import AppCommandOptionType, AppCommandType from ..interactions import Interaction from ..enums import ChannelType, try_enum from .models import AppCommandChannel, AppCommandThread, Choice -from .errors import CommandSignatureMismatch, CommandAlreadyRegistered +from .errors import AppCommandError, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered from ..utils import resolve_annotation, MISSING, is_inside_class from ..user import User from ..member import Member @@ -62,7 +62,6 @@ from ..permissions import Permissions if TYPE_CHECKING: from typing_extensions import ParamSpec, Concatenate - from ..interactions import Interaction from ..types.interactions import ( ResolvedData, PartialThread, @@ -89,6 +88,10 @@ else: T = TypeVar('T') GroupT = TypeVar('GroupT', bound='Group') Coro = Coroutine[Any, Any, T] +Error = Union[ + Callable[[GroupT, Interaction, AppCommandError], Coro[Any]], + Callable[[Interaction, AppCommandError], Coro[Any]], +] ContextMenuCallback = Union[ # If groups end up support context menus these would be uncommented @@ -444,6 +447,7 @@ class Command(Generic[GroupT, P, T]): self._callback: CommandCallback[GroupT, P, T] = callback self.parent: Optional[Group] = parent self.binding: Optional[GroupT] = None + self.on_error: Optional[Error[GroupT]] = None self._params: Dict[str, CommandParameter] = _extract_parameters_from_callback(callback, callback.__globals__) def _copy_with_binding(self, binding: GroupT) -> Command: @@ -453,6 +457,7 @@ class Command(Generic[GroupT, P, T]): copy.description = self.description copy._callback = self._callback copy.parent = self.parent + copy.on_error = self.on_error copy._params = self._params.copy() copy.binding = binding return copy @@ -468,6 +473,21 @@ class Command(Generic[GroupT, P, T]): 'options': [param.to_dict() for param in self._params.values()], } + async def _invoke_error_handler(self, interaction: Interaction, error: AppCommandError) -> None: + # These type ignores are because the type checker can't narrow this type properly. + if self.on_error is not None: + if self.binding is not None: + await self.on_error(self.binding, interaction, error) # type: ignore + else: + await self.on_error(interaction, error) # type: ignore + + parent = self.parent + if parent is not None: + await parent.on_error(interaction, self, error) + + if parent.parent is not None: + await parent.parent.on_error(interaction, self, error) + async def _invoke_with_namespace(self, interaction: Interaction, namespace: Namespace) -> T: defaults = ((name, param.default) for name, param in self._params.items() if not param.required) namespace._update_with_defaults(defaults) @@ -477,7 +497,7 @@ class Command(Generic[GroupT, P, T]): if self.binding is not None: return await self._callback(self.binding, interaction, **namespace.__dict__) # type: ignore return await self._callback(interaction, **namespace.__dict__) # type: ignore - except TypeError: + except TypeError as e: # In order to detect mismatch from the provided signature and the Discord data, # there are many ways it can go wrong yet all of them eventually lead to a TypeError # from the Python compiler showcasing that the signature is incorrect. This lovely @@ -490,7 +510,11 @@ class Command(Generic[GroupT, P, T]): frame = inspect.trace()[-1].frame if frame.f_locals.get('self') is self: raise CommandSignatureMismatch(self) from None + raise CommandInvokeError(self, e) from e + except AppCommandError: raise + except Exception as e: + raise CommandInvokeError(self, e) from e def _get_internal_command(self, name: str) -> Optional[Union[Command, Group]]: return None @@ -503,6 +527,32 @@ class Command(Generic[GroupT, P, T]): parent = self.parent return parent.parent or parent + def error(self, coro: Error[GroupT]) -> Error[GroupT]: + """A decorator that registers a coroutine as a local error handler. + + The local error handler is called whenever an exception is raised in the body + of the command or during handling of the command. The error handler must take + 2 parameters, the interaction and the error. + + The error passed will be derived from :exc:`AppCommandError`. + + Parameters + ----------- + coro: :ref:`coroutine ` + The coroutine to register as the local error handler. + + Raises + ------- + TypeError + The coroutine passed is not actually a coroutine. + """ + + if not inspect.iscoroutinefunction(coro): + raise TypeError('The error handler must be a coroutine.') + + self.on_error = coro + return coro + class ContextMenu: """A class that implements a context menu application command. @@ -560,7 +610,12 @@ class ContextMenu: } async def _invoke(self, interaction: Interaction, arg: Any): - await self._callback(interaction, arg) + try: + await self._callback(interaction, arg) + except AppCommandError: + raise + except Exception as e: + raise CommandInvokeError(self, e) from e class Group: @@ -668,6 +723,25 @@ class Group: def _get_internal_command(self, name: str) -> Optional[Union[Command, Group]]: return self._children.get(name) + async def on_error(self, interaction: Interaction, command: Command, error: AppCommandError) -> None: + """|coro| + + A callback that is called when a child's command raises an :exc:`AppCommandError`. + + The default implementation does nothing. + + Parameters + ----------- + interaction: :class:`~discord.Interaction` + The interaction that is being handled. + command: :class`~discord.app_commands.Command` + The command that failed. + error: :exc:`AppCommandError` + The exception that was raised. + """ + + pass + def add_command(self, command: Union[Command, Group], /, *, override: bool = False): """Adds a command or group to this group's internal list of commands. diff --git a/discord/app_commands/errors.py b/discord/app_commands/errors.py index 086e5e7f6..377b99ecd 100644 --- a/discord/app_commands/errors.py +++ b/discord/app_commands/errors.py @@ -30,6 +30,8 @@ from .enums import AppCommandType from ..errors import DiscordException __all__ = ( + 'AppCommandError', + 'CommandInvokeError', 'CommandAlreadyRegistered', 'CommandSignatureMismatch', 'CommandNotFound', @@ -39,9 +41,54 @@ if TYPE_CHECKING: from .commands import Command, Group, ContextMenu -class CommandAlreadyRegistered(DiscordException): +class AppCommandError(DiscordException): + """The base exception type for all application command related errors. + + This inherits from :exc:`discord.DiscordException`. + + This exception and exceptions inherited from it are handled + in a special way as they are caught and passed into various error handlers + in this order: + + - :meth:`Command.error ` + - :meth:`Group.on_error ` + - :meth:`CommandTree.on_error ` + + .. versionadded:: 2.0 + """ + + pass + + +class CommandInvokeError(AppCommandError): + """An exception raised when the command being invoked raised an exception. + + This inherits from :exc:`~discord.app_commands.AppCommandError`. + + .. versionadded:: 2.0 + + Attributes + ----------- + original: :exc:`Exception` + The original exception that was raised. You can also get this via + the ``__cause__`` attribute. + command: Union[:class:`Command`, :class:`ContextMenu`] + The command that failed. + """ + + def __init__(self, command: Union[Command, ContextMenu], e: Exception) -> None: + self.original: Exception = e + self.command: Union[Command, ContextMenu] = command + super().__init__(f'Command {command.name!r} raised an exception: {e.__class__.__name__}: {e}') + + +class CommandAlreadyRegistered(AppCommandError): """An exception raised when a command is already registered. + This inherits from :exc:`~discord.app_commands.AppCommandError`. + + .. versionadded:: 2.0 + Attributes ----------- name: :class:`str` @@ -57,9 +104,13 @@ class CommandAlreadyRegistered(DiscordException): super().__init__(f'Command {name!r} already registered.') -class CommandNotFound(DiscordException): +class CommandNotFound(AppCommandError): """An exception raised when an application command could not be found. + This inherits from :exc:`~discord.app_commands.AppCommandError`. + + .. versionadded:: 2.0 + Attributes ------------ name: :class:`str` @@ -78,12 +129,16 @@ class CommandNotFound(DiscordException): super().__init__(f'Application command {name!r} not found') -class CommandSignatureMismatch(DiscordException): +class CommandSignatureMismatch(AppCommandError): """An exception raised when an application command from Discord has a different signature from the one provided in the code. This happens because your command definition differs from the command definition you provided Discord. Either your code is out of date or the data from Discord is out of sync. + This inherits from :exc:`~discord.app_commands.AppCommandError`. + + .. versionadded:: 2.0 + Attributes ------------ command: Union[:class:`~.app_commands.Command`, :class:`~.app_commands.ContextMenu`, :class:`~.app_commands.Group`] diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py index 6172c2fc1..64c5215c5 100644 --- a/discord/app_commands/tree.py +++ b/discord/app_commands/tree.py @@ -24,6 +24,8 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import inspect +import sys +import traceback from typing import Callable, Dict, List, Literal, Optional, TYPE_CHECKING, Tuple, Type, Union, overload @@ -31,7 +33,12 @@ from .namespace import Namespace, ResolveKey from .models import AppCommand from .commands import Command, ContextMenu, Group, _shorten from .enums import AppCommandType -from .errors import CommandAlreadyRegistered, CommandNotFound, CommandSignatureMismatch +from .errors import ( + AppCommandError, + CommandAlreadyRegistered, + CommandNotFound, + CommandSignatureMismatch, +) from ..errors import ClientException from ..utils import MISSING @@ -385,6 +392,35 @@ class CommandTree: base.extend(cmd for ((_, g, _), cmd) in self._context_menus.items() if g == guild_id) return base + async def on_error( + self, + interaction: Interaction, + command: Optional[Union[ContextMenu, Command]], + error: AppCommandError, + ) -> None: + """|coro| + + A callback that is called when any command raises an :exc:`AppCommandError`. + + The default implementation prints the traceback to stderr. + + Parameters + ----------- + interaction: :class:`~discord.Interaction` + The interaction that is being handled. + command: Optional[Union[:class:`~discord.app_commands.Command`, :class:`~discord.app_commands.ContextMenu`]] + The command that failed, if any. + error: :exc:`AppCommandError` + The exception that was raised. + """ + + if command is not None: + print(f'Ignoring exception in command {command.name!r}:', file=sys.stderr) + else: + print(f'Ignoring exception in command tree:', file=sys.stderr) + + traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr) + def command( self, *, @@ -519,8 +555,8 @@ class CommandTree: async def wrapper(): try: await self.call(interaction) - except Exception as e: - print(f'Error:', e) + except AppCommandError as e: + await self.on_error(interaction, None, e) self.client.loop.create_task(wrapper(), name='CommandTree-invoker') @@ -547,7 +583,10 @@ class CommandTree: raise RuntimeError('This should not happen if Discord sent well-formed data.') # I assume I don't have to type check here. - await ctx_menu._invoke(interaction, value) + try: + await ctx_menu._invoke(interaction, value) + except AppCommandError as e: + await self.on_error(interaction, ctx_menu, e) async def call(self, interaction: Interaction): """|coro| @@ -623,4 +662,9 @@ class CommandTree: # At this point options refers to the arguments of the command # and command refers to the class type we care about namespace = Namespace(interaction, data.get('resolved', {}), options) - await command._invoke_with_namespace(interaction, namespace) + + try: + await command._invoke_with_namespace(interaction, namespace) + except AppCommandError as e: + await command._invoke_error_handler(interaction, e) + await self.on_error(interaction, command, e)