From 67ca472317b506c45f03127f44543149c9738900 Mon Sep 17 00:00:00 2001 From: ZeasyAlex Date: Tue, 27 Oct 2026 16:12:37 +0100 Subject: [PATCH] Chat-Overlay: echte Twitch-Abzeichen statt farbiger Punkte Der Hub loest die Abzeichen aus dem IRC-Tag ueber Helix (global und Kanal) zu Bildern auf und schickt sie mit jeder Chatzeile mit. Co-Authored-By: Claude Opus 5.5 --- apps/hub/src/index.ts | 17 ++++++++ apps/ingest/src/spike.test.ts | 2 +- apps/overlay-server/src/bus.test.ts | 2 +- apps/overlay-server/src/bus.ts | 2 + apps/overlay-server/src/test-events.ts | 20 +++++++++- apps/overlay-web/public/chat.html | 27 +++++++------ packages/db/src/repos.test.ts | 2 +- packages/shared/src/events.ts | 2 + packages/twitch/src/badges.test.ts | 23 +++++++++++ packages/twitch/src/badges.ts | 55 ++++++++++++++++++++++++++ packages/twitch/src/helix.ts | 17 ++++++++ packages/twitch/src/irc.test.ts | 11 +++++- packages/twitch/src/irc.ts | 10 +++++ 13 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 packages/twitch/src/badges.test.ts create mode 100644 packages/twitch/src/badges.ts diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 9140f61..d8cb18c 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -30,6 +30,7 @@ import { createBot } from '#discord/bot.ts'; import { helix } from '#twitch/helix.ts'; import { readSecrets } from '#twitch/secrets.ts'; import { createEventSub } from '#twitch/eventsub.ts'; +import { createBadges } from '#twitch/badges.ts'; import { createIrc, type UserNotice } from '#twitch/irc.ts'; import { streamWechsel } from '#twitch/stream-state.ts'; import { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts'; @@ -245,6 +246,21 @@ export async function runHub(): Promise { const bot = createBot(); if (bot) void bot.run(abort.signal); + // ---- Abzeichen-Bilder fuer den Chat. Twitch aendert sie selten, ein + // neues Abo-Abzeichen des Kanals ist nach spaetestens sechs Stunden da. + const badges = createBadges(); + void (async () => { + while (!abort.signal.aborted) { + if (helix.canReadStream()) { + const id = state.broadcasterId || cfg.channel.userId || + (await helix.userByLogin(cfg.channel.login).catch(() => null))?.id || null; + if (id) state.broadcasterId = id; + await badges.laden(id); + } + try { await sleep(6 * 60 * 60_000, abort.signal); } catch { return; } + } + })(); + // ---- Chat const irc = createIrc(cfg.channel.login, { onConnected: (anonymous) => { @@ -273,6 +289,7 @@ export async function runHub(): Promise { mod: msg.isMod, sub: msg.isSub, broadcaster: msg.isBroadcaster, + badges: badges.aufloesen(msg.badges), bits: msg.bits, }); diff --git a/apps/ingest/src/spike.test.ts b/apps/ingest/src/spike.test.ts index 696fa21..e1e1cd1 100644 --- a/apps/ingest/src/spike.test.ts +++ b/apps/ingest/src/spike.test.ts @@ -21,7 +21,7 @@ function msg(text: string, userId: string, advanceMs = 0): ChatMessage { id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId, login: `u${userId}`, displayName: `U${userId}`, text, emotes: [], gifs: [], isFirst: false, isReturning: false, isCommand: text.startsWith('!'), - isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null, + isMod: false, isSub: false, isBroadcaster: false, badges: [], bits: 0, color: null, }; } diff --git a/apps/overlay-server/src/bus.test.ts b/apps/overlay-server/src/bus.test.ts index aafe3a1..be3ddd4 100644 --- a/apps/overlay-server/src/bus.test.ts +++ b/apps/overlay-server/src/bus.test.ts @@ -10,7 +10,7 @@ function nachricht(over: Partial = {}): ChatEvent { return { kind: 'chat', id: 'm1', userId: 'u1', login: 'kevin', name: 'Kevin', text: 'moin', color: null, emotes: [], gifs: [], first: false, mod: false, - sub: false, broadcaster: false, bits: 0, + sub: false, broadcaster: false, badges: [], bits: 0, ...over, }; } diff --git a/apps/overlay-server/src/bus.ts b/apps/overlay-server/src/bus.ts index fe3dc74..a3590cf 100644 --- a/apps/overlay-server/src/bus.ts +++ b/apps/overlay-server/src/bus.ts @@ -29,6 +29,8 @@ export type ChatEvent = { mod: boolean; sub: boolean; broadcaster: boolean; + /** Abzeichen als fertige Bilder, aufgeloest im Hub (#twitch/badges.ts). */ + badges: Array<{ url: string; title: string }>; bits: number; }; diff --git a/apps/overlay-server/src/test-events.ts b/apps/overlay-server/src/test-events.ts index a1efbb2..3403532 100644 --- a/apps/overlay-server/src/test-events.ts +++ b/apps/overlay-server/src/test-events.ts @@ -96,6 +96,23 @@ export const TEST_KNOEPFE: TestKnopf[] = [ { art: 'live-aus', label: 'Stand zurücksetzen', gruppe: 'Stand' }, ]; +// Twitchs globale Abzeichen. Die Adressen sind fest, so zeigt die Vorfuehrung +// echte Bilder, ohne Helix fragen zu muessen. +const ABZEICHEN = 'https://static-cdn.jtvnw.net/badges/v1/'; +const MOD = { url: `${ABZEICHEN}3267646d-33f0-4b17-b3df-f923a41db1d0/2`, title: 'Moderator' }; +const VIP = { url: `${ABZEICHEN}b817aba4-fad8-49e2-b88a-7cc744dfa6ec/2`, title: 'VIP' }; +const ABO = { url: `${ABZEICHEN}5d9f2208-5dd8-11e7-8513-2ff4adfae661/2`, title: 'Subscriber' }; + +function zufallsAbzeichen(): Pick { + const mod = Math.random() < 0.15; + const vip = !mod && Math.random() < 0.1; + const sub = Math.random() < 0.3; + return { + mod, sub, + badges: [...(mod ? [MOD] : []), ...(vip ? [VIP] : []), ...(sub ? [ABO] : [])], + }; +} + function chat(over: Partial = {}): ChatEvent { const name = zufall(NAMEN); return { @@ -109,8 +126,7 @@ function chat(over: Partial = {}): ChatEvent { emotes: [], gifs: [], first: false, - mod: Math.random() < 0.15, - sub: Math.random() < 0.3, + ...zufallsAbzeichen(), broadcaster: false, bits: 0, ...over, diff --git a/apps/overlay-web/public/chat.html b/apps/overlay-web/public/chat.html index e7c8927..e3b3eac 100644 --- a/apps/overlay-web/public/chat.html +++ b/apps/overlay-web/public/chat.html @@ -53,18 +53,15 @@ background: rgba(0, 0, 0, .35); } -/* Abzeichen statt Bilder: Twitchs Abzeichen-URLs haengen am Kanal und muessten - * ueber Helix aufgeloest werden. Ein farbiger Punkt sagt dasselbe. */ +/* Twitchs eigene Abzeichen. Die Bilder loest der Hub ueber Helix auf und + * schickt sie mit jeder Zeile mit (#twitch/badges.ts). */ .abzeichen { - display: inline-block; - width: .5em; height: .5em; - border-radius: 50%; - margin-right: .4em; - vertical-align: .08em; + height: 1.05em; + width: auto; + margin-right: .3em; + vertical-align: -.17em; } -.abzeichen.mod { background: var(--gruen); } -.abzeichen.abo { background: var(--lila); } -.abzeichen.host { background: var(--rot); } +.abzeichen:last-of-type { margin-right: .45em; } /* Wer zum ersten Mal schreibt, wird hervorgehoben - das ist der Moment, in * dem eine Begruessung am meisten bringt. Ohne den Balken an der linken Kante @@ -152,9 +149,13 @@ el.dataset.user = ev.userId; var abzeichen = ''; - if (ev.broadcaster) abzeichen += ''; - else if (ev.mod) abzeichen += ''; - if (ev.sub) abzeichen += ''; + var bilder = ev.badges || []; + for (var i = 0; i < bilder.length; i++) { + // Nur Twitchs eigene Bildadressen: was hier steht, laedt OBS. + if (!/^https:\/\/static-cdn\.jtvnw\.net\//.test(bilder[i].url)) continue; + abzeichen += '' + Overlay.esc(bilder[i].title) + ''; + } el.innerHTML = abzeichen + diff --git a/packages/db/src/repos.test.ts b/packages/db/src/repos.test.ts index de68083..9575175 100644 --- a/packages/db/src/repos.test.ts +++ b/packages/db/src/repos.test.ts @@ -22,7 +22,7 @@ function msg(over: Partial = {}): ChatMessage { ts: '2026-09-09T20:00:00.000Z', userId: '42', login: 'maxi_2k', displayName: 'maxi_2k', text: 'moin', emotes: [], gifs: [], isFirst: false, isReturning: false, - isCommand: false, isMod: false, isSub: false, isBroadcaster: false, + isCommand: false, isMod: false, isSub: false, isBroadcaster: false, badges: [], bits: 0, color: null, ...over, }; } diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 02d8619..2e8d60b 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -41,6 +41,8 @@ export type ChatMessage = { isMod: boolean; isSub: boolean; isBroadcaster: boolean; + /** Twitchs Abzeichen in Anzeigereihenfolge, z. B. moderator/1, subscriber/3012. */ + badges: Array<{ set: string; version: string }>; bits: number; color: string | null; }; diff --git a/packages/twitch/src/badges.test.ts b/packages/twitch/src/badges.test.ts new file mode 100644 index 0000000..57f8e14 --- /dev/null +++ b/packages/twitch/src/badges.test.ts @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { badgeKarte, badgesAufloesen } from './badges.ts'; + +const v = (id: string, url: string) => + ({ id, title: url, image_url_1x: '', image_url_2x: url, image_url_4x: '' }); + +test('Kanal-Abzeichen ueberdecken die globalen', () => { + const karte = badgeKarte( + [{ set_id: 'subscriber', versions: [v('0', 'global-sub')] }, { set_id: 'moderator', versions: [v('1', 'mod')] }], + [{ set_id: 'subscriber', versions: [v('0', 'kanal-sub'), v('3012', 'kanal-sub-t3')] }], + ); + assert.deepEqual( + badgesAufloesen(karte, [{ set: 'moderator', version: '1' }, { set: 'subscriber', version: '0' }]) + .map((b) => b.url), + ['mod', 'kanal-sub'], + ); + assert.equal(badgesAufloesen(karte, [{ set: 'subscriber', version: '3012' }])[0]?.url, 'kanal-sub-t3'); +}); + +test('unbekannte Abzeichen fallen weg', () => { + assert.deepEqual(badgesAufloesen(new Map(), [{ set: 'vip', version: '1' }]), []); +}); diff --git a/packages/twitch/src/badges.ts b/packages/twitch/src/badges.ts new file mode 100644 index 0000000..2fa11eb --- /dev/null +++ b/packages/twitch/src/badges.ts @@ -0,0 +1,55 @@ +// Abzeichen im Chat als Bilder. IRC liefert nur `subscriber/3012`, das Bild +// dazu kennt nur Helix. Die Kanal-Abzeichen (eigene Abo-Stufen) ueberdecken +// die globalen gleichen Namens - so zeigt Twitch sie auch an. + +import { logger } from '#core/logger.ts'; +import { helix, type HelixBadgeSet } from './helix.ts'; + +const log = logger('badges'); + +export type BadgeBild = { url: string; title: string }; +export type BadgeKarte = Map; + +export function badgeKarte(global: HelixBadgeSet[], kanal: HelixBadgeSet[]): BadgeKarte { + const karte: BadgeKarte = new Map(); + for (const set of [...global, ...kanal]) { + for (const v of set.versions) { + // 2x ist 36 px: bei 19 px Schrift im Overlay scharf, auch skaliert. + karte.set(`${set.set_id}/${v.id}`, { url: v.image_url_2x, title: v.title }); + } + } + return karte; +} + +/** Unbekannte Abzeichen fallen weg, statt als kaputtes Bild zu erscheinen. */ +export function badgesAufloesen( + karte: BadgeKarte, badges: Array<{ set: string; version: string }>, +): BadgeBild[] { + return badges.flatMap((b) => { + const bild = karte.get(`${b.set}/${b.version}`); + return bild ? [bild] : []; + }); +} + +export function createBadges(): { + laden: (broadcasterId: string | null) => Promise; + aufloesen: (badges: Array<{ set: string; version: string }>) => BadgeBild[]; +} { + let karte: BadgeKarte = new Map(); + return { + async laden(broadcasterId) { + try { + const [global, kanal] = await Promise.all([ + helix.chatBadges(), + broadcasterId ? helix.chatBadges(broadcasterId) : Promise.resolve([]), + ]); + karte = badgeKarte(global, kanal); + log.info(`Abzeichen geladen: ${karte.size}`); + } catch (err) { + // Die alte Karte bleibt stehen. Ohne Karte zeigt der Chat eben keine. + log.warn(`Abzeichen nicht geladen: ${(err as Error).message}`); + } + }, + aufloesen: (badges) => badgesAufloesen(karte, badges), + }; +} diff --git a/packages/twitch/src/helix.ts b/packages/twitch/src/helix.ts index e3e3cd3..3f98be6 100644 --- a/packages/twitch/src/helix.ts +++ b/packages/twitch/src/helix.ts @@ -47,6 +47,11 @@ export type HelixStream = { title: string; viewer_count: number; started_at: string; language: string; thumbnail_url: string; }; +export type HelixBadgeSet = { + set_id: string; + versions: Array<{ id: string; title: string; image_url_1x: string; image_url_2x: string; image_url_4x: string }>; +}; + export type HelixUser = { id: string; login: string; display_name: string }; export type HelixReward = { @@ -220,6 +225,18 @@ export const helix = { return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope); }, + /** + * Abzeichen-Bilder, ohne `broadcasterId` die globalen (Moderator, VIP, + * Broadcaster ...), mit die des Kanals (Abo-Stufen, Bits). Ein App-Token + * reicht fuer beides. + */ + async chatBadges(broadcasterId?: string): Promise { + const r = await call<{ data: HelixBadgeSet[] }>('broadcaster', + broadcasterId ? `/chat/badges?broadcaster_id=${broadcasterId}` : '/chat/badges/global', + {}, { appOk: true }); + return r.data; + }, + async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> { const r = await call<{ data: Array<{ id: string; edit_url: string }> }>( 'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' }); diff --git a/packages/twitch/src/irc.test.ts b/packages/twitch/src/irc.test.ts index 23d9103..41aec5e 100644 --- a/packages/twitch/src/irc.test.ts +++ b/packages/twitch/src/irc.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { parseLine, toChatMessage, parseGifs } from './irc.ts'; +import { parseLine, toChatMessage, parseGifs, parseBadges } from './irc.ts'; const LINE = '@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' + @@ -28,6 +28,9 @@ test('uebersetzt in eine ChatMessage', () => { assert.ok(msg); assert.equal(msg.userId, '98765'); assert.equal(msg.login, 'nic_ooo'); + assert.deepEqual(msg.badges, [ + { set: 'broadcaster', version: '1' }, { set: 'subscriber', version: '6' }, + ]); assert.equal(msg.displayName, 'Nic_OOo'); assert.equal(msg.isBroadcaster, true); assert.equal(msg.isSub, true); @@ -104,3 +107,9 @@ test('unvollstaendige oder unsinnige Tags werden verworfen', () => { assert.deepEqual(parseGifs(kaputt), [], `"${kaputt}" haette verworfen gehoert`); } }); + +test('Abzeichen: leer und kaputt ergeben nichts', () => { + assert.deepEqual(parseBadges(undefined), []); + assert.deepEqual(parseBadges(''), []); + assert.deepEqual(parseBadges('vip/1,kaputt'), [{ set: 'vip', version: '1' }]); +}); diff --git a/packages/twitch/src/irc.ts b/packages/twitch/src/irc.ts index 4c39964..cc944b4 100644 --- a/packages/twitch/src/irc.ts +++ b/packages/twitch/src/irc.ts @@ -111,6 +111,15 @@ function parseEmotes(tag: string | undefined, text: string): ChatMessage['emotes return out; } +/** `broadcaster/1,subscriber/3012` -> [{ set, version }], Reihenfolge wie bei Twitch. */ +export function parseBadges(tag: string | undefined): Array<{ set: string; version: string }> { + if (!tag) return []; + return tag.split(',').flatMap((teil) => { + const [set, version] = teil.split('/'); + return set && version ? [{ set, version }] : []; + }); +} + export function toChatMessage(m: IrcMessage): ChatMessage | null { if (m.command !== 'PRIVMSG') return null; const text = m.params[1] ?? ''; @@ -133,6 +142,7 @@ export function toChatMessage(m: IrcMessage): ChatMessage | null { isMod: m.tags['mod'] === '1' || badges.includes('broadcaster'), isSub: m.tags['subscriber'] === '1', isBroadcaster: badges.includes('broadcaster'), + badges: parseBadges(badges), bits: Number(m.tags['bits'] ?? 0), color: m.tags['color'] || null, };