maubot-plugins/tracker/scheduler.py
Padreug b7a096a77a feat(tracker): Phase 1 plugin — Matrix + SQLite, rules-only
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>
2026-05-24 15:48:21 +02:00

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