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.
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:
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/v1/execute | POST | Authorization: SystemKey <key> (constant-time compared) | Execute a command or moderate handler and return { reply, actions } |
/health | GET | none | Health 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:
- 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 }. compile_triggers()pre-compiles the regex patterns intoAccountExtensionTriggers, keyed by account in an in-memory store.- Every chat message is matched against that store in-process (no network hop).
- A command match becomes a
SyncExecuteRequestsent to the Worker'sPOST /v1/execute(URL and deadline from the bot's[worker]config --url,timeout_ms = 500). The returnedreplyis sanitised and sent to chat; the match is exclusive (the bot stops after it). - A keyword or pattern match becomes an
AsyncTriggerMessagepublished 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:
| Channel | Direction | Purpose |
|---|---|---|
lumio:botmod:msg:{platform}:{account_id} | bot → | Async keyword/pattern trigger matches |
lumio:botmod:trigger_sync:{account_id} | API → bots | Trigger set invalidation (install/uninstall/enable/disable) |
lumio:botmod:kill:{account_id} | API → bots | Extension 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.
| Property | Description |
|---|---|
ctx.auth | Caller identity (userId, accountId) |
ctx.config | Validated install config from extension_installs.config |
ctx.user | Chat user who triggered the handler (id, name, displayName, platform, role, isMod, isVip, isSub) -- null outside chat contexts |
ctx.db | Extension-scoped database access (get, list, insert, patch, delete) |
ctx.cache | Key-value cache scoped to the extension install (get, set, delete, increment) |
ctx.secrets | Server-side secrets (get) |
ctx.fetch | HTTP 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
| Path | Description |
|---|---|
apps/bot-module-worker/src/main.rs | Worker binary: config load, system-key guard, HTTP server |
crates/lo-bot-module-worker/src/sync_handler.rs | POST /v1/execute + GET /health handlers, constant-time key check |
crates/lo-bot-module-worker/src/router.rs | MessageRouter -- sync and async dispatch into the executor |
crates/lo-bot-module-worker/src/executor.rs | execute_bot_module_handler -- V8 execution wrapper |
crates/lo-bot-module-worker/src/types.rs | Wire types (SyncExecuteRequest, AsyncTriggerMessage, trigger configs) |
crates/lo-bot-module-worker/src/config.rs | InstallRegistry -- in-memory installs and handler code |
crates/lo-bot-module-worker/src/sanitizer.rs | Redacts UUIDs, mentions, tokens and IPv4 from developer-visible output |
crates/lo-bot-module-worker/src/audit.rs | Moderation-action audit log (tracing today) |
crates/lo-bot-module-worker/src/timer.rs | TimerEngine -- per-install interval timers |
crates/lo-extensions-v8/src/executor.rs | Handler wrapper, ctx construction, HandlerCategory |
crates/lo-extensions-v8/src/isolate.rs | Isolate limits and script execution |
apps/{platform}-bot/src/extension_triggers.rs | Trigger compilation and matching on the bot side |
apps/api/src/routes/bot_module_extensions.rs | GET /v1/internal/bot-modules/{account_id}/triggers |
apps/api/src/routes/bot_modules.rs | Built-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-srcrestricts which origins can be iframed (the Runtime).frame-ancestorsrestricts who can embed the Supervisor (the webapp). - Runtime (
apps/extension-runtime/tsup.config.ts):worker-srcrestricts Worker origins.frame-ancestorsrestricts embedding to Supervisor origins only.connect-srcallows 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:
| Error | Condition |
|---|---|
UnknownKey | A submitted key is not in the schema |
RequiredFieldMissing | A required field is absent or null |
TypeMismatch | Value type does not match the declared field type |
InvalidSelectValue | select value not in the declared options |
InvalidMultiselectValue | A multiselect entry not in the declared options |
InvalidColorFormat | color value is not #RRGGBB hex |
OutOfRange | slider 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.