Skip to main content

Bot Commands

Overview​

Cross-platform custom command system. Users create commands in the dashboard, assign them to platforms, and bots execute them with template variable support, cooldowns, and automation triggers.

Command Types​

TypeDescription
TextFixed text response, one chat line — the dashboard editor turns typed or pasted line breaks into spaces
TemplateText with variables: {user}, {user_login}, {uptime}, {song}, {count}, {random:a,b,c}
AutomationTriggers an automation flow instead of sending text. Runs only where automation triggers are enabled, and never for a Twitch Shared Chat partner viewer

Platform Assignment​

Each command has a platforms JSON array. When creating, user selects which platforms the command is active on via checkboxes. Discord commands support both slash commands and prefix commands (configurable per command).

Permission Levels​

Hierarchical - higher includes lower:

broadcaster > moderator > vip > subscriber > follower > everyone

Each bot maps platform-specific user data to this hierarchy.

Response Text​

A command answers with one chat line. response_text containing a line break (CR or LF) is rejected on create, update and override, with the same error on GraphQL and REST: response_text must be a single line (no line breaks) (REST wraps it as Validation error: …, like every bot-command validation error).

Independently of that check, every bot sanitises each outgoing message at its platform transport (crates/lo-chat-outbound): line breaks and other control characters become spaces, a leading / or . is removed so the text is never read as a chat command, and the text is capped at the platform maximum (Twitch and Kick 500, YouTube 200, Discord 2000 characters). On Discord line breaks are kept and no message can ping @everyone, @here, users or roles — only a feature post's configured role is mentionable.

Cooldowns​

Redis-based, per-command:

  • Global: lumio:cmd_cd:{account_id}:{command_name}:global (TTL = cooldown seconds)
  • Per-user: lumio:cmd_cd:{account_id}:{command_name}:user:{user_id} (TTL = cooldown seconds)

Default: a new command (account or global) gets a 5 s global cooldown when cooldown_global_secs is not sent. All tenants share one bot account per platform, so an unthrottled command lets one chat burn the shared message budget. Set 0 explicitly to turn the cooldown off. The dashboard editor already defaults to 5 s.

Built-in commands of the Twitch, Kick and YouTube bots:

CommandReplySource
!song, !npNow playing: Artist - Title, or No track is currently playing.The Spotify track cache (lumio:spotify:current:{account_id}) the API's Spotify worker writes while a track plays; behind the widget:spotify_now_playing plan feature like {song}
!uptimeStream has been live for 2h 5m., or The stream is currently offline.The API's channel-status cache for the bot's platform (lumio:channel_status:{account_id}:{platform}), the same as {uptime}
!commandsThe list above-

There is no built-in !queue or !skip. The built-ins read Redis directly; they call no API field. Every GraphQL document the bots send is validated against the API schema by apps/api/tests/zaf1913_bot_graphql_documents.rs.

The built-ins have a per-channel cooldown of 5 s per command, held in the bot's memory. On the Twitch bot it is configurable (TWITCH_BOT__BOT__BUILTIN_COOLDOWN_SECS, 0 turns it off); see Installation.

Template Variables​

Extensible via VariableResolver trait in crates/lo-bot-commands/:

VariableFeature GateDescription
{user}-Display name of invoker
{user_login}-Login / @handle of invoker
{channel}-Channel/server name
{platform}-Platform name
{uptime}-Stream uptime on the command's platform, from the channel-status cache (same as !uptime)
{song}widget:spotify_now_playingCurrently playing Spotify track (same as !song)
{count}-Command use count
{random:a,b,c}-Random selection from list

{user} is the display name; {user_login} is the login / @handle. On Twitch the two differ when a chatter has a localized display name (e.g. {user} → こさむん, {user_login} → pocom_2026). On Kick and YouTube the bot chat payload carries no separate handle, so both resolve to the same value. {user} is left unchanged — templates that already print {user} keep printing the display name.

Adding new variables: implement VariableResolver trait + register in VariableRegistry.

Global Commands & Overrides​

  • Global commands: account_id IS NULL, created by admins, inherited by all accounts
  • Account commands: account_id set, owned by specific account
  • Overrides: Accounts can override global commands (customize response, platforms, cooldowns) or disable them
  • Shadowing: Account commands with same name as globals take priority
  • Security: Overrides cannot make min_role less restrictive than global

Database​

  • bot_commands - command definitions (global + account-scoped)
  • bot_command_overrides - per-account overrides of global commands
  • bot_command_stats - usage statistics (Redis live counter, periodic DB flush)

Shared Crate: lo-bot-commands​

Used by all four bots:

  • context.rs - CommandExecutionContext, UserRole
  • builtins.rs - data behind the built-in !song / !uptime and {song} / {uptime}, and the !commands list
  • registry.rs - LoadedCommand, AccountCommands, execute_command
  • permission.rs - Role hierarchy + checking
  • access.rs - the shared access gate (min_role, then the atomic cooldown slots) used by dynamic commands and extension command triggers
  • cooldown.rs - Redis cooldown check/set/increment
  • template.rs - {variable} parser with right-to-left replacement
  • variables.rs - VariableResolver trait, VariableRegistry, 8 built-in resolvers

Bot Integration​

All four bots (Twitch, YouTube, Kick, Discord):

  1. Load commands per account via botCommandsForAccount(accountId, platform) system query
  2. Store in-memory CommandStore (HashMap<AccountId, AccountCommands>)
  3. On chat message: check dynamic commands → access gate (permission → cooldown) → execute
  4. command_sync Redis action triggers reload
  5. Discord: also registers slash commands per guild (bulk overwrite, 200/day rate limit)

Twitch message processing. One IRC connection serves every tenant, so the Twitch bot never handles chat strictly one message at a time:

  • Incoming IRC messages pass through a bounded ingest queue (4096). When it is full the newest message is dropped and counted in bot_chat_messages_dropped_total{platform="twitch",outcome="ingest_full"}.
  • Each channel gets its own ordered worker (queue of 128, same drop-newest policy, outcome="channel_queue_full"), and at most 32 handlers run at once across all channels. A slow channel (worker call, send throttle) no longer delays the others; messages within one channel keep their order. An idle channel's worker exits after 5 minutes and is re-created on the next message.
  • An account whose commands are not loaded yet is loaded lazily on its first command. If that load fails it is not retried for 60 seconds, so a failing API is not hit once per chat line.

API​

TypeEndpointPermission
GraphQL QuerybotCommands: [BotCommand!]!bot-commands:read
GraphQL QuerybotCommand(id: ID!): BotCommandbot-commands:read
GraphQL MutationcreateBotCommand(input: CreateBotCommandInput!)bot-commands:create
GraphQL MutationupdateBotCommand(id: ID!, input: UpdateBotCommandInput!)bot-commands:edit
GraphQL MutationdeleteBotCommand(id: ID!)bot-commands:delete
GraphQL MutationoverrideGlobalCommand(commandId: ID!, input: OverrideInput!)bot-commands:edit
GraphQL MutationremoveGlobalCommandOverride(commandId: ID!)bot-commands:delete
System QuerybotCommandsForAccount(accountId: ID!, platform: String!)System key with bots:internal

REST Endpoints​

All paths live under /v1 and mirror the GraphQL mutations.

MethodPathPermissionDescription
GET/v1/bot-commandsbot-commands:readList account commands (+ inherited globals)
POST/v1/bot-commandsbot-commands:createCreate an account command
PUT/v1/bot-commands/{id}bot-commands:editUpdate a command
DELETE/v1/bot-commands/{id}bot-commands:deleteDelete a command
POST/v1/bot-commands/{id}/overridebot-commands:editOverride a global command for the account
DELETE/v1/bot-commands/{id}/overridebot-commands:deleteRemove the account's override of a global command

Plan Limits​

The plans.max_commands column caps how many commands an account may define. It is read straight from the account's canonical plans row on every create - both POST /v1/bot-commands and the createBotCommand mutation enforce the same cap with the same PLAN_LIMIT_REACHED error (ZAF-1092). 0 means unlimited; if the plan row cannot be resolved the cap fails open (unlimited). There is no per-account override for this limit.

TierMax Commands
Free25
Pro200

Feature Gating​

The whole Bot Commands surface is gated on the feature:bot_commands flag, in addition to the bot-commands:* permissions. Every query and mutation (botCommands, botCommand, createBotCommand, updateBotCommand, deleteBotCommand, overrideGlobalCommand, removeGlobalCommandOverride on GraphQL; GET/POST/PUT/DELETE /v1/bot-commands and the /override endpoints on REST) enforces it on both protocols - GraphQL via FeatureGuard::new("feature:bot_commands") chained before the permission guard, REST via require_feature(..., "feature:bot_commands", ...). With the flag disabled the API rejects the call directly (Feature 'feature:bot_commands' is not available, HTTP 403); the webapp route gate is UX only. The fleet-only botCommandsForAccount query (used by bot workers; System key with bots:internal) is intentionally not feature-gated.

RBAC Permissions​

Account: bot-commands:read/create/edit/delete Admin: bot-commands:read/create/edit/delete (via require_admin_permission)

Key Files​

PathDescription
crates/lo-bot-commands/Shared command execution crate (context, registry, permission, cooldown, template, variables)
apps/api/src/db/bot_commands.rsDatabase operations
apps/api/src/graphql/bot_commands.rsGraphQL queries and mutations
apps/api/src/routes/bot_commands_crud.rsREST endpoints

See Also​