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>
This commit is contained in:
parent
774cb44a4a
commit
b7a096a77a
6 changed files with 693 additions and 0 deletions
67
tracker/scheduler.py
Normal file
67
tracker/scheduler.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue