"Reminding 2" - reminders are now moderately more accurate
This commit is contained in:
parent
9c713a0980
commit
12df16c53b
|
@ -111,7 +111,7 @@ if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
loop.run_forever()
|
loop.run_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
loop.run_until_complete(bot.logout())
|
loop.run_until_complete(bot.close())
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
170
src/reminders.py
170
src/reminders.py
|
@ -2,17 +2,56 @@ import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import discord.ext.tasks as tasks
|
import discord.ext.tasks as tasks
|
||||||
|
from discord.ext import commands
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import util
|
import util
|
||||||
import metrics
|
import metrics
|
||||||
|
|
||||||
def setup(bot):
|
# https://github.com/python/cpython/blob/3.10/Lib/bisect.py (we need the 3.10 version)
|
||||||
@bot.command(brief="Set a reminder to be reminded about later.", rest_is_raw=True, help="""Sets a reminder which you will (probably) be reminded about at/after the specified time.
|
def bisect_left(a, x, lo=0, hi=None, *, key=None):
|
||||||
|
"""Return the index where to insert item x in list a, assuming a is sorted.
|
||||||
|
The return value i is such that all e in a[:i] have e < x, and all e in
|
||||||
|
a[i:] have e >= x. So if x already appears in the list, a.insert(i, x) will
|
||||||
|
insert just before the leftmost x already there.
|
||||||
|
Optional args lo (default 0) and hi (default len(a)) bound the
|
||||||
|
slice of a to be searched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if lo < 0:
|
||||||
|
raise ValueError('lo must be non-negative')
|
||||||
|
if hi is None:
|
||||||
|
hi = len(a)
|
||||||
|
# Note, the comparison uses "<" to match the
|
||||||
|
# __lt__() logic in list.sort() and in heapq.
|
||||||
|
if key is None:
|
||||||
|
while lo < hi:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
if a[mid] < x:
|
||||||
|
lo = mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
while lo < hi:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
if key(a[mid]) < x:
|
||||||
|
lo = mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
return lo
|
||||||
|
|
||||||
|
class Reminders(commands.Cog):
|
||||||
|
def __init__(self, bot):
|
||||||
|
self.bot = bot
|
||||||
|
self.reminder_queue = []
|
||||||
|
self.reminder_event = asyncio.Event()
|
||||||
|
|
||||||
|
@commands.command(brief="Set a reminder to be reminded about later.", rest_is_raw=True, help="""Sets a reminder which you will (probably) be reminded about at/after the specified time.
|
||||||
All times are UTC unless overridden.
|
All times are UTC unless overridden.
|
||||||
Reminders are checked every minute, so while precise times are not guaranteed, reminders should under normal conditions be received within 2 minutes of what you specify.
|
Thanks to new coding and algorithms, reminders are now not done at minute granularity. However, do not expect sub-5s granularity due to miscellaneous latency we have not optimized away.
|
||||||
Note that due to technical limitations reminders beyond the year 10000 CE or in the past cannot currently be handled.
|
Note that due to technical limitations reminders beyond the year 10000 CE or in the past cannot currently be handled.
|
||||||
Note that reminder delivery is not guaranteed, due to possible issues including but not limited to: data loss, me eventually not caring, the failure of Discord (in this case message delivery will still be attempted manually on a case-by-case basis), the collapse of human civilization, or other existential risks.""")
|
Note that reminder delivery is not guaranteed, due to possible issues including but not limited to: data loss, me eventually not caring, the failure of Discord (in this case message delivery will still be attempted manually on a case-by-case basis), the collapse of human civilization, or other existential risks.""")
|
||||||
async def remind(ctx, time, *, reminder):
|
async def remind(self, ctx, time, *, reminder):
|
||||||
reminder = reminder.strip()
|
reminder = reminder.strip()
|
||||||
if len(reminder) > 512:
|
if len(reminder) > 512:
|
||||||
await ctx.send(embed=util.error_embed("Maximum reminder length is 512 characters", "Foolish user error"))
|
await ctx.send(embed=util.error_embed("Maximum reminder length is 512 characters", "Foolish user error"))
|
||||||
|
@ -32,27 +71,34 @@ def setup(bot):
|
||||||
await ctx.send(embed=util.error_embed("Invalid time (wrong format/too large months or years)"))
|
await ctx.send(embed=util.error_embed("Invalid time (wrong format/too large months or years)"))
|
||||||
return
|
return
|
||||||
utc_time, local_time = util.in_timezone(time, tz)
|
utc_time, local_time = util.in_timezone(time, tz)
|
||||||
await bot.database.execute("INSERT INTO reminders (remind_timestamp, created_timestamp, reminder, expired, extra) VALUES (?, ?, ?, ?, ?)",
|
id = (await self.bot.database.execute_insert("INSERT INTO reminders (remind_timestamp, created_timestamp, reminder, expired, extra) VALUES (?, ?, ?, ?, ?)",
|
||||||
(utc_time.timestamp(), now.timestamp(), reminder, 0, util.json_encode(extra_data)))
|
(utc_time.timestamp(), now.timestamp(), reminder, 0, util.json_encode(extra_data))))["last_insert_rowid()"]
|
||||||
await bot.database.commit()
|
await self.bot.database.commit()
|
||||||
await ctx.send(f"Reminder scheduled for {util.format_time(local_time)} ({util.format_timedelta(now, utc_time)}).")
|
await ctx.send(f"Reminder scheduled for {util.format_time(local_time)} ({util.format_timedelta(now, utc_time)}).")
|
||||||
|
self.insert_reminder(id, utc_time.timestamp())
|
||||||
|
|
||||||
async def send_to_channel(info, text):
|
def insert_reminder(self, id, time):
|
||||||
channel = bot.get_channel(info["channel_id"])
|
pos = bisect_left(self.reminder_queue, time, key=lambda x: x[0])
|
||||||
|
self.reminder_queue.insert(0, (time, id))
|
||||||
|
if pos == 0:
|
||||||
|
self.reminder_event.set()
|
||||||
|
|
||||||
|
async def send_to_channel(self, info, text):
|
||||||
|
channel = self.bot.get_channel(info["channel_id"])
|
||||||
if not channel: raise Exception(f"channel {info['channel_id']} unavailable/nonexistent")
|
if not channel: raise Exception(f"channel {info['channel_id']} unavailable/nonexistent")
|
||||||
await channel.send(text)
|
await channel.send(text)
|
||||||
|
|
||||||
async def send_by_dm(info, text):
|
async def send_by_dm(self, info, text):
|
||||||
user = bot.get_user(info["author_id"])
|
user = self.bot.get_user(info["author_id"])
|
||||||
if not user:
|
if not user:
|
||||||
user = await bot.fetch_user(info["author_id"])
|
user = await bot.fetch_user(info["author_id"])
|
||||||
if not user: raise Exception(f"user {info['author_id']} unavailable/nonexistent")
|
if not user: raise Exception(f"user {info['author_id']} unavailable/nonexistent")
|
||||||
if not user.dm_channel: await user.create_dm()
|
if not user.dm_channel: await user.create_dm()
|
||||||
await user.dm_channel.send(text)
|
await user.dm_channel.send(text)
|
||||||
|
|
||||||
async def send_to_guild(info, text):
|
async def send_to_guild(self, info, text):
|
||||||
if not "guild_id" in info: raise Exception("Guild unknown")
|
if not "guild_id" in info: raise Exception("Guild unknown")
|
||||||
guild = bot.get_guild(info["guild_id"])
|
guild = self.bot.get_guild(info["guild_id"])
|
||||||
member = guild.get_member(info["author_id"])
|
member = guild.get_member(info["author_id"])
|
||||||
self = guild.get_member(bot.user.id)
|
self = guild.get_member(bot.user.id)
|
||||||
# if member is here, find a channel they can read and the bot can send in
|
# if member is here, find a channel they can read and the bot can send in
|
||||||
|
@ -68,53 +114,67 @@ def setup(bot):
|
||||||
return
|
return
|
||||||
raise Exception(f"guild {info['author_id']} has no (valid) channels")
|
raise Exception(f"guild {info['author_id']} has no (valid) channels")
|
||||||
|
|
||||||
remind_send_methods = [
|
async def fire_reminder(self, id):
|
||||||
("original channel", send_to_channel),
|
remind_send_methods = [
|
||||||
("direct message", send_by_dm),
|
("original channel", self.send_to_channel),
|
||||||
("originating guild", send_to_guild)
|
("direct message", self.send_by_dm),
|
||||||
]
|
("originating guild", self.send_to_guild)
|
||||||
|
]
|
||||||
@tasks.loop(seconds=60)
|
row = await self.bot.database.execute_fetchone("SELECT * FROM reminders WHERE id = ?", (id,))
|
||||||
async def remind_worker():
|
|
||||||
csr = bot.database.execute("SELECT * FROM reminders WHERE expired = 0 AND remind_timestamp < ?", (util.timestamp(),))
|
|
||||||
to_expire = []
|
to_expire = []
|
||||||
async with csr as cursor:
|
rid, remind_timestamp, created_timestamp, reminder_text, _, extra = row
|
||||||
async for row in cursor:
|
try:
|
||||||
rid, remind_timestamp, created_timestamp, reminder_text, _, extra = row
|
remind_timestamp = datetime.utcfromtimestamp(remind_timestamp)
|
||||||
try:
|
created_timestamp = datetime.utcfromtimestamp(created_timestamp).replace(tzinfo=timezone.utc)
|
||||||
remind_timestamp = datetime.utcfromtimestamp(remind_timestamp)
|
extra = json.loads(extra)
|
||||||
created_timestamp = datetime.utcfromtimestamp(created_timestamp).replace(tzinfo=timezone.utc)
|
uid = extra["author_id"]
|
||||||
extra = json.loads(extra)
|
tz = await util.get_user_timezone(util.AltCtx(util.IDWrapper(uid), util.IDWrapper(extra.get("guild_id")), self.bot))
|
||||||
uid = extra["author_id"]
|
created_time = util.format_time(created_timestamp.astimezone(tz))
|
||||||
tz = await util.get_user_timezone(util.AltCtx(util.IDWrapper(uid), util.IDWrapper(extra.get("guild_id")), bot))
|
text = f"<@{uid}> Reminder queued at {created_time}: {reminder_text}"
|
||||||
print(created_timestamp, tz, created_timestamp.astimezone(tz))
|
|
||||||
created_time = util.format_time(created_timestamp.astimezone(tz))
|
|
||||||
text = f"<@{uid}> Reminder queued at {created_time}: {reminder_text}"
|
|
||||||
|
|
||||||
for method_name, func in remind_send_methods:
|
for method_name, func in remind_send_methods:
|
||||||
print("trying", method_name, rid)
|
try:
|
||||||
try:
|
await func(extra, text)
|
||||||
await func(extra, text)
|
metrics.reminders_fired.inc()
|
||||||
metrics.reminders_fired.inc()
|
to_expire.append((1, rid)) # 1 = expired normally
|
||||||
to_expire.append((1, rid)) # 1 = expired normally
|
break
|
||||||
break
|
except Exception as e: logging.warning("Failed to send %d to %s", rid, method_name, exc_info=e)
|
||||||
except Exception as e: logging.warning("Failed to send %d to %s", rid, method_name, exc_info=e)
|
except Exception as e:
|
||||||
except Exception as e:
|
logging.warning("Could not send reminder %d", rid, exc_info=e)
|
||||||
logging.warning("Could not send reminder %d", rid, exc_info=e)
|
#to_expire.append((2, rid)) # 2 = errored
|
||||||
#to_expire.append((2, rid)) # 2 = errored
|
|
||||||
for expiry_type, expiry_id in to_expire:
|
for expiry_type, expiry_id in to_expire:
|
||||||
logging.info("Expiring reminder %d", expiry_id)
|
logging.info("Expiring reminder %d", expiry_id)
|
||||||
await bot.database.execute("UPDATE reminders SET expired = ? WHERE id = ?", (expiry_type, expiry_id))
|
await self.bot.database.execute("UPDATE reminders SET expired = ? WHERE id = ?", (expiry_type, expiry_id))
|
||||||
await bot.database.commit()
|
await self.bot.database.commit()
|
||||||
|
|
||||||
@remind_worker.before_loop
|
async def init_reminders(self):
|
||||||
async def before_remind_worker():
|
reminders = await self.bot.database.execute_fetchall("SELECT * FROM reminders WHERE expired = 0 AND remind_timestamp < ?", (util.timestamp(),))
|
||||||
logging.info("Waiting for bot readiness...")
|
for reminder in reminders:
|
||||||
await bot.wait_until_ready()
|
self.insert_reminder(reminder["id"], reminder["remind_timestamp"])
|
||||||
logging.info("Remind worker starting")
|
logging.info("Loaded %d reminders", len(reminders))
|
||||||
|
self.rloop_task = await self.reminder_loop()
|
||||||
|
|
||||||
remind_worker.start()
|
async def reminder_loop(self):
|
||||||
bot.remind_worker = remind_worker
|
await self.bot.wait_until_ready()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
next_time, next_id = self.reminder_queue[0]
|
||||||
|
except IndexError:
|
||||||
|
await self.reminder_event.wait()
|
||||||
|
self.reminder_event.clear()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.reminder_event.wait(), next_time - util.timestamp())
|
||||||
|
self.reminder_event.clear()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.reminder_event.clear()
|
||||||
|
self.reminder_queue.pop(0)
|
||||||
|
await self.fire_reminder(next_id)
|
||||||
|
|
||||||
|
def setup(bot):
|
||||||
|
cog = Reminders(bot)
|
||||||
|
asyncio.create_task(cog.init_reminders())
|
||||||
|
bot.add_cog(cog)
|
||||||
|
|
||||||
def teardown(bot):
|
def teardown(bot):
|
||||||
bot.remind_worker.cancel()
|
bot.rloop_task.cancel()
|
Loading…
Reference in New Issue