diff --git a/README.md b/README.md index bf869d1..35e778f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ castle hosts; the actual plugin code lives here. | Plugin | Purpose | |---|---| | [`journal/`](./journal/) | Farm-journal bot. `!journal ` 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 diff --git a/tracker/README.md b/tracker/README.md new file mode 100644 index 0000000..0afa4ea --- /dev/null +++ b/tracker/README.md @@ -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 ` | Freeform capture. Rules classifier sorts into a kind (task/journal/remind/etc.) or leaves it in the inbox as `unclassified` for later sorting. | +| `!task [#tag…]` | Direct task capture. Tags become NIP-52 `t` tags. | +| `!sidequest [#tag…]` | Optional / passion-project item. Same lifecycle as a task but listed separately so the day-to-day list stays clean. | +| `!remind in (s\|m\|h\|d) ` | Schedule a chat-side reminder. Fires by posting in the room at the due time. | +| `!done ` | 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 [#tag…]` | Add a per-room shortcut (e.g. `!setup add buy task #buy`). | +| `!setup remove ` | 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 …` 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 (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` 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. diff --git a/tracker/classify.py b/tracker/classify.py new file mode 100644 index 0000000..a7394ef --- /dev/null +++ b/tracker/classify.py @@ -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, []) diff --git a/tracker/maubot.yaml b/tracker/maubot.yaml new file mode 100644 index 0000000..452d522 --- /dev/null +++ b/tracker/maubot.yaml @@ -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 diff --git a/tracker/scheduler.py b/tracker/scheduler.py new file mode 100644 index 0000000..84b27a1 --- /dev/null +++ b/tracker/scheduler.py @@ -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() diff --git a/tracker/tracker.py b/tracker/tracker.py new file mode 100644 index 0000000..3f39b0e --- /dev/null +++ b/tracker/tracker.py @@ -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 ` — freeform inbox capture (auto-classified)\n" + "- `!task [#tag…]` — record a task\n" + "- `!sidequest [#tag…]` — record an optional / passion-project item\n" + "- `!remind in (s|m|h|d) ` — fire a reminder later\n" + "- `!done ` — 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 ` 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} [#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 (s|m|h|d) `\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 (s|m|h|d) ` " + "(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 ` — 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 [#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 [#tag…]` — add a shortcut\n" + "- `!setup remove ` — drop a shortcut" + )