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>
This commit is contained in:
1 parent
e87879de9e
commit
c4dc6ee04f
12 files changed
+1098
-18
No files matched your search
@@ -0,0 +1,283 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
// Die Go-Live-Ankuendigung auf Discord.
|
||||
//
|
||||
// Ablauf, wenn der Ingest einen neuen Stream sieht:
|
||||
//
|
||||
// stream.online
|
||||
// -> warten (Titel und Kategorie stehen erst kurz nach dem Start)
|
||||
// -> Titel und Kategorie frisch von Helix holen
|
||||
// -> Vorschaubild: OBS-Screenshot, sonst Twitchs Vorschau
|
||||
// -> pruefen, ob dieser Stream schon angekuendigt wurde
|
||||
// -> Webhook posten, mit Ping an die Rolle Live-Ping
|
||||
// -> Ergebnis in `posts` schreiben
|
||||
//
|
||||
// Der teuerste Fehler hier ist nicht der ausgefallene Post, sondern der
|
||||
// doppelte: wer zweimal in zehn Minuten angepingt wird, nimmt die Rolle ab,
|
||||
// und dann ist der Kanal fuer diese Person fuer immer stumm. Deshalb liegt
|
||||
// die halbe Datei in `decide()`.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { sleep } from '#core/retry.ts';
|
||||
import type { Repos } from '#db/repos.ts';
|
||||
import { Discord } from '#discord/api.ts';
|
||||
import { createRoleResolver, type RoleResolver } from '#discord/roles.ts';
|
||||
import { messageUrl, postWebhook, rolePing, type Attachment, type Embed, type WebhookMessage }
|
||||
from '#discord/webhook.ts';
|
||||
import type { ObsClient } from '#obs/client.ts';
|
||||
import { helix, type HelixStream } from '#twitch/helix.ts';
|
||||
import { readSecrets } from '#twitch/secrets.ts';
|
||||
import { INVITATIONS, OPENERS, pick } from './templates.ts';
|
||||
|
||||
const log = logger('golive');
|
||||
|
||||
/** Twitch-Lila. Dieselbe Farbe wie die Rolle "Abdu" im Bauplan. */
|
||||
const TWITCH_PURPLE = 0x9146ff;
|
||||
|
||||
export type GoLiveConfig = {
|
||||
enabled: boolean;
|
||||
/** `live` postet in die Ankuendigungen, `draft` in den internen Kanal. */
|
||||
mode: 'live' | 'draft';
|
||||
/** Wartezeit nach dem Erkennen. Titel und Kategorie brauchen die. */
|
||||
delaySeconds: number;
|
||||
/** Innerhalb dieser Zeit wird kein zweites Mal angekuendigt. */
|
||||
cooldownHours: number;
|
||||
/** Rolle, die angepingt wird. Leer heisst: Post ohne Ping. */
|
||||
pingRole: string;
|
||||
/** Vorschaubild aus OBS holen. Ohne OBS faellt es auf Twitch zurueck. */
|
||||
obsScreenshot: boolean;
|
||||
};
|
||||
|
||||
export type StreamInfo = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
login: string;
|
||||
startedAt: string;
|
||||
/** Twitchs Vorlage mit {width}/{height} - wird hier eingesetzt. */
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Entscheidung
|
||||
|
||||
export type Decision = { post: true } | { post: false; reason: string };
|
||||
|
||||
/**
|
||||
* Darf dieser Stream angekuendigt werden? Bewusst ohne Netz, Datenbank und
|
||||
* Uhr im Bauch - alles kommt als Wert herein, damit die Faelle pruefbar sind,
|
||||
* die im Betrieb hoechstens einmal im Jahr vorkommen.
|
||||
*/
|
||||
export function decide(input: {
|
||||
enabled: boolean;
|
||||
alreadyPosted: boolean;
|
||||
lastPostAt: string | null;
|
||||
cooldownHours: number;
|
||||
now: number;
|
||||
/** Ein Testpost geht durch die Abklingzeit hindurch, aber nie an die Rolle. */
|
||||
force?: boolean;
|
||||
}): Decision {
|
||||
if (!input.enabled) return { post: false, reason: 'broadcaster.enabled steht auf false' };
|
||||
if (input.force) return { post: true };
|
||||
if (input.alreadyPosted) {
|
||||
return { post: false, reason: 'dieser Stream wurde bereits angekuendigt' };
|
||||
}
|
||||
if (input.lastPostAt) {
|
||||
const since = input.now - Date.parse(input.lastPostAt);
|
||||
const cooldown = input.cooldownHours * 3_600_000;
|
||||
if (Number.isFinite(since) && since < cooldown) {
|
||||
const rest = Math.ceil((cooldown - since) / 60_000);
|
||||
return {
|
||||
post: false,
|
||||
reason: `letzte Ankuendigung ist ${Math.floor(since / 60_000)} min her, ` +
|
||||
`Abklingzeit laeuft noch ${rest} min`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { post: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Nachricht
|
||||
|
||||
/**
|
||||
* Baut die fertige Webhook-Nachricht. Rein - kein Netz, keine Uhr ausser der
|
||||
* uebergebenen. `attachment` ist gesetzt, wenn ein Bild mitgeschickt wird;
|
||||
* dann muss der Dateiname im Anhang genau so heissen.
|
||||
*/
|
||||
export function buildMessage(input: {
|
||||
stream: StreamInfo;
|
||||
/** Aufgeloeste Rollen-ID, oder undefined fuer einen Post ohne Ping. */
|
||||
roleId?: string;
|
||||
/** Name der angehaengten Bilddatei, wenn eines mitgeht. */
|
||||
attachment?: string;
|
||||
/** Fertige Bild-URL, wenn kein Anhang mitgeht (Twitchs Vorschau). */
|
||||
imageUrl?: string;
|
||||
draft?: boolean;
|
||||
}): WebhookMessage {
|
||||
const { stream } = input;
|
||||
const url = `https://twitch.tv/${stream.login}`;
|
||||
const ping = rolePing(input.roleId);
|
||||
const opener = pick(OPENERS, stream.id);
|
||||
const invitation = pick(INVITATIONS, stream.id + 'x');
|
||||
|
||||
// Discord rechnet <t:...:R> in "vor 3 Minuten" um, in der Zeitzone des
|
||||
// Lesenden. Eine feste Uhrzeit waere hier falsch, sobald jemand nicht in
|
||||
// Deutschland sitzt.
|
||||
const startedUnix = Math.floor(Date.parse(stream.startedAt) / 1000);
|
||||
|
||||
const embed: Embed = {
|
||||
author: { name: stream.login, url },
|
||||
title: stream.title || 'Stream läuft',
|
||||
url,
|
||||
description: invitation,
|
||||
color: TWITCH_PURPLE,
|
||||
fields: [
|
||||
{ name: 'Kategorie', value: stream.category || 'ohne Angabe', inline: true },
|
||||
...(Number.isFinite(startedUnix)
|
||||
? [{ name: 'Gestartet', value: `<t:${startedUnix}:R>`, inline: true }]
|
||||
: []),
|
||||
],
|
||||
footer: { text: input.draft ? 'Entwurf - so ginge der Post raus' : 'abdulive Toolkit' },
|
||||
timestamp: new Date(stream.startedAt).toISOString(),
|
||||
};
|
||||
|
||||
const image = input.attachment ? `attachment://${input.attachment}` : input.imageUrl;
|
||||
if (image) embed.image = { url: image };
|
||||
|
||||
const content = [input.draft ? '**Entwurf**' : ping.mention, opener]
|
||||
.filter(Boolean).join(' ').trim();
|
||||
|
||||
return {
|
||||
content,
|
||||
embeds: [embed],
|
||||
// Im Entwurf nie pingen, auch wenn eine Rolle aufgeloest wurde: der
|
||||
// interne Kanal ist zum Ansehen da, nicht zum Benachrichtigen.
|
||||
allowed_mentions: input.draft ? { parse: [] } : ping.allowed_mentions,
|
||||
};
|
||||
}
|
||||
|
||||
/** Twitchs Vorschaubild. Die Vorlage kommt mit Platzhaltern fuer die Groesse. */
|
||||
export function thumbnailUrl(template: string | undefined, cacheBuster: string): string | undefined {
|
||||
if (!template) return undefined;
|
||||
const url = template.replace('{width}', '1280').replace('{height}', '720');
|
||||
// Discord merkt sich Bilder anhand der URL. Ohne diesen Anhang zeigt der
|
||||
// zweite Stream das Vorschaubild des ersten.
|
||||
return `${url}${url.includes('?') ? '&' : '?'}t=${encodeURIComponent(cacheBuster)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Vorschaubild
|
||||
|
||||
/**
|
||||
* Screenshot der laufenden Programmausgabe. Gibt undefined zurueck, wenn OBS
|
||||
* nicht verbunden ist oder nicht antwortet - dann bleibt Twitchs Vorschau, und
|
||||
* die ist kurz nach dem Start haeufig noch schwarz.
|
||||
*/
|
||||
export async function obsScreenshot(obs: ObsClient): Promise<Attachment | undefined> {
|
||||
if (!obs.isConnected()) return undefined;
|
||||
try {
|
||||
const scene = await obs.request<{ currentProgramSceneName?: string; sceneName?: string }>(
|
||||
'GetCurrentProgramScene');
|
||||
const sourceName = scene.sceneName ?? scene.currentProgramSceneName;
|
||||
if (!sourceName) return undefined;
|
||||
|
||||
const shot = await obs.request<{ imageData: string }>('GetSourceScreenshot', {
|
||||
sourceName, imageFormat: 'jpg', imageWidth: 1280,
|
||||
});
|
||||
// OBS liefert eine data:-URL, kein rohes base64.
|
||||
const comma = shot.imageData.indexOf(',');
|
||||
const base64 = comma === -1 ? shot.imageData : shot.imageData.slice(comma + 1);
|
||||
return { name: 'vorschau.jpg', type: 'image/jpeg', data: Buffer.from(base64, 'base64') };
|
||||
} catch (err) {
|
||||
log.warn(`Kein OBS-Screenshot: ${(err as Error).message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ankuendiger
|
||||
|
||||
/**
|
||||
* Unter welcher Art ein Post in `posts` steht.
|
||||
*
|
||||
* Ein Probelauf bekommt eine eigene Art, und das ist kein Schoenheitsfehler:
|
||||
* zaehlt er als echter Post, sperrt er ueber die Abklingzeit die naechste
|
||||
* richtige Ankuendigung. Genau das ist am 2026-09-17 passiert - der Testping
|
||||
* lief 93 Minuten vor dem Streamstart, und die Ankuendigung fiel aus.
|
||||
*/
|
||||
export type PostArt = 'golive' | 'golive-test';
|
||||
|
||||
export type Announcer = {
|
||||
/**
|
||||
* Kuendigt den Stream an. Kehrt sofort zurueck - das Warten und der Post
|
||||
* laufen im Hintergrund, damit der Ingest nicht daran haengt.
|
||||
*/
|
||||
announce: (stream: StreamInfo, opts?: { force?: boolean; immediate?: boolean }) => void;
|
||||
/** Dasselbe, aber wartbar. Fuer den Testbefehl und fuer Tests. */
|
||||
announceNow: (stream: StreamInfo, opts?: { force?: boolean; art?: PostArt }) => Promise<Result>;
|
||||
lastResult: () => Result | null;
|
||||
};
|
||||
|
||||
export type Result =
|
||||
| { status: 'gesendet'; url: string; mode: 'live' | 'draft'; pinged: boolean }
|
||||
| { status: 'uebersprungen'; reason: string }
|
||||
| { status: 'fehlgeschlagen'; reason: string };
|
||||
|
||||
export type AnnouncerDeps = {
|
||||
cfg: GoLiveConfig;
|
||||
repo: Repos;
|
||||
/** Ohne OBS gibt es kein eigenes Vorschaubild, sonst aendert sich nichts. */
|
||||
obs?: ObsClient;
|
||||
/** Fuer Tests: Webhook-Post und Rollenauflösung unterschieben. */
|
||||
post?: typeof postWebhook;
|
||||
roles?: RoleResolver;
|
||||
/** Webhook-URLs. Vorgabe ist der verschluesselte Speicher. */
|
||||
webhooks?: () => { live?: string; draft?: string; guildId?: string };
|
||||
now?: () => number;
|
||||
/** Fuer Tests: Wartezeit ueberspringen. */
|
||||
wait?: (ms: number) => Promise<void>;
|
||||
/** Frische Stream-Daten nach der Wartezeit. Vorgabe ist Helix. */
|
||||
refresh?: (stream: StreamInfo) => Promise<StreamInfo>;
|
||||
};
|
||||
|
||||
function secretWebhooks(): { live?: string; draft?: string; guildId?: string } {
|
||||
const d = readSecrets().discord;
|
||||
return {
|
||||
...(d?.goLiveWebhook ? { live: d.goLiveWebhook } : {}),
|
||||
...(d?.technikWebhook ? { draft: d.technikWebhook } : {}),
|
||||
...(d?.guildId ? { guildId: d.guildId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function helixRefresh(stream: StreamInfo): Promise<StreamInfo> {
|
||||
try {
|
||||
const user = await helix.userByLogin(stream.login);
|
||||
const live: HelixStream | null = user ? await helix.streamByUserId(user.id) : null;
|
||||
if (!live) return stream;
|
||||
const frisch = live.thumbnail_url || stream.thumbnailUrl;
|
||||
return {
|
||||
...stream,
|
||||
title: live.title || stream.title,
|
||||
category: live.game_name || stream.category,
|
||||
...(frisch ? { thumbnailUrl: frisch } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
// Kein Grund, den Post ausfallen zu lassen: die Daten aus dem ersten
|
||||
// Abruf sind selten falsch, nur manchmal leer.
|
||||
log.warn(`Konnte Titel und Kategorie nicht nachladen: ${(err as Error).message}`);
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAnnouncer(deps: AnnouncerDeps): Announcer {
|
||||
const { cfg, repo } = deps;
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const wait = deps.wait ?? ((ms: number) => sleep(ms));
|
||||
const post = deps.post ?? postWebhook;
|
||||
const refresh = deps.refresh ?? helixRefresh;
|
||||
const webhooks = deps.webhooks ?? secretWebhooks;
|
||||
let last: Result | null = null;
|
||||
|
||||
// Der Rollen-Resolver braucht das Bot-Token. Fehlt es, gibt es keinen Ping,
|
||||
// aber sehr wohl einen Post - ein Post ohne Ping ist besser als keiner.
|
||||
let roles: RoleResolver | null = deps.roles ?? null;
|
||||
function roleResolver(): RoleResolver | null {
|
||||
if (roles) return roles;
|
||||
const d = readSecrets().discord;
|
||||
if (!d?.botToken || !d.guildId) return null;
|
||||
// Bremse von einer Minute: hier fragt niemand von Hand nach.
|
||||
roles = createRoleResolver(new Discord(d.botToken), d.guildId, 60_000);
|
||||
return roles;
|
||||
}
|
||||
|
||||
async function run(
|
||||
stream: StreamInfo, opts: { force?: boolean; art?: PostArt } = {},
|
||||
): Promise<Result> {
|
||||
const art: PostArt = opts.art ?? 'golive';
|
||||
const draft = cfg.mode === 'draft';
|
||||
const hooks = webhooks();
|
||||
const target = draft ? hooks.draft : hooks.live;
|
||||
|
||||
const decision = decide({
|
||||
enabled: cfg.enabled,
|
||||
alreadyPosted: Boolean(repo.postFor(stream.id, art, 'discord')),
|
||||
lastPostAt: repo.lastPost(art, 'discord')?.created_at ?? null,
|
||||
cooldownHours: cfg.cooldownHours,
|
||||
now: now(),
|
||||
...(opts.force ? { force: true } : {}),
|
||||
});
|
||||
|
||||
if (!decision.post) {
|
||||
log.info(`Keine Go-Live-Ankuendigung: ${decision.reason}`);
|
||||
last = { status: 'uebersprungen', reason: decision.reason };
|
||||
return last;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
const reason = draft
|
||||
? 'kein Webhook fuer den internen Kanal - einmal `abdulive discord:setup` laufen lassen'
|
||||
: 'kein Go-Live-Webhook hinterlegt - einmal `abdulive discord:setup` laufen lassen';
|
||||
log.warn(`Go-Live-Ankuendigung faellt aus: ${reason}`);
|
||||
last = { status: 'fehlgeschlagen', reason };
|
||||
repo.insertPost({
|
||||
streamId: stream.id, kind: art, platform: 'discord', mode: cfg.mode,
|
||||
status: 'fehlgeschlagen', title: stream.title, category: stream.category, detail: reason,
|
||||
});
|
||||
return last;
|
||||
}
|
||||
|
||||
// Ping erst hier aufloesen: schlaegt es fehl, geht der Post trotzdem raus.
|
||||
let roleId: string | undefined;
|
||||
if (cfg.pingRole && !draft) {
|
||||
try {
|
||||
roleId = await roleResolver()?.id(cfg.pingRole);
|
||||
if (!roleId) {
|
||||
log.warn(`Rolle "${cfg.pingRole}" nicht gefunden - der Post geht ohne Ping raus`);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Rolle nicht aufloesbar (${(err as Error).message}) - Post ohne Ping`);
|
||||
}
|
||||
}
|
||||
|
||||
const files: Attachment[] = [];
|
||||
if (cfg.obsScreenshot && deps.obs) {
|
||||
const shot = await obsScreenshot(deps.obs);
|
||||
if (shot) files.push(shot);
|
||||
}
|
||||
|
||||
// Entweder eigener Screenshot als Anhang oder Twitchs Vorschau als URL -
|
||||
// nie beides, sonst zeigt Discord zwei Bilder.
|
||||
const fallbackImage = files.length === 0
|
||||
? thumbnailUrl(stream.thumbnailUrl, stream.id) : undefined;
|
||||
|
||||
const message = buildMessage({
|
||||
stream,
|
||||
...(roleId ? { roleId } : {}),
|
||||
...(files[0] ? { attachment: files[0].name } : {}),
|
||||
...(fallbackImage ? { imageUrl: fallbackImage } : {}),
|
||||
...(draft ? { draft: true } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
const sent = await post(target, message, files);
|
||||
const url = hooks.guildId ? messageUrl(hooks.guildId, sent) : '';
|
||||
repo.insertPost({
|
||||
streamId: stream.id, kind: art, platform: 'discord', mode: cfg.mode,
|
||||
status: 'gesendet', title: stream.title, category: stream.category, url,
|
||||
});
|
||||
log.info(`Go-Live-Post raus${draft ? ' (Entwurf, interner Kanal)' : ''}` +
|
||||
`${roleId ? `, mit Ping an ${cfg.pingRole}` : ', ohne Ping'}` +
|
||||
`${files.length ? ', mit OBS-Vorschau' : ''}`);
|
||||
last = { status: 'gesendet', url, mode: cfg.mode, pinged: Boolean(roleId) };
|
||||
return last;
|
||||
} catch (err) {
|
||||
const reason = (err as Error).message;
|
||||
log.error(`Go-Live-Post fehlgeschlagen: ${reason}`);
|
||||
repo.insertPost({
|
||||
streamId: stream.id, kind: art, platform: 'discord', mode: cfg.mode,
|
||||
status: 'fehlgeschlagen', title: stream.title, category: stream.category, detail: reason,
|
||||
});
|
||||
last = { status: 'fehlgeschlagen', reason };
|
||||
return last;
|
||||
}
|
||||
}
|
||||
|
||||
async function delayedRun(
|
||||
stream: StreamInfo, opts: { force?: boolean; immediate?: boolean; art?: PostArt },
|
||||
) {
|
||||
if (!opts.immediate && cfg.delaySeconds > 0) {
|
||||
log.info(`Stream erkannt - Ankuendigung in ${cfg.delaySeconds} s ` +
|
||||
'(Titel und Kategorie stehen erst kurz nach dem Start)');
|
||||
await wait(cfg.delaySeconds * 1000);
|
||||
}
|
||||
const fresh = await refresh(stream);
|
||||
return run(fresh, opts);
|
||||
}
|
||||
|
||||
return {
|
||||
announce(stream, opts = {}) {
|
||||
void delayedRun(stream, opts).catch((err: Error) => {
|
||||
log.error(`Ankuendigung abgebrochen: ${err.message}`);
|
||||
last = { status: 'fehlgeschlagen', reason: err.message };
|
||||
});
|
||||
},
|
||||
announceNow: (stream, opts = {}) => delayedRun(stream, { ...opts, immediate: true }),
|
||||
lastResult: () => last,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Textbausteine fuer die Go-Live-Ankuendigung.
|
||||
//
|
||||
// Warum mehrere: eine Ankuendigung, die jeden Donnerstag Wort fuer Wort
|
||||
// dieselbe ist, wird nach drei Wochen nicht mehr gelesen. Das ist kein
|
||||
// Algorithmus-Aberglaube, sondern gilt genauso fuer Menschen, die die
|
||||
// Nachricht in der Seitenleiste ueberfliegen.
|
||||
//
|
||||
// Die Auswahl ist nicht zufaellig, sondern haengt an der Stream-ID: derselbe
|
||||
// Stream bekommt bei einem Wiederholungsversuch denselben Text, und Tests
|
||||
// bekommen ein vorhersagbares Ergebnis. Zwei aufeinanderfolgende Streams
|
||||
// bekommen verschiedene IDs und damit fast immer verschiedene Texte.
|
||||
|
||||
/** Die Zeile ueber dem Embed. Hier steht der Ping, also faellt sie ins Auge. */
|
||||
export const OPENERS = [
|
||||
'Abdu ist live.',
|
||||
'Der Stream läuft.',
|
||||
'Es geht los.',
|
||||
'Abdu ist da.',
|
||||
'Ab jetzt live.',
|
||||
];
|
||||
|
||||
/** Die Zeile im Embed. Darf laenger sein, wird ruhiger gelesen. */
|
||||
export const INVITATIONS = [
|
||||
'Schau vorbei.',
|
||||
'Komm rein, es ist noch Platz.',
|
||||
'Jetzt einschalten.',
|
||||
'Der Chat ist offen.',
|
||||
'Bis gleich im Chat.',
|
||||
];
|
||||
|
||||
/**
|
||||
* Kleiner, stabiler Hash. Kein crypto: er muss nur gleichmaessig streuen und
|
||||
* ueber Neustarts hinweg dasselbe Ergebnis liefern.
|
||||
*/
|
||||
export function pick<T>(list: readonly T[], seed: string): T {
|
||||
if (list.length === 0) throw new Error('Keine Textbausteine hinterlegt.');
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return list[Math.abs(h) % list.length]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platzhalter ersetzen. Unbekannte Platzhalter bleiben stehen - so sieht man
|
||||
* im Ergebnis, dass einer falsch geschrieben ist, statt eine Luecke zu
|
||||
* bekommen, die nach Absicht aussieht.
|
||||
*/
|
||||
export function fill(template: string, values: Record<string, string>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (all, key: string) =>
|
||||
key in values ? values[key]! : all);
|
||||
}
|
||||
Reference in new issue
Block a user