diff --git a/CHANGELOG.md b/CHANGELOG.md index e41c73d..51505d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Lumi changelog +## 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. + ## 0.2.20 - Added comma-separated aliases to conditional command reply rules, allowing several keywords to return one shared response while retaining exact and fuzzy matching. diff --git a/TODO.md b/TODO.md index b31ba66..4fcc4bd 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,10 @@ save. Patch completed on 2026-07-19: conditional reply rules now accept comma-separated keyword aliases that share one response. +Overlay chat follow-up completed on 2026-07-19: Twitch messages resolve cached +global, channel, and shared BetterTTV emotes without affecting chat delivery +when BetterTTV is unavailable. + Remaining work: - Add platform action providers when YouTube or future chat integrations expose diff --git a/docs/obs-overlays.md b/docs/obs-overlays.md index 06c27d0..54bde3c 100644 --- a/docs/obs-overlays.md +++ b/docs/obs-overlays.md @@ -79,8 +79,10 @@ timestamps, message spacing, rounded corners, maximum visible messages, top-or-bottom placement, timeout removal, and separate entry and exit animations. Message cards fit their contents up to the source boundary rather than filling its width. Entry and exit slides can travel left, right, up, or -down. Twitch emotes and Discord custom or animated emoji are displayed as -images. Discord GIF links, GIF attachments, GIFV embeds, and stickers are shown +down. Native Twitch emotes, global and channel BetterTTV emotes, and Discord +custom or animated emoji are displayed as images. BetterTTV channel and shared +emotes override a same-named global emote, matching normal channel behavior. +Discord GIF links, GIF attachments, GIFV embeds, and stickers are shown as bounded inline media at roughly two text lines tall. The editor continuously cycles several short and wrapped sample messages so both static appearance and entry/exit behavior can be configured before the stream is live. @@ -90,7 +92,8 @@ are shown as icons, and chat badges are rendered as images. Twitch badge artwork is resolved from Twitch's global and channel badge catalogs; a local image fallback is used when a provider does not expose badge artwork. Twitch profile images are resolved through the Twitch user catalog and cached to avoid a lookup -for every message. +for every message. BetterTTV catalogs are likewise cached, and API failures fall +back to ordinary message text without interrupting chat delivery. The optional hidden-user list accepts one rule per line in the form `platform:username|user-id`. Twitch, YouTube, and Discord rules match only that diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index a350488..d6415e6 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,7 +14,7 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.2.20 +Version: 0.2.21 ## Routes - GET /api/events - POST /api/destructive-confirmations diff --git a/package-lock.json b/package-lock.json index 8919064..02734a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.2.20", + "version": "0.2.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.2.20", + "version": "0.2.21", "dependencies": { "adm-zip": "^0.5.12", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index 141c6a1..814ea79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index 95ce2bc..0e74e5a 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,36 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.2.21", + "ref": "refs/tags/v0.2.21", + "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 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 are preserved.", + "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.20", "ref": "refs/tags/v0.2.20", diff --git a/scripts/verify-overlays.js b/scripts/verify-overlays.js index e3edfcf..7fad44b 100644 --- a/scripts/verify-overlays.js +++ b/scripts/verify-overlays.js @@ -21,7 +21,8 @@ for (const file of [ "overlay-permissions.js", "overlay-connectors.js", "overlay-chat.js", - "discord-chat-content.js" + "discord-chat-content.js", + "twitch-chat-assets.js" ]) { fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file)); } @@ -240,6 +241,14 @@ try { assert.strictEqual(discordContent.text, "Hello "); assert.strictEqual(discordContent.emotes[0].image, "https://cdn.discordapp.com/emojis/123456.gif?size=96&quality=lossless"); assert.strictEqual(discordContent.media[0].url, "https://cdn.example/dance.gif"); + const twitchChatAssets = require(path.join(serviceDir, "twitch-chat-assets.js")); + const betterTtvCatalog = twitchChatAssets.indexBetterTtvCatalog([ + { id: "emote-kekw", code: "KEKW", imageType: "webp", animated: false }, + { id: "emote-dance", code: "Dance", imageType: "gif", animated: true } + ]); + const betterTtvMatches = twitchChatAssets.findBetterTtvEmotes("KEKW hello Dance KEKW!", betterTtvCatalog); + assert.deepStrictEqual(betterTtvMatches.map((emote) => [emote.start, emote.end, emote.label]), [[0, 3, "KEKW"], [11, 15, "Dance"]]); + assert.strictEqual(betterTtvMatches[0].image, "https://cdn.betterttv.net/emote/emote-kekw/3x"); overlayChat.publishOverlayChatMessage(normalizedChat); assert(overlayChat.recentOverlayChatMessages(created.id).some((event) => event.payload.id === "message-1")); const websiteState = overlays.buildPublicState(created.id, created.scenes[0].id); @@ -312,6 +321,7 @@ try { const { OBS_BROWSER_DEFAULT_CSS } = require(path.join(root, "src", "services", "obs-browser-defaults.js")); const connectorScript = fs.readFileSync(path.join(root, "src", "services", "overlay-connectors.js"), "utf8"); const twitchBadgeScript = fs.readFileSync(path.join(root, "src", "services", "twitch-chat-assets.js"), "utf8"); + const twitchServiceScript = fs.readFileSync(path.join(root, "src", "services", "twitch.js"), "utf8"); const routeScript = fs.readFileSync(path.join(root, "src", "services", "overlay-routes.js"), "utf8"); assert(editorView.includes("data-overlay-canvas"), "management page must include a visual canvas"); assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"')); @@ -372,6 +382,9 @@ try { assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report")); assert(twitchBadgeScript.includes("api.twitch.tv/helix/${path}") && twitchBadgeScript.includes("chat/badges") && twitchBadgeScript.includes("image_url_2x")); assert(twitchBadgeScript.includes("resolveTwitchAvatar") && twitchBadgeScript.includes("profile_image_url") && twitchBadgeScript.includes("users?${query}")); + assert(twitchBadgeScript.includes("api.betterttv.net/3/${path}") && twitchBadgeScript.includes("cached/emotes/global") && twitchBadgeScript.includes("cached/users/twitch/")); + assert(twitchBadgeScript.includes("channelEmotes") && twitchBadgeScript.includes("sharedEmotes") && twitchBadgeScript.includes("cdn.betterttv.net/emote/")); + assert(twitchServiceScript.includes("resolveBetterTtvEmotes") && twitchServiceScript.includes("...betterTtvEmotes")); assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import")); assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument")); assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket")); diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index 5a16643..e156f86 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,8 +4,8 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.2.20"; -const previousCoreVersion = "0.2.19"; +const releaseVersion = "0.2.21"; +const previousCoreVersion = "0.2.20"; const earliestCompatibleCoreVersion = "0.1.9"; const changedPlugins = { "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, @@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: core 0.2.20, Lumi AI 0.8.5, and synchronized package metadata."); +console.log("Release metadata verification passed: core 0.2.21, Lumi AI 0.8.5, and synchronized package metadata."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 26b483f..918a570 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -16,7 +16,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.2.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.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); for (const release of releaseIndex.releases) { assert.equal(normalizeRepositoryRef(release.ref), release.ref); @@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { const baseTarget = { current_version: "0.2.4", available_versions: [ + { version: "0.2.21", ref: "refs/tags/v0.2.21", rollback_safe: true }, { version: "0.2.20", ref: "refs/tags/v0.2.20", rollback_safe: true }, { version: "0.2.19", ref: "refs/tags/v0.2.19", rollback_safe: true }, { version: "0.2.18", ref: "refs/tags/v0.2.18", rollback_safe: true }, @@ -80,7 +81,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.2.20"); +assert.equal(corrected.safe_target_version, "0.2.21"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/twitch-chat-assets.js b/src/services/twitch-chat-assets.js index 70ecc28..cf4dbaa 100644 --- a/src/services/twitch-chat-assets.js +++ b/src/services/twitch-chat-assets.js @@ -2,9 +2,11 @@ const { getSetting } = require("./settings"); const badgeCatalogs = new Map(); const userProfiles = new Map(); +const betterTtvCatalogs = new Map(); const CACHE_MS = 30 * 60 * 1000; const USER_CACHE_MS = 6 * 60 * 60 * 1000; const USER_FAILURE_CACHE_MS = 5 * 60 * 1000; +const EXTERNAL_FAILURE_CACHE_MS = 5 * 60 * 1000; function fallbackBadges(badges = {}) { return Object.entries(badges).map(([label]) => ({ label, image: null })); @@ -94,4 +96,109 @@ async function resolveTwitchAvatar(userId, username) { return promise; } -module.exports = { resolveTwitchAvatar, resolveTwitchBadges }; +async function betterTtvRequest(path) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2500); + try { + const response = await fetch(`https://api.betterttv.net/3/${path}`, { + headers: { Accept: "application/json", "User-Agent": "Lumi-Bot" }, + redirect: "error", + signal: controller.signal + }); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`BetterTTV request returned HTTP ${response.status}.`); + return response.json(); + } finally { + clearTimeout(timeout); + } +} + +function indexBetterTtvCatalog(emotes = []) { + const catalog = new Map(); + for (const emote of Array.isArray(emotes) ? emotes : []) { + const id = String(emote?.id || "").trim(); + const code = String(emote?.code || "").trim(); + if (!id || !code || /\s/u.test(code)) continue; + catalog.set(code, { + label: code, + image: `https://cdn.betterttv.net/emote/${encodeURIComponent(id)}/3x` + }); + } + return catalog; +} + +function mergeBetterTtvCatalog(target, emotes) { + for (const [code, emote] of indexBetterTtvCatalog(emotes)) target.set(code, emote); + return target; +} + +async function loadBetterTtvCatalog(roomId) { + if (!roomId) return loadGlobalBetterTtvCatalog(); + const key = `room:${String(roomId)}`; + const cached = betterTtvCatalogs.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.promise; + const promise = Promise.allSettled([ + loadGlobalBetterTtvCatalog(), + betterTtvRequest(`cached/users/twitch/${encodeURIComponent(roomId)}`) + ]).then((results) => { + const catalog = results[0].status === "fulfilled" ? new Map(results[0].value) : new Map(); + const channelPayload = results[1].status === "fulfilled" ? results[1].value : null; + mergeBetterTtvCatalog(catalog, channelPayload?.channelEmotes); + mergeBetterTtvCatalog(catalog, channelPayload?.sharedEmotes); + return catalog; + }); + const result = { expiresAt: Date.now() + CACHE_MS, promise }; + betterTtvCatalogs.set(key, result); + promise.then((catalog) => { + if (!catalog.size) result.expiresAt = Date.now() + EXTERNAL_FAILURE_CACHE_MS; + }); + return promise; +} + +async function loadGlobalBetterTtvCatalog() { + const key = "global-emotes"; + const cached = betterTtvCatalogs.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.promise; + const promise = betterTtvRequest("cached/emotes/global") + .then((payload) => indexBetterTtvCatalog(payload)) + .catch(() => new Map()); + const result = { expiresAt: Date.now() + CACHE_MS, promise }; + betterTtvCatalogs.set(key, result); + promise.then((catalog) => { + if (!catalog.size) result.expiresAt = Date.now() + EXTERNAL_FAILURE_CACHE_MS; + }); + return promise; +} + +function findBetterTtvEmotes(message, catalog) { + const text = String(message || ""); + if (!text || !(catalog instanceof Map) || !catalog.size) return []; + const matches = []; + for (const token of text.matchAll(/\S+/gu)) { + const emote = catalog.get(token[0]); + if (!emote) continue; + matches.push({ + start: token.index, + end: token.index + token[0].length - 1, + image: emote.image, + label: emote.label + }); + } + return matches; +} + +async function resolveBetterTtvEmotes(roomId, message) { + try { + return findBetterTtvEmotes(message, await loadBetterTtvCatalog(roomId)); + } catch { + return []; + } +} + +module.exports = { + findBetterTtvEmotes, + indexBetterTtvCatalog, + resolveBetterTtvEmotes, + resolveTwitchAvatar, + resolveTwitchBadges +}; diff --git a/src/services/twitch.js b/src/services/twitch.js index d1d20e7..bf728e9 100644 --- a/src/services/twitch.js +++ b/src/services/twitch.js @@ -4,7 +4,7 @@ const { incrementMessages } = require("./stats"); const { ensureUserForIdentity } = require("./users"); const { createLogger } = require("./logger"); const { publishOverlayChatMessage } = require("./overlay-chat"); -const { resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets"); +const { resolveBetterTtvEmotes, resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets"); const twitchLog = createLogger("platform:twitch", { category: "integration" }); @@ -46,9 +46,10 @@ async function startTwitchBot({ commandRouter } = {}) { return; } const displayName = tags["display-name"] || tags.username || username; - const [badges, avatar] = await Promise.all([ + const [badges, avatar, betterTtvEmotes] = await Promise.all([ resolveTwitchBadges(tags["room-id"], tags.badges || {}), - resolveTwitchAvatar(tags["user-id"], tags.username || username) + resolveTwitchAvatar(tags["user-id"], tags.username || username), + resolveBetterTtvEmotes(tags["room-id"], message) ]); const profile = self ? null : ensureUserForIdentity({ provider: "twitch", @@ -71,10 +72,13 @@ async function startTwitchBot({ commandRouter } = {}) { badges, lumi: profile ? { id: profile.id, username: profile.internal_username } : null }, - emotes: Object.entries(tags.emotes || {}).flatMap(([id, positions]) => (positions || []).map((position) => { - const [start, end] = String(position).split("-").map(Number); - return { start, end, image: `https://static-cdn.jtvnw.net/emoticons/v2/${encodeURIComponent(id)}/default/dark/3.0` }; - })) + emotes: [ + ...Object.entries(tags.emotes || {}).flatMap(([id, positions]) => (positions || []).map((position) => { + const [start, end] = String(position).split("-").map(Number); + return { start, end, image: `https://static-cdn.jtvnw.net/emoticons/v2/${encodeURIComponent(id)}/default/dark/3.0` }; + })), + ...betterTtvEmotes + ] }); if (self) return; incrementMessages(profile.id); diff --git a/update-manifest.json b/update-manifest.json index 55a628d..80415b4 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,6 +1,6 @@ { "name": "Lumi Core", - "version": "0.2.20", + "version": "0.2.21", "channel": "stable", "released_at": "2026-07-19", "compatible_from": "0.1.9", @@ -8,7 +8,7 @@ "replaces_versions": [ "1.2.0" ], - "migration_notes": "Adds comma-separated aliases for conditional command reply rules. Existing commands, policies, groups, usage totals, overlays, 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 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.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -265,6 +265,18 @@ ], "rollback_safe": true, "migration_notes": "Adds comma-separated aliases for conditional command reply rules; existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved." + }, + { + "version": "0.2.21", + "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 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." } ] }