Add BetterTTV chat overlay support
This commit is contained in:
parent
66d0b63077
commit
a5bd15cf3e
@ -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.
|
||||
|
||||
4
TODO.md
4
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.20",
|
||||
"version": "0.2.21",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user