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:
1 parent
884e4517d8
commit
67ca472317
13 files changed
+171
-19
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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' }]), []);
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user