Repository: python-discord/bot

Issue:
Better Discord error handling
**Originally posted by Leon Sandøy:**

Currently, innocent Discord errors such as "this command does not exist" or "could not find that user" are not being handled by the command error handler. As a result, the bot logs a lot of stacktraces for relatively innocent things.

See the following paste for examples: https://paste.pythondiscord.com/uwopupoyun.py

Investigate the problem, create logic to handle these types of errors, and merge request it to the bot.


Discussion:
#392 addressed this somewhat. Current errors that are still not handled:

* `CheckFailure` and its subclasses
    * `BotMissingPermissions` is handled
    * `NoPrivateMessage` is handled
* `DisabledCommand`
* `CommandOnCooldown`
* `ConversionError`
    * This should probably still be raised
Addressed in 51832a333ea544df5c94943b7dc000c2dfcd0979. However, it may be improved when Discord.py is updated to 1.1 by adding handlers specifically for missing roles.
Currently the default is to ignore `InChannelCheckFailure`. Commands which wish to send a message to users with this error need to use their own error handlers, and that's currently what `!eval` and `!charinfo` do. It's probably better to flip the default behaviour so that a custom error handler is needed to _hide_ the message from users. As of now, the `verification` cog would be the only place that needs to hide it.

#126 is also somewhat relevant to these issues.
Merging #126 into the scope of this ticket as they're close enough related.

> **Kingsley McDonald:** many error responses in the `Moderation` cog aren't sent as an embed. instead, the messages are sent with a very simple (and ugly) :x:. all occurrences of this should be changed to be consistent with the rest of the bot's responses.

> **Mark:** It seems we are moving away from embeds in favour of plain messages because they have less clutter. The downside is that they are no longer as prominent as they would be if they were embeds.
> Which direction to take is still open for discussion.

> **Mark:** Some additional context:
> The default error handler currently does not use embeds. However, some commands/cogs have their own error handlers which do use embeds. For example: utils and snekbox. These embeds also take a random error message to display in the title.

Resolving patch:
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
--- a/bot/cogs/error_handler.py
+++ b/bot/cogs/error_handler.py
@@ -1,20 +1,8 @@
 import contextlib
 import logging
+import typing as t
 
-from discord.ext.commands import (
-    BadArgument,
-    BotMissingPermissions,
-    CheckFailure,
-    CommandError,
-    CommandInvokeError,
-    CommandNotFound,
-    CommandOnCooldown,
-    DisabledCommand,
-    MissingPermissions,
-    NoPrivateMessage,
-    UserInputError,
-)
-from discord.ext.commands import Cog, Context
+from discord.ext.commands import Cog, Command, Context, errors
 from sentry_sdk import push_scope
 
 from bot.api import ResponseCodeError
@@ -32,118 +20,177 @@ def __init__(self, bot: Bot):
         self.bot = bot
 
     @Cog.listener()
-    async def on_command_error(self, ctx: Context, e: CommandError) -> None:
+    async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None:
         """
         Provide generic command error handling.
 
-        Error handling is deferred to any local error handler, if present.
-
-        Error handling emits a single error response, prioritized as follows:
-            1. If the name fails to match a command but matches a tag, the tag is invoked
-            2. Send a BadArgument error message to the invoking context & invoke the command's help
-            3. Send a UserInputError error message to the invoking context & invoke the command's help
-            4. Send a NoPrivateMessage error message to the invoking context
-            5. Send a BotMissingPermissions error message to the invoking context
-            6. Log a MissingPermissions error, no message is sent
-            7. Send a InChannelCheckFailure error message to the invoking context
-            8. Log CheckFailure, CommandOnCooldown, and DisabledCommand errors, no message is sent
-            9. For CommandInvokeErrors, response is based on the type of error:
-                * 404: Error message is sent to the invoking context
-                * 400: Log the resopnse JSON, no message is sent
-                * 500 <= status <= 600: Error message is sent to the invoking context
-            10. Otherwise, handling is deferred to `handle_unexpected_error`
+        Error handling is deferred to any local error handler, if present. This is done by
+        checking for the presence of a `handled` attribute on the error.
+
+        Error handling emits a single error message in the invoking context `ctx` and a log message,
+        prioritised as follows:
+
+        1. If the name fails to match a command but matches a tag, the tag is invoked
+            * If CommandNotFound is raised when invoking the tag (determined by the presence of the
+              `invoked_from_error_handler` attribute), this error is treated as being unexpected
+              and therefore sends an error message
+            * Commands in the verification channel are ignored
+        2. UserInputError: see `handle_user_input_error`
+        3. CheckFailure: see `handle_check_failure`
+        4. CommandOnCooldown: send an error message in the invoking context
+        5. ResponseCodeError: see `handle_api_error`
+        6. Otherwise, if not a DisabledCommand, handling is deferred to `handle_unexpected_error`
         """
         command = ctx.command
-        parent = None
 
+        if hasattr(e, "handled"):
+            log.trace(f"Command {command} had its error already handled locally; ignoring.")
+            return
+
+        # Try to look for a tag with the command's name if the command isn't found.
+        if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):
+            if ctx.channel.id != Channels.verification:
+                await self.try_get_tag(ctx)
+                return  # Exit early to avoid logging.
+        elif isinstance(e, errors.UserInputError):
+            await self.handle_user_input_error(ctx, e)
+        elif isinstance(e, errors.CheckFailure):
+            await self.handle_check_failure(ctx, e)
+        elif isinstance(e, errors.CommandOnCooldown):
+            await ctx.send(e)
+        elif isinstance(e, errors.CommandInvokeError):
+            if isinstance(e.original, ResponseCodeError):
+                await self.handle_api_error(ctx, e.original)
+            else:
+                await self.handle_unexpected_error(ctx, e.original)
+            return  # Exit early to avoid logging.
+        elif not isinstance(e, errors.DisabledCommand):
+            # ConversionError, MaxConcurrencyReached, ExtensionError
+            await self.handle_unexpected_error(ctx, e)
+            return  # Exit early to avoid logging.
+
+        log.debug(
+            f"Command {command} invoked by {ctx.message.author} with error "
+            f"{e.__class__.__name__}: {e}"
+        )
+
+    async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple:
+        """Return the help command invocation args to display help for `command`."""
+        parent = None
         if command is not None:
             parent = command.parent
 
         # Retrieve the help command for the invoked command.
         if parent and command:
-            help_command = (self.bot.get_command("help"), parent.name, command.name)
+            return self.bot.get_command("help"), parent.name, command.name
         elif command:
-            help_command = (self.bot.get_command("help"), command.name)
+            return self.bot.get_command("help"), command.name
         else:
-            help_command = (self.bot.get_command("help"),)
+            return self.bot.get_command("help")
 
-        if hasattr(e, "handled"):
-            log.trace(f"Command {command} had its error already handled locally; ignoring.")
+    async def try_get_tag(self, ctx: Context) -> None:
+        """
+        Attempt to display a tag by interpreting the command name as a tag name.
+
+        The invocation of tags get respects its checks. Any CommandErrors raised will be handled
+        by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to
+        the context to prevent infinite recursion in the case of a CommandNotFound exception.
+        """
+        tags_get_command = self.bot.get_command("tags get")
+        ctx.invoked_from_error_handler = True
+
+        log_msg = "Cancelling attempt to fall back to a tag due to failed checks."
+        try:
+            if not await tags_get_command.can_run(ctx):
+                log.debug(log_msg)
+                return
+        except errors.CommandError as tag_error:
+            log.debug(log_msg)
+            await self.on_command_error(ctx, tag_error)
             return
 
-        # Try to look for a tag with the command's name if the command isn't found.
-        if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):
-            if not ctx.channel.id == Channels.verification:
-                tags_get_command = self.bot.get_command("tags get")
-                ctx.invoked_from_error_handler = True
-
-                log_msg = "Cancelling attempt to fall back to a tag due to failed checks."
-                try:
-                    if not await tags_get_command.can_run(ctx):
-                        log.debug(log_msg)
-                        return
-                except CommandError as tag_error:
-                    log.debug(log_msg)
-                    await self.on_command_error(ctx, tag_error)
-                    return
-
-                # Return to not raise the exception
-                with contextlib.suppress(ResponseCodeError):
-                    await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with)
-                    return
-        elif isinstance(e, BadArgument):
+        # Return to not raise the exception
+        with contextlib.suppress(ResponseCodeError):
+            await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with)
+            return
+
+    async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None:
+        """
+        Send an error message in `ctx` for UserInputError, sometimes invoking the help command too.
+
+        * MissingRequiredArgument: send an error message with arg name and the help command
+        * TooManyArguments: send an error message and the help command
+        * BadArgument: send an error message and the help command
+        * BadUnionArgument: send an error message including the error produced by the last converter
+        * ArgumentParsingError: send an error message
+        * Other: send an error message and the help command
+        """
+        # TODO: use ctx.send_help() once PR #519 is merged.
+        help_command = await self.get_help_command(ctx.command)
+
+        if isinstance(e, errors.MissingRequiredArgument):
+            await ctx.send(f"Missing required argument `{e.param.name}`.")
+            await ctx.invoke(*help_command)
+        elif isinstance(e, errors.TooManyArguments):
+            await ctx.send(f"Too many arguments provided.")
+            await ctx.invoke(*help_command)
+        elif isinstance(e, errors.BadArgument):
             await ctx.send(f"Bad argument: {e}\n")
             await ctx.invoke(*help_command)
-        elif isinstance(e, UserInputError):
+        elif isinstance(e, errors.BadUnionArgument):
+            await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```")
+        elif isinstance(e, errors.ArgumentParsingError):
+            await ctx.send(f"Argument parsing error: {e}")
+        else:
             await ctx.send("Something about your input seems off. Check the arguments:")
             await ctx.invoke(*help_command)
-            log.debug(
-                f"Command {command} invoked by {ctx.message.author} with error "
-                f"{e.__class__.__name__}: {e}"
-            )
-        elif isinstance(e, NoPrivateMessage):
-            await ctx.send("Sorry, this command can't be used in a private message!")
-        elif isinstance(e, BotMissingPermissions):
-            await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.")
-            log.warning(
-                f"The bot is missing permissions to execute command {command}: {e.missing_perms}"
-            )
-        elif isinstance(e, MissingPermissions):
-            log.debug(
-                f"{ctx.message.author} is missing permissions to invoke command {command}: "
-                f"{e.missing_perms}"
+
+    @staticmethod
+    async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None:
+        """
+        Send an error message in `ctx` for certain types of CheckFailure.
+
+        The following types are handled:
+
+        * BotMissingPermissions
+        * BotMissingRole
+        * BotMissingAnyRole
+        * NoPrivateMessage
+        * InChannelCheckFailure
+        """
+        bot_missing_errors = (
+            errors.BotMissingPermissions,
+            errors.BotMissingRole,
+            errors.BotMissingAnyRole
+        )
+
+        if isinstance(e, bot_missing_errors):
+            await ctx.send(
+                f"Sorry, it looks like I don't have the permissions or roles I need to do that."
             )
-        elif isinstance(e, InChannelCheckFailure):
+        elif isinstance(e, (InChannelCheckFailure, errors.NoPrivateMessage)):
             await ctx.send(e)
-        elif isinstance(e, (CheckFailure, CommandOnCooldown, DisabledCommand)):
-            log.debug(
-                f"Command {command} invoked by {ctx.message.author} with error "
-                f"{e.__class__.__name__}: {e}"
-            )
-        elif isinstance(e, CommandInvokeError):
-            if isinstance(e.original, ResponseCodeError):
-                status = e.original.response.status
-
-                if status == 404:
-                    await ctx.send("There does not seem to be anything matching your query.")
-                elif status == 400:
-                    content = await e.original.response.json()
-                    log.debug(f"API responded with 400 for command {command}: %r.", content)
-                    await ctx.send("According to the API, your request is malformed.")
-                elif 500 <= status < 600:
-                    await ctx.send("Sorry, there seems to be an internal issue with the API.")
-                    log.warning(f"API responded with {status} for command {command}")
-                else:
-                    await ctx.send(f"Got an unexpected status code from the API (`{status}`).")
-                    log.warning(f"Unexpected API response for command {command}: {status}")
-            else:
-                await self.handle_unexpected_error(ctx, e.original)
+
+    @staticmethod
+    async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None:
+        """Send an error message in `ctx` for ResponseCodeError and log it."""
+        if e.status == 404:
+            await ctx.send("There does not seem to be anything matching your query.")
+            log.debug(f"API responded with 404 for command {ctx.command}")
+        elif e.status == 400:
+            content = await e.response.json()
+            log.debug(f"API responded with 400 for command {ctx.command}: %r.", content)
+            await ctx.send("According to the API, your request is malformed.")
+        elif 500 <= e.status < 600:
+            await ctx.send("Sorry, there seems to be an internal issue with the API.")
+            log.warning(f"API responded with {e.status} for command {ctx.command}")
         else:
-            await self.handle_unexpected_error(ctx, e)
+            await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).")
+            log.warning(f"Unexpected API response for command {ctx.command}: {e.status}")
 
     @staticmethod
-    async def handle_unexpected_error(ctx: Context, e: CommandError) -> None:
-        """Generic handler for errors without an explicit handler."""
+    async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None:
+        """Send a generic error message in `ctx` and log the exception as an error with exc_info."""
         await ctx.send(
             f"Sorry, an unexpected error occurred. Please let us know!\n\n"
             f"```{e.__class__.__name__}: {e}```"
diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py
--- a/bot/cogs/moderation/infractions.py
+++ b/bot/cogs/moderation/infractions.py
@@ -313,6 +313,6 @@ def cog_check(self, ctx: Context) -> bool:
     async def cog_command_error(self, ctx: Context, error: Exception) -> None:
         """Send a notification to the invoking context on a Union failure."""
         if isinstance(error, commands.BadUnionArgument):
-            if discord.User in error.converters:
+            if discord.User in error.converters or discord.Member in error.converters:
                 await ctx.send(str(error.errors[0]))
                 error.handled = True


Test patch: