From 12df16c53b45cd83075eabfe067789529822e9bb Mon Sep 17 00:00:00 2001 From: osmarks Date: Tue, 23 Nov 2021 22:22:50 +0000 Subject: [PATCH] "Reminding 2" - reminders are now moderately more accurate --- src/main.py | 2 +- src/reminders.py | 170 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 116 insertions(+), 56 deletions(-) diff --git a/src/main.py b/src/main.py index 31bf082..4a3ec98 100644 --- a/src/main.py +++ b/src/main.py @@ -111,7 +111,7 @@ if __name__ == "__main__": try: loop.run_forever() except KeyboardInterrupt: - loop.run_until_complete(bot.logout()) + loop.run_until_complete(bot.close()) sys.exit(0) finally: loop.close() diff --git a/src/reminders.py b/src/reminders.py index 91c01b7..a1fc387 100644 --- a/src/reminders.py +++ b/src/reminders.py @@ -2,17 +2,56 @@ import json import logging from datetime import datetime, timezone import discord.ext.tasks as tasks +from discord.ext import commands +import asyncio import util import metrics -def setup(bot): - @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. +# https://github.com/python/cpython/blob/3.10/Lib/bisect.py (we need the 3.10 version) +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. - 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 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() if len(reminder) > 512: 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)")) return utc_time, local_time = util.in_timezone(time, tz) - await bot.database.execute("INSERT INTO reminders (remind_timestamp, created_timestamp, reminder, expired, extra) VALUES (?, ?, ?, ?, ?)", - (utc_time.timestamp(), now.timestamp(), reminder, 0, util.json_encode(extra_data))) - await bot.database.commit() + 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))))["last_insert_rowid()"] + await self.bot.database.commit() 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): - channel = bot.get_channel(info["channel_id"]) + def insert_reminder(self, id, time): + 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") await channel.send(text) - async def send_by_dm(info, text): - user = bot.get_user(info["author_id"]) + async def send_by_dm(self, info, text): + user = self.bot.get_user(info["author_id"]) if not user: user = await bot.fetch_user(info["author_id"]) if not user: raise Exception(f"user {info['author_id']} unavailable/nonexistent") if not user.dm_channel: await user.create_dm() 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") - guild = bot.get_guild(info["guild_id"]) + guild = self.bot.get_guild(info["guild_id"]) member = guild.get_member(info["author_id"]) self = guild.get_member(bot.user.id) # if member is here, find a channel they can read and the bot can send in @@ -68,53 +114,67 @@ def setup(bot): return raise Exception(f"guild {info['author_id']} has no (valid) channels") - remind_send_methods = [ - ("original channel", send_to_channel), - ("direct message", send_by_dm), - ("originating guild", send_to_guild) - ] - - @tasks.loop(seconds=60) - async def remind_worker(): - csr = bot.database.execute("SELECT * FROM reminders WHERE expired = 0 AND remind_timestamp < ?", (util.timestamp(),)) + async def fire_reminder(self, id): + remind_send_methods = [ + ("original channel", self.send_to_channel), + ("direct message", self.send_by_dm), + ("originating guild", self.send_to_guild) + ] + row = await self.bot.database.execute_fetchone("SELECT * FROM reminders WHERE id = ?", (id,)) to_expire = [] - async with csr as cursor: - async for row in cursor: - rid, remind_timestamp, created_timestamp, reminder_text, _, extra = row - try: - remind_timestamp = datetime.utcfromtimestamp(remind_timestamp) - created_timestamp = datetime.utcfromtimestamp(created_timestamp).replace(tzinfo=timezone.utc) - extra = json.loads(extra) - uid = extra["author_id"] - tz = await util.get_user_timezone(util.AltCtx(util.IDWrapper(uid), util.IDWrapper(extra.get("guild_id")), bot)) - 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}" + rid, remind_timestamp, created_timestamp, reminder_text, _, extra = row + try: + remind_timestamp = datetime.utcfromtimestamp(remind_timestamp) + created_timestamp = datetime.utcfromtimestamp(created_timestamp).replace(tzinfo=timezone.utc) + extra = json.loads(extra) + uid = extra["author_id"] + tz = await util.get_user_timezone(util.AltCtx(util.IDWrapper(uid), util.IDWrapper(extra.get("guild_id")), self.bot)) + 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: - print("trying", method_name, rid) - try: - await func(extra, text) - metrics.reminders_fired.inc() - to_expire.append((1, rid)) # 1 = expired normally - 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("Could not send reminder %d", rid, exc_info=e) - #to_expire.append((2, rid)) # 2 = errored + for method_name, func in remind_send_methods: + try: + await func(extra, text) + metrics.reminders_fired.inc() + to_expire.append((1, rid)) # 1 = expired normally + 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("Could not send reminder %d", rid, exc_info=e) + #to_expire.append((2, rid)) # 2 = errored for expiry_type, expiry_id in to_expire: logging.info("Expiring reminder %d", expiry_id) - await bot.database.execute("UPDATE reminders SET expired = ? WHERE id = ?", (expiry_type, expiry_id)) - await bot.database.commit() + await self.bot.database.execute("UPDATE reminders SET expired = ? WHERE id = ?", (expiry_type, expiry_id)) + await self.bot.database.commit() - @remind_worker.before_loop - async def before_remind_worker(): - logging.info("Waiting for bot readiness...") - await bot.wait_until_ready() - logging.info("Remind worker starting") + async def init_reminders(self): + reminders = await self.bot.database.execute_fetchall("SELECT * FROM reminders WHERE expired = 0 AND remind_timestamp < ?", (util.timestamp(),)) + for reminder in reminders: + self.insert_reminder(reminder["id"], reminder["remind_timestamp"]) + logging.info("Loaded %d reminders", len(reminders)) + self.rloop_task = await self.reminder_loop() - remind_worker.start() - bot.remind_worker = remind_worker + async def reminder_loop(self): + 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): - bot.remind_worker.cancel() \ No newline at end of file + bot.rloop_task.cancel() \ No newline at end of file