ui(reminders): Rework user interface.

This commit is contained in:
2023-09-13 02:33:49 +03:00
parent e164e3ec7b
commit 0e8512b971
6 changed files with 698 additions and 533 deletions

View File

@@ -12,158 +12,36 @@ Max 25 reminders (propagating Discord restriction)
"""
from typing import Optional
import datetime as dt
from cachetools import TTLCache, LRUCache
from cachetools import TTLCache
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from discord.app_commands import Transform
from discord.ui.select import select, SelectOption
from dateutil.parser import parse, ParserError
from data import RowModel, Registry, WeakCache
from data.queries import ORDER
from data.columns import Integer, String, Timestamp, Bool
from meta import LionBot, LionCog, LionContext
from meta.errors import UserInputError
from meta.app import shard_talk, appname_from_shard
from meta.logger import log_wrap, logging_context, set_logging_context
from meta.logger import log_wrap, set_logging_context
from babel import ctx_translator, ctx_locale
from utils.lib import parse_duration, utc_now, strfdur, error_embed
from utils.lib import parse_duration, utc_now, strfdur, error_embed, check_dm
from utils.monitor import TaskMonitor
from utils.transformers import DurationTransformer
from utils.ui import LeoUI, AButton, AsComponents
from utils.ui import AButton, AsComponents
from utils.ratelimits import Bucket
from . import babel, logger
from .data import ReminderData
from .ui import ReminderList
_, _p, _np = babel._, babel._p, babel._np
class ReminderData(Registry, name='reminders'):
class Reminder(RowModel):
"""
Model representing a single reminder.
Since reminders are likely to change across shards,
does not use an explicit reference cache.
Schema
------
CREATE TABLE reminders(
reminderid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL REFERENCES user_config(userid) ON DELETE CASCADE,
remind_at TIMESTAMPTZ NOT NULL,
content TEXT NOT NULL,
message_link TEXT,
interval INTEGER,
created_at TIMESTAMP DEFAULT (now() at time zone 'utc'),
title TEXT,
footer TEXT
);
CREATE INDEX reminder_users ON reminders (userid);
"""
_tablename_ = 'reminders'
reminderid = Integer(primary=True)
userid = Integer() # User which created the reminder
remind_at = Timestamp() # Time when the reminder should be executed
content = String() # Content the user gave us to remind them
message_link = String() # Link to original confirmation message, for context
interval = Integer() # Repeat interval, if applicable
created_at = Timestamp() # Time when this reminder was originally created
title = String() # Title of the final reminder embed, only set in automated reminders
footer = String() # Footer of the final reminder embed, only set in automated reminders
failed = Bool() # Whether the reminder was already attempted and failed
@property
def timestamp(self) -> int:
"""
Time when this reminder should be executed (next) as an integer timestamp.
"""
return int(self.remind_at.timestamp())
@property
def embed(self) -> discord.Embed:
t = ctx_translator.get().t
embed = discord.Embed(
title=self.title or t(_p('reminder|embed', "You asked me to remind you!")),
colour=discord.Colour.orange(),
description=self.content,
timestamp=self.remind_at
)
if self.message_link:
embed.add_field(
name=t(_p('reminder|embed', "Context?")),
value="[{click}]({link})".format(
click=t(_p('reminder|embed', "Click Here")),
link=self.message_link
)
)
if self.interval:
embed.add_field(
name=t(_p('reminder|embed', "Next reminder")),
value=f"<t:{self.timestamp + self.interval}:R>"
)
if self.footer:
embed.set_footer(text=self.footer)
return embed
@property
def formatted(self):
"""
Single-line string format for the reminder, intended for an embed.
"""
t = ctx_translator.get().t
content = self.content
trunc_content = content[:50] + '...' * (len(content) > 50)
if interval := self.interval:
if not interval % (24 * 60 * 60):
# Exact day case
days = interval // (24 * 60 * 60)
repeat = t(_np(
'reminder|formatted|interval',
"Every day",
"Every `{days}` days",
days
)).format(days=days)
elif not interval % (60 * 60):
# Exact hour case
hours = interval // (60 * 60)
repeat = t(_np(
'reminder|formatted|interval',
"Every hour",
"Every `{hours}` hours",
hours
)).format(hours=hours)
else:
# Inexact interval, e.g 10m or 1h 10m.
# Use short duration format
repeat = t(_p(
'reminder|formatted|interval',
"Every `{duration}`"
)).format(duration=strfdur(interval))
repeat = f"({repeat})"
else:
repeat = ""
return "<t:{timestamp}:R>, [{content}]({jump_link}) {repeat}".format(
jump_link=self.message_link,
content=trunc_content,
timestamp=self.timestamp,
repeat=repeat
)
class ReminderMonitor(TaskMonitor[int]):
...
@@ -191,7 +69,7 @@ class Reminders(LionCog):
# Short term userid -> list[Reminder] cache, mainly for autocomplete
self._user_reminder_cache: TTLCache[int, list[ReminderData.Reminder]] = TTLCache(1000, ttl=60)
self._active_reminderlists: dict[int, ReminderListUI] = {}
self._active_reminderlists: dict[int, ReminderList] = {}
async def cog_load(self):
await self.data.init()
@@ -212,6 +90,105 @@ class Reminders(LionCog):
# Start firing reminders
self.monitor.start()
# ----- Cog API -----
async def create_reminder(
self,
userid: int, remind_at: dt.datetime, content: str,
message_link: Optional[str] = None,
interval: Optional[int] = None,
created_at: Optional[dt.datetime] = None,
) -> ReminderData.Reminder:
"""
Create and schedule a new reminder from user-entered data.
Raises UserInputError if the requested parameters are invalid.
"""
now = utc_now()
if remind_at <= now:
t = self.bot.translator.t
raise UserInputError(
t(_p(
'create_reminder|error:past',
"The provided reminder time {timestamp} is in the past!"
)).format(timestamp=discord.utils.format_dt(remind_at))
)
if interval is not None and interval < 600:
t = self.bot.translator.t
raise UserInputError(
t(_p(
'create_reminder|error:too_fast',
"You cannot set a repeating reminder with a period less than 10 minutes."
))
)
existing = await self.data.Reminder.fetch_where(userid=userid)
if len(existing) >= 25:
t = self.bot.translator.t
raise UserInputError(
t(_p(
'create_reminder|error:too_many',
"Sorry, you have reached the maximum of `25` reminders."
))
)
user = self.bot.get_user(userid)
if not user:
user = await self.bot.fetch_user(userid)
if not user:
raise ValueError(f"Target user {userid} does not exist.")
can_dm = await check_dm(user)
if not can_dm:
t = self.bot.translator.t
raise UserInputError(
t(_p(
'create_reminder|error:cannot_dm',
"I cannot direct message you! Do you have me blocked or direct messages closed?"
))
)
created_at = created_at or now
# Passes validation, actually create
reminder = await self.data.Reminder.create(
userid=userid,
remind_at=remind_at,
content=content,
message_link=message_link,
interval=interval,
created_at=created_at,
)
# Schedule from executor
await self.talk_schedule(reminder.reminderid).send(self.executor_name, wait_for_reply=False)
# Dispatch reminder update
await self.dispatch_update_for(userid)
# Return fresh reminder
return reminder
async def parse_time_static(self, timestr, timezone):
timestr = timestr.strip()
default = dt.datetime.now(tz=timezone).replace(hour=0, minute=0, second=0, microsecond=0)
if not timestr:
return default
try:
ts = parse(timestr, fuzzy=True, default=default)
except ParserError:
t = self.bot.translator.t
raise UserInputError(
t(_p(
'parse_timestamp|error:parse',
"Could not parse `{given}` as a valid reminder time. "
"Try entering the time in the form `HH:MM` or `YYYY-MM-DD HH:MM`."
)).format(given=timestr)
)
return ts
async def get_reminders_for(self, userid: int):
"""
Retrieve a list of reminders for the given userid, using the cache.
@@ -348,116 +325,43 @@ class Reminders(LionCog):
# Dispatch for analytics
self.bot.dispatch('reminder_sent', reminder)
@cmds.hybrid_group(
name=_p('cmd:reminders', "reminders")
)
async def reminders_group(self, ctx: LionContext):
pass
@reminders_group.command(
# No help string
name=_p('cmd:reminders_show', "show"),
@cmds.hybrid_command(
name=_p('cmd:reminders', "reminders"),
description=_p(
'cmd:reminders_show|desc',
"Display your current reminders."
'cmd:reminders|desc',
"View and set your reminders."
)
)
async def cmd_reminders_show(self, ctx: LionContext):
# No help string
async def cmd_reminders(self, ctx: LionContext):
"""
Display the reminder widget for this user.
"""
t = self.bot.translator.t
if not ctx.interaction:
return
if ctx.author.id in self._active_reminderlists:
await self._active_reminderlists[ctx.author.id].close(
msg=t(_p(
'cmd:reminders_show|close_elsewhere',
"Closing since the list was opened elsewhere."
))
)
ui = ReminderListUI(self.bot, ctx.author)
await self._active_reminderlists[ctx.author.id].quit()
ui = ReminderList(self.bot, ctx.author)
try:
self._active_reminderlists[ctx.author.id] = ui
await ui.run(ctx.interaction)
await ui.run(ctx.interaction, ephemeral=True)
await ui.wait()
finally:
self._active_reminderlists.pop(ctx.author.id, None)
@reminders_group.command(
name=_p('cmd:reminders_clear', "clear"),
description=_p(
'cmd:reminders_clear|desc',
"Clear your reminder list."
)
@cmds.hybrid_group(
name=_p('cmd:remindme', "remindme"),
description=_p('cmd:remindme|desc', "View and set task reminders."),
)
async def cmd_reminders_clear(self, ctx: LionContext):
# No help string
"""
Confirm and then clear all the reminders for this user.
"""
if not ctx.interaction:
return
async def remindme_group(self, ctx: LionContext):
# Base command group for scheduling reminders.
pass
t = self.bot.translator.t
reminders = await self.data.Reminder.fetch_where(userid=ctx.author.id)
if not reminders:
await ctx.reply(
embed=discord.Embed(
description=t(_p(
'cmd:reminders_clear|error:no_reminders',
"You have no reminders to clear!"
)),
colour=discord.Colour.brand_red()
),
ephemeral=True
)
return
embed = discord.Embed(
title=t(_p('cmd:reminders_clear|confirm|title', "Are You Sure?")),
description=t(_np(
'cmd:reminders_clear|confirm|desc',
"Are you sure you want to delete your `{count}` reminder?",
"Are you sure you want to clear your `{count}` reminders?",
len(reminders)
)).format(count=len(reminders))
)
@AButton(label=t(_p('cmd:reminders_clear|confirm|button:yes', "Yes, clear my reminders")))
async def confirm(interaction, press):
await interaction.response.defer()
reminders = await self.data.Reminder.table.delete_where(userid=ctx.author.id)
await self.talk_cancel(*(r['reminderid'] for r in reminders)).send(self.executor_name, wait_for_reply=False)
await ctx.interaction.edit_original_response(
embed=discord.Embed(
description=t(_p(
'cmd:reminders_clear|success|desc',
"Your reminders have been cleared!"
)),
colour=discord.Colour.brand_green()
),
view=None
)
await press.view.close()
await self.dispatch_update_for(ctx.author.id)
@AButton(label=t(_p('cmd:reminders_clear|confirm|button:cancel', "Cancel")))
async def deny(interaction, press):
await interaction.response.defer()
await ctx.interaction.delete_original_response()
await press.view.close()
components = AsComponents(confirm, deny)
await ctx.interaction.response.send_message(embed=embed, view=components, ephemeral=True)
@reminders_group.command(
@remindme_group.command(
name=_p('cmd:reminders_cancel', "cancel"),
description=_p(
'cmd:reminders_cancel|desc',
"Cancel a single reminder. Use the menu in \"reminder show\" to cancel multiple reminders."
"Cancel a single reminder. Use /reminders to clear or cancel multiple reminders."
)
)
@appcmds.rename(
@@ -576,13 +480,6 @@ class Reminders(LionCog):
]
return choices
@cmds.hybrid_group(
name=_p('cmd:remindme', "remindme")
)
async def remindme_group(self, ctx: LionContext):
# Base command group for scheduling reminders.
pass
@remindme_group.command(
name=_p('cmd:remindme_at', "at"),
description=_p(
@@ -596,118 +493,79 @@ class Reminders(LionCog):
every=_p('cmd:remindme_at|param:every', "repeat_every"),
)
@appcmds.describe(
time=_p('cmd:remindme_at|param:time|desc', "When you want to be reminded. (E.g. `4pm` or `16:00`)."),
reminder=_p('cmd:remindme_at|param:reminder|desc', "What should the reminder be?"),
every=_p('cmd:remindme_at|param:every|desc', "How often to repeat this reminder.")
time=_p(
'cmd:remindme_at|param:time|desc',
"When you want to be reminded. (E.g. `4pm` or `16:00`)."
),
reminder=_p(
'cmd:remindme_at|param:reminder|desc',
"What should the reminder be?"
),
every=_p(
'cmd:remindme_at|param:every|desc',
"How often to repeat this reminder."
)
)
async def cmd_remindme_at(
self,
ctx: LionContext,
time: str,
reminder: str,
time: appcmds.Range[str, 1, 100],
reminder: appcmds.Range[str, 1, 2000],
every: Optional[Transform[int, DurationTransformer(60)]] = None
):
t = self.bot.translator.t
reminders = await self.data.Reminder.fetch_where(userid=ctx.author.id)
# Guard against too many reminders
if len(reminders) > 25:
await ctx.error_reply(
embed=error_embed(
t(_p(
'cmd_remindme_at|error:too_many|desc',
"Sorry, you have reached the maximum of `25` reminders!"
)),
title=t(_p(
'cmd_remindme_at|error:too_many|title',
"Could not create reminder!"
))
),
ephemeral=True
)
return
# Guard against too frequent reminders
if every is not None and every < 600:
await ctx.reply(
embed=error_embed(
t(_p(
'cmd_remindme_at|error:too_fast|desc',
"You cannot set a repeating reminder with a period less than 10 minutes."
)),
title=t(_p(
'cmd_remindme_at|error:too_fast|title',
"Could not create reminder!"
))
),
ephemeral=True
)
return
# Parse the provided static time
timezone = ctx.lmember.timezone
time = time.strip()
default = dt.datetime.now(tz=timezone).replace(hour=0, minute=0, second=0, microsecond=0)
try:
ts = parse(time, fuzzy=True, default=default)
except ParserError:
await ctx.reply(
embed=error_embed(
t(_p(
'cmd:remindme_at|error:parse_time|desc',
"Could not parse provided time `{given}`. Try entering e.g. `4 pm` or `16:00`."
)).format(given=time),
title=t(_p(
'cmd:remindme_at|error:parse_time|title',
"Could not create reminder!"
))
),
ephemeral=True
timezone = ctx.lmember.timezone
remind_at = await self.parse_time_static(time, timezone)
reminder = await self.create_reminder(
userid=ctx.author.id,
remind_at=remind_at,
content=reminder,
message_link=ctx.message.jump_url,
interval=every,
)
return
if ts < utc_now():
await ctx.reply(
embed=error_embed(
t(_p(
'cmd:remindme_at|error:past_time|desc',
"Provided time is in the past!"
)),
title=t(_p(
'cmd:remindme_at|error:past_time|title',
"Could not create reminder!"
))
),
ephemeral=True
embed = reminder.set_response
except UserInputError as e:
embed = discord.Embed(
title=t(_p(
'cmd:remindme_at|error|title',
"Could not create reminder!"
)),
description=e.msg,
colour=discord.Colour.brand_red()
)
return
# Everything seems to be in order
# Create the reminder
now = utc_now()
rem = await self.data.Reminder.create(
userid=ctx.author.id,
remind_at=ts,
content=reminder,
message_link=ctx.message.jump_url,
interval=every,
created_at=now
)
# Reminder created, request scheduling from executor shard
await self.talk_schedule(rem.reminderid).send(self.executor_name, wait_for_reply=False)
# TODO Add repeat to description
embed = discord.Embed(
title=t(_p(
'cmd:remindme_in|success|title',
"Reminder Set at {timestamp}"
)).format(timestamp=f"<t:{rem.timestamp}>"),
description=f"> {rem.content}"
)
await ctx.reply(
embed=embed,
ephemeral=True
)
await self.dispatch_update_for(ctx.author.id)
@cmd_remindme_at.autocomplete('time')
async def cmd_remindme_at_acmpl_time(self, interaction: discord.Interaction, partial: str):
if interaction.guild:
lmember = await self.bot.core.lions.fetch_member(interaction.guild.id, interaction.user.id)
timezone = lmember.timezone
else:
luser = await self.bot.core.lions.fetch_user(interaction.user.id)
timezone = luser.timezone
t = self.bot.translator.t
try:
timestamp = await self.parse_time_static(partial, timezone)
choice = appcmds.Choice(
name=timestamp.strftime('%Y-%m-%d %H:%M'),
value=partial
)
except UserInputError:
choice = appcmds.Choice(
name=t(_p(
'cmd:remindme_at|acmpl:time|error:parse',
"Cannot parse \"{partial}\" as a time. Try the format HH:MM or YYYY-MM-DD HH:MM"
)).format(partial=partial),
value=partial
)
return [choice]
@remindme_group.command(
name=_p('cmd:remindme_in', "in"),
@@ -722,228 +580,49 @@ class Reminders(LionCog):
every=_p('cmd:remindme_in|param:every', "repeat_every"),
)
@appcmds.describe(
time=_p('cmd:remindme_in|param:time|desc', "How far into the future to set the reminder (e.g. 1 day 10h 5m)."),
reminder=_p('cmd:remindme_in|param:reminder|desc', "What should the reminder be?"),
every=_p('cmd:remindme_in|param:every|desc', "How often to repeat this reminder. (e.g. 1 day, or 2h)")
time=_p(
'cmd:remindme_in|param:time|desc',
"How far into the future to set the reminder (e.g. 1 day 10h 5m)."
),
reminder=_p(
'cmd:remindme_in|param:reminder|desc',
"What should the reminder be?"
),
every=_p(
'cmd:remindme_in|param:every|desc',
"How often to repeat this reminder. (e.g. 1 day, or 2h)"
)
)
async def cmd_remindme_in(
self,
ctx: LionContext,
time: Transform[int, DurationTransformer(60)],
reminder: appcmds.Range[str, 1, 1000], # TODO: Maximum length 1000?
reminder: appcmds.Range[str, 1, 2000],
every: Optional[Transform[int, DurationTransformer(60)]] = None
):
t = self.bot.translator.t
reminders = await self.data.Reminder.fetch_where(userid=ctx.author.id)
# Guard against too many reminders
if len(reminders) > 25:
await ctx.error_reply(
embed=error_embed(
t(_p(
'cmd_remindme_in|error:too_many|desc',
"Sorry, you have reached the maximum of `25` reminders!"
)),
title=t(_p(
'cmd_remindme_in|error:too_many|title',
"Could not create reminder!"
))
),
ephemeral=True
try:
remind_at = utc_now() + dt.timedelta(seconds=time)
reminder = await self.create_reminder(
userid=ctx.author.id,
remind_at=remind_at,
content=reminder,
message_link=ctx.message.jump_url,
interval=every,
)
return
# Guard against too frequent reminders
if every is not None and every < 600:
await ctx.reply(
embed=error_embed(
t(_p(
'cmd_remindme_in|error:too_fast|desc',
"You cannot set a repeating reminder with a period less than 10 minutes."
)),
title=t(_p(
'cmd_remindme_in|error:too_fast|title',
"Could not create reminder!"
))
),
ephemeral=True
embed = reminder.set_response
except UserInputError as e:
embed = discord.Embed(
title=t(_p(
'cmd:remindme_in|error|title',
"Could not create reminder!"
)),
description=e.msg,
colour=discord.Colour.brand_red()
)
return
# Everything seems to be in order
# Create the reminder
now = utc_now()
rem = await self.data.Reminder.create(
userid=ctx.author.id,
remind_at=now + dt.timedelta(seconds=time),
content=reminder,
message_link=ctx.message.jump_url,
interval=every,
created_at=now
)
# Reminder created, request scheduling from executor shard
await self.talk_schedule(rem.reminderid).send(self.executor_name, wait_for_reply=False)
# TODO Add repeat to description
embed = discord.Embed(
title=t(_p(
'cmd:remindme_in|success|title',
"Reminder Set {timestamp}"
)).format(timestamp=f"<t:{rem.timestamp}:R>"),
description=f"> {rem.content}"
)
await ctx.reply(
embed=embed,
ephemeral=True
)
await self.dispatch_update_for(ctx.author.id)
class ReminderListUI(LeoUI):
def __init__(self, bot: LionBot, user: discord.User, **kwargs):
super().__init__(**kwargs)
self.bot = bot
self.user = user
cog = bot.get_cog('Reminders')
if cog is None:
raise ValueError("Cannot create a ReminderUI without the Reminder cog!")
self.cog: Reminders = cog
self.userid = user.id
# Original interaction which sent the UI message
# Since this is an ephemeral UI, we need this to update and delete
self._interaction: Optional[discord.Interaction] = None
self._reminders = []
async def cleanup(self):
# Cleanup after an ephemeral UI
# Just close if possible
if self._interaction and not self._interaction.is_expired():
try:
await self._interaction.delete_original_response()
except discord.HTTPException:
pass
@select()
async def select_remove(self, interaction: discord.Interaction, selection):
"""
Select a number of reminders to delete.
"""
await interaction.response.defer()
# Hopefully this is a list of reminderids
values = selection.values
# Delete from data
await self.cog.data.Reminder.table.delete_where(reminderid=values)
# Send cancellation
await self.cog.talk_cancel(*values).send(self.cog.executor_name, wait_for_reply=False)
self.cog._user_reminder_cache.pop(self.userid, None)
await self.refresh()
async def refresh_select_remove(self):
"""
Refresh the select remove component from current state.
"""
t = self.bot.translator.t
self.select_remove.placeholder = t(_p(
'ui:reminderlist|select:remove|placeholder',
"Select to cancel."
))
self.select_remove.options = [
SelectOption(
label=f"[{i}] {reminder.content[:50] + '...' * (len(reminder.content) > 50)}",
value=reminder.reminderid,
emoji=self.bot.config.emojis.getemoji('clock')
)
for i, reminder in enumerate(self._reminders, start=1)
]
self.select_remove.min_values = 1
self.select_remove.max_values = len(self._reminders)
async def refresh_reminders(self):
self._reminders = await self.cog.get_reminders_for(self.userid)
async def refresh(self):
"""
Refresh the UI message and components.
"""
if not self._interaction:
raise ValueError("Cannot refresh ephemeral UI without an origin interaction!")
await self.refresh_reminders()
await self.refresh_select_remove()
embed = await self.build_embed()
if self._reminders:
self.set_layout((self.select_remove,))
else:
self.set_layout()
try:
if not self._interaction.response.is_done():
# Fresh message
await self._interaction.response.send_message(embed=embed, view=self, ephemeral=True)
else:
# Update existing message
await self._interaction.edit_original_response(embed=embed, view=self)
except discord.HTTPException:
await self.close()
async def run(self, interaction: discord.Interaction):
"""
Run the UI responding to the given interaction.
"""
self._interaction = interaction
await self.refresh()
async def build_embed(self):
"""
Build the reminder list embed.
"""
t = self.bot.translator.t
reminders = self._reminders
if reminders:
lines = []
num_len = len(str(len(reminders)))
for i, reminder in enumerate(reminders):
lines.append(
"`[{:<{}}]` | {}".format(
i+1,
num_len,
reminder.formatted
)
)
description = '\n'.join(lines)
embed = discord.Embed(
description=description,
colour=discord.Colour.orange(),
timestamp=utc_now()
).set_author(
name=t(_p(
'ui:reminderlist|embed:list|author',
"{name}'s reminders"
)).format(name=self.user.display_name),
icon_url=self.user.avatar
).set_footer(
text=t(_p(
'ui:reminderlist|embed:list|footer',
"Click a reminder twice to jump to the context!"
))
)
else:
embed = discord.Embed(
description=t(_p(
'ui:reminderlist|embed:no_reminders|desc',
"You have no reminders to display!\n"
"Use {remindme} to create a new reminder."
)).format(
remindme=self.bot.core.cmd_name_cache['remindme'].mention,
)
)
return embed

View File

@@ -0,0 +1,165 @@
import discord
from data import RowModel, Registry
from data.columns import Integer, String, Timestamp, Bool
from babel import ctx_translator
from utils.lib import strfdur
from . import babel
_, _p, _np = babel._, babel._p, babel._np
class ReminderData(Registry, name='reminders'):
class Reminder(RowModel):
"""
Model representing a single reminder.
Since reminders are likely to change across shards,
does not use an explicit reference cache.
Schema
------
CREATE TABLE reminders(
reminderid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL REFERENCES user_config(userid) ON DELETE CASCADE,
remind_at TIMESTAMPTZ NOT NULL,
content TEXT NOT NULL,
message_link TEXT,
interval INTEGER,
created_at TIMESTAMP DEFAULT (now() at time zone 'utc'),
title TEXT,
footer TEXT
);
CREATE INDEX reminder_users ON reminders (userid);
"""
_tablename_ = 'reminders'
reminderid = Integer(primary=True)
userid = Integer() # User which created the reminder
remind_at = Timestamp() # Time when the reminder should be executed
content = String() # Content the user gave us to remind them
message_link = String() # Link to original confirmation message, for context
interval = Integer() # Repeat interval, if applicable
created_at = Timestamp() # Time when this reminder was originally created
title = String() # Title of the final reminder embed, only set in automated reminders
footer = String() # Footer of the final reminder embed, only set in automated reminders
failed = Bool() # Whether the reminder was already attempted and failed
@property
def timestamp(self) -> int:
"""
Time when this reminder should be executed (next) as an integer timestamp.
"""
return int(self.remind_at.timestamp())
@property
def set_response(self) -> discord.Embed:
t = ctx_translator.get().t
embed = discord.Embed(
title=t(_p(
'reminder_set|title',
"Reminder Set!"
)),
description=t(_p(
'reminder_set|desc',
"At {timestamp} I will remind you about:\n"
"> {content}"
)).format(
timestamp=discord.utils.format_dt(self.remind_at),
content=self.content,
)[:2048],
colour=discord.Colour.brand_green(),
)
if self.interval:
embed.add_field(
name=t(_p(
'reminder_set|field:repeat|name',
"Repeats"
)),
value=t(_p(
'reminder_set|field:repeat|value',
"This reminder will repeat every `{interval}` (after the first reminder)."
)).format(interval=strfdur(self.interval, short=False)),
inline=False
)
return embed
@property
def embed(self) -> discord.Embed:
t = ctx_translator.get().t
embed = discord.Embed(
title=self.title or t(_p('reminder|embed', "You asked me to remind you!")),
colour=discord.Colour.orange(),
description=self.content,
timestamp=self.remind_at
)
if self.message_link:
embed.add_field(
name=t(_p('reminder|embed', "Context?")),
value="[{click}]({link})".format(
click=t(_p('reminder|embed', "Click Here")),
link=self.message_link
)
)
if self.interval:
embed.add_field(
name=t(_p('reminder|embed', "Next reminder")),
value=f"<t:{self.timestamp + self.interval}:R>"
)
if self.footer:
embed.set_footer(text=self.footer)
return embed
@property
def formatted(self):
"""
Single-line string format for the reminder, intended for an embed.
"""
t = ctx_translator.get().t
content = self.content
trunc_content = content[:50] + '...' * (len(content) > 50)
if interval := self.interval:
if not interval % (24 * 60 * 60):
# Exact day case
days = interval // (24 * 60 * 60)
repeat = t(_np(
'reminder|formatted|interval',
"Every day",
"Every `{days}` days",
days
)).format(days=days)
elif not interval % (60 * 60):
# Exact hour case
hours = interval // (60 * 60)
repeat = t(_np(
'reminder|formatted|interval',
"Every hour",
"Every `{hours}` hours",
hours
)).format(hours=hours)
else:
# Inexact interval, e.g 10m or 1h 10m.
# Use short duration format
repeat = t(_p(
'reminder|formatted|interval',
"Every `{duration}`"
)).format(duration=strfdur(interval))
repeat = f"({repeat})"
else:
repeat = ""
return "<t:{timestamp}:R>, [{content}]({jump_link}) {repeat}".format(
jump_link=self.message_link,
content=trunc_content,
timestamp=self.timestamp,
repeat=repeat
)

304
src/modules/reminders/ui.py Normal file
View File

@@ -0,0 +1,304 @@
from typing import Optional, TYPE_CHECKING
import asyncio
import datetime as dt
import discord
from discord.ui.select import select, Select, SelectOption
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.text_input import TextInput, TextStyle
from meta import LionBot
from meta.errors import UserInputError
from utils.lib import utc_now, MessageArgs, parse_duration
from utils.ui import MessageUI, AButton, AsComponents, ConfigEditor
from . import babel, logger
_, _p, _np = babel._, babel._p, babel._np
if TYPE_CHECKING:
from .cog import Reminders
class ReminderList(MessageUI):
def __init__(self, bot: LionBot, user: discord.User, **kwargs):
super().__init__(callerid=user.id, **kwargs)
self.bot = bot
self.user = user
self.userid = user.id
self.cog: 'Reminders' = bot.get_cog('Reminders')
if self.cog is None:
raise ValueError("Cannot initialise ReminderList without loaded Reminder cog.")
# UI state
self._reminders = []
# ----- UI API -----
# ----- UI Components -----
# Clear button
@button(label="CLEAR_BUTTON_PLACEHOLDER", style=ButtonStyle.red)
async def clear_button(self, press: discord.Interaction, pressed: Button):
t = self.bot.translator.t
reminders = self._reminders
embed = discord.Embed(
title=t(_p('ui:reminderlist|button:clear|confirm|title', "Are You Sure?")),
description=t(_np(
'ui:reminderlist|button:clear|confirm|desc',
"Are you sure you want to delete your `{count}` reminder?",
"Are you sure you want to clear your `{count}` reminders?",
len(reminders)
)).format(count=len(reminders)),
colour=discord.Colour.dark_orange()
)
@AButton(label=t(_p('ui:reminderlist|button:clear|confirm|button:yes', "Yes, clear my reminders")))
async def confirm(interaction, pressed):
await interaction.response.defer()
reminders = await self.cog.data.Reminder.table.delete_where(userid=self.userid)
await self.cog.talk_cancel(*(r['reminderid'] for r in reminders)).send(
self.cog.executor_name, wait_for_reply=False
)
await press.edit_original_response(
embed=discord.Embed(
description=t(_p(
'ui:reminderlist|button:clear|success|desc',
"Your reminders have been cleared!"
)),
colour=discord.Colour.brand_green()
),
view=None
)
await pressed.view.close()
await self.cog.dispatch_update_for(self.userid)
@AButton(label=t(_p('ui:reminderlist|button:clear|confirm|button:cancel', "Cancel")))
async def deny(interaction, pressed):
await interaction.response.defer()
await press.delete_original_response()
await pressed.view.close()
components = AsComponents(confirm, deny)
await press.response.send_message(embed=embed, view=components, ephemeral=True)
async def clear_button_refresh(self):
self.clear_button.label = self.bot.translator.t(_p(
'ui:reminderlist|button:clear|label',
"Clear Reminders"
))
# New reminder button
@button(label="NEW_BUTTON_PLACEHOLDER", style=ButtonStyle.green)
async def new_button(self, press: discord.Interaction, pressed: Button):
"""
Pop up a modal for the user to enter new reminder information.
"""
t = self.bot.translator.t
if press.guild:
lmember = await self.bot.core.lions.fetch_member(press.guild.id, press.user.id)
timezone = lmember.timezone
else:
luser = await self.bot.core.lions.fetch_user(press.user.id)
timezone = luser.timezone
default = dt.datetime.now(tz=timezone).replace(hour=0, minute=0, second=0, microsecond=0)
time_field = TextInput(
label=t(_p(
'ui:reminderlist|button:new|modal|field:time|label',
"When would you like to be reminded?"
)),
placeholder=default.strftime('%Y-%m-%d %H:%M'),
required=True,
max_length=100,
)
interval_field = TextInput(
label=t(_p(
'ui:reminderlist|button:new|modal|field:repeat|label',
"How often should the reminder repeat?"
)),
placeholder=t(_p(
'ui:reminderlist|button:new|modal|field:repeat|placeholder',
"1 day 10 hours 5 minutes (Leave empty for no repeat.)"
)),
required=False,
max_length=100,
)
content_field = TextInput(
label=t(_p(
'ui:reminderlist|button:new|modal|field:content|label',
"What should I remind you?"
)),
required=True,
style=TextStyle.long,
max_length=2000,
)
modal = ConfigEditor(
time_field, interval_field, content_field,
title=t(_p(
'ui:reminderlist|button:new|modal|title',
"Set a Reminder"
))
)
@modal.submit_callback()
async def create_reminder(interaction: discord.Interaction):
remind_at = await self.cog.parse_time_static(time_field.value, timezone)
if intervalstr := interval_field.value:
interval = parse_duration(intervalstr)
if interval is None:
raise UserInputError(
t(_p(
'ui:reminderlist|button:new|modal|parse|error:interval',
"Cannot parse '{value}' as a duration."
)).format(value=intervalstr)
)
else:
interval = None
message = await self._original.original_response()
reminder = await self.cog.create_reminder(
userid=self.userid,
remind_at=remind_at,
content=content_field.value,
message_link=message.jump_url,
interval=interval,
)
embed = reminder.set_response
await interaction.response.send_message(embed=embed, ephemeral=True)
await press.response.send_modal(modal)
async def new_button_refresh(self):
self.new_button.label = self.bot.translator.t(_p(
'ui:reminderlist|button:new|label',
"New Reminder"
))
self.new_button.disabled = (len(self._reminders) >= 25)
# Cancel menu
@select(cls=Select, placeholder="CANCEL_REMINDER_PLACEHOLDER", min_values=0, max_values=1)
async def cancel_menu(self, selection: discord.Interaction, selected):
"""
Select a number of reminders to delete.
"""
await selection.response.defer()
if selected.values:
# Hopefully this is a list of reminderids
values = selected.values
# Delete from data
await self.cog.data.Reminder.table.delete_where(reminderid=values)
# Send cancellation
await self.cog.talk_cancel(*values).send(self.cog.executor_name, wait_for_reply=False)
self.cog._user_reminder_cache.pop(self.userid, None)
await self.refresh()
async def cancel_menu_refresh(self):
t = self.bot.translator.t
self.cancel_menu.placeholder = t(_p(
'ui:reminderlist|select:remove|placeholder',
"Select to cancel"
))
self.cancel_menu.options = [
SelectOption(
label=f"[{i}] {reminder.content[:50] + '...' * (len(reminder.content) > 50)}",
value=reminder.reminderid,
emoji=self.bot.config.emojis.getemoji('clock')
)
for i, reminder in enumerate(self._reminders, start=1)
]
self.cancel_menu.min_values = 0
self.cancel_menu.max_values = len(self._reminders)
# ----- UI Flow -----
async def refresh_layout(self):
to_refresh = (
self.cancel_menu_refresh(),
self.new_button_refresh(),
self.clear_button_refresh(),
)
await asyncio.gather(*to_refresh)
if self._reminders:
self.set_layout(
(self.new_button, self.clear_button,),
(self.cancel_menu,),
)
else:
self.set_layout(
(self.new_button,),
)
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
reminders = self._reminders
if reminders:
lines = []
num_len = len(str(len(reminders)))
for i, reminder in enumerate(reminders):
lines.append(
"`[{:<{}}]` | {}".format(
i+1,
num_len,
reminder.formatted
)
)
description = '\n'.join(lines)
embed = discord.Embed(
description=description,
colour=discord.Colour.orange(),
timestamp=utc_now()
).set_author(
name=t(_p(
'ui:reminderlist|embed:list|author',
"Your reminders"
)),
icon_url=self.user.avatar or self.user.default_avatar
).set_footer(
text=t(_p(
'ui:reminderlist|embed:list|footer',
"Click a reminder to jump back to the context!"
))
)
else:
embed = discord.Embed(
title=t(_p(
'ui:reminderlist|embed:no_reminders|title',
"You have no reminders set!"
)).format(
remindme=self.bot.core.cmd_name_cache['remindme'].mention,
),
colour=discord.Colour.dark_orange(),
)
embed.add_field(
name=t(_p(
'ui:reminderlist|embed|tips:name',
"Reminder Tips"
)),
value=t(_p(
'ui:reminderlist|embed|tips:value',
"- Use {at_cmd} to set a reminder at a known time (e.g. `at 10 am`).\n"
"- Use {in_cmd} to set a reminder in a certain time (e.g. `in 2 hours`).\n"
"- Both commands support repeating reminders using the `every` parameter.\n"
"- Remember to tell me your timezone with {timezone_cmd} if you haven't already!"
)).format(
at_cmd=self.bot.core.mention_cmd('remindme at'),
in_cmd=self.bot.core.mention_cmd('remindme in'),
timezone_cmd=self.bot.core.mention_cmd('my timezone'),
)
)
return MessageArgs(embed=embed)
async def reload(self):
self._reminders = await self.cog.get_reminders_for(self.userid)