Skip to main content

Bot Modules

Bot modules are extensions that handle chat messages, platform events, and timed actions. All bot modules -- built-in and community -- run as handler-based server functions inside V8 isolate sandboxes via the Bot Module Worker.

One execution model

There is no native Rust BotModule trait or module registry crate. The built-in modules -- Link Protection, Spam Protection, Word Filter, Timed Messages -- live in extensions/system/ and run through the same V8 execution model as third-party bot modules. See Extensions for the full list.

Architecture

Chat message / Platform event

Platform bot (twitch / youtube / kick / trovo / discord)
├─ Holds the account's compiled trigger set in memory
├─ Matches the message against commands / keywords / regex patterns
├─ command match → sync HTTP POST /v1/execute on the Worker (500 ms deadline)
└─ keyword/pattern → Redis publish to lumio:botmod:msg:{platform}:{account_id}

Bot Module Worker
└─ Executes the handler in a V8 isolate
├─ Handler receives ctx (auth, config, user, db, cache, secrets, fetch, defer)
├─ Returns { reply?, actions[] }
└─ The bot sends the reply / performs the actions via the platform API

Bot Module Worker

The Bot Module Worker (apps/bot-module-worker/, library in crates/lo-bot-module-worker/) is a standalone Actix Web service on port 8090. The Worker does not do trigger matching and does not talk to Redis -- it is a stateless executor. The bots own the trigger set and the matching; the Worker only runs the handler it is told to run.

HTTP surface:

EndpointMethodAuthPurpose
/v1/executePOSTAuthorization: SystemKey <key> (constant-time compared)Execute a command or moderate handler and return { reply, actions }
/healthGETnoneHealth check

The Worker refuses to start if worker.system_key is shorter than 16 characters -- an empty key would let an empty SystemKey header through.

Bot-side lifecycle:

  1. On connect, the bot fetches the account's trigger config from the API: GET /v1/internal/bot-modules/{account_id}/triggers (SystemKey auth). The response is { installs: [...], killed: bool }.
  2. compile_triggers() pre-compiles the regex patterns into AccountExtensionTriggers, keyed by account in an in-memory store.
  3. Every chat message is matched against that store in-process (no network hop).
  4. A command match becomes a SyncExecuteRequest sent to the Worker's POST /v1/execute (URL and deadline from the bot's [worker] config -- url, timeout_ms = 500). The returned reply is sanitised and sent to chat; the match is exclusive (the bot stops after it).
  5. A keyword or pattern match becomes an AsyncTriggerMessage published to Redis. These matches are non-exclusive -- the bot keeps evaluating the remaining installs.

Trigger refresh: the bots psubscribe to lumio:botmod:trigger_sync:* and lumio:botmod:kill:*. A message on the trigger-sync channel makes the leader bot re-fetch and recompile that account's trigger set; the kill channel flips the extension kill switch. Only the leader instance acts on these (leader.is_leader()).

API-side trigger resolution (apps/api/src/routes/bot_module_extensions.rs): the feed joins extension_installs → extensions → extension_versions for every category = 'bot_module' install and, per install, resolves the effective trigger set by layering optional per-install overrides from extension_installs.config.triggers over the version manifest's triggers (COALESCE: config wins field-by-field, manifest is the default). Commands, keyword substrings and regex patterns come from the manifest; has_moderate is set when the manifest declares triggers.moderation = true (the system moderation modules). The body is returned raw, without an ApiResponse envelope, because the bots deserialize it directly into TriggerConfigResponse. killed is always false here — the kill state is authoritative over the pub/sub channel, not the feed.

Each trigger carries a handler string, which is the function_name the bot sends to /v1/execute; the Worker looks it up in InstallRegistry.handler_code. The naming contract (the registry population must key handler_code identically) is: command → command name, keyword → keyword text, pattern → regex source, and a moderation handler is invoked with type: "moderate".

Registry population (handler-bundle feed). The Worker's InstallRegistry is filled from a separate internal feed: GET /v1/internal/bot-modules/handlers (SystemKey), resolved in apps/api/src/routes/internal_extension_handlers.rs. Where the trigger feed above delivers only trigger metadata, this feed delivers, per install, the executable handler bundle (the compiled server.js downloaded from storage) plus trusted context — account_id, extension_id, the installed version, install_config, enabled, platforms, has_moderate, and the list of valid handlers. The Automation Worker has the analogous GET /v1/internal/automation-nodes/handlers (which additionally carries node_type). Both accept optional account_id / install_id query params so the worker sync-loop can do a full initial load and then incremental refresh on install / uninstall / update. handler_code is null when the bundle cannot be resolved (missing file or storage unavailable); the install is still reported so the worker keeps full context.

Worker sync-loop. The worker actually pulls this feed at runtime (crates/lo-{bot-module,automation}-worker/src/sync.rs): on startup the SyncClient GETs the full cross-account snapshot and calls InstallRegistry::load_snapshot, then re-syncs the full snapshot every poll_interval_secs. A failed fetch is logged and the last-good registry is kept (fail-open), so a brief API outage never stops the worker. The loop runs only when the [sync] config section is fully set — enabled (default true), api_base_url, system_key (the Bearer key presented to the feed), and poll_interval_secs (default 30); an unconfigured worker starts with an empty registry (every request short-circuits) rather than crashing. The bot-module conversion maps every advertised handlers name to the single shared server.js bundle (InstalledBotModule.handler_code: HashMap<name, bundle>); disabled installs and installs with an unresolvable bundle are kept so requests short-circuit cleanly.

Redis Channels

Channel names are built by crates/lo-cache/src/pubsub.rs:

ChannelDirectionPurpose
lumio:botmod:msg:{platform}:{account_id}bot →Async keyword/pattern trigger matches
lumio:botmod:trigger_sync:{account_id}API → botsTrigger set invalidation (install/uninstall/enable/disable)
lumio:botmod:kill:{account_id}API → botsExtension kill switch
lumio:botmod:reload:{account_id}API →Bot module reload

V8 Execution

Extension bot module handlers execute inside the same V8 isolate sandbox used by all extension server functions (crates/lo-extensions-v8/). The handler context (ctx) includes:

The ctx object is assembled in build_handler_wrapper (crates/lo-extensions-v8/src/executor.rs). auth, config and user are inlined as plain JavaScript objects; the rest are globalThis proxy references installed by the ops layer (crates/lo-extensions-v8/src/ops/), each with a no-op fallback when the op is unavailable.

PropertyDescription
ctx.authCaller identity (userId, accountId)
ctx.configValidated install config from extension_installs.config
ctx.userChat user who triggered the handler (id, name, displayName, platform, role, isMod, isVip, isSub) -- null outside chat contexts
ctx.dbExtension-scoped database access (get, list, insert, patch, delete)
ctx.cacheKey-value cache scoped to the extension install (get, set, delete, increment)
ctx.secretsServer-side secrets (get)
ctx.fetchHTTP client (egress allowlist enforced by crates/lo-extensions/src/egress.rs)
ctx.defer()Queue a follow-up action to run after the handler returns

The isolate defaults are 256 MB heap and a 10 s timeout (IsolateConfig::default() in crates/lo-extensions-v8/src/isolate.rs), with no filesystem access and no dynamic code evaluation. Bot module handlers run tighter: the executor is called with a 500 ms timeout for sync command/moderate handlers, keeping the bot inside its own HTTP deadline.

Bundle evaluation (module_executor). The registry holds the real compiled server.js — an esbuild ESM module with export default / named exports and external @zaflun/lumio-sdk imports, not a bare async (ctx, args) => … expression. crates/lo-extensions-v8/src/module_executor.rs (execute_bundle_handler) evaluates it: it rewrites esbuild's regular output (hoisted imports + one trailing export{…}) onto the classic-script isolate path, binding the stripped SDK imports to an in-isolate shim of the pure command / keyword / defineModerate / defineAutomationAction / … tagging helpers, then selects the export the request targets (by __trigger for the low-level tagged handlers, by __type for the define* definitions, falling back to default) and invokes it with the calling convention its category expects. The shim adds no host authority — the isolate boundary is unchanged. The routers (MessageRouter, NodeRouter) call this via execute_bot_module_bundle / execute_automation_node_bundle; the older bare-expression execute_*_handler path is retained only for inline unit fixtures.

Trigger Matching Flow

Matching happens in the bot (apps/{platform}-bot/src/extension_triggers.rs), not in the Worker:

1. Chat message arrives on the bot's platform connection
2. Bot iterates the account's installs, skipping installs whose
`platforms` list excludes the current platform
3. match_triggers() checks, in this fixed order, and returns the FIRST hit:
a. command → message starts with the bot prefix and the first word
equals a command name or alias (case-insensitive)
b. keyword → lowercased message contains the keyword
c. pattern → compiled regex finds a match
4. Command → sync POST /v1/execute; reply sanitised and sent; bot stops
Keyword → Redis publish; bot continues to the next install
Pattern → Redis publish; bot continues to the next install

Disabled triggers are skipped, and a pattern whose regex fails to compile is dropped at compile time with a warning rather than failing the install.

Key Files

PathDescription
apps/bot-module-worker/src/main.rsWorker binary: config load, system-key guard, HTTP server
crates/lo-bot-module-worker/src/sync_handler.rsPOST /v1/execute + GET /health handlers, constant-time key check
crates/lo-bot-module-worker/src/router.rsMessageRouter -- sync and async dispatch into the executor
crates/lo-bot-module-worker/src/executor.rsexecute_bot_module_handler -- V8 execution wrapper
crates/lo-bot-module-worker/src/types.rsWire types (SyncExecuteRequest, AsyncTriggerMessage, trigger configs)
crates/lo-bot-module-worker/src/config.rsInstallRegistry -- in-memory installs and handler code
crates/lo-bot-module-worker/src/sanitizer.rsRedacts UUIDs, mentions, tokens and IPv4 from developer-visible output
crates/lo-bot-module-worker/src/audit.rsModeration-action audit log (tracing today)
crates/lo-bot-module-worker/src/timer.rsTimerEngine -- per-install interval timers
crates/lo-extensions-v8/src/executor.rsHandler wrapper, ctx construction, HandlerCategory
crates/lo-extensions-v8/src/isolate.rsIsolate limits and script execution
apps/{platform}-bot/src/extension_triggers.rsTrigger compilation and matching on the bot side
apps/api/src/routes/bot_module_extensions.rsGET /v1/internal/bot-modules/{account_id}/triggers
apps/api/src/routes/bot_modules.rsBuilt-in module config REST (bot-modules:read / bot-modules:edit)

Security Headers

The Extension Supervisor and Runtime apps serve HTTP security headers via Cloudflare Pages _headers files. Each app generates its dist/_headers at build time from the same origin lists it compiles into its bundle, so the CSP and the runtime postMessage allowlist always describe the same origins:

  • Supervisor (apps/extension-supervisor/tsup.config.ts): frame-src restricts which origins can be iframed (the Runtime). frame-ancestors restricts who can embed the Supervisor (the webapp).
  • Runtime (apps/extension-runtime/tsup.config.ts): worker-src restricts Worker origins. frame-ancestors restricts embedding to Supervisor origins only. connect-src allows Sentry error reporting.

Because the lists are per-environment, a build only advertises the origins of the environment it is built for — the production build's frame-ancestors names only supervisor.ext.lumio.vision, the staging build only lumio.supervisor.staging.zaflun.dev.

When adding a new environment domain, update the origin defaults in both tsup.config.ts files (and the matching ALLOWED_*_ORIGINS in .github/workflows/deploy-extension-apps.yml) alongside DNS and CF Pages configuration. There is no committed _headers file to edit.

Config Validation

Extension config values are validated server-side against the extension's config_schema before writing to extension_installs.config. The validator is validate_config_values() in crates/lo-extensions/src/config_validator.rs.

Field types accepted in a config_schema (VALID_CONFIG_TYPES): string, number, boolean, color, select, textarea, slider, font, icon, image, sound, multiselect, group, divider, info, channel, designer, presets. A field may also carry min / max / step, options, placeholder, rows, unit, collapsed, nested fields (for group), and a visible_when condition.

The validator produces a Vec<ConfigValidationError> covering:

ErrorCondition
UnknownKeyA submitted key is not in the schema
RequiredFieldMissingA required field is absent or null
TypeMismatchValue type does not match the declared field type
InvalidSelectValueselect value not in the declared options
InvalidMultiselectValueA multiselect entry not in the declared options
InvalidColorFormatcolor value is not #RRGGBB hex
OutOfRangeslider value outside [min, max]

group, divider and info fields are presentational and carry no value check.

Both GraphQL (configureExtension mutation) and REST (PATCH /v1/extension-installs/\{id\}) call the same validator, so the two protocols reject identically. The stored config is passed to V8 handlers via ctx.config.