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

feat/error handler #77

Merged
merged 14 commits into from
Sep 29, 2021
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
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Running the bot after configuring the env vars is now as simple as `docker-compose up`
- Automatic docker image creation: `ghcr.io/discord-modmail/modmail` (#19)
- Dockerfile support for all supported hosting providers. (#58)
- Errors no longer happen silently and notify the user when they make a mistake. (#77)

### Changed

Expand Down
223 changes: 223 additions & 0 deletions modmail/extensions/utils/error_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import logging
import re
import typing

import discord
import discord.errors
from discord.ext import commands

from modmail.bot import ModmailBot
from modmail.log import ModmailLogger
from modmail.utils.cogs import BotModes, ExtMetadata, ModmailCog
from modmail.utils.extensions import BOT_MODE


logger: ModmailLogger = logging.getLogger(__name__)

EXT_METADATA = ExtMetadata()

ERROR_COLOUR = discord.Colour.red()
onerandomusername marked this conversation as resolved.
Show resolved Hide resolved

ERROR_TITLE_REGEX = re.compile(r"(?<=[a-zA-Z])([A-Z])(?=[a-z])")

ANY_DEV_MODE = BOT_MODE & (BotModes.DEVELOP.value + BotModes.PLUGIN_DEV.value)
bast0006 marked this conversation as resolved.
Show resolved Hide resolved


class ErrorHandler(ModmailCog, name="Error Handler"):
"""Handles all errors across the bot."""

def __init__(self, bot: ModmailBot):
self.bot = bot

@staticmethod
def error_embed(title: str, message: str) -> discord.Embed:
"""Create an error embed with an error colour and reason and return it."""
return discord.Embed(title=title, description=message, colour=ERROR_COLOUR)

@staticmethod
def get_title_from_name(error: typing.Union[Exception, str]) -> str:
"""
Return a message dervived from the exception class name.

Eg NSFWChannelRequired returns NSFW Channel Required
"""
if not isinstance(error, str):
error = error.__class__.__name__
return re.sub(ERROR_TITLE_REGEX, r" \1", error)

@staticmethod
def _reset_command_cooldown(ctx: commands.Context) -> bool:
if return_value := ctx.command.is_on_cooldown(ctx):
ctx.command.reset_cooldown(ctx)
return return_value

async def handle_user_input_error(
self,
ctx: commands.Context,
error: commands.UserInputError,
reset_cooldown: bool = True,
) -> discord.Embed:
"""Handling deferred from main error handler to handle UserInputErrors."""
if reset_cooldown:
self._reset_command_cooldown(ctx)
msg = None
if isinstance(error, commands.BadUnionArgument):
msg = self.get_title_from_name(str(error))
title = self.get_title_from_name(error)
return self.error_embed(title, msg or str(error))

async def handle_bot_missing_perms(
self, ctx: commands.Context, error: commands.BotMissingPermissions
) -> bool:
"""Handles bot missing permissing by dming the user if they have a permission which may be able to fix this.""" # noqa: E501
embed = self.error_embed("Permissions Failure", str(error))
bot_perms = ctx.channel.permissions_for(ctx.me)
not_responded = True
if bot_perms >= discord.Permissions(send_messages=True, embed_links=True):
await ctx.send(embeds=[embed])
not_responded = False
elif bot_perms >= discord.Permissions(send_messages=True):
# make a message as similar to the embed, using as few permissions as possible
# this is the only place we send a standard message instead of an embed
# so no helper methods are necessary
await ctx.send(
"**Permissions Failure**\n\n"
"I am missing the permissions required to properly execute your command."
)
# intentionally not setting responded to True, since we want to attempt to dm the user
logger.warning(
f"Missing partial required permissions for {ctx.channel}. "
"I am able to send messages, but not embeds."
)
else:
logger.error(f"Unable to send an error message to channel {ctx.channel}")

if not_responded and ANY_DEV_MODE:
# non-general permissions
perms = discord.Permissions(
administrator=True,
manage_threads=True,
manage_roles=True,
manage_channels=True,
)
if perms.value & ctx.channel.permissions_for(ctx.author).value:
logger.info(
f"Attempting to dm {ctx.author} since they have a permission which may be able "
"to give the bot send message permissions."
)
try:
await ctx.author.send(embeds=[embed])
except discord.Forbidden:
logger.notice("Also encountered an error when trying to reply in dms.")
return False
return True

async def handle_check_failure(
self, ctx: commands.Context, error: commands.CheckFailure
) -> typing.Optional[discord.Embed]:
"""Handle CheckFailures seperately given that there are many of them."""
title = "Check Failure"
if isinstance(error, commands.CheckAnyFailure):
title = self.get_title_from_name(error.checks[-1])
elif isinstance(error, commands.PrivateMessageOnly):
title = "DMs Only"
elif isinstance(error, commands.NoPrivateMessage):
title = "Server Only"
elif isinstance(error, commands.BotMissingPermissions):
# defer handling BotMissingPermissions to a method
# the error could be that the bot is unable to send messages, which would cause
# the error handling to fail
await self.handle_bot_missing_perms(ctx, error)
return None
else:
title = self.get_title_from_name(error)
embed = self.error_embed(title, str(error))
return embed

@ModmailCog.listener()
async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None:
"""Activates when a command raises an error."""
if getattr(error, "handled", False):
logging.debug(f"Command {ctx.command} had its error already handled locally, ignoring.")
return

if isinstance(error, commands.CommandNotFound):
# ignore every time the user inputs a message that starts with our prefix but isn't a command
# this will be modified in the future to support prefilled commands
if ANY_DEV_MODE:
logger.trace(error)
return

logger.trace(error)

embed: typing.Optional[discord.Embed] = None
should_respond = True

if isinstance(error, commands.UserInputError):
embed = await self.handle_user_input_error(ctx, error)
elif isinstance(error, commands.CheckFailure):
embed = await self.handle_check_failure(ctx, error)
# handle_check_failure may send its own error if its a BotMissingPermissions error.
if embed is None:
should_respond = False
elif isinstance(error, commands.ConversionError):
pass
elif isinstance(error, commands.DisabledCommand):
logger.debug("")
if ctx.command.hidden:
should_respond = False
else:
msg = f"Command `{ctx.invoked_with}` is disabled."
if reason := ctx.command.extras.get("disabled_reason", None):
msg += f"\nReason: {reason}"
embed = self.error_embed("Command Disabled", msg)

elif isinstance(error, commands.CommandInvokeError):
if isinstance(error.original, discord.Forbidden):
logger.warn(f"Permissions error occurred in {ctx.command}.")
await self.handle_bot_missing_perms(ctx, error.original)
should_respond = False
else:
# todo: this should properly handle plugin errors and note that they are not bot bugs
# todo: this should log somewhere else since this is a bot bug.
# generic error
logger.error(f'Error occurred in command "{ctx.command}".', exc_info=error.original)
if ctx.command.cog.__module__.startswith("modmail.plugins"):
# plugin msg
title = "Plugin Internal Error Occurred"
msg = (
"Something went wrong internally in the plugin contributed command you were trying "
"to execute. Please report this error and what you were trying to do to the "
"respective plugin developers.\n\n**PLEASE NOTE**: Modmail developers will not help "
"you with this issue and will refer you to the plugin developers."
)
else:
# built in command msg
title = "Internal Error"
msg = (
"Something went wrong internally in the command you were trying to execute. "
"Please report this error and what you were trying to do to the bot developers."
)
logger.debug(ctx.command.callback.__module__)
embed = self.error_embed(title, msg)

# TODO: this has a fundamental problem with any BotMissingPermissions error
# if the issue is the bot does not have permissions to send embeds or send messages...
# yeah, problematic.

if not should_respond:
logger.debug(
"Not responding to error since should_respond is falsey because either "
"the embed has already been sent or belongs to a hidden command and thus should be hidden."
)
return

if embed is None:
embed = self.error_embed(self.get_title_from_name(error), str(error))

await ctx.send(embeds=[embed])


def setup(bot: ModmailBot) -> None:
"""Add the error handler to the bot."""
bot.add_cog(ErrorHandler(bot))