From e379afe108c8b3b86a1f8bb996781dd3331ddf22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Tue, 14 Jan 2025 00:22:52 +0100 Subject: [PATCH] Add better error handling --- discord_reminder_bot/interaction_responses.py | 68 +++++++++++ discord_reminder_bot/main.py | 114 ++++++++++-------- discord_reminder_bot/misc.py | 30 ++++- discord_reminder_bot/ui.py | 33 +++-- 4 files changed, 189 insertions(+), 56 deletions(-) create mode 100644 discord_reminder_bot/interaction_responses.py diff --git a/discord_reminder_bot/interaction_responses.py b/discord_reminder_bot/interaction_responses.py new file mode 100644 index 0000000..e1ffb48 --- /dev/null +++ b/discord_reminder_bot/interaction_responses.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging + +import discord + +logger: logging.Logger = logging.getLogger(__name__) + + +async def send_error_response(interaction: discord.Interaction, msg: str, *, ephemeral: bool = False) -> None: + """Handle the error. + + Args: + interaction (discord.Interaction): The interaction object for the command. + msg (str): The message to send. + ephemeral (bool, optional): Whether the message should be ephemeral. Defaults to False. + """ + logger.exception(msg) + await interaction.response.send_message(msg, ephemeral=ephemeral) + + +async def followup_msg( + interaction: discord.Interaction, + *, # So that the following arguments are keyword-only + msg: str | None = None, + ephemeral: bool = False, + embed: discord.Embed | None = None, + view: discord.ui.View | None = None, +) -> None: + """Send a followup message to the interaction. + + Handles the exceptions that may occur when sending a followup message. + + Args: + interaction (discord.Interaction): The interaction object for the command. + msg (str): The message to send. + ephemeral (bool, optional): Whether the message should be ephemeral. Defaults to False. + embed (discord.Embed | None, optional): The embed to send. Defaults to no embed. + view (discord.ui.View | None, optional): The view to send. Defaults to no view. + """ + if not msg: + msg = "No message was provided." + try: + if embed and view: + log_msg: str = f"Sending followup message with embed and view to the interaction.\n{msg=}.\n{ephemeral=}\n{embed.to_dict()=}" # noqa: E501 + for view_item in view.children: + log_msg += f"\n{view_item=}" + + logger.info(log_msg) + await interaction.followup.send(embed=embed, ephemeral=ephemeral, view=view) + else: + logger.info("Sending followup message to the interaction.\nMessage: %s.\nEphemeral: %s", msg, ephemeral) + await interaction.followup.send(content=msg, ephemeral=ephemeral) + + except (discord.NotFound, discord.Forbidden, discord.HTTPException, TypeError, ValueError) as e: + error_messages: dict[type[discord.HTTPException | TypeError | ValueError], str] = { + discord.NotFound: "The original message was not found.", + discord.Forbidden: "The authorization token for the webhook is incorrect.", + discord.HTTPException: "Failed to send followup message.", + TypeError: "We specified both embed and embeds or file and files, or thread and threads.", + ValueError: ( + "The length of embeds was invalid, there was no token associated with this webhook or " + "ephemeral was passed with the improper webhook type or there was no state attached with " + "this webhook when giving it a view." + ), + } + assert_msg: str = error_messages[type(e)] + await send_error_response(interaction=interaction, msg=assert_msg, ephemeral=ephemeral) diff --git a/discord_reminder_bot/main.py b/discord_reminder_bot/main.py index 723297a..ac19cef 100644 --- a/discord_reminder_bot/main.py +++ b/discord_reminder_bot/main.py @@ -10,6 +10,7 @@ from discord.abc import PrivateChannel from discord_webhook import DiscordWebhook from discord_reminder_bot import settings +from discord_reminder_bot.interaction_responses import followup_msg from discord_reminder_bot.misc import calculate from discord_reminder_bot.parser import parse_time from discord_reminder_bot.ui import JobManagementView, create_job_embed @@ -106,7 +107,7 @@ class RemindGroup(discord.app_commands.Group): run_date: str = parsed_time.strftime("%Y-%m-%d %H:%M:%S") guild: discord.Guild | None = interaction.guild or None if not guild: - await interaction.followup.send("Failed to get guild.") + await followup_msg(interaction=interaction, msg="Failed to get guild.") return dm_message: str = "" @@ -142,8 +143,9 @@ class RemindGroup(discord.app_commands.Group): where_and_when = ( f"I will notify you in <#{channel_id}> {dm_message}at:\n**{run_date}** {calculate(reminder)}\n" ) + final_message: str = f"Hello {interaction.user.display_name}, {where_and_when}With the message:\n**{message}**." - await interaction.followup.send(final_message) + await followup_msg(interaction=interaction, msg=final_message) # /remind list @discord.app_commands.command(name="list", description="List, pause, unpause, and remove reminders.") @@ -157,21 +159,17 @@ class RemindGroup(discord.app_commands.Group): jobs: list[Job] = settings.scheduler.get_jobs() if not jobs: - await interaction.followup.send(content="No jobs available.") + await followup_msg(interaction=interaction, msg="No scheduled jobs found in the database.") return - first_job: Job | None = jobs[0] if jobs else None - if not first_job: - await interaction.followup.send(content="No jobs available.") - return + embed: discord.Embed = create_job_embed(job=jobs[0]) + view = JobManagementView(job=jobs[0], scheduler=settings.scheduler) - embed: discord.Embed = create_job_embed(job=first_job) - view = JobManagementView(job=first_job, scheduler=settings.scheduler) - await interaction.followup.send(embed=embed, view=view) + await followup_msg(interaction=interaction, embed=embed, view=view) # /remind cron @discord.app_commands.command(name="cron", description="Create new cron job. Works like UNIX cron.") - async def cron( # noqa: PLR0913, PLR0917 + async def cron( # noqa: C901, PLR0911, PLR0913, PLR0917 self, interaction: discord.Interaction, message: str, @@ -214,9 +212,14 @@ class RemindGroup(discord.app_commands.Group): user (discord.User, optional): Send reminder as a DM to this user. Defaults to None. dm_and_current_channel (bool, optional): If user is provided, send reminder as a DM to the user and in this channel. Defaults to only the user. """ # noqa: E501 - # TODO(TheLovinator): Add try/except for all of these await calls # noqa: TD003 - # TODO(TheLovinator): Add a warning if the interval is too short # noqa: TD003 - # TODO(TheLovinator): Check if we have access to the channel and user# noqa: TD003 + try: + await interaction.response.defer() + except discord.HTTPException as e: + logger.exception("Failed to defer interaction: text=%s, status=%s, code=%s", e.text, e.status, e.code) + return + except discord.InteractionResponded as e: + logger.exception("Interaction already responded to - interaction: %s", e.interaction) + return # Log kwargs logger.info("New cron job from %s (%s) in %s", interaction.user, interaction.user.id, interaction.channel) @@ -225,39 +228,43 @@ class RemindGroup(discord.app_commands.Group): # Get the channel ID channel_id: int | None = self.get_channel_id(interaction=interaction, channel=channel) if not channel_id: - await interaction.followup.send(content="Failed to get channel.") + await followup_msg(interaction, msg="Failed to get channel.") return # Ensure the guild is valid guild: discord.Guild | None = interaction.guild or None if not guild: - await interaction.followup.send(content="Failed to get guild.") + await followup_msg(interaction, msg="Failed to get guild.") return # Helper to add a job - def add_job(func: Callable, job_kwargs: dict[str, int | str]) -> Job: - return settings.scheduler.add_job( - func=func, - trigger="cron", - year=year, - month=month, - day=day, - week=week, - day_of_week=day_of_week, - hour=hour, - minute=minute, - second=second, - start_date=start_date, - end_date=end_date, - timezone=timezone, - jitter=jitter, - kwargs=job_kwargs, - ) + def add_job(func: Callable, job_kwargs: dict[str, int | str]) -> Job | None: + try: + return settings.scheduler.add_job( + func=func, + trigger="cron", + year=year, + month=month, + day=day, + week=week, + day_of_week=day_of_week, + hour=hour, + minute=minute, + second=second, + start_date=start_date, + end_date=end_date, + timezone=timezone, + jitter=jitter, + kwargs=job_kwargs, + ) + except Exception: + logger.exception("Failed to add job: %s") + return None # Create user DM reminder job if user is specified dm_message: str = "" if user: - dm_job: Job = add_job( + dm_job: Job | None = add_job( func=send_to_user, job_kwargs={ "user_id": user.id, @@ -265,17 +272,22 @@ class RemindGroup(discord.app_commands.Group): "message": message, }, ) + if not dm_job: + await followup_msg(interaction=interaction, msg="Failed to create DM reminder job.") + return + dm_message = f" and a DM to {user.display_name}" if not dm_and_current_channel: - # If only DM is required, notify about the DM job and exit - await interaction.followup.send( - content=f"Hello {interaction.user.display_name},\n" + await followup_msg( + interaction=interaction, + msg=f"Hello {interaction.user.display_name},\n" f"I will send a DM to {user.display_name} at:\n" f"First run in {calculate(dm_job)} with the message:\n**{message}**.", ) + return # Create channel reminder job - channel_job: Job = add_job( + channel_job: Job | None = add_job( func=send_to_discord, job_kwargs={ "channel_id": channel_id, @@ -283,10 +295,14 @@ class RemindGroup(discord.app_commands.Group): "author_id": interaction.user.id, }, ) + if not channel_job: + await followup_msg(interaction, msg="Failed to create channel reminder job.") + return # Compose the final message - await interaction.followup.send( - content=f"Hello {interaction.user.display_name},\n" + await followup_msg( + interaction=interaction, + msg=f"Hello {interaction.user.display_name},\n" f"I will notify you in <#{channel_id}>{dm_message}.\n" f"First run in {calculate(channel_job)} with the message:\n**{message}**.", ) @@ -341,13 +357,13 @@ class RemindGroup(discord.app_commands.Group): # Get the channel ID channel_id: int | None = self.get_channel_id(interaction=interaction, channel=channel) if not channel_id: - await interaction.followup.send(content="Failed to get channel.") + await followup_msg(interaction, msg="Failed to get channel.") return # Ensure the guild is valid guild: discord.Guild | None = interaction.guild or None if not guild: - await interaction.followup.send(content="Failed to get guild.") + await followup_msg(interaction, msg="Failed to get guild.") return # Helper to add a job @@ -381,8 +397,9 @@ class RemindGroup(discord.app_commands.Group): dm_message = f" and a DM to {user.display_name} " if not dm_and_current_channel: # If only DM is required, notify about the DM job and exit - await interaction.followup.send( - content=f"Hello {interaction.user.display_name},\n" + await followup_msg( + interaction=interaction, + msg=f"Hello {interaction.user.display_name},\n" f"I will send a DM to {user.display_name} at:\n" f"First run in {calculate(dm_job)} with the message:\n**{message}**.", ) @@ -398,8 +415,9 @@ class RemindGroup(discord.app_commands.Group): ) # Compose the final message - await interaction.followup.send( - content=f"Hello {interaction.user.display_name},\n" + await followup_msg( + interaction=interaction, + msg=f"Hello {interaction.user.display_name},\n" f"I will notify you in <#{channel_id}>{dm_message}.\n" f"First run in {calculate(channel_job)} with the message:\n**{message}**.", ) @@ -444,7 +462,7 @@ class RemindGroup(discord.app_commands.Group): logger.exception("Error parsing time '%s'", time) error_during_parsing = e if not parsed: - await interaction.followup.send(f"Failed to parse time. Error: {error_during_parsing}") + await followup_msg(interaction=interaction, msg=f"Failed to parse time. Error: {error_during_parsing}") return None return parsed diff --git a/discord_reminder_bot/misc.py b/discord_reminder_bot/misc.py index 09a3e7d..b515834 100644 --- a/discord_reminder_bot/misc.py +++ b/discord_reminder_bot/misc.py @@ -25,14 +25,42 @@ def calculate(job: Job) -> str: trigger_time: datetime.datetime | None = ( job.trigger.run_date if isinstance(job.trigger, DateTrigger) else job.next_run_time ) + + # Check if the job is paused if trigger_time is None: logger.error("Couldn't calculate time for job: %s: %s", job.id, job.name) logger.error("State: %s", job.__getstate__() if hasattr(job, "__getstate__") else "No state") - return "Couldn't calculate time" + return "Paused" return f"" +def get_human_time(time: datetime.timedelta) -> str: + """Convert timedelta to human-readable format. + + Args: + time: The timedelta to convert. + + Returns: + str: The human-readable time. + """ + days, seconds = divmod(time.total_seconds(), 86400) + hours, seconds = divmod(seconds, 3600) + minutes, seconds = divmod(seconds, 60) + + time_str: str = "" + if days: + time_str += f"{int(days)}d" + if hours: + time_str += f"{int(hours)}h" + if minutes: + time_str += f"{int(minutes)}m" + if seconds: + time_str += f"{int(seconds)}s" + + return time_str + + def calc_time(time: datetime.datetime) -> str: """Convert a datetime object to a Discord timestamp. diff --git a/discord_reminder_bot/ui.py b/discord_reminder_bot/ui.py index 73e94d8..773d620 100644 --- a/discord_reminder_bot/ui.py +++ b/discord_reminder_bot/ui.py @@ -12,7 +12,7 @@ from apscheduler.triggers.interval import IntervalTrigger from discord.ui import Button, Select from discord_reminder_bot import settings -from discord_reminder_bot.misc import calc_time, calculate +from discord_reminder_bot.misc import DateTrigger, calc_time, calculate from discord_reminder_bot.parser import parse_time if TYPE_CHECKING: @@ -208,9 +208,14 @@ def create_job_embed(job: Job) -> discord.Embed: author_id: int = job_kwargs.get("author_id", 0) embed_title: str = textwrap.shorten(f"{message}", width=256, placeholder="...") + # If trigger is IntervalTrigger, show the interval + interval: str = "" + if isinstance(job.trigger, IntervalTrigger): + interval = f"Interval: {job.trigger.interval} seconds" + return discord.Embed( title=embed_title, - description=f"ID: {job.id}\nNext run: {next_run_time}\nTime left: {calculate(job)}\nChannel: <#{channel_id}>\nAuthor: <@{author_id}>", # noqa: E501 + description=f"ID: {job.id}\nNext run: {next_run_time}\nTime left: {calculate(job)}\n{interval}\nChannel: <#{channel_id}>\nAuthor: <@{author_id}>", # noqa: E501 color=discord.Color.blue(), ) @@ -240,9 +245,11 @@ class JobSelector(Select): label_prefix: str = "" if job.next_run_time is None: label_prefix = "Paused: " + # Cron job elif isinstance(job.trigger, CronTrigger): label_prefix = "Cron: " + # Interval job elif isinstance(job.trigger, IntervalTrigger): label_prefix = "Interval: " @@ -281,6 +288,7 @@ class JobManagementView(discord.ui.View): self.scheduler: settings.AsyncIOScheduler = scheduler self.add_item(JobSelector(scheduler)) self.update_buttons() + logger.debug("JobManagementView created for job: %s", job.id) @discord.ui.button(label="Delete", style=discord.ButtonStyle.danger) async def delete_button(self, interaction: discord.Interaction, button: Button) -> None: # noqa: ARG002 @@ -292,7 +300,7 @@ class JobManagementView(discord.ui.View): """ job_kwargs: dict = self.job.kwargs or {} - logger.info("Deleting job: %s", self.job.id) + logger.info("Deleting job: %s because %s clicked the button.", self.job.id, interaction.user.name) if hasattr(self.job, "__getstate__"): logger.debug("State: %s", self.job.__getstate__() if hasattr(self.job, "__getstate__") else "No state") @@ -379,6 +387,7 @@ class JobManagementView(discord.ui.View): if self.job.max_instances: msg += f"**Max instances**: {self.job.max_instances}\n" + logger.debug("Deletion message: %s", msg) return msg @discord.ui.button(label="Modify", style=discord.ButtonStyle.primary) @@ -389,7 +398,7 @@ class JobManagementView(discord.ui.View): interaction: The interaction object for the command. button: The button that was clicked. """ - logger.info("Modifying job: %s", self.job.id) + logger.info("Modifying job: %s. Clicked by %s", self.job.id, interaction.user.name) if hasattr(self.job, "__getstate__"): logger.debug("State: %s", self.job.__getstate__() if hasattr(self.job, "__getstate__") else "No state") @@ -422,17 +431,27 @@ class JobManagementView(discord.ui.View): self.update_buttons() await interaction.response.edit_message(view=self) - msg: str = f"Job '{self.job.name}' has been {status}." + job_kwargs: dict = self.job.kwargs or {} + job_msg: str = job_kwargs.get("message", "No message found") + job_author: int = job_kwargs.get("author_id", 0) + msg: str = f"Job '{job_msg}' has been {status} by <@{interaction.user.id}>. Job was created by <@{job_author}>." + if hasattr(self.job, "next_run_time"): - msg += f"\nNext run time: {self.job.next_run_time} {calculate(self.job)}" + trigger_time: datetime.datetime | None = ( + self.job.trigger.run_date if isinstance(self.job.trigger, DateTrigger) else self.job.next_run_time + ) + msg += f"\nNext run time: {trigger_time} {calculate(self.job)}" await interaction.followup.send(msg) def update_buttons(self) -> None: """Update the visibility of buttons based on job status.""" - self.pause_button.disabled = not self.job.next_run_time + logger.debug("Updating buttons for job: %s", self.job.id) self.pause_button.label = "Resume" if self.job.next_run_time is None else "Pause" + logger.debug("Pause button disabled: %s", self.pause_button.disabled) + logger.debug("Pause button label: %s", self.pause_button.label) + async def interaction_check(self, interaction: discord.Interaction) -> bool: # noqa: ARG002 """Check the interaction and update buttons before responding.