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

Add comprehensive error handling #8

Merged
merged 1 commit into from
Dec 3, 2023
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
37 changes: 34 additions & 3 deletions src/bot.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import asyncio
import logging
import logging.handlers
Expand All @@ -7,6 +9,7 @@
import aiohttp
import discord
import gspread_asyncio
from discord import app_commands
from discord.ext import commands
from discord.ext import tasks as ext_tasks
from google.auth import crypt
Expand All @@ -21,6 +24,7 @@
GSPREAD_TOKEN_URI,
GUILD_ID,
)
from .exceptions import MILBotErrorHandler
from .reports import ReportsView
from .roles import MechanicalRolesView, TeamRolesView
from .tasks import TaskManager
Expand Down Expand Up @@ -53,6 +57,19 @@ def get_creds():
intents = discord.Intents.all()


class MILBotCommandTree(app_commands.CommandTree):
def __init__(self, client: MILBot):
super().__init__(client)
self.handler = MILBotErrorHandler()

async def on_error(
self,
interaction: discord.Interaction[MILBot],
error: app_commands.AppCommandError,
) -> None:
await self.handler.handle_interaction_exception(interaction, error)


class MILBot(commands.Bot):

# MIL server ref
Expand All @@ -62,6 +79,7 @@ class MILBot(commands.Bot):
leave_channel: discord.TextChannel
general_channel: discord.TextChannel
reports_channel: discord.TextChannel
errors_channel: discord.TextChannel
# Emojis
loading_emoji: str
# Roles
Expand All @@ -78,6 +96,7 @@ def __init__(self):
command_prefix="!",
case_insensitive=True,
intents=intents,
tree_cls=MILBotCommandTree,
)
self.tasks = TaskManager()

Expand Down Expand Up @@ -210,6 +229,13 @@ async def fetch_vars(self) -> None:
assert isinstance(leaders_channel, discord.TextChannel)
self.leaders_channel = leaders_channel

errors_channel = discord.utils.get(
self.active_guild.text_channels,
name="bot-errors",
)
assert isinstance(errors_channel, discord.TextChannel)
self.errors_channel = errors_channel

# Roles
egn4912_role = discord.utils.get(
self.active_guild.roles,
Expand Down Expand Up @@ -237,16 +263,21 @@ async def on_message(self, message):
if message.author == self.user:
return

if message.content == "ping":
await message.channel.send("pong")

await self.process_commands(message)

async def on_member_join(self, member: discord.Member):
role = discord.utils.get(member.guild.roles, name="New Member")
assert isinstance(role, discord.Role)
await member.add_roles(role)

async def on_error(self, event, *args, **kwargs):
self.handler = MILBotErrorHandler()
await self.handler.handle_event_exception(event, self)

async def on_command_error(self, ctx, error):
self.handler = MILBotErrorHandler()
await self.handler.handle_command_exception(ctx, error)


bot = MILBot()

Expand Down
159 changes: 159 additions & 0 deletions src/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from __future__ import annotations

import contextlib
import datetime
import logging
import sys
import traceback
from typing import TYPE_CHECKING

import discord
from discord import app_commands
from discord.ext import commands

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from .bot import MILBot


class MILException(Exception):
"""
Base class for all exceptions handled by the bot.
"""


class MILBotErrorHandler:
"""
General error handler for the bot. Handles command errors, interaction errors,
and errors with events. Can be instantitated infinite times, although using
MILBotView and MILBotModal will take care of the error handling
for most interactions.
"""

def error_message(self, error: BaseException) -> tuple[str, float | None]:
"""
Returns the error message and the delay, if any.
"""
delay = None

# Handle our failures first
if isinstance(error, app_commands.CommandInvokeError):
return (
f"This command experienced a general error of type `{error.original.__class__}`.",
delay,
)
elif isinstance(error, app_commands.CommandOnCooldown):
next_time = discord.utils.utcnow() + datetime.timedelta(
seconds=error.retry_after,
)
message = (
"Time to _chill out_ - this command is on cooldown! "
f"Please try again **{discord.utils.format_dt(next_time, 'R')}.**"
"\n\n"
"For future reference, this command is currently limited to "
f"being executed **{error.cooldown.rate} times every {error.cooldown.per} seconds**."
)
delay = error.retry_after
return message, delay
elif isinstance(error, app_commands.MissingRole | app_commands.MissingAnyRole):
return str(error), delay

error_messages: dict[type[BaseException], str] = {
# Custom messages
# Application commands or Interactions
app_commands.NoPrivateMessage: "Sorry, but this command does not work in private message. Please hop on over to the server to use the command!",
app_commands.MissingPermissions: "Hey pal, you don't have the necessary permissions to run this command.",
app_commands.BotMissingPermissions: "Hmm, looks like I don't have the permissions to do that. Something went wrong. You should definitely let someone know about this.",
app_commands.CommandLimitReached: "Oh no! I've reached my max command limit. Please contact a developer.",
app_commands.TransformerError: "This command experienced a transformer error.",
app_commands.CommandAlreadyRegistered: "This command was already registered.",
app_commands.CommandSignatureMismatch: "This command is currently out of sync.",
app_commands.CheckFailure: "A check failed indicating you are not allowed to perform this action at this time.",
app_commands.CommandNotFound: "This command could not be found.",
app_commands.MissingApplicationID: "This application needs an application ID.",
discord.InteractionResponded: "An exception occurred because I tried responding to an already-completed user interaction.",
# General
discord.LoginFailure: "Failed to log in.",
discord.Forbidden: "An exception occurred because I tried completing an operation that I don't have permission to do.",
discord.NotFound: "An exception occurred because I tried completing an operation that doesn't exist.",
discord.DiscordServerError: "An exception occurred because of faulty communication with the Discord API server.",
}
return (
error_messages.get(
error.__class__,
f"Oops, an unhandled error occurred: `{error.__class__}`.",
),
delay,
)

async def handle_event_exception(
self,
event: str,
client: MILBot,
):
e_type, error, tb = sys.exc_info()
if error:
logger.exception(f"{e_type}: {error} occurred in `{event}` event.")
exc_format = "".join(traceback.format_exception(e_type, error, tb, None))
await client.errors_channel.send(
f"**{error.__class__.__name__}** occurred in a `{event}` event:\n"
f"```py\n{exc_format}\n```",
)

async def handle_command_exception(
self,
ctx: commands.Context[MILBot],
error: Exception,
):
message, _ = self.error_message(error)
logger.exception(f"{error.__class__.__name__}: {error} occurred.")
if isinstance(error, commands.CommandInvokeError):
error = error.original
try:
raise error
except Exception:
await ctx.bot.errors_channel.send(
f"**{error.__class__.__name__}** occurred in a command:\n"
f"```py\n{traceback.format_exc()}\n```",
)
await ctx.reply(message)

async def handle_interaction_exception(
self,
interaction: discord.Interaction[MILBot],
error: app_commands.AppCommandError,
) -> None:
# For commands on cooldown, delete message after delay
message, delay = self.error_message(error)

if interaction.response.is_done() and interaction.response.type not in (
discord.InteractionResponseType.deferred_message_update,
discord.InteractionResponseType.deferred_channel_message,
):
msg = await interaction.edit_original_response(content=message)
else:
await interaction.response.defer(ephemeral=True)
msg = await interaction.followup.send(message, ephemeral=True, wait=True)

if delay is not None:
await msg.delete(delay=delay)

logger.exception(f"{error.__class__.__name__}: {error} occurred.")

channel_name = None
if interaction.channel:
if isinstance(interaction.channel, discord.DMChannel):
channel_name = f"DM with {interaction.channel.recipient}"
elif isinstance(interaction.channel, discord.GroupChannel):
channel_name = f"DM with {interaction.channel.recipients}"
else:
channel_name = interaction.channel.mention

# Attempt to log to channel, but only log errors not from our code
if error.__class__.__module__ != __name__:
with contextlib.suppress():
await interaction.client.errors_channel.send(
f"**{error.__class__.__name__}** occurred in {channel_name} interaction by {interaction.user.mention}:\n"
f"```py\n{traceback.format_exc()}```",
)
7 changes: 4 additions & 3 deletions src/leaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from .env import LEADERS_MEETING_NOTES_URL, LEADERS_MEETING_URL
from .helper import run_on_weekday
from .views import MILBotView

if TYPE_CHECKING:
from .bot import MILBot
Expand Down Expand Up @@ -41,7 +42,7 @@ async def notes_reminder(self):
description=f"Don't forget to attend the leaders meeting tonight at {discord.utils.format_dt(meeting_time, 't')} today! To help the meeting proceed efficiently, **all leaders** from **each team** should fill out the meeting notes for tonight's meeting **ahead of the meeting time**. Please include:\n* What has been completed over the past week\n* Plans for this upcoming week\n* Challenges your team faces\n\nThank you! If you have any questions, please ping {self.bot.sys_leads_role.mention}.",
color=discord.Color.teal(),
)
view = discord.ui.View()
view = MILBotView()
view.add_item(
discord.ui.Button(label="Meeting Notes", url=LEADERS_MEETING_NOTES_URL),
)
Expand All @@ -63,7 +64,7 @@ async def pre_reminder(self):
description="Who's excited to meet?? 🙋 Please arrive on time so we can promptly begin the meeting. If you have not already filled out the meeting notes for your team, please do so **now**! Thank you so much!",
color=discord.Color.brand_green(),
)
view = discord.ui.View()
view = MILBotView()
view.add_item(
discord.ui.Button(label="Meeting Notes", url=LEADERS_MEETING_NOTES_URL),
)
Expand All @@ -86,7 +87,7 @@ async def at_reminder(self):
description="It's time! The leaders meeting is starting now! Please join on time so we can begin the meeting promptly.",
color=discord.Color.brand_red(),
)
view = discord.ui.View()
view = MILBotView()
view.add_item(
discord.ui.Button(label="Meeting Notes", url=LEADERS_MEETING_NOTES_URL),
)
Expand Down
3 changes: 2 additions & 1 deletion src/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from discord.ext import commands

from .helper import run_on_weekday
from .views import MILBotView

if TYPE_CHECKING:
from .bot import MILBot
Expand Down Expand Up @@ -133,7 +134,7 @@ async def on_submit(self, interaction: discord.Interaction):
)


class ReportsView(discord.ui.View):
class ReportsView(MILBotView):
def __init__(self, bot: MILBot):
self.bot = bot
super().__init__(timeout=None)
Expand Down
30 changes: 4 additions & 26 deletions src/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import discord
from discord.ext import commands

from .views import MILBotView

if TYPE_CHECKING:
from .bot import MILBot

Expand Down Expand Up @@ -62,45 +64,21 @@ async def callback(self, interaction: discord.Interaction):
)


class TeamRolesView(discord.ui.View):
class TeamRolesView(MILBotView):
def __init__(self, bot: MILBot):
super().__init__(timeout=None)
self.add_item(GroupButton(label="Mechanical", bot=bot, emoji="🔧"))
self.add_item(GroupButton(label="Electrical", bot=bot, emoji="🔋"))
self.add_item(GroupButton(label="Software", bot=bot, emoji="💻"))

async def on_error(
self,
interaction: discord.Interaction,
exception: Exception,
item: discord.ui.Item,
):
logger.exception(f"Error with role selection: {exception}")
await interaction.response.send_message(
f"Sorry! There was an error with your role selection: `{exception}`",
ephemeral=True,
)


class MechanicalRolesView(discord.ui.View):
class MechanicalRolesView(MILBotView):
def __init__(self, bot: MILBot):
super().__init__(timeout=None)
self.add_item(GroupButton(label="Structures and Manufacturing", bot=bot))
self.add_item(GroupButton(label="Mechanisms and Testing", bot=bot))
self.add_item(GroupButton(label="Dynamics and Controls", bot=bot))

async def on_error(
self,
interaction: discord.Interaction,
exception: Exception,
item: discord.ui.Item,
):
logger.exception(f"Error with role selection: {exception}")
await interaction.response.send_message(
f"Sorry! There was an error with your role selection: `{exception}`",
ephemeral=True,
)


class GroupCog(commands.Cog):
def __init__(self, bot: MILBot):
Expand Down
25 changes: 25 additions & 0 deletions src/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import discord
from discord import app_commands

from .exceptions import MILBotErrorHandler

if TYPE_CHECKING:
from .bot import MILBot


class MILBotView(discord.ui.View):
def __init__(self, *, timeout: float | None = None):
super().__init__(timeout=timeout)
self.handler = MILBotErrorHandler()

async def on_error(
self,
interaction: discord.Interaction[MILBot],
error: app_commands.AppCommandError,
item: discord.ui.Item,
) -> None:
await self.handler.handle_interaction_exception(interaction, error)
Loading
Loading