From 2acef999c559ae64f1bf99ff823ff8d89afc80f0 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 15 Sep 2021 19:58:32 +0300 Subject: [PATCH] (Reminders): Created reminders module and system. --- bot/modules/__init__.py | 1 + bot/modules/reminders/__init__.py | 5 + bot/modules/reminders/commands.py | 253 ++++++++++++++++++++++++++++++ bot/modules/reminders/data.py | 8 + bot/modules/reminders/module.py | 4 + bot/modules/reminders/reminder.py | 168 ++++++++++++++++++++ data/schema.sql | 13 ++ 7 files changed, 452 insertions(+) create mode 100644 bot/modules/reminders/commands.py create mode 100644 bot/modules/reminders/data.py create mode 100644 bot/modules/reminders/module.py create mode 100644 bot/modules/reminders/reminder.py diff --git a/bot/modules/__init__.py b/bot/modules/__init__.py index fcedb483..2550fff9 100644 --- a/bot/modules/__init__.py +++ b/bot/modules/__init__.py @@ -6,4 +6,5 @@ from .study import * from .user_config import * from .workout import * from .todo import * +from .reminders import * # from .moderation import * diff --git a/bot/modules/reminders/__init__.py b/bot/modules/reminders/__init__.py index e69de29b..e5bebe91 100644 --- a/bot/modules/reminders/__init__.py +++ b/bot/modules/reminders/__init__.py @@ -0,0 +1,5 @@ +from .module import module + +from . import commands +from . import data +from . import reminder diff --git a/bot/modules/reminders/commands.py b/bot/modules/reminders/commands.py new file mode 100644 index 00000000..9252309e --- /dev/null +++ b/bot/modules/reminders/commands.py @@ -0,0 +1,253 @@ +import re +import asyncio +import datetime +import discord + +from utils.lib import parse_dur, parse_ranges, multiselect_regex + +from .module import module +from .data import reminders +from .reminder import Reminder + + +reminder_regex = re.compile( + r""" + (^)?(?P (?: \b in) | (?: every)) + \s*(?P (?: day| hour| (?:\d+\s*(?:(?:d|h|m|s)[a-zA-Z]*)?(?:\s|and)*)+)) + (?:(?(1) (?:, | ; | : | \. | to)? | $)) + """, + re.IGNORECASE | re.VERBOSE | re.DOTALL +) + +reminder_limit = 20 + + +@module.cmd( + name="remindme", + desc="Ask me to remind you about important tasks.", + group="Productivity", + aliases=('reminders', 'reminder'), + flags=('remove', 'clear') +) +async def cmd_remindme(ctx, flags): + """ + Usage``: + {prefix}remindme in to + {prefix}remindme every to + + {prefix}reminders + {prefix}reminders --clear + {prefix}reminders --remove + Description: + Ask LionBot to remind you about important tasks. + Examples``: + {prefix}remindme in 2h 20m, Revise chapter 1 + {prefix}remindme every hour, Drink water! + {prefix}remindme Anatomy class in 8h 20m + """ + # TODO: (FUTURE) every day at 9:00 + + if flags['remove']: + # Do removal stuff + rows = reminders.fetch_rows_where( + userid=ctx.author.id, + _extra="ORDER BY remind_at ASC" + ) + if not rows: + return await ctx.reply("You have no reminders to remove!") + + live = Reminder.fetch(*(row.reminderid for row in rows)) + + lines = [] + num_field = len(str(len(live) - 1)) + for i, reminder in enumerate(live): + lines.append( + "`[{:{}}]` | {}".format( + i, + num_field, + reminder.formatted + ) + ) + + description = '\n'.join(lines) + description += ( + "\n\nPlease select the reminders to remove, or type `c` to cancel.\n" + "(For example, respond with `1, 2, 3` or `1-3`.)" + ) + embed = discord.Embed( + description=description, + colour=discord.Colour.orange(), + timestamp=datetime.datetime.utcnow() + ).set_author( + name="Reminders for {}".format(ctx.author.display_name), + icon_url=ctx.author.avatar_url + ) + + out_msg = await ctx.reply(embed=embed) + + def check(msg): + valid = msg.channel == ctx.ch and msg.author == ctx.author + valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c') + return valid + + try: + message = await ctx.client.wait_for('message', check=check, timeout=60) + except asyncio.TimeoutError: + await out_msg.delete() + await ctx.error_reply("Session timed out. No reminders were deleted.") + return + + try: + await out_msg.delete() + await message.delete() + except discord.HTTPException: + pass + + if message.content.lower() == 'c': + return + + to_delete = [ + live[index].reminderid + for index in parse_ranges(message.content) if index < len(live) + ] + if not to_delete: + return await ctx.error_reply("Nothing to delete!") + + # Delete the selected reminders + Reminder.delete(*to_delete) + + # Ack + await ctx.embed_reply( + "{tick} Reminder{plural} deleted.".format( + tick='✅', + plural='s' if len(to_delete) > 1 else '' + ) + ) + elif flags['clear']: + # Do clear stuff + rows = reminders.fetch_rows_where( + userid=ctx.author.id, + ) + if not rows: + return await ctx.reply("You have no reminders to remove!") + + Reminder.delete(*(row.reminderid for row in rows)) + await ctx.embed_reply( + "{tick} Reminders cleared.".format( + tick='✅', + ) + ) + elif ctx.args: + # Add a new reminder + + content = None + duration = None + repeating = None + + # First parse it + match = re.search(reminder_regex, ctx.args) + if match: + repeating = match.group('type').lower() == 'every' + + duration_str = match.group('duration').lower() + if duration_str.isdigit(): + duration = int(duration_str) + elif duration_str == 'day': + duration = 24 * 60 * 60 + elif duration_str == 'hour': + duration = 60 * 60 + else: + duration = parse_dur(duration_str) + + content = (ctx.args[:match.start()] + ctx.args[match.end():]).strip() + if content.startswith('to '): + content = content[3:].strip() + else: + # Legacy parsing, without requiring "in" at the front + splits = ctx.args.split(maxsplit=1) + if len(splits) == 2 and splits[0].isdigit(): + repeating = False + duration = int(splits[0]) * 60 + content = splits[1].strip() + + # Sanity checking + if not duration or not content: + return await ctx.error_reply( + "Sorry, I didn't understand your reminder!\n" + "See `{prefix}help remindme` for usage and examples.".format(prefix=ctx.best_prefix) + ) + + # Don't allow rapid repeating reminders + if repeating and duration < 10 * 60: + return await ctx.error_reply( + "You can't have a repeating reminder shorter than `10` minutes!" + ) + + # Check the user doesn't have too many reminders already + count = reminders.select_one_where( + userid=ctx.author.id, + select_columns=("COUNT(*)",) + )[0] + if count > reminder_limit: + return await ctx.error_reply( + "Sorry, you have reached your maximum of `{}` reminders!".format(reminder_limit) + ) + + # Create reminder + reminder = Reminder.create( + userid=ctx.author.id, + content=content, + message_link=ctx.msg.jump_url, + interval=duration if repeating else None, + remind_at=datetime.datetime.utcnow() + datetime.timedelta(seconds=duration) + ) + + # Schedule reminder + reminder.schedule() + + # Ack + embed = discord.Embed( + title="Reminder Created!", + colour=discord.Colour.orange(), + description="Got it! I will remind you .".format(reminder.timestamp), + timestamp=datetime.datetime.utcnow() + ) + await ctx.reply(embed=embed) + elif ctx.alias.lower() == 'remindme': + # Show hints about adding reminders + ... + else: + # Show formatted list of reminders + rows = reminders.fetch_rows_where( + userid=ctx.author.id, + _extra="ORDER BY remind_at ASC" + ) + if not rows: + return await ctx.reply("You have no reminders!") + + live = Reminder.fetch(*(row.reminderid for row in rows)) + + lines = [] + num_field = len(str(len(live) - 1)) + for i, reminder in enumerate(live): + lines.append( + "`[{:{}}]` | {}".format( + i, + num_field, + reminder.formatted + ) + ) + + description = '\n'.join(lines) + embed = discord.Embed( + description=description, + colour=discord.Colour.orange(), + timestamp=datetime.datetime.utcnow() + ).set_author( + name="{}'s reminders".format(ctx.author.display_name), + icon_url=ctx.author.avatar_url + ).set_footer( + text="For examples and usage see {}help reminders".format(ctx.best_prefix) + ) + + await ctx.reply(embed=embed) diff --git a/bot/modules/reminders/data.py b/bot/modules/reminders/data.py new file mode 100644 index 00000000..3c57c6bb --- /dev/null +++ b/bot/modules/reminders/data.py @@ -0,0 +1,8 @@ +from data import RowTable + + +reminders = RowTable( + 'reminders', + ('reminderid', 'userid', 'remind_at', 'content', 'message_link', 'interval', 'created_at'), + 'reminderid' +) diff --git a/bot/modules/reminders/module.py b/bot/modules/reminders/module.py new file mode 100644 index 00000000..1609dcce --- /dev/null +++ b/bot/modules/reminders/module.py @@ -0,0 +1,4 @@ +from LionModule import LionModule + + +module = LionModule("Reminders") diff --git a/bot/modules/reminders/reminder.py b/bot/modules/reminders/reminder.py new file mode 100644 index 00000000..5725e608 --- /dev/null +++ b/bot/modules/reminders/reminder.py @@ -0,0 +1,168 @@ +import asyncio +import datetime +import discord + +from meta import client +from utils.lib import strfdur + +from .data import reminders +from .module import module + + +class Reminder: + __slots__ = ('reminderid', '_task') + + _live_reminders = {} # map reminderid -> Reminder + + def __init__(self, reminderid): + self.reminderid = reminderid + + self._task = None + + @classmethod + def create(cls, **kwargs): + row = reminders.create_row(**kwargs) + return cls(row.reminderid) + + @classmethod + def fetch(cls, *reminderids): + """ + Fetch an live reminders associated to the given reminderids. + """ + return [ + cls._live_reminders[reminderid] + for reminderid in reminderids + if reminderid in cls._live_reminders + ] + + @classmethod + def delete(cls, *reminderids): + """ + Cancel and delete the given reminders in an idempotent fashion. + """ + # Cancel the rmeinders + for reminderid in reminderids: + if reminderid in cls._live_reminders: + cls._live_reminders[reminderid].cancel() + + # Remove from data + reminders.delete_where(reminderid=reminderids) + + @property + def data(self): + return reminders.fetch(self.reminderid) + + @property + def timestamp(self): + """ + True unix timestamp for (next) reminder time. + """ + return int(self.data.remind_at.replace(tzinfo=datetime.timezone.utc).timestamp()) + + @property + def user(self): + """ + The discord.User that owns this reminder, if we can find them. + """ + return client.get_user(self.data.userid) + + @property + def formatted(self): + """ + Single-line string format for the reminder, intended for an embed. + """ + content = self.data.content + trunc_content = content[:50] + '...' * (len(content) > 50) + + if self.data.interval: + repeat = "(Every `{}`)".format(strfdur(self.data.interval)) + else: + repeat = "" + + return ", [{content}]({jump_link}) {repeat}".format( + jump_link=self.data.message_link, + content=trunc_content, + timestamp=self.timestamp, + repeat=repeat + ) + + def cancel(self): + """ + Cancel the live reminder waiting task, if it exists. + Does not remove the reminder from data. Use `Reminder.delete` for this. + """ + if self._task and not self._task.done(): + self._task.cancel() + self._live_reminders.pop(self.reminderid, None) + + def schedule(self): + """ + Schedule this reminder to be executed. + """ + asyncio.create_task(self._schedule()) + self._live_reminders[self.reminderid] = self + + async def _schedule(self): + """ + Execute this reminder after a sleep. + Accepts cancellation by aborting the scheduled execute. + """ + # Calculate time left + remaining = (self.data.remind_at - datetime.datetime.utcnow()).total_seconds() + + # Create the waiting task and wait for it, accepting cancellation + self._task = asyncio.create_task(asyncio.sleep(remaining)) + try: + await self._task + except asyncio.CancelledError: + return + await self._execute() + + async def _execute(self): + """ + Execute the reminder. + """ + # Build the message embed + embed = discord.Embed( + title="You asked me to remind you!", + colour=discord.Colour.orange(), + description=self.data.content, + timestamp=datetime.datetime.utcnow() + ) + embed.add_field(name="Context?", value="[Click here]({})".format(self.data.message_link)) + + if self.data.interval: + embed.add_field( + name="Next reminder", + value="".format( + self.timestamp + self.data.interval + ) + ) + + # Send the message, if possible + user = self.user + if user: + try: + await user.send(embed=embed) + except discord.HTTPException: + # Nothing we can really do here. Maybe tell the user about their reminder next time? + pass + + # Update the reminder data, and reschedule if required + if self.data.interval: + next_time = self.data.remind_at + datetime.timedelta(seconds=self.data.interval) + reminders.update_where({'remind_at': next_time}, reminderid=self.reminderid) + self.schedule() + else: + self.delete(self.reminderid) + + +@module.launch_task +async def schedule_reminders(client): + rows = reminders.fetch_rows_where() + for row in rows: + Reminder(row.reminderid).schedule() + client.log( + "Scheduled {} reminders.".format(len(rows)), + context="LAUNCH_REMINDERS" + ) diff --git a/data/schema.sql b/data/schema.sql index b9886df1..5d69ada5 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -107,6 +107,19 @@ CREATE TABLE tasklist_reward_history( CREATE INDEX tasklist_reward_history_members ON tasklist_reward_history (guildid, userid, reward_time); -- }}} +-- Reminder data {{{ +CREATE TABLE reminders( + reminderid SERIAL PRIMARY KEY, + userid BIGINT NOT NULL, + remind_at TIMESTAMP NOT NULL, + content TEXT NOT NULL, + message_link TEXT, + interval INTEGER, + created_at TIMESTAMP DEFAULT (now() at time zone 'utc') +); +CREATE INDEX reminder_users ON reminders (userid); +-- }}} + -- Study tracking data {{{ CREATE TABLE untracked_channels( guildid BIGINT NOT NULL,