reminder: setup plugin with proper asyncio
This commit is contained in:
parent
c6326d5ee0
commit
3af98b0270
1
Pipfile
1
Pipfile
|
@ -19,3 +19,4 @@ scaruffi = "==0.0.3"
|
|||
wolframalpha = "*"
|
||||
meteofrance-api = "~=1.0.2"
|
||||
openai = "~=0.25.0"
|
||||
apscheduler = "~=3.9.0"
|
||||
|
|
917
Pipfile.lock
generated
917
Pipfile.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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": {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
|
@ -9,13 +10,15 @@ from pathlib import Path
|
|||
from typing import Any, Iterable, Optional
|
||||
|
||||
import irc.client
|
||||
import irc.client_aio
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from irc.client import Connection, Event, NickMask
|
||||
|
||||
from edmond.log import Logger
|
||||
from edmond.plugin import Plugin
|
||||
|
||||
|
||||
class Bot(irc.client.SimpleIRCClient, Logger):
|
||||
class Bot(irc.client_aio.AioSimpleIRCClient, Logger):
|
||||
"""Main class for the IRC bot: handles connection and manages plugins."""
|
||||
|
||||
CHANNELS_RUNTIME_KEY = "_channels"
|
||||
|
@ -27,7 +30,10 @@ class Bot(irc.client.SimpleIRCClient, Logger):
|
|||
self.plugins: list[Plugin] = []
|
||||
self.values: dict[str, Any] = {}
|
||||
self.storage: dict[str, Any] = self.get_storage()
|
||||
self.tasks: list[asyncio.Task] = []
|
||||
self.done: bool = False
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
self.scheduler.start()
|
||||
|
||||
@property
|
||||
def nick(self) -> str:
|
||||
|
@ -72,6 +78,11 @@ class Bot(irc.client.SimpleIRCClient, Logger):
|
|||
except OSError as exc:
|
||||
self.log_e(f"Could not save storage file: {exc}")
|
||||
|
||||
def handle_task(self, coro):
|
||||
"""Schedule a task in the event loop. Keep a reference to cancel it."""
|
||||
task = self.connection.reactor.loop.create_task(coro)
|
||||
self.tasks.append(task)
|
||||
|
||||
def on_welcome(self, connection: Connection, event: Event):
|
||||
"""Handle a successful connection to a server."""
|
||||
self.log_i(f"Connected to server {event.source}.")
|
||||
|
@ -86,6 +97,9 @@ class Bot(irc.client.SimpleIRCClient, Logger):
|
|||
self.channels.append(event.target)
|
||||
self.run_plugin_callbacks(event)
|
||||
|
||||
async def flubiz(self):
|
||||
self.say('#idi0crates', "acab")
|
||||
|
||||
def on_part(self, connection: Connection, event: Event):
|
||||
"""Handle someone, possibly the bot, leaving a channel."""
|
||||
if event.source.nick == self.nick:
|
||||
|
@ -118,11 +132,14 @@ class Bot(irc.client.SimpleIRCClient, Logger):
|
|||
"""Connect the bot to server, join channels and start responding."""
|
||||
self.log_i("Starting Edmond.")
|
||||
self.load_plugins()
|
||||
self.log_i("Connecting to server.")
|
||||
self.connect(self.config["host"], self.config["port"], self.nick)
|
||||
self.log_i("Connecting to server…")
|
||||
|
||||
signal.signal(signal.SIGTERM, self.handle_sigterm)
|
||||
try:
|
||||
self.connect(self.config["host"], self.config["port"], self.nick)
|
||||
self.start()
|
||||
except irc.client.ServerConnectionError as exc:
|
||||
self.log_c(f"Connection failed: {exc}")
|
||||
except KeyboardInterrupt:
|
||||
self.log_i("Caught keyboard interrupt.")
|
||||
except Exception as exc:
|
||||
|
@ -135,7 +152,7 @@ class Bot(irc.client.SimpleIRCClient, Logger):
|
|||
|
||||
def load_plugins(self):
|
||||
"""Load all installed plugins."""
|
||||
self.log_i("Loading plugins.")
|
||||
self.log_i("Loading plugins…")
|
||||
plugin_files = os.listdir(Path(__file__).parent / "plugins")
|
||||
plugin_names = map(
|
||||
lambda f: os.path.splitext(f)[0],
|
||||
|
@ -199,7 +216,13 @@ class Bot(irc.client.SimpleIRCClient, Logger):
|
|||
if self.done:
|
||||
return
|
||||
self.log_i("Stopping Edmond.")
|
||||
self.save_storage()
|
||||
self.save_storage() # FIRST THINGS FIRST
|
||||
|
||||
for task in self.tasks:
|
||||
if not task.cancelled():
|
||||
self.log_d(f"Cancelling task {task.get_name()}…")
|
||||
task.cancel()
|
||||
if self.connection.is_connected():
|
||||
self.connection.close()
|
||||
self.reactor.loop.close()
|
||||
self.done = True
|
||||
|
|
107
edmond/plugins/reminder.py
Normal file
107
edmond/plugins/reminder.py
Normal file
|
@ -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<day>\d+)" + self.config["day_letter"] + ")?"
|
||||
r"((?P<hour>\d+)" + self.config["hour_letter"] + ")?"
|
||||
r"((?P<minute>\d+)" + self.config["minute_letter"] + ")?"
|
||||
r"((?P<second>\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)
|
22
edmond/plugins/tests/test_reminder.py
Normal file
22
edmond/plugins/tests/test_reminder.py
Normal file
|
@ -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})
|
Loading…
Reference in a new issue