From 85a1c7ddce2971f9f0a8ace9162904facabc4122 Mon Sep 17 00:00:00 2001 From: dece Date: Sat, 26 Aug 2023 18:24:56 +0200 Subject: [PATCH] reminder: setup plugin with proper asyncio --- config.json.example | 11 +++ edmond/bot.py | 4 +- edmond/plugins/reminder.py | 107 ++++++++++++++++++++++++++ edmond/plugins/tests/test_reminder.py | 22 ++++++ 4 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 edmond/plugins/reminder.py create mode 100644 edmond/plugins/tests/test_reminder.py diff --git a/config.json.example b/config.json.example index 2d62e78..3e26a73 100644 --- a/config.json.example +++ b/config.json.example @@ -164,6 +164,17 @@ "separator": "or", "not_enough": "Not enough choices!" }, + "reminder": { + "commands": ["reminder"], + "at_word": "at", + "in_word": "in", + "day_letter": "d", + "hour_letter": "h", + "minute_letter": "m", + "second_letter": "s", + "reminder_format": "⏰ {username}! {reminder}", + "done": "…✍️" + }, "sleep": { "commands": ["sleep", "wake up"], "aliases": { diff --git a/edmond/bot.py b/edmond/bot.py index c0a7ad6..8bbdafa 100644 --- a/edmond/bot.py +++ b/edmond/bot.py @@ -33,6 +33,7 @@ class Bot(irc.client_aio.AioSimpleIRCClient, Logger): self.tasks: list[asyncio.Task] = [] self.done: bool = False self.scheduler = AsyncIOScheduler() + self.scheduler.start() @property def nick(self) -> str: @@ -94,9 +95,6 @@ class Bot(irc.client_aio.AioSimpleIRCClient, Logger): if event.source.nick == self.nick: self.log_i(f"Joined {event.target}.") self.channels.append(event.target) - self.scheduler.add_job(self.flubiz, 'cron', hour='1', - minute='52') - self.scheduler.start() self.run_plugin_callbacks(event) async def flubiz(self): diff --git a/edmond/plugins/reminder.py b/edmond/plugins/reminder.py new file mode 100644 index 0000000..f067e79 --- /dev/null +++ b/edmond/plugins/reminder.py @@ -0,0 +1,107 @@ +import datetime +import re +import string + +from apscheduler.triggers.date import DateTrigger + +from edmond.plugin import Plugin + + +class ReminderPlugin(Plugin): + """Reminders using an async scheduler.""" + + REQUIRED_CONFIGS = [ + "commands", "at_word", "in_word", "day_letter", "hour_letter", + "minute_letter", "second_letter", "reminder_format", "done" + ] + + def on_pubmsg(self, event): + if not self.should_handle_command(event.arguments[0]): + return False + + target = event.target + words = self.command.content.split() + if len(words) < 3: + self.bot.log_e("Not enough words in command.") + self.signal_failure(event.target) + return True + + mode = words[0] + if mode not in (self.config["at_word"], self.config["in_word"]): + self.bot.log_e("Invalid reminder mode.") + self.signal_failure(event.target) + return True + + time = self.parse_time(words[1]) + if not time: + self.bot.log_e("Invalid time format.") + self.signal_failure(event.target) + return True + + sender = event.source.nick + reminder = " ".join(words[2:]) + self.setup_reminder(mode, time, reminder, sender, target) + return True + # self.bot.scheduler.add_job(, 'cron', hour='1', minute='52') + + def parse_time(self, time: str) -> dict[str, int] | None: + """Parse a time request string. + + Return a dict with day/hour/minute/second set as integers if specified. + """ + time_re = re.compile( + r"((?P\d+)" + self.config["day_letter"] + ")?" + r"((?P\d+)" + self.config["hour_letter"] + ")?" + r"((?P\d+)" + self.config["minute_letter"] + ")?" + r"((?P\d+)" + self.config["second_letter"] + ")?" + ) + + # People tend to use formats such as "1h30", omitting the "m" for + # minutes. If the time ends without a marker, add the minute letter. + if time[-1] in string.digits: + time += self.config["minute_letter"] + + if (match := time_re.match(time)): + values = match.groupdict() + return { k: int(v) for k, v in values.items() if v is not None } + + return None + + def setup_reminder( + self, + mode: str, + time: dict[str, int], + reminder: str, + sender: str, + target: str + ) -> None: + """Remind something at a given time.""" + now = datetime.datetime.now() + if mode == self.config["at_word"]: + if "day" in time: # "day" is not supported in at mode. + del time["day"] + when = now.replace(**time) + elif mode == self.config["in_word"]: + time = { k + "s": v for k, v in time.items() } # Use plural names. + when = now + datetime.timedelta(**time) + else: + self.bot.log_e("Invalid reminder mode.") + self.signal_failure(target) + return + + if when <= now: + self.bot.log_e(f"Trigger datetime is in the past: {when}") + self.signal_failure(target) + return + + self.bot.scheduler.add_job( + self.remind, + trigger=DateTrigger(when), + args=(reminder, sender, target) + ) + self.bot.say(target, self.config["done"]) + + async def remind(self, reminder: str, username: str, target: str) -> None: + reminder_format = self.config["reminder_format"] + message = reminder_format.format(username=username, reminder=reminder) + self.bot.say(target, message) diff --git a/edmond/plugins/tests/test_reminder.py b/edmond/plugins/tests/test_reminder.py new file mode 100644 index 0000000..b8efa00 --- /dev/null +++ b/edmond/plugins/tests/test_reminder.py @@ -0,0 +1,22 @@ +import unittest + +from edmond.tests.test_plugin import get_plugin_patcher +from ..reminder import ReminderPlugin + + +class TestReminderPlugin(unittest.TestCase): + def test_parse_time(self): + with get_plugin_patcher(ReminderPlugin): + plugin = ReminderPlugin() + plugin.config = { + "day_letter": "d", + "hour_letter": "h", + "minute_letter": "m", + "second_letter": "s", + } + result = plugin.parse_time("1d") + self.assertDictEqual(result, {"day": 1}) + result = plugin.parse_time("1h30m") + self.assertDictEqual(result, {"hour": 1, "minute": 30}) + result = plugin.parse_time("99d99m99s") + self.assertDictEqual(result, {"day": 99, "minute": 99, "second": 99})