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_connectionsand 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
| Platform | Auth Type | Token Refresh |
|---|---|---|
| Twitch | OAuth2 | Yes (automatic) |
| YouTube | OAuth2 | Yes (automatic) |
| Kick | OAuth2 | Yes (automatic) |
| Trovo | 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 |
| Trovo | trovo_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:
| Platform | Scopes |
|---|---|
| Twitch | chat:read, chat:edit, user:write:chat, user:bot, user:read:chat |
| YouTube | youtube.force-ssl |
| Kick | chat:write |
| Trovo | chat_send_self |
| Discord | Set 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
systemflows and retains the legacy per-flow exchange foraccountfallback/in-flight authorizations. - 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
- 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
| 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 only | All bot-enabled channels for a platform (consumed by bot workers) |
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. Returns AuthorizeResult (redirect URL). Discord is unsupported (static token). |
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 |
POST | /v1/bot-connections/{platform}/exchange | bot-connections:create | Legacy 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-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) (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:
| Bot | Fields requested | Uses them for |
|---|---|---|
| twitch-bot | accountId, channelName, botType, customBotToken | Global vs custom bot selection |
| youtube-bot | accountId, channelName, accessToken | Live-chat poll + send |
| trovo-bot | accountId, channelName, channelId, accessToken | Chat-WS auth frame + 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.
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, 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. - 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 theapps/apisend 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
| Permission | Owner | Admin | Moderator | Viewer |
|---|---|---|---|---|
bot-connections:read | Yes | Yes | Yes | No |
bot-connections:create | Yes | Yes | No | No |
bot-connections:delete | Yes | Yes | No | 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. 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
| 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