diff --git a/apps/broadcaster/README.md b/apps/broadcaster/README.md index 7416c6c..02ba6e3 100644 --- a/apps/broadcaster/README.md +++ b/apps/broadcaster/README.md @@ -1,5 +1,23 @@ # @abdulive/broadcaster -Go-Live-Ankuendigung auf Discord, Bluesky, Mastodon, plus Instagram-Story-Bild. +Die Go-Live-Ankuendigung auf Discord. + +Wird von `abdulive hub` und von `abdulive ingest` benutzt: sobald ein neuer +Stream erkannt wird, wartet der Ankuendiger eine Minute (Titel und Kategorie +stehen bei Twitch erst kurz nach dem Start), holt die Daten frisch, und postet +ueber den Webhook in `#ankuendigungen` - mit Ping an die Rolle `Live-Ping`. + +Der teuerste Fehler hier ist der doppelte Post: wer zweimal in zehn Minuten +angepingt wird, nimmt sich die Rolle wieder ab. Dagegen steht `decide()` in +`src/golive.ts`, die Tabelle `posts` und eine Abklingzeit von sechs Stunden. + +Probelauf, ohne auf einen Stream zu warten: + +``` +abdulive golive:test # in #technik-log, ohne Ping +abdulive golive:test --echt # in #ankuendigungen, mit Ping +``` Spezifikation: [docs/projects/go-live-broadcaster.md](../../docs/projects/go-live-broadcaster.md) + +Noch nicht gebaut: Bluesky, Mastodon und das Instagram-Story-Bild. diff --git a/apps/broadcaster/src/golive.test.ts b/apps/broadcaster/src/golive.test.ts new file mode 100644 index 0000000..d152673 --- /dev/null +++ b/apps/broadcaster/src/golive.test.ts @@ -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 => { + 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[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 ?? '', /^$/, '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); + }); +}); diff --git a/apps/broadcaster/src/golive.ts b/apps/broadcaster/src/golive.ts new file mode 100644 index 0000000..7d2d46f --- /dev/null +++ b/apps/broadcaster/src/golive.ts @@ -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 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: ``, 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 { + 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; + 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; + /** Frische Stream-Daten nach der Wartezeit. Vorgabe ist Helix. */ + refresh?: (stream: StreamInfo) => Promise; +}; + +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 { + 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 { + 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, + }; +} diff --git a/apps/broadcaster/src/templates.ts b/apps/broadcaster/src/templates.ts new file mode 100644 index 0000000..753721f --- /dev/null +++ b/apps/broadcaster/src/templates.ts @@ -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(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 { + return template.replace(/\{\{(\w+)\}\}/g, (all, key: string) => + key in values ? values[key]! : all); +} diff --git a/packages/db/migrations/002_posts.sql b/packages/db/migrations/002_posts.sql new file mode 100644 index 0000000..baf1a31 --- /dev/null +++ b/packages/db/migrations/002_posts.sql @@ -0,0 +1,34 @@ +-- Was das Toolkit nach draussen geschickt hat. Eine Zeile je Plattform und +-- Anlass. +-- +-- Der Zweck ist nicht Statistik, sondern Verhinderung: Twitch schickt nach +-- einem Verbindungsabbruch ein zweites stream.online, und ohne diese Tabelle +-- bekommt der Discord-Server dann einen zweiten Rundruf. Ein Ping zu viel +-- kostet Abonnenten der Rolle, ein Ping zu wenig kostet nur einen Stream. + +-- stream_id ist bewusst KEIN Fremdschluessel. Ein Post ist die Quittung fuer +-- etwas, das den Rechner verlassen hat; die muss auch dann schreibbar sein, +-- wenn es die Sitzung in `streams` nicht gibt - beim Probelauf von +-- `golive:test` etwa. Ein Fremdschluessel wuerde hier ausgerechnet das +-- Protokoll verhindern, das erklaeren soll, was passiert ist. +CREATE TABLE posts ( + id TEXT PRIMARY KEY, + stream_id TEXT, + kind TEXT NOT NULL, -- golive | recap | clip + platform TEXT NOT NULL, -- discord | bluesky | mastodon | ... + mode TEXT NOT NULL, -- live | draft + created_at TEXT NOT NULL, + status TEXT NOT NULL, -- gesendet | fehlgeschlagen | entwurf + title TEXT, + category TEXT, + url TEXT, -- Nachricht auf der Zielplattform + detail TEXT -- Fehlertext, wenn es schiefging +); + +-- Die Frage, die vor jedem Post gestellt wird: gab es das hier schon? +-- Nur gesendete Posts sperren; ein fehlgeschlagener Versuch darf wiederholt +-- werden, sonst kostet ein Netzwerkaussetzer die Ankuendigung des Streams. +CREATE UNIQUE INDEX idx_posts_einmal + ON posts(stream_id, kind, platform) + WHERE stream_id IS NOT NULL AND status = 'gesendet'; +CREATE INDEX idx_posts_kind ON posts(kind, created_at); diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 6323ee1..d5a57bb 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -19,6 +19,12 @@ export type ClipRow = { status: string; score: number | null; }; +export type PostRow = { + id: string; stream_id: string | null; kind: string; platform: string; + mode: string; created_at: string; status: string; + title: string | null; category: string | null; url: string | null; detail: string | null; +}; + export type StreamRow = { id: string; started_at: string; ended_at: string | null; peak_viewers: number; unique_chatters: number; @@ -84,6 +90,21 @@ export function repos(db: Db) { clipsOfStream: db.prepare( 'SELECT * FROM clips WHERE stream_id IS ? ORDER BY created_at DESC' ), + insertPost: db.prepare(` + INSERT INTO posts + (id, stream_id, kind, platform, mode, created_at, status, title, category, url, detail) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + `), + postFor: db.prepare(` + SELECT * FROM posts + WHERE stream_id IS ? AND kind = ? AND platform = ? AND status = 'gesendet' + LIMIT 1 + `), + lastPost: db.prepare(` + SELECT * FROM posts + WHERE kind = ? AND platform = ? AND status = 'gesendet' + ORDER BY created_at DESC LIMIT 1 + `), stats: db.prepare(` SELECT (SELECT COUNT(*) FROM chat_messages) AS messages, @@ -160,6 +181,37 @@ export function repos(db: Db) { .run(...keys.map((k) => fields[k] ?? null), id); }, + /** + * Haelt fest, was rausgegangen ist. Wirft nicht, wenn der eindeutige Index + * zuschlaegt - dann war jemand anders schneller, und genau das soll er. + */ + insertPost(p: { + streamId: string | null; kind: string; platform: string; mode: string; + status: string; title?: string | null; category?: string | null; + url?: string | null; detail?: string | null; + }): string | null { + const id = ulid(); + try { + stmt.insertPost.run(id, p.streamId, p.kind, p.platform, p.mode, + new Date().toISOString(), p.status, + p.title ?? null, p.category ?? null, p.url ?? null, p.detail ?? null); + return id; + } catch (err) { + if (/UNIQUE/i.test((err as Error).message)) return null; + throw err; + } + }, + + /** Gab es fuer diesen Stream schon einen gesendeten Post dieser Art? */ + postFor(streamId: string | null, kind: string, platform: string) { + return stmt.postFor.get(streamId, kind, platform) as PostRow | undefined; + }, + + /** Der letzte gesendete Post dieser Art - fuer die Abklingzeit. */ + lastPost(kind: string, platform: string) { + return stmt.lastPost.get(kind, platform) as PostRow | undefined; + }, + getClip(id: string) { return stmt.getClip.get(id) as ClipRow | undefined; }, clipsOfStream(streamId: string | null) { return stmt.clipsOfStream.all(streamId) as ClipRow[]; diff --git a/packages/discord/src/plan.test.ts b/packages/discord/src/plan.test.ts index 4db36d3..026d3dc 100644 --- a/packages/discord/src/plan.test.ts +++ b/packages/discord/src/plan.test.ts @@ -150,8 +150,20 @@ test('alle internen Kanaele liegen wirklich in der internen Kategorie', () => { }); test('jeder Webhook-Platz kommt genau einmal vor', () => { + // Zweimal derselbe Platz hiesse: der zweite Kanal ueberschreibt die URL des + // ersten im Zugangsdaten-Speicher, und einer der beiden postet ins Leere. const slots = CHANNELS.flatMap((c) => (c.webhooks ?? []).map((w) => w.slot)); - assert.deepEqual([...slots].sort(), ['clipsWebhook', 'goLiveWebhook', 'recapWebhook']); + assert.deepEqual([...slots].sort(), + ['clipsWebhook', 'goLiveWebhook', 'recapWebhook', 'technikWebhook']); + assert.equal(new Set(slots).size, slots.length); +}); + +test('der Entwurfsmodus postet in einen Kanal, den Mitglieder nicht sehen', () => { + // `broadcaster.mode: draft` schickt dieselbe Nachricht wie im Betrieb, + // nur ohne Ping. Laege der Kanal oeffentlich, waere jeder Probelauf eine + // falsche Ankuendigung im Server. + const kanal = CHANNELS.find((c) => (c.webhooks ?? []).some((w) => w.slot === 'technikWebhook')); + assert.equal(kanal?.category, 'intern'); }); test('Webhooks haengen nur an Kanaelen, in denen Mitglieder nicht dazwischenfunken', () => { diff --git a/packages/discord/src/plan.ts b/packages/discord/src/plan.ts index e41ab3f..6244a05 100644 --- a/packages/discord/src/plan.ts +++ b/packages/discord/src/plan.ts @@ -21,7 +21,7 @@ import { CH } from './api.ts'; // und der erste Eindruck eines Servers ist der Text darin. /** Schluessel, unter denen eine Webhook-URL im Zugangsdaten-Speicher landet. */ -export type WebhookSlot = 'goLiveWebhook' | 'recapWebhook' | 'clipsWebhook'; +export type WebhookSlot = 'goLiveWebhook' | 'recapWebhook' | 'clipsWebhook' | 'technikWebhook'; export type RolePlan = { name: string; @@ -236,6 +236,10 @@ export const CHANNELS: ChannelPlan[] = [ type: CH.TEXT, category: 'intern', topic: 'Meldungen des Toolkits: Probeläufe, Fehler, AutoMod-Treffer.', + // Der Entwurfsmodus des Go-Live-Posts landet hier: dieselbe Nachricht, + // die sonst in die Ankuendigungen ginge, nur ohne Ping und nur fuer Mods. + // So sieht man das Ergebnis, bevor es der Server sieht. + webhooks: [{ name: 'Technik', slot: 'technikWebhook' }], }, ]; diff --git a/packages/discord/src/roles.ts b/packages/discord/src/roles.ts new file mode 100644 index 0000000..1c23685 --- /dev/null +++ b/packages/discord/src/roles.ts @@ -0,0 +1,54 @@ +// Rollennamen zu Rollen-IDs. Der Bauplan verweist auf Namen, Discord auf IDs. +// +// Bewusst eigenstaendig: die Knopfleiste braucht das, und der Go-Live-Ping +// braucht es auch - der muss `<@&ID>` in die Nachricht schreiben, weil ein +// Webhook eine Rolle nicht ueber ihren Namen anpingen kann. + +import type { ApiRole, Rest } from './api.ts'; + +export type RoleResolver = { + /** + * Gross-/Kleinschreibung spielt keine Rolle. Ein Fehlschlag laedt die Liste + * einmal neu, bevor er `undefined` zurueckgibt: eine Rolle kann nach dem + * Start des Daemons angelegt worden sein, und ein Neustart ist eine + * schlechte Antwort auf eine neue Rolle. + */ + id: (name: string) => Promise; + /** Vergisst die gemerkte Liste - fuer Tests und nach einem Umbau. */ + forget: () => void; +}; + +/** + * `reloadAfterMs` bremst das Nachladen nach einem Fehlschlag. Vorgabe ist 0: + * jeder Fehlschlag laedt neu, denn ein Klick kommt von einem Menschen und ist + * selten. Wer im Hintergrund fragt - der Go-Live-Ping etwa - setzt eine + * Bremse, sonst fragt ein Tippfehler im Bauplan im Sekundentakt bei Discord + * nach, und zwar genau dann, wenn es dort keine Antwort gibt. + */ +export function createRoleResolver( + api: Rest, guildId: string, reloadAfterMs = 0 +): RoleResolver { + let cache = new Map(); + let loaded = false; + let lastLoad = 0; + + async function load(): Promise { + const roles = await api.get(`/guilds/${guildId}/roles`); + cache = new Map(roles.map((r) => [r.name.toLowerCase(), r.id])); + loaded = true; + lastLoad = Date.now(); + } + + return { + async id(name) { + const key = name.toLowerCase(); + if (!loaded) await load(); + const hit = cache.get(key); + if (hit) return hit; + if (reloadAfterMs > 0 && Date.now() - lastLoad < reloadAfterMs) return undefined; + await load(); + return cache.get(key); + }, + forget() { loaded = false; cache = new Map(); }, + }; +} diff --git a/packages/discord/src/selfroles.ts b/packages/discord/src/selfroles.ts index e7c5472..9bc8e41 100644 --- a/packages/discord/src/selfroles.ts +++ b/packages/discord/src/selfroles.ts @@ -7,9 +7,10 @@ // Knopf unter dem Willkommenstext. import { logger } from '#core/logger.ts'; -import type { ApiRole, RestFull } from './api.ts'; +import type { RestFull } from './api.ts'; import type { Interaction } from './gateway.ts'; import { SELF_ROLES, type SelfRolePlan } from './plan.ts'; +import { createRoleResolver } from './roles.ts'; const log = logger('discord:rollen'); @@ -42,19 +43,9 @@ export type RoleButtons = { }; export function createRoleButtons(api: RestFull, guildId: string): RoleButtons { - // Rollennamen einmal aufloesen und merken. Kommt ein Name nicht vor, wird - // einmal neu geladen - eine Rolle kann nach dem Start angelegt worden sein. - let cache: Map | null = null; - - async function roleId(name: string, force = false): Promise { - if (!cache || force) { - const roles = await api.get(`/guilds/${guildId}/roles`); - cache = new Map(roles.map((r) => [r.name.toLowerCase(), r.id])); - } - const hit = cache.get(name.toLowerCase()); - if (hit || force) return hit; - return roleId(name, true); - } + // Ohne Bremse: ein Klick kommt von einem Menschen, und wenn eine Rolle + // gerade erst angelegt wurde, soll der naechste Klick sie finden. + const roles = createRoleResolver(api, guildId); async function reply(interaction: Interaction, content: string): Promise { await api.post(`/interactions/${interaction.id}/${interaction.token}/callback`, { @@ -78,7 +69,7 @@ export function createRoleButtons(api: RestFull, guildId: string): RoleButtons { } try { - const id = await roleId(plan.role); + const id = await roles.id(plan.role); if (!id) { await reply(interaction, `Die Rolle **${plan.role}** gibt es gerade nicht. Sag einem Mod Bescheid.`); log.error(`Rolle "${plan.role}" nicht gefunden - Bauplan und Server laufen auseinander`); diff --git a/packages/discord/src/webhook.ts b/packages/discord/src/webhook.ts new file mode 100644 index 0000000..89b530b --- /dev/null +++ b/packages/discord/src/webhook.ts @@ -0,0 +1,159 @@ +// Posten ueber einen Webhook. Kein Bot-Token, kein Gateway - die URL allein +// genuegt, und genau deshalb legt `discord:setup` sie an. +// +// Eigener Client statt `Discord` aus api.ts: Webhooks haben eigene +// Drosselungs-Eimer, brauchen keinen Authorization-Kopf, und sie koennen als +// Einzige eine Datei anhaengen. Gemeinsam ist nur der Umgang mit 429. + +import { logger } from '#core/logger.ts'; +import { retry, sleep } from '#core/retry.ts'; + +const log = logger('discord:webhook'); + +/** + * Laenger warten wir nicht. Ein Go-Live-Post, der eine Viertelstunde spaeter + * kommt, ist schlimmer als gar keiner - dann steht in den Ankuendigungen + * "jetzt live", waehrend der Stream laeuft und keiner mehr kommt. + */ +const MAX_WAIT_MS = 30_000; + +export type Embed = { + title?: string; + description?: string; + url?: string; + color?: number; + timestamp?: string; + footer?: { text: string; icon_url?: string }; + image?: { url: string }; + thumbnail?: { url: string }; + author?: { name: string; url?: string; icon_url?: string }; + fields?: Array<{ name: string; value: string; inline?: boolean }>; +}; + +export type WebhookMessage = { + content?: string; + embeds?: Embed[]; + username?: string; + avatar_url?: string; + /** + * Ohne dieses Feld entscheidet Discord selbst, was aus `@everyone` und + * `<@&...>` im Text wird. Wir entscheiden das immer selbst - ein Rundruf + * aus Versehen ist der einzige Fehler hier, den man nicht zuruecknehmen kann. + */ + allowed_mentions?: { parse: string[]; roles?: string[]; users?: string[] }; + /** Komponenten braucht der Go-Live-Post nicht, der Willkommenstext schon. */ + components?: Array>; +}; + +export type Attachment = { name: string; type: string; data: Uint8Array }; + +/** Was Discord von einer gesendeten Nachricht zurueckmeldet. */ +export type PostedMessage = { id: string; channel_id: string }; + +/** + * Baut eine Erwaehnung, die auch wirklich pingt. + * + * `<@&ID>` allein reicht nicht: ohne `allowed_mentions.roles` faerbt Discord + * die Erwaehnung zwar ein, schickt aber keine Benachrichtigung. Das ist der + * Fehler, den man erst bemerkt, wenn drei Streams lang niemand gekommen ist. + */ +export type Ping = { + /** Was in den Text gehoert. Leer, wenn es nichts zu pingen gibt. */ + mention: string; + allowed_mentions: { parse: string[]; roles?: string[] }; +}; + +export function rolePing(roleId: string | undefined): Ping { + if (!roleId) return { mention: '', allowed_mentions: { parse: [] } }; + return { mention: `<@&${roleId}>`, allowed_mentions: { parse: [], roles: [roleId] } }; +} + +function isWebhookUrl(url: string): boolean { + return /^https:\/\/(canary\.|ptb\.)?discord\.com\/api\/webhooks\/\d+\/[\w-]+/.test(url); +} + +/** + * Schickt die Nachricht. Mit `?wait=true`, damit Discord die angelegte + * Nachricht zurueckgibt - ohne das kommt ein 204 ohne Inhalt, und wir koennten + * hinterher nicht sagen, wo der Post steht. + */ +export async function postWebhook( + url: string, + message: WebhookMessage, + files: Attachment[] = [] +): Promise { + if (!isWebhookUrl(url)) { + // Die URL kommt aus dem verschluesselten Speicher. Steht dort etwas + // anderes, ist das ein Einrichtungsfehler und kein Netzwerkproblem. + throw new Error('Das ist keine Discord-Webhook-URL.'); + } + + const target = `${url}${url.includes('?') ? '&' : '?'}wait=true`; + + return await retry(async () => { + let body: BodyInit; + let headers: Record = {}; + + if (files.length === 0) { + body = JSON.stringify(message); + headers = { 'content-type': 'application/json' }; + } else { + // Mit Anhang verlangt Discord multipart: die Nachricht als + // `payload_json`, die Dateien als `files[n]`. Die Namen in + // `attachment://...` eines Embeds muessen exakt den Dateinamen treffen. + const form = new FormData(); + form.set('payload_json', JSON.stringify(message)); + files.forEach((f, i) => { + form.set(`files[${i}]`, new File([f.data as BlobPart], f.name, { type: f.type })); + }); + body = form; + } + + const res = await fetch(target, { + method: 'POST', + headers: { 'User-Agent': 'abdulive-toolkit (https://git.zeasy.dev/ZeasyAlex/abdulive)', ...headers }, + body, + }); + + if (res.status === 429) { + const info = await res.json().catch(() => ({})) as { retry_after?: number }; + const waitMs = Math.max(1000, (info.retry_after ?? 1) * 1000 + 100); + if (waitMs > MAX_WAIT_MS) { + throw Object.assign(new Error( + `Discord verlangt ${Math.round(waitMs / 1000)} s Pause - Post faellt aus` + ), { final: true }); + } + log.warn(`Rate Limit am Webhook, warte ${Math.round(waitMs)} ms`); + await sleep(waitMs); + throw new Error('Rate Limit erreicht'); + } + + if (!res.ok) { + const text = (await res.text()).slice(0, 300); + const err = Object.assign( + new Error(`Webhook: HTTP ${res.status} ${text}`), + // 401/403/404 heisst: Webhook geloescht oder Kanal weg. Wiederholen + // hilft nicht, und der Text soll sagen, was zu tun ist. + res.status === 401 || res.status === 403 || res.status === 404 + ? { final: true } : {} + ); + if (res.status === 404) { + err.message += ' - der Webhook existiert nicht mehr. Neu anlegen mit: abdulive discord:setup'; + } + throw err; + } + + return await res.json() as PostedMessage; + }, { + attempts: 4, + onRetry: (err, n, ms) => { + if ((err as { final?: boolean }).final) throw err; + log.warn(`Wiederhole den Webhook-Post (${n}) in ${ms} ms`, { err: String(err) }); + }, + }); +} + +/** Link auf eine gepostete Nachricht - fuers Protokoll und die Statusseite. */ +export function messageUrl(guildId: string, m: PostedMessage): string { + return `https://discord.com/channels/${guildId}/${m.channel_id}/${m.id}`; +} diff --git a/packages/twitch/src/secrets.ts b/packages/twitch/src/secrets.ts index 23c9150..6048352 100644 --- a/packages/twitch/src/secrets.ts +++ b/packages/twitch/src/secrets.ts @@ -41,6 +41,24 @@ export type SecretStore = { goLiveWebhook?: string; recapWebhook?: string; clipsWebhook?: string; + /** Interner Kanal: Entwuerfe und Fehlermeldungen, nur fuer Mods sichtbar. */ + technikWebhook?: string; + }; + /** + * Zugang zur Weboberflaeche. Erst noetig, seit der Dienst nicht mehr auf + * 127.0.0.1 haengt - vorher war "wer am Rechner sitzt, darf" die Regel. + */ + web?: { + passwordHash?: string; + passwordSalt?: string; + /** Steht in der Adresse jeder Browser-Source. Darf nur lesen. */ + overlayKey?: string; + /** + * Signiert die Sitzungs-Cookies. Liegt hier und nicht im Arbeitsspeicher, + * damit ein Neustart niemanden abmeldet - der Dienst startet bei jedem + * Ausrollen neu. + */ + sessionKey?: string; }; bluesky?: { handle: string; appPassword: string }; /** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */