Skip to main content

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_connections and 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​

PlatformAuth TypeToken Refresh
TwitchOAuth2Yes (automatic)
YouTubeOAuth2Yes (automatic)
KickOAuth2Yes (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
Discorddiscord_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:

PlatformScopes
Twitchchat:read, chat:edit, user:write:chat, user:bot, user:read:chat, moderator:manage:banned_users, moderator:manage:chat_messages
YouTubeyoutube.force-ssl
Kickchat:write
Discordbot, 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 system flows and retains the legacy per-flow exchange for account fallback. 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 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​

  • 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​

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 with bots:internalAll bot-enabled channels for a platform (consumed by bot workers), plan-gated per account at read time
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. For Discord, return the verified invite URL for the global bot; an interactive session and feature:bots are required.
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; discord returns the verified server invite and requires an interactive session
POST/v1/bot-connections/{platform}/exchangebot-connections:create + session of the initiating userLegacy 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-guildsbot-connections:readList servers with bot_removed and missing_permissions
DELETE/v1/discord-guilds/{guild_id}bot-connections:deleteDisconnect one server, remove its settings and make the bot leave it
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) 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:

BotFields requestedUses them for
twitch-botaccountId, channelName, channelLoginIRC JOIN target and message routing
youtube-botaccountId, channelName, accessTokenLive-chat poll + 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.

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, !uptime and 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 request botType or customBotToken from botChannels - 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, 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.

Permissions​

PermissionOwnerAdminModerator
bot-connections:readYesYesYes
bot-connections:createYesYesNo
bot-connections:deleteYesYesNo

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. 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​

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​