Ctrl K
ComunidadGuías…

docs/BOT_API_V2.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Bot API v2 — design contract

Status: **in implementation** (branch `feat/bot-api-v2-marketplace-sandbox`).
This document is the shared contract for everyone building v2: database,
REST, realtime, SDK and UI must match it. When an implementation detail
forces a change, update this file in the same change.

v1 (`/api/bot/v1`: `me`, `channels`, read/post messages, polling) keeps
working unchanged. v2 adds four things, all **scoped per channel**:

1. **Slash commands** — bots register commands; members run them from the
   composer with `/`.
2. **Interactions** — a command run is an interaction the bot answers
   (publicly or only to the invoker).
3. **Event stream** — bots receive events over the WebSocket gateway
   instead of polling.
4. **Webhooks** — incoming (external services post into a channel) and
   outgoing (the instance POSTs a bot's events and interactions to the
   bot's HTTPS endpoint, signed).

## 1. Scoping and permissions

### 1.1 Channel access per bot (`bots.channel_access_mode` + `bot_channel_access`)
- The mode is **stored** on the bot (`bots.channel_access_mode`, `'all'` |
  `'selected'`, default `'all'`):
  - `all` keeps the v1 rule: every text/announcement channel of its server
    that has **no role gate**. Grant rows are not used (switching to `all`
    deletes them).
  - `selected` reaches exactly its `bot_channel_access` rows (still text or
    announcement channels of its own server) — and **no channel at all**
    when it has none left. "No rows" never means "all": a grant row
    cascades away with its channel, and deleting (or revoking) a bot's last
    channel must narrow it, never widen it to every open channel.
  - Anything but the exact `'all'` (an unknown value, a missing bot row) is
    read as `selected`.
- A role-gated channel may be granted only by someone who can see that
  channel AND has `MANAGE_CHANNELS` (owner/administrator always). When a
  channel gets (or changes) a role restriction (`PATCH …/channels/{id}`
  with a non-empty `visibleToRoleIds`), every grant on it whose granter is
  not the owner and does not hold `MANAGE_CHANNELS` at that moment (or who
  left / was banned / deleted their account) is dropped — audited as
  `bot.channel_access` `{ removed: [channelId], reason:
  'channel_restricted' }`, with a `bot-access` invalidation and
  `channel_access_changed` to each bot. A grant that stays through a bulk
  replace keeps its original `granted_by`.
- Deleting a channel: its grants cascade; the route reads the granted bots
  first, then audits `bot.channel_access` `{ removed: [channelId], reason:
  'channel_deleted' }` per bot, publishes `bot-access` per bot and a
  `channel-policy` invalidation for the channel, and drops the fan-out
  cache.
- Every v1/v2 route, every event and every command/interaction check uses
  ONE helper: `botCanAccessChannel(bot, channelId)` in
  `apps/web/lib/bots/access.ts` (and a gateway twin reading the same
  query from `packages/db`).
- As implemented: the rule itself lives in `@lobbyforge/db`
  (`getBotReachableChannel(db, { id, serverId }, channelId)` and
  `listBotReachableChannels(db, { id, serverId })`); the web helper and the
  gateway both call those. The built-in bots go through it too
  (`postBotMessage`), so v1 behaviour for a bot in mode `all` (every bot
  until a manager chooses channels) is unchanged. The fan-out cache and the
  composer list read the mode from the bot row they already load
  (`BotEventTarget.channelAccessMode`, `ServerCommandRow.bot.channelAccessMode`)
  and decide with `botReachesChannel({ mode, granted, channelId,
  openToBots })` — no query per bot.
- Managing access (Manage Community, like every bot setting):
  `GET|PUT /api/servers/{id}/bots/{botId}/channel-access` — PUT
  `{ channelIds: [...] | null }`, mode and rows written in ONE transaction
  (`null` = mode `all`, rows deleted; a list = mode `selected`, exactly
  those, 1..500 — rows not in it deleted, new ones inserted). A mode switch
  with no grant added or removed (`selected` with no channel → `null`) is a
  change too. Response `{ access: { mode: 'all' |
  'selected', channels: [{ id, name, type, position, gated, granted,
  reachable, grantable }], hiddenGrantCount } }`. Only text/announcement
  channels the MANAGER can see are listed; a grant on a private channel
  they cannot see is only counted, and a replace keeps it as it is
  (switching to `null` over such a grant needs Manage Channels: 403
  `cannot_change_hidden_access`). Adding a role-gated channel without
  Manage Channels → 403 `cannot_grant_channel`; a channel that is not an
  eligible, visible text channel → 400 `invalid_channel`.
  `PUT|DELETE …/channel-access/{channelId}` grant / revoke one channel in
  `selected` mode only (409 `access_mode_all` otherwise — one grant would
  silently narrow "every open channel" to one; the grant itself re-checks
  the mode under a row lock on the bot, so a concurrent switch to `all`
  leaves no stray row). Revoking the LAST grant is allowed and leaves the
  bot with no channel (there is no `last_channel_access` refusal any more:
  with the mode stored, two concurrent revokes cannot widen anything).
  Every change: audit `bot.channel_access` `{ mode, added, removed }`, an
  access invalidation `{ kind: 'bot-access', serverId, botId, reason:
  'channel_access_changed' }`, and `channel_access_changed` to the bot's
  endpoint (the gateway re-sends it on the stream).
- The bot admin routes also publish `bot-access` invalidations:
  `permissions_changed` / `enabled_changed` (PATCH), `deleted` (DELETE),
  `token_changed` (token rotate / revoke).
- Hidden grants and the token (security review BOT-001): whoever holds the
  token reads and posts in every channel the bot reaches. So a manager
  without Manage Channels for whom the bot has hidden grants (role-gated
  channels they cannot see) can neither issue / rotate its token (`POST
  …/bots/{botId}/token`) nor add `read_messages`, `send_messages`,
  `receive_events` or `slash_commands` to it (`PATCH …/bots/{botId}`):
  403 `cannot_take_hidden_access` (the refusal names no channel). Owner,
  administrators and Manage Channels holders see every channel, so they
  are never refused; revoking the token or removing a permission is always
  allowed. Code: `refuseHiddenChannelReach` in `lib/bots/admin.ts`, over
  the same `loadChannelAccessView` as the channel-access routes.

### 1.2 New bot permissions (`@lobbyforge/bot-sdk` `BotPermission`)
| id | needs from the granter (core) | allows |
|---|---|---|
| `slash_commands` | `SEND_MESSAGES` | register commands, receive and answer interactions |
| `read_members` | none | `member_join` / `member_leave` events, member lookups |
| `receive_events` | none | open the event stream / set an event webhook |

Existing ids (`read_messages`, `send_messages`, …) keep their meaning:
message events need `read_messages`; answering publicly needs
`send_messages`. Grants follow `findUngrantableBotPermissions`.

### 1.3 Webhook management
Creating, rotating or deleting a channel's **incoming webhook** needs core
`MANAGE_CHANNELS` (+ seeing the channel). Configuring a bot's **outgoing
event URL** needs the same rights as managing the bot (`requireBotManager`).

## 2. Database (migration `0044_bot_api_v2.sql`)

All new tables cascade on server/bot/channel delete. Secrets are hashed
unless the server must sign with them.

```
bots.channel_access_mode text NOT NULL DEFAULT 'all'
                   CHECK (channel_access_mode IN ('all', 'selected'))  -- §1.1

bot_channel_access(bot_id uuid FK bots, channel_id uuid FK channels,
                   granted_by uuid FK users SET NULL, created_at,
                   PK(bot_id, channel_id))      -- used in 'selected' mode only

bot_commands(id uuid PK, bot_id FK bots, server_id FK servers,
             name text            -- ^[a-z0-9_-]{1,32}$, unique per server
             description text     -- 1..100 chars
             options jsonb        -- ≤ 25 options, see §3.1
             channel_ids jsonb    -- null = every channel the bot can access;
                                  -- else a subset of them
             required_permission text NULL  -- a CorePermission id the
                                  -- INVOKER must hold (e.g. 'kick_members')
             enabled boolean default true,
             created_at, updated_at,
             UNIQUE(server_id, name))

bot_command_overrides(bot_id FK bots CASCADE, name text,  -- the managers'
             enabled boolean NOT NULL default true,       -- switches per
             admin_channel_ids jsonb NULL,                -- (bot, name): they
             updated_by FK users SET NULL, updated_at,    -- outlive the row
             PK(bot_id, name))

bot_interactions(id uuid PK, bot_id FK, command_id FK bot_commands SET NULL,
             server_id FK, channel_id FK, user_id FK users,
             command_name text, options jsonb,
             status text  -- 'pending' | 'answered' | 'expired' | 'failed'
             response jsonb NULL, -- {content, ephemeral}
             created_at, answered_at, expires_at)   -- expires_at = +15 min
             -- indexes: (bot_id, status, expires_at), (user_id, created_at),
             -- (channel_id), (command_id) WHERE command_id IS NOT NULL

channel_webhooks(id uuid PK, server_id FK, channel_id FK,
             name text (1..32), token_hash text  -- sha256$<hex>, token shown once
             enabled boolean default true, created_by FK users SET NULL,
             created_at, last_used_at)

bot_event_endpoints(bot_id uuid PK FK bots, url text (https only, ≤ 512),
             secret text      -- HMAC key the instance signs with (shown once,
                              -- stored because the server must sign)
             events jsonb     -- subset of §4 event names
             enabled boolean, failure_count int default 0,
             disabled_reason text NULL, last_delivery_at, last_status int NULL,
             created_at, updated_at)
```

As implemented (`packages/db/drizzle/0044_bot_api_v2.sql`, expand-only —
six new tables and one added column on `bots` (NOT NULL with a constant
default, so no rewrite and a rollback-safe previous image) — idempotent
(`IF NOT EXISTS` everywhere, `ADD COLUMN IF NOT EXISTS`); queries in
`packages/db/src/queries/bot*.ts` and `channelWebhooks.ts`), with these
additions to the contract above:
- `bots.channel_access_mode` — the stored §1.1 mode (see §1.1).
- `bot_commands.admin_channel_ids jsonb NULL` — the **managers'** channel
  restriction (Admin → Bots → commands). The bot owns `channel_ids`; a
  command runs only where BOTH lists allow it. A bot re-registering its
  commands (`PUT /commands`) never resets `enabled` or
  `admin_channel_ids`, so a bot cannot undo a manager's switch — not even
  with `DELETE /commands/{name}` (or an empty `PUT`) followed by a
  re-register: the admin PATCH writes the row AND
  `bot_command_overrides(bot_id, name)` in one transaction, and a newly
  inserted command takes `enabled` / `admin_channel_ids` from that
  override (defaults only for a name no manager touched). Overrides go
  with the bot.
- `bot_interactions` indexes on `channel_id` and (partial) `command_id`
  for the channel-delete cascade and the command-delete `SET NULL`. No
  `server_id` index: no path deletes by server (servers are soft-deleted;
  a hard delete also cascades through `bots`, whose `bot_id` is indexed).
- `bot_interactions.followup_count int` (CHECK 0..5) — the follow-up cap.
- `channel_webhooks.updated_at`.
- `bot_interactions.user_id` is `ON DELETE CASCADE` (an interaction
  belongs to its invoker); every other user reference is `SET NULL`.
- SQL CHECKs back the app's validation: the access mode, command/option
  (and override) name pattern, description 1..100, ≤ 25 options (array),
  interaction status values,
  webhook name 1..32 and `token_hash ~ '^sha256\$[0-9a-f]{64}$'`, endpoint
  URL `https://…` ≤ 512, secret 32..128 characters.
- No DB-level check ties `bot_commands.server_id` / `bot_channel_access`
  rows to the bot's server; the routes set and check it, and the access
  rule ignores a grant pointing at another server's channel.

## 3. Slash commands and interactions

### 3.1 Command shape (Bot API)
```json
{ "name": "roll", "description": "Roll dice",
  "options": [ { "name": "sides", "description": "Sides", "type": "integer",
                 "required": false, "min": 2, "max": 1000 },
               { "name": "who", "type": "user" },
               { "name": "mode", "type": "string",
                 "choices": [ {"name": "Public", "value": "public"} ] } ],
  "channelIds": null, "requiredPermission": null }
```
Option types: `string` (≤ 1000 chars, optional `choices` ≤ 25),
`integer`, `number` (optional `min`/`max`), `boolean`, `user` (a member
of the server), `channel` (a channel the INVOKER can see). Option names
follow the command name rule; required options first.

### 3.2 Bot API v2 routes (bot token, `Authorization: Bot <token>`)
Base path `/api/bot/v2` (v1 routes stay at `/api/bot/v1`; `/v2/me`,
`/v2/channels`, `/v2/channels/{id}/messages` behave like v1 but honour
§1.1).

| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | `/commands` | `slash_commands` | this bot's commands |
| PUT | `/commands` | `slash_commands` | bulk overwrite (≤ 50 per bot); 409 `command_name_taken` if another bot of the server owns a name |
| DELETE | `/commands/{name}` | `slash_commands` | |
| POST | `/interactions/{id}/respond` | `slash_commands` (+ `send_messages` unless ephemeral) | body `{content (1..4000), ephemeral?: boolean}`; once per interaction (409 after); 410 `interaction_expired` after 15 min |
| POST | `/interactions/{id}/followup` | same | more messages for the same interaction within 15 min (≤ 5) |
| GET | `/gateway` | `receive_events` | `{ url }` of the event stream (§4) |
| PUT | `/event-endpoint` / DELETE | `receive_events` | set/remove the outgoing webhook URL; PUT returns the signing secret ONCE |
| GET | `/members/{userId}` | `read_members` | `{ id, displayName, roles[], joinedAt }` |

Rate limits (per bot): commands PUT 5/min, respond+followup 60/min,
other reads 60/min. All responses use the v1 error shape `{error, code}`.

As implemented (`apps/web/app/api/bot/v2/**`, on v1's pipeline in
`lib/bots/api.ts`):
- `/v2/me`, `/v2/channels`, `/v2/channels/{id}/messages` re-export the v1
  handlers (same budgets). A message object may carry two optional extra
  fields (v1 too): `webhook: { id, name }` on an incoming-webhook post
  (its `author` stays `{ type: 'unknown' }`, so v1 clients see no new
  author type) and `interaction: { id, commandName }` on a command answer.
- `GET /commands` → `{ commands: [{ id, name, description, options,
  channelIds, requiredPermission, enabled, createdAt, updatedAt }] }`; it
  also sweeps this bot's overdue interactions to `expired`.
- `PUT /commands` body: the list as a JSON array OR `{ commands: [...] }`
  (exactly that key); ≤ 256 KiB. Validation: names `^[a-z0-9_-]{1,32}$`,
  description 1..100, ≤ 25 options, option names unique, required options
  first, `min`/`max` only on integer/number (integers for integer),
  `choices` (1..25, unique, typed like the option) on string, integer or
  number, `requiredPermission` a CorePermission id, `channelIds` (1..500)
  only channels the bot reaches. Errors: 400 `invalid_request` with
  `issues: ["0.options.1.name: …"]`; 409 `command_name_taken` with
  `names` (nothing is written — also when a concurrent registration wins
  the race). Response `{ commands }`.
- `DELETE /commands/{name}` → `{ ok: true }`; 404 `not_found`.
- `respond` / `followup` → `200 { interaction: { id, status: 'answered',
  followupCount }, message? }` (`message` for a public answer). Codes:
  404 `not_found` (unknown id, another bot's id, or the channel is no
  longer reachable), 409 `interaction_already_answered`, 409
  `interaction_not_answered` (a follow-up needs the respond first), 409
  `followup_limit_reached` (5), 409 `interaction_failed` (also returned —
  and the interaction marked `failed` for good, its stored answer dropped —
  when an EPHEMERAL answer's invoker is no longer a member or can no
  longer see the channel), 410
  `interaction_expired`, 403 `missing_permission` (`slash_commands`, or
  `send_messages` for a public answer), 403 `mass_mention_forbidden`
  (public answers only — an ephemeral one pings nobody). Checks run before
  the atomic claim, so a refused answer never consumes it; a public post
  that fails gives the claim back.
- `GET /gateway` → `{ url }`: `LOBBYFORGE_PUBLIC_BOT_GATEWAY_URL` if set,
  else the realtime URL (`LOBBYFORGE_PUBLIC_WS_URL`) with `/ws/bot`
  (`wss://host/ws` → `wss://host/ws/bot`), else `wss://<host>/ws/bot`
  behind HTTPS / `ws://<host>:19521/ws/bot` on plain HTTP.
- `GET /event-endpoint` (added) → `{ endpoint: {...} | null }`, never the
  secret. `PUT /event-endpoint` `{ url, events? }` → `{ endpoint, secret }`:
  **every PUT issues a new secret** (returned only there; the old one stops
  signing at once) and re-arms a disabled endpoint — including one switched
  off by a token rotate / revoke (`token_changed`, §5.2): that is how the
  new token's holder takes it over. `events` ⊆ the §4.2
  names minus `ready`; default = everything the bot's permissions allow.
  A URL that is not https, has credentials, has a single-label host
  (`https://postgres/`, `https://web./` — names that only resolve inside a
  private network; IP literals are judged by their address), or does not
  resolve within 2 s only to public addresses → 400 `invalid_endpoint`.
  The `error` for the address case is always the same sentence ("url must
  resolve, over public DNS, to public internet addresses only"): never the
  address it resolved to, the host name or the resolver's error — those go
  to the server log only (security review BOT-004). The save is bound to
  the token that authenticated it: if that token was rotated or revoked
  while the request ran (say during the DNS check), nothing is saved →
  401 `unauthorized`. PUT/DELETE share 10/min.
  Audit `bot.event_endpoint.set` / `.remove` (host + events, never the
  secret).
- `GET /members/{userId}` → `{ member: { id, displayName, nickname,
  roles: [{ id, name }], joinedAt } }` (roles highest first); 404 for
  anyone who is not a member of the bot's server.

### 3.3 Running a command (member side)
`POST /api/servers/{id}/channels/{channelId}/commands/{commandId}/invoke`
body `{ options: { [name]: value } }` → checks, in order: member can see
the channel and has `SEND_MESSAGES` (timeouts block it), command enabled
in this channel, invoker holds `requiredPermission` (owner bypass), bot
enabled + has `slash_commands` + `botCanAccessChannel`, options validate
against the schema (server-side, never trust the client). Creates a
`bot_interactions` row and delivers `interaction_create` (§4) — with the
invoker's language as `locale` (§3.5) — to the bot's stream AND its event
endpoint (whichever exist). Response `202
{ interaction: { id, status: 'pending' } }`. Rate limit 20/min per user.

`GET /api/servers/{id}/commands?channelId=` → the commands this member
may run in that channel (name, description, options, bot {id,name}) for
the composer's autocomplete.

As implemented:
- invoke answers `202 { interaction: { id, status: 'pending',
  commandName, channelId, bot: { id, name }, expiresAt } }`. Refusals, in
  check order: 401; 403/404 from the channel check (membership,
  visibility, `SEND_MESSAGES`); 403 `timed_out` (`until`); 404
  `command_not_found` (unknown or another server's); 403
  `command_disabled`; 404 `command_not_available` (not allowed in this
  channel by the bot's or the managers' list, or the bot cannot reach the
  channel); 403 `missing_permission` (`permission`); 409 `bot_unavailable`
  (bot disabled, not custom, or without `slash_commands`); 400
  `invalid_options` with `issues: ["<option>: …"]` (unknown names, types,
  ranges, choices, ≤ 1000-char strings, `user` not a member of this
  server, `channel` not visible to the invoker); 422
  `blocked_by_moderation` (`rule`); 409 `bot_offline` (`bot: { id, name }`)
  — nobody is there to answer: the bot has no live event-stream connection
  and no enabled event endpoint subscribed to `interaction_create` (or it
  lacks `receive_events`, which both need). Nothing is written or audited,
  so the member sees "offline" at once instead of a "thinking…" row that
  expires 15 minutes later. A bot WITH such an endpoint keeps the 202 (the
  endpoint is called whether its process is up or not). "Live connection"
  is read from Redis: the gateway subscribes to `lf:{env}:bot-events:{botId}`
  exactly while a connection for the bot is open, so `PUBSUB NUMSUB` on that
  channel counts them (`lib/bots/reachability.ts`); when Redis cannot be
  asked, the run goes ahead as before. 429 `rate_limited` (20/min per
  member, plus 60/min per address).
- "Exactly like member posts" for the Moderation Bot means its CONTENT
  rules (blocked words, links, mentions) run over the free-text string
  options (choice values come from the bot's list); its counting rules
  (flood / repeat) do not — the per-member invoke limit stands in for
  them, so running `/roll` four times is not a "repeat". Bans need no
  extra check: a banned user has no membership.
- Audit `command.invoke` (actor = member, target = bot, with command name,
  channel and interaction id).
- The composer list answers `{ commands: [{ id, name, description,
  options, bot: { id, name, online } }] }`, ordered by bot then name, and hides
  everything the invoke would refuse on the command's side (disabled
  command or bot, missing `slash_commands`, channel restrictions, bot
  access, `requiredPermission` the member lacks). A fixed number of
  queries whatever the number of bots.
- `online: false` means the invoke would answer `bot_offline` right now
  (no live event stream, no endpoint taking `interaction_create`). The
  picker keeps those commands listed after the online bots, greyed out
  under an "Offline" label, and the keys and clicks skip them. Same rule
  and same fail-open as the invoke check, in one endpoint query and one
  `PUBSUB NUMSUB`.
- Managers: `GET /api/servers/{id}/bots/{botId}/commands` → `{ commands:
  [... + adminChannelIds] }`; `PATCH …/commands/{commandId}` `{ enabled?,
  channelIds?: [...] | null }` (the managers' restriction, text channels
  of this server) → `{ command }`, audit `bot.command.update`; the
  resulting state is also kept in `bot_command_overrides` (bot, name), so
  it survives the bot deleting and re-registering the command.

### 3.4 Answers
- **Public** answer → a normal bot message in the channel
  (`messages.bot_id` set) with `metadata.interaction = { id, commandName,
  invokedBy }`; the chat shows "↳ <user> used /<name>".
- **Ephemeral** answer → never stored as a message; kept in
  `bot_interactions.response` and pushed to the invoker only, on the
  gateway topic `user:{uid}` (§4.3). The client shows it inline in that
  channel, marked "Only you can see this", until reload.
- No answer within 15 min → status `expired`; the invoker's pending row
  shows "<bot> did not respond".
- An endpoint delivery (§5.2) may answer synchronously: a 200 response
  body `{ "type": "respond", "content": "...", "ephemeral": true }` within
  3 s counts as the respond call.
- As implemented: the sync answer goes through exactly the respond checks
  (current bot row, `send_messages` for a public answer, once only). A
  public answer stores `metadata.interaction = { id, commandName,
  invokedBy: { id, displayName }, followup? }`. An ephemeral answer is
  published on `lf:{env}:user-events:{uid}` as `{ type:
  'interaction_response', interaction: { id, serverId, channelId,
  commandName, bot: { id, name } }, response: { content, ephemeral: true,
  followup }, at }` — only after re-checking that the invoker is still a
  member (a ban removes the membership) who can see the channel
  (`authorizeChannelMessageAccess`, `mutate`); otherwise nothing is
  published, the interaction becomes `failed` (response cleared) and the
  bot gets 409 `interaction_failed`. A public answer is a channel message
  and is not re-checked against the invoker. Expiry is lazy: an overdue
  pending row turns
  `expired` when answered (410), when its bot lists its commands, and
  when a new command of that bot runs; no `interaction_status` event is
  published (the client times the pending row out at `expiresAt`).
- **Retention** (`pruneBotInteractions`, run with that same sweep — on
  invoke and on `GET /commands` — at most once a minute per bot and
  process): a row past `expires_at` keeps no answer text (`response` →
  NULL; nothing can follow up any more, and an ephemeral answer only lived
  there), and a row more than **24 h past `expires_at`** is deleted (a late
  respond then gets 404 instead of 410). Both statements are scoped to the
  bot and use `idx_bot_interactions_bot_status`. A bot that is never
  invoked and never lists its commands keeps its old rows until it is
  (or until it is deleted — rows cascade with the bot, channel, server and
  invoker).

### 3.5 Languages
Every `interaction_create` carries **`locale`**: the language of the member
who ran the command. It's the language their LobbyForge is shown in: the
language saved on their account, else their choice on this device, else
their browser's (`Accept-Language`), else the instance default (the
admin's choice, else `LOBBYFORGE_DEFAULT_LOCALE`), else `en`.

- It's always one of the instance's installed language codes (BCP 47, the
  folder names under `apps/web/messages`): `en`, `tr`, `pt-BR`, …
- The event stream and endpoint deliveries carry the same value. A
  synchronous endpoint answer (§3.4) can use it too.
- It's additive: a bot that ignores it is unaffected. Instances older than
  this field don't send it, so treat a missing `locale` as your default
  language.
- Only interactions carry it. A channel message is read by everyone, and
  member events are about someone else, so `message_*` and `member_*`
  events carry no language: answer those in your bot's default language.
- Command names, descriptions and choices are registered once, in one
  language, and the composer shows them to everyone as registered.

With the SDK's locale helpers (`tFor` falls back to English, and
`pickBestLocale` maps `pt-BR` to a bot that only ships `pt`):

```js
import { LobbyForgeBot, loadBotLocale, pickBestLocale, tFor } from '@lobbyforge/bot-sdk';

loadBotLocale('dice', {
  en: { 'roll.result': '{name} rolled {result} ({sides}-sided)' },
  tr: { 'roll.result': '{name} {result} attı ({sides} yüzlü)' },
});

bot.on('interaction', async (interaction) => {
  const sides = interaction.options.sides ?? 6;
  const result = 1 + Math.floor(Math.random() * sides);
  const locale = pickBestLocale('dice', interaction.locale); // or tFor('dice', interaction.locale, …) directly
  await interaction.reply(
    tFor('dice', locale, 'roll.result', { name: interaction.user.displayName, result, sides })
  );
  // tr → "Ayşe 18 attı (20 yüzlü)", anything else → "Ayşe rolled 18 (20-sided)"
});
```

`packages/bot-sdk/examples/roll-text.mjs` is the complete version, used by
both example bots.

The built-in bots solve the same problem the other way round. They post
one message for everyone, so they store the catalogue key of a default
text with it, and each member's lobby renders that key in the member's
language ([BOTS.md → Built-in bots](BOTS.md#built-in-bots)).

## 4. Event stream (WebSocket gateway)

Implemented in `apps/ws-gateway/src/bot-gateway.ts`. The wire types live in
`apps/ws-gateway/src/bot-protocol.ts`, and the SDK ships a byte-identical
copy (`packages/bot-sdk/src/gateway-protocol.ts`); a test pins the two
files together.

### 4.1 Connecting
Bots connect to the gateway at **`/ws/bot`** (a separate path; browser
sessions keep `/ws`). No cookie and no Origin are needed on this path
(any Origin is accepted: the token is the only credential). The bot
authenticates EITHER with the upgrade header
`Authorization: Bot <token>` (libraries such as `ws`) OR — because the
standard WebSocket API (browsers, Node's global `WebSocket`) cannot set
headers — by sending `{ "type": "identify", "token": "lfb_…" }` as its
first message within 5 s. If a header is sent, it alone decides (a
malformed one fails). Anything else first, or nothing, closes the
socket with `4001`. The gateway verifies the token like the REST API
(same `sha256$` hash and constant-time compare, a test pins the gateway
copy to `apps/web/lib/bots/token.ts`; custom bot; server not deleted),
then checks bot enabled + `receive_events`, and sends
`{ type: 'hello', ok: true, bot: { id, serverId }, at }` followed at once
by the `ready` event. It subscribes the connection to the bot's own feed
automatically — the bot does not choose topics. One connection per bot
(a new one closes the old with `4009`).

Every refusal sends an error frame first, then closes:
`{ type: 'error', code, message, permission? }` with `code` one of
`unauthorized`, `bot_disabled`, `missing_permission` (+ `permission`),
`replaced`, `rate_limited`, `bad_message`, `internal_error`. Database
errors are never echoed.

**Close codes** (`BotCloseCode`):

| code | meaning | client should |
|---|---|---|
| 1000 | normal close | — |
| 1001 | gateway shutting down | reconnect |
| 1011 | gateway could not reach the database during the handshake/feed setup | reconnect with backoff |
| 4001 | no/invalid token, identify timeout, wrong first message; later: token rotated or revoked, bot deleted | stop |
| 4003 | bot disabled, or lacks `receive_events` (at connect or later) | stop |
| 4009 | replaced by a newer connection for the same bot | stop |
| 4029 | too many failed identifies from this address, too many connects for this bot, or too many messages | back off (SDK: ≥ 10 s) |
| (1006) | terminated after two missed heartbeats — no close frame | reconnect |

**Heartbeat**: every 30 s the server sends a protocol ping AND a
`{ type: 'heartbeat', at }` frame (the standard WebSocket API hides
pings, so SDKs watch for frames); a bot that misses two pongs is
terminated. After identifying, the only message a bot may send is
`{ type: 'ping' }` (answered with `{ type: 'pong', at }`); anything else
gets `bad_message`, and more than 60 messages a minute closes with 4029.

**Limits** (per gateway process, env-configurable):

| What | Default | Env |
|---|---|---|
| `/ws/bot` sockets per address (counted apart from browser sockets; over it the upgrade gets HTTP 429) | 10 | `WS_BOT_MAX_CONN_PER_IP` |
| failed identifies per address per minute (then 4029 before any lookup) | 30 | `WS_BOT_AUTH_FAIL_MAX` |
| successful connects per bot per minute | 30 | `WS_BOT_CONNECT_MAX` |
| identify timeout | 5 s | `WS_BOT_IDENTIFY_TIMEOUT_MS` |
| channels one bot hears | 500 | `WS_BOT_MAX_CHANNELS` |
| events waiting for a DB read per connection (then dropped — at-most-once) | 500 | `WS_BOT_MAX_PENDING_EVENTS` |
| messages a bot may send per minute after identifying (then 4029) | 60 | `WS_BOT_INBOUND_MAX` |

The one-connection rule and these limits are kept in memory per gateway
process. The shipped stacks run one gateway; a deployment with several
gateway replicas behind a load balancer would let a bot hold one stream
per replica until a shared (Redis) registry is added.

**Freshness**: the bot row is re-read and the channel set recomputed on
every `bot-access` invalidation for that bot, on every `channel-policy` /
`server-policy` invalidation in its server, and every 30 s (the periodic
re-authorization sweep, in case Pub/Sub lost an invalidation). A rotated
or revoked token, or a deleted bot, closes with 4001; a disabled bot or a
lost `receive_events` with 4003. A lost `read_messages` /
`read_members` / `slash_commands` just stops those events. If the
database cannot answer during an invalidation, the connection fails
closed: its message subscriptions are dropped until a later recheck
succeeds.

### 4.2 Events (`{ type: 'event', topic: 'bot', data: { event, … }, at }`)
| event | needs | data |
|---|---|---|
| `ready` | — | `{ bot: {id, name, serverId, permissions}, channels: [{id,name}] }` (the §1.1 set) |
| `message_create` | `read_messages` + channel access | `{ message: {id, channelId, content, author:{id, displayName, bot?, webhook?}, createdAt, editedAt, replyToId} }` — never the bot's own messages |
| `message_update` | same | `{ message }` (same shape, `editedAt` set) |
| `message_delete` | same | `{ id, channelId }` |
| `member_join` | `read_members` | `{ member: {id, displayName} }` |
| `member_leave` | `read_members` | `{ member: {id, displayName}, reason? }` (`leave` / `kick` / `ban`) |
| `interaction_create` | `slash_commands` + channel access | `{ interaction: {id, commandId, commandName, options, channelId, user:{id,displayName}, locale, expiresAt} }` (`locale`: the invoker's language, §3.5) — forwarded only while the bot still reaches `channelId` (its live channel set; beyond a capped set, one database check) |
| `channel_access_changed` | — | `{ channels }` — sent after every `bot-access` invalidation for the bot, and whenever a policy change or the periodic recheck changes its set |

A poll posted in a text channel (docs/CHAT_POLLS.md) reaches bots as a
plain `message_create` whose `content` is the question. Bots get no poll
data, no vote counts and no `poll_update` events.

Message `author`: a member → `{ id, displayName }`; a bot →
`{ id, displayName, bot: true }`; an incoming webhook →
`{ id: <webhook id>, displayName: <username or webhook name>, webhook: true }`;
a deleted user → `{ id: null, displayName: null }`. On the stream
`editedAt` and `replyToId` are always present (null when unset); endpoint
deliveries (§5.2) omit them when null.

Each permission is checked when the event is about to be sent (and again
after any database read), against the bot's latest row. Events are
at-most-once; there is no replay. A bot that reconnects backfills with
`GET /channels/{id}/messages?before=`.

### 4.3 Internal plumbing (web → gateway)
- Message events reuse the chat bus (`apps/web/lib/chat-bus.ts`): the
  gateway subscribes each bot connection to the chat topics of its
  channels on the ONE shared Redis subscriber, treats a bus payload as a
  trigger only, and loads the message by id from the database (content,
  author, deleted state, and that it belongs to the topic's channel)
  before anything is sent. Envelopes it understands:
  `{ type: 'message', message: { id, botId? }, at }` (every new message),
  `{ type: 'message_update', message: { id, botId? }, at }` (a text edit)
  and `{ type: 'message_delete', id, botId?, at }` (a delete), published
  by the single-message route through `publishChatMessageUpdate` /
  `publishChatMessageDelete` in `chat-bus.ts`; `botId` is the AUTHOR bot.
  Browser consumers act only on `type: 'message'`. A delete always names
  the topic's channel, never the publisher's claim.
- Endpoint deliveries of message events do NOT come from the gateway: the
  web app fans them out where messages are created, edited and deleted
  (`emitMessageEvent` in `lib/bots/events.ts`, called by the member
  messages routes, `postBotMessage` and the webhook route). Who listens is
  decided from a per-server cache (enabled custom bots, permissions,
  endpoint, explicit grants: two queries per fill, 15 s, dropped at once
  in the process that changed something) plus at most one role-gate
  lookup per message — never a query per bot. Each queued delivery
  re-reads the bot and re-checks `read_messages` + channel access before
  every attempt (retries included), so a stale cache cannot leak a message.
- Access changes publish on `lf:access-invalidation`:
  `{ kind: 'bot-access', serverId, botId, reason }`. Browser
  subscriptions ignore it.
- Interactions and member events publish on Redis channel
  `lf:{env}:bot-events:{botId}` with the §4.2 `data` payload (a payload
  wrapped as `{ data }` is accepted too). The gateway forwards only
  `member_join`, `member_leave` and `interaction_create` from it, so
  `ready`, `channel_access_changed` and message events cannot be injected
  there; an `interaction_create` whose `channelId` is malformed or not
  reachable by the bot any more is dropped.
- A bot in `selected` mode with no grant left has an EMPTY feed (`ready` /
  `channel_access_changed` with `channels: []`), never the v1 rule. A
  gateway running against a database without 0044 (no mode column / grants
  table: Postgres `42703` / `42P01`) applies the v1 rule — the only mode
  that can exist then.
- Ephemeral answers publish on `lf:{env}:user-events:{uid}` and reach the
  browser on the gateway topic `user:{uid}` (subscribe like any topic;
  authorised only when the session uid equals `{uid}`, with no database
  read; no server/channel invalidation revokes it). The payload is
  forwarded verbatim: `{ type: 'event', topic: 'user:{uid}', data, at }`.

## 5. Webhooks

### 5.1 Incoming (channel webhooks)
- Created in channel settings (admin) → URL
  `https://<instance>/api/webhooks/{webhookId}/{token}` (token shown once).
- `POST` JSON `{ "content": "…", "username": "optional display name (≤ 32)" }`
  (a Discord-style subset; unknown fields ignored). `?wait=true` returns
  `{ message }`, else `204`.
- Machine route (`withMachineApiSecurity`, no Origin), 30 posts/min per
  webhook, 4000 chars, `@everyone/@here` refused, the Moderation Bot's
  content rules apply. Messages are stored with `user_id NULL`,
  `metadata.webhook = { id, name, username? }` and render with a
  WEBHOOK badge. A disabled or deleted webhook answers 404.
- As implemented (`app/api/webhooks/[webhookId]/[token]/route.ts`,
  `lib/bots/webhooks.ts`): the token is `lfw_` + 43 base64url characters
  (256 random bits), stored as a domain-separated `sha256$<hex>` and
  compared in constant time. A wrong token, an unknown, disabled or
  deleted webhook, a soft-deleted server and a channel that is gone or no
  longer a text channel all answer the same 404 `not_found`. Limits: 30
  posts/min per webhook (once the token checks out), 120 lookups/min and
  30 failed attempts/min per client address, 16 KiB body. `username` is
  1..32 characters, whitespace collapsed, then refused if it still holds a
  control, invisible-formatting or separator character (`/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u`:
  zero-width characters, bidi embeddings / overrides / isolates
  U+202A–202E and U+2066–2069, U+061C, U+180E, U+FEFF, soft hyphen …) —
  the same rule as a webhook's own name. The token is in the URL path, so
  the shipped nginx config serves `/api/webhooks/` from its own location
  with `access_log off`. Content
  is trimmed, 1..4000. `?wait=true` → `200 { message }` in the Bot API
  message shape (with `webhook: { id, name }`). Every post is audited as
  `message.create` (`webhookId`); a Moderation Bot block is audited as
  `bot.moderation.block` with `targetType: 'webhook'` (content rules only,
  no staff exemption — a webhook has no roles). A member's message carries
  no client metadata at all — the member messages API refuses `metadata`
  outright (400 `metadata_not_allowed`) — so `webhook` and `interaction`
  cannot be forged.
- Managing (Manage Channels + seeing the channel; text / announcement
  channels only; ≤ 10 per channel → 409 `webhook_limit_reached`):
  `GET|POST /api/servers/{id}/channels/{channelId}/webhooks` (POST
  `{ name }` → `201 { webhook, token, path, url }` — the only time the
  token is returned), `PATCH|DELETE …/webhooks/{webhookId}`
  (`{ name?, enabled? }`), `POST …/webhooks/{webhookId}/token` (rotate →
  `{ webhook, token, path, url }`; the old URL stops at once). The URL's
  origin is `LOBBYFORGE_APP_ORIGIN`, else `NEXT_PUBLIC_BASE_URL`, else the
  request's (checked) Origin. Audit `webhook.create`, `webhook.update`,
  `webhook.enable`, `webhook.disable`, `webhook.token.rotate`,
  `webhook.delete` — never the token.

### 5.2 Outgoing (bot event endpoint)
- HTTPS only; the URL is checked with the IP-pinned SSRF transport
  (`apps/web/lib/ip-pinned-https.ts`) — private, loopback, link-local and
  metadata addresses are refused at save time AND at delivery time.
- **A token rotate or revoke switches the endpoint off** (security review
  BOT-002). Its URL and signing secret were set by whoever held the old
  token, so in the same transaction as the new token hash
  (`changeBotToken`) the endpoint gets `enabled = false`,
  `disabled_reason = 'token_changed'` (audited `bot.event_endpoint.disable`
  `{ reason: 'token_changed' }`). From then on it is offline everywhere:
  no event or interaction is delivered (a queued retry is dropped), a
  synchronous answer that arrives after the change is not applied, and a
  command of the bot answers 409 `bot_offline` unless its event stream is
  connected (the stream opened with the old token was closed). A manager
  cannot re-enable it (PATCH → 409 `endpoint_token_changed`); the bot sets
  it again with its new token (`PUT /event-endpoint`, new secret), or a
  manager removes it (DELETE).
- Each delivery: `POST <url>` with body `{ id, event, timestamp, data }`
  and headers
  `X-LobbyForge-Event`, `X-LobbyForge-Delivery` (uuid),
  `X-LobbyForge-Timestamp` (unix s),
  `X-LobbyForge-Signature: v1=<hex HMAC-SHA256(secret, timestamp + "." + body)>`.
  Receivers must reject timestamps older than 5 min (SDK does).
- Timeout 3 s, 3 attempts with backoff (1 s, 5 s, 30 s) for 5xx/timeouts;
  after 20 consecutive failures the endpoint is disabled
  (`disabled_reason`) and the bot manager sees it in the admin UI.
- Interactions are delivered first and may be answered synchronously
  (§3.4).
- As implemented (`lib/bots/event-delivery.ts`):
  - the secret is `whsec_` + 43 base64url characters; the HMAC key is that
    string as UTF-8. `body.id` equals `X-LobbyForge-Delivery` and
    `body.timestamp` equals `X-LobbyForge-Timestamp`; retries resend the
    IDENTICAL bytes (same id, timestamp, signature) so a receiver can
    dedupe on the id;
  - "3 attempts with backoff (1 s, 5 s, 30 s)" = the first attempt, then
    up to three retries after 1 s, 5 s and 30 s (≈ 36 s in all, inside the
    5-minute window). Retried: timeouts, network errors, 408, 429, 5xx.
    Final at once: any other status, and a host that now resolves to a
    private address (the rebinding case — refused, not retried). A
    delivery that ends in failure counts ONCE; a success resets the
    counter; the 20th consecutive failure sets `enabled = false`,
    `disabled_reason = 'too_many_failures'` and is audited
    (`bot.event_endpoint.disable`). Managers: `GET|PATCH|DELETE
    /api/servers/{id}/bots/{botId}/event-endpoint` (status without the
    secret; PATCH `{ enabled: true }` re-enables and resets the counter —
    never for `token_changed`, 409 `endpoint_token_changed`; DELETE removes
    it; audit `bot.event_endpoint.enable` / `.remove`);
  - every attempt re-resolves DNS — with the c-ares resolver and a 2 s
    deadline, so a stalling authoritative server costs one cancelled
    query, not a libuv threadpool thread (the pool is shared with scrypt;
    the container also raises `UV_THREADPOOL_SIZE` to 16) — refuses
    private / loopback / link-local / CGNAT / ULA / metadata addresses
    (and single-label hosts) and pins the connection to the addresses it
    checked; redirects are never followed; the answer is read up to 64 KiB;
  - the queue is in-process and NOT durable: deliveries waiting for a
    retry are lost when the web process restarts (events are
    at-most-once). It is fair across tenants (security review BOT-003):
    each bot has its own lane (interactions first within it), lanes are
    grouped per server, and a free slot goes round-robin — the next
    server, then that server's next bot. Caps: 16 deliveries in flight per
    process, 8 per server, 2 per bot; 100 waiting per bot (a full lane
    drops its OWN oldest normal delivery) and 2 000 in all (the longest
    lane gives one up). An attempt that takes over 1 s — even a 2xx — or
    fails counts toward a back-off: after 3 in a row the bot's lane waits
    1 s, then 2 s, 4 s … up to 60 s between attempts, until an answer
    comes quickly again; deliveries still go out, just not faster than the
    endpoint can take them;
  - before EVERY attempt (the first and each retry) and again before a
    synchronous interaction answer is applied, the current bot row and
    endpoint are re-read: the bot still enabled, still `receive_events`,
    the endpoint still enabled, subscribed and the very URL + secret the
    delivery was signed for, plus the event's own check (message events:
    `read_messages` + channel access; member events: `read_members`;
    interactions: `slash_commands` + channel access). Anything else drops
    the delivery (or the answer) silently.

## 6. SDK (`@lobbyforge/bot-sdk` v2)
```ts
const bot = new LobbyForgeBot({ baseUrl, token });
await bot.commands.set([{ name: 'roll', description: 'Roll dice', options: [...] }]);
bot.on('interaction', async (i) => i.reply(`🎲 ${roll(i.options.sides ?? 6)}`, { ephemeral: false }));
bot.on('message', (m) => { /* ... */ });
await bot.connect();                         // event stream with auto-reconnect
// HTTP mode instead of a socket:
const ok = verifySignature({ secret, timestamp, body, signature });
// Incoming webhook helper:
await postToWebhook(url, { content: 'Deploy finished' });
```
Zero runtime dependencies: it uses the global `fetch` and `WebSocket`
(browsers, Node ≥ 22) with the `identify` message, or implementations
passed in (`new LobbyForgeBot({ fetch, WebSocket })` — e.g. the `ws`
package). v1's `createBotClient` is unchanged.

As implemented (`packages/bot-sdk/src/bot.ts`, `signature.ts`,
`webhook.ts`):
- **REST (v2 base)**: `getMe` / `listChannels` / `readMessages` /
  `sendMessage`; `commands.get()`, `commands.set(list)` (sends
  `PUT /commands` with `{ commands: [...] }`, validated client-side:
  names, 1–100 char descriptions, ≤ 25 options, ≤ 50 commands, no
  duplicates), `commands.delete(name)`; `interactions.respond(id,
  content, { ephemeral })` / `.followup(...)`; `members.get(userId)`
  (unwraps `{ member }`); `eventEndpoint.get()` / `.set(url, { events })`
  (returns `{ endpoint, secret }` — the secret once) / `.remove()`;
  `getGatewayUrl()`. Same typed errors as v1, no redirects, 15 s timeout.
- **Stream**: `connect()` discovers the URL with `GET /gateway` (or uses
  `gatewayUrl`; falls back to `<baseUrl>/ws/bot` on a 404; an https
  instance never gets a `ws://` stream), sends `identify`, and resolves
  on the first `ready`. It keeps retrying through network failures and
  rejects only on a fatal close — 4001 → `BotAuthError`, 4003 →
  `BotForbiddenError` (`.permission`), 4009 → `BotApiError` with
  `code: 'replaced'` — or after `reconnect.maxAttempts`. Reconnects use
  exponential backoff with "equal jitter" (initial 1 s, cap 30 s; at
  least 10 s after a 4029); the attempt counter resets on `ready`. A
  watchdog reconnects when no frame (event, heartbeat, pong) arrived for
  75 s (`heartbeatTimeoutMs`). `close()` closes with 1000 and stops.
- **Events** (`on()` returns an unsubscribe): `ready` `{ bot, channels }`,
  `message`, `message_update`, `message_delete`, `member_join`,
  `member_leave`, `interaction` (with `reply(content, { ephemeral })` and
  `followup(...)` bound to its id, and the invoker's `locale` — optional in
  the type, since older instances do not send it; §3.5),
  `channel_access_changed`, `raw` (every
  event payload), `disconnect` `{ code, reason, willReconnect, delayMs }`,
  `error`. A listener that throws or rejects becomes an `error` event.
  `bot.ready` holds the latest bot + channel list.
- **`verifySignature({ secret, timestamp, body, signature,
  toleranceSeconds = 300, now? })`** is SYNCHRONOUS (a Promise would be
  truthy inside `if (…)`), constant-time, accepts a comma-separated list
  of `v1=` signatures, rejects timestamps more than the tolerance away in
  either direction, and never throws. `toleranceSeconds` is clamped to
  1..3600 (`0` / negative → 1 s, larger → 1 hour); a non-finite value
  (`Infinity`, `NaN`) or a non-number falls back to 300 — a bad setting can
  narrow the replay window, never switch it off. `body` is the raw request (string,
  `Uint8Array` or `ArrayBuffer`). SHA-256/HMAC are implemented in the SDK
  (pinned to `node:crypto` by tests); `signPayload` produces the header.
- **`postToWebhook(url, { content, username? }, { wait?, fetch?,
  timeoutMs? })`** → `null` (204), or the stored message with `wait: true`.
- The protocol types (`BotEventData`, `BotFeedMessage`, `BotCloseCode`, …)
  are exported from the package root.

Ships two examples under `packages/bot-sdk/examples/`: `roll-bot.mjs`
(a `/roll` command and `!roll` over the socket) and `http-bot.mjs` (a
`node:http` endpoint that verifies signatures and answers interactions
synchronously). Both answer each member in their language through
`roll-text.mjs` (English and Turkish strings, §3.5). Both turn every refusal into one line a person can act on
(`/roll is already registered by another bot on this server — pick another
name` for `command_name_taken`, a revoked token, a missing permission, a
port in use) instead of a stack trace, and exit 1 on anything fatal —
including, for `roll-bot.mjs`, a stream the gateway closed for good after
it was running (4001 token rotated/revoked, 4003 disabled, 4009 another
copy took over), where a bot would otherwise just stop with exit 0 — and 0
only after Ctrl+C / SIGTERM.

## 7. UI
- Composer: typing `/` opens a command picker (keyboard accessible,
  grouped by bot), then option fields; submit calls §3.3; a pending
  "<bot> is thinking…" row is shown to the invoker only.
- Messages: "↳ used /cmd" header on interaction answers, WEBHOOK badge,
  ephemeral answers with "Only you can see this · Dismiss". A desktop /
  browser notification cannot show a badge, so its title carries the cue:
  "<name> (WEBHOOK) in #channel" / "<name> (BOT) in #channel"
  (`lobbyMain.chat.notificationTitleWebhook` / `…TitleBot`).
- Admin → Bots: per bot — permissions incl. the new ones, channel access
  (all eligible / chosen channels), registered commands (read-only list,
  enable/disable per command, channel restriction), event endpoint
  status (URL, last delivery, failures, why it stopped, re-enable — not
  after a token change — and remove, with a confirmation).
- Admin → Channels: per text channel — incoming webhooks (create, copy
  URL once, rotate, disable, delete).
- Everything themed (Tailwind tokens), en + tr.

### 7.1 How the browser uses the routes
Every path and payload the UI reads is in one module,
`apps/web/lib/bots/client-api.ts` (parsed defensively; errors shown by
`code`, never the English `error`).
- The composer loads `GET /api/servers/{id}/commands?channelId=` the first
  time `/` is typed in a channel and caches it per channel for a minute; a
  refusal that means the list is stale (`command_not_found`,
  `command_disabled`, `command_not_available`, `bot_unavailable`,
  `missing_permission`) drops that cache. `invalid_options` marks the
  fields named in `issues` (`"<option>: …"`).
- The pending row lives in browser memory (`lib/bots/interaction-store.ts`)
  and turns into "<bot> did not respond" at the 202's `expiresAt`; no
  server event is needed for that. It resolves on a chat message whose
  `metadata.interaction.id` matches, or on `user:{uid}` data
  `{ type: 'interaction_response', interaction: { id, serverId, channelId,
  commandName, bot: { id, name } }, response: { content, ephemeral: true } }`.
  An optional `{ type: 'interaction_status', interaction: { id, status:
  'expired' | 'failed' } }` is honoured if the server ever sends one.
- The chat only draws the "↳" header on a BOT message and the WEBHOOK badge
  on a message with no user and no bot, whatever the metadata claims.
- Admin → Bots calls `GET|PUT …/bots/{botId}/channel-access` (PUT
  `{ channelIds: [...] | null }`), `GET …/bots/{botId}/commands` +
  `PATCH …/commands/{commandId}` (`{ enabled?, channelIds? }` — the
  managers' restriction), `GET|PATCH|DELETE …/bots/{botId}/event-endpoint`
  (`{ enabled: true }` re-enables; `endpoint_token_changed` is shown as
  "switched off because the bot's token changed"; DELETE removes it).
  `cannot_take_hidden_access` (token or permissions refused, §1.1) is
  shown in the admin's language. Admin → Channels calls
  `GET|POST …/channels/{channelId}/webhooks`, `PATCH|DELETE
  …/webhooks/{webhookId}` and `POST …/webhooks/{webhookId}/token` (rotate).

## 8. Security checklist (must hold)
- Every bot-reachable path goes through `botCanAccessChannel`.
- Interaction ids are unguessable (uuid) AND bound to the bot: another
  bot's token gets 404.
- Option values are validated server-side; `user`/`channel` options are
  re-checked (member of the server / channel visible to the invoker).
- Webhook tokens and bot tokens are stored hashed; event-endpoint secrets
  are stored (needed to sign) and never returned after creation.
- Outgoing deliveries never reach private networks (save + delivery
  checks, no redirects), and a refused URL is never explained with what it
  resolved to.
- A token rotate / revoke switches the event endpoint off; deliveries,
  retries and synchronous answers re-check the bot and its endpoint each
  time.
- Nobody takes a token (or adds a channel permission) for a bot that
  reaches private channels they cannot see.
- One bot or server cannot hold the delivery queue: per-bot and per-server
  caps, round-robin, back-off for slow endpoints.
- No event ever carries a message from a channel the bot cannot access.
- Commands and webhook posts obey timeouts, bans, slow paths and the
  Moderation Bot exactly like member posts.