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
|
|
@ -10,6 +10,7 @@ castle hosts; the actual plugin code lives here.
|
||||||
| Plugin | Purpose |
|
| Plugin | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`journal/`](./journal/) | Farm-journal bot. `!journal <text>` records what you did, scoped per-user/room/timestamp. `!journal show [@user]` and `!journal today` query back. |
|
| [`journal/`](./journal/) | Farm-journal bot. `!journal <text>` records what you did, scoped per-user/room/timestamp. `!journal show [@user]` and `!journal today` query back. |
|
||||||
|
| [`tracker/`](./tracker/) | Community-organizer bot. `!add` / `!task` / `!sidequest` / `!remind` / `!done` / `!list` / `!setup`. Implements the [Community Organizer spec](./docs/community-organizer-spec.md) — per-room shortcuts, 5-level priority, rules-based inbox classifier. |
|
||||||
|
|
||||||
## Community Organizer protocol
|
## Community Organizer protocol
|
||||||
|
|
||||||
|
|
|
||||||
176
tracker/README.md
Normal file
176
tracker/README.md
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
# tracker
|
||||||
|
|
||||||
|
Community-organizer bot for Matrix. Implements the
|
||||||
|
[Community Organizer spec](../docs/community-organizer-spec.md) over
|
||||||
|
maubot — capture/list/close tasks, sidequests, reminders, and freeform
|
||||||
|
inbox entries scoped per room.
|
||||||
|
|
||||||
|
Sibling to [`journal/`](../journal/) (which owns `!journal`); the two
|
||||||
|
work side-by-side on the same maubot instance if you want both.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```
|
||||||
|
!add door handles from Laura # freeform, classifier sorts it
|
||||||
|
!task fix the south fence #urgent # explicit task
|
||||||
|
!sidequest #priority:3 build a chicken-tractor
|
||||||
|
!remind in 30m check the eggs # reminder; fires in chat at due time
|
||||||
|
!list # open items in this room
|
||||||
|
!list sidequest # only sidequests
|
||||||
|
!done 17 # close item 17
|
||||||
|
!setup # show room shortcuts
|
||||||
|
!setup add buy task #buy # define a !buy shortcut for this room
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-line input works either inline or after a newline:
|
||||||
|
|
||||||
|
```
|
||||||
|
!task
|
||||||
|
- fix the alpaca fence
|
||||||
|
- patch the duck pond liner
|
||||||
|
- replace the gate latch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `!add <text>` | Freeform capture. Rules classifier sorts into a kind (task/journal/remind/etc.) or leaves it in the inbox as `unclassified` for later sorting. |
|
||||||
|
| `!task <text> [#tag…]` | Direct task capture. Tags become NIP-52 `t` tags. |
|
||||||
|
| `!sidequest <text> [#tag…]` | Optional / passion-project item. Same lifecycle as a task but listed separately so the day-to-day list stays clean. |
|
||||||
|
| `!remind in <N>(s\|m\|h\|d) <text>` | Schedule a chat-side reminder. Fires by posting in the room at the due time. |
|
||||||
|
| `!done <id>` | Close a task or sidequest by id. |
|
||||||
|
| `!list [task\|sidequest\|remind\|inbox\|all]` | List open items in this room (last 20). Default is everything except journals. |
|
||||||
|
| `!setup` | Show current room shortcuts. |
|
||||||
|
| `!setup add <verb> <kind> [#tag…]` | Add a per-room shortcut (e.g. `!setup add buy task #buy`). |
|
||||||
|
| `!setup remove <verb>` | Drop a shortcut. |
|
||||||
|
|
||||||
|
Universal verbs are hardcoded; per-room shortcuts are stored in the
|
||||||
|
plugin DB and apply only to the room they're defined in. A bakery
|
||||||
|
co-op can define `!harvest` / `!market` in their rooms; Ariège can
|
||||||
|
define `!buy` / `!steward` / `!chores` in theirs. Same plugin code.
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
|
||||||
|
Items can carry a priority tag (`#priority:1` through `#priority:5`)
|
||||||
|
per the spec's 5-level scale: urgent / crucial / important / future /
|
||||||
|
frequent-ongoing. The bot surfaces the label in `!list` output;
|
||||||
|
renderers (eink panel, etc.) decide how to use it for sorting and
|
||||||
|
visual emphasis.
|
||||||
|
|
||||||
|
The most ergonomic capture is via room shortcuts:
|
||||||
|
|
||||||
|
```
|
||||||
|
!setup add urgent task #priority:1
|
||||||
|
!setup add chores task #priority:5
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `!urgent the alpaca fence is down` records a priority-1 task.
|
||||||
|
|
||||||
|
## Classification
|
||||||
|
|
||||||
|
`!add` runs through a tiny rules engine (`classify.py`):
|
||||||
|
|
||||||
|
| Pattern | Bucket |
|
||||||
|
|---|---|
|
||||||
|
| `remind me to…` / `don't forget…` / `remember to…` | `remind` (no auto-time; you'll need to re-capture with `!remind in <N>…` to set a fire-time) |
|
||||||
|
| `buy` / `purchase` / `order` / `pick up` / `grab` / `get more` | `task #buy` |
|
||||||
|
| past-tense verbs (`did`, `finished`, `cleaned`, `watered`, `fed`, …) | `journal` |
|
||||||
|
| `need to` / `should` / `have to` / `must` / `todo` | `task` |
|
||||||
|
| anything else | `unclassified` (sits in `!list inbox` until sorted) |
|
||||||
|
|
||||||
|
This is intentionally **conservative** — better to leave something
|
||||||
|
unclassified than to mis-bucket it. The Phase 2b plan is an LLM
|
||||||
|
fallback on a dedicated inference node that handles the long tail.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
One SQLite DB per maubot instance, schema managed by
|
||||||
|
`mautrix.util.async_db.UpgradeTable`. Two tables:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- The items log
|
||||||
|
CREATE TABLE items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL, -- task | sidequest | journal | remind | unclassified
|
||||||
|
status TEXT NOT NULL, -- open | done | canceled
|
||||||
|
tags TEXT NOT NULL DEFAULT '', -- comma-separated; e.g. "buy,priority:2"
|
||||||
|
room TEXT NOT NULL,
|
||||||
|
user TEXT NOT NULL, -- originating MXID
|
||||||
|
ts BIGINT NOT NULL, -- captured at (ms since epoch)
|
||||||
|
due_at BIGINT, -- for reminders/timed tasks
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
classification_source TEXT, -- explicit | shortcut | rules | llm | manual
|
||||||
|
nostr_event_id TEXT -- populated in Phase 2 (Nostr bridge)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-room shortcut config + (future) publish posture
|
||||||
|
CREATE TABLE community_config (
|
||||||
|
room TEXT PRIMARY KEY,
|
||||||
|
config_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Wipe via the maubot UI's per-instance **Database** tab:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DELETE FROM items;
|
||||||
|
DELETE FROM community_config;
|
||||||
|
DELETE FROM sqlite_sequence WHERE name = 'items';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reminders survive restarts
|
||||||
|
|
||||||
|
Pending reminders are reloaded from the DB when the bot starts —
|
||||||
|
nothing in flight when maubot restarts, but anything scheduled for
|
||||||
|
the future re-arms. Reminders whose due time elapsed while the bot
|
||||||
|
was down stay open (they aren't auto-fired late); query with
|
||||||
|
`!list remind` to find them.
|
||||||
|
|
||||||
|
## Known quirks
|
||||||
|
|
||||||
|
- **Edited messages don't re-trigger the bot.** Matrix sends edits
|
||||||
|
as a separate `m.replace` event that maubot doesn't pass to
|
||||||
|
handlers. Type a fresh message instead of editing.
|
||||||
|
- **Time parsing is minimal in v1.** Only `in <N>(s|m|h|d)` works.
|
||||||
|
No "tomorrow 9am", no calendar dates. Use Phase 2b's LLM layer or
|
||||||
|
upstream `dateparser` for richer parsing later.
|
||||||
|
- **`!done` accepts only the id.** Closing by partial text match
|
||||||
|
(e.g. `!done flypaper`) isn't implemented in v1.
|
||||||
|
- **Phase 1 is Matrix-local.** Nothing publishes to Nostr yet — the
|
||||||
|
`nostr_event_id` column exists but is always NULL until Phase 2a
|
||||||
|
lands the bridge.
|
||||||
|
- **Universal verbs win over shortcuts.** You can't define a shortcut
|
||||||
|
named `task`, `add`, etc. — those are protocol-level.
|
||||||
|
|
||||||
|
## Build + iterate
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ~/dev/maubot-plugins/tracker
|
||||||
|
# bump version in maubot.yaml so the UI surfaces the new build
|
||||||
|
zip -j ../tracker.mbp maubot.yaml *.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload via maubot UI → Plugins → click existing → upload new `.mbp`.
|
||||||
|
**Hit Save on the affected instance** after upload — toggling
|
||||||
|
Enabled alone doesn't persist (easy facepalm). Create a fresh bot
|
||||||
|
client (e.g. `@trackerbot:ariege.io`) or attach to an existing one;
|
||||||
|
invite to the rooms you want it active in.
|
||||||
|
|
||||||
|
See `~/dev/CLAUDE.md` "Maubot plugin development" for the multi-line
|
||||||
|
command footgun and other plugin-wide gotchas.
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
This plugin uses `@command.passive` with a single dispatch regex
|
||||||
|
(`^!(\w+)…`) rather than per-verb `@command.new` decorators. Two
|
||||||
|
reasons:
|
||||||
|
|
||||||
|
1. **Multi-line freeform input.** Per `~/dev/CLAUDE.md`, `@command.new`
|
||||||
|
silently drops `!task\n<body>` because maubot's parser only
|
||||||
|
treats space as the command/args delimiter. Passive regex with
|
||||||
|
`re.DOTALL` catches every form.
|
||||||
|
2. **Per-room shortcuts.** A single dispatch handler can route
|
||||||
|
universal verbs to fixed handlers AND check the room's shortcut
|
||||||
|
table for unknown verbs, all in one place. Cleaner than declaring
|
||||||
|
N decorators and N matchers.
|
||||||
48
tracker/classify.py
Normal file
48
tracker/classify.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Rules-based classifier for !add freeform capture.
|
||||||
|
|
||||||
|
Level 1 of the spec's classifier conformance (§6.1). Maps obvious
|
||||||
|
shapes to a kind+tags. Anything ambiguous returns (None, []) and the
|
||||||
|
caller records the entry as `unclassified` for later sorting.
|
||||||
|
|
||||||
|
LLM fallback is Phase 2b and lives separately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_BUY_RE = re.compile(
|
||||||
|
r"\b(buy|purchase|order|pick\s*up|grab|get\s+more)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_REMIND_PREFIX_RE = re.compile(
|
||||||
|
r"\b(remind\s+me\s+to|don'?t\s+forget|remember\s+to)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_PAST_TENSE_RE = re.compile(
|
||||||
|
r"\b("
|
||||||
|
r"did|finished|completed|cleaned|watered|fed|opened|closed|"
|
||||||
|
r"planted|harvested|fixed|moved|painted|swept|mucked|brushed|"
|
||||||
|
r"installed|repaired|cooked|prepared"
|
||||||
|
r")\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_TASK_INTENT_RE = re.compile(
|
||||||
|
r"\b(need\s+to|should|have\s+to|must|todo|to\s+do)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(body: str) -> tuple[str | None, list[str]]:
|
||||||
|
"""Return (kind, tags). (None, []) means unclassified."""
|
||||||
|
if _REMIND_PREFIX_RE.search(body):
|
||||||
|
return ("remind", [])
|
||||||
|
|
||||||
|
if _BUY_RE.search(body):
|
||||||
|
return ("task", ["buy"])
|
||||||
|
|
||||||
|
if _PAST_TENSE_RE.search(body):
|
||||||
|
return ("journal", [])
|
||||||
|
|
||||||
|
if _TASK_INTENT_RE.search(body):
|
||||||
|
return ("task", [])
|
||||||
|
|
||||||
|
return (None, [])
|
||||||
9
tracker/maubot.yaml
Normal file
9
tracker/maubot.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
maubot: 0.1.0
|
||||||
|
id: dev.aiolabs.tracker
|
||||||
|
version: 0.1.0
|
||||||
|
license: AGPL-3.0-or-later
|
||||||
|
modules:
|
||||||
|
- tracker
|
||||||
|
main_class: TrackerBot
|
||||||
|
database: true
|
||||||
|
database_type: asyncpg
|
||||||
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()
|
||||||
392
tracker/tracker.py
Normal file
392
tracker/tracker.py
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from maubot import MessageEvent, Plugin
|
||||||
|
from maubot.handlers import command
|
||||||
|
from mautrix.util.async_db import Connection, UpgradeTable
|
||||||
|
|
||||||
|
from classify import classify
|
||||||
|
from scheduler import RemindScheduler
|
||||||
|
|
||||||
|
upgrade_table = UpgradeTable()
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register(description="Initial schema")
|
||||||
|
async def upgrade_v1(conn: Connection) -> None:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
tags TEXT NOT NULL DEFAULT '',
|
||||||
|
room TEXT NOT NULL,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
ts BIGINT NOT NULL,
|
||||||
|
due_at BIGINT,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
classification_source TEXT,
|
||||||
|
nostr_event_id TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX items_room_kind_status_ts"
|
||||||
|
" ON items (room, kind, status, ts DESC)"
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX items_due_at ON items (due_at) WHERE due_at IS NOT NULL"
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE community_config (
|
||||||
|
room TEXT PRIMARY KEY,
|
||||||
|
config_json TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CMD_RE = re.compile(r"^!(\w+)(?:[ \t\r\n]+(.*))?$", re.DOTALL)
|
||||||
|
_TAG_RE = re.compile(r"#(\S+)")
|
||||||
|
_RELATIVE_TIME_RE = re.compile(r"^in\s+(\d+)\s*([smhd])\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
UNIVERSAL_VERBS = {"add", "task", "sidequest", "remind", "done", "list", "setup"}
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"verbs": {},
|
||||||
|
"publish": "matrix-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
PRIORITY_LABELS = {
|
||||||
|
"1": "urgent",
|
||||||
|
"2": "crucial",
|
||||||
|
"3": "important",
|
||||||
|
"4": "future",
|
||||||
|
"5": "frequent/ongoing",
|
||||||
|
}
|
||||||
|
|
||||||
|
_USAGE = (
|
||||||
|
"**Tracker commands:**\n"
|
||||||
|
"- `!add <text>` — freeform inbox capture (auto-classified)\n"
|
||||||
|
"- `!task <text> [#tag…]` — record a task\n"
|
||||||
|
"- `!sidequest <text> [#tag…]` — record an optional / passion-project item\n"
|
||||||
|
"- `!remind in <N>(s|m|h|d) <text>` — fire a reminder later\n"
|
||||||
|
"- `!done <id>` — close a task or sidequest\n"
|
||||||
|
"- `!list [task|sidequest|remind|inbox|all]` — list recent items in this room\n"
|
||||||
|
"- `!setup` — show or configure room shortcuts\n"
|
||||||
|
"\nSee `docs/community-organizer-spec.md` for the protocol shape."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tags(body: str) -> tuple[list[str], str]:
|
||||||
|
"""Pull #tag tokens out of body; return (tags, cleaned_body)."""
|
||||||
|
tags = _TAG_RE.findall(body)
|
||||||
|
cleaned = _TAG_RE.sub("", body).strip()
|
||||||
|
cleaned = re.sub(r"\s+", " ", cleaned)
|
||||||
|
return tags, cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_relative_time(s: str) -> tuple[int | None, str]:
|
||||||
|
"""Returns (due_at_ms, remainder). (None, s) if unparseable."""
|
||||||
|
m = _RELATIVE_TIME_RE.match(s)
|
||||||
|
if not m:
|
||||||
|
return None, s
|
||||||
|
n = int(m.group(1))
|
||||||
|
unit = m.group(2).lower()
|
||||||
|
mult = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
||||||
|
due_at_s = time.time() + n * mult
|
||||||
|
rest = s[m.end():].strip()
|
||||||
|
return int(due_at_s * 1000), rest
|
||||||
|
|
||||||
|
|
||||||
|
def _format_age(ts_ms: int) -> str:
|
||||||
|
delta = time.time() - ts_ms / 1000
|
||||||
|
if delta < 60:
|
||||||
|
return "just now"
|
||||||
|
if delta < 3600:
|
||||||
|
return f"{int(delta / 60)}m ago"
|
||||||
|
if delta < 86400:
|
||||||
|
return f"{int(delta / 3600)}h ago"
|
||||||
|
return f"{int(delta / 86400)}d ago"
|
||||||
|
|
||||||
|
|
||||||
|
def _priority_label(tags: list[str]) -> str | None:
|
||||||
|
for t in tags:
|
||||||
|
if t.startswith("priority:"):
|
||||||
|
return PRIORITY_LABELS.get(t.split(":", 1)[1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerBot(Plugin):
|
||||||
|
scheduler: RemindScheduler
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_db_upgrade_table(cls) -> UpgradeTable:
|
||||||
|
return upgrade_table
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
await super().start()
|
||||||
|
self.scheduler = RemindScheduler(self)
|
||||||
|
await self.scheduler.start()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if hasattr(self, "scheduler"):
|
||||||
|
await self.scheduler.stop()
|
||||||
|
await super().stop()
|
||||||
|
|
||||||
|
async def _get_config(self, room: str) -> dict:
|
||||||
|
row = await self.database.fetchrow(
|
||||||
|
"SELECT config_json FROM community_config WHERE room = $1", room
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return json.loads(row["config_json"])
|
||||||
|
return json.loads(json.dumps(DEFAULT_CONFIG))
|
||||||
|
|
||||||
|
async def _set_config(self, room: str, cfg: dict) -> None:
|
||||||
|
await self.database.execute(
|
||||||
|
"INSERT INTO community_config (room, config_json) VALUES ($1, $2)"
|
||||||
|
" ON CONFLICT (room) DO UPDATE SET config_json = excluded.config_json",
|
||||||
|
room,
|
||||||
|
json.dumps(cfg),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _record(
|
||||||
|
self,
|
||||||
|
evt: MessageEvent,
|
||||||
|
kind: str,
|
||||||
|
body: str,
|
||||||
|
tags: list[str],
|
||||||
|
status: str = "open",
|
||||||
|
due_at: int | None = None,
|
||||||
|
source: str = "explicit",
|
||||||
|
) -> int:
|
||||||
|
tags_str = ",".join(tags)
|
||||||
|
item_id = await self.database.fetchval(
|
||||||
|
"INSERT INTO items"
|
||||||
|
" (kind, status, tags, room, user, ts, due_at, body, classification_source)"
|
||||||
|
" VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id",
|
||||||
|
kind,
|
||||||
|
status,
|
||||||
|
tags_str,
|
||||||
|
evt.room_id,
|
||||||
|
evt.sender,
|
||||||
|
evt.timestamp,
|
||||||
|
due_at,
|
||||||
|
body,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
return item_id
|
||||||
|
|
||||||
|
@command.passive(regex=_CMD_RE)
|
||||||
|
async def dispatch(self, evt: MessageEvent, match) -> None:
|
||||||
|
verb = match[1].lower()
|
||||||
|
body = (match[2] or "").strip()
|
||||||
|
|
||||||
|
if verb in UNIVERSAL_VERBS:
|
||||||
|
handler = getattr(self, f"_handle_{verb}")
|
||||||
|
await handler(evt, body)
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = await self._get_config(evt.room_id)
|
||||||
|
shortcut = cfg.get("verbs", {}).get(verb)
|
||||||
|
if shortcut:
|
||||||
|
await self._handle_shortcut(evt, body, shortcut, verb)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _handle_add(self, evt: MessageEvent, body: str) -> None:
|
||||||
|
if not body:
|
||||||
|
await evt.reply(_USAGE)
|
||||||
|
return
|
||||||
|
explicit_tags, cleaned = _parse_tags(body)
|
||||||
|
kind, auto_tags = classify(cleaned)
|
||||||
|
if kind is None:
|
||||||
|
await self._record(evt, "unclassified", cleaned, explicit_tags, "open", None, "rules")
|
||||||
|
await evt.reply(
|
||||||
|
"📥 Logged to inbox (unclassified).\n"
|
||||||
|
"Reply with `!sort <kind>` on this thread to bucket, or use `!list inbox`."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
tags = list(dict.fromkeys(auto_tags + explicit_tags))
|
||||||
|
await self._record(evt, kind, cleaned, tags, "open", None, "rules")
|
||||||
|
tag_display = (" #" + " #".join(tags)) if tags else ""
|
||||||
|
await evt.reply(f"📥 Logged as `{kind}`{tag_display}. (auto-classified)")
|
||||||
|
|
||||||
|
async def _handle_task(self, evt: MessageEvent, body: str) -> None:
|
||||||
|
await self._task_like(evt, body, "task", [], "explicit", "✅ Task")
|
||||||
|
|
||||||
|
async def _handle_sidequest(self, evt: MessageEvent, body: str) -> None:
|
||||||
|
await self._task_like(evt, body, "sidequest", [], "explicit", "🎯 Sidequest")
|
||||||
|
|
||||||
|
async def _handle_shortcut(
|
||||||
|
self, evt: MessageEvent, body: str, shortcut: dict, verb: str
|
||||||
|
) -> None:
|
||||||
|
kind = shortcut.get("kind", "task")
|
||||||
|
base_tags = list(shortcut.get("tags", []))
|
||||||
|
emoji = "🎯" if kind == "sidequest" else "✅"
|
||||||
|
await self._task_like(evt, body, kind, base_tags, "shortcut", f"{emoji} {kind.capitalize()} (#{verb})")
|
||||||
|
|
||||||
|
async def _task_like(
|
||||||
|
self,
|
||||||
|
evt: MessageEvent,
|
||||||
|
body: str,
|
||||||
|
kind: str,
|
||||||
|
base_tags: list[str],
|
||||||
|
source: str,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
if not body:
|
||||||
|
await evt.reply(f"Usage: `!{kind} <text> [#tag…]`")
|
||||||
|
return
|
||||||
|
explicit_tags, cleaned = _parse_tags(body)
|
||||||
|
tags = list(dict.fromkeys(base_tags + explicit_tags))
|
||||||
|
item_id = await self._record(evt, kind, cleaned, tags, "open", None, source)
|
||||||
|
prio = _priority_label(tags)
|
||||||
|
prio_display = f" *(priority: {prio})*" if prio else ""
|
||||||
|
tag_display = (" #" + " #".join(t for t in tags if not t.startswith("priority:"))) if tags else ""
|
||||||
|
await evt.reply(f"{label}{tag_display}{prio_display} — id `{item_id}`")
|
||||||
|
|
||||||
|
async def _handle_remind(self, evt: MessageEvent, body: str) -> None:
|
||||||
|
if not body:
|
||||||
|
await evt.reply(
|
||||||
|
"Usage: `!remind in <N>(s|m|h|d) <text>`\n"
|
||||||
|
"Example: `!remind in 30m check the eggs`"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
due_at_ms, rest = _parse_relative_time(body)
|
||||||
|
if due_at_ms is None or not rest:
|
||||||
|
await evt.reply(
|
||||||
|
"Couldn't parse the time. Use `in <N>(s|m|h|d) <text>` "
|
||||||
|
"(e.g. `in 2h water the chickens`)."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
explicit_tags, cleaned = _parse_tags(rest)
|
||||||
|
item_id = await self._record(
|
||||||
|
evt, "remind", cleaned, explicit_tags, "open", due_at_ms, "explicit"
|
||||||
|
)
|
||||||
|
self.scheduler.schedule(item_id, due_at_ms, evt.room_id, cleaned)
|
||||||
|
when = datetime.fromtimestamp(due_at_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
await evt.reply(f"⏰ Reminder set for {when} — id `{item_id}`")
|
||||||
|
|
||||||
|
async def _handle_done(self, evt: MessageEvent, body: str) -> None:
|
||||||
|
if not body:
|
||||||
|
await evt.reply("Usage: `!done <id>` — close a task or sidequest.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
item_id = int(body.strip().split()[0])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
await evt.reply("That doesn't look like an id. Try `!list` to find one.")
|
||||||
|
return
|
||||||
|
row = await self.database.fetchrow(
|
||||||
|
"SELECT id, kind, body, status FROM items"
|
||||||
|
" WHERE id = $1 AND room = $2",
|
||||||
|
item_id,
|
||||||
|
evt.room_id,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
await evt.reply(f"No item `{item_id}` in this room.")
|
||||||
|
return
|
||||||
|
if row["kind"] == "journal":
|
||||||
|
await evt.reply("Journal entries don't get closed — they're append-only.")
|
||||||
|
return
|
||||||
|
if row["status"] == "done":
|
||||||
|
await evt.reply(f"`{item_id}` is already done.")
|
||||||
|
return
|
||||||
|
await self.database.execute(
|
||||||
|
"UPDATE items SET status = 'done' WHERE id = $1", item_id
|
||||||
|
)
|
||||||
|
await evt.reply(f"✅ Closed `{item_id}`: {row['body']}")
|
||||||
|
|
||||||
|
async def _handle_list(self, evt: MessageEvent, body: str) -> None:
|
||||||
|
body = body.strip()
|
||||||
|
filter_clause = "kind != 'journal' AND status = 'open'"
|
||||||
|
title = "Open items"
|
||||||
|
if body in ("task", "tasks"):
|
||||||
|
filter_clause = "kind = 'task' AND status = 'open'"
|
||||||
|
title = "Open tasks"
|
||||||
|
elif body in ("sidequest", "sidequests"):
|
||||||
|
filter_clause = "kind = 'sidequest' AND status = 'open'"
|
||||||
|
title = "Open sidequests"
|
||||||
|
elif body in ("remind", "reminders"):
|
||||||
|
filter_clause = "kind = 'remind' AND status = 'open'"
|
||||||
|
title = "Pending reminders"
|
||||||
|
elif body == "inbox":
|
||||||
|
filter_clause = "kind = 'unclassified' AND status = 'open'"
|
||||||
|
title = "Inbox (unclassified)"
|
||||||
|
elif body == "all":
|
||||||
|
filter_clause = "status = 'open'"
|
||||||
|
title = "Everything open"
|
||||||
|
rows = await self.database.fetch(
|
||||||
|
f"SELECT id, kind, tags, body, ts, due_at FROM items"
|
||||||
|
f" WHERE room = $1 AND {filter_clause}"
|
||||||
|
f" ORDER BY ts DESC LIMIT 20",
|
||||||
|
evt.room_id,
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
await evt.reply(f"{title}: nothing here.")
|
||||||
|
return
|
||||||
|
lines = [f"**{title} ({len(rows)}):**"]
|
||||||
|
for r in rows:
|
||||||
|
tags = [t for t in (r["tags"] or "").split(",") if t]
|
||||||
|
prio = _priority_label(tags)
|
||||||
|
display_tags = [t for t in tags if not t.startswith("priority:")]
|
||||||
|
tag_str = (" #" + " #".join(display_tags)) if display_tags else ""
|
||||||
|
prio_str = f" *[{prio}]*" if prio else ""
|
||||||
|
kind_str = "" if r["kind"] == "task" else f" _({r['kind']})_"
|
||||||
|
lines.append(
|
||||||
|
f"- `{r['id']}`{kind_str}{prio_str}{tag_str}: {r['body']} "
|
||||||
|
f"_({_format_age(r['ts'])})_"
|
||||||
|
)
|
||||||
|
await evt.reply("\n".join(lines))
|
||||||
|
|
||||||
|
async def _handle_setup(self, evt: MessageEvent, body: str) -> None:
|
||||||
|
cfg = await self._get_config(evt.room_id)
|
||||||
|
body = body.strip()
|
||||||
|
if not body or body == "show":
|
||||||
|
verbs = cfg.get("verbs", {})
|
||||||
|
if not verbs:
|
||||||
|
await evt.reply(
|
||||||
|
"No room shortcuts configured.\n"
|
||||||
|
"Add one with: `!setup add <verb> <kind> [#tag…]`\n"
|
||||||
|
"Example: `!setup add buy task #buy`"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
lines = ["**Room shortcuts:**"]
|
||||||
|
for v, spec in verbs.items():
|
||||||
|
tags = spec.get("tags", [])
|
||||||
|
tag_str = " ".join(f"#{t}" for t in tags)
|
||||||
|
lines.append(f"- `!{v}` → `!{spec.get('kind', 'task')} {tag_str}`")
|
||||||
|
await evt.reply("\n".join(lines))
|
||||||
|
return
|
||||||
|
parts = body.split()
|
||||||
|
if parts[0] == "add" and len(parts) >= 3:
|
||||||
|
verb, kind = parts[1].lstrip("!"), parts[2]
|
||||||
|
if verb in UNIVERSAL_VERBS:
|
||||||
|
await evt.reply(f"`{verb}` is a universal verb — pick a different name.")
|
||||||
|
return
|
||||||
|
if kind not in ("task", "sidequest", "remind"):
|
||||||
|
await evt.reply(f"Kind must be task, sidequest, or remind (got `{kind}`).")
|
||||||
|
return
|
||||||
|
tags = [t.lstrip("#") for t in parts[3:] if t.startswith("#")]
|
||||||
|
cfg.setdefault("verbs", {})[verb] = {"kind": kind, "tags": tags}
|
||||||
|
await self._set_config(evt.room_id, cfg)
|
||||||
|
tag_display = " ".join(f"#{t}" for t in tags)
|
||||||
|
await evt.reply(f"✅ Added: `!{verb}` → `!{kind} {tag_display}`")
|
||||||
|
return
|
||||||
|
if parts[0] == "remove" and len(parts) >= 2:
|
||||||
|
verb = parts[1].lstrip("!")
|
||||||
|
if verb in cfg.get("verbs", {}):
|
||||||
|
del cfg["verbs"][verb]
|
||||||
|
await self._set_config(evt.room_id, cfg)
|
||||||
|
await evt.reply(f"🗑 Removed shortcut `!{verb}`.")
|
||||||
|
else:
|
||||||
|
await evt.reply(f"No shortcut `!{verb}` configured.")
|
||||||
|
return
|
||||||
|
await evt.reply(
|
||||||
|
"Usage:\n"
|
||||||
|
"- `!setup` or `!setup show` — list shortcuts\n"
|
||||||
|
"- `!setup add <verb> <kind> [#tag…]` — add a shortcut\n"
|
||||||
|
"- `!setup remove <verb>` — drop a shortcut"
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue