Skip to main content

Bot Connections

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.

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 uses a static admin bot token.
  • Account Bot: Connected by an account through the dashboard. It stores a separate bot access/refresh token in bot_connections and takes priority over the global bot for that account. The OAuth client source is resolved for (platform, bot) and pinned on the row.

Fallback Logic

Account has account bot connection for platform?
-> YES: Use account bot token
-> NO: Use global system bot token

Platforms

PlatformAuth TypeToken Refresh
TwitchOAuth2Yes (automatic)
YouTubeOAuth2Yes (automatic)
KickOAuth2Yes (automatic)
TrovoOAuth2Yes (automatic)
DiscordStatic Bot TokenNo (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]:

PlatformBot config keys
Twitchtwitch_bot_client_id, twitch_bot_client_secret
YouTubeyoutube_bot_client_id, youtube_bot_client_secret
Kickkick_bot_client_id, kick_bot_client_secret
Trovotrovo_bot_client_id, trovo_bot_client_secret

If a dedicated bot pair is empty, Twitch, Kick, and Trovo bot flows fall back to the platform's channel pair and log a one-time warning. With neither bot nor channel keys, the flow degrades to account and uses app_credentials, which is the normal local/operator-managed fallback. platformCredentialModes.systemConfigured is strict: a Bot row is false until the dedicated bot pair exists, even if the channel fallback would let a connect succeed.

The bot/channel split keeps provider limits separate by client_id: Twitch app-access tokens allow 800 requests per minute per client_id; Kick EventSub subscription caps are 10,000 subscriptions per event type per app; Trovo allows 1,200 requests per minute shared by all clients using the same client_id.

Bot Scopes

Bot connections use minimal scopes focused on chat functionality:

PlatformScopes
Twitchchat:read, chat:edit, user:write:chat, user:bot, user:read:chat
YouTubeyoutube.force-ssl
Kickchat:write
Trovochat_send_self
DiscordSet via Developer Portal (not OAuth scopes)

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 system flows and retains the legacy per-flow exchange for account fallback/in-flight authorizations.
  • Database (apps/api/src/db/bot_connections.rs) -- PostgreSQL operations for the bot_connections table. Supports list, get, upsert, and delete operations.
  • OAuth (apps/api/src/oauth.rs) -- get_fresh_bot_token() reads the pinned credential_source, resolves the matching OAuth client source, and returns fresh tokens; update_bot_connection_tokens() saves refreshed tokens.

Frontend

  • Bot connection settings page with cards per platform showing connection status.
  • Connect starts OAuth with bot-specific scopes for Twitch, YouTube, Kick, or Trovo.
  • Discord is configured by admins with a static token, not through the account bot OAuth flow.

API

GraphQL Queries

QueryPermissionDescription
botConnectionsbot-connections:readList bot connections for the account
botConnectionStatusesbot-connections:readBot platform statuses with enabled field
botStatusbot-connections:readPer-platform bot status for the Bot Control dashboard
botChannels(platform)System key onlyAll bot-enabled channels for a platform (consumed by bot workers)
platformCredentialModesconnections:readCredential mode per (platform, kind), including bot rows for OAuth bot platforms

GraphQL Mutations

MutationPermissionDescription
authorizeBot(platform)bot-connections:createStart a bot OAuth flow. Returns AuthorizeResult (redirect URL). Discord is unsupported (static token).
deleteBotConnection(platform)bot-connections:deleteDelete a bot connection
toggleBotForPlatform(platform, enabled)bot-connections:createToggle bot_enabled on the platform's channel connection
rejoinBot(platform)bot-connections:createSend a rejoin command to the bot worker for a platform

REST Endpoints

All paths live under /v1.

MethodPathPermissionDescription
GET/v1/bot-connectionsbot-connections:readList bot connections for the account
DELETE/v1/bot-connections/{platform}bot-connections:deleteDelete a bot connection
GET/v1/bot-connections/{platform}/authorizebot-connections:createStart OAuth and return the authorize URL
POST/v1/bot-connections/{platform}/exchangebot-connections:createLegacy account-source exchange for encrypted tokens
POST/v1/connections/oauth/{platform}/exchange--Unified exchange for system-sourced channel, account-bot, and admin global-bot OAuth callbacks
GET/v1/bot-statusbot-connections:readAggregated bot runtime status for the account
POST/v1/bot-togglebot-connections:createPause or resume the bot for a platform
POST/v1/bot-rejoinbot-connections:createForce 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) (system-key only) to learn which channels to join. The returned BotChannel carries accountId, channelName, channelId, botEnabled, botType, customBotToken, and accessToken — the decrypted channel OAuth token, exposed only over this system-key-gated query (the same trust boundary as customBotToken).

Each worker requests only the fields it needs:

BotFields requestedUses them for
twitch-botaccountId, channelName, botType, customBotTokenGlobal vs custom bot selection
youtube-botaccountId, channelName, accessTokenLive-chat poll + send
trovo-botaccountId, channelName, channelId, accessTokenChat-WS auth frame + send
kick-botaccountId, channelNameDiscovery 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.

Known Exceptions & Operational Risk

  • 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, so botChannels cannot supply it. The kick-bot therefore discovers its channels but subscribes to none, logging a warn! 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 by apps/api is the documented webhook. Building out Kick bot chat is tracked separately.
  • Trovo bot Client-ID. The trovo-bot sends chat with a literal Client-ID: "lumio-bot" rather than resolving the configured OAuth client ID used by the apps/api send path — two sources of truth for the same value. Deliberate for now; align it with the configured credential when Trovo bot chat is built out.

Permissions

PermissionOwnerAdminModeratorViewer
bot-connections:readYesYesYesNo
bot-connections:createYesYesNoNo
bot-connections:deleteYesYesNoNo

Database

Bot connections are stored in the bot_connections table:

ColumnTypeDescription
idUUIDPrimary key
account_idUUIDAccount (nil UUID for global bots)
platformVARCHARPlatform name
bot_typeVARCHARglobal or custom
bot_usernameVARCHARBot display name
access_tokenTEXTEncrypted OAuth token
refresh_tokenTEXTEncrypted refresh token
scopesTEXT[]Granted scopes
expires_atTIMESTAMPTZToken expiration
credential_sourceTEXTPinned 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. Discord bot tokens are excluded since they don't expire. 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 system-key-only botChannels query (used by bot workers to discover channels to join) is intentionally not feature-gated.

In addition, individual bot connections are gated by the finer platform:{platform}:bot sub-flags: when a platform's bot sub-flag is disabled, new bot connections for that platform are blocked (checked on the authorize step) while existing ones continue working. See Provider Management for details.

Key Files

PathDescription
apps/api/src/graphql/bot_connections.rsGraphQL queries and mutations
apps/api/src/db/bot_connections.rsDatabase operations
apps/api/src/oauth.rsToken refresh utilities (get_fresh_bot_token, update_bot_connection_tokens)
apps/api/src/platforms.rsBot OAuth scopes per platform

See Also