Files
abdulive/apps/broadcaster/src/golive.test.ts
T
ZeasyAlexandClaude Opus 5 c4dc6ee04f Go-Live-Ping: Discord meldet den Streamstart
Der billigste Hebel im Repo, und bis jetzt nur Vorarbeit: die Webhooks lagen
seit der Discord-Einrichtung bereit, gepostet hat nie jemand.

Ablauf: neuer Stream erkannt, eine Minute warten (Twitch setzt Titel und
Kategorie erst kurz nach dem Start, wer sofort postet kuendigt den Titel des
letzten Streams an), Daten frisch holen, Webhook posten, Rolle Live-Ping
anpingen.

Der Ping funktioniert nur mit `allowed_mentions` im Nachrichtenkoerper. `<@&ID>`
allein faerbt die Erwaehnung lila ein und benachrichtigt niemanden - der
Fehler, den man erst nach drei leeren Streams bemerkt. Dagegen steht ein
eigener Test.

Gegen den doppelten Post stehen drei Dinge, denn wer zweimal in zehn Minuten
angepingt wird, nimmt sich die Rolle wieder ab und ist damit fuer immer stumm:

- Tabelle `posts` mit eindeutigem Index je Stream und Plattform. Nur
  *gesendete* Posts sperren; ein fehlgeschlagener Versuch darf wiederholt
  werden, sonst kostet ein Netzwerkaussetzer die ganze Ankuendigung.
- Abklingzeit von sechs Stunden, gegen die neue Stream-ID nach einem
  Verbindungsabbruch.
- Ausgeloest wird nur beim erkannten Neustart, nie im Fortsetzungszweig.

`stream_id` ist bewusst kein Fremdschluessel: ein Post ist die Quittung fuer
etwas, das den Rechner verlassen hat, und muss auch dann schreibbar sein,
wenn es die Sitzung nicht gibt - beim Probelauf etwa.

Dazu ein vierter Webhook in #technik-log. Der Entwurfsmodus postet dieselbe
Nachricht dorthin, ohne Ping und nur fuer Mods sichtbar. So sieht man das
Ergebnis, bevor es der Server sieht.

Die Rollenaufloesung aus selfroles.ts liegt jetzt in roles.ts, weil der Ping
sie auch braucht: ein Webhook kann eine Rolle nicht ueber ihren Namen
anpingen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:12:35 +02:00

284 lines
11 KiB
TypeScript

// Der Go-Live-Post geht an Menschen, die sich den Ping selbst geholt haben.
// Zwei Pings fuer denselben Stream kosten Abonnenten der Rolle, und die kommen
// nicht wieder. Deshalb liegt der Schwerpunkt hier auf dem, was NICHT rausgeht.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { repos, type Repos } from '#db/repos.ts';
import type { Attachment, PostedMessage, WebhookMessage } from '#discord/webhook.ts';
import {
buildMessage, createAnnouncer, decide, thumbnailUrl,
type GoLiveConfig, type StreamInfo,
} from './golive.ts';
import { pick, OPENERS } from './templates.ts';
const MIGRATIONS = path.join(
path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'packages', 'db', 'migrations');
/** Frische Datenbank im Arbeitsspeicher, mit allen Migrationen. */
function freshRepo(): Repos {
const db = new DatabaseSync(':memory:');
for (const f of fs.readdirSync(MIGRATIONS).filter((n) => n.endsWith('.sql')).sort()) {
db.exec(fs.readFileSync(path.join(MIGRATIONS, f), 'utf8'));
}
return repos(db);
}
const STREAM: StreamInfo = {
id: '48212345',
title: 'Elden Ring, aber nur mit Fäusten',
category: 'Elden Ring',
login: 'abdulive_',
startedAt: '2026-09-17T18:00:00.000Z',
thumbnailUrl: 'https://static-cdn.twitch.tv/previews/abdulive_-{width}x{height}.jpg',
};
const CFG: GoLiveConfig = {
enabled: true, mode: 'live', delaySeconds: 0, cooldownHours: 6,
pingRole: 'Live-Ping', obsScreenshot: false,
};
type Sent = { url: string; message: WebhookMessage; files: Attachment[] };
function fakePost(sink: Sent[], fail?: string) {
return async (url: string, message: WebhookMessage, files: Attachment[] = [])
: Promise<PostedMessage> => {
if (fail) throw new Error(fail);
sink.push({ url, message, files });
return { id: '999', channel_id: '888' };
};
}
const HOOKS = () => ({ live: 'https://live', draft: 'https://draft', guildId: '1' });
const ROLES = { id: async () => '777', forget: () => undefined };
function announcer(over: Partial<Parameters<typeof createAnnouncer>[0]> = {}, cfg = CFG) {
const sent: Sent[] = [];
const a = createAnnouncer({
cfg, repo: freshRepo(), post: fakePost(sent), roles: ROLES,
webhooks: HOOKS, refresh: async (s) => s, wait: async () => undefined,
...over,
});
return { a, sent };
}
// ---------------------------------------------------------------- Entscheidung
test('ein frischer Stream wird angekuendigt', () => {
assert.deepEqual(
decide({ enabled: true, alreadyPosted: false, lastPostAt: null, cooldownHours: 6, now: 0 }),
{ post: true });
});
test('der Kill-Switch haelt', () => {
const d = decide({ enabled: false, alreadyPosted: false, lastPostAt: null, cooldownHours: 6, now: 0 });
assert.equal(d.post, false);
assert.match((d as { reason: string }).reason, /enabled/);
});
test('derselbe Stream wird kein zweites Mal angekuendigt', () => {
const d = decide({ enabled: true, alreadyPosted: true, lastPostAt: null, cooldownHours: 6, now: 0 });
assert.equal(d.post, false);
assert.match((d as { reason: string }).reason, /bereits/);
});
test('ein zweites stream.online kurz nach dem ersten laeuft in die Abklingzeit', () => {
// Der Fall aus dem Betrieb: Twitch vergibt nach einem Aussetzer eine neue
// Stream-ID. Dann greift `alreadyPosted` nicht, und nur die Abklingzeit
// verhindert den zweiten Rundruf.
const vorZehnMinuten = new Date('2026-09-17T18:00:00Z').toISOString();
const d = decide({
enabled: true, alreadyPosted: false, lastPostAt: vorZehnMinuten,
cooldownHours: 6, now: Date.parse('2026-09-17T18:10:00Z'),
});
assert.equal(d.post, false);
assert.match((d as { reason: string }).reason, /Abklingzeit/);
});
test('nach der Abklingzeit geht es wieder', () => {
const d = decide({
enabled: true, alreadyPosted: false, lastPostAt: '2026-09-17T10:00:00Z',
cooldownHours: 6, now: Date.parse('2026-09-17T18:00:00Z'),
});
assert.equal(d.post, true);
});
test('force geht durch die Abklingzeit, aber nicht durch den Kill-Switch', () => {
assert.equal(decide({
enabled: true, alreadyPosted: true, lastPostAt: '2026-09-17T17:59:00Z',
cooldownHours: 6, now: Date.parse('2026-09-17T18:00:00Z'), force: true,
}).post, true);
assert.equal(decide({
enabled: false, alreadyPosted: false, lastPostAt: null,
cooldownHours: 6, now: 0, force: true,
}).post, false);
});
// ---------------------------------------------------------------- Nachricht
test('der Ping steht im Text UND in allowed_mentions', () => {
// Nur im Text faerbt Discord die Erwaehnung ein, benachrichtigt aber
// niemanden. Das ist der Fehler, den man erst nach drei leeren Streams sieht.
const m = buildMessage({ stream: STREAM, roleId: '777' });
assert.match(m.content ?? '', /<@&777>/);
assert.deepEqual(m.allowed_mentions, { parse: [], roles: ['777'] });
});
test('ohne aufgeloeste Rolle geht der Post raus, aber pingt niemanden', () => {
const m = buildMessage({ stream: STREAM });
assert.equal(m.content?.includes('<@&'), false);
assert.deepEqual(m.allowed_mentions, { parse: [] });
assert.equal(m.embeds?.[0]?.title, STREAM.title, 'der Post selbst bleibt vollstaendig');
});
test('ein Entwurf pingt auch dann nicht, wenn die Rolle bekannt ist', () => {
const m = buildMessage({ stream: STREAM, roleId: '777', draft: true });
assert.equal(m.content?.includes('<@&777>'), false);
assert.deepEqual(m.allowed_mentions, { parse: [] });
assert.match(m.content ?? '', /Entwurf/);
});
test('Titel, Kategorie und Link stehen im Embed', () => {
const e = buildMessage({ stream: STREAM }).embeds?.[0];
assert.equal(e?.title, STREAM.title);
assert.equal(e?.url, 'https://twitch.tv/abdulive_');
assert.equal(e?.fields?.[0]?.value, 'Elden Ring');
assert.match(e?.fields?.[1]?.value ?? '', /^<t:\d+:R>$/, 'Startzeit relativ, nicht in MESZ');
});
test('ein Anhang wird per attachment:// referenziert', () => {
const e = buildMessage({ stream: STREAM, attachment: 'vorschau.jpg' }).embeds?.[0];
assert.equal(e?.image?.url, 'attachment://vorschau.jpg');
});
test('Twitchs Vorschau bekommt Groesse und einen Anhaenger gegen den Cache', () => {
const url = thumbnailUrl(STREAM.thumbnailUrl, '48212345');
assert.match(url ?? '', /-1280x720\.jpg\?t=48212345$/);
assert.equal(thumbnailUrl(undefined, 'x'), undefined);
});
test('ein leerer Titel laesst das Embed nicht ohne Titel zurueck', () => {
// Discord lehnt ein Embed mit leerem title ab, und genau das liefert Twitch
// in der ersten Minute nach dem Start.
const e = buildMessage({ stream: { ...STREAM, title: '', category: '' } }).embeds?.[0];
assert.ok((e?.title ?? '').length > 0);
assert.ok((e?.fields?.[0]?.value ?? '').length > 0);
});
test('derselbe Stream bekommt denselben Text, ein anderer einen anderen', () => {
assert.equal(pick(OPENERS, 'abc'), pick(OPENERS, 'abc'));
const texte = new Set(['1', '2', '3', '4', '5', '6', '7', '8'].map((s) => pick(OPENERS, s)));
assert.ok(texte.size > 1, 'die Auswahl darf nicht auf einen Text zusammenfallen');
});
// ---------------------------------------------------------------- Ablauf
test('der Post geht an den Go-Live-Webhook und wird protokolliert', async () => {
const repo = freshRepo();
const { a, sent } = announcer({ repo });
const r = await a.announceNow(STREAM);
assert.equal(r.status, 'gesendet');
assert.equal(sent.length, 1);
assert.equal(sent[0]?.url, 'https://live');
assert.equal(repo.postFor(STREAM.id, 'golive', 'discord')?.status, 'gesendet');
});
test('zweimal derselbe Stream gibt genau einen Post', async () => {
const repo = freshRepo();
const { a, sent } = announcer({ repo });
await a.announceNow(STREAM);
const zweiter = await a.announceNow(STREAM);
assert.equal(sent.length, 1);
assert.equal(zweiter.status, 'uebersprungen');
});
test('im Entwurfsmodus geht es in den internen Kanal', async () => {
const { a, sent } = announcer({}, { ...CFG, mode: 'draft' });
await a.announceNow(STREAM);
assert.equal(sent[0]?.url, 'https://draft');
assert.equal(sent[0]?.message.allowed_mentions?.roles, undefined);
});
test('ohne hinterlegten Webhook wird das gemeldet, nicht verschluckt', async () => {
const repo = freshRepo();
const { a, sent } = announcer({ repo, webhooks: () => ({}) });
const r = await a.announceNow(STREAM);
assert.equal(r.status, 'fehlgeschlagen');
assert.match((r as { reason: string }).reason, /discord:setup/);
assert.equal(sent.length, 0);
assert.equal(repo.postFor(STREAM.id, 'golive', 'discord'), undefined,
'ein Fehlschlag darf den naechsten Versuch nicht sperren');
});
test('ein fehlgeschlagener Post darf wiederholt werden', async () => {
const repo = freshRepo();
const sent: Sent[] = [];
let erster = true;
const a = createAnnouncer({
cfg: CFG, repo, roles: ROLES, webhooks: HOOKS,
refresh: async (s) => s, wait: async () => undefined,
post: async (url, message, files = []) => {
if (erster) { erster = false; throw new Error('Netz weg'); }
sent.push({ url, message, files });
return { id: '1', channel_id: '2' };
},
});
assert.equal((await a.announceNow(STREAM)).status, 'fehlgeschlagen');
assert.equal((await a.announceNow(STREAM)).status, 'gesendet');
assert.equal(sent.length, 1);
});
test('eine nicht aufloesbare Rolle stoppt den Post nicht', async () => {
const { a, sent } = announcer({
roles: { id: async () => { throw new Error('403'); }, forget: () => undefined },
});
const r = await a.announceNow(STREAM);
assert.equal(r.status, 'gesendet');
assert.equal((r as { pinged: boolean }).pinged, false);
assert.equal(sent[0]?.message.content?.includes('<@&'), false);
});
test('der Kill-Switch schickt gar nichts los', async () => {
const { a, sent } = announcer({}, { ...CFG, enabled: false });
assert.equal((await a.announceNow(STREAM)).status, 'uebersprungen');
assert.equal(sent.length, 0);
});
test('ein Probelauf sperrt die naechste echte Ankuendigung nicht', () => {
// Genau das ist am 2026-09-17 passiert: `golive:test --echt` lief 93 Minuten
// vor dem Streamstart, trug sich als gesendeter Post ein, und die
// Abklingzeit hat die richtige Ankuendigung verschluckt. Der Stream lief
// ohne Ping.
const repo = freshRepo();
repo.insertPost({
streamId: 'probe-1', kind: 'golive-test', platform: 'discord',
mode: 'live', status: 'gesendet',
});
// Der Probelauf steht in der Datenbank ...
assert.ok(repo.lastPost('golive-test', 'discord'));
// ... zaehlt aber fuer den Echtbetrieb nicht.
assert.equal(repo.lastPost('golive', 'discord'), undefined);
});
test('zwei echte Ankuendigungen kurz hintereinander bleiben gesperrt', () => {
// Die Abklingzeit soll ja weiter wirken - nur eben nicht gegen Probelaeufe.
const repo = freshRepo();
const { a, sent } = announcer({ repo });
return a.announceNow(STREAM).then(async () => {
const zweiter = await a.announceNow({ ...STREAM, id: 'anderer-stream' });
assert.equal(zweiter.status, 'uebersprungen');
assert.match((zweiter as { reason: string }).reason, /Abklingzeit/);
assert.equal(sent.length, 1);
});
});