From d9dc415436f70d01761c96dffc4905bf42d8b0ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 13:38:48 +0000 Subject: [PATCH] docs(plans): slash command dispatcher design (phase D parity #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of two phase-D parity plans. Grounded in the existing V2 command dispatcher (Server/ws/command.go) and the dormant plugin.Registry.DispatchCommand + host_commands.go — this is not a green-field design, it's a wiring plan for code that already exists. Covers: - Wire format: command_invoke, command_autocomplete, command_reply, command_autocomplete_result - Manifest extension: commands[] with option types, default_member_permissions, contexts, autocomplete flag - Schema: migrations/016_plugin_commands.sql with a unique name index so two plugins can't both own /ban - Code surface: enumerated file-by-file touch list - Permission model: server-enforces default_member_permissions BEFORE the plugin is invoked, plugins never get to gate their own commands - Built-in commands: /me + /shrug ship in-tree as reference handlers - Concurrency: 3s deadline via context.WithTimeout passed to DispatchCommand - Failure modes & UX: 6-row table from "unknown command" through panic auto-disable - Testing: unit + integration + contract round trip - Telemetry: 3 new counters + OTel span - 4-stage rollout, each step independently shippable - Open questions: bot identity for broadcasts, component v2 reservation, cross-plugin imports, DM-context handling Plan #8 (E2EE DMs + DAVE voice) and PHASE_D_PARITY_TODO.md items 2-7 to follow in a subsequent commit. https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk --- docs/plans/slash-commands.md | 330 +++++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 docs/plans/slash-commands.md diff --git a/docs/plans/slash-commands.md b/docs/plans/slash-commands.md new file mode 100644 index 00000000..003cacaf --- /dev/null +++ b/docs/plans/slash-commands.md @@ -0,0 +1,330 @@ +# Plan: Slash command dispatcher in WS + +**Status:** design only, not implemented +**Owner:** TBD +**Tracks:** PHASE_D_PARITY_TODO.md item #1 +**Estimated effort:** 1–2 weeks of focused work + +## Why + +Discord's command system is the primary surface every bot/app uses. OwnCord +already ships: + +- A plugin runtime (`Server/plugin/`) with a `commands` capability, +- `plugin.Registry.RegisterCommand` and `plugin.Registry.DispatchCommand` + (`Server/plugin/host_commands.go`), +- A V2 WS dispatcher (`Server/ws/handlers_chat.go`, + `Server/ws/command.go`) that already routes typed `Command` structs to + pure handlers, +- A `chat_send` payload that flows through `MessageService.SendMessage`. + +…but `chat_send` content beginning with `/` is currently treated as a +literal message. There is no dispatcher that recognises `/help`, no +autocomplete, no per-command argument schema, and the +`Registry.DispatchCommand` plumbing is dead code. + +This plan wires the existing pieces together and adds the surface that +clients need to render slash commands the way Discord does. + +## Non-goals + +- **Not** an OAuth2/app directory (Tier 2 work — separate plan). +- **Not** a hosted "verified bot" registry — local plugins only. +- **Not** modal interactions or buttons/select menus (component v2). These + are a follow-up; the v1 surface is text reply + ephemeral reply. +- **Not** a re-implementation of `MessageService` — slash commands take a + separate path so they don't pollute the message table. + +## Surface design + +### Wire format + +Two new client→server messages: + +```json +// 1. Command invocation (from /chat input or autocomplete pick) +{ + "type": "command_invoke", + "req_id": "c-42", + "payload": { + "channel_id": 17, + "name": "kick", + "args": [ + {"name": "user", "value": "12345"}, + {"name": "reason", "value": "spam"} + ] + } +} + +// 2. Autocomplete query (sent on every keystroke after `/`) +{ + "type": "command_autocomplete", + "req_id": "c-43", + "payload": { + "channel_id": 17, + "name": "kick", + "focused": "user", + "partial": "ali", + "args": [{"name": "user", "value": "ali"}] + } +} +``` + +Two new server→client messages: + +```json +// Successful invocation reply (ephemeral by default). +{ + "type": "command_reply", + "req_id": "c-42", + "payload": { + "ephemeral": true, + "content": "Kicked alice for: spam.", + "embeds": [], + "broadcast": null + } +} + +// Autocomplete suggestions. +{ + "type": "command_autocomplete_result", + "req_id": "c-43", + "payload": { + "choices": [ + {"label": "alice (id 12345)", "value": "12345"}, + {"label": "alistair (id 67)", "value": "67"} + ] + } +} +``` + +The ephemeral reply is delivered only to the invoking client (it never hits +`MessageSentChannelEvent`). The optional `broadcast` field, when non-null, +becomes a real `chat_message` posted by a synthetic `Bot:` +identity so the channel timeline records it. + +### Manifest changes + +Plugin manifests gain a `commands` block. The manifest is the source of +truth for the per-command schema; the runtime never trusts what the plugin +says at dispatch time. Example: + +```json +{ + "name": "moderation-tools", + "version": "0.2.0", + "entrypoint": "mod.wasm", + "permissions": ["commands", "events"], + "commands": [ + { + "name": "kick", + "description": "Remove a user from the server.", + "default_member_permissions": ["kick_members"], + "options": [ + { + "name": "user", + "type": "user", + "description": "Who to kick.", + "required": true, + "autocomplete": true + }, + { + "name": "reason", + "type": "string", + "description": "Optional reason recorded in audit log.", + "max_length": 256 + } + ] + } + ] +} +``` + +`Manifest.Validate` (`Server/plugin/manifest.go`) gets a new +`validateCommands()` step: + +- `name`: same regex as plugin names (`^[a-z][a-z0-9_-]{0,31}$`), + forced lowercase, **no spaces**, **no slashes**, max 32 chars. +- `description`: 1–100 chars, no NUL/control bytes. +- `options`: max 25, each `name` unique, recurses on nested groups. +- `option.type`: closed enum (`string`, `int`, `bool`, `user`, `channel`, + `role`, `mention`, `attachment`, `subcommand`, `subcommand_group`). +- `default_member_permissions`: closed enum matched against + `permissions.Permission` constants (so a typo fails at install, not at + dispatch). + +Same defense profile as the existing manifest validation: the closed-set +checks live in `manifest.go` next to `validCapabilities`. + +### Schema additions + +A new table to support **server-installed plugin commands without +duplicating the manifest** — needed so the WS hub can answer +`command_autocomplete` requests without doing a JSON walk on every keystroke: + +```sql +-- migrations/016_plugin_commands.sql +CREATE TABLE plugin_commands ( + plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE, + name TEXT NOT NULL, + schema_json TEXT NOT NULL, -- the validated `commands[i]` blob + permissions INTEGER NOT NULL DEFAULT 0, -- packed permission bits + PRIMARY KEY (plugin_id, name) +); +CREATE UNIQUE INDEX plugin_commands_name_uq ON plugin_commands(name); +``` + +The unique index on `name` enforces that two enabled plugins can't both +own `/ban` — install of the second one fails. This is intentional: command +namespace collisions are confusing for users. + +### Code surface + +| File | Change | +|---|---| +| `Server/ws/message_types.go` | Add `MsgTypeCommandInvoke`, `MsgTypeCommandAutocomplete`, `MsgTypeCommandReply`, `MsgTypeCommandAutocompleteResult`. | +| `Server/ws/command.go` | Add `CommandInvokeCmd`, `CommandAutocompleteCmd` structs and constructors. Validate name regex + arg count cap (25) at parse time so the dispatcher trusts its input. | +| `Server/ws/handlers_command.go` | **New file.** `handleCommandInvokeV2`, `handleCommandAutocompleteV2`. Pure handlers — return a `Result` like the existing chat handlers. | +| `Server/ws/handlers.go` | Register the new handlers via `r.RegisterV2(MsgTypeCommandInvoke, handleCommandInvokeV2, deps)`. | +| `Server/ws/deps.go` | Add a `CommandDeps` carrying `*plugin.Registry`, `service.PermissionService`, and `service.MessageService`. | +| `Server/plugin/host_commands.go` | Extend `DispatchCommand` to take a typed arg map (`map[string]any`) instead of `[]string`. Add `Autocomplete(ctx, name, focused, partial)`. | +| `Server/plugin/manifest.go` | Add `Commands []CommandSpec` to `Manifest`, `validateCommands()`, and a `Manifest.Command(name)` lookup. | +| `Server/store/sqlite_plugin_commands.go` | **New file.** CRUD over the `plugin_commands` table. | +| `Server/migrations/016_plugin_commands.sql` | New migration. | +| `Client/tauri-client/src/state/commands.ts` | **New module.** Caches per-server command list (fetched at `auth_ok` time via a new `commands_list` REST endpoint), feeds the autocomplete UI. | +| `Client/tauri-client/src/components/Composer/SlashCommandPopup.tsx` | New component — autocomplete dropdown that opens when the message buffer starts with `/`. | +| `docs/protocol.md` | Document the four new wire messages. | + +### Permission model + +`default_member_permissions` is enforced **server-side** in +`handleCommandInvokeV2` *before* the plugin is invoked, by calling +`PermissionService.HasChannelPerm` for each declared permission. Plugins +do not get to decide who can use their commands; the manifest declares, +the host enforces. + +Slash commands inherit the existing channel ACL: `command_invoke` for a +channel the user can't `view_channel` in returns `ErrCodeForbidden` with +no plugin invocation, no telemetry leak. + +### Built-in commands + +Two slash commands ship in-tree (no plugin required), to validate the +dispatcher and to give bare-metal deployments something useful: + +| Command | Implementation | Why in-tree | +|---|---|---| +| `/me ` | Built-in handler in `handlers_command.go` | Discord parity, IRC tradition. | +| `/shrug` | Built-in handler | Same. Trivial. | + +A future PR can add `/poll`, `/remind`, `/nick` etc. — all should follow +the same handler shape so a plugin author can read the source as the +canonical example. + +## Concurrency & lifecycle + +- Plugin command registration happens at `Registry.activateAll` time + (already exists for the wazero build) and at `installFromDisk` for the + default build. +- `plugin_commands` rows are written inside the same transaction as + `plugins` so a half-installed plugin can never have orphan rows. +- `DispatchCommand` runs the plugin handler **off the WS goroutine** with a + 3 s context deadline (configurable via `config.yaml > plugins.command_deadline_ms`). + A slow command must not block `chat_send` for the same client. +- The 3 s budget is enforced by passing `context.WithTimeout` into + `Registry.DispatchCommand`; the wazero runtime already accepts a `ctx` + on every host call. + +## Failure modes & UX + +| Failure | Server response | Client UX | +|---|---|---| +| No such command | `command_reply` ephemeral: `Unknown command: /foo` | Red banner under composer. | +| Plugin runtime not built (default build) | Existing fallback in `DispatchCommand` returns the helpful error message | Same banner, no crash. | +| Plugin handler timeout (>3s) | `command_reply` ephemeral: `/foo timed out` + audit log entry | Banner + telemetry tag. | +| Plugin handler panics | Recovered in the runtime, ephemeral error, plugin auto-disabled after 3 panics in 60s | Banner + plugin marked unhealthy in admin panel. | +| Permission denied | `command_invoke` returns `ErrCodeForbidden` before invocation | Banner: "You lack permission". | +| Argument validation fails | `command_invoke` returns `ErrCodeBadPayload` with the field name | Composer highlights the bad option. | + +## Testing strategy + +Unit: +- `manifest_test.go` — extend with command validation (name regex, option + type enum, max 25 options, max 100 char description). +- `host_commands_test.go` — `DispatchCommand` with a stub `Instance`, + arg-map round trip, auto-disable after panics. +- `handlers_command_test.go` — pure handler test using the existing + V2 test pattern (`stubMessageSvc`, `stubPermSvc`). + +Integration: +- Add a new in-tree test plugin under `Server/plugin/examples/echo` (no + wasm needed — installable via the default build) that registers `/echo` + and is loaded inside `ws_integration_test.go`. + +Contract: +- `docs/protocol.md` round trip — JSON examples kept in sync with the + parser via golden tests. + +## Telemetry + +Three new counters under `commands_*`: + +- `commands_invoked_total{name,plugin,result}` +- `commands_autocomplete_total{name,plugin}` +- `commands_duration_ms_bucket{name,plugin}` (histogram) + +Existing OTel skeleton (`Server/telemetry/`) gets a new +`tracer.Start(ctx, "command.invoke")` span around `DispatchCommand`. + +## Rollout + +1. Land schema migration + manifest validation behind the existing + default build. New plugins can declare commands but the WS dispatcher + still treats `/` as plain text. +2. Land WS dispatcher + `/me` / `/shrug` built-ins. Slash commands work + for in-tree handlers, plugin commands still no-op. +3. Land the autocomplete RPC + client UI. Composer learns to open the + popup on `/`. +4. Land `Registry.DispatchCommand` wiring so plugin commands route through. + Gate behind `-tags wazero` for the actual invocation; the default + build returns the existing helpful "runtime not built" message. + +Each step is independently shippable. + +## Open questions + +1. **Bot identity for broadcast replies.** When a slash command produces a + `broadcast`, who is the author? Options: (a) a virtual `Bot:` + user with a synthetic ID in a reserved namespace, (b) the invoking + user (Discord style: "Used /poll"). I'm leaning (b) for simplicity — + it avoids new identity rows — but it loses the visual distinction. TBD + in review. +2. **Component v2 (buttons / selects).** Out of scope for v1 but the + wire-format reservations should leave room for a `components` array on + `command_reply`. Worth adding the field as `[]any` now even though + nothing renders it, so v2 isn't a breaking change. +3. **Cross-plugin command imports.** Discord allows one app to use another's + commands. We don't, and shouldn't until there's a real reason — the + namespace flatness is a feature for a self-hosted product. +4. **DM-context commands.** Some commands make sense in DMs (`/poll`), + some don't (`/kick`). Add a manifest field `contexts: ["channel", "dm"]` + defaulting to `["channel"]`. + +## Files-to-touch checklist (for the implementing agent) + +- [ ] `Server/migrations/016_plugin_commands.sql` +- [ ] `Server/plugin/manifest.go` — `CommandSpec`, `validateCommands` +- [ ] `Server/plugin/manifest_test.go` — command validation table +- [ ] `Server/plugin/host_commands.go` — typed args, autocomplete +- [ ] `Server/store/sqlite_plugin_commands.go` +- [ ] `Server/ws/message_types.go` — four new constants +- [ ] `Server/ws/command.go` — `CommandInvokeCmd`, `CommandAutocompleteCmd` +- [ ] `Server/ws/handlers_command.go` — V2 handlers + `/me` + `/shrug` +- [ ] `Server/ws/handlers.go` — register new handlers +- [ ] `Server/ws/deps.go` — `CommandDeps` +- [ ] `Server/ws/handlers_command_test.go` +- [ ] `Server/api/router.go` — `GET /api/v1/commands` (cached schema dump) +- [ ] `Client/tauri-client/src/state/commands.ts` +- [ ] `Client/tauri-client/src/components/Composer/SlashCommandPopup.tsx` +- [ ] `docs/protocol.md` — four new wire messages +- [ ] `CHANGELOG.md` — Phase D entry