From 155f080fc5cfe63119ec86d8b433070ebb46040b Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sun, 19 Jul 2026 22:46:39 +0200 Subject: [PATCH] Add event-driven overlay alerts --- CHANGELOG.md | 7 + TODO.md | 22 +++ docs/obs-overlays.md | 25 +++ knowledge/core/lumi-core.md | 2 +- package-lock.json | 7 +- package.json | 5 +- release-index.json | 30 +++ scripts/verify-overlays.js | 52 +++++- scripts/verify-release-metadata.js | 6 +- scripts/verify-update-system.js | 5 +- src/main.js | 7 + src/services/auth.js | 15 ++ src/services/db.js | 18 ++ src/services/discord.js | 20 +- src/services/lumi-events.js | 63 +++++++ src/services/overlay-event-hooks.js | 230 +++++++++++++++++++++++ src/services/overlay-routes.js | 58 ++++++ src/services/overlays.js | 16 +- src/services/plugins.js | 1 + src/services/twitch-eventsub.js | 241 +++++++++++++++++++++++++ src/web/public/overlay-management.css | 42 +++++ src/web/public/overlay-management.js | 30 +++ src/web/public/overlay-renderer.js | 4 + src/web/public/overlay-runtime.js | 71 ++++++++ src/web/server.js | 41 ++++- src/web/views/admin-overlay-detail.ejs | 41 +++++ update-manifest.json | 16 +- 27 files changed, 1052 insertions(+), 23 deletions(-) create mode 100644 src/services/lumi-events.js create mode 100644 src/services/overlay-event-hooks.js create mode 100644 src/services/twitch-eventsub.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 51505d0..2d0243e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TODO.md b/TODO.md index 4fcc4bd..fc46f02 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/docs/obs-overlays.md b/docs/obs-overlays.md index 54bde3c..00cf6b7 100644 --- a/docs/obs-overlays.md +++ b/docs/obs-overlays.md @@ -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 diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index d6415e6..cf736b0 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 02734a9..156b33e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 814ea79..0457fc2 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/release-index.json b/release-index.json index 0e74e5a..88b5c6f 100644 --- a/release-index.json +++ b/release-index.json @@ -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", diff --git a/scripts/verify-overlays.js b/scripts/verify-overlays.js index 7fad44b..b76398d 100644 --- a/scripts/verify-overlays.js +++ b/scripts/verify-overlays.js @@ -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")); diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index e156f86..cbb5067 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -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."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 918a570..e5ecff3 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -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); diff --git a/src/main.js b/src/main.js index 027644d..45746d2 100644 --- a/src/main.js +++ b/src/main.js @@ -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(); diff --git a/src/services/auth.js b/src/services/auth.js index def8fad..bb14183 100644 --- a/src/services/auth.js +++ b/src/services/auth.js @@ -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, diff --git a/src/services/db.js b/src/services/db.js index c383723..885077a 100644 --- a/src/services/db.js +++ b/src/services/db.js @@ -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, diff --git a/src/services/discord.js b/src/services/discord.js index efc872c..7f68869 100644 --- a/src/services/discord.js +++ b/src/services/discord.js @@ -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; diff --git a/src/services/lumi-events.js b/src/services/lumi-events.js new file mode 100644 index 0000000..6bab7e9 --- /dev/null +++ b/src/services/lumi-events.js @@ -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 }; diff --git a/src/services/overlay-event-hooks.js b/src/services/overlay-event-hooks.js new file mode 100644 index 0000000..5958b40 --- /dev/null +++ b/src/services/overlay-event-hooks.js @@ -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 +}; diff --git a/src/services/overlay-routes.js b/src/services/overlay-routes.js index 93087f9..b8fddfa 100644 --- a/src/services/overlay-routes.js +++ b/src/services/overlay-routes.js @@ -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 { diff --git a/src/services/overlays.js b/src/services/overlays.js index 3c01910..3f6298a 100644 --- a/src/services/overlays.js +++ b/src/services/overlays.js @@ -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 }; }) diff --git a/src/services/plugins.js b/src/services/plugins.js index acad078..b258b01 100644 --- a/src/services/plugins.js +++ b/src/services/plugins.js @@ -247,6 +247,7 @@ function loadEnabled({ plugin, commandRouter, placeholders, + events: global.lumiFrameworks?.events || null, logger: pluginLog }); if (typeof cleanup === "function") { diff --git a/src/services/twitch-eventsub.js b/src/services/twitch-eventsub.js new file mode 100644 index 0000000..1a310fa --- /dev/null +++ b/src/services/twitch-eventsub.js @@ -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 }; diff --git a/src/web/public/overlay-management.css b/src/web/public/overlay-management.css index 6024582..58f5ea8 100644 --- a/src/web/public/overlay-management.css +++ b/src/web/public/overlay-management.css @@ -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; diff --git a/src/web/public/overlay-management.js b/src/web/public/overlay-management.js index 3dacc52..7aa02ad 100644 --- a/src/web/public/overlay-management.js +++ b/src/web/public/overlay-management.js @@ -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; diff --git a/src/web/public/overlay-renderer.js b/src/web/public/overlay-renderer.js index 163a9d1..d0c4057 100644 --- a/src/web/public/overlay-renderer.js +++ b/src/web/public/overlay-renderer.js @@ -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"}`); diff --git a/src/web/public/overlay-runtime.js b/src/web/public/overlay-runtime.js index f457d27..704d69c 100644 --- a/src/web/public/overlay-runtime.js +++ b/src/web/public/overlay-runtime.js @@ -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 { diff --git a/src/web/server.js b/src/web/server.js index 8275f2e..21801b9 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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." }); } diff --git a/src/web/views/admin-overlay-detail.ejs b/src/web/views/admin-overlay-detail.ejs index bda7a72..1d4730f 100644 --- a/src/web/views/admin-overlay-detail.ejs +++ b/src/web/views/admin-overlay-detail.ejs @@ -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 }) %> @@ -231,6 +236,42 @@ <% }) %> +
+
Automation

Event alerts

Play a sound or briefly show a source when something happens on a connected service.

<%= eventHooks.filter((hook) => hook.enabled).length %> active
+
Before going live

Use an Audio source set to Wait for manual playback, then connect it to an event below. In OBS, enable Control audio via OBS for this Browser Source.

<%= twitchEventStatus.detail %><% if (canViewSecrets) { %><%= twitchEventStatus.state === "connected" ? "Reconnect Twitch events" : "Connect Twitch events" %><% } %>
+ + <% if (!eventHooks.length) { %>
No alerts yet. Create one below, then use its test button while the overlay is open in OBS.
<% } %> +
+ <% eventHooks.forEach((hook) => { %> +
+ <%= hook.event?.label || hook.event_type %><%= hook.action?.label || hook.action_type %> · <%= hook.module_name %> · <%= hook.enabled ? "On" : "Off" %> +
+
+
+
+
+
+
+
+
+
+ <% }) %> +
+ +
+ Add an event alert +
+
+
+
+
+
+ +
+
+
+
+ <% if (canManageObs) { %>
Connect Lumi to OBS Optional

This can switch scenes in OBS and keep OBS scenes matched with Lumi scenes. Most users can leave it off.

diff --git a/update-manifest.json b/update-manifest.json index 80415b4..b7260a7 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -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." } ] }