Add better error handling
This commit is contained in:
68
discord_reminder_bot/interaction_responses.py
Normal file
68
discord_reminder_bot/interaction_responses.py
Normal file
@ -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)
|
@ -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,17 +228,18 @@ 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:
|
||||
def add_job(func: Callable, job_kwargs: dict[str, int | str]) -> Job | None:
|
||||
try:
|
||||
return settings.scheduler.add_job(
|
||||
func=func,
|
||||
trigger="cron",
|
||||
@ -253,11 +257,14 @@ class RemindGroup(discord.app_commands.Group):
|
||||
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
|
||||
|
||||
|
@ -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"<t:{int(trigger_time.timestamp())}:R>"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
Reference in New Issue
Block a user