(Reminders): Created reminders module and system.
This commit is contained in:
@@ -6,4 +6,5 @@ from .study import *
|
||||
from .user_config import *
|
||||
from .workout import *
|
||||
from .todo import *
|
||||
from .reminders 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);
|
||||
-- }}}
|
||||
|
||||
-- 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,
|
||||
|
||||
Reference in New Issue
Block a user