(Reminders): Created reminders module and system.

This commit is contained in:
2021-09-15 19:58:32 +03:00
parent bac72194a3
commit 2acef999c5
7 changed files with 452 additions and 0 deletions

View File

@@ -6,4 +6,5 @@ from .study import *
from .user_config import *
from .workout import *
from .todo import *
from .reminders import *
# from .moderation import *

View File

@@ -0,0 +1,5 @@
from .module import module
from . import commands
from . import data
from . import reminder

View File

@@ -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<type> (?: \b in) | (?: every))
\s*(?P<duration> (?: 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 <duration> to <task>
{prefix}remindme every <duration> to <task>
{prefix}reminders
{prefix}reminders --clear
{prefix}reminders --remove <ids>
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 <t:{}:R>.".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)

View File

@@ -0,0 +1,8 @@
from data import RowTable
reminders = RowTable(
'reminders',
('reminderid', 'userid', 'remind_at', 'content', 'message_link', 'interval', 'created_at'),
'reminderid'
)

View File

@@ -0,0 +1,4 @@
from LionModule import LionModule
module = LionModule("Reminders")

View File

@@ -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 "<t:{timestamp}:R>, [{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="<t:{}:R>".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"
)

View File

@@ -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,