mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
docs(plans): slash command dispatcher design (phase D parity #1)
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
This commit is contained in:
@@ -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:<plugin-name>`
|
||||
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 <text>` | 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:<name>`
|
||||
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
|
||||
Reference in New Issue
Block a user