diff --git a/bot/__main__.py b/bot/__main__.py index bacb2fdd69..a6eb6d888c 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -11,12 +11,14 @@ import bot from bot import constants from bot.bot import Bot +from bot.command_error_handlers import bootstrap_command_error_manager from bot.log import setup_sentry from bot.utils.decorators import whitelist_check log = get_logger(__name__) setup_sentry() + async def _create_redis_session() -> RedisSession: """Create and connect to a redis session.""" redis_session = RedisSession( @@ -74,6 +76,8 @@ async def main() -> None: allowed_roles=allowed_roles, ) + bot.instance.register_command_error_manager(bootstrap_command_error_manager(bot.instance)) + async with bot.instance as _bot: _bot.add_check(whitelist_check( channels=constants.WHITELISTED_CHANNELS, diff --git a/bot/command_error_handlers/__init__.py b/bot/command_error_handlers/__init__.py new file mode 100644 index 0000000000..a940638007 --- /dev/null +++ b/bot/command_error_handlers/__init__.py @@ -0,0 +1,30 @@ +from pydis_core.utils.error_handling.commands import CommandErrorManager + +from bot.bot import Bot + +from .api_error import APIErrorHandler +from .bad_argument import BadArgumentErrorHandler +from .check_failure import CheckFailureErrorHandler +from .command_not_found import CommandNotFoundErrorHandler +from .command_on_cooldown import CommandOnCooldownErrorHandler +from .default import DefaultCommandErrorHandler +from .disabled_command import DisabledCommandErrorHandler +from .moved_command import MovedCommandErrorHandler +from .user_input_error import UserInputErrorHandler +from .user_not_playing import UserNotPlayingErrorHandler + + +def bootstrap_command_error_manager(bot: Bot) -> CommandErrorManager: + """Bootstraps the command error manager with all the needed error handlers.""" + default_handler = DefaultCommandErrorHandler() + manager = CommandErrorManager(default=default_handler) + manager.register_handler(CommandNotFoundErrorHandler(bot)) + manager.register_handler(MovedCommandErrorHandler()) + manager.register_handler(UserInputErrorHandler()) + manager.register_handler(APIErrorHandler()) + manager.register_handler(CommandOnCooldownErrorHandler()) + manager.register_handler(UserNotPlayingErrorHandler()) + manager.register_handler(BadArgumentErrorHandler()) + manager.register_handler(CheckFailureErrorHandler()) + manager.register_handler(DisabledCommandErrorHandler()) + return manager diff --git a/bot/command_error_handlers/_utils.py b/bot/command_error_handlers/_utils.py new file mode 100644 index 0000000000..410a8b9ded --- /dev/null +++ b/bot/command_error_handlers/_utils.py @@ -0,0 +1,40 @@ +import random +from collections.abc import Iterable + +from discord import Embed, Message +from discord.ext import commands +from pydis_core.utils.logging import get_logger + +from bot.constants import Colours, ERROR_REPLIES + +log = get_logger(__name__) + + +def create_error_embed(message: str, title: Iterable | str = ERROR_REPLIES) -> Embed: + """Build a basic embed with red colour and either a random error title or a title provided.""" + embed = Embed(colour=Colours.soft_red) + if isinstance(title, str): + embed.title = title + else: + embed.title = random.choice(title) + embed.description = message + return embed + + +def revert_cooldown_counter(command: commands.Command, message: Message) -> None: + """Undoes the last cooldown counter for user-error cases.""" + if command._buckets.valid: + bucket = command._buckets.get_bucket(message) + bucket._tokens = min(bucket.rate, bucket._tokens + 1) + log.debug("Cooldown counter reverted as the command was not used correctly.") + + +def get_parent_command_and_subcontext(context: commands.Context) -> tuple[str, commands.Context]: + """Extracts the parent command and subcontext, if any.""" + parent_command = "" + ctx = context + if sub_context := getattr(context, "subcontext", None): + parent_command = f"{context.command} " + ctx = sub_context + + return parent_command, ctx diff --git a/bot/command_error_handlers/api_error.py b/bot/command_error_handlers/api_error.py new file mode 100644 index 0000000000..3c131896d7 --- /dev/null +++ b/bot/command_error_handlers/api_error.py @@ -0,0 +1,36 @@ +from typing import NoReturn + +from discord import Interaction +from discord.ext.commands import Context +from pydis_core.utils.error_handling.commands import AbstractCommandErrorHandler + +from bot.constants import NEGATIVE_REPLIES +from bot.utils.exceptions import APIError + +from ._utils import create_error_embed + + +class APIErrorHandler(AbstractCommandErrorHandler): + """An handler for the APIError error.""" + + async def should_handle_error(self, error: Exception) -> bool: + """A predicate that determines whether the error should be handled or not.""" + return isinstance(error, APIError) + + async def handle_text_command_error(self, context: Context, error: Exception) -> NoReturn: + """Handle error raised in the context of text commands.""" + await context.send( + embed=create_error_embed( + f"There was an error when communicating with the {error.api}", + NEGATIVE_REPLIES + ) + ) + + async def handle_app_command_error(self, interaction: Interaction, error: Exception) -> NoReturn: + """Handle error raised in the context of app commands.""" + await interaction.response.send_message( + embed=create_error_embed( + f"There was an error when communicating with the {error.api}", + NEGATIVE_REPLIES + ) + ) diff --git a/bot/command_error_handlers/bad_argument.py b/bot/command_error_handlers/bad_argument.py new file mode 100644 index 0000000000..1d5bb29ba3 --- /dev/null +++ b/bot/command_error_handlers/bad_argument.py @@ -0,0 +1,29 @@ +from typing import NoReturn + +from discord import Interaction +from discord.ext.commands import BadArgument, Context +from pydis_core.utils.error_handling.commands import AbstractCommandErrorHandler + +from ._utils import create_error_embed, get_parent_command_and_subcontext, revert_cooldown_counter + + +class BadArgumentErrorHandler(AbstractCommandErrorHandler): + """An handler for the BadArgument error.""" + + async def should_handle_error(self, error: Exception) -> bool: + """A predicate that determines whether the error should be handled or not.""" + return isinstance(error, BadArgument) + + async def handle_text_command_error(self, context: Context, error: Exception) -> NoReturn: + """Handle error raised in the context of text commands.""" + revert_cooldown_counter(context.command, context.message) + parent_command, ctx = get_parent_command_and_subcontext(context) + embed = create_error_embed( + "The argument you provided was invalid: " + f"{error}\n\nUsage:\n```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" + ) + await context.send(embed=embed) + + async def handle_app_command_error(self, interaction: Interaction, error: Exception) -> NoReturn: + """Handle error raised in the context of app commands.""" + return diff --git a/bot/command_error_handlers/check_failure.py b/bot/command_error_handlers/check_failure.py new file mode 100644 index 0000000000..0ab6c83f7f --- /dev/null +++ b/bot/command_error_handlers/check_failure.py @@ -0,0 +1,40 @@ +from typing import NoReturn + +from discord import Embed, Interaction +from discord.ext.commands import CheckFailure, Context, NoPrivateMessage +from pydis_core.utils.error_handling.commands import AbstractCommandErrorHandler + +from bot.constants import Channels, NEGATIVE_REPLIES +from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure + +from ._utils import create_error_embed + + +class CheckFailureErrorHandler(AbstractCommandErrorHandler): + """An handler for the CheckFailure error.""" + + async def should_handle_error(self, error: Exception) -> bool: + """A predicate that determines whether the error should be handled or not.""" + return isinstance(error, CheckFailure) + + async def handle_text_command_error(self, context: Context, error: Exception) -> NoReturn: + """Handle error raised in the context of text commands.""" + error_embed = self._get_error_embed(error) + await context.send(embed=error_embed, delete_after=7.5) + return + + async def handle_app_command_error(self, interaction: Interaction, error: Exception) -> NoReturn: + """Handle error raised in the context of app commands.""" + await interaction.response.send_message(embed=self._get_error_embed(error)) + + @staticmethod + def _get_error_embed(error: Exception) -> Embed: + if isinstance(error, InChannelCheckFailure | InMonthCheckFailure): + return create_error_embed(str(error), NEGATIVE_REPLIES) + if isinstance(error, NoPrivateMessage): + return create_error_embed( + "This command can only be used in the server. " + f"Go to <#{Channels.sir_lancebot_playground}> instead!", + NEGATIVE_REPLIES + ) + return create_error_embed("You are not authorized to use this command.", NEGATIVE_REPLIES) diff --git a/bot/command_error_handlers/command_not_found.py b/bot/command_error_handlers/command_not_found.py new file mode 100644 index 0000000000..957b4d381c --- /dev/null +++ b/bot/command_error_handlers/command_not_found.py @@ -0,0 +1,64 @@ +from typing import NoReturn + +from discord import Embed, Interaction, errors +from discord.ext import commands +from pydis_core.utils.error_handling.commands import AbstractCommandErrorHandler +from pydis_core.utils.logging import get_logger + +from bot.bot import Bot +from bot.utils.commands import get_command_suggestions + +log = get_logger(__name__) + +DELETE_DELAY = 10 +QUESTION_MARK_ICON = "https://cdn.discordapp.com/emojis/512367613339369475.png" + + +class CommandNotFoundErrorHandler(AbstractCommandErrorHandler): + """A handler for all CommandNotFound exceptions.""" + + def __init__(self, bot: Bot): + self.bot = bot + + async def should_handle_error(self, error: errors.DiscordException) -> bool: + """A predicate that determines whether the error should be handled or not.""" + return isinstance(error, commands.CommandNotFound) + + async def handle_app_command_error(self, interaction: Interaction, error: errors.DiscordException) -> NoReturn: + """Handle error raised in the context of app commands.""" + # CommandNotFound cannot happen with app commands, so there's nothing to do here + return + + async def handle_text_command_error(self, context: commands.Context, error: errors.DiscordException) -> NoReturn: + """Handle error raised in the context of text commands.""" + if not context.invoked_with.startswith("."): + await self.send_command_suggestion(context, context.invoked_with) + + async def send_command_suggestion(self, ctx: commands.Context, command_name: str) -> None: + """Sends user similar commands if any can be found.""" + command_suggestions = [] + if similar_command_names := get_command_suggestions(list(self.bot.all_commands.keys()), command_name): + for similar_command_name in similar_command_names: + similar_command = self.bot.get_command(similar_command_name) + + if not similar_command: + continue + + log_msg = "Cancelling attempt to suggest a command due to failed checks." + try: + if not await similar_command.can_run(ctx): + log.debug(log_msg) + continue + except commands.errors.CommandError: + log.debug(log_msg) + continue + + command_suggestions.append(similar_command_name) + + misspelled_content = ctx.message.content + e = Embed() + e.set_author(name="Did you mean:", icon_url=QUESTION_MARK_ICON) + e.description = "\n".join( + misspelled_content.replace(command_name, cmd, 1) for cmd in command_suggestions + ) + await ctx.send(embed=e, delete_after=DELETE_DELAY) diff --git a/bot/command_error_handlers/command_on_cooldown.py b/bot/command_error_handlers/command_on_cooldown.py new file mode 100644 index 0000000000..37b6b30245 --- /dev/null +++ b/bot/command_error_handlers/command_on_cooldown.py @@ -0,0 +1,31 @@ +import math +from typing import NoReturn + +from discord import Interaction +from discord.ext.commands import CommandOnCooldown, Context +from pydis_core.utils.error_handling.commands import AbstractCommandErrorHandler + +from bot.constants import NEGATIVE_REPLIES + +from ._utils import create_error_embed + + +class CommandOnCooldownErrorHandler(AbstractCommandErrorHandler): + """An handler for the CommandOnCooldown error.""" + + async def should_handle_error(self, error: Exception) -> bool: + """A predicate that determines whether the error should be handled or not.""" + return isinstance(error, CommandOnCooldown) + + async def handle_text_command_error(self, context: Context, error: Exception) -> NoReturn: + """Handle error raised in the context of text commands.""" + mins, secs = divmod(math.ceil(error.retry_after), 60) + embed = create_error_embed( + f"This command is on cooldown:\nPlease retry in {mins} minutes {secs} seconds.", + NEGATIVE_REPLIES + ) + await context.send(embed=embed, delete_after=7.5) + + async def handle_app_command_error(self, interaction: Interaction, error: Exception) -> NoReturn: + """Handle error raised in the context of app commands.""" + raise Exception from error diff --git a/bot/command_error_handlers/default.py b/bot/command_error_handlers/default.py new file mode 100644 index 0000000000..324b1dfc44 --- /dev/null +++ b/bot/command_error_handlers/default.py @@ -0,0 +1,74 @@ +from typing import NoReturn + +from discord import Guild, Interaction, errors +from discord.ext.commands import Context +from pydis_core.utils.error_handling.commands import AbstractCommandErrorHandler +from pydis_core.utils.logging import get_logger +from sentry_sdk import push_scope + +log = get_logger(__name__) + + +class DefaultCommandErrorHandler(AbstractCommandErrorHandler): + """A default command error handler.""" + + async def should_handle_error(self, error: errors.DiscordException) -> bool: + """A predicate that determines whether the error should be handled or not.""" + return True + + async def handle_text_command_error(self, context: Context, error: errors.DiscordException) -> NoReturn: + """Handle error raised in the context of text commands.""" + self._handle_unexpected_error( + error=error, + author_id=context.author.id, + username=str(context.author), + command_name=context.command.qualified_name, + message_id=context.message.id, + channel_id=context.channel.id, + content=context.message.content, + guild=context.guild, + jump_url=context.message.jump_url + ) + + async def handle_app_command_error(self, interaction: Interaction, error: errors.DiscordException) -> NoReturn: + """Handle error raised in the context of app commands.""" + self._handle_unexpected_error( + error=error, + author_id=interaction.user.id, + username=str(interaction.user), + command_name=interaction.command.name, + message_id=interaction.message.id, + channel_id=interaction.channel_id, + content=interaction.message.content, + guild=interaction.guild, + jump_url=interaction.message.jump_url + ) + + def _handle_unexpected_error( + self, + error: errors.DiscordException, + author_id: int, + username: str, + command_name: str, + message_id: int, + channel_id: int, + content: str, + guild: Guild | None = None, + jump_url: str | None = None + ) -> None: + with push_scope() as scope: + scope.user = { + "id": author_id, + "username": username + } + + scope.set_tag("command", command_name) + scope.set_tag("message_id", message_id) + scope.set_tag("channel_id", channel_id) + + scope.set_extra("full_message", content) + + if guild is not None and jump_url is not None: + scope.set_extra("jump_to", jump_url) + + log.exception(f"Unhandled command error: {error!s}", exc_info=error) diff --git a/bot/command_error_handlers/disabled_command.py b/bot/command_error_handlers/disabled_command.py new file mode 100644 index 0000000000..8b277198c7 --- /dev/null +++ b/bot/command_error_handlers/disabled_command.py @@ -0,0 +1,27 @@ +from typing import NoReturn + +from discord import Interaction +from discord.ext.commands import Context, DisabledCommand +from pydis_core.utils.error_handling.commands import AbstractCommandErrorHandler + +from bot.constants import NEGATIVE_REPLIES + +from ._utils import create_error_embed + + +class DisabledCommandErrorHandler(AbstractCommandErrorHandler): + """An handler for the DisabledCommand error.""" + + async def should_handle_error(self, error: Exception) -> bool: + """A predicate that determines whether the error should be handled or not.""" + return isinstance(error, DisabledCommand) + + async def handle_text_command_error(self, context: Context, error: Exception) -> NoReturn: + """Handle error raised in the context of text commands.""" + await context.send(embed=create_error_embed("This command has been disabled.", NEGATIVE_REPLIES)) + + async def handle_app_command_error(self, interaction: Interaction, error: Exception) -> NoReturn: + """Handle error raised in the context of app commands.""" + await interaction.response.send_message( + embed=create_error_embed("This command has been disabled.", NEGATIVE_REPLIES) + ) diff --git a/bot/command_error_handlers/moved_command.py b/bot/command_error_handlers/moved_command.py new file mode 100644 index 0000000000..d01acae429 --- /dev/null +++ b/bot/command_error_handlers/moved_command.py @@ -0,0 +1,40 @@ +from typing import NoReturn + +from discord import Embed, Interaction +from discord.ext.commands import Context +from pydis_core.utils.error_handling.commands import AbstractCommandErrorHandler + +from bot.constants import NEGATIVE_REPLIES +from bot.utils.exceptions import MovedCommandError + +from ._utils import create_error_embed + + +class MovedCommandErrorHandler(AbstractCommandErrorHandler): + """An handler for the MovedCommand error.""" + + async def should_handle_error(self, error: Exception) -> bool: + """A predicate that determines whether the error should be handled or not.""" + return isinstance(error, MovedCommandError) + + async def handle_text_command_error(self, context: Context, error: Exception) -> NoReturn: + """Handle error raised in the context of text commands.""" + await context.send( + embed=self._get_error_embed(context.prefix, context.command.qualified_name, error.new_command_name) + ) + + async def handle_app_command_error(self, interaction: Interaction, error: Exception) -> NoReturn: + """Handle error raised in the context of app commands.""" + await interaction.response.send_message( + embed=self._get_error_embed("/", interaction.command.name, error.new_command_name) + ) + + @staticmethod + def _get_error_embed(prefix: str, command_name: str, new_command_name: str) -> Embed: + return create_error_embed( + message=( + f"This command, `{prefix}{command_name}` has moved to `{new_command_name}`.\n" + f"Please use `{new_command_name}` instead." + ), + title=NEGATIVE_REPLIES + ) diff --git a/bot/command_error_handlers/user_input_error.py b/bot/command_error_handlers/user_input_error.py new file mode 100644 index 0000000000..3f99da4959 --- /dev/null +++ b/bot/command_error_handlers/user_input_error.py @@ -0,0 +1,32 @@ +from typing import NoReturn + +from discord import Interaction +from discord.ext.commands import Context, UserInputError +from pydis_core.utils.error_handling.commands import AbstractCommandErrorHandler + +from ._utils import create_error_embed, get_parent_command_and_subcontext, revert_cooldown_counter + + +class UserInputErrorHandler(AbstractCommandErrorHandler): + """An handler for the UserInputError error.""" + + async def should_handle_error(self, error: Exception) -> bool: + """A predicate that determines whether the error should be handled or not.""" + return isinstance(error, UserInputError) + + async def handle_text_command_error(self, context: Context, error: Exception) -> NoReturn: + """Handle error raised in the context of text commands.""" + revert_cooldown_counter(context.command, context.message) + parent_command, ctx = get_parent_command_and_subcontext(context) + usage = f"```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" + embed = create_error_embed( + f"Your input was invalid: {error}\n\nUsage:{usage}" + ) + await context.send(embed=embed) + + async def handle_app_command_error(self, interaction: Interaction, error: Exception) -> NoReturn: + """Handle error raised in the context of app commands.""" + embed = create_error_embed( + f"Your input was invalid: {error}\n" + ) + await interaction.response.send_message(embed=embed) diff --git a/bot/command_error_handlers/user_not_playing.py b/bot/command_error_handlers/user_not_playing.py new file mode 100644 index 0000000000..87542478ed --- /dev/null +++ b/bot/command_error_handlers/user_not_playing.py @@ -0,0 +1,23 @@ +from typing import NoReturn + +from discord import Interaction +from discord.ext.commands import Context +from pydis_core.utils.error_handling.commands import AbstractCommandErrorHandler + +from bot.utils.exceptions import UserNotPlayingError + + +class UserNotPlayingErrorHandler(AbstractCommandErrorHandler): + """An handler for the UserNotPlayingError error.""" + + async def should_handle_error(self, error: Exception) -> bool: + """A predicate that determines whether the error should be handled or not.""" + return isinstance(error, UserNotPlayingError) + + async def handle_text_command_error(self, context: Context, error: Exception) -> NoReturn: + """Handle error raised in the context of text commands.""" + await context.send("Game not found.") + + async def handle_app_command_error(self, interaction: Interaction, error: Exception) -> NoReturn: + """Handle error raised in the context of app commands.""" + await interaction.response.send_message("Game not found.") diff --git a/bot/exts/core/error_handler.py b/bot/exts/core/error_handler.py index cf116ba029..cf7959e6d6 100644 --- a/bot/exts/core/error_handler.py +++ b/bot/exts/core/error_handler.py @@ -1,50 +1,17 @@ -import math -import random -from collections.abc import Iterable - -from discord import Embed, Message from discord.ext import commands from pydis_core.utils.logging import get_logger -from sentry_sdk import push_scope from bot.bot import Bot -from bot.constants import Channels, Colours, ERROR_REPLIES, NEGATIVE_REPLIES -from bot.utils.commands import get_command_suggestions -from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure -from bot.utils.exceptions import APIError, MovedCommandError, UserNotPlayingError log = get_logger(__name__) -DELETE_DELAY = 10 -QUESTION_MARK_ICON = "https://cdn.discordapp.com/emojis/512367613339369475.png" - - class CommandErrorHandler(commands.Cog): """A error handler for the PythonDiscord server.""" def __init__(self, bot: Bot): self.bot = bot - @staticmethod - def revert_cooldown_counter(command: commands.Command, message: Message) -> None: - """Undoes the last cooldown counter for user-error cases.""" - if command._buckets.valid: - bucket = command._buckets.get_bucket(message) - bucket._tokens = min(bucket.rate, bucket._tokens + 1) - log.debug("Cooldown counter reverted as the command was not used correctly.") - - @staticmethod - def error_embed(message: str, title: Iterable | str = ERROR_REPLIES) -> Embed: - """Build a basic embed with red colour and either a random error title or a title provided.""" - embed = Embed(colour=Colours.soft_red) - if isinstance(title, str): - embed.title = title - else: - embed.title = random.choice(title) - embed.description = message - return embed - @commands.Cog.listener() async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: """Activates when a command raises an error.""" @@ -52,11 +19,6 @@ async def on_command_error(self, ctx: commands.Context, error: commands.CommandE log.debug(f"Command {ctx.command} had its error already handled locally; ignoring.") return - parent_command = "" - if subctx := getattr(ctx, "subcontext", None): - parent_command = f"{ctx.command} " - ctx = subctx - error = getattr(error, "original", error) log.debug( f"Error Encountered: {type(error).__name__} - {error!s}, " @@ -65,127 +27,7 @@ async def on_command_error(self, ctx: commands.Context, error: commands.CommandE f"Channel: {ctx.channel}" ) - if isinstance(error, commands.CommandNotFound): - # Ignore messages that start with "..", as they were likely not meant to be commands - if not ctx.invoked_with.startswith("."): - await self.send_command_suggestion(ctx, ctx.invoked_with) - return - - if isinstance(error, InChannelCheckFailure | InMonthCheckFailure): - await ctx.send(embed=self.error_embed(str(error), NEGATIVE_REPLIES), delete_after=7.5) - return - - if isinstance(error, commands.UserInputError): - self.revert_cooldown_counter(ctx.command, ctx.message) - usage = f"```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" - embed = self.error_embed( - f"Your input was invalid: {error}\n\nUsage:{usage}" - ) - await ctx.send(embed=embed) - return - - if isinstance(error, commands.CommandOnCooldown): - mins, secs = divmod(math.ceil(error.retry_after), 60) - embed = self.error_embed( - f"This command is on cooldown:\nPlease retry in {mins} minutes {secs} seconds.", - NEGATIVE_REPLIES - ) - await ctx.send(embed=embed, delete_after=7.5) - return - - if isinstance(error, commands.DisabledCommand): - await ctx.send(embed=self.error_embed("This command has been disabled.", NEGATIVE_REPLIES)) - return - - if isinstance(error, commands.NoPrivateMessage): - await ctx.send( - embed=self.error_embed( - "This command can only be used in the server. " - f"Go to <#{Channels.sir_lancebot_playground}> instead!", - NEGATIVE_REPLIES - ) - ) - return - - if isinstance(error, commands.BadArgument): - self.revert_cooldown_counter(ctx.command, ctx.message) - embed = self.error_embed( - "The argument you provided was invalid: " - f"{error}\n\nUsage:\n```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" - ) - await ctx.send(embed=embed) - return - - if isinstance(error, commands.CheckFailure): - await ctx.send(embed=self.error_embed("You are not authorized to use this command.", NEGATIVE_REPLIES)) - return - - if isinstance(error, UserNotPlayingError): - await ctx.send("Game not found.") - return - - if isinstance(error, APIError): - await ctx.send( - embed=self.error_embed( - f"There was an error when communicating with the {error.api}", - NEGATIVE_REPLIES - ) - ) - return - - if isinstance(error, MovedCommandError): - description = ( - f"This command, `{ctx.prefix}{ctx.command.qualified_name}` has moved to `{error.new_command_name}`.\n" - f"Please use `{error.new_command_name}` instead." - ) - await ctx.send(embed=self.error_embed(description, NEGATIVE_REPLIES)) - return - - with push_scope() as scope: - scope.user = { - "id": ctx.author.id, - "username": str(ctx.author) - } - - scope.set_tag("command", ctx.command.qualified_name) - scope.set_tag("message_id", ctx.message.id) - scope.set_tag("channel_id", ctx.channel.id) - - scope.set_extra("full_message", ctx.message.content) - - if ctx.guild is not None: - scope.set_extra("jump_to", ctx.message.jump_url) - - log.exception(f"Unhandled command error: {error!s}", exc_info=error) - - async def send_command_suggestion(self, ctx: commands.Context, command_name: str) -> None: - """Sends user similar commands if any can be found.""" - command_suggestions = [] - if similar_command_names := get_command_suggestions(list(self.bot.all_commands.keys()), command_name): - for similar_command_name in similar_command_names: - similar_command = self.bot.get_command(similar_command_name) - - if not similar_command: - continue - - log_msg = "Cancelling attempt to suggest a command due to failed checks." - try: - if not await similar_command.can_run(ctx): - log.debug(log_msg) - continue - except commands.errors.CommandError: - log.debug(log_msg) - continue - - command_suggestions.append(similar_command_name) - - misspelled_content = ctx.message.content - e = Embed() - e.set_author(name="Did you mean:", icon_url=QUESTION_MARK_ICON) - e.description = "\n".join( - misspelled_content.replace(command_name, cmd, 1) for cmd in command_suggestions - ) - await ctx.send(embed=e, delete_after=DELETE_DELAY) + await self.bot.command_error_manager.handle_error(error, ctx) async def setup(bot: Bot) -> None: