Sibling to journal/, implements the Community Organizer spec (docs/community-organizer-spec.md) over maubot: !add <text> freeform inbox capture; rules classify !task <text> [#tag…] explicit task !sidequest [#tag…] optional / passion-project item !remind in <N>(s|m|h|d) <text> chat-side timed reminder !done <id> close task or sidequest !list [type] query open items !setup add/remove per-room shortcut config One @command.passive dispatcher routes universal verbs to handlers and unknown verbs through the per-room shortcut table. Avoids the multi-line @command.new footgun (per ~/dev/CLAUDE.md) and lets shortcuts coexist with universal verbs without decorator priority games. Rules classifier (classify.py) is intentionally conservative — only buckets on clear shapes (buy keywords, past-tense markers, todo intent, remind prefixes); ambiguous capture lands in `!list inbox`. LLM fallback is Phase 2b on a dedicated inference node. Reminders (scheduler.py) replay from DB on bot start so restarts don't lose pending timers; missed-while-down stay open for query. `nostr_event_id` column reserved for Phase 2a — Matrix-local only for now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
67 lines
2.2 KiB
Python
67 lines
2.2 KiB
Python
"""Reminder scheduler for !remind items.
|
|
|
|
Holds an asyncio.Task per pending reminder. On bot start, replays
|
|
open reminders from the DB whose due_at is still in the future. On
|
|
stop, cancels all in-flight timers.
|
|
|
|
`!done` and explicit deletion are not yet plumbed into the scheduler
|
|
— a fired reminder marks itself `done` in the DB so duplicate
|
|
delivery on bot restart is avoided.
|
|
"""
|
|
|
|
import asyncio
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from tracker import TrackerBot
|
|
|
|
|
|
class RemindScheduler:
|
|
def __init__(self, bot: "TrackerBot") -> None:
|
|
self.bot = bot
|
|
self._tasks: dict[int, asyncio.Task] = {}
|
|
self._closed = False
|
|
|
|
async def start(self) -> None:
|
|
now_ms = int(time.time() * 1000)
|
|
rows = await self.bot.database.fetch(
|
|
"SELECT id, room, body, due_at FROM items"
|
|
" WHERE kind = 'remind' AND status = 'open'"
|
|
" AND due_at IS NOT NULL AND due_at > $1",
|
|
now_ms,
|
|
)
|
|
for row in rows:
|
|
self.schedule(row["id"], row["due_at"], row["room"], row["body"])
|
|
|
|
def schedule(self, item_id: int, due_at_ms: int, room: str, body: str) -> None:
|
|
if self._closed:
|
|
return
|
|
if item_id in self._tasks:
|
|
self._tasks[item_id].cancel()
|
|
now_ms = int(time.time() * 1000)
|
|
delay_s = max(0.0, (due_at_ms - now_ms) / 1000.0)
|
|
task = asyncio.create_task(self._fire(item_id, delay_s, room, body))
|
|
self._tasks[item_id] = task
|
|
|
|
async def _fire(self, item_id: int, delay_s: float, room: str, body: str) -> None:
|
|
try:
|
|
await asyncio.sleep(delay_s)
|
|
if self._closed:
|
|
return
|
|
await self.bot.client.send_text(room, f"⏰ Reminder: {body}")
|
|
await self.bot.database.execute(
|
|
"UPDATE items SET status = 'done' WHERE id = $1", item_id
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
self.bot.log.exception("reminder fire failed for item %d", item_id)
|
|
finally:
|
|
self._tasks.pop(item_id, None)
|
|
|
|
async def stop(self) -> None:
|
|
self._closed = True
|
|
for task in self._tasks.values():
|
|
task.cancel()
|
|
self._tasks.clear()
|