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:
Padreug 2026-05-24 15:48:21 +02:00
commit b7a096a77a
6 changed files with 693 additions and 0 deletions

View file

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