# 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$, 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 `) 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:///ws/bot` behind HTTPS / `ws://: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: ["