Add event-driven overlay alerts

This commit is contained in:
Franz Rolfsvaag 2026-07-19 22:46:39 +02:00
parent a5bd15cf3e
commit 155f080fc5
27 changed files with 1052 additions and 23 deletions

View File

@ -1,5 +1,12 @@
# Lumi changelog
## 0.2.22
- Added reusable per-overlay event alerts that can queue one-shot Audio/Video playback or show Text/Image sources for a chosen duration without refreshing OBS.
- Added Twitch EventSub follow, incoming raid, subscription, and gifted-subscription inputs with server-side OAuth, token refresh, reconnect recovery, and subscription-tier filters.
- Added Discord member-join alerts through the existing bot connection, live test-delivery feedback, safe hook duplication/cleanup, and plugin event registration/emission extension points.
- Preserved all existing overlays, sources, tokens, settings, databases, plugin data, models, uploads, feedback, and secrets; the new event-hook table and Twitch event settings are additive.
## 0.2.21
- Added cached BetterTTV global, channel, and shared emote rendering to Twitch messages in Lumi chat overlays and standalone OBS chat docks, with safe plain-text fallback during provider failures.

22
TODO.md
View File

@ -35,6 +35,28 @@ Remaining work:
- Replace heuristic Discord subscriber/VIP role-name matching with explicit
per-server role mapping if communities need stricter Discord entitlements.
## Event automation and audience tracking
Overlay event-alert baseline completed on 2026-07-19: a shared, extensible event
registry now drives saved per-overlay actions through the existing scoped live
event stream. Admins can play queued audio/video once or show text/images for a
chosen duration, test alerts against connected Browser Sources, and receive
Twitch follows, incoming raids, subscriptions, gifted subscriptions, and Discord
member joins. Twitch uses a revocable server-side EventSub authorization with
automatic reconnect/token refresh; plugins can register and emit additional
event types through the shared event API.
Remaining work:
- Add centralized named counters with atomic modification actions, persistent or
per-stream reset modes, admin controls, and placeholders such as
`{{counter.twitch.followers}}`.
- Add watchtime, followage, subscription age, and equivalent provider-backed
audience tracking, then expose suitable totals and durations in leaderboards.
- Add more trigger adapters as integrations expose reliable events, including
chat-presence joins where the platform can distinguish a real join from an
ordinary message or reconnect.
## Current Local State / Source of Truth
### P0 OBS Overlay System

View File

@ -154,6 +154,31 @@ autoplay permission. To route video, audio, and website-alert sound through an
independent OBS mixer channel, enable **Control audio via OBS** in the Lumi Browser
Source properties, then configure monitoring/output in OBS as desired.
## Event alerts and sound alerts
Open an overlay and use **Event alerts** to connect a live service event to a
source. **Play media once** restarts an Audio or Video source and waits for that
playback to finish before the next alert for the same source begins. For a sound
alert, set the Audio source playback option to **Wait for manual playback**.
**Show source temporarily** keeps its Text or Image source hidden in clean OBS
output until an event arrives, displays it for the selected duration, and hides
it again. The editor still shows the source so it can be positioned normally.
The built-in event inputs are Twitch follows, incoming raids, subscriptions,
gifted subscriptions, and Discord member joins. Use **Connect Twitch events**
once as an administrator to grant the follower/subscription scopes. The token is
stored only on the server, refreshes automatically, and is never included in an
overlay page. Twitch events reconnect after temporary network loss. Discord
member joins use the existing bot connection and require the Server Members
Intent to be enabled for the bot in the Discord Developer Portal.
Use **Test alert** while either the pop-out preview or OBS Browser Source is open.
Lumi reports when no matching Browser Source is connected. An event only affects
the source when its scene is the one currently rendered; fixed scene links
therefore remain isolated. Plugins can add event types through the shared
`events.registerType(...)` and `events.emit(...)` API received during plugin
initialization.
### External website and alert overlays
Website sources support third-party browser overlays such as alert and chat

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
Version: 0.2.21
Version: 0.2.22
## Routes
- GET /api/events
- POST /api/destructive-confirmations

7
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.2.21",
"version": "0.2.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.2.21",
"version": "0.2.22",
"dependencies": {
"adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0",
@ -16,7 +16,8 @@
"express": "^4.19.2",
"express-session": "^1.18.1",
"multer": "^2.2.0",
"tmi.js": "^1.8.5"
"tmi.js": "^1.8.5",
"ws": "^8.21.1"
},
"engines": {
"node": ">=18"

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.2.21",
"version": "0.2.22",
"private": true,
"type": "commonjs",
"scripts": {
@ -31,7 +31,8 @@
"express": "^4.19.2",
"express-session": "^1.18.1",
"multer": "^2.2.0",
"tmi.js": "^1.8.5"
"tmi.js": "^1.8.5",
"ws": "^8.21.1"
},
"optionalDependencies": {
"obs-websocket-js": "^5.0.8"

View File

@ -2,6 +2,36 @@
"schema_version": 1,
"channel": "stable",
"releases": [
{
"version": "0.2.22",
"ref": "refs/tags/v0.2.22",
"released_at": "2026-07-19",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Adds event-driven overlay sound and temporary visual alerts for Twitch follows, raids, subscriptions, gifted subscriptions, and Discord member joins. Existing overlays, sources, tokens, settings, databases, plugin data, models, uploads, feedback, and secrets are preserved; event storage and Twitch event credentials are additive.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.5",
"moderation": "0.1.5",
"okf": "0.1.1",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{
"version": "0.2.21",
"ref": "refs/tags/v0.2.21",

View File

@ -17,6 +17,8 @@ for (const file of [
"overlay-secrets.js",
"obs-browser-defaults.js",
"overlay-modules.js",
"lumi-events.js",
"overlay-event-hooks.js",
"overlays.js",
"overlay-permissions.js",
"overlay-connectors.js",
@ -86,11 +88,40 @@ try {
enabled: true,
config: { text: "Starting soon", x: 5, y: 10, width: 90, height: 20 }
});
const soundId = overlays.addModule(created.scenes[0].id, {
name: "Follower sound",
type: "audio",
enabled: true,
config: { url: "https://example.com/follow.ogg", playBehavior: "manual" }
});
const eventHooks = require(path.join(serviceDir, "overlay-event-hooks.js"));
const followHook = eventHooks.createOverlayEventHook({
overlayId: created.id,
eventType: "twitch.follow",
actionType: "play_once",
moduleId: soundId,
enabled: true
});
eventHooks.createOverlayEventHook({
overlayId: created.id,
eventType: "discord.member_join",
actionType: "show_temporary",
moduleId: headlineId,
durationSeconds: 7.5,
enabled: true
});
assert.strictEqual(eventHooks.listOverlayEventHooks(created.id).length, 2);
assert.strictEqual(overlays.buildPublicState(created.id).scene.modules.find((module) => module.id === headlineId).config.eventOnly, true);
const dispatched = eventHooks.dispatchOverlayEvent({ id: "follow-1", type: "twitch.follow", payload: {}, source: "verify", test: false });
assert.strictEqual(dispatched.matched, 1);
assert.strictEqual(eventHooks.dispatchOverlayEvent({ id: "follow-1", type: "twitch.follow", payload: {}, source: "verify", test: false }).duplicate, true);
assert.throws(() => eventHooks.createOverlayEventHook({ overlayId: created.id, eventType: "twitch.raid", actionType: "play_once", moduleId: headlineId }), /requires audio or video source/);
assert.strictEqual(eventHooks.testOverlayEventHook(followHook.id, created.id).hook.module_id, soundId);
const state = overlays.buildPublicState(created.id);
assert.strictEqual(state.scene.modules.length, 1);
assert.strictEqual(state.scene.modules[0].config.text, "Starting soon");
assert.strictEqual(state.scene.modules.length, 2);
assert.strictEqual(state.scene.modules.find((module) => module.id === headlineId).config.text, "Starting soon");
overlays.updateModuleTransform(headlineId, { x: 50, y: 50, width: 40, height: 15, anchor: "center" });
const transformed = overlays.getOverlay(created.id).scenes[0].modules[0];
const transformed = overlays.getOverlay(created.id).scenes[0].modules.find((module) => module.id === headlineId);
assert.strictEqual(transformed.config.anchor, "center");
assert.strictEqual(transformed.config.text, "Starting soon", "canvas transforms must preserve source content");
@ -288,7 +319,8 @@ try {
const duplicate = overlays.duplicateOverlay(created.id);
assert.notStrictEqual(duplicate.public_token, newOverlayToken);
assert.strictEqual(duplicate.scenes.length, 1);
assert.strictEqual(duplicate.scenes[0].modules.length, 1);
assert.strictEqual(duplicate.scenes[0].modules.length, 2);
assert.strictEqual(eventHooks.listOverlayEventHooks(duplicate.id).length, 2, "duplicating an overlay must duplicate its event alerts with remapped sources");
overlays.saveObsSettings(created.id, {
enabled: false,
@ -322,6 +354,9 @@ try {
const connectorScript = fs.readFileSync(path.join(root, "src", "services", "overlay-connectors.js"), "utf8");
const twitchBadgeScript = fs.readFileSync(path.join(root, "src", "services", "twitch-chat-assets.js"), "utf8");
const twitchServiceScript = fs.readFileSync(path.join(root, "src", "services", "twitch.js"), "utf8");
const twitchEventSubScript = fs.readFileSync(path.join(root, "src", "services", "twitch-eventsub.js"), "utf8");
const discordServiceScript = fs.readFileSync(path.join(root, "src", "services", "discord.js"), "utf8");
const serverScript = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
const routeScript = fs.readFileSync(path.join(root, "src", "services", "overlay-routes.js"), "utf8");
assert(editorView.includes("data-overlay-canvas"), "management page must include a visual canvas");
assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"'));
@ -341,6 +376,7 @@ try {
assert(editorScript.includes("focusCanvas = true") && editorScript.includes("focusCanvas: false"), "draft rerenders must preserve form focus");
assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position");
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
assert(runtimeScript.includes("overlay:action") && runtimeScript.includes("playMediaOnce") && runtimeScript.includes("showSourceTemporarily"));
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
assert(runtimeScript.includes("window.obsstudio") && runtimeScript.includes("overlay:obs-browser-command"));
assert(runtimeScript.includes("saveReplayBuffer"), "Browser Bridge must support OBS replay saves");
@ -385,10 +421,18 @@ try {
assert(twitchBadgeScript.includes("api.betterttv.net/3/${path}") && twitchBadgeScript.includes("cached/emotes/global") && twitchBadgeScript.includes("cached/users/twitch/"));
assert(twitchBadgeScript.includes("channelEmotes") && twitchBadgeScript.includes("sharedEmotes") && twitchBadgeScript.includes("cdn.betterttv.net/emote/"));
assert(twitchServiceScript.includes("resolveBetterTtvEmotes") && twitchServiceScript.includes("...betterTtvEmotes"));
assert(twitchEventSubScript.includes('type: "channel.follow"') && twitchEventSubScript.includes('type: "channel.raid"'));
assert(twitchEventSubScript.includes('type: "channel.subscribe"') && twitchEventSubScript.includes('type: "channel.subscription.gift"'));
assert(twitchEventSubScript.includes("refreshAccessToken") && twitchEventSubScript.includes("session_reconnect"));
assert(discordServiceScript.includes('client.on("guildMemberAdd"') && discordServiceScript.includes('emitLumiEvent("discord.member_join"'));
assert(serverScript.includes('/admin/twitch-events/connect') && serverScript.includes("buildTwitchEventAuthUrl"));
assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import"));
assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument"));
assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket"));
assert(routeScript.includes('/overlay-chat/:ticket') && routeScript.includes("resolveChatDockTicket"));
assert(routeScript.includes("/event-hooks/:hookId/test") && routeScript.includes("testOverlayEventHook"));
assert(editorView.includes("Event alerts") && editorView.includes("data-event-hook-form"));
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/event-hooks/<%= hook.id %>/delete"'));
assert(routeScript.includes("validate: () => Boolean(chatDockContext(req))"));
assert(chatDockView.includes("lumi-chat-dock-root") && chatDockView.includes("overlay-chat-dock.js"));
assert(chatDockScript.includes("renderChatDock") && chatDockScript.includes("overlay:chat-message"));

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.2.21";
const previousCoreVersion = "0.2.20";
const releaseVersion = "0.2.22";
const previousCoreVersion = "0.2.21";
const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: core 0.2.21, Lumi AI 0.8.5, and synchronized package metadata.");
console.log("Release metadata verification passed: core 0.2.22, Lumi AI 0.8.5, and synchronized package metadata.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.deepEqual(releaseVersions, ["0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
available_versions: [
{ version: "0.2.22", ref: "refs/tags/v0.2.22", rollback_safe: true },
{ version: "0.2.21", ref: "refs/tags/v0.2.21", rollback_safe: true },
{ version: "0.2.20", ref: "refs/tags/v0.2.20", rollback_safe: true },
{ version: "0.2.19", ref: "refs/tags/v0.2.19", rollback_safe: true },
@ -81,7 +82,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.21");
assert.equal(corrected.safe_target_version, "0.2.22");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);

View File

@ -18,6 +18,7 @@ const { isPlatformEnabled } = require("./services/platforms");
const { registerCorePlaceholders } = require("./services/placeholders");
const { overlayConnectorManager } = require("./services/overlay-connectors");
const { cleanupSnapshots } = require("./services/update-manager");
const { twitchEventSubManager } = require("./services/twitch-eventsub");
const {
isSafeModeRequested,
markStartupVerification
@ -71,6 +72,11 @@ async function main() {
} catch (error) {
console.error("Twitch bot failed to start", error);
}
try {
await twitchEventSubManager.start();
} catch (error) {
console.error("Twitch event alerts failed to start", error);
}
}
if (!safeModeRequested && isPlatformEnabled("youtube")) {
@ -143,6 +149,7 @@ async function main() {
shuttingDown = true;
runtimeLog.info("Lumi shutdown started", null, { event: "shutdown" });
await overlayConnectorManager.stop();
await twitchEventSubManager.stop();
await stopPlugins();
await stopBot();
await stopTwitchBot();

View File

@ -85,6 +85,20 @@ function buildTwitchAuthUrl(state, redirectOverride) {
return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`;
}
function buildTwitchEventAuthUrl(state, redirectOverride) {
const clientId = getSetting("twitch_client_id");
const redirectUri = redirectOverride || getSetting("twitch_redirect_uri");
const params = new URLSearchParams({
client_id: clientId || "",
redirect_uri: redirectUri || "",
response_type: "code",
scope: "moderator:read:followers channel:read:subscriptions",
state,
force_verify: "true"
});
return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`;
}
async function exchangeTwitchCode(code, redirectOverride) {
const clientId = getSetting("twitch_client_id");
const clientSecret = getSetting("twitch_client_secret");
@ -180,6 +194,7 @@ module.exports = {
fetchDiscordUser,
fetchDiscordGuildMember,
buildTwitchAuthUrl,
buildTwitchEventAuthUrl,
exchangeTwitchCode,
fetchTwitchUser,
buildYouTubeAuthUrl,

View File

@ -351,6 +351,24 @@ function migrate() {
CREATE INDEX IF NOT EXISTS overlay_modules_scene_idx ON overlay_modules (scene_id, sort_order, created_at);
CREATE TABLE IF NOT EXISTS overlay_event_hooks (
id TEXT PRIMARY KEY,
overlay_id TEXT NOT NULL,
event_type TEXT NOT NULL,
action_type TEXT NOT NULL,
module_id TEXT NOT NULL,
duration_seconds REAL NOT NULL DEFAULT 0,
filters_json TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (overlay_id) REFERENCES overlays(id) ON DELETE CASCADE,
FOREIGN KEY (module_id) REFERENCES overlay_modules(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS overlay_event_hooks_event_idx ON overlay_event_hooks (event_type, enabled);
CREATE INDEX IF NOT EXISTS overlay_event_hooks_overlay_idx ON overlay_event_hooks (overlay_id, created_at);
CREATE TABLE IF NOT EXISTS overlay_obs_settings (
overlay_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,

View File

@ -10,6 +10,7 @@ const { ensureUserForIdentity } = require("./users");
const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat");
const { discordOverlayContent } = require("./discord-chat-content");
const { emitLumiEvent } = require("./lumi-events");
const discordLog = createLogger("platform:discord", { category: "integration" });
@ -118,7 +119,24 @@ async function startBot({ commandRouter } = {}) {
}
});
}
});
});
client.on("guildMemberAdd", (member) => {
if (!member?.user || member.user.bot) return;
const configuredGuildId = String(getSetting("discord_guild_id", "") || "");
if (configuredGuildId && member.guild?.id !== configuredGuildId) return;
emitLumiEvent("discord.member_join", {
guild_id: member.guild?.id || null,
guild_name: member.guild?.name || null,
user_id: member.user.id,
user_name: member.user.globalName || member.user.username || member.user.tag,
joined_at: member.joinedTimestamp || Date.now()
}, {
id: `${member.guild?.id || "guild"}:${member.user.id}:${member.joinedTimestamp || Date.now()}`,
source: "discord-gateway",
occurredAt: member.joinedTimestamp || Date.now()
});
});
await client.login(token);
return client;

View File

@ -0,0 +1,63 @@
const crypto = require("crypto");
const { createLogger } = require("./logger");
const eventLog = createLogger("core:events", { category: "automation" });
const eventTypes = new Map();
const listeners = new Set();
function registerEventType(definition) {
const id = String(definition?.id || "").trim().toLowerCase();
if (!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(id)) throw new Error("Event types require a stable namespaced id.");
const normalized = Object.freeze({
id,
label: String(definition.label || id).trim().slice(0, 120),
description: String(definition.description || "").trim().slice(0, 500),
platform: String(definition.platform || id.split(".")[0]).trim().toLowerCase().slice(0, 40),
supportsTier: Boolean(definition.supportsTier)
});
eventTypes.set(id, normalized);
return () => eventTypes.delete(id);
}
function listEventTypes() {
return Array.from(eventTypes.values()).sort((left, right) => left.label.localeCompare(right.label));
}
function onLumiEvent(listener) {
if (typeof listener !== "function") throw new Error("Event listener must be a function.");
listeners.add(listener);
return () => listeners.delete(listener);
}
function emitLumiEvent(type, payload = {}, metadata = {}) {
const eventType = String(type || "").trim().toLowerCase();
if (!eventTypes.has(eventType)) throw new Error(`Unknown Lumi event type: ${eventType || "empty"}`);
const event = Object.freeze({
id: String(metadata.id || crypto.randomUUID()).slice(0, 256),
type: eventType,
payload: payload && typeof payload === "object" && !Array.isArray(payload) ? Object.freeze({ ...payload }) : Object.freeze({}),
source: String(metadata.source || eventType.split(".")[0]).slice(0, 80),
occurredAt: Number.isFinite(Number(metadata.occurredAt))
? Number(metadata.occurredAt)
: (Number.isFinite(Date.parse(metadata.occurredAt)) ? Date.parse(metadata.occurredAt) : Date.now()),
test: Boolean(metadata.test)
});
for (const listener of listeners) {
try {
Promise.resolve(listener(event)).catch((error) => eventLog.error("Event listener failed", error, { event: "automation_listener_failed", event_type: eventType }));
} catch (error) {
eventLog.error("Event listener failed", error, { event: "automation_listener_failed", event_type: eventType });
}
}
return event;
}
for (const definition of [
{ id: "twitch.follow", label: "Twitch follow", description: "A viewer follows a configured Twitch channel.", platform: "twitch" },
{ id: "twitch.raid", label: "Twitch raid received", description: "A configured Twitch channel receives a raid that appears in chat.", platform: "twitch" },
{ id: "twitch.subscribe", label: "Twitch subscription", description: "A configured Twitch channel receives a new subscription.", platform: "twitch", supportsTier: true },
{ id: "twitch.subscription_gift", label: "Twitch gifted subscriptions", description: "A viewer gifts one or more subscriptions in a configured Twitch channel.", platform: "twitch", supportsTier: true },
{ id: "discord.member_join", label: "Discord member joined", description: "A member joins the configured Discord server.", platform: "discord" }
]) registerEventType(definition);
module.exports = { emitLumiEvent, listEventTypes, onLumiEvent, registerEventType };

View File

@ -0,0 +1,230 @@
const crypto = require("crypto");
const { db } = require("./db");
const { createLogger } = require("./logger");
const { getOverlayModuleType } = require("./overlay-modules");
const { emitLumiEvent, listEventTypes, onLumiEvent, registerEventType } = require("./lumi-events");
const { publishWebEvent } = require("./web-events");
const hookLog = createLogger("core:overlay-events", { category: "automation" });
const recentEventIds = new Map();
const EVENT_DEDUP_MS = 10 * 60 * 1000;
const ACTION_TYPES = Object.freeze([
{ id: "play_once", label: "Play media once", description: "Restart an audio or video source and play it once.", renderTypes: ["audio", "video"] },
{ id: "show_temporary", label: "Show source temporarily", description: "Show a text or image source for the selected time, then hide it again.", renderTypes: ["text", "image"] }
]);
function parseFilters(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value || "{}") : value;
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function actionDefinition(id) {
return ACTION_TYPES.find((action) => action.id === id) || null;
}
function eventDefinition(id) {
return listEventTypes().find((event) => event.id === id) || null;
}
function normalizedTier(value) {
const tier = String(value || "").trim();
return ["1000", "2000", "3000"].includes(tier) ? tier : "";
}
function normalizedDuration(value) {
const duration = Number(value);
return Number.isFinite(duration) ? Math.min(3600, Math.max(0.5, duration)) : 5;
}
function moduleRecord(overlayId, moduleId) {
return db.prepare(
`SELECT m.id, m.type, m.name, m.enabled, s.id AS scene_id, s.name AS scene_name, s.enabled AS scene_enabled, s.overlay_id
FROM overlay_modules m JOIN overlay_scenes s ON s.id = m.scene_id
WHERE m.id = ? AND s.overlay_id = ?`
).get(moduleId, overlayId);
}
function validateHookInput(input) {
const overlayId = String(input.overlayId || "");
const eventType = String(input.eventType || "").trim().toLowerCase();
const actionType = String(input.actionType || "").trim().toLowerCase();
const moduleId = String(input.moduleId || "");
const event = eventDefinition(eventType);
const action = actionDefinition(actionType);
const module = moduleRecord(overlayId, moduleId);
if (!event) throw new Error("Choose an available event.");
if (!action) throw new Error("Choose an available alert action.");
if (!module) throw new Error("Choose a source from this overlay.");
if (!module.enabled || !module.scene_enabled) throw new Error("The selected source and its scene must be enabled.");
const renderType = getOverlayModuleType(module.type)?.renderType || module.type;
if (!action.renderTypes.includes(renderType)) {
throw new Error(`${action.label} requires ${action.renderTypes.join(" or ")} source.`);
}
const tier = event.supportsTier ? normalizedTier(input.tier) : "";
return {
overlayId,
eventType,
actionType,
moduleId,
durationSeconds: actionType === "show_temporary" ? normalizedDuration(input.durationSeconds) : 0,
filters: tier ? { tier } : {},
enabled: input.enabled !== false,
module
};
}
function rowPresentation(row) {
return {
...row,
enabled: Boolean(row.enabled),
duration_seconds: Number(row.duration_seconds || 0),
filters: parseFilters(row.filters_json),
event: eventDefinition(row.event_type),
action: actionDefinition(row.action_type)
};
}
function listOverlayEventHooks(overlayId) {
return db.prepare(
`SELECT h.*, m.name AS module_name, m.type AS module_type, s.name AS scene_name
FROM overlay_event_hooks h
JOIN overlay_modules m ON m.id = h.module_id
JOIN overlay_scenes s ON s.id = m.scene_id
WHERE h.overlay_id = ? ORDER BY h.created_at, h.id`
).all(overlayId).map(rowPresentation);
}
function touchOverlay(overlayId) {
const now = Date.now();
const changed = db.prepare("UPDATE overlays SET updated_at = ? WHERE id = ?").run(now, overlayId);
if (changed.changes) publishWebEvent("overlay:changed", { revision: now }, { scope: `overlay:${overlayId}` });
}
function createOverlayEventHook(input) {
const value = validateHookInput(input);
const id = crypto.randomUUID();
const now = Date.now();
db.prepare(
`INSERT INTO overlay_event_hooks
(id, overlay_id, event_type, action_type, module_id, duration_seconds, filters_json, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(id, value.overlayId, value.eventType, value.actionType, value.moduleId, value.durationSeconds, JSON.stringify(value.filters), value.enabled ? 1 : 0, now, now);
touchOverlay(value.overlayId);
return listOverlayEventHooks(value.overlayId).find((hook) => hook.id === id);
}
function updateOverlayEventHook(id, input) {
const existing = db.prepare("SELECT * FROM overlay_event_hooks WHERE id = ? AND overlay_id = ?").get(id, input.overlayId);
if (!existing) throw new Error("Event alert not found.");
const value = validateHookInput(input);
db.prepare(
`UPDATE overlay_event_hooks SET event_type = ?, action_type = ?, module_id = ?, duration_seconds = ?, filters_json = ?, enabled = ?, updated_at = ?
WHERE id = ?`
).run(value.eventType, value.actionType, value.moduleId, value.durationSeconds, JSON.stringify(value.filters), value.enabled ? 1 : 0, Date.now(), id);
touchOverlay(value.overlayId);
return listOverlayEventHooks(value.overlayId).find((hook) => hook.id === id);
}
function deleteOverlayEventHook(id, overlayId) {
const result = db.prepare("DELETE FROM overlay_event_hooks WHERE id = ? AND overlay_id = ?").run(id, overlayId);
if (!result.changes) throw new Error("Event alert not found.");
touchOverlay(overlayId);
}
function matchesFilters(filters, payload) {
if (filters.tier && String(payload?.tier || "") !== filters.tier) return false;
return true;
}
function publishHookAction(hook, event) {
return publishWebEvent("overlay:action", {
action_id: crypto.randomUUID(),
hook_id: hook.id,
module_id: hook.module_id,
action: hook.action_type,
duration_seconds: Number(hook.duration_seconds || 0),
event_type: event.type,
test: Boolean(event.test)
}, { scope: `overlay:${hook.overlay_id}` });
}
function eventWasHandled(event) {
const now = Date.now();
for (const [id, at] of recentEventIds) if (now - at > EVENT_DEDUP_MS) recentEventIds.delete(id);
const key = `${event.source}:${event.id}`;
if (recentEventIds.has(key)) return true;
recentEventIds.set(key, now);
return false;
}
function dispatchOverlayEvent(event) {
if (eventWasHandled(event)) return { matched: 0, delivered: 0, duplicate: true };
const hooks = db.prepare(
`SELECT h.* FROM overlay_event_hooks h
JOIN overlays o ON o.id = h.overlay_id
JOIN overlay_modules m ON m.id = h.module_id
JOIN overlay_scenes s ON s.id = m.scene_id
WHERE h.event_type = ? AND h.enabled = 1 AND o.enabled = 1 AND m.enabled = 1 AND s.enabled = 1`
).all(event.type).filter((hook) => matchesFilters(parseFilters(hook.filters_json), event.payload));
let delivered = 0;
for (const hook of hooks) delivered += publishHookAction(hook, event);
if (hooks.length) hookLog.info("Overlay event alert dispatched", {
event_type: event.type,
matched_hooks: hooks.length,
browser_sources_reached: delivered,
test: event.test
}, { event: "overlay_event_dispatched" });
return { matched: hooks.length, delivered, duplicate: false };
}
function testOverlayEventHook(id, overlayId) {
const hook = db.prepare("SELECT * FROM overlay_event_hooks WHERE id = ? AND overlay_id = ?").get(id, overlayId);
if (!hook) throw new Error("Event alert not found.");
const event = { id: crypto.randomUUID(), type: hook.event_type, payload: parseFilters(hook.filters_json), source: "admin-test", test: true };
return { hook: rowPresentation(hook), delivered: publishHookAction(hook, event) };
}
function eventOnlyModuleIds(sceneId) {
return new Set(db.prepare(
`SELECT DISTINCT h.module_id FROM overlay_event_hooks h
JOIN overlay_modules m ON m.id = h.module_id
WHERE m.scene_id = ? AND h.enabled = 1 AND h.action_type = 'show_temporary'`
).all(sceneId).map((row) => row.module_id));
}
function duplicateOverlayEventHooks(sourceOverlayId, targetOverlayId, moduleIds) {
const hooks = db.prepare("SELECT * FROM overlay_event_hooks WHERE overlay_id = ?").all(sourceOverlayId);
const insert = db.prepare(
`INSERT INTO overlay_event_hooks
(id, overlay_id, event_type, action_type, module_id, duration_seconds, filters_json, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const now = Date.now();
for (const hook of hooks) {
const moduleId = moduleIds.get(hook.module_id);
if (moduleId) insert.run(crypto.randomUUID(), targetOverlayId, hook.event_type, hook.action_type, moduleId, hook.duration_seconds, hook.filters_json, hook.enabled, now, now);
}
}
function eventHooksApi() {
return Object.freeze({ emit: emitLumiEvent, listTypes: listEventTypes, on: onLumiEvent, registerType: registerEventType });
}
onLumiEvent(dispatchOverlayEvent);
module.exports = {
ACTION_TYPES,
createOverlayEventHook,
deleteOverlayEventHook,
dispatchOverlayEvent,
duplicateOverlayEventHooks,
eventHooksApi,
eventOnlyModuleIds,
listOverlayEventHooks,
testOverlayEventHook,
updateOverlayEventHook
};

View File

@ -41,6 +41,16 @@ const {
const { OBS_BROWSER_DEFAULT_CSS } = require("./obs-browser-defaults");
const { fetchPublicOverlay, loadInjectedOverlayDocument } = require("./overlay-web-documents");
const { recentOverlayChatMessages } = require("./overlay-chat");
const { listEventTypes } = require("./lumi-events");
const { twitchEventSubManager } = require("./twitch-eventsub");
const {
ACTION_TYPES: OVERLAY_EVENT_ACTIONS,
createOverlayEventHook,
deleteOverlayEventHook,
listOverlayEventHooks,
testOverlayEventHook,
updateOverlayEventHook
} = require("./overlay-event-hooks");
const {
listOverlayConnectorProviders,
overlayConnectorManager
@ -496,6 +506,10 @@ function registerOverlayAdminRoutes(app) {
baseUrl: baseUrl(req),
obsBrowserDefaultCss: OBS_BROWSER_DEFAULT_CSS,
moduleTypes: listOverlayModuleTypes(),
eventTypes: listEventTypes(),
eventActions: OVERLAY_EVENT_ACTIONS,
eventHooks: listOverlayEventHooks(overlay.id),
twitchEventStatus: twitchEventSubManager.getStatus(),
editorStateJson: JSON.stringify({
overlayId: overlay.id,
canvas: { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 },
@ -552,6 +566,50 @@ function registerOverlayAdminRoutes(app) {
app.post("/admin/overlays/:id/modules/:moduleId/duplicate", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => duplicateModule(req.params.moduleId, req.body.name), "Source copied."));
app.post("/admin/overlays/:id/modules/:moduleId/delete", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => deleteModule(req.params.moduleId), "Source deleted."));
const eventHookInput = (req) => ({
overlayId: req.params.id,
eventType: req.body.event_type,
actionType: req.body.action_type,
moduleId: req.body.module_id,
durationSeconds: req.body.duration_seconds,
tier: req.body.tier,
enabled: booleanField(req.body, "enabled")
});
app.post("/admin/overlays/:id/event-hooks", requireOverlayAccess(), (req, res) => redirectWithResult(
req,
res,
`/admin/overlays/${req.params.id}`,
() => createOverlayEventHook(eventHookInput(req)),
"Event alert created."
));
app.post("/admin/overlays/:id/event-hooks/:hookId", requireOverlayAccess(), (req, res) => redirectWithResult(
req,
res,
`/admin/overlays/${req.params.id}`,
() => updateOverlayEventHook(req.params.hookId, eventHookInput(req)),
"Event alert saved."
));
app.post("/admin/overlays/:id/event-hooks/:hookId/test", requireOverlayAccess(), (req, res) => {
const returnTo = `/admin/overlays/${req.params.id}`;
try {
const result = testOverlayEventHook(req.params.hookId, req.params.id);
flash(req, result.delivered ? "success" : "warning", result.delivered
? `Test alert sent to ${result.delivered} connected Browser Source${result.delivered === 1 ? "" : "s"}.`
: "No Browser Source is connected to this overlay. Open its private link in OBS or the pop-out preview, then test again.");
} catch (error) {
flash(req, "error", error.message);
}
res.redirect(returnTo);
});
app.post("/admin/overlays/:id/event-hooks/:hookId/delete", requireOverlayAccess(), (req, res) => redirectWithResult(
req,
res,
`/admin/overlays/${req.params.id}`,
() => deleteOverlayEventHook(req.params.hookId, req.params.id),
"Event alert deleted."
));
app.post("/api/admin/overlays/:id/modules/:moduleId/transform", requireOverlayAccess(), requireModuleInOverlay, (req, res) => {
noStore(res);
try {

View File

@ -3,6 +3,7 @@ const EventEmitter = require("events");
const { db } = require("./db");
const { publishWebEvent } = require("./web-events");
const { getOverlayModuleType, normalizeModuleConfig } = require("./overlay-modules");
const { duplicateOverlayEventHooks, eventOnlyModuleIds } = require("./overlay-event-hooks");
const {
createStoredToken,
decryptSecret,
@ -172,6 +173,7 @@ function duplicateOverlay(id, name) {
sceneName: source.scenes[0]?.name || "Main"
});
const copyId = copy.id;
const moduleIds = new Map();
const clone = db.transaction(() => {
db.prepare("DELETE FROM overlay_modules WHERE scene_id IN (SELECT id FROM overlay_scenes WHERE overlay_id = ?)").run(copyId);
db.prepare("DELETE FROM overlay_scenes WHERE overlay_id = ?").run(copyId);
@ -184,11 +186,14 @@ function duplicateOverlay(id, name) {
"INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(newSceneId, copyId, scene.name, scene.enabled ? 1 : 0, scene.sort_order, storedToken.hash, storedToken.encrypted, scene.obs_scene_name, Date.now(), Date.now());
for (const module of scene.modules) {
const newModuleId = crypto.randomUUID();
db.prepare(
"INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(crypto.randomUUID(), newSceneId, module.type, module.name, module.enabled ? 1 : 0, module.sort_order, module.config_json, Date.now(), Date.now());
).run(newModuleId, newSceneId, module.type, module.name, module.enabled ? 1 : 0, module.sort_order, module.config_json, Date.now(), Date.now());
moduleIds.set(module.id, newModuleId);
}
}
duplicateOverlayEventHooks(id, copyId, moduleIds);
db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(firstSceneId, Date.now(), copyId);
});
clone();
@ -222,6 +227,7 @@ function deleteOverlay(id) {
const exists = db.prepare("SELECT id FROM overlays WHERE id = ?").get(id);
if (!exists) throw new Error("Overlay not found.");
db.transaction(() => {
db.prepare("DELETE FROM overlay_event_hooks WHERE overlay_id = ?").run(id);
db.prepare("DELETE FROM overlay_modules WHERE scene_id IN (SELECT id FROM overlay_scenes WHERE overlay_id = ?)").run(id);
db.prepare("DELETE FROM overlay_scenes WHERE overlay_id = ?").run(id);
db.prepare("DELETE FROM overlay_obs_settings WHERE overlay_id = ?").run(id);
@ -306,6 +312,7 @@ function deleteScene(sceneId) {
const count = db.prepare("SELECT COUNT(*) AS count FROM overlay_scenes WHERE overlay_id = ?").get(scene.overlay_id).count;
if (count <= 1) throw new Error("Every overlay must have at least one scene.");
db.transaction(() => {
db.prepare("DELETE FROM overlay_event_hooks WHERE module_id IN (SELECT id FROM overlay_modules WHERE scene_id = ?)").run(sceneId);
db.prepare("DELETE FROM overlay_modules WHERE scene_id = ?").run(sceneId);
db.prepare("DELETE FROM overlay_scenes WHERE id = ?").run(sceneId);
if (db.prepare("SELECT active_scene_id FROM overlays WHERE id = ?").get(scene.overlay_id)?.active_scene_id === sceneId) {
@ -415,7 +422,10 @@ function reorderModules(sceneId, ids) {
function deleteModule(moduleId) {
const overlayId = overlayIdForModule(moduleId);
if (!overlayId) throw new Error("Overlay module not found.");
db.prepare("DELETE FROM overlay_modules WHERE id = ?").run(moduleId);
db.transaction(() => {
db.prepare("DELETE FROM overlay_event_hooks WHERE module_id = ?").run(moduleId);
db.prepare("DELETE FROM overlay_modules WHERE id = ?").run(moduleId);
})();
touchOverlay(overlayId);
notifyOverlayChanged(overlayId, "module_delete");
}
@ -509,11 +519,13 @@ function buildPublicState(overlayId, fixedSceneId = null) {
? db.prepare("SELECT id, name, enabled FROM overlay_scenes WHERE id = ? AND overlay_id = ?").get(sceneId, overlayId)
: null;
if (!scene || !scene.enabled) return { exists: true, enabled: true, revision: overlay.updated_at, canvas, scene: null };
const eventOnlyModules = eventOnlyModuleIds(scene.id);
const modules = db.prepare("SELECT id, type, name, config_json FROM overlay_modules WHERE scene_id = ? AND enabled = 1 ORDER BY sort_order, created_at")
.all(scene.id)
.map((module) => {
const renderType = getOverlayModuleType(module.type)?.renderType || null;
const config = parseConfig(module.config_json);
if (eventOnlyModules.has(module.id)) config.eventOnly = true;
if (renderType === "web" && config.injectPageCss !== false) config.renderUrl = webSourceRenderUrl(module.id);
return { id: module.id, type: module.type, renderType, name: module.name, config };
})

View File

@ -247,6 +247,7 @@ function loadEnabled({
plugin,
commandRouter,
placeholders,
events: global.lumiFrameworks?.events || null,
logger: pluginLog
});
if (typeof cleanup === "function") {

View File

@ -0,0 +1,241 @@
const WebSocket = require("ws");
const { getSetting, setSetting } = require("./settings");
const { emitLumiEvent } = require("./lumi-events");
const { createLogger } = require("./logger");
const EVENTSUB_URL = "wss://eventsub.wss.twitch.tv/ws?keepalive_timeout_seconds=30";
const eventLog = createLogger("platform:twitch:eventsub", { category: "integration" });
function configuredChannels() {
const channels = String(getSetting("twitch_channels", ""))
.split(/[\s,]+/)
.map((value) => value.replace(/^#/, "").trim().toLowerCase())
.filter(Boolean);
if (!channels.length) {
const authorizedLogin = String(getSetting("twitch_event_login", "")).trim().toLowerCase();
if (authorizedLogin) channels.push(authorizedLogin);
}
return Array.from(new Set(channels));
}
async function twitchRequest(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`Twitch request failed (${response.status})${body ? `: ${body.slice(0, 300)}` : ""}`);
}
return response.json();
}
async function refreshAccessToken() {
const refreshToken = getSetting("twitch_event_refresh_token", "");
const clientId = getSetting("twitch_client_id", "");
const clientSecret = getSetting("twitch_client_secret", "");
if (!refreshToken || !clientId || !clientSecret) return null;
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret
});
const token = await twitchRequest(`https://id.twitch.tv/oauth2/token?${params.toString()}`, { method: "POST" });
setSetting("twitch_event_oauth", token.access_token);
if (token.refresh_token) setSetting("twitch_event_refresh_token", token.refresh_token);
setSetting("twitch_event_expires_at", Date.now() + Number(token.expires_in || 0) * 1000);
return token.access_token;
}
async function validCredentials() {
let accessToken = getSetting("twitch_event_oauth", "");
if (!accessToken) return null;
let validation;
try {
validation = await twitchRequest("https://id.twitch.tv/oauth2/validate", {
headers: { Authorization: `OAuth ${accessToken}` }
});
} catch {
accessToken = await refreshAccessToken();
if (!accessToken) return null;
validation = await twitchRequest("https://id.twitch.tv/oauth2/validate", {
headers: { Authorization: `OAuth ${accessToken}` }
});
}
return { accessToken, validation, clientId: getSetting("twitch_client_id", "") };
}
class TwitchEventSubManager {
constructor() {
this.socket = null;
this.running = false;
this.reconnectTimer = null;
this.reconnectAttempt = 0;
this.generation = 0;
this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 };
}
getStatus() {
return { ...this.status };
}
async start() {
if (this.running) return this.getStatus();
this.running = true;
if (!getSetting("twitch_event_oauth", "")) {
this.status = { state: "needs_authorization", detail: "Connect Twitch events to receive alerts.", subscriptions: 0 };
return this.getStatus();
}
await this.connect(EVENTSUB_URL, false);
return this.getStatus();
}
async restart() {
await this.stop();
return this.start();
}
async stop() {
this.running = false;
this.generation += 1;
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
const socket = this.socket;
this.socket = null;
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Lumi shutdown");
this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 };
}
async connect(url, preservingSubscriptions) {
if (!this.running) return;
const credentials = await validCredentials().catch((error) => {
eventLog.error("Twitch EventSub credentials could not be validated", error, { event: "eventsub_auth_failed" });
return null;
});
if (!credentials?.accessToken || !credentials.clientId) {
this.status = { state: "needs_authorization", detail: "Twitch event authorization is missing or expired.", subscriptions: 0 };
return;
}
const generation = ++this.generation;
this.status = { state: "connecting", detail: "Connecting to Twitch events…", subscriptions: 0 };
const socket = new WebSocket(url);
this.socket = socket;
socket.on("message", (raw) => {
this.handleMessage(raw, credentials, preservingSubscriptions, generation).catch((error) => {
eventLog.error("Twitch EventSub message could not be handled", error, { event: "eventsub_message_failed" });
});
});
socket.on("error", (error) => eventLog.error("Twitch EventSub socket error", error, { event: "eventsub_socket_error" }));
socket.on("close", () => {
if (!this.running || generation !== this.generation) return;
this.status = { state: "reconnecting", detail: "Twitch events disconnected; retrying automatically.", subscriptions: 0 };
this.scheduleReconnect();
});
}
scheduleReconnect() {
clearTimeout(this.reconnectTimer);
const delay = Math.min(60000, 1000 * 2 ** Math.min(this.reconnectAttempt++, 6));
this.reconnectTimer = setTimeout(() => {
this.connect(EVENTSUB_URL, false).catch((error) => {
eventLog.error("Twitch EventSub reconnect failed", error, { event: "eventsub_reconnect_failed" });
if (this.running) this.scheduleReconnect();
});
}, delay);
}
async handleMessage(raw, credentials, preservingSubscriptions, generation) {
let message;
try {
message = JSON.parse(raw.toString());
} catch {
return;
}
const type = message.metadata?.message_type;
if (type === "session_welcome") {
this.reconnectAttempt = 0;
this.status = { state: "connected", detail: "Receiving Twitch events.", subscriptions: preservingSubscriptions ? this.status.subscriptions : 0 };
if (!preservingSubscriptions) {
await this.subscribe(message.payload.session.id, credentials, generation);
}
return;
}
if (type === "session_reconnect") {
const reconnectUrl = message.payload?.session?.reconnect_url;
if (reconnectUrl) {
this.connect(reconnectUrl, true).catch((error) => {
eventLog.error("Twitch requested reconnect failed", error, { event: "eventsub_reconnect_failed" });
if (this.running) this.scheduleReconnect();
});
}
return;
}
if (type === "notification") this.emitNotification(message);
if (type === "revocation") {
this.status = { ...this.status, detail: "A Twitch event permission was revoked. Reconnect Twitch events if alerts stop." };
eventLog.warn("Twitch revoked an EventSub subscription", message.payload?.subscription || {}, { event: "eventsub_revoked" });
}
}
async subscribe(sessionId, credentials, generation) {
const headers = {
"Client-Id": credentials.clientId,
Authorization: `Bearer ${credentials.accessToken}`,
"Content-Type": "application/json"
};
const scopes = new Set(credentials.validation.scopes || []);
const query = configuredChannels();
const users = query.length
? await twitchRequest(`https://api.twitch.tv/helix/users?${query.map((login) => `login=${encodeURIComponent(login)}`).join("&")}`, { headers })
: { data: [] };
const definitions = [];
for (const broadcaster of users.data || []) {
definitions.push({ type: "channel.raid", version: "1", condition: { to_broadcaster_user_id: broadcaster.id } });
if (scopes.has("moderator:read:followers")) {
definitions.push({ type: "channel.follow", version: "2", condition: { broadcaster_user_id: broadcaster.id, moderator_user_id: credentials.validation.user_id } });
}
if (scopes.has("channel:read:subscriptions") && broadcaster.id === credentials.validation.user_id) {
definitions.push({ type: "channel.subscribe", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
definitions.push({ type: "channel.subscription.gift", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
}
}
let active = 0;
for (const definition of definitions) {
try {
await twitchRequest("https://api.twitch.tv/helix/eventsub/subscriptions", {
method: "POST",
headers,
body: JSON.stringify({ ...definition, transport: { method: "websocket", session_id: sessionId } })
});
active += 1;
} catch (error) {
eventLog.error(`Twitch EventSub subscription failed for ${definition.type}`, error, { event: "eventsub_subscribe_failed" });
}
}
if (generation !== this.generation) return;
this.status = {
state: "connected",
detail: active ? `Receiving ${active} Twitch event feed${active === 1 ? "" : "s"}.` : "Connected, but no configured channel events could be subscribed.",
subscriptions: active
};
eventLog.info("Twitch EventSub connected", { channels: users.data?.length || 0, subscriptions: active }, { event: "eventsub_ready" });
}
emitNotification(message) {
const subscription = message.payload?.subscription || {};
const event = message.payload?.event || {};
const common = {
broadcaster_id: event.broadcaster_user_id || event.to_broadcaster_user_id || null,
broadcaster_name: event.broadcaster_user_name || event.to_broadcaster_user_name || null,
user_id: event.user_id || event.from_broadcaster_user_id || null,
user_name: event.user_name || event.from_broadcaster_user_name || null
};
const metadata = { id: message.metadata?.message_id, source: "twitch-eventsub", occurredAt: message.metadata?.message_timestamp };
if (subscription.type === "channel.follow") emitLumiEvent("twitch.follow", { ...common, followed_at: event.followed_at }, metadata);
if (subscription.type === "channel.raid") emitLumiEvent("twitch.raid", { ...common, viewers: Number(event.viewers || 0) }, metadata);
if (subscription.type === "channel.subscribe") emitLumiEvent("twitch.subscribe", { ...common, tier: event.tier, gifted: Boolean(event.is_gift) }, metadata);
if (subscription.type === "channel.subscription.gift") emitLumiEvent("twitch.subscription_gift", { ...common, tier: event.tier, total: Number(event.total || 0), anonymous: Boolean(event.is_anonymous) }, metadata);
}
}
const twitchEventSubManager = new TwitchEventSubManager();
module.exports = { TwitchEventSubManager, twitchEventSubManager, configuredChannels };

View File

@ -324,6 +324,48 @@
display: none !important;
}
.overlay-event-alerts .callout {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--lumi-space-3);
margin-bottom: var(--lumi-space-4);
}
.overlay-event-alerts .callout p {
flex: 1 1 28rem;
margin: 0;
}
.overlay-event-hook-list {
display: grid;
gap: var(--lumi-space-3);
margin-bottom: var(--lumi-space-4);
}
.overlay-event-hook-card {
border: 1px solid var(--lumi-border-strong, var(--border));
border-radius: var(--lumi-radius-md, 10px);
background: color-mix(in srgb, var(--surface-2) 82%, transparent);
}
.overlay-event-hook-card > summary {
padding: var(--lumi-space-3) var(--lumi-space-4);
}
.overlay-event-hook-card > summary span {
display: grid;
gap: var(--lumi-space-1);
}
.overlay-event-hook-card > form {
margin: 0 var(--lumi-space-3) var(--lumi-space-3);
padding: var(--lumi-space-4);
border: 1px solid var(--border);
border-radius: var(--lumi-radius-sm, 8px);
background: var(--card);
}
.overlay-help {
position: relative;
width: 1.3rem;

View File

@ -540,6 +540,36 @@
}
}));
document.querySelectorAll("[data-event-hook-form]").forEach((form) => {
const eventType = form.querySelector("[data-event-type]");
const action = form.querySelector("[data-event-action]");
const source = form.querySelector("[data-event-source]");
const duration = form.querySelector("[data-event-duration]");
const tier = form.querySelector("[data-event-tier]");
const sourceHelp = form.querySelector("[data-event-source-help]");
const submit = form.querySelector('button[type="submit"]');
const sync = () => {
const selectedAction = action?.selectedOptions?.[0];
const accepted = new Set(String(selectedAction?.dataset.renderTypes || "").split(",").filter(Boolean));
let available = 0;
source?.querySelectorAll("option").forEach((option) => {
const compatible = accepted.has(option.dataset.renderType);
option.hidden = !compatible;
option.disabled = !compatible;
if (compatible) available += 1;
});
if (source?.selectedOptions?.[0]?.disabled) source.value = source.querySelector("option:not(:disabled)")?.value || "";
if (duration) duration.hidden = action?.value !== "show_temporary";
const supportsTier = eventType?.selectedOptions?.[0]?.dataset.supportsTier === "true";
if (tier) tier.hidden = !supportsTier;
if (submit) submit.disabled = available === 0;
if (sourceHelp) sourceHelp.textContent = available ? "" : `Add an ${Array.from(accepted).join(" or ")} source before creating this alert.`;
};
eventType?.addEventListener("change", sync);
action?.addEventListener("change", sync);
sync();
});
const connector = document.querySelector("[data-obs-connector]");
if (!connector) return;
const overlayId = connector.dataset.overlayId;

View File

@ -439,6 +439,10 @@
box.dataset.renderType = renderType;
box.dataset.moduleName = module.name || "Source";
applyBox(box, values);
if (values.eventOnly && !options.editor) {
box.hidden = true;
box.dataset.eventOnly = "true";
}
if (renderType === "text") {
box.classList.add("lumi-overlay-text", `overflow-${values.overflow || "wrap"}`);

View File

@ -10,6 +10,7 @@
let currentState = null;
let resizeTimer = null;
const sourceTimers = new Map();
const actionQueues = new Map();
const obsBridge = window.obsstudio || null;
const bridgeInstanceId = globalThis.crypto?.randomUUID?.() || `${Date.now()}:${Math.random().toString(16).slice(2)}`;
const handledBridgeCommands = new Set();
@ -36,6 +37,10 @@
return renderer.findSourceContent(root, moduleId, "video, audio");
}
function sourceFor(moduleId) {
return root.querySelector(`.lumi-overlay-module[data-module-id="${CSS.escape(moduleId)}"]`);
}
function reloadWebsiteSource(moduleId) {
const frame = frameFor(moduleId);
if (!frame) return false;
@ -49,6 +54,68 @@
return reloadWebsiteSource(moduleId) || renderer.restartMediaElement(mediaFor(moduleId));
}
function wait(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function playMediaOnce(moduleId) {
const media = mediaFor(moduleId);
if (!media) return false;
media.loop = false;
const startAt = Math.max(0, Number(media.dataset.startAt || 0));
try { media.currentTime = startAt; } catch {}
await new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
media.removeEventListener("ended", finish);
media.removeEventListener("error", finish);
resolve();
};
const duration = Number.isFinite(media.duration) && media.duration > startAt
? ((media.duration - startAt) / Math.max(0.25, Number(media.playbackRate || 1))) * 1000 + 2000
: 5 * 60 * 1000;
const timeout = setTimeout(finish, Math.min(10 * 60 * 1000, Math.max(2000, duration)));
media.addEventListener("ended", finish, { once: true });
media.addEventListener("error", finish, { once: true });
try {
const playback = media.play();
playback?.catch?.(finish);
} catch {
finish();
}
});
return true;
}
async function showSourceTemporarily(moduleId, durationSeconds) {
const source = sourceFor(moduleId);
if (!source) return false;
source.hidden = false;
source.classList.add("is-event-active");
await wait(Math.min(3600, Math.max(0.5, Number(durationSeconds || 5))) * 1000);
source.classList.remove("is-event-active");
if (source.dataset.eventOnly === "true") source.hidden = true;
return true;
}
function enqueueOverlayAction(payload) {
const moduleId = String(payload?.module_id || "");
if (!moduleId || !sourceFor(moduleId)) return;
const previous = actionQueues.get(moduleId) || Promise.resolve();
const next = previous.catch(() => {}).then(() => {
if (payload.action === "play_once") return playMediaOnce(moduleId);
if (payload.action === "show_temporary") return showSourceTemporarily(moduleId, payload.duration_seconds);
return false;
});
actionQueues.set(moduleId, next);
next.finally(() => {
if (actionQueues.get(moduleId) === next) actionQueues.delete(moduleId);
});
}
async function checkWebsiteSource(module, attempt = 0) {
try {
const url = new URL(config.dataset.healthUrl, window.location.href);
@ -228,6 +295,10 @@
try { renderer.handleChatMessage(root, JSON.parse(event.data || "{}")); }
catch {}
});
events.addEventListener("overlay:action", (event) => {
try { enqueueOverlayAction(JSON.parse(event.data || "{}")); }
catch {}
});
events.addEventListener("overlay:obs-browser-command", (event) => {
if (!obsBridge) return;
try {

View File

@ -40,6 +40,7 @@ const {
fetchDiscordUser,
fetchDiscordGuildMember,
buildTwitchAuthUrl,
buildTwitchEventAuthUrl,
exchangeTwitchCode,
fetchTwitchUser,
buildYouTubeAuthUrl,
@ -76,6 +77,8 @@ const {
serializePlatformSelection
} = require("../services/platforms");
const { getClient: getTwitchClient } = require("../services/twitch");
const { twitchEventSubManager } = require("../services/twitch-eventsub");
const { eventHooksApi } = require("../services/overlay-event-hooks");
const { getClient: getYouTubeClient } = require("../services/youtube");
const {
conditionalRepliesFromBody,
@ -3048,6 +3051,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
}
global.lumiFrameworks.webhooks = webhooks;
global.lumiFrameworks.placeholders = placeholders;
global.lumiFrameworks.events = eventHooksApi();
global.lumiFrameworks.overlays = Object.freeze({
registerAccessProvider: registerOverlayAccessProvider,
registerConnectorProvider: registerOverlayConnectorProvider,
@ -4286,6 +4290,22 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
res.redirect(url);
});
app.get("/admin/twitch-events/connect", requireRole("admin"), (req, res) => {
if (!isPlatformEnabled("twitch") || !isPlatformConfigured("twitch")) {
setFlash(req, "error", "Configure and enable Twitch before connecting event alerts.");
return res.redirect("/admin/overlays");
}
const state = crypto.randomBytes(24).toString("hex");
const requestedReturn = String(req.query.return || "");
req.session.twitchEventState = state;
req.session.twitchEventReturn = /^\/admin\/[a-zA-Z0-9/?=&._-]*$/.test(requestedReturn)
? requestedReturn
: "/admin/overlays";
const baseUrl = `${req.protocol}://${req.get("host")}`;
const redirectUri = getSetting("twitch_redirect_uri") || `${baseUrl}/auth/twitch/callback`;
res.redirect(buildTwitchEventAuthUrl(state, redirectUri));
});
app.get("/auth/twitch/callback", async (req, res) => {
if (!isPlatformEnabled("twitch")) {
return res.status(400).render("error", {
@ -4296,7 +4316,8 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
const { code, state } = req.query;
const isLogin = state && state === req.session.twitchLoginState;
const isLink = state && state === req.session.twitchState && req.session.user;
if (!code || (!isLogin && !isLink)) {
const isEvent = state && state === req.session.twitchEventState && req.session.user?.isAdmin;
if (!code || (!isLogin && !isLink && !isEvent)) {
return res.status(400).render("error", {
title: "Auth failed",
message: "Invalid Twitch OAuth state."
@ -4313,7 +4334,19 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
throw new Error("Twitch user not found.");
}
const now = Date.now();
if (isLogin) {
if (isEvent) {
setSetting("twitch_event_oauth", token.access_token);
setSetting("twitch_event_refresh_token", token.refresh_token || "");
setSetting("twitch_event_expires_at", token.expires_in ? now + token.expires_in * 1000 : null);
setSetting("twitch_event_user_id", twitchUser.id);
setSetting("twitch_event_login", twitchUser.login || twitchUser.display_name);
const returnTo = req.session.twitchEventReturn || "/admin/overlays";
delete req.session.twitchEventState;
delete req.session.twitchEventReturn;
await twitchEventSubManager.restart();
setFlash(req, "success", `Twitch event alerts connected as ${twitchUser.display_name}.`);
res.redirect(returnTo);
} else if (isLogin) {
const profile = ensureUserForIdentity({
provider: "twitch",
providerUserId: twitchUser.id,
@ -4382,9 +4415,11 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
} catch (error) {
console.error(error);
res.status(500).render("error", {
title: isLogin ? "Login failed" : "Link failed",
title: isLogin ? "Login failed" : isEvent ? "Event connection failed" : "Link failed",
message: isLogin
? "Unable to sign in with Twitch."
: isEvent
? "Unable to authorize Twitch event alerts. Check the Twitch integration settings and try again."
: "Unable to link Twitch account."
});
}

View File

@ -12,6 +12,11 @@
["center-left", "Middle left"], ["center", "Center"], ["center-right", "Middle right"],
["bottom-left", "Bottom left"], ["bottom-center", "Bottom middle"], ["bottom-right", "Bottom right"]
];
const overlayModules = overlay.scenes.flatMap((scene) => scene.modules.map((module) => ({
...module,
sceneName: scene.name,
renderType: moduleTypes.find((type) => type.id === module.type)?.renderType || module.type
})));
%>
<%- include("partials/layout-top", { title }) %>
<link rel="stylesheet" href="/overlay-runtime.css?v=<%= assetVersion %>" />
@ -231,6 +236,42 @@
<% }) %>
</section>
<section class="card overlay-event-alerts">
<div class="section-header"><div><span class="eyebrow">Automation</span><h2>Event alerts</h2><p class="hint">Play a sound or briefly show a source when something happens on a connected service.</p></div><span class="badge"><%= eventHooks.filter((hook) => hook.enabled).length %> active</span></div>
<div class="callout"><strong>Before going live</strong><p>Use an <strong>Audio</strong> source set to <strong>Wait for manual playback</strong>, then connect it to an event below. In OBS, enable <strong>Control audio via OBS</strong> for this Browser Source.</p><span class="badge"><%= twitchEventStatus.detail %></span><% if (canViewSecrets) { %><a class="button subtle" href="/admin/twitch-events/connect?return=<%= encodeURIComponent(`/admin/overlays/${overlay.id}`) %>"><%= twitchEventStatus.state === "connected" ? "Reconnect Twitch events" : "Connect Twitch events" %></a><% } %></div>
<% if (!eventHooks.length) { %><div class="empty-state">No alerts yet. Create one below, then use its test button while the overlay is open in OBS.</div><% } %>
<div class="overlay-event-hook-list">
<% eventHooks.forEach((hook) => { %>
<details class="overlay-event-hook-card">
<summary><span><strong><%= hook.event?.label || hook.event_type %></strong><small><%= hook.action?.label || hook.action_type %> · <%= hook.module_name %> · <%= hook.enabled ? "On" : "Off" %></small></span></summary>
<form method="post" action="/admin/overlays/<%= overlay.id %>/event-hooks/<%= hook.id %>" class="form-grid" data-event-hook-form>
<div class="field"><label>When this happens</label><select name="event_type" data-event-type><% eventTypes.forEach((eventType) => { %><option value="<%= eventType.id %>" data-supports-tier="<%= eventType.supportsTier ? 'true' : 'false' %>" <%= eventType.id === hook.event_type ? "selected" : "" %>><%= eventType.label %></option><% }) %></select></div>
<div class="field"><label>Do this</label><select name="action_type" data-event-action><% eventActions.forEach((action) => { %><option value="<%= action.id %>" data-render-types="<%= action.renderTypes.join(',') %>" <%= action.id === hook.action_type ? "selected" : "" %>><%= action.label %></option><% }) %></select></div>
<div class="field"><label>Source</label><select name="module_id" data-event-source><% overlayModules.forEach((module) => { %><option value="<%= module.id %>" data-render-type="<%= module.renderType %>" <%= module.id === hook.module_id ? "selected" : "" %>><%= module.sceneName %> — <%= module.name %></option><% }) %></select></div>
<div class="field" data-event-duration><label>Show for (seconds)</label><input type="number" name="duration_seconds" min="0.5" max="3600" step="0.5" value="<%= hook.duration_seconds || 5 %>" /></div>
<div class="field" data-event-tier><label>Subscription tier</label><select name="tier"><option value="">Any tier</option><option value="1000" <%= hook.filters.tier === "1000" ? "selected" : "" %>>Tier 1</option><option value="2000" <%= hook.filters.tier === "2000" ? "selected" : "" %>>Tier 2</option><option value="3000" <%= hook.filters.tier === "3000" ? "selected" : "" %>>Tier 3</option></select></div>
<div class="field"><label>Enabled</label><label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= hook.enabled ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text"><%= hook.enabled ? "On" : "Off" %></span></label></div>
<div class="form-actions"><button class="button" type="submit">Save alert</button><button class="button subtle" type="submit" formaction="/admin/overlays/<%= overlay.id %>/event-hooks/<%= hook.id %>/test">Test alert</button><button class="button danger" type="button" data-confirm-action="/admin/overlays/<%= overlay.id %>/event-hooks/<%= hook.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete event alert" data-confirm-text="Delete this event alert? The source itself will remain." data-confirm-label="Delete alert">Delete alert</button></div>
</form>
</details>
<% }) %>
</div>
<details class="overlay-add-source">
<summary>Add an event alert</summary>
<form method="post" action="/admin/overlays/<%= overlay.id %>/event-hooks" class="form-grid" data-event-hook-form>
<div class="field"><label>When this happens</label><select name="event_type" data-event-type><% eventTypes.forEach((eventType) => { %><option value="<%= eventType.id %>" data-supports-tier="<%= eventType.supportsTier ? 'true' : 'false' %>"><%= eventType.label %></option><% }) %></select></div>
<div class="field"><label>Do this</label><select name="action_type" data-event-action><% eventActions.forEach((action) => { %><option value="<%= action.id %>" data-render-types="<%= action.renderTypes.join(',') %>"><%= action.label %></option><% }) %></select></div>
<div class="field"><label>Source</label><select name="module_id" data-event-source required><% overlayModules.forEach((module) => { %><option value="<%= module.id %>" data-render-type="<%= module.renderType %>"><%= module.sceneName %> — <%= module.name %></option><% }) %></select><small class="hint" data-event-source-help></small></div>
<div class="field" data-event-duration><label>Show for (seconds)</label><input type="number" name="duration_seconds" min="0.5" max="3600" step="0.5" value="5" /></div>
<div class="field" data-event-tier><label>Subscription tier</label><select name="tier"><option value="">Any tier</option><option value="1000">Tier 1</option><option value="2000">Tier 2</option><option value="3000">Tier 3</option></select></div>
<input type="hidden" name="enabled" value="on" />
<div class="form-actions"><button class="button" type="submit" <%= overlayModules.length ? "" : "disabled" %>>Create alert</button></div>
</form>
</details>
</section>
<% if (canManageObs) { %>
<section class="card" data-obs-connector data-overlay-id="<%= overlay.id %>">
<details><summary><strong>Connect Lumi to OBS</strong> <span class="hint">Optional</span></summary><p class="hint">This can switch scenes in OBS and keep OBS scenes matched with Lumi scenes. Most users can leave it off.</p>

View File

@ -1,6 +1,6 @@
{
"name": "Lumi Core",
"version": "0.2.21",
"version": "0.2.22",
"channel": "stable",
"released_at": "2026-07-19",
"compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [
"1.2.0"
],
"migration_notes": "Adds cached BetterTTV global, channel, and shared emote rendering to Twitch messages in Lumi chat overlays and standalone OBS chat docks. Existing commands, policies, groups, usage totals, overlays, chat settings, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.",
"migration_notes": "Adds event-driven overlay sound and temporary visual alerts for Twitch follows, raids, subscriptions, gifted subscriptions, and Discord member joins. Existing commands, policies, groups, usage totals, overlays, chat settings, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved; new event-hook storage and Twitch event credentials are additive.",
"rollback_safe": true,
"requirements": [
"Node.js 18 or newer"
@ -277,6 +277,18 @@
],
"rollback_safe": true,
"migration_notes": "Adds cached BetterTTV global, channel, and shared emote rendering to Twitch messages in Lumi chat overlays and standalone OBS chat docks; existing commands, policies, groups, overlays, chat settings, tokens, scenes, sources, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
},
{
"version": "0.2.22",
"channel": "stable",
"released_at": "2026-07-19",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Adds event-driven overlay sound and temporary visual alerts for Twitch follows, raids, subscriptions, gifted subscriptions, and Discord member joins; existing overlays, sources, tokens, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
}
]
}