Bot Connections
The dashboard shows an explanation of the requested platform permissions before starting bot OAuth. It warns against using the channel's main account as the bot identity; the API rejects that account. The Chat re-enter action re-joins chat through /v1/bot-rejoin and does not reauthorize the bot credential. Connection results appear as dashboard toasts.
Overview
Bot connections let chat workers act with a bot identity for each platform. Lumio supports global bot identities configured by admins and account-level bot identities connected by an account, but the OAuth app that issues bot tokens is resolved by Lumio's credential mode system. Operator-managed system bots use dedicated bot OAuth app keys, separate from the channel app keys, while account-fallback bot connects use the account's app credentials.
The Chat bot section in each Twitch, YouTube and Kick area of the Connections group shows that platform's bot state, pause control, chat re-entry and own-bot connection. A bot OAuth flow returns to its platform area with kind=bot; a Discord invite returns to the Discord area (/dashboard/settings/channel/connections/discord).
Global vs Custom Bots
- Global Bot: Connected by admins in the Provider Management page. All accounts use this bot by default. OAuth global bots are pinned to
credential_source = "system"and use the platform's dedicated bot config keys. Discord has one global bot token; accounts connect servers to that bot through a verified invite. - Account Bot: Connected by an account through the dashboard. It stores a separate bot access/refresh token in
bot_connectionsand is meant to take priority over the global bot for that account. The OAuth client source is resolved for(platform, bot)and pinned on the row. The chat workers do not act as the account bot yet - see Known Exceptions.
Fallback Logic
Account has account bot connection for platform?
-> YES: Use account bot token
-> NO: Use global system bot token
Platforms
| Platform | Auth Type | Token Refresh |
|---|---|---|
| Twitch | OAuth2 | Yes (automatic) |
| YouTube | OAuth2 | Yes (automatic) |
| Kick | OAuth2 | Yes (automatic) |
| Discord | Static Bot Token | No (permanent) |
Spotify has no bot connection. YouTube channel connections remain BYO/account because of Google Cloud quota and consent-screen constraints, while YouTube bot connections have their own bot credential mode. A YouTube bot connect uses youtube_bot_client_id / youtube_bot_client_secret when configured; with no YouTube bot keys it degrades to the account-credential path because there is no YouTube channel app to fall back to.
Credential Resolution
CredentialResolver resolves bot OAuth apps per (platform, bot). The system path first reads the dedicated bot pair from [auth]:
| Platform | Bot config keys |
|---|---|
| Twitch | twitch_bot_client_id, twitch_bot_client_secret |
| YouTube | youtube_bot_client_id, youtube_bot_client_secret |
| Kick | kick_bot_client_id, kick_bot_client_secret |
| Discord | discord_bot_client_id, discord_bot_client_secret (server invite) |
If a dedicated bot pair is empty, Twitch and Kick bot flows fall back to the platform's channel pair and log a one-time warning. With neither bot nor channel keys, those OAuth flows degrade to account and use app_credentials. Discord has no account-app fallback: without both Discord bot app keys, a server invite fails closed. [platform_credentials.bot].discord accepts only "system"; an "account" override is ignored with a warning. platformCredentialModes includes a Discord Bot row whose systemConfigured becomes true when the Discord bot app pair is set.
The bot/channel split keeps provider limits separate by client_id: Twitch app-access tokens allow 800 requests per minute per client_id, and Kick EventSub subscription caps are 10,000 subscriptions per event type per app.
Bot Scopes
Bot connections use minimal scopes focused on chat functionality, plus the Twitch moderation scopes that extension chat actions need:
| Platform | Scopes |
|---|---|
| Twitch | chat:read, chat:edit, user:write:chat, user:bot, user:read:chat, moderator:manage:banned_users, moderator:manage:chat_messages |
| YouTube | youtube.force-ssl |
| Kick | chat:write |
| Discord | bot, applications.commands, identify for the verified server invite |
Extension chat actions
Extensions moderate chat (chat:delete, chat:ban, chat:unban,
chat:timeout) with the account's own bot connection, never with a
person's login. The bot must be a moderator of the channel. Twitch supports all
four actions, YouTube all except unban, and Kick none. A Twitch bot connected
before the two moderator:manage:* scopes were requested keeps chatting
normally (it is not flagged for reconnect), but extension moderation fails
with bot_scope_missing until the streamer reconnects the bot. Extension
chat:send is published to the account's running bot like an automation chat
step; on Twitch it therefore comes from the global bot (see below). Details:
useLumioAction → Chat.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/bot_connections.rs) -- Queries for listing bot connections, bot connection statuses with enablement. Mutation for deleting bot connections. - REST -- Bot connection OAuth uses the unified web-host callback for
systemflows and retains the legacy per-flow exchange foraccountfallback. Both exchanges are bound to the interactive session of the user who started the authorize step (ZAF-1922). - Database (
apps/api/src/db/bot_connections.rs) -- PostgreSQL operations for thebot_connectionstable. Supports list, get, upsert, and delete operations. - OAuth (
apps/api/src/oauth.rs) --get_fresh_bot_token()reads the pinnedcredential_source, resolves the matching OAuth client source, and returns fresh tokens;update_bot_connection_tokens()saves refreshed tokens.
Frontend
- Platform areas in the Connections group show bot status beside the channel, its rights and its app.
- Connect starts OAuth with bot-specific scopes for Twitch, YouTube, or Kick.
- Discord's global token is configured by admins. Account members invite that same bot to individual servers on the Discord tab; no account bot token is stored or refreshed.
Discord servers
The Discord area (/dashboard/settings/channel/connections/discord) lists every server linked to the active account. Add server opens the shared connect dialog, which explains that the inviter needs Manage Server on Discord. The invite asks to add the bot, register slash commands, and read the Discord name once (identify); the user token is revoked immediately after the verified code exchange. Discord returns through /api/connections/oauth/discord/callback, then the browser returns to the Discord area with a toast naming the server.
Each row shows Connected, Bot was removed from the server, or Permissions missing. A server whose live notification is switched on also names the Discord channel it posts to. For the latter two, Reconnect repeats the verified invite. A removed bot keeps its server settings until reconnect or Disconnect; disconnect removes the mapping and settings. Several servers can belong to one account, but an active server cannot be taken over by another account.
API
GraphQL Queries
| Query | Permission | Description |
|---|---|---|
botConnections | bot-connections:read | List bot connections for the account |
botConnectionStatuses | bot-connections:read | Bot platform statuses with enabled field |
botStatus | bot-connections:read | Per-platform bot status for the Bot Control dashboard |
botChannels(platform) | System key with bots:internal | All bot-enabled channels for a platform (consumed by bot workers), plan-gated per account at read time |
platformCredentialModes | connections:read | Credential mode per (platform, kind), including bot rows for OAuth bot platforms |
GraphQL Mutations
| Mutation | Permission | Description |
|---|---|---|
authorizeBot(platform) | bot-connections:create | Start a bot OAuth flow. For Discord, return the verified invite URL for the global bot; an interactive session and feature:bots are required. |
deleteBotConnection(platform) | bot-connections:delete | Delete a bot connection |
toggleBotForPlatform(platform, enabled) | bot-connections:create | Toggle bot_enabled on the platform's channel connection |
rejoinBot(platform) | bot-connections:create | Send a rejoin command to the bot worker for a platform |
REST Endpoints
All paths live under /v1.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/bot-connections | bot-connections:read | List bot connections for the account |
DELETE | /v1/bot-connections/{platform} | bot-connections:delete | Delete a bot connection |
GET | /v1/bot-connections/{platform}/authorize | bot-connections:create | Start OAuth and return the authorize URL; discord returns the verified server invite and requires an interactive session |
POST | /v1/bot-connections/{platform}/exchange | bot-connections:create + session of the initiating user | Legacy account-source exchange for encrypted tokens. Bound to the initiator's interactive session (same check as the unified exchange, before PKCE and the token exchange; ZAF-1922). Discord server invites complete only at the unified callback |
POST | /v1/connections/oauth/{platform}/exchange | -- | Unified exchange for system-sourced channel, account-bot, and admin global-bot OAuth callbacks |
GET | /v1/discord-guilds | bot-connections:read | List servers with bot_removed and missing_permissions |
DELETE | /v1/discord-guilds/{guild_id} | bot-connections:delete | Disconnect one server, remove its settings and make the bot leave it |
GET | /v1/bot-status | bot-connections:read | Aggregated bot runtime status for the account |
POST | /v1/bot-toggle | bot-connections:create | Pause or resume the bot for a platform |
POST | /v1/bot-rejoin | bot-connections:create | Force the bot to reconnect / rejoin the channel |
Request and response bodies use snake_case and mirror the GraphQL counterparts.
Channel Discovery (bot workers)
Each bot worker polls botChannels(platform) to learn which channels to join.
The query requires a system key carrying
bots:internal
(ZAF-1922) - a system key without that grant is refused. The returned
BotChannel carries accountId, channelName, channelId, botEnabled,
botType, customBotToken, and accessToken - the decrypted channel OAuth
token, exposed only over this bots:internal-gated query (the same trust
boundary as customBotToken).
The list is plan-gated per account at read time: an account whose
feature:bots is off is left out entirely, and a custom bot row whose account
lacks feature:custom_bot is served as the global bot with no custom bot token.
Because the check runs on every read, plan downgrades, per-account overrides and
kill switches take effect on the fleet without rewriting the account's stored bot
configuration - re-enabling the feature restores it unchanged.
Each worker requests only the fields it needs:
| Bot | Fields requested | Uses them for |
|---|---|---|
| twitch-bot | accountId, channelName, channelLogin | IRC JOIN target and message routing |
| youtube-bot | accountId, channelName, accessToken | Live-chat poll + send |
| kick-bot | accountId, channelName | Discovery only (see below) |
A worker must request only fields BotChannel actually exposes: GraphQL rejects
the entire query on an unknown field, which leaves the worker subscribed to
zero channels. The struct must also carry #[serde(rename_all = "camelCase")]
or the camelCase response fails to deserialize. Both traps are guarded by
apps/api/tests/bot_channel_queries.rs and per-bot deserialization tests.
Worker health signal
A channel sync that fails repeatedly is surfaced, not silent: each worker's
/ready endpoint returns 503 once channel sync has failed several times in a
row, and reports channels_active, channels_skipped, and
consecutive_sync_failures as JSON. /health (liveness) stays OK while the
process runs.
Outbound Safety
All tenants on a platform share one Lumio bot connection, so the bots protect it centrally:
- Sanitised sends. Every outgoing message passes the platform transport wrapper
(
crates/lo-chat-outbound): one line, no leading/or., capped at the platform maximum. Discord messages never ping@everyone,@here, users or roles except a feature post's configured role. - Twitch throughput. The Twitch bot throttles its connection with a token bucket:
100 messages per 30 s sustained, bursts of 20, across all channels. A send that
would wait more than 2 s is dropped and logged. Configure it with
TWITCH_BOT__BOT__OUTBOUND_MESSAGES_PER_30S/TWITCH_BOT__BOT__OUTBOUND_BURST(Installation); raise it only for a Twitch-verified bot account. - Built-in command cooldown.
!song,!uptimeand the other built-ins fire at most once per 5 s per command and channel. - State keyed by stable ids. Bots key per-tenant state by account id and the platform's stable id (Twitch login, Kick slug, YouTube channel id, Discord snowflake) — never by a display title. The YouTube bot also ignores the echo of its own chat posts and logs chat events without viewer names or message text.
Discord Feature Channels
setDiscordGuildFeature binds a feature (live notification, event feed, …) to a
channel only if the channel is one of that server's channels as reported by the
Discord gateway that the bot can view (the bot's
lumio:discord_guild:{guild_id}:channels cache, which also feeds the channel picker
via discordGuildChannels). Otherwise the mutation fails with
Channel does not belong to this Discord server or the Lumio bot cannot view it;
if the bot has not reported the server's channels yet it fails closed with
Cannot verify the channel: ….
The channel list holds only channels the bot has VIEW_CHANNEL on, computed from
the bot's roles and the channel's permission overwrites; a category is listed when
any of its channels is. Discord sends channels a bot cannot view as obfuscated
objects named ___hidden___ (mandatory for all bots from 2026-11-16) — they never
appear in the picker. The list is rebuilt on GUILD_CREATE, CHANNEL_*, role
changes and changes to the bot's own member, so a channel shows up again as soon as
the bot is given access. If the bot's own member is missing from the gateway cache,
the list is empty (fail closed).
The Discord bot builds its channel→server map only
from gateway data (GUILD_CREATE, CHANNEL_CREATE/UPDATE/DELETE, including
channels it cannot view), re-checks the
channel before every feature post, and skips (and logs) a stored mapping whose
channel is not in that server. Mappings are never deleted for this. This mutation is
GraphQL-only; there is no REST twin.
Known Exceptions & Operational Risk
- Twitch chat always runs as the global bot. The twitch-bot holds a single
IRC connection authenticated with the configured global bot account
(
[bot] username/oauth_token) and joins every channel with it. An account bot connection is stored and kept fresh by the Token Refresh Worker, but the twitch-bot does not connect as it, so commands and replies come from the global bot. The worker therefore does not requestbotTypeorcustomBotTokenfrombotChannels- it holds no account bot secret in memory. - Kick live subscription is deferred. The kick-bot subscribes to chat over
Kick's Pusher WebSocket keyed by a
chatroom_id, which Lumio does not store server-side, sobotChannelscannot supply it. The kick-bot therefore discovers its channels but subscribes to none, logging awarn!with a skip counter (reflected on/ready). The Pusher path also uses a hard-coded public app key with no auth frame - an unofficial channel with no platform contract; the official Kick chat ingest used byapps/apiis the documented webhook. Building out Kick bot chat is tracked separately.
Permissions
| Permission | Owner | Admin | Moderator |
|---|---|---|---|
bot-connections:read | Yes | Yes | Yes |
bot-connections:create | Yes | Yes | No |
bot-connections:delete | Yes | Yes | No |
Database
Bot connections are stored in the bot_connections table:
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
account_id | UUID | Account (nil UUID for global bots) |
platform | VARCHAR | Platform name |
bot_type | VARCHAR | global or custom |
bot_username | VARCHAR | Bot display name |
access_token | TEXT | Encrypted OAuth token |
refresh_token | TEXT | Encrypted refresh token |
scopes | TEXT[] | Granted scopes |
expires_at | TIMESTAMPTZ | Token expiration |
credential_source | TEXT | Pinned OAuth app source: system or account |
Token Refresh
The Token Refresh Worker automatically refreshes bot connection tokens alongside channel and login connection tokens. For OAuth bot rows, refresh follows bot_connections.credential_source: system rows use Lumio's configured bot OAuth app for the platform, with the channel fallback when the dedicated bot pair is missing, while account rows use the account's encrypted app_credentials. The Discord global bot token is static, and server mappings contain no OAuth token or refresh token. See Token Refresh for details.
Feature Flags
The whole Bot Control surface is gated on the umbrella feature:bots flag.
Every query and mutation (botConnections, botConnectionStatuses, botStatus,
authorizeBot, deleteBotConnection, toggleBotForPlatform, rejoinBot on
GraphQL; GET /v1/bot-connections, GET /v1/bot-status, POST /v1/bot-toggle,
POST /v1/bot-rejoin, the authorize/exchange OAuth endpoints, and
DELETE /v1/bot-connections/{platform} on REST) enforces it on both protocols —
GraphQL via FeatureGuard::new("feature:bots") chained before the permission
guard, REST via require_feature(..., "feature:bots", ...). With feature:bots
disabled the API rejects the call directly (Feature 'feature:bots' is not available, HTTP 403); the webapp route gate is UX only. The fleet's
botChannels query (used by bot workers to discover channels to join) is not a
caller-facing guard but applies the same plan flags per account when it builds
the list: feature:bots off drops the account's row, feature:custom_bot off
serves a custom bot as the global bot (see
Channel Discovery).
For Twitch, YouTube and Kick, new account-bot connections also check the finer
platform:{platform}:bot sub-flag on authorize; existing connections continue
working when it is off. The Discord authorize branch checks feature:bots and
bot-connections:create but does not run that platform sub-flag check; the
Discord tab is disabled in the web UI when Discord bot connections
are unavailable. See
Provider Management for details.
Key Files
| Path | Description |
|---|---|
apps/api/src/graphql/bot_connections.rs | GraphQL queries and mutations |
apps/api/src/db/bot_connections.rs | Database operations |
apps/api/src/oauth.rs | Token refresh utilities (get_fresh_bot_token, update_bot_connection_tokens) |
apps/api/src/platforms.rs | Bot OAuth scopes per platform |
See Also
- Provider Management -- Feature flag controls for bot connections
- Connections -- Channel credential modes and the unified OAuth callback
- Token Refresh -- Automatic token refresh worker
- Bot Modules -- Chat moderation modules that use bot connections