Skip to content

Commit

Permalink
Merge pull request #77 from discord-modmail/feat/error-handler
Browse files Browse the repository at this point in the history
feat/error handler
  • Loading branch information
bast0006 committed Sep 29, 2021
2 parents b5ab21f + 144b008 commit ebc2f94
Show file tree
Hide file tree
Showing 2 changed files with 224 additions and 0 deletions.
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()

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)


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))

0 comments on commit ebc2f94

Please sign in to comment.