Ctrl K
ComunidadGuías…

docs/BOTS.md

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

Archivo original ↗Preguntar a KiBot
# Bots

A bot is an identity that belongs to one community (server) and acts on its
behalf: it greets newcomers, keeps chat clean, or connects your own program to
a channel. A bot is never mistaken for a person — everything it posts carries
a **BOT** badge and a robot avatar, and it can only do what its permissions
allow.

- [Concepts](#concepts)
- [Permissions](#permissions)
- [Tokens](#tokens)
- [Bot API v1](#bot-api-v1)
- [Bot API v2: slash commands, events, webhooks](#bot-api-v2-slash-commands-events-webhooks)
- [Built-in bots](#built-in-bots)
- [Managing bots](#managing-bots)
- [How bots appear](#how-bots-appear)
- [Writing a bot with the SDK](#writing-a-bot-with-the-sdk)
- [Under the hood](#under-the-hood)

## Concepts

| Type | Runs | Credential | Trust badge |
|---|---|---|---|
| `welcome` | inside LobbyForge | none | Official |
| `moderation` | inside LobbyForge | none | Official |
| `custom` | your own program, anywhere | a bot token | Unverified |

- **One server.** A bot row belongs to exactly one server (`bots.server_id`,
  deleted with the server). It can never see or act in another server — a
  channel id from elsewhere is simply "not found".
- **Built-in bots** (Welcome, Moderation) run inside the web app — no extra
  process — but they go through the same permission checks as a custom bot
  and post as bot identities, not as "the system". A server has at most one
  of each.
- **Custom bots** are driven through the [Bot API](#bot-api-v1) with a token.
  A server can have up to 20.
- **Channels.** Bots use text and announcement channels that every member can
  see. A role-gated channel (a private channel) is not available to a bot
  unless someone with *Manage Channels* grants it explicitly
  ([channel access](BOT_API_V2.md#11-channel-access-per-bot-botschannel_access_mode--bot_channel_access)):
  a bot holds no roles, so there is no way to let it in by accident.
- **No mass pings.** A bot can never mention `@everyone` or `@here`.

## Permissions

Bot permissions are their own list (`BotPermission` in `@lobbyforge/bot-sdk`),
separate from member permissions. No bot permission maps to *Administrator*,
and a bot never goes through the member permission system — a bot cannot
manage roles, members, channels, invites or other bots, whatever it holds.

| Permission | Allows | Honoured by |
|---|---|---|
| `read_messages` | list channels, read recent messages | Bot API v1 |
| `send_messages` | list channels, post messages | Bot API v1, built-in bots |
| `moderate_messages` | filter members' messages | Moderation Bot |
| `slash_commands` | register slash commands, answer interactions | Bot API v2 |
| `read_members` | member join/leave events, member lookups | Bot API v2 |
| `receive_events` | the event stream and an outgoing event endpoint | Bot API v2 |
| `join_voice`, `publish_audio` | voice (reserved) | not yet |
| `read_presence` | who is online (reserved) | not yet |
| `manage_game_session`, `manage_music_queue` | activities, music (reserved) | not yet |
| `read_audit_log` | the audit log (reserved) | not yet |

Reserved permissions can be granted today but unlock nothing until the
matching API ships; the settings page marks them *(coming soon)*.

**No escalation.** A member who manages bots (the *Manage Community*
permission) can only give a bot permissions they hold themselves:
`read_messages` needs *Read Message History*, `send_messages` needs *Send
Messages*, `moderate_messages` needs *Manage Messages*, `slash_commands` needs *Send
Messages*, `read_audit_log` needs
*View Audit Log*, `join_voice` / `publish_audio` need *Join Voice Rooms* /
*Speak*, the activity and music permissions need *Start Activities*. The owner
and administrators may grant any of them. Removing a permission, or keeping
one a bot already had, is always allowed.

Built-in bots have a fixed set — Welcome: `send_messages`; Moderation:
`read_messages`, `moderate_messages`, `send_messages` — which the API
re-asserts on every save.

## Tokens

```
lfb_<bot id: 32 hex>_<secret: 43 base64url characters>
```

- **Shown once.** The token is in the response that creates the bot (or
  rotates its token) and nowhere else. The settings page shows it in a dialog
  with a copy button; once the dialog closes it is gone from the page.
- **Stored as a hash.** The database keeps `sha256$<hex>` of the whole token
  (domain-separated). The secret is 256 random bits, so a fast hash is right
  (password hashes are for guessable secrets); comparison is constant-time.
- **Recognisable.** The `lfb_` prefix lets secret scanners and log filters
  spot a leaked token; the embedded bot id lets the server check exactly one
  row.
- **Rotate** (*New token*) issues a new token and the old one stops working
  at once. **Revoke** removes the token; the bot stays but cannot call the
  API. **Deleting** a bot also ends its token. Every one of these is written
  to the audit log (without the token).
- **Rotate and revoke also switch off the bot's event endpoint.** The
  outgoing HTTPS endpoint ([Bot API v2](BOT_API_V2.md#52-outgoing-bot-event-endpoint))
  was set by whoever held the old token, who chose its URL and knows its
  signing secret. So the endpoint goes off (`disabled_reason =
  'token_changed'`, audited as `bot.event_endpoint.disable`): no events, no
  interactions and no synchronous answers reach it, and a command of the
  bot answers `bot_offline` until the bot is reachable again. A manager
  cannot switch such an endpoint back on — the bot sets it again with the
  new token (`PUT /api/bot/v2/event-endpoint`, which issues a new secret),
  or a manager removes it. An open event stream is closed too.
- **A token outlives the person who issued it.** It belongs to the bot, not
  to a member: a manager who leaves, is banned or loses Manage Community
  does not take the bot's token with them. Rotate it when someone who had
  it should no longer act as the bot.
- **Private channels.** A bot granted role-gated channels by someone with
  *Manage Channels* reaches channels other managers may not see. A manager
  who cannot see one of them (and cannot grant private channels) cannot take
  that bot's token, nor give it `read_messages`, `send_messages`,
  `receive_events` or `slash_commands` — `403 cannot_take_hidden_access`.
  Revoking the token, or removing permissions, is always allowed.

If a token leaks, rotate it — the bot's endpoint goes off with it.

## Bot API v1

Base path: `/api/bot/v1`. Every request authenticates with

```
Authorization: Bot lfb_…
```

— never a cookie, a query parameter or `Bearer`. Only custom bots have tokens.
The API is meant for server-side programs: it sends no CORS headers, needs no
`Origin`, and is not reachable with a member's session.

### Endpoints

| Method | Path | Permission | Returns |
|---|---|---|---|
| `GET` | `/me` | — | `{ bot: { id, name, type, serverId, permissions } }` |
| `GET` | `/channels` | `read_messages` or `send_messages` | `{ channels: [{ id, name, type, position, topic }] }` |
| `GET` | `/channels/{channelId}/messages?limit=50&before=<ISO>` | `read_messages` | `{ messages: [Message] }`, newest first |
| `POST` | `/channels/{channelId}/messages` | `send_messages` | `201 { message: Message }` |

```
Message = {
  id, channelId, content, createdAt, editedAt, replyToId,
  author: { type: 'user', id, name } | { type: 'bot', id, name } | { type: 'unknown', id: null, name: null }
}
```

`limit` is 1–100 (default 50). `before` returns messages older than that
instant, for paging back. A message body is `{ "content": "…" }`, 1–4000
characters, no other fields; leading and trailing whitespace is trimmed.

A bot's message is stored and delivered exactly like a member's — same
table, same realtime fan-out to every open lobby, same `message.create` audit
entry — and is marked as the bot's everywhere it is shown.

### Examples

```sh
TOKEN=lfb_…
BASE=https://chat.example.com/api/bot/v1

curl -s -H "Authorization: Bot $TOKEN" $BASE/me

curl -s -H "Authorization: Bot $TOKEN" $BASE/channels

curl -s -H "Authorization: Bot $TOKEN" "$BASE/channels/$CHANNEL/messages?limit=10"

curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
  -d '{"content":"Server restarts in 5 minutes."}' \
  $BASE/channels/$CHANNEL/messages
```

### Errors

Every error is JSON: `{ "error": "<English sentence>", "code": "<code>", …details }`.
Branch on `code`, not on the sentence.

| Status | `code` | Meaning / details |
|---|---|---|
| 400 | `invalid_request` | bad body, `limit` or `before`; `issues: string[]` |
| 401 | `unauthorized` | missing, malformed, revoked or rotated token (`WWW-Authenticate: Bot`) |
| 403 | `bot_disabled` | an admin switched the bot off |
| 403 | `missing_permission` | `permission` names what is missing |
| 403 | `mass_mention_forbidden` | the content mentions `@everyone` / `@here` |
| 404 | `not_found` | channel not in the bot's server, role-gated, or not a text channel |
| 405 | `method_not_allowed` | `allowed` lists the methods |
| 413 | `payload_too_large` | body over 16 KiB |
| 429 | `rate_limited` | `retryAfter` (seconds), `resetAt`; `Retry-After` header |
| 503 | `maintenance` | the instance is in maintenance mode; `message` |
| 500 | `internal_error` | try again later |

### Rate limits

| What | Limit |
|---|---|
| `POST …/messages` | 30 per minute per bot |
| every other endpoint | 60 per minute per bot, each |
| requests presenting a token, per client address | 600 per minute |
| failed authentication, per client address | 30 per minute (then `429`) |

A bot's budget is keyed on the bot itself, so nobody can use it up without
the token. Client addresses are only as reliable as the instance's
`LOBBYFORGE_TRUSTED_PROXY` setting — behind nginx set it to
`x-forwarded-for`, as for the rest of the app.

## Bot API v2: slash commands, events, webhooks

v1 stays as it is. v2 adds, all scoped per channel:

- **Slash commands** — a bot registers commands (`PUT /api/bot/v2/commands`);
  members run them from the composer with `/`. The bot answers each run
  (an *interaction*) publicly or only to the member who ran it. Each
  interaction carries that member's language (`locale`), so the answer can
  be in it ([Languages](BOT_API_V2.md#35-languages)).
- **Event stream** — the bot connects to the gateway at `/ws/bot`
  (`Authorization: Bot <token>` or an `identify` message) and receives
  `message_*`, `member_*` and `interaction_create` events instead of
  polling. A `member_leave` event says why the member left (`reason`:
  `leave`, `kick` or `ban`) to every bot with `read_members` — a bot that
  reads members learns who was kicked or banned.
- **Outgoing event endpoint** — or the instance POSTs the same events to the
  bot's HTTPS URL, signed with HMAC-SHA256 (`X-LobbyForge-Signature`).
- **Incoming webhooks** — a channel's webhook URL lets an outside service
  (CI, a game server) post into that channel without a bot.
- **Channel access** — a bot can be limited to chosen channels (or granted a
  private one by someone who may see it).

The full contract — routes, payloads, limits, close codes, signing — is in
[BOT_API_V2.md](BOT_API_V2.md); the SDK (`LobbyForgeBot`) wraps all of it.

## Built-in bots

Both are configured in **Community Settings → Bots**, can be renamed and
switched on and off.

**Languages.** Each member reads a built-in bot's default texts (the greeting,
the notice) and default names in their own language. In one channel, a
Turkish member reads "Ayşe, Oyun Gecesi sunucusuna hoş geldin!" and an
English one "Welcome to Oyun Gecesi, Ayşe!". A text or name an admin writes
is shown to everyone exactly as written.

- The message is stored once. Its `content` is in the instance language
  (the instance default: the admin's choice, else `LOBBYFORGE_DEFAULT_LOCALE`,
  else English), as the fallback for bots,
  search and older apps. A default text also stores
  `metadata.i18n = { key, params }` (the catalogue key and the cleaned
  names), and the lobby shows `t(key, params)` in the viewer's language. An
  unknown key falls back to `content`.
- A name is the default when it equals the shipped default name in any
  installed language ("Welcome Bot", "Karşılama Botu"). Nothing extra is
  stored: browsers get `bot.defaultName: true` and show the default in the
  viewer's language. A renamed bot keeps its name. An admin who types the
  default name by hand gets the default.
- A greeting or notice that is word for word a shipped default in any
  installed language counts as the default, too, including one an older
  admin page saved for you. It's read as the default at once and stored as
  `null` with the next save that changes something. No migration.
- The settings page fills the fields with the defaults in your language but
  sends a field only once you edit it. Each field says which kind it is, and
  **Use the default** switches back. Over the API, `name: null` and
  `template: null` / `noticeTemplate: null` mean the default.

### Welcome Bot

Greets every new member when they join — by invite, by registering, or by the
lobby's automatic join on an open instance. A returning member is not greeted
again.

| Setting | |
|---|---|
| Channel | any text channel every member can see; default: the first one |
| Greeting | up to 500 characters; `{user}` → the member's name, `{server}` → the server's name |

Names are inserted as plain text with `@` removed, so a member called
"@everyone" cannot turn the greeting into a server-wide ping; a greeting
containing `@everyone` / `@here` is refused when saved. During a raid at most
10 greetings a minute are posted per server. A failed greeting never fails the
join.

### Moderation Bot

Checks every new message — and every edit — before it is stored:

| Rule | |
|---|---|
| Blocked words | one word or phrase per line; whole words only (`ass` does not block `class`); `word*` also blocks longer forms (`salak*` → `salaksın`), `*word` endings, `*word*` anything containing it; a phrase matches consecutive words |
| Links | allow all · block all · allow only listed sites (and their subdomains) |
| Mentions | most `@` mentions in one message (0 = no limit; `@everyone`/`@here` count) |
| Flooding | more than *n* messages in *s* seconds from one member (default 6 in 10 s) |
| Repeats | the same message more than *n* times in *s* seconds (default 3 in 60 s) |

Matching ignores case and is Turkish-aware: every I-form (`I`, `İ`, `ı`,
`i`) counts as the same letter, so a blocked `sik` also catches `SIK`, `SİK`
and `sık`. Invisible characters (zero-width spaces, soft hyphens, bidi
controls) are removed and look-alike forms folded (fullwidth `spam` →
`spam`) before matching. Other letters keep their marks (`ş` ≠ `s`), so a
blocked `göt` does not block `got`. Links are found with or without
`https://`, including `www.` and bare `name.tld` for common endings — but not
in file names, version numbers or e-mail addresses.

When a message is blocked:

- the member gets `422 { code: "blocked_by_moderation", rule }`; the lobby
  shows a translated reason ("Your message was not sent: it contains a word
  this community does not allow.");
- the audit log gets `bot.moderation.block` — the bot, the rule, what matched,
  the channel, a trimmed excerpt (120 characters) and a SHA-256 of the full
  message; an audit reader who cannot see that channel gets the row without
  what matched and without the excerpt;
- optionally (*Post a notice in the channel*) the bot posts a short neutral
  line such as "A message from Alex was blocked by the moderation filter." —
  at most one per member per channel per minute.

The owner, administrators and moderators (*Manage Messages*, *Manage
Community* or *Timeout Members*) are exempt unless the exemption is switched
off. If the bot's settings cannot be loaded, messages are allowed — a filter
outage must not take chat down. Edits are checked by the content rules only;
flooding and repeats count new messages.

## Managing bots

**Community Settings → Bots** (`/admin/settings/bots`) lists the built-in bots
and the custom bots with their BOT badge, trust, and an on/off switch, and
lets the owner create a custom bot, copy its token once, rename it, change its
permissions, issue / rotate / revoke its token and delete it.

The same actions over HTTP (session cookie; the *Manage Community*
permission, except listing):

| Method | Path | Body | Notes |
|---|---|---|---|
| `GET` | `/api/servers/{id}/bots` | | every member; settings only for managers; never a token or hash |
| `POST` | `/api/servers/{id}/bots` | `{ name, permissions }` | `201 { bot, token }` — the only time the token is returned |
| `PATCH` | `/api/servers/{id}/bots/{botId}` | `{ name?, enabled?, permissions? }` | built-in permissions cannot change; adding a channel permission to a bot with private channels the caller cannot see → `403 cannot_take_hidden_access` |
| `DELETE` | `/api/servers/{id}/bots/{botId}` | | its messages stay, still marked as a bot's |
| `POST` | `/api/servers/{id}/bots/{botId}/token` | | issue or rotate: `{ bot, token }`; switches the event endpoint off; `403 ungrantable_permissions` / `cannot_take_hidden_access` |
| `DELETE` | `/api/servers/{id}/bots/{botId}/token` | | revoke; switches the event endpoint off |
| `PUT` | `/api/servers/{id}/bots/builtin/{welcome\|moderation}` | `{ enabled?, name?, settings? }` | sets the bot up on first call; settings are merged; `name: null` (or none, on first call) = the default name; a template that is `null` or a shipped default = the default text |

Audit log actions: `bot.create`, `bot.update` (with what changed — for
moderation settings, counts, never the word list), `bot.enable`,
`bot.disable`, `bot.delete`, `bot.token.issue`, `bot.token.rotate`,
`bot.token.revoke` (`metadata.endpointDisabled` when the change switched an
event endpoint off, which is also written as `bot.event_endpoint.disable`
with `reason: 'token_changed'`), `bot.moderation.block`, and
`message.create` for bot messages (`metadata.botId`).

## How bots appear

- **Messages:** the robot avatar instead of a person's initial, the bot's name
  and a `BOT` badge ("This is a bot, not a person"). A deleted bot's messages
  keep its name and badge.
- **Members panel:** a *Bots* group lists the enabled bots with the badge;
  clicking one opens its profile — name, BOT, trust, built-in or connected
  through the Bot API, who installed it, and its permissions — with a
  shortcut to the bot settings for managers.
- **Voice:** LiveKit participants whose metadata says `kind: "bot"` (or
  `bot: true`, or whose identity starts with `bot:`) get the BOT badge in the
  room roster.

## Writing a bot with the SDK

`@lobbyforge/bot-sdk` ships a dependency-free client for the Bot API. It
validates input before anything is sent, never follows redirects (so the
token cannot leak to another host), and turns every failure into a typed
error — `BotAuthError`, `BotForbiddenError` (`.permission`),
`BotNotFoundError`, `BotRateLimitError` (`.retryAfter`), `BotValidationError`
(`.issues`), `BotServerError`, `BotNetworkError` — all subclasses of
`BotApiError` with `.status` and `.code`.

A bot that answers `!ping` in the first channel it can use:

```ts
import { BotRateLimitError, createBotClient } from '@lobbyforge/bot-sdk';

const bot = createBotClient({
  baseUrl: process.env.LOBBYFORGE_URL!, // e.g. https://chat.example.com
  token: process.env.LOBBYFORGE_BOT_TOKEN!,
});

const me = await bot.getMe();
const [channel] = await bot.listChannels();
if (!channel) throw new Error(`${me.name} has no channel it may use`);

// Remember what is already there, so old messages are not answered.
const seen = new Set((await bot.readMessages(channel.id, { limit: 50 })).map((m) => m.id));
await bot.sendMessage(channel.id, `${me.name} is online. Say !ping.`);

const poll = async (): Promise<void> => {
  try {
    const recent = await bot.readMessages(channel.id, { limit: 20 });
    for (const message of recent.reverse()) {
      if (seen.has(message.id)) continue;
      seen.add(message.id);
      if (message.author.type === 'user' && message.content.trim() === '!ping') {
        await bot.sendMessage(channel.id, `pong, ${message.author.name ?? 'friend'}!`);
      }
    }
    setTimeout(poll, 5_000);
  } catch (error) {
    // Back off exactly as long as the server asks; log anything else.
    if (error instanceof BotRateLimitError) setTimeout(poll, error.retryAfter * 1000);
    else {
      console.error(error);
      setTimeout(poll, 30_000);
    }
  }
};
void poll();
```

Give the bot `read_messages` and `send_messages`. The SDK is part of this
monorepo (`packages/bot-sdk`); `src/client.ts` has no dependencies and can be
copied into a standalone project.

## Under the hood

- **Schema** (migration `0037_bots_runtime`, additive): `bots` gains
  `token_issued_at`, `settings`, `created_by`, `last_used_at`, `updated_at`
  and a partial unique index — one Welcome and one Moderation bot per server;
  `messages` gains `bot_id` (`ON DELETE SET NULL`). A bot message has
  `user_id` NULL, `bot_id` set and a `metadata.bot` snapshot
  `{ id, name, type }`. `metadata.bot` is a reserved key: the messages API
  refuses it from clients, so a member cannot forge a bot message. So is
  `metadata.i18n` (a built-in bot's default text, see
  [Built-in bots](#built-in-bots)). The lobby honours it only on a bot's
  message, and only for `bots.welcome.defaultTemplate` and
  `bots.moderation.defaultNotice`. Code: `lib/bots/bot-i18n.ts` (client-safe
  reader and renderer) and `bot-i18n-server.ts` (the "is this a shipped
  default?" check over every installed language).
- **Code:** `apps/web/lib/bots/` — `token.ts` (format, hash, verify),
  `permissions.ts` / `catalog.ts` (vocabulary, grant rule), `api.ts` (the Bot
  API pipeline on `withMachineApiSecurity`), `messages.ts` (the one path every
  bot uses to read and post), `welcome.ts`, `moderation.ts` +
  `moderation-rules.ts`, `settings.ts`, `cache.ts` (a 15-second per-process
  cache of the built-in bots — the Moderation Bot is consulted on every
  message; admin changes invalidate it at once in the process that made them,
  other processes within 15 seconds).
- **Last activity:** `last_used_at` is written at most once a minute per bot.