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 <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5.5 committed 2026-10-27 16:12:37 +01:00
1 parent 884e4517d8
commit 67ca472317
13 files changed
+171 -19

No files matched your search

+17
View File
@@ -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<void> {
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<void> {
mod: msg.isMod,
sub: msg.isSub,
broadcaster: msg.isBroadcaster,
badges: badges.aufloesen(msg.badges),
bits: msg.bits,
});
+1 -1
View File
@@ -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,
};
}
+1 -1
View File
@@ -10,7 +10,7 @@ function nachricht(over: Partial<ChatEvent> = {}): 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,
};
}
+2
View File
@@ -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;
};
+18 -2
View File
@@ -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<ChatEvent, 'mod' | 'sub' | 'badges'> {
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> = {}): ChatEvent {
const name = zufall(NAMEN);
return {
@@ -109,8 +126,7 @@ function chat(over: Partial<ChatEvent> = {}): ChatEvent {
emotes: [],
gifs: [],
first: false,
mod: Math.random() < 0.15,
sub: Math.random() < 0.3,
...zufallsAbzeichen(),
broadcaster: false,
bits: 0,
...over,
+14 -13
View File
@@ -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 += '<span class="abzeichen host"></span>';
else if (ev.mod) abzeichen += '<span class="abzeichen mod"></span>';
if (ev.sub) abzeichen += '<span class="abzeichen abo"></span>';
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 += '<img class="abzeichen" src="' + Overlay.esc(bilder[i].url) +
'" alt="' + Overlay.esc(bilder[i].title) + '">';
}
el.innerHTML =
abzeichen +
+1 -1
View File
@@ -22,7 +22,7 @@ function msg(over: Partial<ChatMessage> = {}): 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,
};
}
+2
View File
@@ -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;
};
+23
View File
@@ -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' }]), []);
});
+55
View File
@@ -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<string, BadgeBild>;
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<void>;
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),
};
}
+17
View File
@@ -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<HelixBadgeSet[]> {
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' });
+10 -1
View File
@@ -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' }]);
});
+10
View File
@@ -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,
};