Add BetterTTV chat overlay support

This commit is contained in:
Franz Rolfsvaag 2026-07-19 22:14:29 +02:00
parent 66d0b63077
commit a5bd15cf3e
13 changed files with 201 additions and 23 deletions

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

4
package-lock.json generated
View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "commonjs",
"scripts": {

View File

@ -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",

View File

@ -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 <a:party:123456>");
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"));

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.2.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.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.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);

View File

@ -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
};

View File

@ -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) => {
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);

View File

@ -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."
}
]
}