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
@@ -1,5 +1,23 @@
|
|||||||
# @abdulive/broadcaster
|
# @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)
|
Spezifikation: [docs/projects/go-live-broadcaster.md](../../docs/projects/go-live-broadcaster.md)
|
||||||
|
|
||||||
|
Noch nicht gebaut: Bluesky, Mastodon und das Instagram-Story-Bild.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -19,6 +19,12 @@ export type ClipRow = {
|
|||||||
status: string; score: number | null;
|
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 = {
|
export type StreamRow = {
|
||||||
id: string; started_at: string; ended_at: string | null;
|
id: string; started_at: string; ended_at: string | null;
|
||||||
peak_viewers: number; unique_chatters: number;
|
peak_viewers: number; unique_chatters: number;
|
||||||
@@ -84,6 +90,21 @@ export function repos(db: Db) {
|
|||||||
clipsOfStream: db.prepare(
|
clipsOfStream: db.prepare(
|
||||||
'SELECT * FROM clips WHERE stream_id IS ? ORDER BY created_at DESC'
|
'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(`
|
stats: db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT COUNT(*) FROM chat_messages) AS messages,
|
(SELECT COUNT(*) FROM chat_messages) AS messages,
|
||||||
@@ -160,6 +181,37 @@ export function repos(db: Db) {
|
|||||||
.run(...keys.map((k) => fields[k] ?? null), id);
|
.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; },
|
getClip(id: string) { return stmt.getClip.get(id) as ClipRow | undefined; },
|
||||||
clipsOfStream(streamId: string | null) {
|
clipsOfStream(streamId: string | null) {
|
||||||
return stmt.clipsOfStream.all(streamId) as ClipRow[];
|
return stmt.clipsOfStream.all(streamId) as ClipRow[];
|
||||||
|
|||||||
@@ -150,8 +150,20 @@ test('alle internen Kanaele liegen wirklich in der internen Kategorie', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('jeder Webhook-Platz kommt genau einmal vor', () => {
|
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));
|
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', () => {
|
test('Webhooks haengen nur an Kanaelen, in denen Mitglieder nicht dazwischenfunken', () => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { CH } from './api.ts';
|
|||||||
// und der erste Eindruck eines Servers ist der Text darin.
|
// und der erste Eindruck eines Servers ist der Text darin.
|
||||||
|
|
||||||
/** Schluessel, unter denen eine Webhook-URL im Zugangsdaten-Speicher landet. */
|
/** 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 = {
|
export type RolePlan = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -236,6 +236,10 @@ export const CHANNELS: ChannelPlan[] = [
|
|||||||
type: CH.TEXT,
|
type: CH.TEXT,
|
||||||
category: 'intern',
|
category: 'intern',
|
||||||
topic: 'Meldungen des Toolkits: Probeläufe, Fehler, AutoMod-Treffer.',
|
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' }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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<string | undefined>;
|
||||||
|
/** 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<string, string>();
|
||||||
|
let loaded = false;
|
||||||
|
let lastLoad = 0;
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
const roles = await api.get<ApiRole[]>(`/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(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,9 +7,10 @@
|
|||||||
// Knopf unter dem Willkommenstext.
|
// Knopf unter dem Willkommenstext.
|
||||||
|
|
||||||
import { logger } from '#core/logger.ts';
|
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 type { Interaction } from './gateway.ts';
|
||||||
import { SELF_ROLES, type SelfRolePlan } from './plan.ts';
|
import { SELF_ROLES, type SelfRolePlan } from './plan.ts';
|
||||||
|
import { createRoleResolver } from './roles.ts';
|
||||||
|
|
||||||
const log = logger('discord:rollen');
|
const log = logger('discord:rollen');
|
||||||
|
|
||||||
@@ -42,19 +43,9 @@ export type RoleButtons = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function createRoleButtons(api: RestFull, guildId: string): RoleButtons {
|
export function createRoleButtons(api: RestFull, guildId: string): RoleButtons {
|
||||||
// Rollennamen einmal aufloesen und merken. Kommt ein Name nicht vor, wird
|
// Ohne Bremse: ein Klick kommt von einem Menschen, und wenn eine Rolle
|
||||||
// einmal neu geladen - eine Rolle kann nach dem Start angelegt worden sein.
|
// gerade erst angelegt wurde, soll der naechste Klick sie finden.
|
||||||
let cache: Map<string, string> | null = null;
|
const roles = createRoleResolver(api, guildId);
|
||||||
|
|
||||||
async function roleId(name: string, force = false): Promise<string | undefined> {
|
|
||||||
if (!cache || force) {
|
|
||||||
const roles = await api.get<ApiRole[]>(`/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);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reply(interaction: Interaction, content: string): Promise<void> {
|
async function reply(interaction: Interaction, content: string): Promise<void> {
|
||||||
await api.post(`/interactions/${interaction.id}/${interaction.token}/callback`, {
|
await api.post(`/interactions/${interaction.id}/${interaction.token}/callback`, {
|
||||||
@@ -78,7 +69,7 @@ export function createRoleButtons(api: RestFull, guildId: string): RoleButtons {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = await roleId(plan.role);
|
const id = await roles.id(plan.role);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
await reply(interaction, `Die Rolle **${plan.role}** gibt es gerade nicht. Sag einem Mod Bescheid.`);
|
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`);
|
log.error(`Rolle "${plan.role}" nicht gefunden - Bauplan und Server laufen auseinander`);
|
||||||
|
|||||||
@@ -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<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<PostedMessage> {
|
||||||
|
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<string, string> = {};
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -41,6 +41,24 @@ export type SecretStore = {
|
|||||||
goLiveWebhook?: string;
|
goLiveWebhook?: string;
|
||||||
recapWebhook?: string;
|
recapWebhook?: string;
|
||||||
clipsWebhook?: 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 };
|
bluesky?: { handle: string; appPassword: string };
|
||||||
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
|
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
|
||||||
|
|||||||
Reference in new issue
Block a user