(Reminders): Created reminders module and system.
This commit is contained in:
@@ -6,4 +6,5 @@ from .study import *
|
|||||||
from .user_config import *
|
from .user_config import *
|
||||||
from .workout import *
|
from .workout import *
|
||||||
from .todo import *
|
from .todo import *
|
||||||
|
from .reminders import *
|
||||||
# from .moderation import *
|
# from .moderation import *
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .module import module
|
||||||
|
|
||||||
|
from . import commands
|
||||||
|
from . import data
|
||||||
|
from . import reminder
|
||||||
|
|||||||
253
bot/modules/reminders/commands.py
Normal file
253
bot/modules/reminders/commands.py
Normal 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)
|
||||||
8
bot/modules/reminders/data.py
Normal file
8
bot/modules/reminders/data.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from data import RowTable
|
||||||
|
|
||||||
|
|
||||||
|
reminders = RowTable(
|
||||||
|
'reminders',
|
||||||
|
('reminderid', 'userid', 'remind_at', 'content', 'message_link', 'interval', 'created_at'),
|
||||||
|
'reminderid'
|
||||||
|
)
|
||||||
4
bot/modules/reminders/module.py
Normal file
4
bot/modules/reminders/module.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from LionModule import LionModule
|
||||||
|
|
||||||
|
|
||||||
|
module = LionModule("Reminders")
|
||||||
168
bot/modules/reminders/reminder.py
Normal file
168
bot/modules/reminders/reminder.py
Normal 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"
|
||||||
|
)
|
||||||
@@ -107,6 +107,19 @@ CREATE TABLE tasklist_reward_history(
|
|||||||
CREATE INDEX tasklist_reward_history_members ON tasklist_reward_history (guildid, userid, reward_time);
|
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 {{{
|
-- Study tracking data {{{
|
||||||
CREATE TABLE untracked_channels(
|
CREATE TABLE untracked_channels(
|
||||||
guildid BIGINT NOT NULL,
|
guildid BIGINT NOT NULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user