aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar hedy <[email protected]>2024-03-25 22:19:19 +0800
committerGravatar hedy <[email protected]>2024-03-25 22:24:57 +0800
commitcdaa59fa5ee05a2f47e003c289d57a4e3098c507 (patch)
treeb517b08d9dfd49b8de3ea7aa692f816c8d4bcfaf
parentReminders: Add a button for others to opt-in to a ping (diff)
Reminders: More robust implementation of mention opt-in button
This solves most, if not all issues from the previous commit. - A timeout of 5 minutes is enforced - this means the button can no longer be used either when the reminder arrives or 5 minutes passes since creation, whichever comes first. - Reminder edits in between creation and button clicks will be handled responsibly - This includes both edits of duration, mentions, and deleting reminders altogether. - UX is improved. This list of to-be-mentioned users is sent up-front with the author included. Instructions to click the button comes right after the list. - No updates to the API or site schema required, as the button message will disable itself when it encounters any sort of errors. - Implementation is also somewhat simplified. There are probably more improvements, maybe one caveat, but it's like almost midnight and I want to sleep :/ I sure hope the list above covers most of it. Further testing will be done. Now `.remind 10s test` is ingrained in my muscle memory...
-rw-r--r--bot/exts/utils/reminders.py220
1 files changed, 131 insertions, 89 deletions
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index c09e427c0..277d8a3af 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -39,6 +39,7 @@ LOCK_NAMESPACE = "reminder"
WHITELISTED_CHANNELS = Guild.reminder_whitelist
MAXIMUM_REMINDERS = 5
REMINDER_EDIT_CONFIRMATION_TIMEOUT = 60
+REMINDER_MENTION_BUTTON_TIMEOUT = 5*60
# The number of mentions that can be sent when a reminder arrives is limited by
# the 2000-character message limit.
MAXIMUM_REMINDER_MENTION_OPT_INS = 80
@@ -78,38 +79,52 @@ class ModifyReminderConfirmationView(discord.ui.View):
self.stop()
-class GetNotificationView(discord.ui.View):
- """A button to get notified of someone else's reminder."""
+class OptInReminderMentionView(discord.ui.View):
+ """A button to opt-in to get notified of someone else's reminder."""
- def __init__(
- self,
- reminder: dict,
- callback: t.Callable[[list], t.Awaitable[t.Any]],
- ):
+ def __init__(self, cog: "Reminders", reminder: dict):
super().__init__()
+ self.cog = cog
self.reminder = reminder
- self.callback = callback
- self.first_click: bool = True
- self.success_message: discord.Message | None = None
- self.button: discord.ui.Button | None = None
- @discord.ui.button(label="Notify me", style=discord.ButtonStyle.green)
+ async def get_embed_description(self) -> str:
+ """Return a string for use in the embed that shows the button."""
+
+ description = "The following user(s) will be notified when the reminder arrives:\n"
+ description += " ".join([
+ mentionable.mention async for mentionable in self.cog.get_mentionables(
+ [self.reminder["author"]] + self.reminder["mentions"]
+ )
+ ])
+ description += "\n\nClick on the button to add yourself to the list of mentions."
+
+ return description
+
+ @discord.ui.button(emoji="🔔", label="Notify me", style=discord.ButtonStyle.green)
async def button_callback(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
"""The button callback."""
- self.button = button
+ # This is required in case the reminder was edited/deleted between
+ # creation and the opt-in button click.
+ try:
+ self.reminder = await self.cog.bot.api_client.get(f"bot/reminders/{self.reminder['id']}")
+ except ResponseCodeError as e:
+ await self.handle_api_error(interaction, button, e)
+ return
- if interaction.user.id in self.reminder['mentions']:
+ # Check whether the user should be added.
+ if interaction.user.id == self.reminder['author']:
await interaction.response.send_message(
- "You are already in the list of mentions for that reminder!",
+ "As the author of that reminder, you will already be notified when the reminder arrives.",
ephemeral=True,
)
return
- if interaction.user.id == self.reminder['author']:
+ if interaction.user.id in self.reminder['mentions']:
await interaction.response.send_message(
- "As the author of that reminder, you will already be notified when the reminder arrives.",
+ "You are already in the list of mentions for that reminder.",
ephemeral=True,
+ delete_after=5,
)
return
@@ -117,58 +132,91 @@ class GetNotificationView(discord.ui.View):
await interaction.response.send_message(
"Sorry, this reminder has reached the maximum number of allowed mentions.",
ephemeral=True,
+ delete_after=5,
)
- await self.disable_button("Maximum number of allowed mentions reached!")
+ await self.disable(interaction, button, "Maximum number of allowed mentions reached!")
return
- new_mentions = self.reminder['mentions'] + [interaction.user.id]
- await self.callback(new_mentions)
+ # Add the user to the list of mentions.
+ try:
+ self.reminder = await self.cog.add_mention_opt_in(self.reminder, interaction.user.id)
+ except ResponseCodeError as e:
+ await self.handle_api_error(interaction, button, e)
+ return
+ # Confirm that it was successful.
await interaction.response.send_message(
"You were successfully added to the list of mentions for that reminder.",
- ephemeral=True
+ ephemeral=True,
+ delete_after=5,
)
- # Edit original message to show the opt-ins
- if not self.success_message or len(self.success_message.embeds) != 2:
- log.trace(f"Unable to update the message for the list of member opt-ins for reminder #{self.reminder['id']}.")
- return
+ # Update the embed to show the new list of mentions.
+ try:
+ embed = interaction.message.embeds[0]
+ embed.description = await self.get_embed_description()
+ await interaction.message.edit(embed=embed)
+ except:
+ log.trace(f"Unable to edit the interaction message for reminder #{self.reminder['id']}.")
- embeds = self.success_message.embeds
+ async def handle_api_error(
+ self,
+ interaction: discord.Interaction,
+ button: discord.ui.Button,
+ error: ResponseCodeError
+ ) -> None:
+ """Handle a ResponseCodeError from the API responsibly."""
- if self.first_click:
- embeds[1].description += "\n\nThe following member(s) opted-in to be notified:\n"
- self.first_click = False
- embeds[1].description += f"{interaction.user.mention} "
+ log.trace(f"API returned {error.status} for reminder #{self.reminder['id']}.")
- await self.success_message.edit(embeds=embeds)
+ if error.status == 404:
+ # This might happen if the reminder was edited to arrive before the
+ # button was initially scheduled to timeout.
+ await interaction.response.send_message(
+ "This reminder was either deleted or has already arrived.",
+ ephemeral=True,
+ delete_after=5,
+ )
+ # Don't delete the whole interaction message here or the user will
+ # see the above response message seemingly without context.
+ await self.disable(interaction, button)
- async def disable_button(self, reason: str | None = None) -> None:
- """Disable the button."""
- if not self.success_message or not self.button:
- log.trace("Unable to disable the button for opting-in for the reminder notification.")
- return
+ else:
+ await interaction.response.send_message(
+ "Sorry, an unexpected error occurred when performing this operation.\n"
+ "Please create your own reminder instead.",
+ ephemeral=True,
+ delete_after=5,
+ )
+ await self.disable(
+ interaction,
+ button,
+ "An unexpected error occurred when attempting to add users."
+ )
- self.button.disabled = True
+ async def disable(
+ self,
+ interaction: discord.Interaction,
+ button: discord.ui.Button,
+ reason: str | None = None,
+ ) -> None:
+ """Disable the button and add an optional reason to the original interaction message."""
- if not reason:
- await self.success_message.edit(view=self)
- return
+ button.disabled = True
- embeds = self.success_message.embeds
+ embeds = interaction.message.embeds
- if len(embeds) != 2:
- log.trace("Unable to get the button instruction embed for editing reason of disabling the reminder notification button.")
- return
+ try:
+ embed = embeds[0]
+ embed.description = embed.description.split("\n\n", maxsplit=2)[0]
+ if reason:
+ embed.description += "\n\n" + reason
- description = embeds[1].description
- if "\n\n" in description:
- # The button was clicked at least once. Don't remove the text that
- # shows the list of opt-ins.
- embeds[1].description = reason + "\n\n"
- embeds[1].description += description.split("\n\n", maxsplit=2)[1]
+ await interaction.message.edit(embed=embed, view=self)
- await self.success_message.edit(embeds=embeds, view=self)
+ except:
+ log.trace("Unable to disable the reminder notification button.")
+ await interaction.message.edit(embeds=embeds, view=None)
class Reminders(Cog):
@@ -223,12 +271,10 @@ class Reminders(Cog):
async def _send_confirmation(
ctx: Context,
on_success: str,
- reminder_id: str | int,
- embed: discord.Embed | None = None,
- view: discord.ui.View | None = None,
- ) -> discord.Message:
+ reminder_id: str | int
+ ) -> None:
"""Send an embed confirming the reminder change was made successfully."""
- success_embed = discord.Embed(
+ embed = discord.Embed(
description=on_success,
colour=discord.Colour.green(),
title=random.choice(POSITIVE_REPLIES)
@@ -236,12 +282,9 @@ class Reminders(Cog):
footer_str = f"ID: {reminder_id}"
- success_embed.set_footer(text=footer_str)
+ embed.set_footer(text=footer_str)
- if embed:
- return await ctx.send(embeds=[success_embed, embed], view=view)
- else:
- return await ctx.send(embed=success_embed, view=view)
+ await ctx.send(embed=embed)
@staticmethod
async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> tuple[bool, str]:
@@ -309,6 +352,19 @@ class Reminders(Cog):
self.schedule_reminder(reminder)
@lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True)
+ async def add_mention_opt_in(self, reminder: dict, user_id: int) -> dict:
+ """Add an opt-in user to a reminder's mentions and return the edited reminder."""
+
+ if user_id in reminder['mentions'] or user_id == reminder['author']:
+ return reminder
+
+ reminder['mentions'].append(user_id)
+ reminder = await self._edit_reminder(reminder['id'], {'mentions': reminder['mentions']})
+
+ await self._reschedule_reminder(reminder)
+ return reminder
+
+ @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True)
async def send_reminder(self, reminder: dict, expected_time: time.Timestamp | None = None) -> None:
"""Send the reminder."""
is_valid, channel = self.ensure_valid_reminder(reminder)
@@ -352,11 +408,6 @@ class Reminders(Cog):
log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).")
await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")
- # Remove the button that lets others opt-in to pings.
- view = reminder.get("notification_view")
- if view:
- await view.disable_button()
-
@staticmethod
async def try_get_content_from_reply(ctx: Context) -> str | None:
"""
@@ -473,35 +524,26 @@ class Reminders(Cog):
)
formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME)
- mention_string = f"Your reminder will arrive on {formatted_time}"
-
- if mentions:
- mention_string += f" and will mention {len(mentions)} other(s)"
- mention_string += "!"
-
- # Add a button for others to also get notified.
- button_embed = discord.Embed(
- description= "Click on the button to also get a ping when the reminder arrives.",
- )
-
- async def callback(new_mentions: list):
- reminder['mentions'] = new_mentions
- await self._edit_reminder(reminder['id'], { 'mentions': new_mentions })
-
- view = GetNotificationView(reminder, callback)
+ success_message = f"Your reminder will arrive on {formatted_time}!"
# Confirm to the user that it worked.
- message = await self._send_confirmation(
+ await self._send_confirmation(
ctx,
- on_success=mention_string,
- reminder_id=reminder["id"],
- embed=button_embed,
- view=view,
+ on_success=success_message,
+ reminder_id=reminder["id"]
)
- view.success_message = message
+ # Add a button for others to also get notified.
+ view = OptInReminderMentionView(self, reminder)
- reminder['notification_view'] = view
+ button_embed = discord.Embed(
+ description=await view.get_embed_description(),
+ )
+ button_timeout = min(
+ (expiration - datetime.now(UTC)).total_seconds(),
+ REMINDER_MENTION_BUTTON_TIMEOUT
+ )
+ await ctx.send(embed=button_embed, view=view, delete_after=button_timeout)
self.schedule_reminder(reminder)