Add event-driven overlay alerts
This commit is contained in:
parent
a5bd15cf3e
commit
155f080fc5
@ -1,5 +1,12 @@
|
|||||||
# Lumi changelog
|
# 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
|
## 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.
|
- 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
22
TODO.md
@ -35,6 +35,28 @@ Remaining work:
|
|||||||
- Replace heuristic Discord subscriber/VIP role-name matching with explicit
|
- Replace heuristic Discord subscriber/VIP role-name matching with explicit
|
||||||
per-server role mapping if communities need stricter Discord entitlements.
|
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
|
## Current Local State / Source of Truth
|
||||||
|
|
||||||
### P0 OBS Overlay System
|
### P0 OBS Overlay System
|
||||||
|
|||||||
@ -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
|
independent OBS mixer channel, enable **Control audio via OBS** in the Lumi Browser
|
||||||
Source properties, then configure monitoring/output in OBS as desired.
|
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
|
### External website and alert overlays
|
||||||
|
|
||||||
Website sources support third-party browser overlays such as alert and chat
|
Website sources support third-party browser overlays such as alert and chat
|
||||||
|
|||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Lumi is the core web UI and bot runtime.
|
Lumi is the core web UI and bot runtime.
|
||||||
## Runtime
|
## Runtime
|
||||||
Package: lumi-bot
|
Package: lumi-bot
|
||||||
Version: 0.2.21
|
Version: 0.2.22
|
||||||
## Routes
|
## Routes
|
||||||
- GET /api/events
|
- GET /api/events
|
||||||
- POST /api/destructive-confirmations
|
- POST /api/destructive-confirmations
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.21",
|
"version": "0.2.22",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.21",
|
"version": "0.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.12",
|
"adm-zip": "^0.5.12",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
@ -16,7 +16,8 @@
|
|||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"tmi.js": "^1.8.5"
|
"tmi.js": "^1.8.5",
|
||||||
|
"ws": "^8.21.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.21",
|
"version": "0.2.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -31,7 +31,8 @@
|
|||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"tmi.js": "^1.8.5"
|
"tmi.js": "^1.8.5",
|
||||||
|
"ws": "^8.21.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"obs-websocket-js": "^5.0.8"
|
"obs-websocket-js": "^5.0.8"
|
||||||
|
|||||||
@ -2,6 +2,36 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"releases": [
|
"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",
|
"version": "0.2.21",
|
||||||
"ref": "refs/tags/v0.2.21",
|
"ref": "refs/tags/v0.2.21",
|
||||||
|
|||||||
@ -17,6 +17,8 @@ for (const file of [
|
|||||||
"overlay-secrets.js",
|
"overlay-secrets.js",
|
||||||
"obs-browser-defaults.js",
|
"obs-browser-defaults.js",
|
||||||
"overlay-modules.js",
|
"overlay-modules.js",
|
||||||
|
"lumi-events.js",
|
||||||
|
"overlay-event-hooks.js",
|
||||||
"overlays.js",
|
"overlays.js",
|
||||||
"overlay-permissions.js",
|
"overlay-permissions.js",
|
||||||
"overlay-connectors.js",
|
"overlay-connectors.js",
|
||||||
@ -86,11 +88,40 @@ try {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
config: { text: "Starting soon", x: 5, y: 10, width: 90, height: 20 }
|
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);
|
const state = overlays.buildPublicState(created.id);
|
||||||
assert.strictEqual(state.scene.modules.length, 1);
|
assert.strictEqual(state.scene.modules.length, 2);
|
||||||
assert.strictEqual(state.scene.modules[0].config.text, "Starting soon");
|
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" });
|
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.anchor, "center");
|
||||||
assert.strictEqual(transformed.config.text, "Starting soon", "canvas transforms must preserve source content");
|
assert.strictEqual(transformed.config.text, "Starting soon", "canvas transforms must preserve source content");
|
||||||
|
|
||||||
@ -288,7 +319,8 @@ try {
|
|||||||
const duplicate = overlays.duplicateOverlay(created.id);
|
const duplicate = overlays.duplicateOverlay(created.id);
|
||||||
assert.notStrictEqual(duplicate.public_token, newOverlayToken);
|
assert.notStrictEqual(duplicate.public_token, newOverlayToken);
|
||||||
assert.strictEqual(duplicate.scenes.length, 1);
|
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, {
|
overlays.saveObsSettings(created.id, {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@ -322,6 +354,9 @@ try {
|
|||||||
const connectorScript = fs.readFileSync(path.join(root, "src", "services", "overlay-connectors.js"), "utf8");
|
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 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 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");
|
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-overlay-canvas"), "management page must include a visual canvas");
|
||||||
assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"'));
|
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("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(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: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("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
|
||||||
assert(runtimeScript.includes("window.obsstudio") && runtimeScript.includes("overlay:obs-browser-command"));
|
assert(runtimeScript.includes("window.obsstudio") && runtimeScript.includes("overlay:obs-browser-command"));
|
||||||
assert(runtimeScript.includes("saveReplayBuffer"), "Browser Bridge must support OBS replay saves");
|
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("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(twitchBadgeScript.includes("channelEmotes") && twitchBadgeScript.includes("sharedEmotes") && twitchBadgeScript.includes("cdn.betterttv.net/emote/"));
|
||||||
assert(twitchServiceScript.includes("resolveBetterTtvEmotes") && twitchServiceScript.includes("...betterTtvEmotes"));
|
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("/obs-bridge") && routeScript.includes("/obs/import"));
|
||||||
assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument"));
|
assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument"));
|
||||||
assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket"));
|
assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket"));
|
||||||
assert(routeScript.includes('/overlay-chat/:ticket') && routeScript.includes("resolveChatDockTicket"));
|
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(routeScript.includes("validate: () => Boolean(chatDockContext(req))"));
|
||||||
assert(chatDockView.includes("lumi-chat-dock-root") && chatDockView.includes("overlay-chat-dock.js"));
|
assert(chatDockView.includes("lumi-chat-dock-root") && chatDockView.includes("overlay-chat-dock.js"));
|
||||||
assert(chatDockScript.includes("renderChatDock") && chatDockScript.includes("overlay:chat-message"));
|
assert(chatDockScript.includes("renderChatDock") && chatDockScript.includes("overlay:chat-message"));
|
||||||
|
|||||||
@ -4,8 +4,8 @@ const path = require("path");
|
|||||||
const { findSafeTarget } = require("../src/services/versioning");
|
const { findSafeTarget } = require("../src/services/versioning");
|
||||||
|
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const releaseVersion = "0.2.21";
|
const releaseVersion = "0.2.22";
|
||||||
const previousCoreVersion = "0.2.20";
|
const previousCoreVersion = "0.2.21";
|
||||||
const earliestCompatibleCoreVersion = "0.1.9";
|
const earliestCompatibleCoreVersion = "0.1.9";
|
||||||
const changedPlugins = {
|
const changedPlugins = {
|
||||||
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
|
"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(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
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.");
|
||||||
|
|||||||
@ -16,7 +16,7 @@ function readJson(relativePath) {
|
|||||||
|
|
||||||
const releaseIndex = readJson("release-index.json");
|
const releaseIndex = readJson("release-index.json");
|
||||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
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");
|
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||||
for (const release of releaseIndex.releases) {
|
for (const release of releaseIndex.releases) {
|
||||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||||
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
|
|||||||
const baseTarget = {
|
const baseTarget = {
|
||||||
current_version: "0.2.4",
|
current_version: "0.2.4",
|
||||||
available_versions: [
|
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.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.20", ref: "refs/tags/v0.2.20", rollback_safe: true },
|
||||||
{ version: "0.2.19", ref: "refs/tags/v0.2.19", 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"
|
channel: "stable"
|
||||||
});
|
});
|
||||||
assert.equal(corrected.version_correction, true);
|
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.update_available, true);
|
||||||
assert.equal(corrected.blocked, false);
|
assert.equal(corrected.blocked, false);
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const { isPlatformEnabled } = require("./services/platforms");
|
|||||||
const { registerCorePlaceholders } = require("./services/placeholders");
|
const { registerCorePlaceholders } = require("./services/placeholders");
|
||||||
const { overlayConnectorManager } = require("./services/overlay-connectors");
|
const { overlayConnectorManager } = require("./services/overlay-connectors");
|
||||||
const { cleanupSnapshots } = require("./services/update-manager");
|
const { cleanupSnapshots } = require("./services/update-manager");
|
||||||
|
const { twitchEventSubManager } = require("./services/twitch-eventsub");
|
||||||
const {
|
const {
|
||||||
isSafeModeRequested,
|
isSafeModeRequested,
|
||||||
markStartupVerification
|
markStartupVerification
|
||||||
@ -71,6 +72,11 @@ async function main() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Twitch bot failed to start", 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")) {
|
if (!safeModeRequested && isPlatformEnabled("youtube")) {
|
||||||
@ -143,6 +149,7 @@ async function main() {
|
|||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
runtimeLog.info("Lumi shutdown started", null, { event: "shutdown" });
|
runtimeLog.info("Lumi shutdown started", null, { event: "shutdown" });
|
||||||
await overlayConnectorManager.stop();
|
await overlayConnectorManager.stop();
|
||||||
|
await twitchEventSubManager.stop();
|
||||||
await stopPlugins();
|
await stopPlugins();
|
||||||
await stopBot();
|
await stopBot();
|
||||||
await stopTwitchBot();
|
await stopTwitchBot();
|
||||||
|
|||||||
@ -85,6 +85,20 @@ function buildTwitchAuthUrl(state, redirectOverride) {
|
|||||||
return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`;
|
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) {
|
async function exchangeTwitchCode(code, redirectOverride) {
|
||||||
const clientId = getSetting("twitch_client_id");
|
const clientId = getSetting("twitch_client_id");
|
||||||
const clientSecret = getSetting("twitch_client_secret");
|
const clientSecret = getSetting("twitch_client_secret");
|
||||||
@ -180,6 +194,7 @@ module.exports = {
|
|||||||
fetchDiscordUser,
|
fetchDiscordUser,
|
||||||
fetchDiscordGuildMember,
|
fetchDiscordGuildMember,
|
||||||
buildTwitchAuthUrl,
|
buildTwitchAuthUrl,
|
||||||
|
buildTwitchEventAuthUrl,
|
||||||
exchangeTwitchCode,
|
exchangeTwitchCode,
|
||||||
fetchTwitchUser,
|
fetchTwitchUser,
|
||||||
buildYouTubeAuthUrl,
|
buildYouTubeAuthUrl,
|
||||||
|
|||||||
@ -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 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 (
|
CREATE TABLE IF NOT EXISTS overlay_obs_settings (
|
||||||
overlay_id TEXT PRIMARY KEY,
|
overlay_id TEXT PRIMARY KEY,
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const { ensureUserForIdentity } = require("./users");
|
|||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
const { publishOverlayChatMessage } = require("./overlay-chat");
|
const { publishOverlayChatMessage } = require("./overlay-chat");
|
||||||
const { discordOverlayContent } = require("./discord-chat-content");
|
const { discordOverlayContent } = require("./discord-chat-content");
|
||||||
|
const { emitLumiEvent } = require("./lumi-events");
|
||||||
|
|
||||||
const discordLog = createLogger("platform:discord", { category: "integration" });
|
const discordLog = createLogger("platform:discord", { category: "integration" });
|
||||||
|
|
||||||
@ -120,6 +121,23 @@ 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);
|
await client.login(token);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/services/lumi-events.js
Normal file
63
src/services/lumi-events.js
Normal 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 };
|
||||||
230
src/services/overlay-event-hooks.js
Normal file
230
src/services/overlay-event-hooks.js
Normal 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
|
||||||
|
};
|
||||||
@ -41,6 +41,16 @@ const {
|
|||||||
const { OBS_BROWSER_DEFAULT_CSS } = require("./obs-browser-defaults");
|
const { OBS_BROWSER_DEFAULT_CSS } = require("./obs-browser-defaults");
|
||||||
const { fetchPublicOverlay, loadInjectedOverlayDocument } = require("./overlay-web-documents");
|
const { fetchPublicOverlay, loadInjectedOverlayDocument } = require("./overlay-web-documents");
|
||||||
const { recentOverlayChatMessages } = require("./overlay-chat");
|
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 {
|
const {
|
||||||
listOverlayConnectorProviders,
|
listOverlayConnectorProviders,
|
||||||
overlayConnectorManager
|
overlayConnectorManager
|
||||||
@ -496,6 +506,10 @@ function registerOverlayAdminRoutes(app) {
|
|||||||
baseUrl: baseUrl(req),
|
baseUrl: baseUrl(req),
|
||||||
obsBrowserDefaultCss: OBS_BROWSER_DEFAULT_CSS,
|
obsBrowserDefaultCss: OBS_BROWSER_DEFAULT_CSS,
|
||||||
moduleTypes: listOverlayModuleTypes(),
|
moduleTypes: listOverlayModuleTypes(),
|
||||||
|
eventTypes: listEventTypes(),
|
||||||
|
eventActions: OVERLAY_EVENT_ACTIONS,
|
||||||
|
eventHooks: listOverlayEventHooks(overlay.id),
|
||||||
|
twitchEventStatus: twitchEventSubManager.getStatus(),
|
||||||
editorStateJson: JSON.stringify({
|
editorStateJson: JSON.stringify({
|
||||||
overlayId: overlay.id,
|
overlayId: overlay.id,
|
||||||
canvas: { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 },
|
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/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."));
|
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) => {
|
app.post("/api/admin/overlays/:id/modules/:moduleId/transform", requireOverlayAccess(), requireModuleInOverlay, (req, res) => {
|
||||||
noStore(res);
|
noStore(res);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ const EventEmitter = require("events");
|
|||||||
const { db } = require("./db");
|
const { db } = require("./db");
|
||||||
const { publishWebEvent } = require("./web-events");
|
const { publishWebEvent } = require("./web-events");
|
||||||
const { getOverlayModuleType, normalizeModuleConfig } = require("./overlay-modules");
|
const { getOverlayModuleType, normalizeModuleConfig } = require("./overlay-modules");
|
||||||
|
const { duplicateOverlayEventHooks, eventOnlyModuleIds } = require("./overlay-event-hooks");
|
||||||
const {
|
const {
|
||||||
createStoredToken,
|
createStoredToken,
|
||||||
decryptSecret,
|
decryptSecret,
|
||||||
@ -172,6 +173,7 @@ function duplicateOverlay(id, name) {
|
|||||||
sceneName: source.scenes[0]?.name || "Main"
|
sceneName: source.scenes[0]?.name || "Main"
|
||||||
});
|
});
|
||||||
const copyId = copy.id;
|
const copyId = copy.id;
|
||||||
|
const moduleIds = new Map();
|
||||||
const clone = db.transaction(() => {
|
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_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);
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
"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());
|
).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) {
|
for (const module of scene.modules) {
|
||||||
|
const newModuleId = crypto.randomUUID();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
"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);
|
db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(firstSceneId, Date.now(), copyId);
|
||||||
});
|
});
|
||||||
clone();
|
clone();
|
||||||
@ -222,6 +227,7 @@ function deleteOverlay(id) {
|
|||||||
const exists = db.prepare("SELECT id FROM overlays WHERE id = ?").get(id);
|
const exists = db.prepare("SELECT id FROM overlays WHERE id = ?").get(id);
|
||||||
if (!exists) throw new Error("Overlay not found.");
|
if (!exists) throw new Error("Overlay not found.");
|
||||||
db.transaction(() => {
|
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_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_scenes WHERE overlay_id = ?").run(id);
|
||||||
db.prepare("DELETE FROM overlay_obs_settings 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;
|
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.");
|
if (count <= 1) throw new Error("Every overlay must have at least one scene.");
|
||||||
db.transaction(() => {
|
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_modules WHERE scene_id = ?").run(sceneId);
|
||||||
db.prepare("DELETE FROM overlay_scenes WHERE 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) {
|
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) {
|
function deleteModule(moduleId) {
|
||||||
const overlayId = overlayIdForModule(moduleId);
|
const overlayId = overlayIdForModule(moduleId);
|
||||||
if (!overlayId) throw new Error("Overlay module not found.");
|
if (!overlayId) throw new Error("Overlay module not found.");
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare("DELETE FROM overlay_event_hooks WHERE module_id = ?").run(moduleId);
|
||||||
db.prepare("DELETE FROM overlay_modules WHERE id = ?").run(moduleId);
|
db.prepare("DELETE FROM overlay_modules WHERE id = ?").run(moduleId);
|
||||||
|
})();
|
||||||
touchOverlay(overlayId);
|
touchOverlay(overlayId);
|
||||||
notifyOverlayChanged(overlayId, "module_delete");
|
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)
|
? db.prepare("SELECT id, name, enabled FROM overlay_scenes WHERE id = ? AND overlay_id = ?").get(sceneId, overlayId)
|
||||||
: null;
|
: null;
|
||||||
if (!scene || !scene.enabled) return { exists: true, enabled: true, revision: overlay.updated_at, canvas, scene: 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")
|
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)
|
.all(scene.id)
|
||||||
.map((module) => {
|
.map((module) => {
|
||||||
const renderType = getOverlayModuleType(module.type)?.renderType || null;
|
const renderType = getOverlayModuleType(module.type)?.renderType || null;
|
||||||
const config = parseConfig(module.config_json);
|
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);
|
if (renderType === "web" && config.injectPageCss !== false) config.renderUrl = webSourceRenderUrl(module.id);
|
||||||
return { id: module.id, type: module.type, renderType, name: module.name, config };
|
return { id: module.id, type: module.type, renderType, name: module.name, config };
|
||||||
})
|
})
|
||||||
|
|||||||
@ -247,6 +247,7 @@ function loadEnabled({
|
|||||||
plugin,
|
plugin,
|
||||||
commandRouter,
|
commandRouter,
|
||||||
placeholders,
|
placeholders,
|
||||||
|
events: global.lumiFrameworks?.events || null,
|
||||||
logger: pluginLog
|
logger: pluginLog
|
||||||
});
|
});
|
||||||
if (typeof cleanup === "function") {
|
if (typeof cleanup === "function") {
|
||||||
|
|||||||
241
src/services/twitch-eventsub.js
Normal file
241
src/services/twitch-eventsub.js
Normal 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 };
|
||||||
@ -324,6 +324,48 @@
|
|||||||
display: none !important;
|
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 {
|
.overlay-help {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 1.3rem;
|
width: 1.3rem;
|
||||||
|
|||||||
@ -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]");
|
const connector = document.querySelector("[data-obs-connector]");
|
||||||
if (!connector) return;
|
if (!connector) return;
|
||||||
const overlayId = connector.dataset.overlayId;
|
const overlayId = connector.dataset.overlayId;
|
||||||
|
|||||||
@ -439,6 +439,10 @@
|
|||||||
box.dataset.renderType = renderType;
|
box.dataset.renderType = renderType;
|
||||||
box.dataset.moduleName = module.name || "Source";
|
box.dataset.moduleName = module.name || "Source";
|
||||||
applyBox(box, values);
|
applyBox(box, values);
|
||||||
|
if (values.eventOnly && !options.editor) {
|
||||||
|
box.hidden = true;
|
||||||
|
box.dataset.eventOnly = "true";
|
||||||
|
}
|
||||||
|
|
||||||
if (renderType === "text") {
|
if (renderType === "text") {
|
||||||
box.classList.add("lumi-overlay-text", `overflow-${values.overflow || "wrap"}`);
|
box.classList.add("lumi-overlay-text", `overflow-${values.overflow || "wrap"}`);
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
let currentState = null;
|
let currentState = null;
|
||||||
let resizeTimer = null;
|
let resizeTimer = null;
|
||||||
const sourceTimers = new Map();
|
const sourceTimers = new Map();
|
||||||
|
const actionQueues = new Map();
|
||||||
const obsBridge = window.obsstudio || null;
|
const obsBridge = window.obsstudio || null;
|
||||||
const bridgeInstanceId = globalThis.crypto?.randomUUID?.() || `${Date.now()}:${Math.random().toString(16).slice(2)}`;
|
const bridgeInstanceId = globalThis.crypto?.randomUUID?.() || `${Date.now()}:${Math.random().toString(16).slice(2)}`;
|
||||||
const handledBridgeCommands = new Set();
|
const handledBridgeCommands = new Set();
|
||||||
@ -36,6 +37,10 @@
|
|||||||
return renderer.findSourceContent(root, moduleId, "video, audio");
|
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) {
|
function reloadWebsiteSource(moduleId) {
|
||||||
const frame = frameFor(moduleId);
|
const frame = frameFor(moduleId);
|
||||||
if (!frame) return false;
|
if (!frame) return false;
|
||||||
@ -49,6 +54,68 @@
|
|||||||
return reloadWebsiteSource(moduleId) || renderer.restartMediaElement(mediaFor(moduleId));
|
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) {
|
async function checkWebsiteSource(module, attempt = 0) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(config.dataset.healthUrl, window.location.href);
|
const url = new URL(config.dataset.healthUrl, window.location.href);
|
||||||
@ -228,6 +295,10 @@
|
|||||||
try { renderer.handleChatMessage(root, JSON.parse(event.data || "{}")); }
|
try { renderer.handleChatMessage(root, JSON.parse(event.data || "{}")); }
|
||||||
catch {}
|
catch {}
|
||||||
});
|
});
|
||||||
|
events.addEventListener("overlay:action", (event) => {
|
||||||
|
try { enqueueOverlayAction(JSON.parse(event.data || "{}")); }
|
||||||
|
catch {}
|
||||||
|
});
|
||||||
events.addEventListener("overlay:obs-browser-command", (event) => {
|
events.addEventListener("overlay:obs-browser-command", (event) => {
|
||||||
if (!obsBridge) return;
|
if (!obsBridge) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -40,6 +40,7 @@ const {
|
|||||||
fetchDiscordUser,
|
fetchDiscordUser,
|
||||||
fetchDiscordGuildMember,
|
fetchDiscordGuildMember,
|
||||||
buildTwitchAuthUrl,
|
buildTwitchAuthUrl,
|
||||||
|
buildTwitchEventAuthUrl,
|
||||||
exchangeTwitchCode,
|
exchangeTwitchCode,
|
||||||
fetchTwitchUser,
|
fetchTwitchUser,
|
||||||
buildYouTubeAuthUrl,
|
buildYouTubeAuthUrl,
|
||||||
@ -76,6 +77,8 @@ const {
|
|||||||
serializePlatformSelection
|
serializePlatformSelection
|
||||||
} = require("../services/platforms");
|
} = require("../services/platforms");
|
||||||
const { getClient: getTwitchClient } = require("../services/twitch");
|
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 { getClient: getYouTubeClient } = require("../services/youtube");
|
||||||
const {
|
const {
|
||||||
conditionalRepliesFromBody,
|
conditionalRepliesFromBody,
|
||||||
@ -3048,6 +3051,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
}
|
}
|
||||||
global.lumiFrameworks.webhooks = webhooks;
|
global.lumiFrameworks.webhooks = webhooks;
|
||||||
global.lumiFrameworks.placeholders = placeholders;
|
global.lumiFrameworks.placeholders = placeholders;
|
||||||
|
global.lumiFrameworks.events = eventHooksApi();
|
||||||
global.lumiFrameworks.overlays = Object.freeze({
|
global.lumiFrameworks.overlays = Object.freeze({
|
||||||
registerAccessProvider: registerOverlayAccessProvider,
|
registerAccessProvider: registerOverlayAccessProvider,
|
||||||
registerConnectorProvider: registerOverlayConnectorProvider,
|
registerConnectorProvider: registerOverlayConnectorProvider,
|
||||||
@ -4286,6 +4290,22 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
res.redirect(url);
|
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) => {
|
app.get("/auth/twitch/callback", async (req, res) => {
|
||||||
if (!isPlatformEnabled("twitch")) {
|
if (!isPlatformEnabled("twitch")) {
|
||||||
return res.status(400).render("error", {
|
return res.status(400).render("error", {
|
||||||
@ -4296,7 +4316,8 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
const { code, state } = req.query;
|
const { code, state } = req.query;
|
||||||
const isLogin = state && state === req.session.twitchLoginState;
|
const isLogin = state && state === req.session.twitchLoginState;
|
||||||
const isLink = state && state === req.session.twitchState && req.session.user;
|
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", {
|
return res.status(400).render("error", {
|
||||||
title: "Auth failed",
|
title: "Auth failed",
|
||||||
message: "Invalid Twitch OAuth state."
|
message: "Invalid Twitch OAuth state."
|
||||||
@ -4313,7 +4334,19 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
throw new Error("Twitch user not found.");
|
throw new Error("Twitch user not found.");
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
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({
|
const profile = ensureUserForIdentity({
|
||||||
provider: "twitch",
|
provider: "twitch",
|
||||||
providerUserId: twitchUser.id,
|
providerUserId: twitchUser.id,
|
||||||
@ -4382,9 +4415,11 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).render("error", {
|
res.status(500).render("error", {
|
||||||
title: isLogin ? "Login failed" : "Link failed",
|
title: isLogin ? "Login failed" : isEvent ? "Event connection failed" : "Link failed",
|
||||||
message: isLogin
|
message: isLogin
|
||||||
? "Unable to sign in with Twitch."
|
? "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."
|
: "Unable to link Twitch account."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,11 @@
|
|||||||
["center-left", "Middle left"], ["center", "Center"], ["center-right", "Middle right"],
|
["center-left", "Middle left"], ["center", "Center"], ["center-right", "Middle right"],
|
||||||
["bottom-left", "Bottom left"], ["bottom-center", "Bottom middle"], ["bottom-right", "Bottom 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 }) %>
|
<%- include("partials/layout-top", { title }) %>
|
||||||
<link rel="stylesheet" href="/overlay-runtime.css?v=<%= assetVersion %>" />
|
<link rel="stylesheet" href="/overlay-runtime.css?v=<%= assetVersion %>" />
|
||||||
@ -231,6 +236,42 @@
|
|||||||
<% }) %>
|
<% }) %>
|
||||||
</section>
|
</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) { %>
|
<% if (canManageObs) { %>
|
||||||
<section class="card" data-obs-connector data-overlay-id="<%= overlay.id %>">
|
<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>
|
<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>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Lumi Core",
|
"name": "Lumi Core",
|
||||||
"version": "0.2.21",
|
"version": "0.2.22",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"released_at": "2026-07-19",
|
"released_at": "2026-07-19",
|
||||||
"compatible_from": "0.1.9",
|
"compatible_from": "0.1.9",
|
||||||
@ -8,7 +8,7 @@
|
|||||||
"replaces_versions": [
|
"replaces_versions": [
|
||||||
"1.2.0"
|
"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,
|
"rollback_safe": true,
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"Node.js 18 or newer"
|
"Node.js 18 or newer"
|
||||||
@ -277,6 +277,18 @@
|
|||||||
],
|
],
|
||||||
"rollback_safe": true,
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user