reminder: setup plugin with proper asyncio

master
dece 9 months ago committed by Adrien Abraham
parent c6326d5ee0
commit 3af98b0270

@ -19,3 +19,4 @@ scaruffi = "==0.0.3"
wolframalpha = "*" wolframalpha = "*"
meteofrance-api = "~=1.0.2" meteofrance-api = "~=1.0.2"
openai = "~=0.25.0" openai = "~=0.25.0"
apscheduler = "~=3.9.0"

933
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

@ -164,6 +164,17 @@
"separator": "or", "separator": "or",
"not_enough": "Not enough choices!" "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": { "sleep": {
"commands": ["sleep", "wake up"], "commands": ["sleep", "wake up"],
"aliases": { "aliases": {

@ -1,3 +1,4 @@
import asyncio
import importlib import importlib
import json import json
import os import os
@ -9,13 +10,15 @@ from pathlib import Path
from typing import Any, Iterable, Optional from typing import Any, Iterable, Optional
import irc.client import irc.client
import irc.client_aio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from irc.client import Connection, Event, NickMask from irc.client import Connection, Event, NickMask
from edmond.log import Logger from edmond.log import Logger
from edmond.plugin import Plugin 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.""" """Main class for the IRC bot: handles connection and manages plugins."""
CHANNELS_RUNTIME_KEY = "_channels" CHANNELS_RUNTIME_KEY = "_channels"
@ -27,7 +30,10 @@ class Bot(irc.client.SimpleIRCClient, Logger):
self.plugins: list[Plugin] = [] self.plugins: list[Plugin] = []
self.values: dict[str, Any] = {} self.values: dict[str, Any] = {}
self.storage: dict[str, Any] = self.get_storage() self.storage: dict[str, Any] = self.get_storage()
self.tasks: list[asyncio.Task] = []
self.done: bool = False self.done: bool = False
self.scheduler = AsyncIOScheduler()
self.scheduler.start()
@property @property
def nick(self) -> str: def nick(self) -> str:
@ -72,6 +78,11 @@ class Bot(irc.client.SimpleIRCClient, Logger):
except OSError as exc: except OSError as exc:
self.log_e(f"Could not save storage file: {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): def on_welcome(self, connection: Connection, event: Event):
"""Handle a successful connection to a server.""" """Handle a successful connection to a server."""
self.log_i(f"Connected to server {event.source}.") 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.channels.append(event.target)
self.run_plugin_callbacks(event) self.run_plugin_callbacks(event)
async def flubiz(self):
self.say('#idi0crates', "acab")
def on_part(self, connection: Connection, event: Event): def on_part(self, connection: Connection, event: Event):
"""Handle someone, possibly the bot, leaving a channel.""" """Handle someone, possibly the bot, leaving a channel."""
if event.source.nick == self.nick: 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.""" """Connect the bot to server, join channels and start responding."""
self.log_i("Starting Edmond.") self.log_i("Starting Edmond.")
self.load_plugins() self.load_plugins()
self.log_i("Connecting to server.") self.log_i("Connecting to server")
self.connect(self.config["host"], self.config["port"], self.nick)
signal.signal(signal.SIGTERM, self.handle_sigterm) signal.signal(signal.SIGTERM, self.handle_sigterm)
try: try:
self.connect(self.config["host"], self.config["port"], self.nick)
self.start() self.start()
except irc.client.ServerConnectionError as exc:
self.log_c(f"Connection failed: {exc}")
except KeyboardInterrupt: except KeyboardInterrupt:
self.log_i("Caught keyboard interrupt.") self.log_i("Caught keyboard interrupt.")
except Exception as exc: except Exception as exc:
@ -135,7 +152,7 @@ class Bot(irc.client.SimpleIRCClient, Logger):
def load_plugins(self): def load_plugins(self):
"""Load all installed plugins.""" """Load all installed plugins."""
self.log_i("Loading plugins.") self.log_i("Loading plugins")
plugin_files = os.listdir(Path(__file__).parent / "plugins") plugin_files = os.listdir(Path(__file__).parent / "plugins")
plugin_names = map( plugin_names = map(
lambda f: os.path.splitext(f)[0], lambda f: os.path.splitext(f)[0],
@ -199,7 +216,13 @@ class Bot(irc.client.SimpleIRCClient, Logger):
if self.done: if self.done:
return return
self.log_i("Stopping Edmond.") 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(): if self.connection.is_connected():
self.connection.close() self.connection.close()
self.reactor.loop.close()
self.done = True self.done = True

@ -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)

@ -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…
Cancel
Save