Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use botcore command error manager #1458

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions bot/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions bot/command_error_handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions bot/command_error_handlers/_utils.py
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions bot/command_error_handlers/api_error.py
Original file line number Diff line number Diff line change
@@ -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
)
)
29 changes: 29 additions & 0 deletions bot/command_error_handlers/bad_argument.py
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions bot/command_error_handlers/check_failure.py
Original file line number Diff line number Diff line change
@@ -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:
Copy link
Member

Choose a reason for hiding this comment

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

The default value of a function is None. If a function can return, but has no explicit return value, then this annotation should be None.

NoReturn means it does not return i.e. control flow of the program is interrupted before it returns. I've only seen this used for functions that always throw an exception, like an abstract method that raises NotImplemented. Also, this was superceded (?) in 3.11 by Never.

"""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)
64 changes: 64 additions & 0 deletions bot/command_error_handlers/command_not_found.py
Original file line number Diff line number Diff line change
@@ -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:
Copy link
Member

Choose a reason for hiding this comment

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

Thoughts about using @typing.override for these? It makes it more explicit that this is an override, and it helps type checkers. But if we don't use a type checker then it could be argued that this would just clutter the code.

"""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)
31 changes: 31 additions & 0 deletions bot/command_error_handlers/command_on_cooldown.py
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions bot/command_error_handlers/default.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading