Compare commits
7
Commits
e87879de9e
...
0a3d462854
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a3d462854 | ||
|
|
24f0528ff6 | ||
|
|
70928235b6 | ||
|
|
bc2c8f6cbe | ||
|
|
3b606e36fb | ||
|
|
e18336c57f | ||
|
|
c4dc6ee04f |
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);
|
||||||
|
}
|
||||||
+185
-1
@@ -646,6 +646,177 @@ Was jetzt noch von Hand gehoert:
|
|||||||
verbinden, dann bekommen Subs automatisch eine Rolle.`);
|
verbinden, dann bekommen Subs automatisch eine Rolle.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- overlay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay-Server allein starten. Zum Ansehen und Einrichten, ohne dass der
|
||||||
|
* ganze Hub laeuft. Mit --demo laeuft erfundener Betrieb: anders koennte man
|
||||||
|
* eine Raid-Einblendung nur pruefen, indem man auf einen echten Raid wartet.
|
||||||
|
*/
|
||||||
|
async function overlayOnly(args: string[]): Promise<void> {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const { startOverlayServer } = await import('../overlay-server/src/server.ts');
|
||||||
|
const { createBus } = await import('../overlay-server/src/bus.ts');
|
||||||
|
|
||||||
|
const bus = createBus();
|
||||||
|
const abort = new AbortController();
|
||||||
|
process.on('SIGINT', () => { abort.abort(); process.exit(0); });
|
||||||
|
|
||||||
|
startOverlayServer({
|
||||||
|
port: cfg.overlay.port,
|
||||||
|
channel: cfg.channel.login,
|
||||||
|
publicUrl: cfg.publicUrl,
|
||||||
|
bus,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.includes('--demo')) {
|
||||||
|
const { startDemo } = await import('../overlay-server/src/demo.ts');
|
||||||
|
startDemo(bus, abort.signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wo = cfg.publicUrl || `http://127.0.0.1:${cfg.overlay.port}`;
|
||||||
|
console.log(`
|
||||||
|
Uebersicht mit allen Adressen fuer OBS: ${wo}/`);
|
||||||
|
console.log(`${C.dim}Beenden mit Strg+C.${C.off}`);
|
||||||
|
await new Promise(() => { /* laeuft bis Strg+C */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Passwort fuer die Weboberflaeche setzen. Nur hier, nie ueber das Netz. */
|
||||||
|
async function webPasswort(args: string[]): Promise<void> {
|
||||||
|
const { setzePasswort } = await import('../overlay-server/src/auth.ts');
|
||||||
|
const pw = args[0] || process.env.ABDULIVE_WEB_PASSWORT ||
|
||||||
|
await promptHidden('Neues Passwort (Eingabe wird nicht angezeigt): ');
|
||||||
|
setzePasswort(pw.trim());
|
||||||
|
console.log(`${C.ok}Passwort gesetzt.${C.off} Angemeldet bleibt man 30 Tage.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- golive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probelauf der Go-Live-Ankuendigung.
|
||||||
|
*
|
||||||
|
* Ohne Argument geht der Post in den internen Kanal und pingt niemanden -
|
||||||
|
* damit laesst sich das Aussehen pruefen, ohne den Server aufzuwecken. Mit
|
||||||
|
* `--echt` geht er dorthin, wo er im Betrieb hinginge, mit Ping. Das ist der
|
||||||
|
* einzige Weg, vor dem ersten Stream zu sehen, ob der Ping wirklich ankommt;
|
||||||
|
* eine Erwaehnung ohne `allowed_mentions` sieht naemlich genauso aus wie eine
|
||||||
|
* mit, benachrichtigt aber niemanden.
|
||||||
|
*/
|
||||||
|
async function goLiveTest(args: string[]): Promise<void> {
|
||||||
|
const echt = args.includes('--echt');
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const { createAnnouncer } = await import('../broadcaster/src/golive.ts');
|
||||||
|
const { repos } = await import('#db/repos.ts');
|
||||||
|
const { helix } = await import('#twitch/helix.ts');
|
||||||
|
const { readSecrets } = await import('#twitch/secrets.ts');
|
||||||
|
|
||||||
|
const discord = readSecrets().discord;
|
||||||
|
const ziel = echt ? discord?.goLiveWebhook : discord?.technikWebhook;
|
||||||
|
if (!ziel) {
|
||||||
|
throw new Error(echt
|
||||||
|
? 'Kein Go-Live-Webhook hinterlegt. Einmal `abdulive discord:setup` laufen lassen.'
|
||||||
|
: 'Kein Webhook fuer den internen Kanal. Einmal `abdulive discord:setup` laufen lassen ' +
|
||||||
|
'- der Technik-Webhook ist seit Fassung 0.6 im Bauplan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn Abdu gerade live ist, wird der echte Stream angekuendigt. Sonst ein
|
||||||
|
// erfundener - das prueft denselben Weg, nur mit anderem Text.
|
||||||
|
let stream = {
|
||||||
|
id: `probe-${Date.now()}`,
|
||||||
|
title: 'Probelauf der Go-Live-Ankuendigung',
|
||||||
|
category: 'Just Chatting',
|
||||||
|
login: cfg.channel.login,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
} as import('../broadcaster/src/golive.ts').StreamInfo;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await helix.userByLogin(cfg.channel.login);
|
||||||
|
const live = user ? await helix.streamByUserId(user.id) : null;
|
||||||
|
if (live) {
|
||||||
|
stream = {
|
||||||
|
id: `probe-${live.id}-${Date.now()}`,
|
||||||
|
title: live.title,
|
||||||
|
category: live.game_name,
|
||||||
|
login: cfg.channel.login,
|
||||||
|
startedAt: live.started_at,
|
||||||
|
thumbnailUrl: live.thumbnail_url,
|
||||||
|
};
|
||||||
|
console.log(`${C.dim}Stream laeuft gerade - der Probelauf nimmt die echten Daten.${C.off}`);
|
||||||
|
} else {
|
||||||
|
console.log(`${C.dim}Kein laufender Stream - der Probelauf nimmt erfundene Daten.${C.off}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Haeufigster Fall ist nicht "Netz weg", sondern "noch keine Twitch-App
|
||||||
|
// hinterlegt". Der Satz soll das sagen, sonst sucht man am falschen Ende.
|
||||||
|
console.log(`${C.warn}Twitch nicht abgefragt:${C.off} ${(err as Error).message}`);
|
||||||
|
console.log(`${C.dim}Der Probelauf nimmt erfundene Daten - der Weg zum Webhook ` +
|
||||||
|
`wird trotzdem vollstaendig geprueft.${C.off}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OBS nur dazunehmen, wenn es laeuft: sonst wartet der Screenshot ins Leere.
|
||||||
|
let obs: Awaited<ReturnType<typeof withObs>> | null = null;
|
||||||
|
if (cfg.broadcaster.obsScreenshot && await tcpProbe(OBS_HOST, OBS_PORT, 1500)) {
|
||||||
|
obs = await withObs();
|
||||||
|
}
|
||||||
|
|
||||||
|
const announcer = createAnnouncer({
|
||||||
|
cfg: {
|
||||||
|
enabled: true,
|
||||||
|
mode: echt ? 'live' : 'draft',
|
||||||
|
delaySeconds: 0,
|
||||||
|
cooldownHours: cfg.broadcaster.cooldownHours,
|
||||||
|
pingRole: cfg.broadcaster.pingRole,
|
||||||
|
obsScreenshot: Boolean(obs),
|
||||||
|
},
|
||||||
|
repo: repos(openDb()),
|
||||||
|
...(obs ? { obs: obs.client } : {}),
|
||||||
|
// Ein Probelauf geht durch die Abklingzeit hindurch, sonst koennte man ihn
|
||||||
|
// nach einem echten Stream sechs Stunden lang nicht wiederholen.
|
||||||
|
refresh: async (s) => s,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Als eigene Art protokollieren: sonst sperrt der Probelauf ueber die
|
||||||
|
// Abklingzeit die naechste echte Ankuendigung.
|
||||||
|
const result = await announcer.announceNow(stream, { force: true, art: 'golive-test' });
|
||||||
|
obs?.stop();
|
||||||
|
|
||||||
|
if (result.status === 'gesendet') {
|
||||||
|
const wohin = result.mode === 'draft' ? 'interner Kanal' : 'Ankuendigungen';
|
||||||
|
const ping = result.pinged ? `mit Ping an "${cfg.broadcaster.pingRole}"` : 'ohne Ping';
|
||||||
|
console.log(`
|
||||||
|
${C.ok}Post ist raus${C.off} (${wohin}, ${ping}).`);
|
||||||
|
if (result.url) console.log(` ${result.url}`);
|
||||||
|
if (echt && !result.pinged) {
|
||||||
|
console.log(`
|
||||||
|
${C.warn}Kein Ping gesetzt.${C.off} Pruefen: gibt es die Rolle ` +
|
||||||
|
`"${cfg.broadcaster.pingRole}" auf dem Server, und ist ein Bot-Token hinterlegt?`);
|
||||||
|
}
|
||||||
|
if (!echt) console.log(`
|
||||||
|
${C.dim}Mit --echt geht derselbe Post in die Ankuendigungen, ` +
|
||||||
|
`mit Ping.${C.off}`);
|
||||||
|
} else {
|
||||||
|
console.log(`
|
||||||
|
${C.bad}${result.status}${C.off}: ${result.reason}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OBS kurz verbinden, damit ein Screenshot moeglich ist. */
|
||||||
|
async function withObs() {
|
||||||
|
const { createObs } = await import('#obs/client.ts');
|
||||||
|
const { readSecrets } = await import('#twitch/secrets.ts');
|
||||||
|
const abort = new AbortController();
|
||||||
|
const client = createObs(`ws://${OBS_HOST}:${OBS_PORT}`,
|
||||||
|
() => readSecrets().obs?.password, {});
|
||||||
|
void client.run(abort.signal);
|
||||||
|
// Kurz auf die Identifikation warten; ohne sie gibt es keinen Screenshot,
|
||||||
|
// und das ist kein Grund zum Abbruch.
|
||||||
|
for (let i = 0; i < 30 && !client.isConnected(); i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
return { client, stop: () => abort.abort() };
|
||||||
|
}
|
||||||
|
|
||||||
async function discordBot(): Promise<void> {
|
async function discordBot(): Promise<void> {
|
||||||
const { createBot } = await import('#discord/bot.ts');
|
const { createBot } = await import('#discord/bot.ts');
|
||||||
const bot = createBot();
|
const bot = createBot();
|
||||||
@@ -673,7 +844,10 @@ function help(): void {
|
|||||||
console.log(`
|
console.log(`
|
||||||
abdulive - Werkzeugkasten fuer Abdus Stream
|
abdulive - Werkzeugkasten fuer Abdus Stream
|
||||||
|
|
||||||
ingest Daemon starten (Chat, Zuschauerzahlen, Einrichtungsseite)
|
hub Zentraler Dienst: Chat, Discord, Go-Live, Overlay
|
||||||
|
ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern)
|
||||||
|
overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb
|
||||||
|
web:passwort [wort] Passwort fuer die Weboberflaeche setzen
|
||||||
check-env Umgebung pruefen
|
check-env Umgebung pruefen
|
||||||
db:migrate Migrationen anwenden
|
db:migrate Migrationen anwenden
|
||||||
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
|
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
|
||||||
@@ -684,6 +858,8 @@ abdulive - Werkzeugkasten fuer Abdus Stream
|
|||||||
discord:setup [--server ID] [--vorschau]
|
discord:setup [--server ID] [--vorschau]
|
||||||
Discord-Server nach docs/projects/discord-community.md einrichten
|
Discord-Server nach docs/projects/discord-community.md einrichten
|
||||||
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
|
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
|
||||||
|
golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen
|
||||||
|
Kanal ohne Ping, mit --echt in die Ankuendigungen
|
||||||
help Diese Uebersicht
|
help Diese Uebersicht
|
||||||
|
|
||||||
Einrichtung nach dem Start: http://localhost:4792/setup
|
Einrichtung nach dem Start: http://localhost:4792/setup
|
||||||
@@ -715,6 +891,14 @@ try {
|
|||||||
}
|
}
|
||||||
case 'render': await renderOne(args[0], args[1]); break;
|
case 'render': await renderOne(args[0], args[1]); break;
|
||||||
case 'discord:setup': await discordSetup(args); break;
|
case 'discord:setup': await discordSetup(args); break;
|
||||||
|
case 'golive:test': await goLiveTest(args); break;
|
||||||
|
case 'hub': {
|
||||||
|
const { runHub } = await import('../hub/src/index.ts');
|
||||||
|
await runHub();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'overlay': await overlayOnly(args); break;
|
||||||
|
case 'web:passwort': await webPasswort(args); break;
|
||||||
case 'discord:bot': await discordBot(); break;
|
case 'discord:bot': await discordBot(); break;
|
||||||
case 'help': case undefined: help(); break;
|
case 'help': case undefined: help(); break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# @abdulive/hub
|
||||||
|
|
||||||
|
Der zentrale Dienst. Alles, was nur Internet braucht: Twitch-Chat,
|
||||||
|
Zuschauerzahlen, der Discord-Rollen-Knopf, die Go-Live-Ankuendigung und die
|
||||||
|
Overlay-Seiten fuer OBS.
|
||||||
|
|
||||||
|
Laeuft im LXC, nicht auf Abdus Rechner - damit der Ping auch dann rausgeht,
|
||||||
|
wenn der Stream-PC gerade nicht laeuft. Der Schnitt zwischen Hub und Ingest
|
||||||
|
steht in [ADR-0008](../../docs/decisions/ADR-0008-geteilter-betrieb.md).
|
||||||
|
|
||||||
|
```
|
||||||
|
abdulive hub
|
||||||
|
```
|
||||||
|
|
||||||
|
Bedienung ueber die Weboberflaeche des Overlay-Servers: Uebersicht auf `/`,
|
||||||
|
Einrichtung auf `/setup`. Beides hinter einem Passwort
|
||||||
|
(`abdulive web:passwort`).
|
||||||
|
|
||||||
|
Nicht hier drin und auch nicht hierher zu holen: OBS, der Wiedergabepuffer,
|
||||||
|
ffmpeg und whisper. Die bleiben in `abdulive ingest`.
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
// Der zentrale Dienst. Laeuft in einem LXC, nicht auf Abdus Rechner.
|
||||||
|
//
|
||||||
|
// Was hier drin ist, braucht nur Internet: Twitch-Chat, Twitch-API, Discord
|
||||||
|
// und die Overlay-Seiten. Das laeuft damit auch dann, wenn der Stream-PC aus
|
||||||
|
// ist - der Rollen-Knopf auf Discord antwortet nachts um drei, und die
|
||||||
|
// Go-Live-Ankuendigung haengt nicht daran, dass jemand den Daemon gestartet
|
||||||
|
// hat.
|
||||||
|
//
|
||||||
|
// Was NICHT hier drin ist und auch nicht hierher kann: OBS, der
|
||||||
|
// Wiedergabepuffer, ffmpeg und whisper. Die haengen an den Dateien auf dem
|
||||||
|
// Stream-PC und bleiben in `abdulive ingest`.
|
||||||
|
//
|
||||||
|
// Hub (LXC) Stream-PC (abdulive ingest)
|
||||||
|
// --------- --------------------------
|
||||||
|
// Chat lesen und senden OBS-Wiedergabepuffer
|
||||||
|
// Zuschauerzahlen, Sitzungen !clip, Rendern, Untertitel
|
||||||
|
// Discord: Knopf und Go-Live
|
||||||
|
// Overlay-Seiten fuer OBS
|
||||||
|
//
|
||||||
|
// Beide schreiben in ihre eigene Datenbank. Das ist bewusst: eine gemeinsame
|
||||||
|
// waere eine Netzwerkfreigabe fuer SQLite, und das ist der zuverlaessigste
|
||||||
|
// Weg zu einer beschaedigten Datei.
|
||||||
|
|
||||||
|
import { loadConfig } from '#core/config.ts';
|
||||||
|
import { logger } from '#core/logger.ts';
|
||||||
|
import { sleep } from '#core/retry.ts';
|
||||||
|
import { openDb, closeDb } from '#db/index.ts';
|
||||||
|
import { repos } from '#db/repos.ts';
|
||||||
|
import { createBot } from '#discord/bot.ts';
|
||||||
|
import { helix } from '#twitch/helix.ts';
|
||||||
|
import { readSecrets } from '#twitch/secrets.ts';
|
||||||
|
import { createEventSub } from '#twitch/eventsub.ts';
|
||||||
|
import { createIrc, type UserNotice } from '#twitch/irc.ts';
|
||||||
|
import { streamWechsel } from '#twitch/stream-state.ts';
|
||||||
|
import {
|
||||||
|
ausEventSub, createDrossel, createWarteschlange, entscheiden,
|
||||||
|
} from './rewards.ts';
|
||||||
|
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
|
||||||
|
import { createSpikeDetector } from '../../ingest/src/spike.ts';
|
||||||
|
import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts';
|
||||||
|
import { startOverlayServer } from '../../overlay-server/src/server.ts';
|
||||||
|
import { ulid } from '#shared/events.ts';
|
||||||
|
|
||||||
|
const log = logger('hub');
|
||||||
|
|
||||||
|
export async function runHub(): Promise<void> {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const db = openDb();
|
||||||
|
const repo = repos(db);
|
||||||
|
const spikes = createSpikeDetector(cfg.ingest.spike);
|
||||||
|
const bus = createBus();
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
streamId: null as string | null,
|
||||||
|
broadcasterId: null as string | null,
|
||||||
|
lastSeenLive: 0,
|
||||||
|
viewers: 0,
|
||||||
|
title: '',
|
||||||
|
category: '',
|
||||||
|
startedLiveAt: null as string | null,
|
||||||
|
chatConnected: false,
|
||||||
|
chatAnonymous: true,
|
||||||
|
messages: 0,
|
||||||
|
spikeCandidates: 0,
|
||||||
|
eventsub: false,
|
||||||
|
lastError: null as string | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
const stop = (signal: string): void => {
|
||||||
|
log.info(`${signal} empfangen, fahre herunter`);
|
||||||
|
abort.abort();
|
||||||
|
setTimeout(() => { closeDb(); process.exit(0); }, 500);
|
||||||
|
};
|
||||||
|
process.on('SIGINT', () => stop('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => stop('SIGTERM'));
|
||||||
|
|
||||||
|
// ---- Go-Live-Ankuendigung. Ohne OBS gibt es kein eigenes Vorschaubild;
|
||||||
|
// dann nimmt der Post Twitchs Vorschau, und die ist meist gut genug.
|
||||||
|
const announcer = createAnnouncer({
|
||||||
|
cfg: {
|
||||||
|
enabled: cfg.broadcaster.enabled,
|
||||||
|
mode: cfg.broadcaster.mode,
|
||||||
|
delaySeconds: cfg.broadcaster.delaySecondsAfterOnline,
|
||||||
|
cooldownHours: cfg.broadcaster.cooldownHours,
|
||||||
|
pingRole: cfg.broadcaster.pingRole,
|
||||||
|
obsScreenshot: false,
|
||||||
|
},
|
||||||
|
repo,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Kanalpunkte
|
||||||
|
const warteschlange = createWarteschlange();
|
||||||
|
const drossel = createDrossel();
|
||||||
|
|
||||||
|
/** Punkte zurueckgeben. Ohne den Scope nicht moeglich - dann nur melden. */
|
||||||
|
const erstatten = async (rewardId: string, redemptionId: string): Promise<void> => {
|
||||||
|
if (!state.broadcasterId) return;
|
||||||
|
if (!helix.hasScope('channel:manage:redemptions')) {
|
||||||
|
log.warn('Punkte nicht erstattet: dem verbundenen Konto fehlt ' +
|
||||||
|
'channel:manage:redemptions. Einmal neu verbinden unter /setup.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, 'CANCELED');
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(`Punkte nicht erstattet: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const vorlesen = (id: string, name: string, text: string): void => {
|
||||||
|
bus.publish({ kind: 'tts', id, name, text });
|
||||||
|
log.info(`Wird vorgelesen (${name}): ${text.slice(0, 60)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const behandleEinloesung = (roh: Record<string, unknown>): void => {
|
||||||
|
const e = ausEventSub(roh);
|
||||||
|
if (!e) { log.warn('Einloesung ohne verwertbare Felder - ignoriert'); return; }
|
||||||
|
|
||||||
|
repo.event('points.redeemed', {
|
||||||
|
reward: e.rewardTitle, kosten: e.kosten, login: e.userName, text: e.text,
|
||||||
|
}, state.streamId, e.userId, e.ts);
|
||||||
|
|
||||||
|
const ent = entscheiden(e, cfg.rewards, drossel.zaehlen(e.userId));
|
||||||
|
drossel.merken(e.userId);
|
||||||
|
|
||||||
|
switch (ent.art) {
|
||||||
|
case 'unbekannt':
|
||||||
|
log.debug(`Belohnung "${e.rewardTitle}" hat keine hinterlegte Aktion`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'abgelehnt':
|
||||||
|
log.info(`Einloesung von ${e.userName} abgelehnt: ${ent.grund}`);
|
||||||
|
void erstatten(e.rewardId, e.id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ausfuehren':
|
||||||
|
// Immer dieselbe Einblendung: sie sagt, wer was eingeloest hat. Die
|
||||||
|
// Aktion entscheidet nur, was zusaetzlich passiert. Frueher stand
|
||||||
|
// hier die Abo-Einblendung - eine Belohnung als "Neues Abo" ins Bild
|
||||||
|
// zu schreiben ist eine Falschmeldung, die in der Aufzeichnung bleibt.
|
||||||
|
bus.publish({
|
||||||
|
kind: 'alert', type: 'reward', id: ulid(), name: e.userName,
|
||||||
|
reward: e.rewardTitle,
|
||||||
|
...(e.kosten ? { amount: e.kosten } : {}),
|
||||||
|
...(e.text ? { message: e.text } : {}),
|
||||||
|
});
|
||||||
|
if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 });
|
||||||
|
if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 });
|
||||||
|
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'vorlesen':
|
||||||
|
vorlesen(ulid(), e.userName, ent.text);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'warteschlange':
|
||||||
|
warteschlange.einreihen({
|
||||||
|
id: ulid(), name: e.userName, text: ent.text,
|
||||||
|
rewardId: e.rewardId, redemptionId: e.id, seit: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
log.info(`${e.userName} wartet auf Freigabe zum Vorlesen`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- EventSub. Nur sinnvoll mit verbundenem Konto: der WebSocket-Weg
|
||||||
|
// verlangt fuer das Abonnement dasselbe Nutzer-Token.
|
||||||
|
const eventsub = createEventSub({
|
||||||
|
onSession: async (sessionId) => {
|
||||||
|
const id = state.broadcasterId || (await helix.userByLogin(cfg.channel.login))?.id;
|
||||||
|
if (!id) throw new Error('Broadcaster-ID unbekannt');
|
||||||
|
state.broadcasterId = id;
|
||||||
|
await helix.subscribe({
|
||||||
|
type: 'channel.channel_points_custom_reward_redemption.add',
|
||||||
|
version: '1',
|
||||||
|
condition: { broadcaster_user_id: id },
|
||||||
|
}, sessionId);
|
||||||
|
log.info('Abonniert: Kanalpunkt-Einloesungen');
|
||||||
|
},
|
||||||
|
onNotification: (typ, event) => {
|
||||||
|
if (typ === 'channel.channel_points_custom_reward_redemption.add') {
|
||||||
|
behandleEinloesung(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRevocation: (typ) => log.error(
|
||||||
|
`Twitch hat "${typ}" entzogen - meist zurueckgezogene Freigabe. Neu verbinden unter /setup.`),
|
||||||
|
onConnected: () => { state.eventsub = true; },
|
||||||
|
onClosed: () => { state.eventsub = false; },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Discord (Rollen-Knoepfe)
|
||||||
|
const bot = createBot();
|
||||||
|
if (bot) void bot.run(abort.signal);
|
||||||
|
|
||||||
|
// ---- Chat
|
||||||
|
const irc = createIrc(cfg.channel.login, {
|
||||||
|
onConnected: (anonymous) => {
|
||||||
|
state.chatConnected = true;
|
||||||
|
state.chatAnonymous = anonymous;
|
||||||
|
},
|
||||||
|
onMessage: (msg) => {
|
||||||
|
try {
|
||||||
|
repo.recordMessage(msg, state.streamId);
|
||||||
|
state.messages++;
|
||||||
|
repo.event('chat.message', {
|
||||||
|
text: msg.text, login: msg.login, isFirst: msg.isFirst, bits: msg.bits,
|
||||||
|
}, state.streamId, msg.userId, msg.ts);
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
kind: 'chat',
|
||||||
|
id: msg.id,
|
||||||
|
userId: msg.userId,
|
||||||
|
login: msg.login,
|
||||||
|
name: msg.displayName,
|
||||||
|
text: msg.text,
|
||||||
|
color: msg.color,
|
||||||
|
emotes: msg.emotes,
|
||||||
|
gifs: msg.gifs,
|
||||||
|
first: msg.isFirst,
|
||||||
|
mod: msg.isMod,
|
||||||
|
sub: msg.isSub,
|
||||||
|
broadcaster: msg.isBroadcaster,
|
||||||
|
bits: msg.bits,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (msg.isFirst) {
|
||||||
|
repo.event('chat.first_message', { login: msg.login }, state.streamId, msg.userId, msg.ts);
|
||||||
|
bus.publish({ kind: 'alert', type: 'first', id: ulid(), name: msg.displayName });
|
||||||
|
log.info(`Erstchatter: ${msg.displayName}`);
|
||||||
|
}
|
||||||
|
if (msg.bits > 0) {
|
||||||
|
repo.event('cheer', { bits: msg.bits, login: msg.login }, state.streamId, msg.userId, msg.ts);
|
||||||
|
bus.publish({
|
||||||
|
kind: 'alert', type: 'cheer', id: ulid(),
|
||||||
|
name: msg.displayName, amount: msg.bits, message: msg.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const spike = spikes.push(msg);
|
||||||
|
if (spike) {
|
||||||
|
state.spikeCandidates++;
|
||||||
|
repo.event('chat.spike', spike, state.streamId, null, msg.ts);
|
||||||
|
bus.publish({ kind: 'hype', score: spike.score, rate: spike.rate });
|
||||||
|
log.info(`Spitze im Chat: Punktzahl ${spike.score} (${spike.rate} Nachrichten ` +
|
||||||
|
`von ${spike.chatters} Leuten, Basislinie ${spike.baseline})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
state.lastError = (err as Error).message;
|
||||||
|
log.error(`Nachricht nicht verarbeitet: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUserNotice: (notice) => {
|
||||||
|
const alert = toAlert(notice);
|
||||||
|
if (!alert) return;
|
||||||
|
repo.event(eventTypeFor(notice), notice, state.streamId);
|
||||||
|
bus.publish(alert);
|
||||||
|
log.info(notice.systemMessage || `${notice.kind} von ${notice.displayName}`);
|
||||||
|
},
|
||||||
|
onClear: (what) => bus.publish({ kind: 'clear', ...what }),
|
||||||
|
});
|
||||||
|
void irc.run(abort.signal);
|
||||||
|
|
||||||
|
// ---- Die eine oeffentliche Tuer
|
||||||
|
const server = startOverlayServer({
|
||||||
|
port: cfg.overlay.port,
|
||||||
|
channel: cfg.channel.login,
|
||||||
|
publicUrl: cfg.publicUrl,
|
||||||
|
bus,
|
||||||
|
setup: {
|
||||||
|
channel: cfg.channel.login,
|
||||||
|
port: cfg.overlay.port,
|
||||||
|
health: () => ({
|
||||||
|
...state,
|
||||||
|
rolle: 'hub',
|
||||||
|
db: repo.stats(),
|
||||||
|
discordConnected: bot ? bot.isConnected() : false,
|
||||||
|
goLive: announcer.lastResult() ?? 'noch nichts',
|
||||||
|
overlayClients: bus.clients(),
|
||||||
|
ttsWartend: warteschlange.liste().length,
|
||||||
|
spikeArmed: spikes.isArmed(),
|
||||||
|
note: state.chatAnonymous
|
||||||
|
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tts: {
|
||||||
|
wartend: () => warteschlange.liste()
|
||||||
|
.map((w) => ({ id: w.id, name: w.name, text: w.text, seit: w.seit })),
|
||||||
|
freigeben: (id) => {
|
||||||
|
const w = warteschlange.entnehmen(id);
|
||||||
|
if (!w) return false;
|
||||||
|
vorlesen(w.id, w.name, w.text);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
ablehnen: (id) => {
|
||||||
|
const w = warteschlange.entnehmen(id);
|
||||||
|
if (!w) return false;
|
||||||
|
log.info(`Vorlesen abgelehnt (${w.name})`);
|
||||||
|
void erstatten(w.rewardId, w.redemptionId);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
alleAblehnen: () => {
|
||||||
|
const alle = warteschlange.leeren();
|
||||||
|
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
|
||||||
|
if (alle.length) log.info(`${alle.length} wartende Nachricht(en) abgelehnt`);
|
||||||
|
return alle.length;
|
||||||
|
},
|
||||||
|
still: () => {
|
||||||
|
bus.publish({ kind: 'still' });
|
||||||
|
const alle = warteschlange.leeren();
|
||||||
|
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
|
||||||
|
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
void server;
|
||||||
|
|
||||||
|
// ---- Zuschauerzahlen und Stream-Sitzungen
|
||||||
|
void pollStream(cfg, repo, state, bus, abort.signal, (stream) => announcer.announce(stream));
|
||||||
|
|
||||||
|
// ---- EventSub erst, wenn ein Konto verbunden ist. Ohne Nutzer-Token
|
||||||
|
// wuerde der Verbindungsversuch im Minutentakt scheitern und nichts als
|
||||||
|
// Protokollrauschen erzeugen.
|
||||||
|
if (cfg.rewards.enabled && readSecrets().tokens.broadcaster) {
|
||||||
|
void eventsub.run(abort.signal);
|
||||||
|
} else if (cfg.rewards.enabled) {
|
||||||
|
log.warn('Kanalpunkt-Aktionen sind an, aber kein Konto verbunden - ' +
|
||||||
|
'EventSub bleibt aus. Verbinden unter /setup.');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Hub laeuft fuer #${cfg.channel.login}`);
|
||||||
|
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Ereignisse
|
||||||
|
|
||||||
|
/** USERNOTICE in eine Einblendung uebersetzen. Unbekanntes faellt weg. */
|
||||||
|
export function toAlert(n: UserNotice): AlertEvent | null {
|
||||||
|
const gemeinsam = {
|
||||||
|
kind: 'alert' as const,
|
||||||
|
id: ulid(),
|
||||||
|
name: n.displayName,
|
||||||
|
...(n.months !== undefined ? { months: n.months } : {}),
|
||||||
|
...(n.tier ? { tier: n.tier } : {}),
|
||||||
|
...(n.text ? { message: n.text } : {}),
|
||||||
|
};
|
||||||
|
switch (n.kind) {
|
||||||
|
case 'sub': case 'resub':
|
||||||
|
return { ...gemeinsam, type: n.kind };
|
||||||
|
case 'subgift':
|
||||||
|
return { ...gemeinsam, type: 'subgift', ...(n.recipient ? { recipient: n.recipient } : {}) };
|
||||||
|
case 'massgift':
|
||||||
|
return { ...gemeinsam, type: 'massgift', ...(n.count ? { amount: n.count } : {}) };
|
||||||
|
case 'raid':
|
||||||
|
return { ...gemeinsam, type: 'raid', ...(n.count ? { amount: n.count } : {}) };
|
||||||
|
default:
|
||||||
|
// Ankuendigungen und alles Unbekannte bekommen keine Einblendung. Eine
|
||||||
|
// Einblendung fuer etwas, das man nicht kennt, sieht kaputt aus.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTypeFor(n: UserNotice): 'subscribe' | 'subgift' | 'raid.in' | 'chat.message' {
|
||||||
|
if (n.kind === 'subgift' || n.kind === 'massgift') return 'subgift';
|
||||||
|
if (n.kind === 'raid') return 'raid.in';
|
||||||
|
if (n.kind === 'sub' || n.kind === 'resub') return 'subscribe';
|
||||||
|
return 'chat.message';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Abfrage
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
streamId: string | null; broadcasterId: string | null; lastSeenLive: number;
|
||||||
|
viewers: number; title: string; category: string; startedLiveAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function pollStream(
|
||||||
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
|
repo: ReturnType<typeof repos>,
|
||||||
|
state: State,
|
||||||
|
bus: Bus,
|
||||||
|
signal: AbortSignal,
|
||||||
|
onOnline: (stream: StreamInfo) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const gapMs = cfg.ingest.sessionGapMinutes * 60_000;
|
||||||
|
let warnedMissingAuth = false;
|
||||||
|
|
||||||
|
const sendState = (live: boolean): void => {
|
||||||
|
bus.publish({
|
||||||
|
kind: 'stream', live,
|
||||||
|
title: state.title, category: state.category,
|
||||||
|
viewers: state.viewers, startedAt: state.startedLiveAt,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ein laufender Stream wird beim Start uebernommen, damit ein Neustart
|
||||||
|
// mitten im Stream keine zweite Sitzung anlegt - und keinen zweiten Ping.
|
||||||
|
const open = repo.latestOpenStream();
|
||||||
|
if (open) {
|
||||||
|
state.streamId = open.id;
|
||||||
|
state.startedLiveAt = open.started_at;
|
||||||
|
state.lastSeenLive = Date.now();
|
||||||
|
log.info(`Laufende Sitzung uebernommen: ${open.id} seit ${open.started_at}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!signal.aborted) {
|
||||||
|
try {
|
||||||
|
// Client-ID und Secret genuegen: `GET /users` und `GET /streams` laufen
|
||||||
|
// mit einem App-Token. Auf das verbundene Konto wartet hier niemand.
|
||||||
|
if (!helix.canReadStream()) {
|
||||||
|
if (!warnedMissingAuth) {
|
||||||
|
log.warn('Keine Twitch-App hinterlegt - Zuschauerzahlen, Sitzungen und die ' +
|
||||||
|
'Go-Live-Ankuendigung bleiben aus. Einrichtung unter /setup.');
|
||||||
|
warnedMissingAuth = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warnedMissingAuth = false;
|
||||||
|
const userId = state.broadcasterId || cfg.channel.userId ||
|
||||||
|
(await helix.userByLogin(cfg.channel.login))?.id || '';
|
||||||
|
if (userId) state.broadcasterId = userId;
|
||||||
|
const live = userId ? await helix.streamByUserId(userId) : null;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const wechsel = streamWechsel({
|
||||||
|
live: live ? { id: live.id, startedAt: live.started_at } : null,
|
||||||
|
laufendeId: state.streamId,
|
||||||
|
zuletztLive: state.lastSeenLive,
|
||||||
|
jetzt: now,
|
||||||
|
luckeMs: gapMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (wechsel.art === 'gestartet') {
|
||||||
|
state.streamId = wechsel.id;
|
||||||
|
repo.openStream(wechsel.id, wechsel.startedAt);
|
||||||
|
repo.event('stream.online',
|
||||||
|
{ title: live!.title, category: live!.game_name }, wechsel.id);
|
||||||
|
log.info(`Stream online: "${live!.title}" (${live!.game_name}), ` +
|
||||||
|
`gestartet ${wechsel.startedAt}`);
|
||||||
|
onOnline({
|
||||||
|
id: wechsel.id,
|
||||||
|
title: live!.title,
|
||||||
|
category: live!.game_name,
|
||||||
|
login: cfg.channel.login,
|
||||||
|
startedAt: wechsel.startedAt,
|
||||||
|
thumbnailUrl: live!.thumbnail_url,
|
||||||
|
});
|
||||||
|
} else if (wechsel.art === 'fortsetzung') {
|
||||||
|
log.info(`Neue Twitch-Stream-ID ${wechsel.neueId}, behandle als Fortsetzung ` +
|
||||||
|
`von ${state.streamId} - keine zweite Ankuendigung`);
|
||||||
|
} else if (wechsel.art === 'beendet') {
|
||||||
|
repo.closeStream(wechsel.id, new Date().toISOString());
|
||||||
|
repo.event('stream.offline', {}, wechsel.id);
|
||||||
|
log.info(`Stream offline: ${wechsel.id}`);
|
||||||
|
state.streamId = null;
|
||||||
|
state.viewers = 0;
|
||||||
|
state.startedLiveAt = null;
|
||||||
|
sendState(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (live) {
|
||||||
|
state.title = live.title;
|
||||||
|
state.category = live.game_name;
|
||||||
|
state.startedLiveAt = live.started_at;
|
||||||
|
state.lastSeenLive = now;
|
||||||
|
state.viewers = live.viewer_count;
|
||||||
|
if (state.streamId) {
|
||||||
|
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
|
||||||
|
}
|
||||||
|
sendState(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
state.lastError = (err as Error).message;
|
||||||
|
log.error(`Abfrage fehlgeschlagen: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
try { await sleep(cfg.ingest.viewerPollSeconds * 1000, signal); } catch { return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Hier entscheidet sich, ob fremder Text im Stream vorgelesen wird. Was hier
|
||||||
|
// durchrutscht, hoert das Publikum - und die Aufzeichnung behaelt es.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
ausEventSub, createDrossel, createWarteschlange, entscheiden, planFuer,
|
||||||
|
type Einloesung, type RewardsConfig,
|
||||||
|
} from './rewards.ts';
|
||||||
|
|
||||||
|
const CFG: RewardsConfig = {
|
||||||
|
enabled: true,
|
||||||
|
belohnungen: [
|
||||||
|
{ titel: 'Konfetti', aktion: 'konfetti' },
|
||||||
|
{ titel: 'Vorlesen', aktion: 'tts' },
|
||||||
|
{ id: 'abc-123', aktion: 'emote-regen' },
|
||||||
|
],
|
||||||
|
tts: { freigabe: true, maxZeichen: 200, blockliste: ['heilhitler'], proPersonProStunde: 3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function einloesung(over: Partial<Einloesung> = {}): Einloesung {
|
||||||
|
return {
|
||||||
|
id: 'r1', rewardId: 'xyz', rewardTitle: 'Vorlesen', userId: 'u1', userName: 'Kevin',
|
||||||
|
text: 'moin zusammen', kosten: 500, ts: '2026-09-17T20:00:00Z', ...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Zuordnung
|
||||||
|
|
||||||
|
test('die ID gewinnt gegen den Titel', () => {
|
||||||
|
// Der Titel kann sich aendern, die ID nicht. Wer beides hinterlegt, meint
|
||||||
|
// die ID.
|
||||||
|
const p = planFuer({ rewardId: 'abc-123', rewardTitle: 'Konfetti' }, CFG.belohnungen);
|
||||||
|
assert.equal(p?.aktion, 'emote-regen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Titel wird ohne Ruecksicht auf Gross-/Kleinschreibung gefunden', () => {
|
||||||
|
assert.equal(planFuer({ rewardId: 'x', rewardTitle: 'kOnFeTtI' }, CFG.belohnungen)?.aktion,
|
||||||
|
'konfetti');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine unbekannte Belohnung ist kein Fehler', () => {
|
||||||
|
// Abdu darf Belohnungen anlegen, die mit dem Toolkit nichts zu tun haben.
|
||||||
|
const e = entscheiden(einloesung({ rewardTitle: 'Nachricht anheften' }), CFG);
|
||||||
|
assert.equal(e.art, 'unbekannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Einfache Aktionen
|
||||||
|
|
||||||
|
test('Konfetti laeuft sofort, ohne Freigabe', () => {
|
||||||
|
const e = entscheiden(einloesung({ rewardTitle: 'Konfetti', text: '' }), CFG);
|
||||||
|
assert.deepEqual(e, { art: 'ausfuehren', aktion: 'konfetti', plan: CFG.belohnungen[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Kill-Switch haelt alles an', () => {
|
||||||
|
const e = entscheiden(einloesung({ rewardTitle: 'Konfetti' }), { ...CFG, enabled: false });
|
||||||
|
assert.equal(e.art, 'abgelehnt');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- TTS
|
||||||
|
|
||||||
|
test('mit Freigabe geht Text in die Warteschlange, nicht ins Bild', () => {
|
||||||
|
const e = entscheiden(einloesung(), CFG);
|
||||||
|
assert.deepEqual(e, { art: 'warteschlange', text: 'moin zusammen' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ohne Freigabe wird sofort vorgelesen', () => {
|
||||||
|
const e = entscheiden(einloesung(), { ...CFG, tts: { ...CFG.tts, freigabe: false } });
|
||||||
|
assert.deepEqual(e, { art: 'vorlesen', text: 'moin zusammen' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('die Einstellung an der Belohnung schlaegt die allgemeine', () => {
|
||||||
|
const cfg: RewardsConfig = {
|
||||||
|
...CFG,
|
||||||
|
belohnungen: [{ titel: 'Vorlesen', aktion: 'tts', freigabe: false }],
|
||||||
|
tts: { ...CFG.tts, freigabe: true },
|
||||||
|
};
|
||||||
|
assert.equal(entscheiden(einloesung(), cfg).art, 'vorlesen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zu langer Text wird abgelehnt, nicht abgeschnitten', () => {
|
||||||
|
// Abgeschnitten waere schlimmer: dann wird ein halber Satz vorgelesen, und
|
||||||
|
// niemand weiss, ob der Rest Absicht war.
|
||||||
|
const e = entscheiden(einloesung({ text: 'a'.repeat(201) }), CFG);
|
||||||
|
assert.equal(e.art, 'abgelehnt');
|
||||||
|
assert.match((e as { grund: string }).grund, /zu lang/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leerer Text wird abgelehnt', () => {
|
||||||
|
assert.equal(entscheiden(einloesung({ text: ' ' }), CFG).art, 'abgelehnt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Wortfilter greift unabhaengig von Gross-/Kleinschreibung', () => {
|
||||||
|
const e = entscheiden(einloesung({ text: 'guck mal HeilHitler lol' }), CFG);
|
||||||
|
assert.equal(e.art, 'abgelehnt');
|
||||||
|
assert.equal((e as { grund: string }).grund, 'Wortfilter');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Ablehnungsgrund verraet den gefilterten Text nicht', () => {
|
||||||
|
// Der Grund landet im Protokoll und moeglicherweise im Chat. Er darf nicht
|
||||||
|
// wiederholen, was gerade herausgefiltert wurde.
|
||||||
|
const e = entscheiden(einloesung({ text: 'heilhitler' }), CFG);
|
||||||
|
assert.equal((e as { grund: string }).grund.includes('heilhitler'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wer zu oft einloest, wird gebremst', () => {
|
||||||
|
assert.equal(entscheiden(einloesung(), CFG, 2).art, 'warteschlange');
|
||||||
|
assert.equal(entscheiden(einloesung(), CFG, 3).art, 'abgelehnt');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Warteschlange
|
||||||
|
|
||||||
|
test('eingereiht, entnommen, weg', () => {
|
||||||
|
const w = createWarteschlange();
|
||||||
|
w.einreihen({ id: 'a', name: 'Kevin', text: 'moin', rewardId: 'r', redemptionId: 'x', seit: '' });
|
||||||
|
assert.equal(w.liste().length, 1);
|
||||||
|
assert.equal(w.entnehmen('a')?.text, 'moin');
|
||||||
|
assert.equal(w.liste().length, 0);
|
||||||
|
assert.equal(w.entnehmen('a'), undefined, 'zweimal entnehmen geht nicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine volle Warteschlange wirft das Aelteste raus', () => {
|
||||||
|
// Eine Schlange, die bei einem Ansturm auf 300 waechst, arbeitet niemand ab.
|
||||||
|
const w = createWarteschlange(3);
|
||||||
|
for (const id of ['a', 'b', 'c', 'd']) {
|
||||||
|
w.einreihen({ id, name: 'x', text: id, rewardId: 'r', redemptionId: id, seit: '' });
|
||||||
|
}
|
||||||
|
assert.deepEqual(w.liste().map((x) => x.id), ['b', 'c', 'd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leeren gibt alles zurueck, damit die Punkte erstattet werden koennen', () => {
|
||||||
|
const w = createWarteschlange();
|
||||||
|
w.einreihen({ id: 'a', name: 'x', text: '1', rewardId: 'r', redemptionId: 'a', seit: '' });
|
||||||
|
w.einreihen({ id: 'b', name: 'y', text: '2', rewardId: 'r', redemptionId: 'b', seit: '' });
|
||||||
|
assert.equal(w.leeren().length, 2);
|
||||||
|
assert.equal(w.liste().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Drossel
|
||||||
|
|
||||||
|
test('die Drossel zaehlt nur das gleitende Fenster', () => {
|
||||||
|
const d = createDrossel(3_600_000);
|
||||||
|
const t = Date.parse('2026-09-17T20:00:00Z');
|
||||||
|
d.merken('u1', t);
|
||||||
|
d.merken('u1', t + 60_000);
|
||||||
|
assert.equal(d.zaehlen('u1', t + 120_000), 2);
|
||||||
|
// Zwei Stunden spaeter ist beides aus dem Fenster gefallen.
|
||||||
|
assert.equal(d.zaehlen('u1', t + 2 * 3_600_000), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('die Drossel trennt nach Person', () => {
|
||||||
|
const d = createDrossel();
|
||||||
|
d.merken('u1');
|
||||||
|
assert.equal(d.zaehlen('u2'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- EventSub
|
||||||
|
|
||||||
|
test('das EventSub-Ereignis wird in unsere Form gebracht', () => {
|
||||||
|
const e = ausEventSub({
|
||||||
|
id: 'red-1',
|
||||||
|
user_id: '42',
|
||||||
|
user_name: 'Kevin',
|
||||||
|
user_input: 'lies das vor',
|
||||||
|
redeemed_at: '2026-09-17T20:00:00Z',
|
||||||
|
reward: { id: 'rw-9', title: 'Vorlesen', cost: 500 },
|
||||||
|
});
|
||||||
|
assert.equal(e?.id, 'red-1');
|
||||||
|
assert.equal(e?.rewardId, 'rw-9');
|
||||||
|
assert.equal(e?.rewardTitle, 'Vorlesen');
|
||||||
|
assert.equal(e?.text, 'lies das vor');
|
||||||
|
assert.equal(e?.kosten, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine Belohnung ohne Texteingabe hat einen leeren Text, nicht "undefined"', () => {
|
||||||
|
// Ohne das stuende im Bild woertlich "undefined".
|
||||||
|
const e = ausEventSub({ id: 'x', user_id: '1', reward: { id: 'r' } });
|
||||||
|
assert.equal(e?.text, '');
|
||||||
|
assert.equal(e?.userName, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unvollstaendige Ereignisse werden verworfen', () => {
|
||||||
|
assert.equal(ausEventSub({}), null);
|
||||||
|
assert.equal(ausEventSub({ id: 'x' }), null);
|
||||||
|
assert.equal(ausEventSub({ id: 'x', user_id: '1' }), null, 'ohne Belohnung keine Aktion');
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Was bei einer Kanalpunkt-Einloesung passiert.
|
||||||
|
//
|
||||||
|
// Der Weg: EventSub meldet die Einloesung -> hier wird sie einer Aktion aus
|
||||||
|
// der Konfiguration zugeordnet -> die Aktion laeuft, oder sie landet in der
|
||||||
|
// Warteschlange und wartet auf Abdus Klick.
|
||||||
|
//
|
||||||
|
// Zur Zuordnung: Belohnungen werden ueber ihren Titel gefunden, wahlweise
|
||||||
|
// ueber die ID. Der Titel ist lesbar und laesst sich in der Konfiguration
|
||||||
|
// wiedererkennen, kann sich aber aendern; die ID ist stabil, aber
|
||||||
|
// nichtssagend. Deshalb beides, und die ID gewinnt.
|
||||||
|
//
|
||||||
|
// Warum TTS eine Warteschlange hat, steht in docs/projects/chat-control.md:
|
||||||
|
// Kanalpunkte sammeln sich beim Zugucken von allein an und sind damit kaum
|
||||||
|
// eine Huerde. Wer beliebigen Fremdtext ungeprueft vorlesen laesst, hat eine
|
||||||
|
// Vorlesefunktion fuer Fremde im eigenen Stream.
|
||||||
|
|
||||||
|
import { logger } from '#core/logger.ts';
|
||||||
|
|
||||||
|
const log = logger('rewards');
|
||||||
|
|
||||||
|
/** Eine Einloesung, so weit sie hier gebraucht wird. */
|
||||||
|
export type Einloesung = {
|
||||||
|
id: string;
|
||||||
|
rewardId: string;
|
||||||
|
rewardTitle: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
/** Der eingegebene Text. Leer, wenn die Belohnung keinen verlangt. */
|
||||||
|
text: string;
|
||||||
|
kosten: number;
|
||||||
|
ts: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AktionsArt = 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
|
||||||
|
|
||||||
|
export type BelohnungsPlan = {
|
||||||
|
/** Titel der Belohnung auf Twitch. Gross-/Kleinschreibung egal. */
|
||||||
|
titel?: string;
|
||||||
|
/** Stabile Belohnungs-ID. Gewinnt gegen den Titel. */
|
||||||
|
id?: string;
|
||||||
|
aktion: AktionsArt;
|
||||||
|
/** Nur bei `tts`: ohne Freigabe wird sofort vorgelesen. */
|
||||||
|
freigabe?: boolean;
|
||||||
|
/** Nur bei `tts`: laengerer Text wird abgelehnt statt gekuerzt. */
|
||||||
|
maxZeichen?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RewardsConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
belohnungen: BelohnungsPlan[];
|
||||||
|
tts: {
|
||||||
|
/** Vorgabe fuer `freigabe`, wenn eine Belohnung nichts anderes sagt. */
|
||||||
|
freigabe: boolean;
|
||||||
|
maxZeichen: number;
|
||||||
|
/** Woerter, die den Text ohne Nachfrage ablehnen. */
|
||||||
|
blockliste: string[];
|
||||||
|
/** So viele Einloesungen je Person und Stunde. */
|
||||||
|
proPersonProStunde: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Was mit einer Einloesung geschehen soll. Rein, damit pruefbar. */
|
||||||
|
export type Entscheidung =
|
||||||
|
| { art: 'ausfuehren'; aktion: Exclude<AktionsArt, 'tts' | 'nichts'>; plan: BelohnungsPlan }
|
||||||
|
| { art: 'vorlesen'; text: string }
|
||||||
|
| { art: 'warteschlange'; text: string }
|
||||||
|
| { art: 'abgelehnt'; grund: string }
|
||||||
|
| { art: 'unbekannt' };
|
||||||
|
|
||||||
|
/** Findet den Plan zu einer Einloesung. ID gewinnt, sonst der Titel. */
|
||||||
|
export function planFuer(
|
||||||
|
e: Pick<Einloesung, 'rewardId' | 'rewardTitle'>, plaene: BelohnungsPlan[],
|
||||||
|
): BelohnungsPlan | undefined {
|
||||||
|
return plaene.find((p) => p.id && p.id === e.rewardId)
|
||||||
|
?? plaene.find((p) => p.titel && p.titel.toLowerCase() === e.rewardTitle.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entscheiden(
|
||||||
|
e: Einloesung,
|
||||||
|
cfg: RewardsConfig,
|
||||||
|
/** Wie oft diese Person in der letzten Stunde schon eingeloest hat. */
|
||||||
|
bisherigeDieseStunde = 0,
|
||||||
|
): Entscheidung {
|
||||||
|
if (!cfg.enabled) return { art: 'abgelehnt', grund: 'Belohnungs-Aktionen sind ausgeschaltet' };
|
||||||
|
|
||||||
|
const plan = planFuer(e, cfg.belohnungen);
|
||||||
|
// Unbekannte Belohnung heisst nicht "Fehler": Abdu darf Belohnungen anlegen,
|
||||||
|
// die mit dem Toolkit nichts zu tun haben. Sie werden protokolliert und
|
||||||
|
// sonst in Ruhe gelassen.
|
||||||
|
if (!plan) return { art: 'unbekannt' };
|
||||||
|
if (plan.aktion === 'nichts') return { art: 'unbekannt' };
|
||||||
|
|
||||||
|
if (plan.aktion !== 'tts') {
|
||||||
|
return { art: 'ausfuehren', aktion: plan.aktion, plan };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ab hier nur noch TTS
|
||||||
|
const text = e.text.trim();
|
||||||
|
if (!text) return { art: 'abgelehnt', grund: 'kein Text eingegeben' };
|
||||||
|
|
||||||
|
const max = plan.maxZeichen ?? cfg.tts.maxZeichen;
|
||||||
|
if (text.length > max) {
|
||||||
|
// Abschneiden waere schlimmer: dann wird ein halber Satz vorgelesen, und
|
||||||
|
// niemand weiss, ob der Rest Absicht war.
|
||||||
|
return { art: 'abgelehnt', grund: `zu lang (${text.length} von ${max} Zeichen)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const klein = text.toLowerCase();
|
||||||
|
const treffer = cfg.tts.blockliste.find((w) => w && klein.includes(w.toLowerCase()));
|
||||||
|
if (treffer) return { art: 'abgelehnt', grund: 'Wortfilter' };
|
||||||
|
|
||||||
|
if (bisherigeDieseStunde >= cfg.tts.proPersonProStunde) {
|
||||||
|
return { art: 'abgelehnt', grund: 'zu viele Einloesungen in der letzten Stunde' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const freigabe = plan.freigabe ?? cfg.tts.freigabe;
|
||||||
|
return freigabe ? { art: 'warteschlange', text } : { art: 'vorlesen', text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Warteschlange
|
||||||
|
|
||||||
|
export type Wartend = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
/** Fuer die Erstattung, falls abgelehnt wird. */
|
||||||
|
rewardId: string;
|
||||||
|
redemptionId: string;
|
||||||
|
seit: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Warteschlange = {
|
||||||
|
einreihen: (w: Wartend) => void;
|
||||||
|
/** Gibt den Eintrag zurueck, damit der Aufrufer ihn vorlesen lassen kann. */
|
||||||
|
entnehmen: (id: string) => Wartend | undefined;
|
||||||
|
liste: () => Wartend[];
|
||||||
|
leeren: () => Wartend[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wartende Nachrichten. Bewusst im Arbeitsspeicher: was beim Neustart
|
||||||
|
* verloren geht, war ohnehin nicht dringend, und eine Tabelle mit fremdem
|
||||||
|
* Text, die niemand aufraeumt, will hier niemand.
|
||||||
|
*/
|
||||||
|
export function createWarteschlange(maximal = 25): Warteschlange {
|
||||||
|
const wartend: Wartend[] = [];
|
||||||
|
return {
|
||||||
|
einreihen(w) {
|
||||||
|
wartend.push(w);
|
||||||
|
// Laeuft sie voll, faellt das Aelteste raus. Eine Warteschlange, die
|
||||||
|
// bei einem Ansturm auf 300 waechst, arbeitet Abdu nie ab.
|
||||||
|
while (wartend.length > maximal) {
|
||||||
|
const weg = wartend.shift();
|
||||||
|
if (weg) log.warn(`Warteschlange voll, aelteste Nachricht verworfen (${weg.name})`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
entnehmen(id) {
|
||||||
|
const i = wartend.findIndex((w) => w.id === id);
|
||||||
|
return i === -1 ? undefined : wartend.splice(i, 1)[0];
|
||||||
|
},
|
||||||
|
liste: () => [...wartend],
|
||||||
|
leeren: () => wartend.splice(0, wartend.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zaehlt Einloesungen je Person in einem gleitenden Fenster.
|
||||||
|
* Braucht keine Datenbank - nach einem Neustart faengt das Fenster neu an,
|
||||||
|
* und das ist bei einer Stunde Fensterbreite egal.
|
||||||
|
*/
|
||||||
|
export function createDrossel(fensterMs = 3_600_000) {
|
||||||
|
const gesehen = new Map<string, number[]>();
|
||||||
|
return {
|
||||||
|
zaehlen(userId: string, jetzt = Date.now()): number {
|
||||||
|
const bisher = (gesehen.get(userId) ?? []).filter((t) => jetzt - t < fensterMs);
|
||||||
|
gesehen.set(userId, bisher);
|
||||||
|
return bisher.length;
|
||||||
|
},
|
||||||
|
merken(userId: string, jetzt = Date.now()): void {
|
||||||
|
const bisher = (gesehen.get(userId) ?? []).filter((t) => jetzt - t < fensterMs);
|
||||||
|
bisher.push(jetzt);
|
||||||
|
gesehen.set(userId, bisher);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das EventSub-Ereignis in unsere Form bringen. */
|
||||||
|
export function ausEventSub(event: Record<string, unknown>): Einloesung | null {
|
||||||
|
const reward = event['reward'] as { id?: string; title?: string; cost?: number } | undefined;
|
||||||
|
const id = event['id'];
|
||||||
|
const userId = event['user_id'];
|
||||||
|
if (typeof id !== 'string' || typeof userId !== 'string' || !reward?.id) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
rewardId: reward.id,
|
||||||
|
rewardTitle: reward.title ?? '',
|
||||||
|
userId,
|
||||||
|
userName: String(event['user_name'] ?? event['user_login'] ?? ''),
|
||||||
|
text: String(event['user_input'] ?? ''),
|
||||||
|
kosten: Number(reward.cost ?? 0),
|
||||||
|
ts: String(event['redeemed_at'] ?? new Date().toISOString()),
|
||||||
|
};
|
||||||
|
}
|
||||||
+67
-26
@@ -16,6 +16,8 @@ import { isWindows } from '#core/platform.ts';
|
|||||||
import { helix } from '#twitch/helix.ts';
|
import { helix } from '#twitch/helix.ts';
|
||||||
import { createIrc } from '#twitch/irc.ts';
|
import { createIrc } from '#twitch/irc.ts';
|
||||||
import { readSecrets } from '#twitch/secrets.ts';
|
import { readSecrets } from '#twitch/secrets.ts';
|
||||||
|
import { streamWechsel } from '#twitch/stream-state.ts';
|
||||||
|
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
|
||||||
import { createClipper } from './clips.ts';
|
import { createClipper } from './clips.ts';
|
||||||
import { createRenderer } from './render.ts';
|
import { createRenderer } from './render.ts';
|
||||||
import { createSpikeDetector } from './spike.ts';
|
import { createSpikeDetector } from './spike.ts';
|
||||||
@@ -42,6 +44,7 @@ export async function runIngest(): Promise<void> {
|
|||||||
messages: 0,
|
messages: 0,
|
||||||
spikeCandidates: 0,
|
spikeCandidates: 0,
|
||||||
clipsTriggered: 0,
|
clipsTriggered: 0,
|
||||||
|
goLive: 'noch nichts' as string,
|
||||||
lastError: null as string | null,
|
lastError: null as string | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,6 +114,20 @@ export async function runIngest(): Promise<void> {
|
|||||||
onRawReady: (job) => renderer.enqueue(job),
|
onRawReady: (job) => renderer.enqueue(job),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Go-Live-Ankuendigung auf Discord
|
||||||
|
const announcer = createAnnouncer({
|
||||||
|
cfg: {
|
||||||
|
enabled: cfg.broadcaster.enabled,
|
||||||
|
mode: cfg.broadcaster.mode,
|
||||||
|
delaySeconds: cfg.broadcaster.delaySecondsAfterOnline,
|
||||||
|
cooldownHours: cfg.broadcaster.cooldownHours,
|
||||||
|
pingRole: cfg.broadcaster.pingRole,
|
||||||
|
obsScreenshot: cfg.broadcaster.obsScreenshot,
|
||||||
|
},
|
||||||
|
repo,
|
||||||
|
obs,
|
||||||
|
});
|
||||||
|
|
||||||
startServer({
|
startServer({
|
||||||
channel: cfg.channel.login,
|
channel: cfg.channel.login,
|
||||||
port: cfg.dashboard.port,
|
port: cfg.dashboard.port,
|
||||||
@@ -118,6 +135,7 @@ export async function runIngest(): Promise<void> {
|
|||||||
...state,
|
...state,
|
||||||
db: repo.stats(),
|
db: repo.stats(),
|
||||||
discordConnected: bot ? bot.isConnected() : false,
|
discordConnected: bot ? bot.isConnected() : false,
|
||||||
|
goLive: announcer.lastResult() ?? state.goLive,
|
||||||
spikeArmed: spikes.isArmed(),
|
spikeArmed: spikes.isArmed(),
|
||||||
clipsWaitingForFile: clipper.pendingCount(),
|
clipsWaitingForFile: clipper.pendingCount(),
|
||||||
renderQueue: renderer.pending(),
|
renderQueue: renderer.pending(),
|
||||||
@@ -177,7 +195,10 @@ export async function runIngest(): Promise<void> {
|
|||||||
if (bot) void bot.run(abort.signal);
|
if (bot) void bot.run(abort.signal);
|
||||||
|
|
||||||
// ---- Zuschauerzahlen und Stream-Sessions
|
// ---- Zuschauerzahlen und Stream-Sessions
|
||||||
void pollStream(cfg, repo, state, abort.signal);
|
void pollStream(cfg, repo, state, abort.signal, (stream) => {
|
||||||
|
state.goLive = 'laeuft';
|
||||||
|
announcer.announce(stream);
|
||||||
|
});
|
||||||
|
|
||||||
log.info(`Ingest laeuft fuer #${cfg.channel.login}`);
|
log.info(`Ingest laeuft fuer #${cfg.channel.login}`);
|
||||||
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
|
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
|
||||||
@@ -185,14 +206,16 @@ export async function runIngest(): Promise<void> {
|
|||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
streamId: string | null; broadcasterId: string | null;
|
streamId: string | null; broadcasterId: string | null;
|
||||||
lastSeenLive: number; viewers: number; lastError: string | null;
|
lastSeenLive: number; viewers: number; goLive: string; lastError: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function pollStream(
|
async function pollStream(
|
||||||
cfg: ReturnType<typeof loadConfig>,
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
repo: ReturnType<typeof repos>,
|
repo: ReturnType<typeof repos>,
|
||||||
state: State,
|
state: State,
|
||||||
signal: AbortSignal
|
signal: AbortSignal,
|
||||||
|
/** Genau einmal je neu erkanntem Stream. Fortsetzungen zaehlen nicht. */
|
||||||
|
onOnline: (stream: StreamInfo) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const gapMs = cfg.ingest.sessionGapMinutes * 60_000;
|
const gapMs = cfg.ingest.sessionGapMinutes * 60_000;
|
||||||
let warnedMissingAuth = false;
|
let warnedMissingAuth = false;
|
||||||
@@ -208,10 +231,12 @@ async function pollStream(
|
|||||||
|
|
||||||
while (!signal.aborted) {
|
while (!signal.aborted) {
|
||||||
try {
|
try {
|
||||||
if (!readSecrets().tokens.broadcaster) {
|
// Client-ID und Secret genuegen: `GET /users` und `GET /streams` laufen
|
||||||
|
// mit einem App-Token, auch ohne verbundenes Konto.
|
||||||
|
if (!helix.canReadStream()) {
|
||||||
if (!warnedMissingAuth) {
|
if (!warnedMissingAuth) {
|
||||||
log.warn('Kein Broadcaster-Token - Zuschauerzahlen und Stream-Sitzungen bleiben aus. ' +
|
log.warn('Keine Twitch-App hinterlegt - Zuschauerzahlen und Stream-Sitzungen ' +
|
||||||
'Einrichtung unter /setup.');
|
'bleiben aus. Einrichtung unter /setup.');
|
||||||
warnedMissingAuth = true;
|
warnedMissingAuth = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -223,33 +248,49 @@ async function pollStream(
|
|||||||
const live = userId ? await helix.streamByUserId(userId) : null;
|
const live = userId ? await helix.streamByUserId(userId) : null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (live) {
|
// Die Entscheidung "neuer Stream oder Fortsetzung" steht in
|
||||||
if (state.streamId !== live.id) {
|
// #twitch/stream-state.ts und ist dort geprueft. Sie haengt daran,
|
||||||
// Kurze Unterbrechungen gelten als dieselbe Sitzung, sonst
|
// ob ein zweiter Rundruf rausgeht.
|
||||||
// zerfaellt ein Stream bei jedem Aussetzer in Fragmente.
|
const wechsel = streamWechsel({
|
||||||
const wasRecent = now - state.lastSeenLive < gapMs;
|
live: live ? { id: live.id, startedAt: live.started_at } : null,
|
||||||
if (state.streamId && wasRecent) {
|
laufendeId: state.streamId,
|
||||||
log.info(`Neue Twitch-Stream-ID ${live.id}, behandle als Fortsetzung von ${state.streamId}`);
|
zuletztLive: state.lastSeenLive,
|
||||||
} else {
|
jetzt: now,
|
||||||
state.streamId = live.id;
|
luckeMs: gapMs,
|
||||||
repo.openStream(live.id, live.started_at);
|
});
|
||||||
|
|
||||||
|
if (wechsel.art === 'gestartet') {
|
||||||
|
state.streamId = wechsel.id;
|
||||||
|
repo.openStream(wechsel.id, wechsel.startedAt);
|
||||||
repo.event('stream.online',
|
repo.event('stream.online',
|
||||||
{ title: live.title, category: live.game_name }, live.id);
|
{ title: live!.title, category: live!.game_name }, wechsel.id);
|
||||||
log.info(`Stream online: "${live.title}" (${live.game_name})`);
|
log.info(`Stream online: "${live!.title}" (${live!.game_name}), ` +
|
||||||
}
|
`gestartet ${wechsel.startedAt}`);
|
||||||
|
onOnline({
|
||||||
|
id: wechsel.id,
|
||||||
|
title: live!.title,
|
||||||
|
category: live!.game_name,
|
||||||
|
login: cfg.channel.login,
|
||||||
|
startedAt: wechsel.startedAt,
|
||||||
|
thumbnailUrl: live!.thumbnail_url,
|
||||||
|
});
|
||||||
|
} else if (wechsel.art === 'fortsetzung') {
|
||||||
|
log.info(`Neue Twitch-Stream-ID ${wechsel.neueId}, behandle als Fortsetzung ` +
|
||||||
|
`von ${state.streamId}`);
|
||||||
|
} else if (wechsel.art === 'beendet') {
|
||||||
|
repo.closeStream(wechsel.id, new Date().toISOString());
|
||||||
|
repo.event('stream.offline', {}, wechsel.id);
|
||||||
|
log.info(`Stream offline: ${wechsel.id}`);
|
||||||
|
state.streamId = null;
|
||||||
|
state.viewers = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (live) {
|
||||||
state.lastSeenLive = now;
|
state.lastSeenLive = now;
|
||||||
state.viewers = live.viewer_count;
|
state.viewers = live.viewer_count;
|
||||||
if (state.streamId) {
|
if (state.streamId) {
|
||||||
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
|
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
|
||||||
}
|
}
|
||||||
} else if (state.streamId && now - state.lastSeenLive > gapMs) {
|
|
||||||
const ended = new Date().toISOString();
|
|
||||||
repo.closeStream(state.streamId, ended);
|
|
||||||
repo.event('stream.offline', {}, state.streamId);
|
|
||||||
log.info(`Stream offline: ${state.streamId}`);
|
|
||||||
state.streamId = null;
|
|
||||||
state.viewers = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -59,18 +59,45 @@ async function handle(
|
|||||||
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps
|
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`);
|
const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`);
|
||||||
|
// Auf dem Stream-PC ist die Einrichtung das Einzige, was dieser Server
|
||||||
|
// kann - also faengt die Wurzel dorthin. Im Hub steht auf / die
|
||||||
|
// Overlay-Uebersicht, deshalb liegt die Weiche hier und nicht in
|
||||||
|
// `setupRoutes`.
|
||||||
|
if (url.pathname === '/') {
|
||||||
|
res.writeHead(303, { location: '/setup' });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await setupRoutes(req, res, deps, url)) return;
|
||||||
|
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Routen der Einrichtung, ohne 404 am Ende.
|
||||||
|
*
|
||||||
|
* Ausgelagert, weil sie seit dem zentralen Betrieb an zwei Servern haengen:
|
||||||
|
* hier auf dem Stream-PC und im Overlay-Server des Hubs. Der Rueckgabewert
|
||||||
|
* sagt, ob die Anfrage beantwortet wurde - wer sie einbindet, macht sonst
|
||||||
|
* selbst weiter.
|
||||||
|
*/
|
||||||
|
export async function setupRoutes(
|
||||||
|
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps, url: URL
|
||||||
|
): Promise<boolean> {
|
||||||
const html = (body: string, code = 200) => {
|
const html = (body: string, code = 200) => {
|
||||||
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8',
|
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8',
|
||||||
'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
|
'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
|
||||||
res.end(body);
|
res.end(body);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
const back = (kind: 'ok' | 'err', text: string) => {
|
const back = (kind: 'ok' | 'err', text: string) => {
|
||||||
res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` });
|
res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` });
|
||||||
res.end();
|
res.end();
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Einrichtungsseite
|
// ---- Einrichtungsseite
|
||||||
if (url.pathname === '/' || url.pathname === '/setup') {
|
if (url.pathname === '/setup') {
|
||||||
const ok = url.searchParams.get('ok');
|
const ok = url.searchParams.get('ok');
|
||||||
const err = url.searchParams.get('err');
|
const err = url.searchParams.get('err');
|
||||||
return html(setupPage(secretStatus(), {
|
return html(setupPage(secretStatus(), {
|
||||||
@@ -119,7 +146,7 @@ async function handle(
|
|||||||
try {
|
try {
|
||||||
res.writeHead(303, { location: authorizeUrl(role, deps.port) });
|
res.writeHead(303, { location: authorizeUrl(role, deps.port) });
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return back('err', (err as Error).message);
|
return back('err', (err as Error).message);
|
||||||
}
|
}
|
||||||
@@ -144,9 +171,8 @@ async function handle(
|
|||||||
if (url.pathname === '/health') {
|
if (url.pathname === '/health') {
|
||||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
res.end(JSON.stringify(deps.health(), null, 2));
|
res.end(JSON.stringify(deps.health(), null, 2));
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
return false;
|
||||||
res.end('Nicht gefunden');
|
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ function msg(text: string, userId: string, advanceMs = 0): ChatMessage {
|
|||||||
clock += advanceMs;
|
clock += advanceMs;
|
||||||
return {
|
return {
|
||||||
id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId,
|
id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId,
|
||||||
login: `u${userId}`, displayName: `U${userId}`, text, emotes: [],
|
login: `u${userId}`, displayName: `U${userId}`, text, emotes: [], gifs: [],
|
||||||
isFirst: false, isReturning: false, isCommand: text.startsWith('!'),
|
isFirst: false, isReturning: false, isCommand: text.startsWith('!'),
|
||||||
isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null,
|
isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,50 @@
|
|||||||
# @abdulive/overlay-server
|
# @abdulive/overlay-server
|
||||||
|
|
||||||
WebSocket-Backend und Command-Registry fuer alle Overlays.
|
Der einzige Server, der von außen erreichbar ist. Liefert die Seiten aus
|
||||||
|
[`overlay-web`](../overlay-web/README.md) aus, hält den Ereignisstrom dorthin
|
||||||
|
offen und bringt Einrichtung und OAuth-Rückruf unter dieselbe Adresse.
|
||||||
|
|
||||||
Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md)
|
```
|
||||||
|
/ Übersicht mit den Adressen für OBS Passwort
|
||||||
|
/setup Einrichtung: Twitch-Apps, Tokens Passwort
|
||||||
|
/callback OAuth-Rückruf von Twitch offen, `state` schützt
|
||||||
|
/alerts … die Browser-Sources Schlüssel in der URL
|
||||||
|
/events der Ereignisstrom dahinter Schlüssel in der URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-Sent Events statt WebSocket
|
||||||
|
|
||||||
|
[`chat-control.md`](../../docs/projects/chat-control.md) skizziert einen
|
||||||
|
WS-Kanal. Es sind SSE geworden, aus drei Gründen:
|
||||||
|
|
||||||
|
1. Node bringt einen WebSocket-*Client* mit, aber keinen Server. Den müsste
|
||||||
|
man selbst schreiben, inklusive Handshake und Rahmenformat - in einem
|
||||||
|
Projekt ohne Abhängigkeiten heißt das: selbst pflegen.
|
||||||
|
2. `EventSource` verbindet sich von allein neu. Die tote Browser-Source nach
|
||||||
|
einem Neustart ist der häufigste Fehler bei solchen Overlays, und hier
|
||||||
|
löst ihn der Browser.
|
||||||
|
3. Die Richtung stimmt. Das Overlay hört nur zu; es sendet nie.
|
||||||
|
|
||||||
|
Drei Dinge halten die Verbindung ehrlich:
|
||||||
|
|
||||||
|
- **Herzschlag alle 15 s**, als echtes Ereignis und nicht als SSE-Kommentar -
|
||||||
|
ein Kommentar hält die Verbindung offen, ist im Browser aber unsichtbar.
|
||||||
|
- **Wachhund im Client**: 45 s ohne Nachricht heißt neu verbinden. OBS hält
|
||||||
|
eine tote Verbindung erstaunlich lange für lebendig.
|
||||||
|
- **Schnappschuss beim Verbinden**: der letzte Stream-Zustand und die letzten
|
||||||
|
Chatnachrichten, damit die Quelle nach dem Neuverbinden nicht leer im Bild
|
||||||
|
steht.
|
||||||
|
|
||||||
|
## Zugang
|
||||||
|
|
||||||
|
Siehe [ADR-0008](../../docs/decisions/ADR-0008-geteilter-betrieb.md).
|
||||||
|
|
||||||
|
- **Passwort** für alles, was ein Mensch bedient. Setzen mit
|
||||||
|
`abdulive web:passwort`, nie über das Netz - ein Dienst, bei dem man das
|
||||||
|
Passwort über die offene Weboberfläche setzen kann, hat kein Passwort.
|
||||||
|
- **Schlüssel in der Adresse** für die Browser-Sources. Eine Browser-Source
|
||||||
|
kann kein Formular ausfüllen. Der Schlüssel darf nur lesen und lässt sich
|
||||||
|
auf der Übersichtsseite neu würfeln.
|
||||||
|
|
||||||
|
Ohne `publicUrl` bindet der Server weiter auf `127.0.0.1` und verhält sich wie
|
||||||
|
vorher.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Das Sitzungs-Cookie ist der einzige Schutz vor der Einrichtungsseite, auf
|
||||||
|
// der Twitch-Zugangsdaten eingetragen werden. Hier wird geprueft, dass es
|
||||||
|
// haelt, was es soll - und dass es einen Neustart übersteht.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { sitzungGueltig, sitzungToken } from './auth.ts';
|
||||||
|
|
||||||
|
const KEY = crypto.randomBytes(32);
|
||||||
|
const HASH = 'ein-passwort-hash';
|
||||||
|
const JETZT = Date.parse('2026-09-17T18:00:00Z');
|
||||||
|
const IN_30_TAGEN = JETZT + 30 * 24 * 3_600_000;
|
||||||
|
|
||||||
|
test('ein frisch ausgestelltes Cookie gilt', () => {
|
||||||
|
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||||
|
assert.equal(sitzungGueltig(t, KEY, HASH, JETZT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein Neustart meldet niemanden ab', () => {
|
||||||
|
// Der eigentliche Grund für diese Bauform: der Dienst startet bei jedem
|
||||||
|
// Ausrollen neu. Lag die Sitzungsliste im Arbeitsspeicher, war man danach
|
||||||
|
// jedes Mal draußen. Derselbe Schlüssel - dasselbe Ergebnis, ohne dass
|
||||||
|
// irgendwo etwas gemerkt wurde.
|
||||||
|
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||||
|
const nachNeustart = Buffer.from(KEY); // derselbe Wert, neues Objekt
|
||||||
|
assert.equal(sitzungGueltig(t, nachNeustart, HASH, JETZT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('abgelaufen ist abgelaufen', () => {
|
||||||
|
const t = sitzungToken(KEY, HASH, JETZT - 1);
|
||||||
|
assert.equal(sitzungGueltig(t, KEY, HASH, JETZT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('das Ablaufdatum laesst sich nicht verlaengern', () => {
|
||||||
|
// Ohne Signatur ueber das Datum koennte jeder das Cookie umschreiben und
|
||||||
|
// waere damit dauerhaft angemeldet.
|
||||||
|
const t = sitzungToken(KEY, HASH, JETZT + 1000);
|
||||||
|
const [, sig] = t.split('.');
|
||||||
|
const gefaelscht = `${Buffer.from(String(IN_30_TAGEN)).toString('base64url')}.${sig}`;
|
||||||
|
assert.equal(sitzungGueltig(gefaelscht, KEY, HASH, JETZT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein fremder Schluessel oeffnet nichts', () => {
|
||||||
|
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||||
|
assert.equal(sitzungGueltig(t, crypto.randomBytes(32), HASH, JETZT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein neues Passwort macht alle Cookies ungueltig', () => {
|
||||||
|
// Das ist der Ersatz für eine Abmeldeliste: wer sein Passwort wechselt,
|
||||||
|
// wirft damit alle offenen Sitzungen raus.
|
||||||
|
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||||
|
assert.equal(sitzungGueltig(t, KEY, 'neuer-hash', JETZT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unsinn im Cookie wird abgewiesen, nicht geglaubt', () => {
|
||||||
|
for (const müll of [undefined, '', 'abc', 'abc.def', '.', 'x.y.z',
|
||||||
|
`${Buffer.from('keine-zahl').toString('base64url')}.xx`]) {
|
||||||
|
assert.equal(sitzungGueltig(müll, KEY, HASH, JETZT), false, `"${müll}" haette abgewiesen gehoert`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zwei Anmeldungen mit demselben Ablauf ergeben dasselbe Cookie', () => {
|
||||||
|
// Bewusst so: ohne Zufallsanteil gibt es nichts, was der Server sich merken
|
||||||
|
// müsste. Der Preis ist, dass sich eine einzelne Sitzung nicht gezielt
|
||||||
|
// beenden lässt - dafür gibt es den Passwortwechsel.
|
||||||
|
assert.equal(sitzungToken(KEY, HASH, IN_30_TAGEN), sitzungToken(KEY, HASH, IN_30_TAGEN));
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// Zugangskontrolle. Nur noetig, seit der Dienst zentral laeuft: solange er an
|
||||||
|
// 127.0.0.1 hing, war "wer am Rechner sitzt, darf" die ganze Regel.
|
||||||
|
//
|
||||||
|
// Zwei Wege, weil es zwei Arten von Besuchern gibt:
|
||||||
|
//
|
||||||
|
// Mensch -> Passwort, einmal eingeben, danach ein Sitzungs-Cookie.
|
||||||
|
// Gilt fuer die Uebersicht, die Einrichtung und den Status.
|
||||||
|
// OBS -> Schluessel in der Adresse (?key=...). Eine Browser-Source
|
||||||
|
// kann kein Formular ausfuellen und keinen Login durchhalten;
|
||||||
|
// ein Geheimnis in der URL ist hier der einzige Weg, der ohne
|
||||||
|
// Handgriff mitten im Stream auskommt.
|
||||||
|
//
|
||||||
|
// Der Schluessel steht damit in jedem OBS-Profil und in jedem Screenshot der
|
||||||
|
// Einstellungen. Deshalb kann er nur lesen, nie schreiben, und er laesst sich
|
||||||
|
// auf der Uebersichtsseite neu wuerfeln.
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import type http from 'node:http';
|
||||||
|
import { logger } from '#core/logger.ts';
|
||||||
|
import { readSecrets, updateSecrets } from '#twitch/secrets.ts';
|
||||||
|
|
||||||
|
const log = logger('overlay:zugang');
|
||||||
|
|
||||||
|
/** Wie lange eine Anmeldung gilt. Danach einmal neu eintippen. */
|
||||||
|
const SESSION_MS = 30 * 24 * 3_600_000;
|
||||||
|
const COOKIE = 'abdulive_sitzung';
|
||||||
|
|
||||||
|
export type Zugang = {
|
||||||
|
/** Darf diese Anfrage eine geschuetzte Seite sehen? */
|
||||||
|
istAngemeldet: (req: http.IncomingMessage) => boolean;
|
||||||
|
/** Stimmt der Schluessel in der Adresse? Angemeldete duerfen auch ohne. */
|
||||||
|
darfOverlay: (req: http.IncomingMessage, url: URL) => boolean;
|
||||||
|
/** Prueft das Passwort und gibt bei Erfolg den Cookie-Kopf zurueck. */
|
||||||
|
anmelden: (passwort: string) => string | null;
|
||||||
|
abmelden: () => string;
|
||||||
|
overlayKey: () => string;
|
||||||
|
neuerOverlayKey: () => string;
|
||||||
|
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
|
||||||
|
hatPasswort: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergleich in konstanter Zeit. Bei einem einzelnen Passwortfeld ist der
|
||||||
|
* Zeitunterschied kaum messbar, aber das ist kein Grund, ihn zu verschenken.
|
||||||
|
*/
|
||||||
|
function gleich(a: string, b: string): boolean {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
if (ab.length !== bb.length) return false;
|
||||||
|
return crypto.timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash(passwort: string, salt: string): string {
|
||||||
|
return crypto.scryptSync(passwort, salt, 32).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Cookie traegt sein eigenes Ablaufdatum und eine Signatur darueber.
|
||||||
|
* Der Server merkt sich keine Sitzungen.
|
||||||
|
*
|
||||||
|
* Der Grund ist eine gelernte Lektion: eine Liste im Arbeitsspeicher ist
|
||||||
|
* hinfaellig, sobald der Dienst neu startet - und er startet bei jedem
|
||||||
|
* Ausrollen neu. Wer sich nach jeder Aenderung neu anmelden muss, waehlt
|
||||||
|
* irgendwann ein kuerzeres Passwort.
|
||||||
|
*
|
||||||
|
* Mit signiert wird auch der Passwort-Hash. Ein neues Passwort macht damit
|
||||||
|
* alle ausgegebenen Cookies ungueltig, ohne dass es eine Abmeldeliste braucht.
|
||||||
|
*/
|
||||||
|
export function sitzungToken(key: Buffer, passwordHash: string, bisMs: number): string {
|
||||||
|
const bis = String(bisMs);
|
||||||
|
return `${Buffer.from(bis).toString('base64url')}.${signatur(key, passwordHash, bis)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sitzungGueltig(
|
||||||
|
token: string | undefined, key: Buffer, passwordHash: string, jetztMs = Date.now()
|
||||||
|
): boolean {
|
||||||
|
if (!token) return false;
|
||||||
|
const [bisB64, sig] = token.split('.');
|
||||||
|
if (!bisB64 || !sig) return false;
|
||||||
|
const bis = Buffer.from(bisB64, 'base64url').toString('utf8');
|
||||||
|
if (!/^\d+$/.test(bis) || Number(bis) < jetztMs) return false;
|
||||||
|
return gleich(sig, signatur(key, passwordHash, bis));
|
||||||
|
}
|
||||||
|
|
||||||
|
function signatur(key: Buffer, passwordHash: string, bis: string): string {
|
||||||
|
return crypto.createHmac('sha256', key)
|
||||||
|
.update(`${bis}|${passwordHash}`)
|
||||||
|
.digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookies(req: http.IncomingMessage): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const teil of (req.headers.cookie ?? '').split(';')) {
|
||||||
|
const i = teil.indexOf('=');
|
||||||
|
if (i > 0) out[teil.slice(0, i).trim()] = decodeURIComponent(teil.slice(i + 1).trim());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createZugang(): Zugang {
|
||||||
|
function store() {
|
||||||
|
const s = readSecrets();
|
||||||
|
return s.web ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim ersten Start einen Overlay-Schluessel wuerfeln. Ohne einen waere
|
||||||
|
// jede Browser-Source-Adresse zu erraten.
|
||||||
|
function key(): string {
|
||||||
|
const vorhanden = store().overlayKey;
|
||||||
|
if (vorhanden) return vorhanden;
|
||||||
|
const neu = crypto.randomBytes(18).toString('base64url');
|
||||||
|
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
|
||||||
|
log.info('Neuer Overlay-Schluessel erzeugt - die Adressen stehen auf der Uebersichtsseite');
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schluessel zum Signieren der Sitzungen. Liegt neben dem Passwort, wird
|
||||||
|
* einmal gewuerfelt und ueberlebt damit jeden Neustart. Geht er verloren,
|
||||||
|
* sind nur die Anmeldungen weg.
|
||||||
|
*/
|
||||||
|
function sitzungsKey(): Buffer {
|
||||||
|
const vorhanden = store().sessionKey;
|
||||||
|
if (vorhanden) return Buffer.from(vorhanden, 'base64');
|
||||||
|
const neu = crypto.randomBytes(32);
|
||||||
|
updateSecrets((s) => { s.web = { ...s.web, sessionKey: neu.toString('base64') }; });
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function istAngemeldet(req: http.IncomingMessage): boolean {
|
||||||
|
const hash = store().passwordHash;
|
||||||
|
if (!hash) return true; // noch nichts eingerichtet
|
||||||
|
return sitzungGueltig(cookies(req)[COOKIE], sitzungsKey(), hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hatPasswort: () => Boolean(store().passwordHash),
|
||||||
|
istAngemeldet,
|
||||||
|
|
||||||
|
darfOverlay(req, url) {
|
||||||
|
const gegeben = url.searchParams.get('key');
|
||||||
|
if (gegeben && gleich(gegeben, key())) return true;
|
||||||
|
// Wer angemeldet ist, darf die Seiten auch im Browser ansehen - sonst
|
||||||
|
// muesste man zum Nachschauen jedes Mal den Schluessel anhaengen.
|
||||||
|
return istAngemeldet(req);
|
||||||
|
},
|
||||||
|
|
||||||
|
anmelden(passwort) {
|
||||||
|
const s = store();
|
||||||
|
if (!s.passwordHash || !s.passwordSalt) return null;
|
||||||
|
if (!gleich(hash(passwort, s.passwordSalt), s.passwordHash)) {
|
||||||
|
log.warn('Fehlgeschlagene Anmeldung');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const token = sitzungToken(sitzungsKey(), s.passwordHash, Date.now() + SESSION_MS);
|
||||||
|
// Kein `Secure`: NPM beendet TLS, und der Dienst selbst spricht HTTP.
|
||||||
|
// Das Cookie geht trotzdem nur ueber die HTTPS-Adresse raus, weil es
|
||||||
|
// keine andere gibt, die von aussen erreichbar ist.
|
||||||
|
return `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MS / 1000}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
abmelden: () => `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
|
||||||
|
overlayKey: key,
|
||||||
|
neuerOverlayKey() {
|
||||||
|
const neu = crypto.randomBytes(18).toString('base64url');
|
||||||
|
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
|
||||||
|
log.info('Overlay-Schluessel neu gewuerfelt - die alten Adressen sind damit tot');
|
||||||
|
return neu;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt das Passwort. Wird von `abdulive web:passwort` aufgerufen, nicht ueber
|
||||||
|
* das Netz - ein Dienst, bei dem man das Passwort ueber die offene
|
||||||
|
* Weboberflaeche setzen kann, hat kein Passwort.
|
||||||
|
*/
|
||||||
|
export function setzePasswort(passwort: string): void {
|
||||||
|
if (passwort.length < 8) throw new Error('Mindestens acht Zeichen.');
|
||||||
|
const salt = crypto.randomBytes(16).toString('base64');
|
||||||
|
updateSecrets((s) => {
|
||||||
|
s.web = { ...s.web, passwordSalt: salt, passwordHash: hash(passwort, salt) };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Der Bus entscheidet, was eine frisch verbundene Browser-Source sieht.
|
||||||
|
// Danach richtet sich, ob das Overlay nach einem Neustart vollstaendig ist
|
||||||
|
// oder leer im Bild steht - und ob eine geloeschte Nachricht geloescht bleibt.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createBus, type ChatEvent, type OverlayEvent } from './bus.ts';
|
||||||
|
|
||||||
|
function nachricht(over: Partial<ChatEvent> = {}): ChatEvent {
|
||||||
|
return {
|
||||||
|
kind: 'chat', id: 'm1', userId: 'u1', login: 'kevin', name: 'Kevin',
|
||||||
|
text: 'moin', color: null, emotes: [], gifs: [], first: false, mod: false,
|
||||||
|
sub: false, broadcaster: false, bits: 0,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function schnappschuss(bus: ReturnType<typeof createBus>) {
|
||||||
|
const s = bus.snapshot();
|
||||||
|
assert.equal(s.kind, 'state');
|
||||||
|
return s as Extract<OverlayEvent, { kind: 'state' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ein neuer Zuhoerer bekommt den bisherigen Chat', () => {
|
||||||
|
const bus = createBus();
|
||||||
|
bus.publish(nachricht({ id: 'a' }));
|
||||||
|
bus.publish(nachricht({ id: 'b' }));
|
||||||
|
|
||||||
|
const s = schnappschuss(bus);
|
||||||
|
assert.deepEqual(s.chat.map((m) => m.id), ['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Rueckblick waechst nicht unbegrenzt', () => {
|
||||||
|
const bus = createBus();
|
||||||
|
for (let i = 0; i < 100; i++) bus.publish(nachricht({ id: `m${i}` }));
|
||||||
|
|
||||||
|
const s = schnappschuss(bus);
|
||||||
|
assert.equal(s.chat.length, 30);
|
||||||
|
assert.equal(s.chat[s.chat.length - 1]?.id, 'm99', 'die neuesten bleiben');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der zuletzt gemeldete Stream-Zustand steht im Schnappschuss', () => {
|
||||||
|
const bus = createBus();
|
||||||
|
bus.publish({ kind: 'stream', live: true, title: 'Titel', category: 'Elden Ring',
|
||||||
|
viewers: 12, startedAt: '2026-09-17T18:00:00Z' });
|
||||||
|
bus.publish({ kind: 'stream', live: true, title: 'Titel', category: 'Elden Ring',
|
||||||
|
viewers: 19, startedAt: '2026-09-17T18:00:00Z' });
|
||||||
|
|
||||||
|
assert.equal(schnappschuss(bus).stream.viewers, 19);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine geloeschte Nachricht verschwindet auch aus dem Rueckblick', () => {
|
||||||
|
// Sonst holt der naechste Neustart der Browser-Source sie zurueck ins Bild -
|
||||||
|
// und zwar genau die eine, die ein Mod entfernt hat.
|
||||||
|
const bus = createBus();
|
||||||
|
bus.publish(nachricht({ id: 'a' }));
|
||||||
|
bus.publish(nachricht({ id: 'b' }));
|
||||||
|
bus.publish({ kind: 'clear', messageId: 'a' });
|
||||||
|
|
||||||
|
assert.deepEqual(schnappschuss(bus).chat.map((m) => m.id), ['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein gesperrter Chatter verschwindet mit allen Nachrichten', () => {
|
||||||
|
const bus = createBus();
|
||||||
|
bus.publish(nachricht({ id: 'a', userId: 'u1' }));
|
||||||
|
bus.publish(nachricht({ id: 'b', userId: 'u2' }));
|
||||||
|
bus.publish(nachricht({ id: 'c', userId: 'u1' }));
|
||||||
|
bus.publish({ kind: 'clear', userId: 'u1' });
|
||||||
|
|
||||||
|
assert.deepEqual(schnappschuss(bus).chat.map((m) => m.id), ['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein geleerter Chat leert auch den Rueckblick', () => {
|
||||||
|
const bus = createBus();
|
||||||
|
bus.publish(nachricht({ id: 'a' }));
|
||||||
|
bus.publish({ kind: 'clear' });
|
||||||
|
assert.deepEqual(schnappschuss(bus).chat, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alte Einblendungen werden nicht endlos aufbewahrt', () => {
|
||||||
|
const bus = createBus();
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
bus.publish({ kind: 'alert', type: 'sub', id: `a${i}`, name: 'x' });
|
||||||
|
}
|
||||||
|
assert.equal(schnappschuss(bus).alerts.length, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein Zuhoerer, der wirft, reisst die anderen nicht mit', () => {
|
||||||
|
// Eine Browser-Source, deren Verbindung gerade zusammengebrochen ist, darf
|
||||||
|
// den Chat nicht fuer alle anderen anhalten.
|
||||||
|
const bus = createBus();
|
||||||
|
const gesehen: string[] = [];
|
||||||
|
bus.subscribe(() => { throw new Error('Verbindung weg'); });
|
||||||
|
bus.subscribe((ev) => { gesehen.push(ev.kind); });
|
||||||
|
|
||||||
|
bus.publish(nachricht());
|
||||||
|
assert.deepEqual(gesehen, ['chat']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('abgemeldete Zuhoerer bekommen nichts mehr', () => {
|
||||||
|
const bus = createBus();
|
||||||
|
let n = 0;
|
||||||
|
const ab = bus.subscribe(() => { n++; });
|
||||||
|
bus.publish(nachricht());
|
||||||
|
ab();
|
||||||
|
bus.publish(nachricht());
|
||||||
|
|
||||||
|
assert.equal(n, 1);
|
||||||
|
assert.equal(bus.clients(), 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Der Ereignisbus zwischen Daemon und Browser-Sources.
|
||||||
|
//
|
||||||
|
// Warum Server-Sent Events und nicht WebSocket, wie in chat-control.md
|
||||||
|
// skizziert: Node bringt einen WebSocket-*Client* mit, aber keinen Server -
|
||||||
|
// den muesste man selbst schreiben, inklusive Handshake und Rahmenformat.
|
||||||
|
// SSE ist einfacher HTTP, `EventSource` verbindet sich von allein neu, und
|
||||||
|
// die Richtung stimmt: das Overlay hoert nur zu, es sendet nie.
|
||||||
|
//
|
||||||
|
// Der haeufigste Fehler bei solchen Overlays ist die Browser-Source, die nach
|
||||||
|
// einem Serverneustart tot im Bild haengt. Dagegen stehen hier drei Dinge:
|
||||||
|
// der eingebaute Reconnect von EventSource, ein Herzschlag alle paar
|
||||||
|
// Sekunden, und der Zustands-Schnappschuss, den jeder neue Client als erstes
|
||||||
|
// bekommt - damit ist das Overlay nach dem Neuverbinden sofort wieder
|
||||||
|
// vollstaendig und nicht erst nach der naechsten Chatnachricht.
|
||||||
|
|
||||||
|
export type ChatEvent = {
|
||||||
|
kind: 'chat';
|
||||||
|
/** msg-id aus IRC - dieselbe ID, mit der CLEARMSG spaeter loeschen kann. */
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
color: string | null;
|
||||||
|
emotes: Array<{ id: string; name: string; count: number }>;
|
||||||
|
/** GIFs aus Twitchs Auswahlfenster, mit dem Zeichenbereich im Text. */
|
||||||
|
gifs: Array<{ id: string; url: string; from: number; to: number }>;
|
||||||
|
first: boolean;
|
||||||
|
mod: boolean;
|
||||||
|
sub: boolean;
|
||||||
|
broadcaster: boolean;
|
||||||
|
bits: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlertKind =
|
||||||
|
| 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'cheer' | 'first' | 'clip' | 'follow'
|
||||||
|
/**
|
||||||
|
* Kanalpunkt-Einloesung. Bewusst eine eigene Art und nicht die des Abos:
|
||||||
|
* eine eingeloeste Belohnung als "Neues Abo" einzublenden ist eine
|
||||||
|
* Falschmeldung im Bild, und zwar eine, die in der Aufzeichnung bleibt.
|
||||||
|
*/
|
||||||
|
| 'reward';
|
||||||
|
|
||||||
|
export type AlertEvent = {
|
||||||
|
kind: 'alert';
|
||||||
|
type: AlertKind;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Bits, Zuschauer beim Raid, Anzahl verschenkter Abos. */
|
||||||
|
amount?: number;
|
||||||
|
months?: number;
|
||||||
|
tier?: string;
|
||||||
|
recipient?: string;
|
||||||
|
message?: string;
|
||||||
|
/** Titel der eingeloesten Belohnung. Nur bei `reward`. */
|
||||||
|
reward?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamEvent = {
|
||||||
|
kind: 'stream';
|
||||||
|
live: boolean;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
viewers: number;
|
||||||
|
startedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OverlayEvent =
|
||||||
|
| ChatEvent
|
||||||
|
| AlertEvent
|
||||||
|
| StreamEvent
|
||||||
|
| { kind: 'hype'; score: number; rate: number }
|
||||||
|
/**
|
||||||
|
* Konfetti als eigenes Ereignis, nicht als Nebenwirkung einer Einblendung.
|
||||||
|
* So muss die Effektseite nicht wissen, welche Belohnung welche Aktion
|
||||||
|
* ausloest - das entscheidet der Hub.
|
||||||
|
*/
|
||||||
|
| { kind: 'konfetti'; staerke: number }
|
||||||
|
/**
|
||||||
|
* Vorzulesender Text. Geht nur raus, wenn er freigegeben wurde oder die
|
||||||
|
* Belohnung ohne Freigabe eingerichtet ist - die Entscheidung faellt im Hub
|
||||||
|
* (apps/hub/src/rewards.ts), nicht hier.
|
||||||
|
*/
|
||||||
|
| { kind: 'tts'; id: string; name: string; text: string }
|
||||||
|
/** Alles sofort still. Fuer den Panikschalter. */
|
||||||
|
| { kind: 'still' }
|
||||||
|
| { kind: 'scene'; scene: string }
|
||||||
|
| { kind: 'clear'; userId?: string; messageId?: string }
|
||||||
|
| { kind: 'beat'; t: number }
|
||||||
|
| { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr
|
||||||
|
* braucht keine Seite: der Chat zeigt ein gutes Dutzend, und ein Alarm von
|
||||||
|
* vor zehn Minuten soll nach einem Neustart gerade nicht noch einmal
|
||||||
|
* durchlaufen.
|
||||||
|
*/
|
||||||
|
const KEEP_CHAT = 30;
|
||||||
|
const KEEP_ALERTS = 5;
|
||||||
|
|
||||||
|
export type Bus = {
|
||||||
|
publish: (ev: OverlayEvent) => void;
|
||||||
|
/** Meldet einen Zuhoerer an. Der Rueckgabewert meldet ihn wieder ab. */
|
||||||
|
subscribe: (fn: (ev: OverlayEvent) => void) => () => void;
|
||||||
|
/** Der Zustand, den ein neuer Client als erstes bekommt. */
|
||||||
|
snapshot: () => OverlayEvent;
|
||||||
|
clients: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createBus(): Bus {
|
||||||
|
const listeners = new Set<(ev: OverlayEvent) => void>();
|
||||||
|
const chat: ChatEvent[] = [];
|
||||||
|
const alerts: AlertEvent[] = [];
|
||||||
|
let stream: StreamEvent = {
|
||||||
|
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
publish(ev) {
|
||||||
|
// Mitschreiben, bevor verteilt wird: ein Client, der genau jetzt
|
||||||
|
// verbindet, soll dasselbe sehen wie einer, der schon verbunden war.
|
||||||
|
if (ev.kind === 'chat') {
|
||||||
|
chat.push(ev);
|
||||||
|
if (chat.length > KEEP_CHAT) chat.shift();
|
||||||
|
} else if (ev.kind === 'alert') {
|
||||||
|
alerts.push(ev);
|
||||||
|
if (alerts.length > KEEP_ALERTS) alerts.shift();
|
||||||
|
} else if (ev.kind === 'stream') {
|
||||||
|
stream = ev;
|
||||||
|
} else if (ev.kind === 'clear') {
|
||||||
|
// Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der
|
||||||
|
// naechste Neustart der Browser-Source es zurueck ins Bild.
|
||||||
|
for (let i = chat.length - 1; i >= 0; i--) {
|
||||||
|
const m = chat[i]!;
|
||||||
|
if ((ev.messageId && m.id === ev.messageId) ||
|
||||||
|
(ev.userId && m.userId === ev.userId)) chat.splice(i, 1);
|
||||||
|
}
|
||||||
|
if (!ev.messageId && !ev.userId) chat.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fn of listeners) {
|
||||||
|
// Ein kaputter Zuhoerer darf die anderen nicht mitreissen.
|
||||||
|
try { fn(ev); } catch { /* der Transport meldet es selbst */ }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe(fn) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => { listeners.delete(fn); };
|
||||||
|
},
|
||||||
|
|
||||||
|
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts] }),
|
||||||
|
clients: () => listeners.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Dauerbetrieb mit erfundenen Ereignissen: erfundener Chat und alle paar
|
||||||
|
// Sekunden eine Einblendung.
|
||||||
|
//
|
||||||
|
// Fuer `abdulive overlay --demo` und fuer den Knopf "Vorfuehrung" auf der
|
||||||
|
// Uebersichtsseite. Die einzelnen Ereignisse kommen aus test-events.ts - die
|
||||||
|
// Knoepfe und der Dauerbetrieb sollen dasselbe zeigen.
|
||||||
|
|
||||||
|
import { logger } from '#core/logger.ts';
|
||||||
|
import type { Bus } from './bus.ts';
|
||||||
|
import { testEvent, zufall, type TestArt } from './test-events.ts';
|
||||||
|
|
||||||
|
const log = logger('overlay:demo');
|
||||||
|
|
||||||
|
const ALARME: TestArt[] = ['sub', 'resub', 'subgift', 'massgift', 'raid', 'cheer', 'first'];
|
||||||
|
const NACHRICHTEN: TestArt[] = ['chat', 'chat', 'chat', 'chat-emote', 'chat-emote', 'chat-erstmals'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speist den Bus, bis `signal` abbricht.
|
||||||
|
*
|
||||||
|
* Der Stream-Zustand wird mitgesetzt, damit Statusleiste und Pausenbild etwas
|
||||||
|
* zu zeigen haben. Genau deshalb darf das nicht laufen, waehrend ein echter
|
||||||
|
* Stream laeuft - der Aufrufer prueft das.
|
||||||
|
*/
|
||||||
|
export function startDemo(bus: Bus, signal: AbortSignal): void {
|
||||||
|
log.info('Vorfuehrmodus: erfundener Chat und alle paar Sekunden eine Einblendung');
|
||||||
|
|
||||||
|
let viewers = 17;
|
||||||
|
const seit = new Date(Date.now() - 74 * 60_000).toISOString();
|
||||||
|
|
||||||
|
const stand = (): void => {
|
||||||
|
viewers = Math.max(3, viewers + Math.round((Math.random() - 0.45) * 4));
|
||||||
|
bus.publish({
|
||||||
|
kind: 'stream', live: true,
|
||||||
|
title: 'Elden Ring, aber nur mit Fäusten - Tag 3',
|
||||||
|
category: 'Elden Ring',
|
||||||
|
viewers, startedAt: seit,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
stand();
|
||||||
|
|
||||||
|
const sende = (art: TestArt): void => {
|
||||||
|
const ev = testEvent(art);
|
||||||
|
if (ev) bus.publish(ev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chat = setInterval(() => sende(zufall(NACHRICHTEN)), 1400);
|
||||||
|
const alarm = setInterval(() => sende(zufall(ALARME)), 9000);
|
||||||
|
const puls = setInterval(stand, 20_000);
|
||||||
|
// Ab und zu eine Chat-Spitze, damit der Emote-Regen auch mal laeuft.
|
||||||
|
const spitze = setInterval(() => sende('hype'), 31_000);
|
||||||
|
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
clearInterval(chat); clearInterval(alarm); clearInterval(puls); clearInterval(spitze);
|
||||||
|
log.info('Vorfuehrmodus beendet');
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
// Der einzige Server, der von aussen erreichbar ist.
|
||||||
|
//
|
||||||
|
// Er macht drei Dinge unter einer Adresse, weil ein zweiter oeffentlicher
|
||||||
|
// Port ein zweiter Eintrag im Reverse-Proxy und ein zweites Zertifikat waere:
|
||||||
|
//
|
||||||
|
// / Uebersicht mit den Adressen fuer OBS (Passwort)
|
||||||
|
// /setup Einrichtung: Twitch-Apps, Tokens, Discord (Passwort)
|
||||||
|
// /callback OAuth-Rueckruf von Twitch (offen, state schuetzt)
|
||||||
|
// /alerts ... die Browser-Sources (Schluessel)
|
||||||
|
// /events der SSE-Strom dahinter (Schluessel)
|
||||||
|
//
|
||||||
|
// Zum Binden: hinter dem Reverse-Proxy bindet der Dienst auf 0.0.0.0, damit
|
||||||
|
// der Proxy aus einem anderen Container drankommt. Das ist der Bruch mit
|
||||||
|
// ADR-0005, und der Schutz wandert dafuer von "nur localhost" auf Passwort
|
||||||
|
// und Schluessel. Ohne PUBLIC_URL bleibt es bei 127.0.0.1.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { logger } from '#core/logger.ts';
|
||||||
|
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
|
||||||
|
import { createZugang, type Zugang } from './auth.ts';
|
||||||
|
import { createBus, type Bus, type OverlayEvent } from './bus.ts';
|
||||||
|
import { startDemo } from './demo.ts';
|
||||||
|
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
|
||||||
|
|
||||||
|
const log = logger('overlay');
|
||||||
|
|
||||||
|
const WEB_ROOT = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'overlay-web', 'public');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Herzschlag. OBS' Browser-Source haelt eine tote Verbindung sonst fuer
|
||||||
|
* lebendig, bis die naechste Chatnachricht kommt - und wenn der Chat still
|
||||||
|
* ist, ist das eine Stunde.
|
||||||
|
*/
|
||||||
|
const HEARTBEAT_MS = 15_000;
|
||||||
|
|
||||||
|
const TYPES: Record<string, string> = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Die Seiten, die als Browser-Source in OBS eingetragen werden. */
|
||||||
|
export const PAGES = [
|
||||||
|
{ path: '/alerts', file: 'alerts.html', w: 1920, h: 1080,
|
||||||
|
title: 'Einblendungen', hint: 'Abos, Raids, Bits, Clips. Gehört ganz nach oben.' },
|
||||||
|
{ path: '/chat', file: 'chat.html', w: 460, h: 900,
|
||||||
|
title: 'Chat', hint: 'Nachrichten laufen herein und verblassen wieder.' },
|
||||||
|
{ path: '/status', file: 'status.html', w: 1920, h: 160,
|
||||||
|
title: 'Statusleiste', hint: 'Titel, Kategorie, Laufzeit, Zuschauer.' },
|
||||||
|
{ path: '/effects', file: 'effects.html', w: 1920, h: 1080,
|
||||||
|
title: 'Effekte', hint: 'Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids.' },
|
||||||
|
{ path: '/starting', file: 'starting.html', w: 1920, h: 1080,
|
||||||
|
title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' },
|
||||||
|
{ path: '/tts', file: 'tts.html', w: 600, h: 200,
|
||||||
|
title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type OverlayServer = {
|
||||||
|
bus: Bus;
|
||||||
|
zugang: Zugang;
|
||||||
|
close: () => void;
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OverlayOptions = {
|
||||||
|
port: number;
|
||||||
|
channel: string;
|
||||||
|
/** Oeffentliche Adresse ohne Schraegstrich, z. B. https://abdu.dimension47.de */
|
||||||
|
publicUrl: string;
|
||||||
|
bus?: Bus;
|
||||||
|
/** Wird eingehaengt, damit Einrichtung und Status unter derselben Adresse liegen. */
|
||||||
|
setup?: ServerDeps;
|
||||||
|
/**
|
||||||
|
* Die Freigabe wartender Vorlese-Nachrichten. Der Hub reicht sie herein;
|
||||||
|
* ohne sie zeigt die Seite den Abschnitt gar nicht erst an.
|
||||||
|
*/
|
||||||
|
tts?: {
|
||||||
|
wartend: () => Array<{ id: string; name: string; text: string; seit: string }>;
|
||||||
|
freigeben: (id: string) => boolean;
|
||||||
|
ablehnen: (id: string) => boolean;
|
||||||
|
alleAblehnen: () => number;
|
||||||
|
still: () => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startOverlayServer(opts: OverlayOptions): OverlayServer {
|
||||||
|
const bus = opts.bus ?? createBus();
|
||||||
|
const zugang = createZugang();
|
||||||
|
// Laeuft die Vorfuehrung, steht hier ihr Abbruch. Ein zweiter Start soll
|
||||||
|
// den ersten ersetzen, nicht danebenlaufen.
|
||||||
|
const vorfuehrung = { abbruch: null as AbortController | null };
|
||||||
|
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
|
||||||
|
// oeffentliche Adresse bleibt alles wie vorher an der Loopback haengen.
|
||||||
|
const host = process.env.BIND_HOST || (opts.publicUrl ? '0.0.0.0' : '127.0.0.1');
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
void handle(req, res, bus, zugang, opts, vorfuehrung).catch((err: Error) => {
|
||||||
|
log.error(`Anfrage fehlgeschlagen: ${err.message}`);
|
||||||
|
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Interner Fehler');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(opts.port, host, () => {
|
||||||
|
log.info(`Overlay-Server laeuft auf ${host}:${opts.port}` +
|
||||||
|
(opts.publicUrl ? ` (oeffentlich: ${opts.publicUrl})` : ''));
|
||||||
|
if (!zugang.hatPasswort() && opts.publicUrl) {
|
||||||
|
log.warn('Es ist kein Passwort gesetzt - die Einrichtungsseite steht offen. ' +
|
||||||
|
'Setzen mit: abdulive web:passwort');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.on('error', (err) => {
|
||||||
|
log.error(`Overlay-Server nicht gestartet: ${err.message}` +
|
||||||
|
((err as NodeJS.ErrnoException).code === 'EADDRINUSE'
|
||||||
|
? ` - Port ${opts.port} ist belegt. Anderer Port: overlay.port in config.yaml.` : ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { bus, zugang, close: () => server.close(), port: opts.port };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Vorfuehrung = { abbruch: AbortController | null };
|
||||||
|
|
||||||
|
async function handle(
|
||||||
|
req: http.IncomingMessage, res: http.ServerResponse,
|
||||||
|
bus: Bus, zugang: Zugang, opts: OverlayOptions, vorfuehrung: Vorfuehrung
|
||||||
|
): Promise<void> {
|
||||||
|
const url = new URL(req.url ?? '/', opts.publicUrl || `http://127.0.0.1:${opts.port}`);
|
||||||
|
const p = url.pathname;
|
||||||
|
|
||||||
|
// ---- Anmeldung. Bewusst vor allem anderen.
|
||||||
|
if (p === '/login' && req.method === 'POST') {
|
||||||
|
const cookie = zugang.anmelden((await readForm(req)).get('passwort') ?? '');
|
||||||
|
if (!cookie) return redirect(res, '/?fehler=1');
|
||||||
|
res.writeHead(303, { location: '/', 'set-cookie': cookie });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p === '/logout') {
|
||||||
|
res.writeHead(303, { location: '/', 'set-cookie': zugang.abmelden() });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Der Rueckruf von Twitch. Kein Passwort: Twitch schickt den Browser
|
||||||
|
// hierher zurueck, und der `state`-Wert ist der Schutz.
|
||||||
|
if (p === '/callback' && opts.setup) {
|
||||||
|
if (await setupRoutes(req, res, opts.setup, url)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Alles Statische ausser den Seiten selbst. Enthaelt keine Daten,
|
||||||
|
// also auch keinen Schluessel - sonst muesste ihn jede Seite weiterreichen.
|
||||||
|
if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
|
||||||
|
|
||||||
|
// ---- Die Browser-Sources und ihr Datenstrom.
|
||||||
|
const page = PAGES.find((x) => x.path === p || `/${x.file}` === p);
|
||||||
|
if (page || p === '/events') {
|
||||||
|
if (!zugang.darfOverlay(req, url)) {
|
||||||
|
return send(res, 403, 'text/plain; charset=utf-8',
|
||||||
|
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
|
||||||
|
'Sie steht fertig auf der Uebersichtsseite.');
|
||||||
|
}
|
||||||
|
if (p === '/events') return sse(req, res, bus);
|
||||||
|
return sendFile(res, page!.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ab hier nur mit Anmeldung.
|
||||||
|
if (!zugang.istAngemeldet(req)) {
|
||||||
|
return send(res, 401, 'text/html; charset=utf-8',
|
||||||
|
loginPage(opts.channel, url.searchParams.has('fehler')));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Testknoepfe. Die Ereignisse gehen nur an die Browser-Sources: nichts
|
||||||
|
// davon landet in der Datenbank, auf Discord oder bei Twitch.
|
||||||
|
if (p === '/test' && req.method === 'POST') {
|
||||||
|
const form = await readForm(req);
|
||||||
|
const art = (form.get('art') ?? '') as TestArt | 'demo-an' | 'demo-aus';
|
||||||
|
const laeuftStream = zustand(bus).live;
|
||||||
|
|
||||||
|
const antwort = (text: string, code = 200): void =>
|
||||||
|
send(res, code, 'application/json; charset=utf-8',
|
||||||
|
JSON.stringify({ text, demo: vorfuehrung.abbruch !== null }));
|
||||||
|
|
||||||
|
if (art === 'demo-aus') {
|
||||||
|
vorfuehrung.abbruch?.abort();
|
||||||
|
vorfuehrung.abbruch = null;
|
||||||
|
// Der letzte erfundene Stand bleibt sonst im Bild stehen.
|
||||||
|
bus.publish({ kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null });
|
||||||
|
return antwort('Vorführung gestoppt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (art === 'demo-an') {
|
||||||
|
if (laeuftStream) {
|
||||||
|
return antwort('Nicht während eines Streams: die Vorführung überschreibt ' +
|
||||||
|
'Titel, Kategorie und Zuschauerzahl in der Statusleiste.', 409);
|
||||||
|
}
|
||||||
|
vorfuehrung.abbruch?.abort();
|
||||||
|
vorfuehrung.abbruch = new AbortController();
|
||||||
|
startDemo(bus, vorfuehrung.abbruch.signal);
|
||||||
|
return antwort('Vorführung läuft.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const knopf = TEST_KNOEPFE.find((k) => k.art === art);
|
||||||
|
if (!knopf) return antwort('Unbekannter Knopf.', 400);
|
||||||
|
if (knopf.nurOffline && laeuftStream) {
|
||||||
|
return antwort('Gesperrt, solange der Stream läuft - das würde in der ' +
|
||||||
|
'Statusleiste stehen bleiben.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die neueste Nachricht aus dem Rueckblick, damit "letzte löschen" etwas
|
||||||
|
// trifft, das auch wirklich im Bild steht.
|
||||||
|
const chat = zustand(bus).chat;
|
||||||
|
const ev = testEvent(knopf.art, chat[chat.length - 1]?.id);
|
||||||
|
if (!ev) return antwort('Nichts zu löschen - der Chat ist leer.', 409);
|
||||||
|
bus.publish(ev);
|
||||||
|
log.info(`Testereignis ausgeloest: ${knopf.art}`);
|
||||||
|
return antwort(`${knopf.label} ausgelöst.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Vorlese-Warteschlange
|
||||||
|
if (p === '/tts' && req.method === 'POST' && opts.tts) {
|
||||||
|
const form = await readForm(req);
|
||||||
|
const was = form.get('was') ?? '';
|
||||||
|
const id = form.get('id') ?? '';
|
||||||
|
const antwort = (text: string, code = 200): void =>
|
||||||
|
send(res, code, 'application/json; charset=utf-8',
|
||||||
|
JSON.stringify({ text, wartend: opts.tts!.wartend() }));
|
||||||
|
|
||||||
|
if (was === 'still') { opts.tts.still(); return antwort('Alles still.'); }
|
||||||
|
if (was === 'alle-ablehnen') {
|
||||||
|
const n = opts.tts.alleAblehnen();
|
||||||
|
return antwort(`${n} Nachricht(en) abgelehnt.`);
|
||||||
|
}
|
||||||
|
if (was === 'freigeben') {
|
||||||
|
return antwort(opts.tts.freigeben(id) ? 'Wird vorgelesen.' : 'Nicht mehr da.');
|
||||||
|
}
|
||||||
|
if (was === 'ablehnen') {
|
||||||
|
return antwort(opts.tts.ablehnen(id) ? 'Abgelehnt.' : 'Nicht mehr da.');
|
||||||
|
}
|
||||||
|
return antwort('Unbekannt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === '/tts/wartend' && opts.tts) {
|
||||||
|
return send(res, 200, 'application/json; charset=utf-8',
|
||||||
|
JSON.stringify({ wartend: opts.tts.wartend() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === '/schluessel/neu' && req.method === 'POST') {
|
||||||
|
zugang.neuerOverlayKey();
|
||||||
|
return redirect(res, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === '/' || p === '/index.html') {
|
||||||
|
return send(res, 200, 'text/html; charset=utf-8',
|
||||||
|
indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.setup && await setupRoutes(req, res, opts.setup, url)) return;
|
||||||
|
|
||||||
|
send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden. Die Uebersicht steht auf /');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- SSE
|
||||||
|
|
||||||
|
function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): void {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'content-type': 'text/event-stream; charset=utf-8',
|
||||||
|
'cache-control': 'no-cache, no-transform',
|
||||||
|
connection: 'keep-alive',
|
||||||
|
// Ohne das puffert so mancher Proxy den Strom, und die Einblendung kommt
|
||||||
|
// gesammelt nach zwei Minuten. Nginx beachtet genau diesen Kopf.
|
||||||
|
'x-accel-buffering': 'no',
|
||||||
|
});
|
||||||
|
|
||||||
|
const write = (ev: OverlayEvent): void => {
|
||||||
|
// `retry` sagt dem Browser, wie schnell er neu verbinden soll. Vorgabe
|
||||||
|
// waeren drei Sekunden; nach einem Neustart mitten im Stream ist eine
|
||||||
|
// Sekunde besser, und mehr kostet es hier nicht.
|
||||||
|
res.write(`retry: 1000\ndata: ${JSON.stringify(ev)}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Erst anmelden, dann den Schnappschuss: andersherum wuerde ein Ereignis,
|
||||||
|
// das genau dazwischen faellt, verloren gehen.
|
||||||
|
const unsubscribe = bus.subscribe(write);
|
||||||
|
write(bus.snapshot());
|
||||||
|
|
||||||
|
// Als echtes Ereignis, nicht als SSE-Kommentar: ein Kommentar haelt die
|
||||||
|
// Verbindung zwar offen, ist im Browser aber unsichtbar. Die Seite soll
|
||||||
|
// merken koennen, dass laengst nichts mehr kam, und selbst neu verbinden.
|
||||||
|
const beat = setInterval(
|
||||||
|
() => res.write(`data: ${JSON.stringify({ kind: 'beat', t: Date.now() })}\n\n`),
|
||||||
|
HEARTBEAT_MS);
|
||||||
|
beat.unref();
|
||||||
|
|
||||||
|
const end = (): void => { clearInterval(beat); unsubscribe(); };
|
||||||
|
req.on('close', end);
|
||||||
|
res.on('error', end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der aktuelle Stand aus dem Bus, ohne den Typ an jeder Stelle aufzudroeseln. */
|
||||||
|
function zustand(bus: Bus) {
|
||||||
|
const s = bus.snapshot();
|
||||||
|
if (s.kind !== 'state') throw new Error('Schnappschuss hat die falsche Form');
|
||||||
|
return { live: s.stream.live, chat: s.chat };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Werkzeug
|
||||||
|
|
||||||
|
function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
req.on('data', (c: Buffer) => {
|
||||||
|
size += c.length;
|
||||||
|
if (size > 16 * 1024) { reject(new Error('Anfrage zu gross')); req.destroy(); return; }
|
||||||
|
chunks.push(c);
|
||||||
|
});
|
||||||
|
req.on('end', () => resolve(new URLSearchParams(Buffer.concat(chunks).toString('utf8'))));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirect(res: http.ServerResponse, to: string): void {
|
||||||
|
res.writeHead(303, { location: to });
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res: http.ServerResponse, code: number, type: string, body: string | Buffer): void {
|
||||||
|
res.writeHead(code, {
|
||||||
|
'content-type': type,
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
'referrer-policy': 'no-referrer',
|
||||||
|
// Der Schluessel steht in der Adresse. Ohne diesen Kopf traegt ihn jeder
|
||||||
|
// Bildaufruf zu Twitchs Emote-CDN im Referer mit hinaus.
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFile(res: http.ServerResponse, rel: string): void {
|
||||||
|
// Aufloesen und danach pruefen, ob das Ergebnis noch unter WEB_ROOT liegt.
|
||||||
|
// `..` im Pfad ist der Klassiker, und ein Server, der beliebige Dateien
|
||||||
|
// ausliefert, liefert irgendwann secrets.json aus.
|
||||||
|
const file = path.resolve(WEB_ROOT, rel);
|
||||||
|
if (!file.startsWith(WEB_ROOT + path.sep)) {
|
||||||
|
return send(res, 403, 'text/plain; charset=utf-8', 'Nicht erlaubt');
|
||||||
|
}
|
||||||
|
let data: Buffer;
|
||||||
|
try {
|
||||||
|
data = fs.readFileSync(file);
|
||||||
|
} catch {
|
||||||
|
return send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden');
|
||||||
|
}
|
||||||
|
send(res, 200, TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Seiten
|
||||||
|
|
||||||
|
const SEITEN_CSS = `
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
body { margin: 0; padding: 2.5rem clamp(1rem, 5vw, 4rem); background: #0d0b14; color: #e9e6f3;
|
||||||
|
font: 16px/1.6 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; }
|
||||||
|
h1 { font-size: 1.6rem; margin: 0 0 .25rem; } h1 span { color: #a970ff; }
|
||||||
|
p.lead { color: #9b94b3; margin: 0 0 2rem; max-width: 62ch; }
|
||||||
|
table { border-collapse: collapse; width: 100%; max-width: 74rem; }
|
||||||
|
td { padding: .9rem 1rem; border-top: 1px solid #241f35; vertical-align: top; }
|
||||||
|
tr:first-child td { border-top: 0; }
|
||||||
|
.hint { color: #8b84a3; font-size: .875rem; }
|
||||||
|
.size { color: #8b84a3; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
|
code { background: #171327; padding: .25rem .5rem; border-radius: .4rem; word-break: break-all;
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace; font-size: .8rem; }
|
||||||
|
button, a.knopf { font: inherit; font-size: .85rem; margin-left: .5rem; background: none;
|
||||||
|
border: 1px solid #3a3352; color: #c9c2e0; border-radius: .4rem;
|
||||||
|
padding: .2rem .6rem; cursor: pointer; text-decoration: none; }
|
||||||
|
button:hover, a.knopf:hover { border-color: #a970ff; color: #fff; }
|
||||||
|
input { font: inherit; background: #171327; border: 1px solid #3a3352; color: #e9e6f3;
|
||||||
|
border-radius: .4rem; padding: .5rem .7rem; }
|
||||||
|
.status { margin-top: 2rem; color: #8b84a3; font-size: .9rem; }
|
||||||
|
.dot { display: inline-block; width: .6rem; height: .6rem; border-radius: 50%;
|
||||||
|
background: #ef4444; margin-right: .4rem; }
|
||||||
|
.dot.on { background: #22c55e; }
|
||||||
|
ol { max-width: 62ch; color: #9b94b3; }
|
||||||
|
.warn { color: #ffc53d; }
|
||||||
|
h2 { font-size: 1.1rem; margin: 2.5rem 0 .4rem; }
|
||||||
|
.gruppe { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
|
||||||
|
margin-bottom: .6rem; }
|
||||||
|
.gruppenname { display: inline-block; min-width: 9rem; color: #8b84a3;
|
||||||
|
font-size: .85rem; }
|
||||||
|
button.test { margin-left: 0; }
|
||||||
|
button.test:disabled { opacity: .35; cursor: not-allowed; border-color: #241f35; }
|
||||||
|
.warnbox { border: 1px solid #ffc53d; color: #ffc53d; border-radius: .5rem;
|
||||||
|
padding: .6rem .9rem; margin: .8rem 0; max-width: 62ch; }
|
||||||
|
.meldung { min-height: 1.5rem; font-size: .9rem; color: #8b84a3; margin-top: .8rem; }
|
||||||
|
.meldung.ok { color: #4ade80; }
|
||||||
|
.meldung.fehler { color: #ffc53d; }
|
||||||
|
.wartend { display: flex; justify-content: space-between; align-items: flex-start;
|
||||||
|
gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; }
|
||||||
|
.wartend:first-child { border-top: 0; }
|
||||||
|
.wartend .hint { max-width: 60ch; word-break: break-word; }
|
||||||
|
button.tts { margin-left: .4rem; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function loginPage(channel: string, fehler: boolean): string {
|
||||||
|
return `<!doctype html><html lang="de"><meta charset="utf-8">
|
||||||
|
<title>Anmelden</title><style>${SEITEN_CSS}
|
||||||
|
body { display: grid; place-items: center; height: 100vh; padding: 0; }
|
||||||
|
form { text-align: left; }
|
||||||
|
</style>
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<h1>abdulive <span>${escape(channel)}</span></h1>
|
||||||
|
<p class="lead">${fehler ? '<span class="warn">Passwort stimmt nicht.</span>' : 'Passwort eingeben.'}</p>
|
||||||
|
<input type="password" name="passwort" autofocus autocomplete="current-password" size="30">
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Seite, die man aufmacht, bevor man OBS einrichtet. Sie ist bewusst hier
|
||||||
|
* und nicht in public/: sie kennt die oeffentliche Adresse, den Schluessel und
|
||||||
|
* die Seitenliste, und das soll nicht an zwei Stellen stehen.
|
||||||
|
*/
|
||||||
|
export function indexPage(
|
||||||
|
opts: OverlayOptions, key: string, bus: Bus, demoLaeuft = false
|
||||||
|
): string {
|
||||||
|
// Der Abschnitt erscheint nur, wenn der Hub eine Warteschlange einhaengt -
|
||||||
|
// im Alleinbetrieb des Overlay-Servers gibt es nichts freizugeben.
|
||||||
|
const ttsFeld = opts.tts ? `
|
||||||
|
<h2>Vorlesen: wartet auf Freigabe</h2>
|
||||||
|
<p class="lead">Nichts wird vorgelesen, bevor du hier klickst. Ablehnen gibt die
|
||||||
|
Kanalpunkte zurück, sofern das verbundene Konto das darf.</p>
|
||||||
|
<div id="wartend"><span class="hint">wird geladen …</span></div>
|
||||||
|
<div class="gruppe" style="margin-top:.8rem">
|
||||||
|
<button class="tts" data-was="alle-ablehnen">Alle ablehnen</button>
|
||||||
|
<button class="tts" data-was="still">Sofort still</button>
|
||||||
|
<span class="hint">„Sofort still" bricht auch ab, was gerade gesprochen wird.</span>
|
||||||
|
</div>
|
||||||
|
<p id="tts-meldung" class="meldung"> </p>` : '';
|
||||||
|
const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`;
|
||||||
|
const rows = PAGES.map((p) => {
|
||||||
|
const id = `u${p.path.slice(1)}`;
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${p.title}</strong><div class="hint">${p.hint}</div></td>
|
||||||
|
<td class="size">${p.w} × ${p.h}</td>
|
||||||
|
<td>
|
||||||
|
<code id="${id}">${base}${p.path}?key=${key}</code>
|
||||||
|
<button data-copy="${id}">kopieren</button>
|
||||||
|
<a class="knopf" href="${p.path}?probe=1" target="_blank" rel="noreferrer">ansehen</a>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const gruppen = ['Einblendungen', 'Kanalpunkte', 'Chat', 'Effekte', 'Stand'] as const;
|
||||||
|
const testfeld = gruppen.map((g) => `
|
||||||
|
<div class="gruppe">
|
||||||
|
<span class="gruppenname">${g}</span>
|
||||||
|
${TEST_KNOEPFE.filter((k) => k.gruppe === g).map((k) =>
|
||||||
|
`<button class="test" data-art="${k.art}"${k.nurOffline ? ' data-offline="1"' : ''}>${k.label}</button>`
|
||||||
|
).join('')}
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
return `<!doctype html><html lang="de"><meta charset="utf-8">
|
||||||
|
<title>Overlay - ${escape(opts.channel)}</title><style>${SEITEN_CSS}</style>
|
||||||
|
<h1>Overlay <span>${escape(opts.channel)}</span></h1>
|
||||||
|
<p class="lead">In OBS je eine Browser-Source anlegen, Adresse eintragen, Breite und
|
||||||
|
Höhe setzen. "Quelle beim Unsichtbarwerden deaktivieren" ausschalten, sonst
|
||||||
|
verpasst das Overlay alles, was in einer anderen Szene passiert.</p>
|
||||||
|
<table>${rows}</table>
|
||||||
|
<p class="status"><span class="dot" id="dot"></span><span id="txt">verbinde ...</span>
|
||||||
|
· ${bus.clients()} verbundene Quelle(n)
|
||||||
|
${opts.setup ? ' · <a class="knopf" href="/setup">Einrichtung</a>' +
|
||||||
|
'<a class="knopf" href="/health">Status</a>' : ''}
|
||||||
|
<a class="knopf" href="/logout">Abmelden</a>
|
||||||
|
</p>
|
||||||
|
<h2>Ausprobieren</h2>
|
||||||
|
<p class="lead">Löst ein erfundenes Ereignis aus. Es geht nur an die
|
||||||
|
Browser-Sources - nichts davon landet in der Datenbank, auf Discord oder bei
|
||||||
|
Twitch. Am besten die Seiten in einem zweiten Fenster offen haben
|
||||||
|
(„ansehen“ oben).</p>
|
||||||
|
<div id="live-warnung" class="warnbox" hidden>
|
||||||
|
Der Stream läuft gerade. Was du hier auslöst, sehen die Zuschauer.
|
||||||
|
</div>
|
||||||
|
${testfeld}
|
||||||
|
<div class="gruppe">
|
||||||
|
<span class="gruppenname">Dauerbetrieb</span>
|
||||||
|
<button class="test" id="demo" data-art="${demoLaeuft ? 'demo-aus' : 'demo-an'}">
|
||||||
|
${demoLaeuft ? 'Vorführung stoppen' : 'Vorführung starten'}
|
||||||
|
</button>
|
||||||
|
<span class="hint">Erfundener Chat und alle paar Sekunden eine Einblendung,
|
||||||
|
bis du wieder stoppst.</span>
|
||||||
|
</div>
|
||||||
|
<p id="meldung" class="meldung"> </p>
|
||||||
|
|
||||||
|
${ttsFeld}
|
||||||
|
|
||||||
|
<h2>Schlüssel</h2>
|
||||||
|
<form method="post" action="/schluessel/neu">
|
||||||
|
<button type="submit">Schlüssel neu würfeln</button>
|
||||||
|
<span class="hint">Danach müssen alle Adressen in OBS neu eingetragen werden.</span>
|
||||||
|
</form>
|
||||||
|
<ol>
|
||||||
|
<li>Alle Seiten haben einen durchsichtigen Hintergrund. In OBS nichts weiter einstellen.</li>
|
||||||
|
<li>Jede Seite verbindet sich von allein neu - ein Neustart des Dienstes kostet eine Sekunde.</li>
|
||||||
|
<li>Der Schlüssel in der Adresse darf nur lesen. Er landet in jedem OBS-Profil, also nicht weitergeben.</li>
|
||||||
|
</ol>
|
||||||
|
<script>
|
||||||
|
for (const b of document.querySelectorAll('button[data-copy]')) {
|
||||||
|
b.addEventListener('click', async () => {
|
||||||
|
await navigator.clipboard.writeText(document.getElementById(b.dataset.copy).textContent);
|
||||||
|
b.textContent = 'kopiert';
|
||||||
|
setTimeout(() => { b.textContent = 'kopieren'; }, 1200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const es = new EventSource('/events');
|
||||||
|
const dot = document.getElementById('dot'), txt = document.getElementById('txt');
|
||||||
|
es.onopen = () => { dot.classList.add('on'); txt.textContent = 'verbunden'; };
|
||||||
|
es.onerror = () => { dot.classList.remove('on'); txt.textContent = 'getrennt, versuche es erneut'; };
|
||||||
|
const warnung = document.getElementById('live-warnung');
|
||||||
|
const meldung = document.getElementById('meldung');
|
||||||
|
|
||||||
|
function setzeLive(live) {
|
||||||
|
warnung.hidden = !live;
|
||||||
|
// Knöpfe, die einen Zustand setzen, bleiben während eines Streams aus:
|
||||||
|
// sie würden in der Statusleiste stehen bleiben.
|
||||||
|
for (const b of document.querySelectorAll('button.test[data-offline]')) b.disabled = live;
|
||||||
|
const demo = document.getElementById('demo');
|
||||||
|
if (demo.dataset.art === 'demo-an') demo.disabled = live;
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
const ev = JSON.parse(e.data);
|
||||||
|
if (ev.kind === 'state') {
|
||||||
|
txt.textContent = ev.stream.live
|
||||||
|
? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel')
|
||||||
|
: 'verbunden - kein Stream';
|
||||||
|
setzeLive(ev.stream.live);
|
||||||
|
} else if (ev.kind === 'stream') {
|
||||||
|
setzeLive(ev.live);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sagAb;
|
||||||
|
async function loese(art) {
|
||||||
|
const res = await fetch('/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ art }),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status }));
|
||||||
|
meldung.textContent = body.text;
|
||||||
|
meldung.className = 'meldung' + (res.ok ? ' ok' : ' fehler');
|
||||||
|
clearTimeout(sagAb);
|
||||||
|
sagAb = setTimeout(() => { meldung.innerHTML = ' '; meldung.className = 'meldung'; }, 4000);
|
||||||
|
|
||||||
|
const demo = document.getElementById('demo');
|
||||||
|
demo.dataset.art = body.demo ? 'demo-aus' : 'demo-an';
|
||||||
|
demo.textContent = body.demo ? 'Vorführung stoppen' : 'Vorführung starten';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const b of document.querySelectorAll('button.test')) {
|
||||||
|
b.addEventListener('click', () => loese(b.dataset.art));
|
||||||
|
}
|
||||||
|
|
||||||
|
const wartendEl = document.getElementById('wartend');
|
||||||
|
if (wartendEl) {
|
||||||
|
const ttsMeldung = document.getElementById('tts-meldung');
|
||||||
|
|
||||||
|
function zeichne(liste) {
|
||||||
|
if (!liste.length) {
|
||||||
|
wartendEl.innerHTML = '<span class="hint">Nichts wartet.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wartendEl.innerHTML = liste.map((w) => \`
|
||||||
|
<div class="wartend">
|
||||||
|
<div>
|
||||||
|
<strong>\${w.name}</strong>
|
||||||
|
<div class="hint">\${w.text}</div>
|
||||||
|
</div>
|
||||||
|
<div style="white-space:nowrap">
|
||||||
|
<button class="tts" data-was="freigeben" data-id="\${w.id}">Vorlesen</button>
|
||||||
|
<button class="tts" data-was="ablehnen" data-id="\${w.id}">Ablehnen</button>
|
||||||
|
</div>
|
||||||
|
</div>\`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ttsRuf(was, id) {
|
||||||
|
const res = await fetch('/tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ was, id: id || '' }),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status, wartend: [] }));
|
||||||
|
ttsMeldung.textContent = body.text || '';
|
||||||
|
zeichne(body.wartend || []);
|
||||||
|
setTimeout(() => { ttsMeldung.innerHTML = ' '; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
wartendEl.addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('button.tts');
|
||||||
|
if (b) ttsRuf(b.dataset.was, b.dataset.id);
|
||||||
|
});
|
||||||
|
for (const b of document.querySelectorAll('.gruppe button.tts')) {
|
||||||
|
b.addEventListener('click', () => ttsRuf(b.dataset.was));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurzer Takt: eine Einlösung soll nicht erst nach einem Neuladen
|
||||||
|
// auftauchen. Kostet nichts, die Liste ist winzig.
|
||||||
|
const laden = () => fetch('/tts/wartend')
|
||||||
|
.then((r) => r.json()).then((b) => zeichne(b.wartend || [])).catch(() => {});
|
||||||
|
laden();
|
||||||
|
setInterval(laden, 3000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escape(s: string): string {
|
||||||
|
return s.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]!));
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// Die Testknoepfe sind das einzige Werkzeug, mit dem sich das Overlay
|
||||||
|
// ausserhalb eines Streams pruefen laesst. Geht hier etwas kaputt, merkt man
|
||||||
|
// es ausgerechnet dann nicht, wenn man es am noetigsten braucht.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
|
||||||
|
|
||||||
|
test('zu jedem Knopf gibt es ein Ereignis', () => {
|
||||||
|
// Ein Knopf ohne Ereignis tut beim Druecken nichts und sieht dabei aus wie
|
||||||
|
// ein kaputtes Overlay.
|
||||||
|
for (const k of TEST_KNOEPFE) {
|
||||||
|
if (k.art === 'chat-loeschen') continue; // braucht eine Nachricht, siehe unten
|
||||||
|
assert.ok(testEvent(k.art), `${k.art} liefert kein Ereignis`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jeder Knopf kommt nur einmal vor', () => {
|
||||||
|
const arten = TEST_KNOEPFE.map((k) => k.art);
|
||||||
|
assert.equal(new Set(arten).size, arten.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('die Einblendungen tragen die Felder, die die Seite anzeigt', () => {
|
||||||
|
const raid = testEvent('raid') as { kind: string; type: string; amount: number };
|
||||||
|
assert.equal(raid.kind, 'alert');
|
||||||
|
assert.equal(raid.type, 'raid');
|
||||||
|
assert.ok(raid.amount > 0, 'ohne Zuschauerzahl steht "mit undefined Zuschauern" im Bild');
|
||||||
|
|
||||||
|
const gift = testEvent('subgift') as { recipient?: string };
|
||||||
|
assert.ok(gift.recipient, 'ohne Beschenkten fehlt die zweite Zeile');
|
||||||
|
|
||||||
|
const resub = testEvent('resub') as { months?: number };
|
||||||
|
assert.ok(resub.months && resub.months > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"letzte löschen" braucht eine Nachricht und trifft genau die', () => {
|
||||||
|
assert.equal(testEvent('chat-loeschen'), null, 'ohne Chat gibt es nichts zu loeschen');
|
||||||
|
assert.deepEqual(testEvent('chat-loeschen', 'abc'), { kind: 'clear', messageId: 'abc' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"Chat leeren" loescht alles, nicht nur eine Nachricht', () => {
|
||||||
|
assert.deepEqual(testEvent('chat-leeren'), { kind: 'clear' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Chatnachrichten haben eine eigene ID', () => {
|
||||||
|
// Zwei Nachrichten mit derselben ID wuerden sich beim Loeschen gegenseitig
|
||||||
|
// mitnehmen.
|
||||||
|
const a = testEvent('chat') as { id: string };
|
||||||
|
const b = testEvent('chat') as { id: string };
|
||||||
|
assert.notEqual(a.id, b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('das Emote im Test ist im Text auch enthalten', () => {
|
||||||
|
// Sonst wird nichts ersetzt und der Knopf prueft die Emote-Anzeige nicht.
|
||||||
|
const ev = testEvent('chat-emote') as { text: string; emotes: Array<{ name: string }> };
|
||||||
|
assert.equal(ev.emotes.length, 1);
|
||||||
|
assert.ok(ev.text.includes(ev.emotes[0]!.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gesperrt ist nur, was einen falschen Stand setzt - nicht der Weg zurueck', () => {
|
||||||
|
// Eine Einblendung blitzt auf und ist weg. Ein erfundener Stream-Zustand
|
||||||
|
// bleibt in der Statusleiste stehen, bis die naechste Abfrage ihn
|
||||||
|
// geraderueckt - und das dauert bis zu einer Minute.
|
||||||
|
//
|
||||||
|
// "Zuruecksetzen" darf dabei nie gesperrt sein: sonst kommt man aus einem
|
||||||
|
// erfundenen Zustand ohne Neustart nicht mehr heraus, und auf einem Hub ohne
|
||||||
|
// Broadcaster-Token raeumt ueberhaupt niemand hinterher auf.
|
||||||
|
const gesperrt = TEST_KNOEPFE.filter((k) => k.nurOffline).map((k) => k.art);
|
||||||
|
assert.deepEqual(gesperrt, ['live-an']);
|
||||||
|
|
||||||
|
const zurueck = TEST_KNOEPFE.find((k) => k.art === 'live-aus');
|
||||||
|
assert.equal(zurueck?.nurOffline, undefined, 'der Weg zurueck bleibt immer offen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Stand-Knopf setzt einen anzeigbaren Stream', () => {
|
||||||
|
const an = testEvent('live-an') as
|
||||||
|
{ kind: string; live: boolean; title: string; startedAt: string | null };
|
||||||
|
assert.equal(an.kind, 'stream');
|
||||||
|
assert.equal(an.live, true);
|
||||||
|
assert.ok(an.title.length > 0);
|
||||||
|
assert.ok(an.startedAt && Date.parse(an.startedAt) < Date.now(), 'Laufzeit muss positiv sein');
|
||||||
|
|
||||||
|
const aus = testEvent('live-aus') as { live: boolean; startedAt: string | null };
|
||||||
|
assert.equal(aus.live, false);
|
||||||
|
assert.equal(aus.startedAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unbekannte Knoepfe liefern nichts, statt etwas zu erfinden', () => {
|
||||||
|
assert.equal(testEvent('gibtsnicht' as TestArt), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Kanalpunkte
|
||||||
|
|
||||||
|
test('eine Belohnung wird nie als Abo eingeblendet', () => {
|
||||||
|
// Genau das stand hier einmal: die Konfetti-Belohnung hat die Abo-
|
||||||
|
// Einblendung wiederverwendet und "Neues Abo" ins Bild geschrieben. Eine
|
||||||
|
// Falschmeldung im Stream, die in der Aufzeichnung bleibt.
|
||||||
|
const e = testEvent('reward') as { kind: string; type: string; reward?: string };
|
||||||
|
assert.equal(e.kind, 'alert');
|
||||||
|
assert.equal(e.type, 'reward');
|
||||||
|
assert.notEqual(e.type, 'sub');
|
||||||
|
assert.ok(e.reward, 'ohne Titel weiss niemand, welche Belohnung gemeint ist');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Konfetti ist ein eigenes Ereignis, keine Nebenwirkung einer Einblendung', () => {
|
||||||
|
// So muss die Effektseite nicht wissen, welche Belohnung was ausloest.
|
||||||
|
const e = testEvent('reward-konfetti') as { kind: string; staerke: number };
|
||||||
|
assert.equal(e.kind, 'konfetti');
|
||||||
|
assert.ok(e.staerke > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Vorlese-Knopf schickt Text, den die Seite auch sprechen kann', () => {
|
||||||
|
const e = testEvent('tts') as { kind: string; text: string; name: string };
|
||||||
|
assert.equal(e.kind, 'tts');
|
||||||
|
assert.ok(e.text.length > 0);
|
||||||
|
assert.ok(e.name.length > 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Erfundene Ereignisse zum Ausprobieren, für die Knöpfe auf der
|
||||||
|
// Übersichtsseite und für den Vorführmodus.
|
||||||
|
//
|
||||||
|
// Warum das gebraucht wird: ein Raid kommt vielleicht einmal die Woche vor.
|
||||||
|
// Darauf zu warten, um zu sehen, ob die Einblendung passt, ist keine
|
||||||
|
// Arbeitsweise - und mitten im Stream herauszufinden, dass der Text abgeschnitten
|
||||||
|
// wird, ist die schlechteste aller Möglichkeiten.
|
||||||
|
//
|
||||||
|
// Die Knopfliste und die Ereignisse stehen hier zusammen, damit die Seite die
|
||||||
|
// Knöpfe daraus bauen kann. Ein Knopf, den es gibt, hat damit immer auch ein
|
||||||
|
// Ereignis dahinter.
|
||||||
|
|
||||||
|
import { ulid } from '#shared/events.ts';
|
||||||
|
import type { ChatEvent, OverlayEvent } from './bus.ts';
|
||||||
|
|
||||||
|
export const NAMEN = [
|
||||||
|
'kevin_ohne_c', 'Melli', 'HansWurst42', 'dieNina', 'Tobi', 'Schnitzelfan',
|
||||||
|
'pixelpudel', 'Rewe_Kassiererin', 'Olaf', 'zwiebeljule',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SPRUECHE = [
|
||||||
|
'moin', 'was geht', 'der Boss hat 3 Phasen oder?', 'hahaha',
|
||||||
|
'rechts! RECHTS!', 'ich guck seit ner Stunde zu, richtig gut',
|
||||||
|
'kann jemand den Titel lesen', 'das war knapp', 'gg',
|
||||||
|
'mein Internet ist heute wieder Müll', 'hast du den Trank noch?',
|
||||||
|
'erster', 'wie lange streamst du heute noch',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Globale Twitch-Emotes mit ihren echten IDs - nachgeschlagen, nicht geraten,
|
||||||
|
* damit die Bilder wirklich laden. KEKW fehlt hier absichtlich: das ist ein
|
||||||
|
* BTTV-Emote und hat bei Twitch keine ID.
|
||||||
|
*/
|
||||||
|
export const EMOTES = [
|
||||||
|
{ id: '425618', name: 'LUL', count: 1 },
|
||||||
|
{ id: '25', name: 'Kappa', count: 1 },
|
||||||
|
{ id: '354', name: '4Head', count: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FARBEN = ['#ff4fa3', '#4fd1c5', '#a970ff', '#ffc53d', '#4ade80', null];
|
||||||
|
|
||||||
|
export function zufall<T>(list: readonly T[]): T {
|
||||||
|
return list[Math.floor(Math.random() * list.length)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TestArt =
|
||||||
|
| 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'cheer' | 'first' | 'clip' | 'follow'
|
||||||
|
| 'reward' | 'reward-konfetti' | 'tts'
|
||||||
|
| 'chat' | 'chat-emote' | 'chat-gif' | 'chat-erstmals' | 'chat-bits' | 'chat-lang'
|
||||||
|
| 'chat-loeschen' | 'chat-leeren'
|
||||||
|
| 'hype' | 'live-an' | 'live-aus';
|
||||||
|
|
||||||
|
export type TestKnopf = {
|
||||||
|
art: TestArt;
|
||||||
|
label: string;
|
||||||
|
gruppe: 'Einblendungen' | 'Kanalpunkte' | 'Chat' | 'Effekte' | 'Stand';
|
||||||
|
/**
|
||||||
|
* Verändert den Zustand, den das Overlay dauerhaft anzeigt, statt nur einmal
|
||||||
|
* aufzublitzen. Läuft gerade ein Stream, steht danach etwas Falsches im Bild,
|
||||||
|
* bis die nächste Abfrage es geraderückt - solche Knöpfe sind gesperrt.
|
||||||
|
*/
|
||||||
|
nurOffline?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_KNOEPFE: TestKnopf[] = [
|
||||||
|
{ art: 'sub', label: 'Abo', gruppe: 'Einblendungen' },
|
||||||
|
{ art: 'resub', label: 'Abo verlängert', gruppe: 'Einblendungen' },
|
||||||
|
{ art: 'subgift', label: 'Abo verschenkt', gruppe: 'Einblendungen' },
|
||||||
|
{ art: 'massgift', label: '5 Abos verschenkt', gruppe: 'Einblendungen' },
|
||||||
|
{ art: 'raid', label: 'Raid', gruppe: 'Einblendungen' },
|
||||||
|
{ art: 'cheer', label: 'Bits', gruppe: 'Einblendungen' },
|
||||||
|
{ art: 'first', label: 'Erstchatter', gruppe: 'Einblendungen' },
|
||||||
|
{ art: 'clip', label: 'Clip gespeichert', gruppe: 'Einblendungen' },
|
||||||
|
{ art: 'follow', label: 'Follow', gruppe: 'Einblendungen' },
|
||||||
|
|
||||||
|
{ art: 'reward', label: 'Belohnung eingelöst', gruppe: 'Kanalpunkte' },
|
||||||
|
{ art: 'reward-konfetti', label: 'Belohnung mit Konfetti', gruppe: 'Kanalpunkte' },
|
||||||
|
{ art: 'tts', label: 'Vorlesen', gruppe: 'Kanalpunkte' },
|
||||||
|
|
||||||
|
{ art: 'chat', label: 'Nachricht', gruppe: 'Chat' },
|
||||||
|
{ art: 'chat-emote', label: 'mit Emote', gruppe: 'Chat' },
|
||||||
|
{ art: 'chat-gif', label: 'mit GIF', gruppe: 'Chat' },
|
||||||
|
{ art: 'chat-erstmals', label: 'von einem Erstchatter', gruppe: 'Chat' },
|
||||||
|
{ art: 'chat-bits', label: 'mit Bits', gruppe: 'Chat' },
|
||||||
|
{ art: 'chat-lang', label: 'sehr lang', gruppe: 'Chat' },
|
||||||
|
{ art: 'chat-loeschen', label: 'letzte löschen', gruppe: 'Chat' },
|
||||||
|
{ art: 'chat-leeren', label: 'Chat leeren', gruppe: 'Chat' },
|
||||||
|
|
||||||
|
{ art: 'hype', label: 'Emote-Regen', gruppe: 'Effekte' },
|
||||||
|
|
||||||
|
{ art: 'live-an', label: 'Stream läuft', gruppe: 'Stand', nurOffline: true },
|
||||||
|
// Absichtlich NICHT gesperrt: das ist der Weg aus einem erfundenen Zustand
|
||||||
|
// wieder heraus. Wäre der Knopf gesperrt, sobald "Stream läuft" gedrückt
|
||||||
|
// wurde, käme man ohne Neustart nicht mehr zurück - und auf einem Hub ohne
|
||||||
|
// Broadcaster-Token räumt auch keine Abfrage hinterher auf.
|
||||||
|
{ art: 'live-aus', label: 'Stand zurücksetzen', gruppe: 'Stand' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function chat(over: Partial<ChatEvent> = {}): ChatEvent {
|
||||||
|
const name = zufall(NAMEN);
|
||||||
|
return {
|
||||||
|
kind: 'chat',
|
||||||
|
id: ulid(),
|
||||||
|
userId: `test-${NAMEN.indexOf(name)}`,
|
||||||
|
login: name.toLowerCase(),
|
||||||
|
name,
|
||||||
|
text: zufall(SPRUECHE),
|
||||||
|
color: zufall(FARBEN),
|
||||||
|
emotes: [],
|
||||||
|
gifs: [],
|
||||||
|
first: false,
|
||||||
|
mod: Math.random() < 0.15,
|
||||||
|
sub: Math.random() < 0.3,
|
||||||
|
broadcaster: false,
|
||||||
|
bits: 0,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut das Ereignis zu einem Knopf.
|
||||||
|
*
|
||||||
|
* `letzteChatId` ist die neueste Nachricht im Rückblick des Busses - nur so
|
||||||
|
* kann "letzte löschen" etwas treffen, das auch wirklich im Bild steht.
|
||||||
|
*/
|
||||||
|
export function testEvent(art: TestArt, letzteChatId?: string): OverlayEvent | null {
|
||||||
|
const name = zufall(NAMEN);
|
||||||
|
const alarm = (rest: Record<string, unknown>): OverlayEvent =>
|
||||||
|
({ kind: 'alert', id: ulid(), name, ...rest } as OverlayEvent);
|
||||||
|
|
||||||
|
switch (art) {
|
||||||
|
case 'sub': return alarm({ type: 'sub', tier: '1000' });
|
||||||
|
case 'resub': return alarm({
|
||||||
|
type: 'resub', months: 1 + Math.floor(Math.random() * 30),
|
||||||
|
message: 'endlich wieder dabei, zwei Monate Pause waren genug',
|
||||||
|
});
|
||||||
|
case 'subgift': return alarm({ type: 'subgift', recipient: zufall(NAMEN) });
|
||||||
|
case 'massgift': return alarm({ type: 'massgift', amount: 5 });
|
||||||
|
case 'raid': return alarm({ type: 'raid', amount: 5 + Math.floor(Math.random() * 60) });
|
||||||
|
case 'cheer': return alarm({
|
||||||
|
type: 'cheer', amount: 100 * (1 + Math.floor(Math.random() * 10)),
|
||||||
|
message: 'das musste sein',
|
||||||
|
});
|
||||||
|
case 'first': return alarm({ type: 'first' });
|
||||||
|
case 'clip': return alarm({ type: 'clip' });
|
||||||
|
case 'follow': return alarm({ type: 'follow' });
|
||||||
|
case 'reward': return alarm({
|
||||||
|
type: 'reward', reward: 'Nachricht anheften', amount: 500,
|
||||||
|
message: 'guck mal links oben',
|
||||||
|
});
|
||||||
|
case 'reward-konfetti': return { kind: 'konfetti', staerke: 1 };
|
||||||
|
case 'tts': return {
|
||||||
|
kind: 'tts', id: ulid(), name,
|
||||||
|
text: 'Das hier wird vorgelesen, sobald du es freigibst.',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'chat': return chat();
|
||||||
|
case 'chat-emote': {
|
||||||
|
const e = zufall(EMOTES);
|
||||||
|
return chat({ text: `${zufall(SPRUECHE)} ${e.name}`, emotes: [e] });
|
||||||
|
}
|
||||||
|
case 'chat-gif': {
|
||||||
|
// Genau so, wie Twitch es schickt: der Text ist nur ein Platzhalter,
|
||||||
|
// das Bild steckt im Tag. Echte Adresse, damit der Knopf auch wirklich
|
||||||
|
// etwas anzeigt.
|
||||||
|
const text = '[Idk GIF by Respective]';
|
||||||
|
return chat({
|
||||||
|
text,
|
||||||
|
gifs: [{
|
||||||
|
id: 'Pnd7qLFuGCOntZcNKB',
|
||||||
|
url: 'https://media0.giphy.com/media/Pnd7qLFuGCOntZcNKB/giphy.gif',
|
||||||
|
from: 0, to: [...text].length - 1,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'chat-erstmals': return chat({ first: true, text: 'moin, bin neu hier' });
|
||||||
|
case 'chat-bits': return chat({ bits: 500, text: 'nimm die 500 Bits' });
|
||||||
|
case 'chat-lang': return chat({
|
||||||
|
// Der Fall, den man sonst erst im Stream sieht: eine Nachricht, die
|
||||||
|
// den Kasten sprengt.
|
||||||
|
text: 'also ich hab das gestern auch probiert und da ist mir aufgefallen ' +
|
||||||
|
'dass der Boss in Phase zwei immer nach links ausweicht, aber nur wenn ' +
|
||||||
|
'man vorher nicht geblockt hat, und das steht in keinem Guide drin',
|
||||||
|
});
|
||||||
|
case 'chat-loeschen':
|
||||||
|
return letzteChatId ? { kind: 'clear', messageId: letzteChatId } : null;
|
||||||
|
case 'chat-leeren': return { kind: 'clear' };
|
||||||
|
|
||||||
|
case 'hype': return { kind: 'hype', score: 4 + Math.random() * 4, rate: 45 };
|
||||||
|
|
||||||
|
case 'live-an': return {
|
||||||
|
kind: 'stream', live: true,
|
||||||
|
title: 'Probelauf des Overlays', category: 'Just Chatting',
|
||||||
|
viewers: 12 + Math.floor(Math.random() * 30),
|
||||||
|
startedAt: new Date(Date.now() - 42 * 60_000).toISOString(),
|
||||||
|
};
|
||||||
|
case 'live-aus': return {
|
||||||
|
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-2
@@ -1,5 +1,103 @@
|
|||||||
# @abdulive/overlay-web
|
# @abdulive/overlay-web
|
||||||
|
|
||||||
Browser-Sources fuer OBS: Alerts, Effekte, Community-Hinweis, Umfrage, Raid-Panel.
|
Die Browser-Sources für OBS. Fünf Seiten, alle mit durchsichtigem Hintergrund,
|
||||||
|
alle an demselben Ereignisstrom.
|
||||||
|
|
||||||
Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md)
|
Kein Build, kein Framework, keine Abhängigkeit: OBS lädt die Dateien direkt,
|
||||||
|
und jede Zwischenstufe wäre ein Ding, das mitten im Stream kaputtgehen kann.
|
||||||
|
Gemeinsam sind nur [`theme.css`](public/theme.css) (Farben, Schrift, Kontur)
|
||||||
|
und [`client.js`](public/client.js) (Verbindung, Escaping, Emotes).
|
||||||
|
|
||||||
|
## Die Seiten
|
||||||
|
|
||||||
|
| Adresse | Größe | Was sie zeigt |
|
||||||
|
|---|---|---|
|
||||||
|
| `/alerts` | 1920×1080 | Abos, Raids, Bits, Erstchatter. Eine nach der anderen, nie übereinander. |
|
||||||
|
| `/chat` | 460×900 | Chatnachrichten mit Emotes, Abzeichen, Farben. |
|
||||||
|
| `/status` | 1920×160 | Titel, Kategorie, Zuschauer, Laufzeit. |
|
||||||
|
| `/effects` | 1920×1080 | Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids. |
|
||||||
|
| `/starting` | 1920×1080 | Pausenbild als eigene Szene. Deckend, nicht durchsichtig. |
|
||||||
|
|
||||||
|
Die fertigen Adressen mit Schlüssel stehen auf der Übersichtsseite (`/`).
|
||||||
|
|
||||||
|
## In OBS
|
||||||
|
|
||||||
|
Je Seite eine Browser-Source anlegen, Adresse eintragen, Breite und Höhe
|
||||||
|
setzen. Zwei Häkchen sind wichtig:
|
||||||
|
|
||||||
|
- **"Quelle beim Unsichtbarwerden deaktivieren" aus.** Sonst verpasst das
|
||||||
|
Overlay alles, was passiert, während eine andere Szene im Bild ist, und
|
||||||
|
baut sich bei jedem Szenenwechsel neu auf.
|
||||||
|
- **"Browser beim Szenenwechsel aktualisieren" aus.** Eine laufende
|
||||||
|
Einblendung würde sonst mitten im Wechsel abgeschnitten.
|
||||||
|
|
||||||
|
## Einstellungen über die Adresse
|
||||||
|
|
||||||
|
Es gibt keine Konfigurationsdatei. Was einstellbar ist, hängt hinten an der
|
||||||
|
URL - so lässt es sich in OBS ändern, ohne den Dienst anzufassen.
|
||||||
|
|
||||||
|
| Seite | Parameter | Vorgabe |
|
||||||
|
|---|---|---|
|
||||||
|
| alle | `probe=1` | dunkler Hintergrund zum Ansehen im Browser |
|
||||||
|
| `/alerts` | `sekunden` | 7 - wie lange eine Einblendung steht |
|
||||||
|
| `/alerts` | `erstchatter=0` | Erstchatter nicht einblenden |
|
||||||
|
| `/chat` | `max` | 14 - so viele Zeilen gleichzeitig |
|
||||||
|
| `/chat` | `sekunden` | 120 - danach verblasst eine Zeile (0 = nie) |
|
||||||
|
| `/chat` | `befehle=1` | auch `!`-Befehle zeigen |
|
||||||
|
| `/status` | `kanal` | abdulive_ |
|
||||||
|
| `/effects` | `max` | 260 - Obergrenze für Teilchen |
|
||||||
|
| `/starting` | `text`, `unter` | "Gleich geht es los", leer |
|
||||||
|
|
||||||
|
## Zum Ausprobieren
|
||||||
|
|
||||||
|
Auf der Übersichtsseite (`/`) steht unter **Ausprobieren** für jede Einblendung,
|
||||||
|
jede Chat-Variante und jeden Effekt ein Knopf. Er löst ein erfundenes Ereignis
|
||||||
|
aus, das nur an die Browser-Sources geht - nichts davon landet in der Datenbank,
|
||||||
|
auf Discord oder bei Twitch.
|
||||||
|
|
||||||
|
Am besten die zu prüfende Seite über „ansehen" in einem zweiten Fenster offen
|
||||||
|
haben. Dabei hilft `?probe=1`: das legt einen dunklen Grund unter die sonst
|
||||||
|
durchsichtige Seite.
|
||||||
|
|
||||||
|
Zwei Dinge sind gesperrt, solange ein Stream läuft: „Stream läuft" und die
|
||||||
|
Vorführung. Beide setzen einen Zustand, der in der Statusleiste stehen bliebe,
|
||||||
|
bis die nächste Twitch-Abfrage ihn geraderückt. „Stand zurücksetzen" ist
|
||||||
|
absichtlich nie gesperrt - das ist der Weg aus einem erfundenen Zustand wieder
|
||||||
|
heraus.
|
||||||
|
|
||||||
|
Dieselben Ereignisse im Dauerbetrieb, auch ohne laufenden Dienst:
|
||||||
|
|
||||||
|
```
|
||||||
|
abdulive overlay --demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Erfundener Chat, alle neun Sekunden eine Einblendung, ab und zu eine
|
||||||
|
Chat-Spitze. Ohne das ließe sich eine Raid-Einblendung nur prüfen, indem man
|
||||||
|
auf einen echten Raid wartet.
|
||||||
|
|
||||||
|
## Was fehlt
|
||||||
|
|
||||||
|
- **Follows.** Kommen nicht über IRC, dafür braucht es EventSub. Bis dahin
|
||||||
|
bleibt das Overlay bei Follows stumm; Abos, Raids und Bits kommen an.
|
||||||
|
- **BTTV- und 7TV-Emotes.** Die hängen an einer zweiten API. Twitchs eigene
|
||||||
|
Emotes werden angezeigt.
|
||||||
|
- **Töne.** Es liegen keine Audiodateien im Repo, und ein Overlay, das
|
||||||
|
ungefragt Krach macht, ist schlimmer als eines, das still ist.
|
||||||
|
|
||||||
|
## Vorlesen (`/tts`)
|
||||||
|
|
||||||
|
Eigene Browser-Source, 600×200, **mit Ton**. In OBS darf „Audio über OBS
|
||||||
|
steuern" nicht ausgeschaltet sein, sonst hört der Stream nichts.
|
||||||
|
|
||||||
|
Gesprochen wird über `speechSynthesis`, die im Browser eingebaute
|
||||||
|
Sprachausgabe. Kein Cloud-Dienst, keine Kosten, kein Zuschauertext, der das
|
||||||
|
Haus verlässt. **Ob OBS' Browser-Quelle das kann, ist offen** — CEF wird
|
||||||
|
teils ohne Sprachausgabe gebaut. Die Seite prüft das beim Start und blendet
|
||||||
|
einen roten Hinweis ein, wenn sie nicht sprechen kann; im Stream soll das
|
||||||
|
auffallen und nicht stillschweigend fehlen.
|
||||||
|
|
||||||
|
Einstellungen: `?sprache=de-DE`, `?tempo=1`, `?laut=1`.
|
||||||
|
|
||||||
|
Vorgelesen wird nur, was auf der Übersichtsseite freigegeben wurde. Nach einem
|
||||||
|
Neuverbinden wird **nichts nachgeholt** — eine Nachricht von vor zehn Minuten
|
||||||
|
vorzulesen ist schlimmer, als sie zu verpassen.
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>abdulive - Einblendungen</title>
|
||||||
|
<link rel="stylesheet" href="/theme.css">
|
||||||
|
<style>
|
||||||
|
/* Einblendungen laufen nacheinander, nie uebereinander. Zwei Alarme
|
||||||
|
* gleichzeitig sind unlesbar, und bei einem Massengeschenk kommen zehn
|
||||||
|
* Stueck in derselben Sekunde. */
|
||||||
|
.buehne {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.karte {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 620px;
|
||||||
|
max-width: 1100px;
|
||||||
|
padding: 34px 54px 38px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0 34px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: var(--tinte-klar);
|
||||||
|
backdrop-filter: blur(18px) saturate(150%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .14);
|
||||||
|
box-shadow: var(--schatten), var(--glanz), 0 0 90px -20px var(--ton);
|
||||||
|
/* Die Kante in der Akzentfarbe ist das, was die Einblendung dem Ereignis
|
||||||
|
* zuordnet, bevor man ein Wort gelesen hat. */
|
||||||
|
border-left: 8px solid var(--ton);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.karte.an { animation: herein .75s cubic-bezier(.16, 1.2, .3, 1) both; }
|
||||||
|
.karte.weg { animation: hinaus .45s cubic-bezier(.5, 0, .75, 0) both; }
|
||||||
|
|
||||||
|
@keyframes herein {
|
||||||
|
from { opacity: 0; transform: translateY(46px) scale(.9); filter: blur(8px); }
|
||||||
|
to { opacity: 1; transform: none; filter: none; }
|
||||||
|
}
|
||||||
|
@keyframes hinaus {
|
||||||
|
from { opacity: 1; transform: none; }
|
||||||
|
to { opacity: 0; transform: translateY(-34px) scale(.96); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.zeichen {
|
||||||
|
font-size: 92px;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 6px 18px rgba(0, 0, 0, .6));
|
||||||
|
animation: huepfen .9s cubic-bezier(.2, 1.6, .4, 1) .12s both;
|
||||||
|
}
|
||||||
|
@keyframes huepfen {
|
||||||
|
0% { transform: scale(0) rotate(-25deg); }
|
||||||
|
70% { transform: scale(1.18) rotate(6deg); }
|
||||||
|
100% { transform: scale(1) rotate(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.kopf {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ton);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-size: 58px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
.zusatz { font-size: 26px; color: var(--schrift-leise); margin-top: 6px; }
|
||||||
|
.notiz {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--schrift-leise);
|
||||||
|
max-width: 44ch;
|
||||||
|
/* Zuschauertext. Drei Zeilen, dann Schluss - sonst schiebt jemand mit
|
||||||
|
* einem Roman die Karte aus dem Bild. */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.notiz .emote { height: 1.4em; vertical-align: -.3em; }
|
||||||
|
|
||||||
|
/* Der Balken, der unter der Karte ablaeuft: zeigt, wie lange sie noch steht. */
|
||||||
|
.uhr {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; bottom: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--ton);
|
||||||
|
transform-origin: left;
|
||||||
|
animation: ablaufen var(--dauer) linear both;
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
@keyframes ablaufen { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="buehne"><div id="platz"></div></div>
|
||||||
|
|
||||||
|
<script src="/client.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var platz = document.getElementById('platz');
|
||||||
|
var dauer = Overlay.param('sekunden', 7) * 1000;
|
||||||
|
var warteschlange = [];
|
||||||
|
var laeuft = false;
|
||||||
|
|
||||||
|
/* Ein Eintrag je Art: Farbe, Zeichen, Ueberschrift und die beiden
|
||||||
|
* Textzeilen. Alles an einem Ort, damit eine neue Art eine Zeile ist. */
|
||||||
|
var ARTEN = {
|
||||||
|
sub: { ton: 'var(--lila)', zeichen: '💜', kopf: 'Neues Abo' },
|
||||||
|
resub: { ton: 'var(--lila)', zeichen: '💜', kopf: 'Abo verlängert' },
|
||||||
|
subgift: { ton: 'var(--pink)', zeichen: '🎁', kopf: 'Abo verschenkt' },
|
||||||
|
massgift: { ton: 'var(--pink)', zeichen: '🎁', kopf: 'Abos verschenkt' },
|
||||||
|
raid: { ton: 'var(--tuerkis)', zeichen: '🚀', kopf: 'Raid' },
|
||||||
|
cheer: { ton: 'var(--gold)', zeichen: '✨', kopf: 'Bits' },
|
||||||
|
follow: { ton: 'var(--gruen)', zeichen: '⭐', kopf: 'Neuer Follow' },
|
||||||
|
first: { ton: 'var(--gruen)', zeichen: '👋', kopf: 'Zum ersten Mal hier' },
|
||||||
|
clip: { ton: 'var(--tuerkis)', zeichen: '🎬', kopf: 'Clip gespeichert' },
|
||||||
|
// Eigene Art, eigene Farbe, eigene Ueberschrift. Eine eingeloeste
|
||||||
|
// Belohnung darf nicht aussehen wie ein Abo.
|
||||||
|
reward: { ton: 'var(--gold)', zeichen: '🎟️', kopf: 'Belohnung eingelöst' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Die zweite Zeile. Deutsch mit richtigen Pluralformen - "1 Monate"
|
||||||
|
* liest sich wie ein Fehler, und das ist es auch. */
|
||||||
|
function zusatz(ev) {
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'resub':
|
||||||
|
return ev.months ? ev.months + (ev.months === 1 ? ' Monat' : ' Monate') + ' dabei' : '';
|
||||||
|
case 'subgift':
|
||||||
|
return ev.recipient ? 'an ' + Overlay.esc(ev.recipient) : '';
|
||||||
|
case 'massgift':
|
||||||
|
return (ev.amount || 1) + (ev.amount === 1 ? ' Abo' : ' Abos') + ' an den Chat';
|
||||||
|
case 'raid':
|
||||||
|
return 'mit ' + Overlay.zahl(ev.amount || 0) +
|
||||||
|
((ev.amount === 1) ? ' Zuschauer' : ' Zuschauern');
|
||||||
|
case 'cheer':
|
||||||
|
return Overlay.zahl(ev.amount || 0) + ' Bits';
|
||||||
|
case 'reward':
|
||||||
|
// Der Titel der Belohnung ist die Information, nicht die Punktzahl.
|
||||||
|
return Overlay.esc(ev.reward || '') +
|
||||||
|
(ev.amount ? ' · ' + Overlay.zahl(ev.amount) + ' Punkte' : '');
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeige(ev) {
|
||||||
|
var art = ARTEN[ev.type];
|
||||||
|
if (!art) return naechste();
|
||||||
|
|
||||||
|
var karte = document.createElement('div');
|
||||||
|
karte.className = 'karte an';
|
||||||
|
karte.style.setProperty('--ton', art.ton);
|
||||||
|
karte.style.setProperty('--dauer', (dauer / 1000) + 's');
|
||||||
|
|
||||||
|
var text = zusatz(ev);
|
||||||
|
// Die Nachricht nur dort, wo Zuschauer wirklich eine schreiben.
|
||||||
|
var notiz = (ev.message && (ev.type === 'resub' || ev.type === 'cheer' ||
|
||||||
|
ev.type === 'sub' || ev.type === 'reward'))
|
||||||
|
? '<div class="notiz">' + Overlay.emoteHtml(ev.message, []) + '</div>' : '';
|
||||||
|
|
||||||
|
karte.innerHTML =
|
||||||
|
'<div class="zeichen">' + art.zeichen + '</div>' +
|
||||||
|
'<div>' +
|
||||||
|
'<div class="kopf">' + art.kopf + '</div>' +
|
||||||
|
'<div class="name kontur">' + Overlay.esc(ev.name || 'Jemand') + '</div>' +
|
||||||
|
(text ? '<div class="zusatz">' + text + '</div>' : '') +
|
||||||
|
notiz +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="uhr"></div>';
|
||||||
|
|
||||||
|
// Der Lichtstreifen liegt auf der Karte, nicht im Markup - so laeuft er
|
||||||
|
// genau einmal und stoert das Raster nicht.
|
||||||
|
karte.classList.add('streifen');
|
||||||
|
platz.appendChild(karte);
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
karte.classList.remove('an');
|
||||||
|
karte.classList.add('weg');
|
||||||
|
setTimeout(function () { karte.remove(); naechste(); }, 460);
|
||||||
|
}, dauer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function naechste() {
|
||||||
|
var ev = warteschlange.shift();
|
||||||
|
if (!ev) { laeuft = false; return; }
|
||||||
|
laeuft = true;
|
||||||
|
zeige(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
Overlay.on('alert', function (ev) {
|
||||||
|
// Erstchatter sind haeufig und sollen den Bildschirm nicht sprengen -
|
||||||
|
// wer sie nicht will, haengt ?erstchatter=0 an die Adresse.
|
||||||
|
if (ev.type === 'first' && !Overlay.param('erstchatter', true)) return;
|
||||||
|
warteschlange.push(ev);
|
||||||
|
// Eine Warteschlange, die bei einem Bot-Angriff auf 200 waechst, laeuft
|
||||||
|
// eine halbe Stunde lang durch. Lieber die aeltesten fallen lassen.
|
||||||
|
if (warteschlange.length > 12) warteschlange.splice(0, warteschlange.length - 12);
|
||||||
|
if (!laeuft) naechste();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nach dem Neuverbinden NICHT die alten Alarme nachspielen: ein Abo von
|
||||||
|
// vor zehn Minuten noch einmal einzublenden ist schlimmer als es zu
|
||||||
|
// verpassen. Der Schnappschuss wird hier bewusst ignoriert.
|
||||||
|
Overlay.on('state', function () { });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>abdulive - Chat</title>
|
||||||
|
<link rel="stylesheet" href="/theme.css">
|
||||||
|
<style>
|
||||||
|
/* Von unten nach oben, wie im Chat selbst: die neueste Nachricht steht
|
||||||
|
* unten, wo das Auge sie erwartet. Deshalb column-reverse und nicht ein
|
||||||
|
* Scrollcontainer - der haette einen Balken und wuerde mitwandern. */
|
||||||
|
#liste {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zeile {
|
||||||
|
padding: 9px 14px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(18, 14, 29, .66);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, .45);
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.38;
|
||||||
|
word-break: break-word;
|
||||||
|
animation: herein .42s cubic-bezier(.16, 1.1, .3, 1) both;
|
||||||
|
transition: opacity .6s, transform .6s;
|
||||||
|
}
|
||||||
|
@keyframes herein {
|
||||||
|
from { opacity: 0; transform: translateX(-26px) scale(.97); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.zeile.geht { opacity: 0; transform: translateX(-20px); }
|
||||||
|
|
||||||
|
.name { font-weight: 700; margin-right: .45em; }
|
||||||
|
.text .emote { height: 1.55em; vertical-align: -.4em; margin: 0 .05em; }
|
||||||
|
|
||||||
|
/* Ein GIF ist kein Emote: es steht fuer sich, in eigener Zeile, und wird
|
||||||
|
* begrenzt. Ohne Deckel schiebt ein 500 Pixel breites GIF die ganze Spalte
|
||||||
|
* auf - in einer 460 Pixel schmalen Browser-Source faellt das sofort auf. */
|
||||||
|
.text .gif {
|
||||||
|
display: block;
|
||||||
|
margin: 6px 0 2px;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 180px;
|
||||||
|
width: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Abzeichen statt Bilder: Twitchs Abzeichen-URLs haengen am Kanal und muessten
|
||||||
|
* ueber Helix aufgeloest werden. Ein farbiger Punkt sagt dasselbe. */
|
||||||
|
.abzeichen {
|
||||||
|
display: inline-block;
|
||||||
|
width: .5em; height: .5em;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: .4em;
|
||||||
|
vertical-align: .08em;
|
||||||
|
}
|
||||||
|
.abzeichen.mod { background: var(--gruen); }
|
||||||
|
.abzeichen.abo { background: var(--lila); }
|
||||||
|
.abzeichen.host { background: var(--rot); }
|
||||||
|
|
||||||
|
/* Wer zum ersten Mal schreibt, wird hervorgehoben - das ist der Moment, in
|
||||||
|
* dem eine Begruessung am meisten bringt. Ohne den Balken an der linken Kante
|
||||||
|
* tragen das jetzt der Farbverlauf und die Zeile darueber allein, deshalb ist
|
||||||
|
* der Verlauf kraeftiger als vorher. */
|
||||||
|
.zeile.erstmals {
|
||||||
|
background: linear-gradient(90deg, rgba(74, 222, 128, .24), rgba(18, 14, 29, .66) 65%);
|
||||||
|
}
|
||||||
|
.zeile.erstmals::before {
|
||||||
|
content: "zum ersten Mal hier";
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gruen);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.zeile.bits { background: linear-gradient(90deg, rgba(255, 197, 61, .22), rgba(18, 14, 29, .66) 65%); }
|
||||||
|
.zeile.bits .name { color: var(--gold) !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="liste"></div>
|
||||||
|
|
||||||
|
<script src="/client.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var liste = document.getElementById('liste');
|
||||||
|
var maximal = Overlay.param('max', 14);
|
||||||
|
var lebensdauer = Overlay.param('sekunden', 120) * 1000;
|
||||||
|
var befehleZeigen = Overlay.param('befehle', false);
|
||||||
|
|
||||||
|
/* Twitchs Farben werden auf dunklem Grund teils unlesbar - dunkelblau auf
|
||||||
|
* Schwarz ist nicht zu entziffern. Deshalb wird die Helligkeit angehoben
|
||||||
|
* statt die Farbe zu verwerfen: die Farbe ist Wiedererkennung. */
|
||||||
|
function lesbar(hex) {
|
||||||
|
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) return '#c9a7ff';
|
||||||
|
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16),
|
||||||
|
b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
var hell = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
if (hell >= 0.5) return hex;
|
||||||
|
var f = 0.5 / Math.max(hell, 0.08);
|
||||||
|
var an = function (v) { return Math.min(255, Math.round(v * f)); };
|
||||||
|
return '#' + [an(r), an(g), an(b)].map(function (v) {
|
||||||
|
return v.toString(16).padStart(2, '0');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function entferne(el) {
|
||||||
|
// Doppelt aufrufbar: das Zeitlimit und das Nachruecken koennen dieselbe
|
||||||
|
// Zeile treffen.
|
||||||
|
if (!el || !el.parentNode || el.classList.contains('geht')) return;
|
||||||
|
el.classList.add('geht');
|
||||||
|
setTimeout(function () { el.remove(); }, 620);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeilen, die ueber die Obergrenze hinausgehen, ausblenden.
|
||||||
|
*
|
||||||
|
* Gezaehlt wird nur, was noch nicht auf dem Weg nach draussen ist: eine
|
||||||
|
* ausblendende Zeile haengt noch eine halbe Sekunde im DOM. Wer stattdessen
|
||||||
|
* `liste.children.length` zaehlt und in einer while-Schleife entfernt,
|
||||||
|
* bekommt eine Endlosschleife - die Laenge sinkt ja erst nach der
|
||||||
|
* Animation. In OBS friert die Quelle damit beim vierzehnten Chatbeitrag
|
||||||
|
* ein, und im Browser sieht man einen haengenden Tab.
|
||||||
|
*/
|
||||||
|
function aufraeumen() {
|
||||||
|
var bleibend = [];
|
||||||
|
var zeilen = liste.children;
|
||||||
|
for (var i = 0; i < zeilen.length; i++) {
|
||||||
|
if (!zeilen[i].classList.contains('geht')) bleibend.push(zeilen[i]);
|
||||||
|
}
|
||||||
|
// index 0 ist die neueste Zeile, also faellt das Ende zuerst.
|
||||||
|
for (var j = maximal; j < bleibend.length; j++) entferne(bleibend[j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeige(ev) {
|
||||||
|
if (!befehleZeigen && ev.text.charAt(0) === '!') return;
|
||||||
|
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'zeile' + (ev.first ? ' erstmals' : '') + (ev.bits > 0 ? ' bits' : '');
|
||||||
|
el.dataset.id = ev.id;
|
||||||
|
el.dataset.user = ev.userId;
|
||||||
|
|
||||||
|
var abzeichen = '';
|
||||||
|
if (ev.broadcaster) abzeichen += '<span class="abzeichen host"></span>';
|
||||||
|
else if (ev.mod) abzeichen += '<span class="abzeichen mod"></span>';
|
||||||
|
if (ev.sub) abzeichen += '<span class="abzeichen abo"></span>';
|
||||||
|
|
||||||
|
el.innerHTML =
|
||||||
|
abzeichen +
|
||||||
|
'<span class="name" style="color:' + lesbar(ev.color) + '">' +
|
||||||
|
Overlay.esc(ev.name) + '</span>' +
|
||||||
|
'<span class="text">' + Overlay.nachrichtHtml(ev.text, ev.gifs, ev.emotes) + '</span>';
|
||||||
|
|
||||||
|
// prepend, weil column-reverse die Liste ohnehin umdreht.
|
||||||
|
liste.insertBefore(el, liste.firstChild);
|
||||||
|
aufraeumen();
|
||||||
|
|
||||||
|
if (lebensdauer > 0) setTimeout(function () { entferne(el); }, lebensdauer);
|
||||||
|
}
|
||||||
|
|
||||||
|
Overlay.on('chat', zeige);
|
||||||
|
|
||||||
|
// Loeschen durch einen Mod muss durchschlagen: eine geloeschte Beleidigung,
|
||||||
|
// die noch zwei Minuten im Bild steht, steht auch in der Aufzeichnung.
|
||||||
|
Overlay.on('clear', function (ev) {
|
||||||
|
var alle = Array.prototype.slice.call(liste.children);
|
||||||
|
for (var i = 0; i < alle.length; i++) {
|
||||||
|
var el = alle[i];
|
||||||
|
if (!ev.messageId && !ev.userId) entferne(el);
|
||||||
|
else if (ev.messageId && el.dataset.id === ev.messageId) entferne(el);
|
||||||
|
else if (ev.userId && el.dataset.user === ev.userId) entferne(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nach dem Neuverbinden den letzten Stand zeigen, damit die Quelle nicht
|
||||||
|
// leer im Bild steht, bis jemand schreibt.
|
||||||
|
Overlay.on('state', function (ev) {
|
||||||
|
liste.innerHTML = '';
|
||||||
|
var letzte = ev.chat.slice(-maximal);
|
||||||
|
for (var i = 0; i < letzte.length; i++) zeige(letzte[i]);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/* Gemeinsamer Anschluss aller Browser-Sources an den Ereignisstrom.
|
||||||
|
*
|
||||||
|
* Bewusst kein Modul und kein Build: OBS laedt diese Datei direkt, und jede
|
||||||
|
* Zwischenstufe waere ein Ding, das im Stream kaputtgehen kann.
|
||||||
|
*
|
||||||
|
* Die drei Dinge, an denen solche Overlays sonst scheitern:
|
||||||
|
*
|
||||||
|
* 1. Die Quelle bleibt nach einem Neustart des Dienstes tot. -> EventSource
|
||||||
|
* verbindet von allein neu, und ein Wachhund greift ein, wenn selbst das
|
||||||
|
* haengt (OBS haelt tote Verbindungen erstaunlich lange fuer lebendig).
|
||||||
|
* 2. Die Quelle ist nach dem Neuverbinden leer. -> Der Server schickt als
|
||||||
|
* erstes einen Schnappschuss, den die Seiten als `state` bekommen.
|
||||||
|
* 3. Chat-Text landet ungeprueft im DOM. -> `Overlay.esc` und `emoteHtml`
|
||||||
|
* sind die einzigen Wege, auf denen fremder Text hier hineinkommt.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
var handlers = {};
|
||||||
|
var quelle = null;
|
||||||
|
var letzteNachricht = Date.now();
|
||||||
|
|
||||||
|
/** Ohne Nachricht in dieser Zeit gilt die Verbindung als tot. Der Server
|
||||||
|
* schickt alle 15 s einen Herzschlag, also sind 45 s drei verpasste. */
|
||||||
|
var TOT_NACH_MS = 45000;
|
||||||
|
|
||||||
|
function verbinde() {
|
||||||
|
if (quelle) { try { quelle.close(); } catch (e) { /* schon zu */ } }
|
||||||
|
var url = '/events' + (params.get('key') ? '?key=' + encodeURIComponent(params.get('key')) : '');
|
||||||
|
quelle = new EventSource(url);
|
||||||
|
letzteNachricht = Date.now();
|
||||||
|
|
||||||
|
quelle.onopen = function () {
|
||||||
|
document.body.classList.remove('offline');
|
||||||
|
letzteNachricht = Date.now();
|
||||||
|
};
|
||||||
|
quelle.onerror = function () {
|
||||||
|
document.body.classList.add('offline');
|
||||||
|
};
|
||||||
|
quelle.onmessage = function (e) {
|
||||||
|
letzteNachricht = Date.now();
|
||||||
|
document.body.classList.remove('offline');
|
||||||
|
var ev;
|
||||||
|
try { ev = JSON.parse(e.data); } catch (err) { return; }
|
||||||
|
if (ev.kind === 'beat') return;
|
||||||
|
var liste = handlers[ev.kind] || [];
|
||||||
|
for (var i = 0; i < liste.length; i++) {
|
||||||
|
try { liste[i](ev); } catch (err) { console.error('[overlay]', ev.kind, err); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(function () {
|
||||||
|
if (Date.now() - letzteNachricht > TOT_NACH_MS) {
|
||||||
|
console.warn('[overlay] nichts mehr gehoert, verbinde neu');
|
||||||
|
verbinde();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
window.Overlay = {
|
||||||
|
/** Auf eine Ereignisart hoeren. Mehrere Hoerer je Art sind erlaubt. */
|
||||||
|
on: function (kind, fn) {
|
||||||
|
(handlers[kind] = handlers[kind] || []).push(fn);
|
||||||
|
return window.Overlay;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Einstellung aus der Adresse, z. B. ?sekunden=60. */
|
||||||
|
param: function (name, fallback) {
|
||||||
|
var v = params.get(name);
|
||||||
|
if (v === null || v === '') return fallback;
|
||||||
|
if (typeof fallback === 'number') {
|
||||||
|
var n = Number(v);
|
||||||
|
return isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
if (typeof fallback === 'boolean') return v !== '0' && v !== 'false' && v !== 'nein';
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Der einzige erlaubte Weg, fremden Text ins DOM zu bringen. */
|
||||||
|
esc: function (s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text mit eingesetzten Emotes. Erst maskieren, dann ersetzen - in der
|
||||||
|
* anderen Reihenfolge waere jeder Emote-Name ein Einfallstor.
|
||||||
|
*
|
||||||
|
* Twitch liefert nur Name und ID, nicht die Position. Ersetzt wird
|
||||||
|
* deshalb der Name als ganzes Wort. BTTV und 7TV sind nicht dabei; die
|
||||||
|
* haengen an einer zweiten API und waeren ein eigenes Stueck Arbeit.
|
||||||
|
*/
|
||||||
|
emoteHtml: function (text, emotes) {
|
||||||
|
var html = window.Overlay.esc(text);
|
||||||
|
if (!emotes || !emotes.length) return html;
|
||||||
|
for (var i = 0; i < emotes.length; i++) {
|
||||||
|
var e = emotes[i];
|
||||||
|
if (!e.id || !e.name) continue;
|
||||||
|
var sicher = window.Overlay.esc(e.name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
var src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
|
||||||
|
encodeURIComponent(e.id) + '/default/dark/2.0';
|
||||||
|
html = html.replace(new RegExp('(^|\\s)' + sicher + '(?=\\s|$)', 'g'),
|
||||||
|
'$1<img class="emote" src="' + src + '" alt="' + window.Overlay.esc(e.name) + '">');
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachrichtentext mit eingesetzten GIFs und Emotes.
|
||||||
|
*
|
||||||
|
* GIFs kommen anders als Emotes mit einem Zeichenbereich: im Text steht
|
||||||
|
* nur ein Platzhalter wie "[Idk GIF by Respective]", und der gehoert
|
||||||
|
* ersetzt. Wer nur den Text anzeigt, zeigt genau diese eckige Klammer.
|
||||||
|
*/
|
||||||
|
nachrichtHtml: function (text, gifs, emotes) {
|
||||||
|
if (!gifs || !gifs.length) return window.Overlay.emoteHtml(text, emotes);
|
||||||
|
|
||||||
|
// Nach Zeichen zerlegen, nicht nach Bytes: die Bereiche zaehlen
|
||||||
|
// Zeichen, und ein Emoji im Text wuerde die Rechnung sonst verschieben.
|
||||||
|
var chars = Array.from(text);
|
||||||
|
var sortiert = gifs.slice().sort(function (a, b) { return a.from - b.from; });
|
||||||
|
var html = '';
|
||||||
|
var pos = 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < sortiert.length; i++) {
|
||||||
|
var g = sortiert[i];
|
||||||
|
if (typeof g.url !== 'string' || g.url.slice(0, 8) !== 'https://') continue;
|
||||||
|
if (g.from > pos) {
|
||||||
|
html += window.Overlay.emoteHtml(chars.slice(pos, g.from).join(''), emotes);
|
||||||
|
}
|
||||||
|
html += '<img class="gif" src="' + window.Overlay.esc(g.url) + '" alt="GIF"' +
|
||||||
|
' onerror="this.replaceWith(document.createTextNode(this.alt))">';
|
||||||
|
pos = Math.max(pos, g.to + 1);
|
||||||
|
}
|
||||||
|
if (pos < chars.length) {
|
||||||
|
html += window.Overlay.emoteHtml(chars.slice(pos).join(''), emotes);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** "2 Std 14 Min" aus einem ISO-Zeitpunkt. */
|
||||||
|
laufzeit: function (seit) {
|
||||||
|
var ms = Date.now() - Date.parse(seit);
|
||||||
|
if (!isFinite(ms) || ms < 0) return '';
|
||||||
|
var m = Math.floor(ms / 60000);
|
||||||
|
var h = Math.floor(m / 60);
|
||||||
|
return h > 0 ? h + ':' + String(m % 60).padStart(2, '0') + ' Std'
|
||||||
|
: m + ' Min';
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Zahl mit Tausenderpunkt, wie man sie in Deutschland liest. */
|
||||||
|
zahl: function (n) {
|
||||||
|
return Number(n || 0).toLocaleString('de-DE');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Der Punkt unten rechts, der nur bei Verlust erscheint.
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
if (params.get('probe')) document.body.classList.add('probe');
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.className = 'getrennt';
|
||||||
|
d.textContent = 'keine Verbindung';
|
||||||
|
document.body.appendChild(d);
|
||||||
|
verbinde();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>abdulive - Effekte</title>
|
||||||
|
<link rel="stylesheet" href="/theme.css">
|
||||||
|
<style>
|
||||||
|
/* Ein Canvas fuer alles. Hunderte DOM-Knoten, die sich pro Bild bewegen,
|
||||||
|
* kosten in OBS spuerbar Leistung - und Leistung, die hier draufgeht, fehlt
|
||||||
|
* beim Encoder. */
|
||||||
|
canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
/* Der kurze Lichtschein bei einer Chat-Spitze. Liegt hinter den Teilchen und
|
||||||
|
* ist das, was den Effekt "teuer" wirken laesst. */
|
||||||
|
#schein {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
background: radial-gradient(ellipse at 50% 60%,
|
||||||
|
rgba(169, 112, 255, .32), transparent 62%);
|
||||||
|
}
|
||||||
|
#schein.an { animation: blitz 1.6s ease-out both; }
|
||||||
|
@keyframes blitz {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
12% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="schein"></div>
|
||||||
|
<canvas id="bild"></canvas>
|
||||||
|
|
||||||
|
<script src="/client.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var canvas = document.getElementById('bild');
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var schein = document.getElementById('schein');
|
||||||
|
var teilchen = [];
|
||||||
|
var bilder = {}; // Emote-ID -> Image
|
||||||
|
var letzteEmotes = []; // was gerade im Chat benutzt wird
|
||||||
|
var laeuft = false;
|
||||||
|
|
||||||
|
/** Obergrenze. Ein Raid mit 200 Leuten darf den Encoder nicht umbringen. */
|
||||||
|
var MAX = Overlay.param('max', 260);
|
||||||
|
|
||||||
|
function passeAn() {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
passeAn();
|
||||||
|
window.addEventListener('resize', passeAn);
|
||||||
|
|
||||||
|
function bild(id) {
|
||||||
|
if (bilder[id]) return bilder[id];
|
||||||
|
var img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
|
||||||
|
encodeURIComponent(id) + '/default/dark/3.0';
|
||||||
|
bilder[id] = img;
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emote-Regen: faellt von oben, dreht sich leicht, verblasst unten. */
|
||||||
|
function regen(anzahl) {
|
||||||
|
var quellen = letzteEmotes.slice(-12);
|
||||||
|
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
|
||||||
|
var e = quellen.length ? quellen[(Math.random() * quellen.length) | 0] : null;
|
||||||
|
teilchen.push({
|
||||||
|
art: e ? 'emote' : 'punkt',
|
||||||
|
img: e ? bild(e) : null,
|
||||||
|
farbe: farbe(),
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: -60 - Math.random() * canvas.height * 0.5,
|
||||||
|
vx: (Math.random() - 0.5) * 0.8,
|
||||||
|
vy: 2.2 + Math.random() * 3.4,
|
||||||
|
g: 0.02,
|
||||||
|
dreh: (Math.random() - 0.5) * 0.06,
|
||||||
|
winkel: (Math.random() - 0.5) * 0.7,
|
||||||
|
groesse: 34 + Math.random() * 30,
|
||||||
|
leben: 1,
|
||||||
|
zerfall: 0.0016 + Math.random() * 0.0016,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
starte();
|
||||||
|
}
|
||||||
|
|
||||||
|
var PALETTE = ['#a970ff', '#4fd1c5', '#ff4fa3', '#ffc53d', '#4ade80'];
|
||||||
|
function farbe() { return PALETTE[(Math.random() * PALETTE.length) | 0]; }
|
||||||
|
|
||||||
|
/** Konfetti: schiesst aus der unteren Mitte nach oben und faellt zurueck. */
|
||||||
|
function konfetti(anzahl) {
|
||||||
|
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
|
||||||
|
var winkel = -Math.PI / 2 + (Math.random() - 0.5) * 1.5;
|
||||||
|
var tempo = 9 + Math.random() * 11;
|
||||||
|
teilchen.push({
|
||||||
|
art: 'schnipsel',
|
||||||
|
farbe: farbe(),
|
||||||
|
x: canvas.width / 2 + (Math.random() - 0.5) * 340,
|
||||||
|
y: canvas.height + 14,
|
||||||
|
vx: Math.cos(winkel) * tempo,
|
||||||
|
vy: Math.sin(winkel) * tempo,
|
||||||
|
g: 0.28,
|
||||||
|
dreh: (Math.random() - 0.5) * 0.4,
|
||||||
|
winkel: Math.random() * Math.PI,
|
||||||
|
groesse: 9 + Math.random() * 9,
|
||||||
|
leben: 1,
|
||||||
|
zerfall: 0.0055 + Math.random() * 0.004,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
starte();
|
||||||
|
}
|
||||||
|
|
||||||
|
function starte() {
|
||||||
|
if (laeuft) return;
|
||||||
|
laeuft = true;
|
||||||
|
requestAnimationFrame(schritt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schritt() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
for (var i = teilchen.length - 1; i >= 0; i--) {
|
||||||
|
var t = teilchen[i];
|
||||||
|
t.vy += t.g;
|
||||||
|
t.x += t.vx;
|
||||||
|
t.y += t.vy;
|
||||||
|
t.winkel += t.dreh;
|
||||||
|
t.leben -= t.zerfall;
|
||||||
|
|
||||||
|
if (t.leben <= 0 || t.y > canvas.height + 120) { teilchen.splice(i, 1); continue; }
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = Math.max(0, Math.min(1, t.leben));
|
||||||
|
ctx.translate(t.x, t.y);
|
||||||
|
ctx.rotate(t.winkel);
|
||||||
|
|
||||||
|
if (t.art === 'emote' && t.img && t.img.complete && t.img.naturalWidth) {
|
||||||
|
ctx.drawImage(t.img, -t.groesse / 2, -t.groesse / 2, t.groesse, t.groesse);
|
||||||
|
} else if (t.art === 'schnipsel') {
|
||||||
|
ctx.fillStyle = t.farbe;
|
||||||
|
// Schmales Rechteck statt Quadrat: sieht in Drehung nach Papier aus.
|
||||||
|
ctx.fillRect(-t.groesse / 2, -t.groesse / 4, t.groesse, t.groesse / 2);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = t.farbe;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, t.groesse / 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teilchen.length) requestAnimationFrame(schritt);
|
||||||
|
else { laeuft = false; ctx.clearRect(0, 0, canvas.width, canvas.height); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ausloeser
|
||||||
|
|
||||||
|
Overlay.on('chat', function (ev) {
|
||||||
|
for (var i = 0; i < ev.emotes.length; i++) {
|
||||||
|
if (letzteEmotes.indexOf(ev.emotes[i].id) === -1) letzteEmotes.push(ev.emotes[i].id);
|
||||||
|
}
|
||||||
|
if (letzteEmotes.length > 24) letzteEmotes.splice(0, letzteEmotes.length - 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
Overlay.on('hype', function (ev) {
|
||||||
|
schein.classList.remove('an');
|
||||||
|
void schein.offsetWidth;
|
||||||
|
schein.classList.add('an');
|
||||||
|
// Je hoeher die Punktzahl, desto mehr - aber gedeckelt, sonst steht bei
|
||||||
|
// einem echten Ausbruch das Bild voll und man sieht das Spiel nicht mehr.
|
||||||
|
regen(Math.min(110, 34 + Math.round((ev.score || 1) * 9)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Konfetti als eigenes Ereignis: welche Belohnung Konfetti ausloest,
|
||||||
|
// entscheidet der Hub - diese Seite muss davon nichts wissen.
|
||||||
|
Overlay.on('konfetti', function (ev) {
|
||||||
|
konfetti(Math.round(90 * (ev.staerke || 1)));
|
||||||
|
});
|
||||||
|
|
||||||
|
Overlay.on('alert', function (ev) {
|
||||||
|
if (ev.type === 'raid') konfetti(Math.min(180, 70 + (ev.amount || 0) * 2));
|
||||||
|
else if (ev.type === 'massgift') konfetti(Math.min(180, 60 + (ev.amount || 0) * 10));
|
||||||
|
else if (ev.type === 'sub' || ev.type === 'resub' || ev.type === 'subgift') konfetti(80);
|
||||||
|
else if (ev.type === 'cheer' && (ev.amount || 0) >= 100) konfetti(60);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>abdulive - Pausenbild</title>
|
||||||
|
<link rel="stylesheet" href="/theme.css">
|
||||||
|
<style>
|
||||||
|
/* Das einzige Overlay mit deckendem Hintergrund: es ersetzt das Bild, statt
|
||||||
|
* darueber zu liegen. Gedacht als eigene OBS-Szene fuer "gleich geht es los",
|
||||||
|
* Pause und Ende. Text ueber ?text= und ?unter=. */
|
||||||
|
body { background: #0a0813; }
|
||||||
|
|
||||||
|
#grund {
|
||||||
|
position: fixed;
|
||||||
|
inset: -20%;
|
||||||
|
background:
|
||||||
|
radial-gradient(42% 42% at 22% 28%, rgba(169, 112, 255, .55), transparent 70%),
|
||||||
|
radial-gradient(38% 38% at 78% 32%, rgba(79, 209, 197, .45), transparent 70%),
|
||||||
|
radial-gradient(45% 45% at 55% 82%, rgba(255, 79, 163, .40), transparent 70%);
|
||||||
|
filter: blur(48px);
|
||||||
|
animation: treiben 28s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes treiben {
|
||||||
|
0% { transform: translate(0, 0) scale(1) rotate(0deg); }
|
||||||
|
50% { transform: translate(-3%, 2.5%) scale(1.1) rotate(4deg); }
|
||||||
|
100% { transform: translate(3%, -2%) scale(1.04) rotate(-3deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feines Raster darueber: nimmt dem Verlauf das Billige und gibt dem Bild
|
||||||
|
* eine Struktur, die auch nach der Twitch-Kompression noch da ist. */
|
||||||
|
#raster {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, .045) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, .045) 1px, transparent 1px);
|
||||||
|
background-size: 64px 64px;
|
||||||
|
mask-image: radial-gradient(70% 70% at 50% 50%, #000, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mitte {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(64px, 8.5vw, 148px);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -.035em;
|
||||||
|
/* Unterlaengen brauchen Platz. `background-clip: text` faerbt nur, was
|
||||||
|
* innerhalb der Polsterbox liegt - was darueber hinausragt, bleibt
|
||||||
|
* durchsichtig und sieht abgeschnitten aus. Bei fetter Schrift in dieser
|
||||||
|
* Groesse reicht eine knappe Zeilenhoehe dafuer nicht.
|
||||||
|
*
|
||||||
|
* Nachgemessen am "g" in "Gleich geht es los": mit 1.04 und .06em fehlte
|
||||||
|
* noch ein Stueck vom Bogen. */
|
||||||
|
line-height: 1.18;
|
||||||
|
padding-bottom: .14em;
|
||||||
|
background: linear-gradient(100deg, #fff 18%, var(--lila) 48%, var(--tuerkis) 78%, #fff 96%);
|
||||||
|
background-size: 260% 100%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
animation: schimmern 7s linear infinite;
|
||||||
|
filter: drop-shadow(0 10px 40px rgba(0, 0, 0, .55));
|
||||||
|
}
|
||||||
|
@keyframes schimmern { to { background-position: 260% 0; } }
|
||||||
|
|
||||||
|
#unter {
|
||||||
|
font-size: clamp(20px, 2vw, 30px);
|
||||||
|
color: var(--schrift-leise);
|
||||||
|
max-width: 34ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Taktgeber: fuenf Balken, die atmen. Ohne bewegtes Element wirkt ein
|
||||||
|
* Standbild wie ein eingefrorener Stream, und Zuschauer laden neu. */
|
||||||
|
#takt { display: flex; gap: 12px; align-items: flex-end; height: 68px; margin-top: 10px; }
|
||||||
|
#takt i {
|
||||||
|
display: block;
|
||||||
|
width: 13px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: linear-gradient(180deg, var(--tuerkis), var(--lila));
|
||||||
|
animation: atmen 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#takt i:nth-child(1) { height: 40%; animation-delay: 0s; }
|
||||||
|
#takt i:nth-child(2) { height: 75%; animation-delay: .18s; }
|
||||||
|
#takt i:nth-child(3) { height: 100%; animation-delay: .36s; }
|
||||||
|
#takt i:nth-child(4) { height: 65%; animation-delay: .54s; }
|
||||||
|
#takt i:nth-child(5) { height: 35%; animation-delay: .72s; }
|
||||||
|
@keyframes atmen {
|
||||||
|
0%, 100% { transform: scaleY(.35); opacity: .6; }
|
||||||
|
50% { transform: scaleY(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#fuss {
|
||||||
|
position: fixed;
|
||||||
|
left: 0; right: 0; bottom: 42px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 44px;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--schrift-leise);
|
||||||
|
}
|
||||||
|
#fuss b { color: var(--schrift); font-weight: 600; }
|
||||||
|
#uhr { font-variant-numeric: tabular-nums; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="grund"></div>
|
||||||
|
<div id="raster"></div>
|
||||||
|
<div id="mitte">
|
||||||
|
<h1 id="gross">Gleich geht es los</h1>
|
||||||
|
<div id="unter"></div>
|
||||||
|
<div id="takt"><i></i><i></i><i></i><i></i><i></i></div>
|
||||||
|
</div>
|
||||||
|
<div id="fuss">
|
||||||
|
<span id="uhr"></span>
|
||||||
|
<span>twitch.tv/<b id="kanal">abdulive_</b></span>
|
||||||
|
<span id="thema"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/client.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
document.getElementById('gross').textContent = Overlay.param('text', 'Gleich geht es los');
|
||||||
|
document.getElementById('unter').textContent = Overlay.param('unter', '');
|
||||||
|
document.getElementById('kanal').textContent = Overlay.param('kanal', 'abdulive_');
|
||||||
|
|
||||||
|
var uhr = document.getElementById('uhr');
|
||||||
|
function zeit() {
|
||||||
|
uhr.textContent = new Date().toLocaleTimeString('de-DE',
|
||||||
|
{ hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
zeit();
|
||||||
|
setInterval(zeit, 10000);
|
||||||
|
|
||||||
|
// Laeuft der Stream schon, steht unten, worum es geht. Das ist der
|
||||||
|
// Unterschied zwischen einem Pausenbild und einem Standbild.
|
||||||
|
var thema = document.getElementById('thema');
|
||||||
|
function stand(s) {
|
||||||
|
thema.textContent = s.live && s.category ? 'heute: ' + s.category : '';
|
||||||
|
}
|
||||||
|
Overlay.on('stream', stand);
|
||||||
|
Overlay.on('state', function (ev) { stand(ev.stream); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>abdulive - Statusleiste</title>
|
||||||
|
<link rel="stylesheet" href="/theme.css">
|
||||||
|
<style>
|
||||||
|
#leiste {
|
||||||
|
position: fixed;
|
||||||
|
left: 28px; right: 28px; top: 22px;
|
||||||
|
height: 92px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
padding: 0 30px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--tinte-klar);
|
||||||
|
backdrop-filter: blur(16px) saturate(150%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
|
box-shadow: var(--schatten), var(--glanz);
|
||||||
|
/* Ein Farbverlauf an der Oberkante statt eines Rahmens: er bindet die
|
||||||
|
* Leiste an die Marke, ohne einen Kasten um alles zu ziehen. */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#leiste::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; top: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--lila), var(--pink), var(--tuerkis), var(--lila));
|
||||||
|
background-size: 300% 100%;
|
||||||
|
animation: wandern 9s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes wandern { to { background-position: 300% 0; } }
|
||||||
|
|
||||||
|
.marke { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.punkt {
|
||||||
|
width: 13px; height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--rot);
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, .6);
|
||||||
|
}
|
||||||
|
body.live .punkt { background: var(--rot); animation: klopfen 2s ease-out infinite; }
|
||||||
|
body:not(.live) .punkt { background: #555; }
|
||||||
|
@keyframes klopfen {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, .55); }
|
||||||
|
70% { box-shadow: 0 0 0 16px rgba(239, 68, 68, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||||
|
}
|
||||||
|
.kanal { font-size: 26px; font-weight: 700; letter-spacing: -.01em; }
|
||||||
|
|
||||||
|
.mitte { min-width: 0; }
|
||||||
|
#titel {
|
||||||
|
font-size: 25px; font-weight: 600;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
#kategorie { font-size: 17px; color: var(--schrift-leise); margin-top: 2px; }
|
||||||
|
|
||||||
|
.werte { display: flex; gap: 30px; }
|
||||||
|
.wert { text-align: right; min-width: 92px; }
|
||||||
|
.wert .zahl {
|
||||||
|
font-size: 30px; font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.1;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.wert .beschriftung {
|
||||||
|
font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
|
||||||
|
color: var(--schrift-leise);
|
||||||
|
}
|
||||||
|
/* Kurzes Aufleuchten, wenn sich eine Zahl aendert. Ohne das uebersieht man
|
||||||
|
* jede Veraenderung, weil die Leiste sonst voellig still steht. */
|
||||||
|
.zahl.neu { animation: aufleuchten .8s ease-out; }
|
||||||
|
@keyframes aufleuchten {
|
||||||
|
0% { color: var(--tuerkis); transform: translateY(-3px); }
|
||||||
|
100% { color: inherit; transform: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="leiste">
|
||||||
|
<div class="marke">
|
||||||
|
<span class="punkt"></span>
|
||||||
|
<span class="kanal kontur" id="kanal">abdulive_</span>
|
||||||
|
</div>
|
||||||
|
<div class="mitte">
|
||||||
|
<div id="titel" class="kontur">nicht live</div>
|
||||||
|
<div id="kategorie"></div>
|
||||||
|
</div>
|
||||||
|
<div class="werte">
|
||||||
|
<div class="wert"><span class="zahl" id="zuschauer">-</span>
|
||||||
|
<span class="beschriftung">Zuschauer</span></div>
|
||||||
|
<div class="wert"><span class="zahl" id="laufzeit">-</span>
|
||||||
|
<span class="beschriftung">live seit</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/client.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var titel = document.getElementById('titel');
|
||||||
|
var kategorie = document.getElementById('kategorie');
|
||||||
|
var zuschauer = document.getElementById('zuschauer');
|
||||||
|
var laufzeit = document.getElementById('laufzeit');
|
||||||
|
var kanal = document.getElementById('kanal');
|
||||||
|
var seit = null;
|
||||||
|
|
||||||
|
kanal.textContent = Overlay.param('kanal', 'abdulive_');
|
||||||
|
|
||||||
|
function setze(el, wert) {
|
||||||
|
if (el.textContent === wert) return;
|
||||||
|
el.textContent = wert;
|
||||||
|
el.classList.remove('neu');
|
||||||
|
// Neuzeichnen erzwingen, sonst startet die Animation beim zweiten Mal nicht.
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('neu');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stand(ev) {
|
||||||
|
document.body.classList.toggle('live', !!ev.live);
|
||||||
|
titel.textContent = ev.live ? (ev.title || 'ohne Titel') : 'nicht live';
|
||||||
|
kategorie.textContent = ev.live ? (ev.category || '') : '';
|
||||||
|
setze(zuschauer, ev.live ? Overlay.zahl(ev.viewers) : '-');
|
||||||
|
seit = ev.live ? ev.startedAt : null;
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
laufzeit.textContent = seit ? Overlay.laufzeit(seit) : '-';
|
||||||
|
}
|
||||||
|
setInterval(tick, 10000);
|
||||||
|
|
||||||
|
Overlay.on('stream', stand);
|
||||||
|
Overlay.on('state', function (ev) { stand(ev.stream); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/* Gemeinsames Aussehen aller Browser-Sources.
|
||||||
|
*
|
||||||
|
* Zwei Dinge gelten auf jeder Seite und sind der Grund, warum es diese Datei
|
||||||
|
* gibt:
|
||||||
|
*
|
||||||
|
* 1. Der Hintergrund ist durchsichtig. Eine Browser-Source mit weissem
|
||||||
|
* Hintergrund ist der haeufigste Anfaengerfehler, und man sieht ihn erst
|
||||||
|
* in der Aufzeichnung.
|
||||||
|
* 2. Text liegt ueber bewegtem Bild. Reine Farbe reicht dafuer nicht -
|
||||||
|
* jeder Text bekommt eine dunkle Kontur, sonst verschwindet er im hellen
|
||||||
|
* Gameplay. Deshalb `paint-order` und der Schatten unten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Twitch-Lila wie im Discord-Bauplan, damit Stream und Server zusammengehoeren. */
|
||||||
|
--lila: #a970ff;
|
||||||
|
--lila-tief: #6441a5;
|
||||||
|
--tuerkis: #4fd1c5;
|
||||||
|
--pink: #ff4fa3;
|
||||||
|
--gold: #ffc53d;
|
||||||
|
--gruen: #4ade80;
|
||||||
|
--rot: #ef4444;
|
||||||
|
|
||||||
|
--tinte: #120e1d;
|
||||||
|
--tinte-klar: rgba(18, 14, 29, .72);
|
||||||
|
--schrift: #f6f3ff;
|
||||||
|
--schrift-leise: #c5bde0;
|
||||||
|
|
||||||
|
--rund: 14px;
|
||||||
|
--schatten: 0 12px 40px rgba(0, 0, 0, .55);
|
||||||
|
--glanz: inset 0 1px 0 rgba(255, 255, 255, .12);
|
||||||
|
|
||||||
|
/* Segoe UI Variable gibt es auf Windows 11 - Abdus Rechner. Der Rest der
|
||||||
|
* Liste ist Rueckfall fuer Entwicklung auf anderen Systemen. */
|
||||||
|
--font: "Segoe UI Variable Display", "Segoe UI", Inter, system-ui, sans-serif;
|
||||||
|
--font-zahl: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--schrift);
|
||||||
|
/* OBS rendert mit eigener Skalierung; ohne das wirken Kanten weich. */
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
user-select: none;
|
||||||
|
cursor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kontur {
|
||||||
|
paint-order: stroke fill;
|
||||||
|
-webkit-text-stroke: 4px rgba(0, 0, 0, .55);
|
||||||
|
text-shadow: 0 2px 10px rgba(0, 0, 0, .7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glasplatte: der Traeger fuer alles, was ueber dem Spiel steht. */
|
||||||
|
.glas {
|
||||||
|
background: var(--tinte-klar);
|
||||||
|
backdrop-filter: blur(14px) saturate(140%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
|
border-radius: var(--rund);
|
||||||
|
box-shadow: var(--schatten), var(--glanz);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Lichtstreifen, der einmal ueber eine Flaeche wandert. Wird von den
|
||||||
|
* Einblendungen benutzt und ist der Grund, warum eine Einblendung "teuer"
|
||||||
|
* aussieht, ohne dass ein Video dahintersteckt. */
|
||||||
|
@keyframes streifen {
|
||||||
|
from { transform: translateX(-120%) skewX(-18deg); }
|
||||||
|
to { transform: translateX(320%) skewX(-18deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.streifen::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 28%;
|
||||||
|
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .3), transparent);
|
||||||
|
animation: streifen 1.5s cubic-bezier(.3, 0, .2, 1) .35s both;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulsieren {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: .55; transform: scale(.82); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zum Ansehen im normalen Browser: ?probe=1 legt einen dunklen Grund unter
|
||||||
|
* die Seite. Ohne den steht heller Text auf Weiss, und man sieht nichts -
|
||||||
|
* in OBS liegt das Overlay ueber dem Spiel, im Browser ueber gar nichts. */
|
||||||
|
body.probe {
|
||||||
|
background:
|
||||||
|
radial-gradient(60% 60% at 30% 25%, #2a1f45, transparent 70%),
|
||||||
|
radial-gradient(50% 50% at 75% 70%, #123c3a, transparent 70%),
|
||||||
|
#0b0912;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wer im Browser nachsieht, will nicht raten, ob die Verbindung steht.
|
||||||
|
* In OBS ist der Punkt unsichtbar, weil er nur bei Verlust erscheint. */
|
||||||
|
.getrennt {
|
||||||
|
position: fixed;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 8px;
|
||||||
|
font: 500 12px/1 var(--font-zahl);
|
||||||
|
color: var(--rot);
|
||||||
|
background: rgba(0, 0, 0, .7);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .4s;
|
||||||
|
}
|
||||||
|
body.offline .getrennt { opacity: 1; }
|
||||||
|
|
||||||
|
/* Wer mit prefers-reduced-motion arbeitet, bekommt in OBS trotzdem die
|
||||||
|
* Animationen - eine Browser-Source hat keinen Menschen mit Vestibular-
|
||||||
|
* problemen davor. Die Einstellung wird hier bewusst nicht beachtet. */
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>abdulive - Vorlesen</title>
|
||||||
|
<link rel="stylesheet" href="/theme.css">
|
||||||
|
<style>
|
||||||
|
/* Die Seite zeigt fast nichts. Sie ist eine Tonquelle mit einer kleinen
|
||||||
|
* Sichtkontrolle daneben - man soll sehen, was gerade gesprochen wird, und
|
||||||
|
* zwar auch als Zuschauer. Wer nur hoert, was vorgelesen wird, kann nicht
|
||||||
|
* nachlesen, wenn er es nicht verstanden hat. */
|
||||||
|
#kasten {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px; right: 16px; bottom: 16px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--tinte-klar);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
|
box-shadow: var(--schatten), var(--glanz);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
transition: opacity .3s, transform .3s;
|
||||||
|
}
|
||||||
|
#kasten.an { opacity: 1; transform: none; }
|
||||||
|
|
||||||
|
.wer {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tuerkis);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.was { font-size: 22px; line-height: 1.35; }
|
||||||
|
|
||||||
|
/* Ein Hinweis nur fuer den Fall, dass die Browser-Quelle keine Sprachausgabe
|
||||||
|
* kann. Im Stream soll das auffallen, nicht stillschweigend fehlen. */
|
||||||
|
#fehlt {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px; top: 16px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(239, 68, 68, .9);
|
||||||
|
font: 600 15px/1.3 var(--font-zahl);
|
||||||
|
max-width: 520px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="fehlt">
|
||||||
|
Diese Browser-Quelle kann nicht sprechen (keine Sprachausgabe eingebaut).
|
||||||
|
Vorgelesen wird nichts.
|
||||||
|
</div>
|
||||||
|
<div id="kasten"><div class="wer" id="wer"></div><div class="was" id="was"></div></div>
|
||||||
|
|
||||||
|
<script src="/client.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var kasten = document.getElementById('kasten');
|
||||||
|
var werEl = document.getElementById('wer');
|
||||||
|
var wasEl = document.getElementById('was');
|
||||||
|
|
||||||
|
var sprache = Overlay.param('sprache', 'de-DE');
|
||||||
|
var tempo = Overlay.param('tempo', 1);
|
||||||
|
var lautstaerke = Overlay.param('laut', 1);
|
||||||
|
var warteschlange = [];
|
||||||
|
var laeuft = false;
|
||||||
|
|
||||||
|
/* Ob die Browser-Quelle ueberhaupt sprechen kann, weiss man erst zur
|
||||||
|
* Laufzeit: OBS' eingebauter Browser wird teils ohne Sprachausgabe gebaut.
|
||||||
|
* Ohne diese Pruefung bliebe es im Stream einfach still, und niemand wuesste
|
||||||
|
* warum. */
|
||||||
|
var kannSprechen = typeof window.speechSynthesis !== 'undefined' &&
|
||||||
|
typeof window.SpeechSynthesisUtterance !== 'undefined';
|
||||||
|
if (!kannSprechen) {
|
||||||
|
document.getElementById('fehlt').style.display = 'block';
|
||||||
|
console.error('[tts] Diese Browser-Quelle hat keine Sprachausgabe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stimmen werden asynchron geladen. Beim ersten Aufruf ist die Liste oft
|
||||||
|
* noch leer - deshalb bei jedem Sprechen neu nachsehen statt einmal am
|
||||||
|
* Anfang. */
|
||||||
|
function stimme() {
|
||||||
|
if (!kannSprechen) return null;
|
||||||
|
var alle = window.speechSynthesis.getVoices() || [];
|
||||||
|
var passend = alle.filter(function (v) { return v.lang && v.lang.indexOf(sprache.slice(0, 2)) === 0; });
|
||||||
|
return passend[0] || alle[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeige(name, text) {
|
||||||
|
werEl.textContent = name;
|
||||||
|
wasEl.textContent = text;
|
||||||
|
kasten.classList.add('an');
|
||||||
|
}
|
||||||
|
function verstecke() { kasten.classList.remove('an'); }
|
||||||
|
|
||||||
|
function naechste() {
|
||||||
|
var n = warteschlange.shift();
|
||||||
|
if (!n) { laeuft = false; verstecke(); return; }
|
||||||
|
laeuft = true;
|
||||||
|
zeige(n.name, n.text);
|
||||||
|
|
||||||
|
if (!kannSprechen) {
|
||||||
|
// Wenigstens anzeigen, und nach der geschaetzten Lesedauer weiter.
|
||||||
|
setTimeout(naechste, Math.min(15000, 2000 + n.text.length * 60));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rede = new SpeechSynthesisUtterance(n.text);
|
||||||
|
rede.lang = sprache;
|
||||||
|
rede.rate = tempo;
|
||||||
|
rede.volume = lautstaerke;
|
||||||
|
var v = stimme();
|
||||||
|
if (v) rede.voice = v;
|
||||||
|
|
||||||
|
var weiter = false;
|
||||||
|
var gehWeiter = function () {
|
||||||
|
if (weiter) return;
|
||||||
|
weiter = true;
|
||||||
|
setTimeout(naechste, 400);
|
||||||
|
};
|
||||||
|
rede.onend = gehWeiter;
|
||||||
|
rede.onerror = function (e) {
|
||||||
|
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
|
||||||
|
gehWeiter();
|
||||||
|
};
|
||||||
|
// Notbremse: bleibt die Sprachausgabe haengen, steht die Warteschlange
|
||||||
|
// sonst fuer den Rest des Streams. Grosszuegig bemessen.
|
||||||
|
setTimeout(gehWeiter, 5000 + n.text.length * 250);
|
||||||
|
|
||||||
|
window.speechSynthesis.speak(rede);
|
||||||
|
}
|
||||||
|
|
||||||
|
Overlay.on('tts', function (ev) {
|
||||||
|
warteschlange.push({ name: ev.name, text: ev.text });
|
||||||
|
if (warteschlange.length > 10) warteschlange.splice(0, warteschlange.length - 10);
|
||||||
|
if (!laeuft) naechste();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Panikschalter: sofort still, Warteschlange weg.
|
||||||
|
Overlay.on('still', function () {
|
||||||
|
warteschlange.length = 0;
|
||||||
|
if (kannSprechen) { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } }
|
||||||
|
laeuft = false;
|
||||||
|
verstecke();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nach dem Neuverbinden NICHT nachholen: eine Nachricht von vor zehn
|
||||||
|
// Minuten vorzulesen ist schlimmer, als sie zu verpassen.
|
||||||
|
Overlay.on('state', function () { });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -70,16 +70,30 @@ clipper:
|
|||||||
# Der Hotkey wird in OBS auf "Wiedergabepuffer speichern" gelegt. Es gibt
|
# Der Hotkey wird in OBS auf "Wiedergabepuffer speichern" gelegt. Es gibt
|
||||||
# bewusst keinen eigenen Hotkey-Dienst - siehe ADR-0004, Punkt 4.
|
# bewusst keinen eigenen Hotkey-Dienst - siehe ADR-0004, Punkt 4.
|
||||||
|
|
||||||
|
# Go-Live-Ankuendigung auf Discord.
|
||||||
|
#
|
||||||
|
# mode: live -> Post in #ankuendigungen, mit Ping an die Rolle Live-Ping
|
||||||
|
# draft -> derselbe Post in #technik-log, ohne Ping. Zum Zusehen.
|
||||||
|
#
|
||||||
|
# Zum Ausprobieren, ohne auf einen Stream zu warten:
|
||||||
|
# abdulive golive:test (interner Kanal, ohne Ping)
|
||||||
|
# abdulive golive:test --echt (echter Kanal, mit Ping)
|
||||||
broadcaster:
|
broadcaster:
|
||||||
enabled: true
|
enabled: true
|
||||||
mode: draft # draft | live
|
mode: live
|
||||||
|
# Twitch setzt Titel und Kategorie erst kurz nach dem Start. Wer sofort
|
||||||
|
# postet, kuendigt regelmaessig den Titel des letzten Streams an.
|
||||||
delaySecondsAfterOnline: 60
|
delaySecondsAfterOnline: 60
|
||||||
|
# Innerhalb dieser Zeit wird kein zweites Mal angekuendigt. Faengt den Fall
|
||||||
|
# ab, dass Twitch nach einem Aussetzer eine neue Stream-ID vergibt.
|
||||||
cooldownHours: 6
|
cooldownHours: 6
|
||||||
|
pingRole: Live-Ping # leer = Post ohne Ping
|
||||||
|
obsScreenshot: true # Vorschaubild aus OBS statt von Twitch
|
||||||
platforms:
|
platforms:
|
||||||
discord: true
|
discord: true
|
||||||
bluesky: true
|
bluesky: false # noch nicht gebaut
|
||||||
mastodon: false
|
mastodon: false
|
||||||
instagramStoryImage: true
|
instagramStoryImage: false
|
||||||
|
|
||||||
community:
|
community:
|
||||||
greetings:
|
greetings:
|
||||||
@@ -109,8 +123,52 @@ recap:
|
|||||||
|
|
||||||
loyalty:
|
loyalty:
|
||||||
enabled: false # spaetere Ausbaustufe
|
enabled: false # spaetere Ausbaustufe
|
||||||
|
# Kanalpunkt-Belohnungen und was sie ausloesen.
|
||||||
|
#
|
||||||
|
# Zugeordnet wird ueber den Titel der Belohnung auf Twitch (Gross-/Klein-
|
||||||
|
# schreibung egal) oder ueber die stabile ID; liegt beides vor, gewinnt die ID.
|
||||||
|
# Belohnungen, die hier nicht stehen, werden protokolliert und sonst in Ruhe
|
||||||
|
# gelassen - nicht jede Belohnung geht das Toolkit etwas an.
|
||||||
|
#
|
||||||
|
# Aktionen: konfetti | emote-regen | einblendung | tts | nichts
|
||||||
|
rewards:
|
||||||
|
enabled: false # aus, bis hier bewusst Belohnungen stehen
|
||||||
|
belohnungen: []
|
||||||
|
# Beispiel:
|
||||||
|
# belohnungen:
|
||||||
|
# - titel: Konfetti
|
||||||
|
# aktion: konfetti
|
||||||
|
# - titel: Nachricht vorlesen
|
||||||
|
# aktion: tts
|
||||||
|
# maxZeichen: 150
|
||||||
|
tts:
|
||||||
|
# Vorgabe ist die Freigabe von Hand, und das aus gutem Grund: Kanalpunkte
|
||||||
|
# sammeln sich beim Zugucken von allein an und sind damit kaum eine
|
||||||
|
# Huerde. Auf `false` gestellt hat man eine Vorlesefunktion fuer Fremde
|
||||||
|
# im eigenen Stream. Siehe docs/projects/chat-control.md.
|
||||||
|
freigabe: true
|
||||||
|
maxZeichen: 200
|
||||||
|
# Zu langer Text wird abgelehnt, nicht gekuerzt - ein halb vorgelesener
|
||||||
|
# Satz ist schlimmer als gar keiner.
|
||||||
|
blockliste: []
|
||||||
|
proPersonProStunde: 3
|
||||||
|
|
||||||
|
# Overlay-Server. Zugleich die einzige Tuer nach aussen, wenn der Dienst
|
||||||
|
# zentral laeuft: Uebersicht, Einrichtung und OAuth-Rueckruf haengen daran.
|
||||||
overlay:
|
overlay:
|
||||||
port: 4791
|
port: 4791
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Oeffentliche Adresse, ohne Schraegstrich am Ende.
|
||||||
|
#
|
||||||
|
# Leer = der Dienst laeuft lokal und bindet an 127.0.0.1. Ist etwas gesetzt,
|
||||||
|
# bindet er an 0.0.0.0 (der Reverse-Proxy sitzt in einem anderen Container)
|
||||||
|
# und der OAuth-Rueckruf von Twitch geht an <publicUrl>/callback - genau so
|
||||||
|
# muss er auch in der Twitch-App hinterlegt sein.
|
||||||
|
#
|
||||||
|
# Die Umgebungsvariable PUBLIC_URL gewinnt gegen diesen Wert.
|
||||||
|
publicUrl: ""
|
||||||
|
|
||||||
dashboard:
|
dashboard:
|
||||||
port: 4792
|
port: 4792
|
||||||
ingestServer:
|
ingestServer:
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# ADR-0008: Geteilter Betrieb - Hub im LXC, Ingest am Stream-PC
|
||||||
|
|
||||||
|
Datum: 2026-09-17
|
||||||
|
Status: akzeptiert
|
||||||
|
Berührt: [ADR-0005](ADR-0005-zugangsdaten.md), [ADR-0006](ADR-0006-ein-dach.md),
|
||||||
|
[ADR-0007](ADR-0007-native-windows.md)
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Bis hierher lief alles in einem Prozess auf Abdus Rechner (ADR-0007), und
|
||||||
|
alles band an `127.0.0.1` (ADR-0005). Das war richtig, solange jede Aufgabe
|
||||||
|
etwas mit OBS zu tun hatte.
|
||||||
|
|
||||||
|
Zwei neue Aufgaben haben damit aber nichts zu tun:
|
||||||
|
|
||||||
|
- **Der Rollen-Knopf auf Discord.** Er hängt an einer Gateway-Verbindung im
|
||||||
|
Ingest-Daemon. Ist Abdus Rechner aus, klickt jemand auf den Knopf und
|
||||||
|
bekommt "Interaktion fehlgeschlagen". Ein Knopf, der nur läuft, während
|
||||||
|
der Besitzer am Rechner sitzt, ist kein Knopf.
|
||||||
|
- **Die Go-Live-Ankündigung.** Sie braucht Twitch und Discord, sonst nichts.
|
||||||
|
|
||||||
|
Dazu kam das Overlay. Eine Browser-Source in OBS holt ihre Seite über HTTP;
|
||||||
|
ob die von `127.0.0.1` kommt oder aus dem Netz, ist ihr gleich.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Das Toolkit läuft ab jetzt an zwei Stellen, mit einem klaren Schnitt:
|
||||||
|
|
||||||
|
| | Hub (LXC 141, `abdu.dimension47.de`) | Ingest (Abdus PC) |
|
||||||
|
|---|---|---|
|
||||||
|
| Befehl | `abdulive hub` | `abdulive ingest` |
|
||||||
|
| Twitch-Chat lesen und senden | ja | ja |
|
||||||
|
| Zuschauerzahlen, Stream-Sitzungen | ja | ja |
|
||||||
|
| Discord: Rollen-Knopf, Go-Live | **ja** | nein |
|
||||||
|
| Overlay-Seiten ausliefern | **ja** | nein |
|
||||||
|
| OBS, Wiedergabepuffer, `!clip` | nein | **ja** |
|
||||||
|
| Rendern, Untertitel | nein | **ja** |
|
||||||
|
|
||||||
|
Der Schnitt verläuft entlang einer einzigen Frage: **braucht die Aufgabe eine
|
||||||
|
Datei oder ein Programm, das nur auf dem Stream-PC existiert?** Alles, was mit
|
||||||
|
Nein zu beantworten ist, gehört in den Hub.
|
||||||
|
|
||||||
|
Der Hub ist damit der einzige Teil, der von außen erreichbar ist. Er bindet
|
||||||
|
auf `0.0.0.0`, hinter dem vorhandenen Nginx Proxy Manager, und bringt Overlay,
|
||||||
|
Einrichtungsseite und OAuth-Rückruf unter einer Adresse zusammen.
|
||||||
|
|
||||||
|
## Begründung
|
||||||
|
|
||||||
|
**Warum nicht alles zentral.** Der Wiedergabepuffer von OBS liegt als Datei
|
||||||
|
auf Abdus Platte, ffmpeg und whisper arbeiten darauf, und ein Clip ist
|
||||||
|
mehrere hundert Megabyte. Das über das Netz zu schieben wäre teurer als es
|
||||||
|
bringt, und obs-websocket gehört ohnehin nicht ins Internet.
|
||||||
|
|
||||||
|
**Warum nicht alles lokal bleiben.** Genau die zwei Dinge, die Reichweite
|
||||||
|
bringen sollen - der Ping und der Knopf - sind die, die durchlaufen müssen.
|
||||||
|
Sie an den Stream-PC zu hängen heißt, sie an die Verfügbarkeit einer Person zu
|
||||||
|
hängen.
|
||||||
|
|
||||||
|
**Warum zwei Datenbanken und nicht eine.** Beide Seiten schreiben in ihre
|
||||||
|
eigene SQLite-Datei. Eine gemeinsame wäre eine Netzwerkfreigabe für SQLite,
|
||||||
|
und das ist der zuverlässigste bekannte Weg zu einer beschädigten Datei. Was
|
||||||
|
zusammengeführt werden muss, wird später über Ereignisse zusammengeführt, nicht
|
||||||
|
über geteilten Dateizugriff.
|
||||||
|
|
||||||
|
**Warum der Bruch mit ADR-0005 vertretbar ist.** Dort war die Begründung: was
|
||||||
|
nur auf `127.0.0.1` hört, ist gegen alles geschützt, was nicht vor dem Rechner
|
||||||
|
sitzt. Diese Eigenschaft geht verloren, und der Schutz wandert auf zwei Dinge:
|
||||||
|
|
||||||
|
- Passwort mit Sitzungs-Cookie für Übersicht, Einrichtung und Status.
|
||||||
|
- Ein zufälliger Schlüssel in der Adresse jeder Browser-Source. Er darf nur
|
||||||
|
lesen, nie schreiben, und lässt sich auf der Übersichtsseite neu würfeln.
|
||||||
|
|
||||||
|
Der Schlüssel in der URL ist kein schönes Verfahren. Er steht in jedem
|
||||||
|
OBS-Profil und in jedem Screenshot der Einstellungen. Er ist trotzdem der
|
||||||
|
richtige: eine Browser-Source kann kein Formular ausfüllen, keinen Login
|
||||||
|
durchhalten und keinen zweiten Faktor bedienen. Jedes Verfahren, das mehr
|
||||||
|
verlangt, wird spätestens beim ersten Ausfall mitten im Stream abgeschaltet.
|
||||||
|
|
||||||
|
**Warum die Zugangsdaten trotzdem verschlüsselt neben der Datenbank liegen.**
|
||||||
|
Daran ändert sich nichts. Der Hub hat seinen eigenen Speicher, mit eigenem
|
||||||
|
Schlüssel, und die Twitch-Apps brauchen jetzt eine zweite Redirect-URI
|
||||||
|
(`https://abdu.dimension47.de/callback`) neben der bisherigen.
|
||||||
|
|
||||||
|
## Konsequenzen
|
||||||
|
|
||||||
|
- Die Twitch-App braucht eine zweite Redirect-URI. Twitch vergleicht
|
||||||
|
zeichengenau; ein fehlender Schrägstrich sieht im Fehlertext aus wie ein
|
||||||
|
falsches Client-Secret.
|
||||||
|
- Der Discord-Bot darf nur an **einer** Stelle laufen. Laufen Hub und Ingest
|
||||||
|
beide mit hinterlegtem Bot-Token, antworten zwei Prozesse auf denselben
|
||||||
|
Klick. Auf dem Stream-PC bleibt das Discord-Token deshalb leer.
|
||||||
|
- `abdulive ingest` verliert nichts, macht aber weniger. Der Go-Live-Post ist
|
||||||
|
dort weiterhin eingebaut - wer nur lokal fährt, hat damit alles an einer
|
||||||
|
Stelle, und dort gibt es zusätzlich das OBS-Vorschaubild.
|
||||||
|
- Der Hub kennt OBS nicht und kann deshalb keinen Screenshot für die
|
||||||
|
Ankündigung machen. Er nimmt Twitchs Vorschaubild. Das ist kurz nach dem
|
||||||
|
Start manchmal noch schwarz - der Preis dafür, dass der Post auch dann
|
||||||
|
rausgeht, wenn der Daemon am Stream-PC nicht läuft.
|
||||||
|
- ADR-0006 ("ein Dach") gilt weiter, aber jetzt je Seite: der Hub hat ein
|
||||||
|
Dach, der Stream-PC hat eins.
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
| [0005](ADR-0005-zugangsdaten.md) | Zugangsdaten ueber Einrichtungs-Assistent statt .env | akzeptiert |
|
| [0005](ADR-0005-zugangsdaten.md) | Zugangsdaten ueber Einrichtungs-Assistent statt .env | akzeptiert |
|
||||||
| [0006](ADR-0006-ein-dach.md) | Ein Dach: lokale Weboberflaeche, lokale Arbeitsprozesse | akzeptiert |
|
| [0006](ADR-0006-ein-dach.md) | Ein Dach: lokale Weboberflaeche, lokale Arbeitsprozesse | akzeptiert |
|
||||||
| [0007](ADR-0007-native-windows.md) | Native Windows-Anwendung statt Container | akzeptiert |
|
| [0007](ADR-0007-native-windows.md) | Native Windows-Anwendung statt Container | akzeptiert |
|
||||||
|
| [0008](ADR-0008-geteilter-betrieb.md) | Geteilter Betrieb: Hub im LXC, Ingest am Stream-PC | akzeptiert |
|
||||||
|
|
||||||
Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext /
|
Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext /
|
||||||
Entscheidung / Begruendung / Konsequenzen.
|
Entscheidung / Begruendung / Konsequenzen.
|
||||||
+138
-24
@@ -1,6 +1,6 @@
|
|||||||
# Übergabe
|
# Übergabe
|
||||||
|
|
||||||
Stand **2026-09-15**, letztes gebautes Paket 0.5.4. Wer hier weitermacht,
|
Stand **2026-09-17**, letztes gebautes Paket 0.5.4. Wer hier weitermacht,
|
||||||
liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
|
liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
|
||||||
selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
|
selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
|
||||||
|
|
||||||
@@ -20,9 +20,85 @@ Auslöser bis zum fertigen Short steht im Code vollständig. Geprüft ist sie
|
|||||||
| S5 Onboarding + Sichtung | offen | |
|
| S5 Onboarding + Sichtung | offen | |
|
||||||
| S6 Erstchatter-Begrüßung | offen | |
|
| S6 Erstchatter-Begrüßung | offen | |
|
||||||
|
|
||||||
162 Tests, alle grün. Was noch fehlt, steht vollständig in
|
193 Tests, alle grün. Was noch fehlt, steht vollständig in
|
||||||
[`offene-punkte.md`](offene-punkte.md).
|
[`offene-punkte.md`](offene-punkte.md).
|
||||||
|
|
||||||
|
## Neu: der Dienst läuft zentral
|
||||||
|
|
||||||
|
Seit 2026-09-17 läuft ein Teil des Toolkits nicht mehr auf Abdus Rechner,
|
||||||
|
sondern in einem LXC auf dem Proxmox-Host. Der Schnitt und seine Begründung
|
||||||
|
stehen in [ADR-0008](decisions/ADR-0008-geteilter-betrieb.md); die Kurzfassung
|
||||||
|
ist: alles, was nur Internet braucht, gehört dorthin, wo durchgehend Strom
|
||||||
|
ist.
|
||||||
|
|
||||||
|
| | Hub (LXC 141, `192.168.2.15`) | Ingest (Abdus PC) |
|
||||||
|
|---|---|---|
|
||||||
|
| Befehl | `abdulive hub` | `abdulive ingest` |
|
||||||
|
| Chat, Zuschauerzahlen | ja | ja |
|
||||||
|
| Discord-Knopf, Go-Live-Ping | **ja** | nein |
|
||||||
|
| Overlay für OBS | **ja** | nein |
|
||||||
|
| OBS, `!clip`, Rendern | nein | **ja** |
|
||||||
|
|
||||||
|
Erreichbar unter `https://abdu.dimension47.de`, hinter dem vorhandenen Nginx
|
||||||
|
Proxy Manager (Proxy-Eintrag 42, eigenes Let's-Encrypt-Zertifikat, Pufferung
|
||||||
|
abgeschaltet - ohne das sammelt nginx die Overlay-Ereignisse und liefert sie
|
||||||
|
gebündelt aus). Ausgerollt wird mit `scripts/deploy-hub.sh`, gestartet von
|
||||||
|
systemd (`abdulive-hub`). Der Container hat keinen Zugang zum Repo - das
|
||||||
|
Skript schiebt den Arbeitsstand als Archiv hinüber.
|
||||||
|
|
||||||
|
Der Discord-Teil der Zugangsdaten ist übertragen, der Bot hängt am Hub und
|
||||||
|
meldet beim Start "Rollen-Knöpfe sind scharf". Die Twitch-Broadcaster-App ist
|
||||||
|
hinterlegt und geprüft (Client-ID registriert, Secret passt - Twitch stellt
|
||||||
|
darauf ein App-Token aus). **Was fehlt, ist der Klick von Abdu**, siehe unten.
|
||||||
|
|
||||||
|
**Das kostet ADR-0005 seine Hauptbegründung.** "Bindet nur auf 127.0.0.1" gilt
|
||||||
|
für den Hub nicht mehr. An seine Stelle treten ein Passwort für alles, was ein
|
||||||
|
Mensch bedient, und ein Schlüssel in der Adresse für die Browser-Sources.
|
||||||
|
|
||||||
|
## Der Go-Live-Ping geht raus
|
||||||
|
|
||||||
|
War in der letzten Übergabe noch Vorarbeit, ist jetzt gebaut
|
||||||
|
(`apps/broadcaster`). Ablauf: neuer Stream erkannt, eine Minute warten, Titel
|
||||||
|
und Kategorie frisch holen, Webhook posten, Rolle `Live-Ping` anpingen.
|
||||||
|
|
||||||
|
Der Ping funktioniert nur mit `allowed_mentions` im Nachrichtenkörper. `<@&ID>`
|
||||||
|
allein färbt die Erwähnung ein und benachrichtigt niemanden - das ist der
|
||||||
|
Fehler, den man erst nach drei leeren Streams bemerkt, und dagegen steht ein
|
||||||
|
eigener Test.
|
||||||
|
|
||||||
|
Gegen den doppelten Ping stehen drei Dinge, denn wer zweimal in zehn Minuten
|
||||||
|
angepingt wird, nimmt sich die Rolle wieder ab: die Tabelle `posts` mit einem
|
||||||
|
eindeutigen Index je Stream, eine Abklingzeit von sechs Stunden, und der
|
||||||
|
Umstand, dass der Fortsetzungszweig im Polling gar nicht erst auslöst.
|
||||||
|
|
||||||
|
Der Hub steht auf `mode: draft` - der Post geht vollständig, aber in
|
||||||
|
`#technik-log` und ohne Ping. Ein Probelauf ist dort gelandet, der Weg zum
|
||||||
|
Webhook ist damit nachgewiesen. Zum Scharfschalten: `mode: live` in
|
||||||
|
`/var/lib/abdulive/config/config.yaml`.
|
||||||
|
|
||||||
|
**Ausgelöst hat er noch nie**, und das liegt nicht am Broadcaster, sondern an
|
||||||
|
Twitch: die Erkennung des Stream-Starts hängt am Helix-Polling, und das
|
||||||
|
braucht ein Broadcaster-Token. Solange das fehlt, wartet der Ping auf ein
|
||||||
|
Ereignis, das nie kommt.
|
||||||
|
|
||||||
|
## Das Overlay steht
|
||||||
|
|
||||||
|
Fünf Browser-Sources unter `apps/overlay-web/public`, ausgeliefert vom
|
||||||
|
Overlay-Server: Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein
|
||||||
|
Build, kein Framework - OBS lädt die Dateien direkt.
|
||||||
|
|
||||||
|
Die Ereignisse kommen über Server-Sent Events statt WebSocket. Warum, und was
|
||||||
|
gegen die tote Browser-Source nach einem Neustart getan ist, steht in
|
||||||
|
[`apps/overlay-server/README.md`](../apps/overlay-server/README.md).
|
||||||
|
|
||||||
|
Abos, Raids und Bits kommen über IRC-USERNOTICE herein, das wird jetzt
|
||||||
|
ausgewertet. **Follows fehlen** - die gibt es nur über EventSub.
|
||||||
|
|
||||||
|
Zum Ansehen ohne Stream: auf der Übersichtsseite gibt es unter
|
||||||
|
**Ausprobieren** für jede Einblendung, jede Chat-Variante und jeden Effekt
|
||||||
|
einen Knopf. Die Ereignisse gehen nur an die Browser-Sources. Dasselbe im
|
||||||
|
Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten".
|
||||||
|
|
||||||
## Der Discord-Server steht
|
## Der Discord-Server steht
|
||||||
|
|
||||||
Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der
|
Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der
|
||||||
@@ -44,10 +120,14 @@ die Kurzfassung ist, dass bei 5 bis 30 Zuschauern fünf sichtbare Kanäle besser
|
|||||||
sind als zwanzig leere.
|
sind als zwanzig leere.
|
||||||
|
|
||||||
Ein Teil davon läuft dauerhaft: der Knopf unter dem Willkommenstext, mit dem
|
Ein Teil davon läuft dauerhaft: der Knopf unter dem Willkommenstext, mit dem
|
||||||
man sich die Rolle `Live-Ping` selbst gibt. Er hängt als Gateway-Verbindung im
|
man sich die Rolle `Live-Ping` selbst gibt. Er hängt als Gateway-Verbindung mit
|
||||||
Ingest-Daemon mit drin und meldet sich als `discordConnected` auf der
|
im Prozess und meldet sich als `discordConnected` auf der Statusseite. Seit dem
|
||||||
Statusseite. **Läuft der Daemon nicht, tut der Knopf nichts** - der Klick
|
Umzug in den Hub läuft er durch, auch wenn Abdus Rechner aus ist.
|
||||||
läuft dann in Discords Zeitüberschreitung.
|
|
||||||
|
Dazugekommen ist ein vierter Webhook: `technikWebhook` in `#technik-log`. Dort
|
||||||
|
landet der Go-Live-Post im Entwurfsmodus - dieselbe Nachricht wie im Betrieb,
|
||||||
|
nur ohne Ping und nur für Mods sichtbar. Wer den Server vor dieser Fassung
|
||||||
|
eingerichtet hat, muss `abdulive discord:setup` einmal erneut laufen lassen.
|
||||||
|
|
||||||
## Was jemand wissen muss, bevor er etwas anfasst
|
## Was jemand wissen muss, bevor er etwas anfasst
|
||||||
|
|
||||||
@@ -58,9 +138,10 @@ keine Parameter-Properties im Konstruktor, keine `namespace`.
|
|||||||
`erasableSyntaxOnly` in der `tsconfig.json` erzwingt das und hat schon einen
|
`erasableSyntaxOnly` in der `tsconfig.json` erzwingt das und hat schon einen
|
||||||
echten Fehler gefangen.
|
echten Fehler gefangen.
|
||||||
|
|
||||||
**Alles läuft in einem Prozess.** Schwere Arbeit (ffmpeg, whisper) läuft als
|
**Alles läuft in einem Prozess** - aber es gibt jetzt zwei davon, Hub und
|
||||||
Kindprozess und darf abstürzen. Der Ingest darf nie sterben. Was er nicht
|
Ingest (siehe oben). Schwere Arbeit (ffmpeg, whisper) läuft als Kindprozess und
|
||||||
mitschreibt, ist für immer weg.
|
darf abstürzen. Der Chat-Prozess darf nie sterben. Was er nicht mitschreibt,
|
||||||
|
ist für immer weg.
|
||||||
|
|
||||||
**Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie
|
**Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie
|
||||||
liegen verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr. Die
|
liegen verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr. Die
|
||||||
@@ -97,9 +178,16 @@ Zum Entwickeln reicht ein Checkout und Node 24 oder neuer:
|
|||||||
```powershell
|
```powershell
|
||||||
node apps/cli/main.ts check-env
|
node apps/cli/main.ts check-env
|
||||||
node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
|
node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
|
||||||
|
node apps/cli/main.ts overlay --demo # Overlay ohne Stream ansehen
|
||||||
node scripts/build-package.ts --version 0.5.5 # baut dist/
|
node scripts/build-package.ts --version 0.5.5 # baut dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Den Hub ausrollen (braucht SSH auf den Proxmox-Host):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/deploy-hub.sh
|
||||||
|
```
|
||||||
|
|
||||||
Typprüfung, das Einzige wofür npm gebraucht wird:
|
Typprüfung, das Einzige wofür npm gebraucht wird:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -214,12 +302,25 @@ oben.
|
|||||||
|
|
||||||
## Die drei Dinge, die Abdu tun muss
|
## Die drei Dinge, die Abdu tun muss
|
||||||
|
|
||||||
1. **Broadcaster-Konto verbinden.** Die beiden Twitch-Apps
|
1. **Broadcaster-Konto verbinden.** Das ist jetzt der Flaschenhals: ohne
|
||||||
(dev.twitch.tv/console/apps, Redirect-URI `http://localhost:4792/callback`)
|
Broadcaster-Token gibt es keine Zuschauerzahlen, keine Stream-Sitzungen und
|
||||||
kann auch jemand anders anlegen, sie hängen nicht am Kanal. Was nur Abdu
|
damit auch keinen Go-Live-Ping.
|
||||||
kann: auf der Einrichtungsseite eingeloggt als er selbst auf „Mit Twitch
|
|
||||||
verbinden" klicken. Dafür muss die Anwendung auf seinem Rechner laufen,
|
Die beiden Twitch-Apps (dev.twitch.tv/console/apps) kann auch jemand anders
|
||||||
die Seite hört nur auf `127.0.0.1`.
|
anlegen, sie hängen nicht am Kanal. Seit dem Umzug in den Hub brauchen sie
|
||||||
|
als Redirect-URI **`https://abdu.dimension47.de/callback`**; die alte
|
||||||
|
`http://localhost:4792/callback` bleibt daneben stehen, wenn auf dem
|
||||||
|
Stream-PC weiter eingerichtet werden soll. Twitch vergleicht zeichengenau.
|
||||||
|
|
||||||
|
Die App ist inzwischen hinterlegt. Was nur Abdu kann: auf
|
||||||
|
`https://abdu.dimension47.de/setup` eingeloggt als er selbst auf „Mit
|
||||||
|
Twitch verbinden" klicken. Der Vorteil gegenüber vorher: das geht von jedem
|
||||||
|
Gerät, der Stream-PC muss nicht laufen.
|
||||||
|
|
||||||
|
Ob die Redirect-URI in der App richtig eingetragen ist, zeigt sich erst bei
|
||||||
|
diesem Klick. Twitch prüft sie erst nach dem Login und gibt vorher auch bei
|
||||||
|
einer falschen URI ein freundliches 302 zurück - von außen ist das nicht
|
||||||
|
nachweisbar.
|
||||||
2. **OBS einrichten**: obs-websocket an (Port 4455), Wiedergabepuffer 90 s,
|
2. **OBS einrichten**: obs-websocket an (Port 4455), Wiedergabepuffer 90 s,
|
||||||
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
|
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
|
||||||
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
|
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
|
||||||
@@ -248,10 +349,14 @@ in der ganzen Kette, und alles Weitere hängt daran. Seit es `abdulive sources`
|
|||||||
gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
|
gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
|
||||||
malt. Das war vorher nicht zu erraten.
|
malt. Das war vorher nicht zu erraten.
|
||||||
|
|
||||||
**Discord braucht jetzt nichts mehr.** Der Server steht, die Webhooks liegen
|
**Den Go-Live-Ping scharf schalten.** Er steht auf `draft`. Einmal
|
||||||
bereit. Wer als Nächstes `apps/broadcaster` oder `apps/recap` baut, findet
|
`abdulive golive:test --echt` laufen lassen, prüfen ob das Handy klingelt, dann
|
||||||
alles vor und muss nur noch posten. Das wäre auch ein guter Nebenweg, wenn der
|
`mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten
|
||||||
Stream-PC gerade nicht verfügbar ist, denn anders als S2 hängt daran nichts.
|
Wirkung.
|
||||||
|
|
||||||
|
**`apps/recap` bauen.** Der Webhook liegt bereit, der Hub hat die Daten, und
|
||||||
|
anders als S2 hängt daran nichts. Guter Nebenweg, wenn der Stream-PC gerade
|
||||||
|
nicht verfügbar ist.
|
||||||
|
|
||||||
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
|
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
|
||||||
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
|
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
|
||||||
@@ -277,11 +382,20 @@ konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
|
|||||||
Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es
|
Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es
|
||||||
bekommt, nicht mehr.
|
bekommt, nicht mehr.
|
||||||
|
|
||||||
**Der Discord-Knopf hängt am Daemon.** Läuft der Ingest nicht, passiert beim
|
**Der Discord-Knopf hängt am Daemon** - das war der Punkt aus der letzten
|
||||||
Klick nichts, und die klickende Person sieht nur „Interaktion fehlgeschlagen".
|
Übergabe und ist mit dem Hub erledigt. Dafür gibt es jetzt einen neuen: der
|
||||||
Das ist vertretbar, solange der Daemon ohnehin durchläuft, aber es ist eine
|
Bot darf nur an **einer** Stelle laufen. Steht auf dem Stream-PC ein
|
||||||
stille Abhängigkeit zwischen zwei Dingen, die sonst nichts miteinander zu tun
|
Discord-Bot-Token im Speicher und läuft dort `ingest`, antworten zwei Prozesse
|
||||||
haben.
|
auf denselben Klick. Auf dem Stream-PC bleibt das Discord-Token leer.
|
||||||
|
|
||||||
|
**Der Overlay-Schlüssel steht in der Adresse.** Damit steht er in jedem
|
||||||
|
OBS-Profil und auf jedem Screenshot der Einstellungen. Neu würfeln geht auf
|
||||||
|
der Übersichtsseite, aber danach müssen alle fünf Adressen in OBS neu
|
||||||
|
eingetragen werden.
|
||||||
|
|
||||||
|
**Das Overlay hat nie unter Last gelaufen.** Geprüft ist es gegen den
|
||||||
|
Vorführmodus und im Browser. Wie es sich bei 200 Nachrichten pro Minute
|
||||||
|
verhält, und was der Emote-Regen beim Encoder kostet, ist offen.
|
||||||
|
|
||||||
**Der Discord-Bot ist nie unter Last gelaufen.** Getestet sind die Bausteine
|
**Der Discord-Bot ist nie unter Last gelaufen.** Getestet sind die Bausteine
|
||||||
und ein Klickweg gegen einen nachgebauten Server; am echten Server ist die
|
und ein Klickweg gegen einen nachgebauten Server; am echten Server ist die
|
||||||
|
|||||||
@@ -45,6 +45,37 @@ export type Config = {
|
|||||||
ffmpeg: { encoder: string; preset: string; crf: number; niceness: number };
|
ffmpeg: { encoder: string; preset: string; crf: number; niceness: number };
|
||||||
whisper: { model: string; language: string; threads: number };
|
whisper: { model: string; language: string; threads: number };
|
||||||
};
|
};
|
||||||
|
broadcaster: {
|
||||||
|
enabled: boolean;
|
||||||
|
/** `live` postet in die Ankuendigungen, `draft` in den internen Kanal. */
|
||||||
|
mode: 'live' | 'draft';
|
||||||
|
delaySecondsAfterOnline: number;
|
||||||
|
cooldownHours: number;
|
||||||
|
/** Rollenname aus dem Discord-Bauplan. Leer heisst: Post ohne Ping. */
|
||||||
|
pingRole: string;
|
||||||
|
obsScreenshot: boolean;
|
||||||
|
};
|
||||||
|
/** Kanalpunkt-Belohnungen und was sie ausloesen. Siehe apps/hub/src/rewards.ts. */
|
||||||
|
rewards: {
|
||||||
|
enabled: boolean;
|
||||||
|
belohnungen: Array<{
|
||||||
|
titel?: string; id?: string;
|
||||||
|
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
|
||||||
|
freigabe?: boolean; maxZeichen?: number;
|
||||||
|
}>;
|
||||||
|
tts: {
|
||||||
|
freigabe: boolean; maxZeichen: number;
|
||||||
|
blockliste: string[]; proPersonProStunde: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
overlay: { port: number; enabled: boolean };
|
||||||
|
/**
|
||||||
|
* Oeffentliche Adresse des Dienstes, ohne Schraegstrich am Ende. Nur noetig,
|
||||||
|
* wenn er nicht auf demselben Rechner wie der Browser laeuft - dann haengt
|
||||||
|
* daran der OAuth-Rueckruf von Twitch, der exakt zur eingetragenen
|
||||||
|
* Redirect-URI passen muss.
|
||||||
|
*/
|
||||||
|
publicUrl: string;
|
||||||
ingestServer: { port: number };
|
ingestServer: { port: number };
|
||||||
dashboard: { port: number };
|
dashboard: { port: number };
|
||||||
raw: YamlMap;
|
raw: YamlMap;
|
||||||
@@ -81,6 +112,40 @@ const DEFAULTS = {
|
|||||||
ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 },
|
ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 },
|
||||||
whisper: { model: 'ggml-small.bin', language: 'de', threads: 4 },
|
whisper: { model: 'ggml-small.bin', language: 'de', threads: 4 },
|
||||||
},
|
},
|
||||||
|
broadcaster: {
|
||||||
|
enabled: true,
|
||||||
|
// Vorgabe ist `live`: der Sinn der Sache ist der Ping. Wer erst zusehen
|
||||||
|
// will, stellt `draft` ein - dann landet dieselbe Nachricht ohne Ping im
|
||||||
|
// internen Kanal. Zum Ausprobieren gibt es `abdulive golive:test`.
|
||||||
|
mode: 'live' as const,
|
||||||
|
// Twitch setzt Titel und Kategorie erst kurz nach dem Start. Wer sofort
|
||||||
|
// postet, kuendigt regelmaessig den Titel des letzten Streams an.
|
||||||
|
delaySecondsAfterOnline: 60,
|
||||||
|
cooldownHours: 6,
|
||||||
|
pingRole: 'Live-Ping',
|
||||||
|
obsScreenshot: true,
|
||||||
|
},
|
||||||
|
rewards: {
|
||||||
|
// Aus, bis jemand bewusst Belohnungen eintraegt. Eine Aktion, die
|
||||||
|
// niemand erwartet, ist im Stream schlimmer als gar keine.
|
||||||
|
enabled: false,
|
||||||
|
belohnungen: [] as Array<{
|
||||||
|
titel?: string; id?: string;
|
||||||
|
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
|
||||||
|
freigabe?: boolean; maxZeichen?: number;
|
||||||
|
}>,
|
||||||
|
tts: {
|
||||||
|
// Vorgabe ist die Freigabe durch Abdu. Kanalpunkte sammeln sich beim
|
||||||
|
// Zugucken von allein an und sind damit kaum eine Huerde; ohne diesen
|
||||||
|
// Schalter hat man eine Vorlesefunktion fuer Fremde im eigenen Stream.
|
||||||
|
freigabe: true,
|
||||||
|
maxZeichen: 200,
|
||||||
|
blockliste: [] as string[],
|
||||||
|
proPersonProStunde: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overlay: { port: 4791, enabled: true },
|
||||||
|
publicUrl: '',
|
||||||
ingestServer: { port: 4790 },
|
ingestServer: { port: 4790 },
|
||||||
dashboard: { port: 4792 },
|
dashboard: { port: 4792 },
|
||||||
};
|
};
|
||||||
@@ -105,7 +170,12 @@ export function loadConfig(): Config {
|
|||||||
if (fs.existsSync(file)) {
|
if (fs.existsSync(file)) {
|
||||||
raw = parseYaml(fs.readFileSync(file, 'utf8'));
|
raw = parseYaml(fs.readFileSync(file, 'utf8'));
|
||||||
}
|
}
|
||||||
cached = { ...merge(DEFAULTS, raw), raw } as Config;
|
const merged = merge(DEFAULTS, raw) as Omit<Config, 'raw'>;
|
||||||
|
// Die Umgebung gewinnt: im Container steht die oeffentliche Adresse in der
|
||||||
|
// Unit-Datei, nicht in einer YAML, die mitversioniert wird.
|
||||||
|
if (process.env.PUBLIC_URL) merged.publicUrl = process.env.PUBLIC_URL;
|
||||||
|
merged.publicUrl = merged.publicUrl.replace(/\/+$/, '');
|
||||||
|
cached = { ...merged, raw } as Config;
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -21,7 +21,7 @@ function msg(over: Partial<ChatMessage> = {}): ChatMessage {
|
|||||||
id: `m-${Math.random().toString(36).slice(2)}`,
|
id: `m-${Math.random().toString(36).slice(2)}`,
|
||||||
ts: '2026-09-09T20:00:00.000Z',
|
ts: '2026-09-09T20:00:00.000Z',
|
||||||
userId: '42', login: 'maxi_2k', displayName: 'maxi_2k',
|
userId: '42', login: 'maxi_2k', displayName: 'maxi_2k',
|
||||||
text: 'moin', emotes: [], isFirst: false, isReturning: false,
|
text: 'moin', emotes: [], gifs: [], isFirst: false, isReturning: false,
|
||||||
isCommand: false, isMod: false, isSub: false, isBroadcaster: false,
|
isCommand: false, isMod: false, isSub: false, isBroadcaster: false,
|
||||||
bits: 0, color: null, ...over,
|
bits: 0, color: null, ...over,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -162,7 +162,15 @@ export type RestFull = Rest & {
|
|||||||
export type ApiRole = { id: string; name: string; permissions: string; position: number; color: number; hoist: boolean; mentionable: boolean; managed: boolean };
|
export type ApiRole = { id: string; name: string; permissions: string; position: number; color: number; hoist: boolean; mentionable: boolean; managed: boolean };
|
||||||
export type ApiOverwrite = { id: string; type: 0 | 1; allow: string; deny: string };
|
export type ApiOverwrite = { id: string; type: 0 | 1; allow: string; deny: string };
|
||||||
export type ApiChannel = { id: string; name: string; type: number; position: number; parent_id: string | null; topic?: string | null; permission_overwrites?: ApiOverwrite[] };
|
export type ApiChannel = { id: string; name: string; type: number; position: number; parent_id: string | null; topic?: string | null; permission_overwrites?: ApiOverwrite[] };
|
||||||
export type ApiGuild = { id: string; name: string; owner_id: string; roles: ApiRole[]; features: string[] };
|
export type ApiGuild = {
|
||||||
|
id: string; name: string; owner_id: string; roles: ApiRole[]; features: string[];
|
||||||
|
// Die Felder aus GUILD_SETTINGS, damit sich vergleichen laesst, statt bei
|
||||||
|
// jedem Lauf blind zu schreiben.
|
||||||
|
verification_level?: number;
|
||||||
|
explicit_content_filter?: number;
|
||||||
|
default_message_notifications?: number;
|
||||||
|
system_channel_flags?: number;
|
||||||
|
};
|
||||||
export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string };
|
export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string };
|
||||||
export type ApiUser = { id: string; username: string; bot?: boolean };
|
export type ApiUser = { id: string; username: string; bot?: boolean };
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,13 @@ class FakeDiscord implements Rest {
|
|||||||
|
|
||||||
async patch<T>(path: string, body: unknown): Promise<T> {
|
async patch<T>(path: string, body: unknown): Promise<T> {
|
||||||
this.calls.push(`PATCH ${path}`);
|
this.calls.push(`PATCH ${path}`);
|
||||||
if (path === `/guilds/${GUILD}`) return {} as T;
|
if (path === `/guilds/${GUILD}`) {
|
||||||
|
// Wie der echte Server: gesetzte Einstellungen stehen danach auch drin.
|
||||||
|
// Ohne das liesse sich nicht pruefen, ob ein zweiter Lauf sie in Ruhe
|
||||||
|
// laesst.
|
||||||
|
Object.assign(this.guild, body as Record<string, unknown>);
|
||||||
|
return structuredClone(this.guild) as T;
|
||||||
|
}
|
||||||
if (path === `/guilds/${GUILD}/roles`) { // Rangfolge
|
if (path === `/guilds/${GUILD}/roles`) { // Rangfolge
|
||||||
for (const e of body as Array<{ id: string; position: number }>) {
|
for (const e of body as Array<{ id: string; position: number }>) {
|
||||||
const r = this.guild.roles.find((x) => x.id === e.id);
|
const r = this.guild.roles.find((x) => x.id === e.id);
|
||||||
@@ -187,7 +193,7 @@ test('der erste Lauf legt Rollen, Kategorien, Kanaele und Webhooks an', async ()
|
|||||||
for (const c of CHANNELS) {
|
for (const c of CHANNELS) {
|
||||||
assert.ok(api.channels.find((x) => x.name === c.name), `Kanal ${c.name} fehlt`);
|
assert.ok(api.channels.find((x) => x.name === c.name), `Kanal ${c.name} fehlt`);
|
||||||
}
|
}
|
||||||
assert.equal(res.webhooks, 3);
|
assert.equal(res.webhooks, 4);
|
||||||
assert.equal(api.automod.length, 2);
|
assert.equal(api.automod.length, 2);
|
||||||
assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0);
|
assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0);
|
||||||
});
|
});
|
||||||
@@ -259,7 +265,7 @@ test('der zweite Lauf aendert nichts', async () => {
|
|||||||
`unerwartete Schreibzugriffe: ${schreibend.join(', ')}`);
|
`unerwartete Schreibzugriffe: ${schreibend.join(', ')}`);
|
||||||
|
|
||||||
assert.equal(counts(res.steps)['erstellt'] ?? 0, 0);
|
assert.equal(counts(res.steps)['erstellt'] ?? 0, 0);
|
||||||
assert.equal(res.webhooks, 3, 'die bestehenden Webhook-URLs muessen wiedergefunden werden');
|
assert.equal(res.webhooks, 4, 'die bestehenden Webhook-URLs muessen wiedergefunden werden');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('die Begruessung wird nicht ein zweites Mal gepostet', async () => {
|
test('die Begruessung wird nicht ein zweites Mal gepostet', async () => {
|
||||||
@@ -320,7 +326,7 @@ test('ein von Hand angelegter Webhook wird gemeldet, nicht ueberschrieben', asyn
|
|||||||
const step = res.steps.find((s) => s.kind === 'Webhook' && s.name.startsWith('Go-Live'));
|
const step = res.steps.find((s) => s.kind === 'Webhook' && s.name.startsWith('Go-Live'));
|
||||||
assert.equal(step?.change, 'uebersprungen');
|
assert.equal(step?.change, 'uebersprungen');
|
||||||
assert.match(step?.note ?? '', /nicht auslesbar/);
|
assert.match(step?.note ?? '', /nicht auslesbar/);
|
||||||
assert.equal(res.webhooks, 2);
|
assert.equal(res.webhooks, 3, 'die drei uebrigen muessen trotzdem durchkommen');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('eine von Discord verwaltete Rolle wird in Ruhe gelassen', async () => {
|
test('eine von Discord verwaltete Rolle wird in Ruhe gelassen', async () => {
|
||||||
@@ -375,7 +381,7 @@ test('Bot-Rolle ganz unten: Rangfolge wird gemeldet statt zu scheitern', async (
|
|||||||
assert.match(step?.note ?? '', /nach oben ziehen/);
|
assert.match(step?.note ?? '', /nach oben ziehen/);
|
||||||
// Alles andere muss trotzdem stehen.
|
// Alles andere muss trotzdem stehen.
|
||||||
assert.equal(api.channels.length, 3 + CHANNELS.length);
|
assert.equal(api.channels.length, 3 + CHANNELS.length);
|
||||||
assert.equal(res.webhooks, 3);
|
assert.equal(res.webhooks, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Umbenennen
|
// ---------------------------------------------------------------- Umbenennen
|
||||||
@@ -421,10 +427,31 @@ test('haengt Discord beim Umbenennen im Rate Limit, laeuft der Rest weiter', asy
|
|||||||
assert.equal(step?.change, 'uebersprungen');
|
assert.equal(step?.change, 'uebersprungen');
|
||||||
assert.match(step?.note ?? '', /530 s/);
|
assert.match(step?.note ?? '', /530 s/);
|
||||||
// Die uebrigen Kanaele und die Webhooks muessen trotzdem entstehen.
|
// Die uebrigen Kanaele und die Webhooks muessen trotzdem entstehen.
|
||||||
assert.equal(res.webhooks, 3);
|
assert.equal(res.webhooks, 4);
|
||||||
assert.equal(api.automod.length, 2);
|
assert.equal(api.automod.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('der zweite Lauf schreibt nichts mehr - auch nicht Einstellungen und Rangfolge', async () => {
|
||||||
|
// Der Bauplan verspricht, dass ein zweiter Lauf nichts aendert. Zwei
|
||||||
|
// Schritte haben das lange gebrochen, weil sie ohne Vergleich geschrieben
|
||||||
|
// haben: die Servereinstellungen und die Rangfolge der Rollen. Folgenlos
|
||||||
|
// war das nicht - jeder Lauf hinterliess zwei Eintraege im Audit-Log und
|
||||||
|
// liess den Bericht so aussehen, als sei etwas passiert.
|
||||||
|
const api = new FakeDiscord();
|
||||||
|
await run(api);
|
||||||
|
|
||||||
|
api.calls.length = 0;
|
||||||
|
const res = await run(api);
|
||||||
|
|
||||||
|
assert.equal(api.calls.some((c) => c === `PATCH /guilds/${GUILD}`), false,
|
||||||
|
'Servereinstellungen standen schon');
|
||||||
|
assert.equal(api.calls.some((c) => c === `PATCH /guilds/${GUILD}/roles`), false,
|
||||||
|
'die Rangfolge stand schon');
|
||||||
|
assert.equal(res.steps.find((s) => s.kind === 'Server')?.change, 'unveraendert');
|
||||||
|
assert.equal(res.steps.find((s) => s.name === 'Rangfolge')?.change, 'unveraendert');
|
||||||
|
assert.equal(res.steps.some((s) => s.change === 'erstellt'), false, 'nichts neu angelegt');
|
||||||
|
});
|
||||||
|
|
||||||
test('ein geaenderter Blocktext wird auf eine bestehende Regel nachgezogen', async () => {
|
test('ein geaenderter Blocktext wird auf eine bestehende Regel nachgezogen', async () => {
|
||||||
const api = new FakeDiscord();
|
const api = new FakeDiscord();
|
||||||
await run(api);
|
await run(api);
|
||||||
|
|||||||
@@ -193,9 +193,11 @@ async function applyRoleOrder(
|
|||||||
if (dry || wanted.length === 0) return;
|
if (dry || wanted.length === 0) return;
|
||||||
|
|
||||||
let botTop: number;
|
let botTop: number;
|
||||||
|
let istPosition = new Map<string, number>();
|
||||||
try {
|
try {
|
||||||
const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`);
|
const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`);
|
||||||
const fresh = await api.get<ApiRole[]>(`/guilds/${guild.id}/roles`);
|
const fresh = await api.get<ApiRole[]>(`/guilds/${guild.id}/roles`);
|
||||||
|
istPosition = new Map(fresh.map((r) => [r.id, r.position]));
|
||||||
botTop = fresh
|
botTop = fresh
|
||||||
.filter((r) => member.roles.includes(r.id))
|
.filter((r) => member.roles.includes(r.id))
|
||||||
.reduce((max, r) => Math.max(max, r.position), 0);
|
.reduce((max, r) => Math.max(max, r.position), 0);
|
||||||
@@ -211,6 +213,15 @@ async function applyRoleOrder(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stimmt die Rangfolge schon, nicht schreiben. Ein PATCH mit denselben
|
||||||
|
// Werten ist zwar folgenlos, kostet aber bei jedem Lauf einen Aufruf und
|
||||||
|
// einen Eintrag im Audit-Log - und laesst den Lauf so aussehen, als haette
|
||||||
|
// er etwas geaendert.
|
||||||
|
if (wanted.every((id, i) => istPosition.get(id) === fit.positions[i])) {
|
||||||
|
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'unveraendert' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.patch(`/guilds/${guild.id}/roles`,
|
await api.patch(`/guilds/${guild.id}/roles`,
|
||||||
wanted.map((id, i) => ({ id, position: fit.positions[i] })), 'Rangfolge aus dem Bauplan');
|
wanted.map((id, i) => ({ id, position: fit.positions[i] })), 'Rangfolge aus dem Bauplan');
|
||||||
@@ -480,9 +491,17 @@ export async function applyPlan(
|
|||||||
const me = await api.get<ApiUser>('/users/@me');
|
const me = await api.get<ApiUser>('/users/@me');
|
||||||
log.info(`Server "${guild.name}"${dry ? ' (Vorschau, es wird nichts geaendert)' : ''}`);
|
log.info(`Server "${guild.name}"${dry ? ' (Vorschau, es wird nichts geaendert)' : ''}`);
|
||||||
|
|
||||||
// 1. Servereinstellungen
|
// 1. Servereinstellungen. Vergleichen statt blind schreiben: sonst meldet
|
||||||
|
// jeder Lauf eine Aenderung, auch wenn es keine gab, und die Vorschau sagt
|
||||||
|
// etwas anderes als der echte Lauf.
|
||||||
|
const abweichend = (Object.entries(GUILD_SETTINGS) as Array<[keyof ApiGuild, number]>)
|
||||||
|
.some(([k, v]) => guild[k] !== v);
|
||||||
|
if (abweichend) {
|
||||||
if (!dry) await api.patch(`/guilds/${guild.id}`, GUILD_SETTINGS, 'Servereinstellungen aus dem Bauplan');
|
if (!dry) await api.patch(`/guilds/${guild.id}`, GUILD_SETTINGS, 'Servereinstellungen aus dem Bauplan');
|
||||||
steps.push({ kind: 'Server', name: guild.name, change: dry ? 'unveraendert' : 'angepasst' });
|
steps.push({ kind: 'Server', name: guild.name, change: dry ? 'erstellt' : 'angepasst' });
|
||||||
|
} else {
|
||||||
|
steps.push({ kind: 'Server', name: guild.name, change: 'unveraendert' });
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Rollen
|
// 2. Rollen
|
||||||
const roleIds = await applyRoles(api, guild, me.id, steps, dry);
|
const roleIds = await applyRoles(api, guild, me.id, steps, dry);
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -30,6 +30,11 @@ export type ChatMessage = {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
text: string;
|
text: string;
|
||||||
emotes: Array<{ id: string; name: string; count: number }>;
|
emotes: Array<{ id: string; name: string; count: number }>;
|
||||||
|
/**
|
||||||
|
* GIFs aus Twitchs Auswahlfenster. `from`/`to` zeigen auf die Zeichen im
|
||||||
|
* Text, an denen der Platzhalter steht - siehe `parseGifs` in #twitch/irc.ts.
|
||||||
|
*/
|
||||||
|
gifs: Array<{ id: string; url: string; from: number; to: number }>;
|
||||||
isFirst: boolean; // first-msg Tag
|
isFirst: boolean; // first-msg Tag
|
||||||
isReturning: boolean; // returning-chatter Tag
|
isReturning: boolean; // returning-chatter Tag
|
||||||
isCommand: boolean;
|
isCommand: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// EventSub liefert Ereignisse, die sich nicht wiederholen lassen. Wer eine
|
||||||
|
// Nachricht falsch deutet, verpasst eine Einloesung - und der Zuschauer hat
|
||||||
|
// die Punkte bezahlt. Deshalb ist das Deuten hier von der Verbindung getrennt
|
||||||
|
// und einzeln geprueft.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { deuten, type EventSubNachricht } from './eventsub.ts';
|
||||||
|
|
||||||
|
const n = (o: unknown): EventSubNachricht => o as EventSubNachricht;
|
||||||
|
|
||||||
|
test('der Willkommensgruss traegt Sitzung und Zeitlimit', () => {
|
||||||
|
const f = deuten(n({
|
||||||
|
metadata: { message_id: '1', message_type: 'session_welcome' },
|
||||||
|
payload: { session: { id: 'sess-1', keepalive_timeout_seconds: 10, reconnect_url: null } },
|
||||||
|
}));
|
||||||
|
assert.equal(f.art, 'sitzung');
|
||||||
|
assert.equal((f as { sessionId: string }).sessionId, 'sess-1');
|
||||||
|
// 10 s Zeitlimit plus Puffer: der Wachhund soll eine tote Verbindung finden,
|
||||||
|
// nicht eine langsame abwuergen.
|
||||||
|
assert.equal((f as { keepaliveMs: number }).keepaliveMs, 15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ohne Angabe gilt Twitchs dokumentierte Vorgabe', () => {
|
||||||
|
const f = deuten(n({
|
||||||
|
metadata: { message_type: 'session_welcome' },
|
||||||
|
payload: { session: { id: 's', keepalive_timeout_seconds: null, reconnect_url: null } },
|
||||||
|
}));
|
||||||
|
assert.equal((f as { keepaliveMs: number }).keepaliveMs, 15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine Benachrichtigung traegt Typ und Ereignis', () => {
|
||||||
|
const f = deuten(n({
|
||||||
|
metadata: {
|
||||||
|
message_type: 'notification',
|
||||||
|
subscription_type: 'channel.channel_points_custom_reward_redemption.add',
|
||||||
|
},
|
||||||
|
payload: { event: { id: 'red-1', user_name: 'Kevin' } },
|
||||||
|
}));
|
||||||
|
assert.equal(f.art, 'ereignis');
|
||||||
|
assert.match((f as { typ: string }).typ, /reward_redemption\.add$/);
|
||||||
|
assert.equal(((f as { event: Record<string, unknown> }).event)['user_name'], 'Kevin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fehlt der Typ in den Kopfdaten, wird er aus dem Abonnement genommen', () => {
|
||||||
|
const f = deuten(n({
|
||||||
|
metadata: { message_type: 'notification' },
|
||||||
|
payload: { subscription: { id: 's', type: 'channel.follow', status: 'enabled' },
|
||||||
|
event: { user_name: 'x' } },
|
||||||
|
}));
|
||||||
|
assert.equal((f as { typ: string }).typ, 'channel.follow');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine Benachrichtigung ohne Ereignis wird nicht durchgereicht', () => {
|
||||||
|
const f = deuten(n({
|
||||||
|
metadata: { message_type: 'notification', subscription_type: 'channel.follow' },
|
||||||
|
payload: {},
|
||||||
|
}));
|
||||||
|
assert.equal(f.art, 'unbekannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Umzug nennt die neue Adresse', () => {
|
||||||
|
const f = deuten(n({
|
||||||
|
metadata: { message_type: 'session_reconnect' },
|
||||||
|
payload: { session: { id: 's', keepalive_timeout_seconds: 10,
|
||||||
|
reconnect_url: 'wss://eventsub.wss.twitch.tv/ws?id=neu' } },
|
||||||
|
}));
|
||||||
|
assert.deepEqual(f, { art: 'umziehen', url: 'wss://eventsub.wss.twitch.tv/ws?id=neu' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein Umzug ohne Adresse wird ignoriert statt zu einer leeren Verbindung zu fuehren', () => {
|
||||||
|
const f = deuten(n({
|
||||||
|
metadata: { message_type: 'session_reconnect' },
|
||||||
|
payload: { session: { id: 's', keepalive_timeout_seconds: 10, reconnect_url: null } },
|
||||||
|
}));
|
||||||
|
assert.equal(f.art, 'unbekannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein Entzug wird als solcher erkannt', () => {
|
||||||
|
// Passiert, wenn Abdu die App-Freigabe zurueckzieht. Wiederverbinden hilft
|
||||||
|
// dagegen nichts, deshalb darf es nicht wie ein Netzwerkfehler aussehen.
|
||||||
|
const f = deuten(n({
|
||||||
|
metadata: { message_type: 'revocation' },
|
||||||
|
payload: { subscription: { id: 's', type: 'channel.follow', status: 'authorization_revoked' } },
|
||||||
|
}));
|
||||||
|
assert.deepEqual(f, { art: 'entzogen', typ: 'channel.follow', grund: 'authorization_revoked' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein Lebenszeichen ist ein Lebenszeichen', () => {
|
||||||
|
assert.deepEqual(deuten(n({ metadata: { message_type: 'session_keepalive' }, payload: {} })),
|
||||||
|
{ art: 'lebenszeichen' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unsinn wird verworfen, nicht geraten', () => {
|
||||||
|
for (const muell of [{}, { metadata: {} }, { metadata: { message_type: 'gibtsnicht' }, payload: {} }]) {
|
||||||
|
assert.equal(deuten(n(muell)).art, 'unbekannt');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// EventSub ueber WebSocket. Beschlossen in ADR-0003, gebaut erst jetzt.
|
||||||
|
//
|
||||||
|
// Warum ueberhaupt, wo der Chat doch schon da ist: Kanalpunkt-Einloesungen
|
||||||
|
// kommen ueber IRC nur dann an, wenn die Belohnung eine Texteingabe verlangt -
|
||||||
|
// und selbst dann ohne Einloese-ID. Ohne die ID lassen sich Punkte nicht
|
||||||
|
// zurueckgeben, wenn eine Aktion schiefgeht. Wer Punkte nimmt und nichts
|
||||||
|
// liefert, verliert das Vertrauen schneller als durch jeden Fehler im Bild.
|
||||||
|
//
|
||||||
|
// Ablauf des Protokolls:
|
||||||
|
//
|
||||||
|
// verbinden -> session_welcome (traegt session.id und das Zeitlimit)
|
||||||
|
// -> Abonnements ueber Helix anlegen, mit dieser session.id
|
||||||
|
// -> notification ... notification ... session_keepalive ...
|
||||||
|
//
|
||||||
|
// Zwei Eigenheiten, die man kennen muss:
|
||||||
|
//
|
||||||
|
// * Ein Abonnement muss innerhalb von 10 Sekunden nach dem Willkommensgruss
|
||||||
|
// stehen, sonst schliesst Twitch die Verbindung wieder.
|
||||||
|
// * `session_reconnect` nennt eine neue Adresse. Die alte Verbindung bleibt
|
||||||
|
// offen, bis die neue steht - erst verbinden, dann schliessen, sonst
|
||||||
|
// entsteht genau in dieser Luecke ein verpasstes Ereignis.
|
||||||
|
|
||||||
|
import { logger } from '#core/logger.ts';
|
||||||
|
import { forever } from '#core/retry.ts';
|
||||||
|
|
||||||
|
const log = logger('eventsub');
|
||||||
|
|
||||||
|
export const EVENTSUB_URL = 'wss://eventsub.wss.twitch.tv/ws';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie lange ohne ein Lebenszeichen gewartet wird, bevor neu verbunden wird.
|
||||||
|
* Twitch nennt sein eigenes Zeitlimit im Willkommensgruss (ueblich 10 s); wir
|
||||||
|
* legen Luft drauf, damit ein kurzer Netzhaenger nicht sofort neu verbindet.
|
||||||
|
*/
|
||||||
|
const KEEPALIVE_PUFFER_MS = 5_000;
|
||||||
|
|
||||||
|
export type EventSubNachricht = {
|
||||||
|
metadata: {
|
||||||
|
message_id: string;
|
||||||
|
message_type: 'session_welcome' | 'session_keepalive' | 'notification'
|
||||||
|
| 'session_reconnect' | 'revocation';
|
||||||
|
subscription_type?: string;
|
||||||
|
subscription_version?: string;
|
||||||
|
};
|
||||||
|
payload: {
|
||||||
|
session?: { id: string; keepalive_timeout_seconds: number | null; reconnect_url: string | null };
|
||||||
|
subscription?: { id: string; type: string; status: string };
|
||||||
|
event?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ein Abonnement, so wie Helix es entgegennimmt. */
|
||||||
|
export type Abonnement = {
|
||||||
|
type: string;
|
||||||
|
version: string;
|
||||||
|
condition: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventSubHandlers = {
|
||||||
|
/** Die Sitzung steht. Hier gehoeren die Abonnements angelegt. */
|
||||||
|
onSession: (sessionId: string) => Promise<void>;
|
||||||
|
onNotification: (typ: string, event: Record<string, unknown>) => void;
|
||||||
|
/**
|
||||||
|
* Twitch hat ein Abonnement entzogen - etwa weil das Token zurueckgezogen
|
||||||
|
* wurde. Das ist kein Netzwerkproblem und geht durch Wiederverbinden nicht
|
||||||
|
* weg, deshalb bekommt es einen eigenen Weg.
|
||||||
|
*/
|
||||||
|
onRevocation?: (typ: string, grund: string) => void;
|
||||||
|
onConnected?: () => void;
|
||||||
|
onClosed?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was aus einer eingehenden Nachricht folgen soll. Ausgelagert und rein,
|
||||||
|
* damit sich die Faelle pruefen lassen, die im Betrieb selten sind und dann
|
||||||
|
* mitten im Stream zuschlagen.
|
||||||
|
*/
|
||||||
|
export type Folge =
|
||||||
|
| { art: 'sitzung'; sessionId: string; keepaliveMs: number }
|
||||||
|
| { art: 'ereignis'; typ: string; event: Record<string, unknown> }
|
||||||
|
| { art: 'umziehen'; url: string }
|
||||||
|
| { art: 'entzogen'; typ: string; grund: string }
|
||||||
|
| { art: 'lebenszeichen' }
|
||||||
|
| { art: 'unbekannt' };
|
||||||
|
|
||||||
|
export function deuten(n: EventSubNachricht): Folge {
|
||||||
|
switch (n.metadata?.message_type) {
|
||||||
|
case 'session_welcome': {
|
||||||
|
const s = n.payload?.session;
|
||||||
|
if (!s?.id) return { art: 'unbekannt' };
|
||||||
|
// Ohne Angabe 10 Sekunden annehmen, so wie Twitch es dokumentiert.
|
||||||
|
const sek = s.keepalive_timeout_seconds ?? 10;
|
||||||
|
return { art: 'sitzung', sessionId: s.id, keepaliveMs: sek * 1000 + KEEPALIVE_PUFFER_MS };
|
||||||
|
}
|
||||||
|
case 'session_reconnect': {
|
||||||
|
const url = n.payload?.session?.reconnect_url;
|
||||||
|
return url ? { art: 'umziehen', url } : { art: 'unbekannt' };
|
||||||
|
}
|
||||||
|
case 'notification': {
|
||||||
|
const typ = n.metadata.subscription_type ?? n.payload?.subscription?.type ?? '';
|
||||||
|
const event = n.payload?.event;
|
||||||
|
if (!typ || !event) return { art: 'unbekannt' };
|
||||||
|
return { art: 'ereignis', typ, event };
|
||||||
|
}
|
||||||
|
case 'revocation':
|
||||||
|
return {
|
||||||
|
art: 'entzogen',
|
||||||
|
typ: n.payload?.subscription?.type ?? 'unbekannt',
|
||||||
|
grund: n.payload?.subscription?.status ?? 'unbekannt',
|
||||||
|
};
|
||||||
|
case 'session_keepalive':
|
||||||
|
return { art: 'lebenszeichen' };
|
||||||
|
default:
|
||||||
|
return { art: 'unbekannt' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventSubClient = {
|
||||||
|
run: (signal: AbortSignal) => Promise<void>;
|
||||||
|
isConnected: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createEventSub(handlers: EventSubHandlers): EventSubClient {
|
||||||
|
let verbunden = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
run: (signal) => forever(
|
||||||
|
async (sig) => { await einmalVerbinden(EVENTSUB_URL, handlers, sig, (v) => { verbunden = v; }); },
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
onWait: (ms, n) =>
|
||||||
|
log.warn(`EventSub getrennt, neuer Versuch in ${Math.round(ms / 1000)} s (${n}.)`),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
isConnected: () => verbunden,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function einmalVerbinden(
|
||||||
|
url: string,
|
||||||
|
handlers: EventSubHandlers,
|
||||||
|
signal: AbortSignal,
|
||||||
|
setzeStatus: (v: boolean) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise<void>((_erfuellt, ablehnen) => {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
let wachhund: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let umzug: WebSocket | null = null;
|
||||||
|
// Twitch nennt sein Zeitlimit im Willkommensgruss. Bis dahin grosszuegig.
|
||||||
|
let frist = 30_000;
|
||||||
|
|
||||||
|
const abbrechen = (warum: string): void => {
|
||||||
|
if (wachhund) clearTimeout(wachhund);
|
||||||
|
setzeStatus(false);
|
||||||
|
handlers.onClosed?.();
|
||||||
|
try { ws.close(); } catch { /* schon zu */ }
|
||||||
|
try { umzug?.close(); } catch { /* schon zu */ }
|
||||||
|
ablehnen(new Error(warum));
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener('abort', () => abbrechen('abgebrochen'), { once: true });
|
||||||
|
|
||||||
|
/** Kommt zu lange nichts, ist die Verbindung tot, ohne es zu melden. */
|
||||||
|
const wachhundStellen = (ms: number): void => {
|
||||||
|
if (wachhund) clearTimeout(wachhund);
|
||||||
|
wachhund = setTimeout(() => abbrechen('kein Lebenszeichen mehr'), ms);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addEventListener('open', () => wachhundStellen(30_000));
|
||||||
|
|
||||||
|
ws.addEventListener('message', (ev) => {
|
||||||
|
let nachricht: EventSubNachricht;
|
||||||
|
try { nachricht = JSON.parse(String(ev.data)); } catch { return; }
|
||||||
|
const folge = deuten(nachricht);
|
||||||
|
|
||||||
|
switch (folge.art) {
|
||||||
|
case 'sitzung':
|
||||||
|
frist = folge.keepaliveMs;
|
||||||
|
wachhundStellen(frist);
|
||||||
|
setzeStatus(true);
|
||||||
|
handlers.onConnected?.();
|
||||||
|
// Die Abonnements muessen jetzt angelegt werden. Scheitert das,
|
||||||
|
// haelt Twitch die Verbindung ohnehin nicht lange.
|
||||||
|
void handlers.onSession(folge.sessionId).catch((err: Error) => {
|
||||||
|
log.error(`Abonnements nicht angelegt: ${err.message}`);
|
||||||
|
abbrechen('Abonnements fehlgeschlagen');
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'lebenszeichen':
|
||||||
|
wachhundStellen(frist);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ereignis':
|
||||||
|
wachhundStellen(frist);
|
||||||
|
try { handlers.onNotification(folge.typ, folge.event); }
|
||||||
|
catch (err) { log.error(`Ereignis nicht verarbeitet: ${(err as Error).message}`); }
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'umziehen': {
|
||||||
|
// Erst die neue Verbindung aufbauen, dann die alte schliessen.
|
||||||
|
// Andersherum faellt genau dazwischen ein Ereignis unter den Tisch.
|
||||||
|
log.info('Twitch bittet um Umzug auf eine neue Verbindung');
|
||||||
|
umzug = new WebSocket(folge.url);
|
||||||
|
umzug.addEventListener('open', () => {
|
||||||
|
log.info('Umzug steht, alte Verbindung wird geschlossen');
|
||||||
|
abbrechen('umgezogen');
|
||||||
|
});
|
||||||
|
umzug.addEventListener('error', () => {
|
||||||
|
log.warn('Umzug fehlgeschlagen, bleibe auf der alten Verbindung');
|
||||||
|
umzug = null;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'entzogen':
|
||||||
|
log.error(`Abonnement entzogen: ${folge.typ} (${folge.grund})`);
|
||||||
|
handlers.onRevocation?.(folge.typ, folge.grund);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => abbrechen('WebSocket-Fehler'));
|
||||||
|
ws.addEventListener('close', (ev) => abbrechen(`Verbindung geschlossen (${ev.code})`));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { logger } from '#core/logger.ts';
|
import { logger } from '#core/logger.ts';
|
||||||
import { retry, sleep } from '#core/retry.ts';
|
import { retry, sleep } from '#core/retry.ts';
|
||||||
import { accessToken } from './oauth.ts';
|
import { accessToken, appToken } from './oauth.ts';
|
||||||
import { readSecrets, type Role } from './secrets.ts';
|
import { readSecrets, type Role } from './secrets.ts';
|
||||||
|
|
||||||
const log = logger('helix');
|
const log = logger('helix');
|
||||||
@@ -49,13 +49,33 @@ export type HelixStream = {
|
|||||||
|
|
||||||
export type HelixUser = { id: string; login: string; display_name: string };
|
export type HelixUser = { id: string; login: string; display_name: string };
|
||||||
|
|
||||||
async function call<T>(role: Role, endpoint: string, init: RequestInit = {}): Promise<T> {
|
/**
|
||||||
const app = readSecrets().apps[role];
|
* `appOk` heisst: dieser Endpunkt kommt auch ohne Nutzer-Token aus.
|
||||||
|
*
|
||||||
|
* Das ist kein Detail, sondern der Unterschied zwischen "laeuft" und "wartet
|
||||||
|
* auf einen Klick": Stream-Erkennung und damit die Go-Live-Ankuendigung
|
||||||
|
* brauchen nur `GET /users` und `GET /streams`, und beide sind oeffentlich.
|
||||||
|
* Ohne diese Unterscheidung stuende der ganze Dienst still, bis der
|
||||||
|
* Broadcaster sein Konto verbindet.
|
||||||
|
*/
|
||||||
|
type CallOpts = { appOk?: boolean };
|
||||||
|
|
||||||
|
async function call<T>(
|
||||||
|
role: Role, endpoint: string, init: RequestInit = {}, opts: CallOpts = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const secrets = readSecrets();
|
||||||
|
const app = secrets.apps[role];
|
||||||
if (!app?.clientId) throw new Error(`Fuer "${role}" ist keine Client-ID hinterlegt.`);
|
if (!app?.clientId) throw new Error(`Fuer "${role}" ist keine Client-ID hinterlegt.`);
|
||||||
|
const hatNutzerToken = Boolean(secrets.tokens[role]);
|
||||||
|
if (!hatNutzerToken && !opts.appOk) {
|
||||||
|
throw new Error(`"${role}" ist nicht verbunden - Einrichtung unter /setup.`);
|
||||||
|
}
|
||||||
|
|
||||||
return await retry(async () => {
|
return await retry(async () => {
|
||||||
await bucket.take();
|
await bucket.take();
|
||||||
const token = await accessToken(role);
|
// Das Nutzer-Token gewinnt, wenn es da ist: es sieht mehr (Abos, Chatter)
|
||||||
|
// und zaehlt auf ein eigenes Kontingent.
|
||||||
|
const token = hatNutzerToken ? await accessToken(role) : await appToken(role);
|
||||||
const res = await fetch(`${BASE}${endpoint}`, {
|
const res = await fetch(`${BASE}${endpoint}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -85,15 +105,26 @@ async function call<T>(role: Role, endpoint: string, init: RequestInit = {}): Pr
|
|||||||
|
|
||||||
export const helix = {
|
export const helix = {
|
||||||
async userByLogin(login: string): Promise<HelixUser | null> {
|
async userByLogin(login: string): Promise<HelixUser | null> {
|
||||||
const r = await call<{ data: HelixUser[] }>('broadcaster', `/users?login=${encodeURIComponent(login)}`);
|
const r = await call<{ data: HelixUser[] }>(
|
||||||
|
'broadcaster', `/users?login=${encodeURIComponent(login)}`, {}, { appOk: true });
|
||||||
return r.data[0] ?? null;
|
return r.data[0] ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async streamByUserId(userId: string): Promise<HelixStream | null> {
|
async streamByUserId(userId: string): Promise<HelixStream | null> {
|
||||||
const r = await call<{ data: HelixStream[] }>('broadcaster', `/streams?user_id=${userId}`);
|
const r = await call<{ data: HelixStream[] }>(
|
||||||
|
'broadcaster', `/streams?user_id=${userId}`, {}, { appOk: true });
|
||||||
return r.data[0] ?? null;
|
return r.data[0] ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reicht das Hinterlegte, um den Stream-Zustand abzufragen? Client-ID und
|
||||||
|
* Secret genuegen dafuer - ein verbundenes Konto ist nicht noetig.
|
||||||
|
*/
|
||||||
|
canReadStream(): boolean {
|
||||||
|
const app = readSecrets().apps.broadcaster;
|
||||||
|
return Boolean(app?.clientId && app.clientSecret);
|
||||||
|
},
|
||||||
|
|
||||||
async chatters(broadcasterId: string, moderatorId: string): Promise<string[]> {
|
async chatters(broadcasterId: string, moderatorId: string): Promise<string[]> {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
let cursor = '';
|
let cursor = '';
|
||||||
@@ -108,6 +139,48 @@ export const helix = {
|
|||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abonnement fuer die WebSocket-Sitzung anlegen.
|
||||||
|
*
|
||||||
|
* Zwingend mit dem Nutzer-Token: Twitch verlangt fuer den WebSocket-Weg
|
||||||
|
* dasselbe Token, mit dem die Sitzung geoeffnet wurde. Ein App-Token wird
|
||||||
|
* hier abgelehnt, und die Fehlermeldung sagt nicht, warum.
|
||||||
|
*/
|
||||||
|
async subscribe(
|
||||||
|
sub: { type: string; version: string; condition: Record<string, string> },
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const r = await call<{ data: Array<{ id: string }> }>('broadcaster', '/eventsub/subscriptions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ...sub, transport: { method: 'websocket', session_id: sessionId } }),
|
||||||
|
});
|
||||||
|
const angelegt = r.data[0];
|
||||||
|
if (!angelegt) throw new Error(`Abonnement ${sub.type} wurde nicht angelegt`);
|
||||||
|
return angelegt;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Einloesung abschliessen oder die Punkte zurueckgeben.
|
||||||
|
*
|
||||||
|
* Braucht `channel:manage:redemptions`; `channel:read:redemptions` allein
|
||||||
|
* genuegt nicht. Fehlt der Scope, meldet Twitch 401 - der Aufrufer faengt
|
||||||
|
* das ab und macht weiter, statt die Aktion daran scheitern zu lassen.
|
||||||
|
*/
|
||||||
|
async setRedemptionStatus(
|
||||||
|
broadcasterId: string, rewardId: string, redemptionId: string,
|
||||||
|
status: 'FULFILLED' | 'CANCELED',
|
||||||
|
): Promise<void> {
|
||||||
|
await call('broadcaster',
|
||||||
|
`/channel_points/custom_rewards/redemptions?broadcaster_id=${broadcasterId}` +
|
||||||
|
`&reward_id=${encodeURIComponent(rewardId)}&id=${encodeURIComponent(redemptionId)}`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify({ status }) });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Hat das verbundene Konto diesen Scope freigegeben? */
|
||||||
|
hasScope(scope: string): boolean {
|
||||||
|
return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope);
|
||||||
|
},
|
||||||
|
|
||||||
async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> {
|
async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> {
|
||||||
const r = await call<{ data: Array<{ id: string; edit_url: string }> }>(
|
const r = await call<{ data: Array<{ id: string; edit_url: string }> }>(
|
||||||
'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' });
|
'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { parseLine, toChatMessage } from './irc.ts';
|
import { parseLine, toChatMessage, parseGifs } from './irc.ts';
|
||||||
|
|
||||||
const LINE =
|
const LINE =
|
||||||
'@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' +
|
'@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' +
|
||||||
@@ -57,3 +57,50 @@ test('PING wird als solches erkannt', () => {
|
|||||||
assert.equal(m?.command, 'PING');
|
assert.equal(m?.command, 'PING');
|
||||||
assert.equal(m?.params[0], 'tmi.twitch.tv');
|
assert.equal(m?.params[0], 'tmi.twitch.tv');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- GIFs
|
||||||
|
|
||||||
|
test('der gifs-Tag wird zerlegt', () => {
|
||||||
|
// Echte Zeile aus #abdulive_, mitgeschnitten am 2026-09-17.
|
||||||
|
const g = parseGifs(
|
||||||
|
'0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/Pnd7qLFuGCOntZcNKB/giphy.gif?cid=095d&ct=g');
|
||||||
|
assert.equal(g.length, 1);
|
||||||
|
assert.equal(g[0]!.id, 'Pnd7qLFuGCOntZcNKB');
|
||||||
|
assert.equal(g[0]!.from, 0);
|
||||||
|
assert.equal(g[0]!.to, 22);
|
||||||
|
assert.match(g[0]!.url, /^https:\/\/media0\.giphy\.com\//);
|
||||||
|
assert.ok(g[0]!.url.includes('&ct=g'), 'die Adresse darf nicht am & abgeschnitten werden');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Bereich deckt genau den Platzhalter ab', () => {
|
||||||
|
// Wenn das nicht stimmt, bleibt ein Rest der eckigen Klammer im Bild stehen.
|
||||||
|
const text = '[Idk GIF by Respective]';
|
||||||
|
const g = parseGifs('0-22|abc|https://x/y.gif')[0]!;
|
||||||
|
assert.equal([...text].slice(g.from, g.to + 1).join(''), text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine Nachricht ohne GIF hat eine leere Liste', () => {
|
||||||
|
const m = parseLine('@id=1;user-id=7;emotes= :a!a@a PRIVMSG #k :moin');
|
||||||
|
assert.deepEqual(toChatMessage(m!)!.gifs, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine Nachricht mit GIF traegt es bis in die ChatMessage', () => {
|
||||||
|
const m = parseLine(
|
||||||
|
'@id=1;user-id=7;emotes=;gifs=0-4|xy|https://media.giphy.com/a.gif ' +
|
||||||
|
':a!a@a PRIVMSG #k :[GIF]');
|
||||||
|
assert.equal(toChatMessage(m!)!.gifs[0]!.url, 'https://media.giphy.com/a.gif');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kein https, kein Bild', () => {
|
||||||
|
// Der Tag kommt von Twitch, nicht vom Chatter - trotzdem landet die Adresse
|
||||||
|
// als Bild im Stream, und dort gilt kein Vertrauensvorschuss.
|
||||||
|
assert.deepEqual(parseGifs('0-4|xy|http://media.giphy.com/a.gif'), []);
|
||||||
|
assert.deepEqual(parseGifs('0-4|xy|javascript:alert(1)'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unvollstaendige oder unsinnige Tags werden verworfen', () => {
|
||||||
|
for (const kaputt of ['', '0-4', '0-4|nurid', 'abc|x|https://y/z.gif', '-1-4|x|https://y/z.gif',
|
||||||
|
'9-4|x|https://y/z.gif']) {
|
||||||
|
assert.deepEqual(parseGifs(kaputt), [], `"${kaputt}" haette verworfen gehoert`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -52,6 +52,48 @@ export function parseLine(line: string): IrcMessage | null {
|
|||||||
return { tags, prefix, command, params: parts };
|
return { tags, prefix, command, params: parts };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der `gifs`-Tag. Twitch hat ihn 2026 mit der GIF-Auswahl im Chat eingefuehrt.
|
||||||
|
*
|
||||||
|
* Aufbau, am echten Chat abgelesen:
|
||||||
|
*
|
||||||
|
* gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif?cid=...
|
||||||
|
* ^^^^ ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
* Bereich Giphy-ID Adresse des Bildes
|
||||||
|
*
|
||||||
|
* Der Bereich zeigt wie bei `emotes` auf Zeichen im Nachrichtentext - dort
|
||||||
|
* steht ein Platzhalter wie `[Idk GIF by Respective]`, der ersetzt gehoert.
|
||||||
|
* Wer den Tag nicht auswertet, zeigt genau diesen Platzhalter an.
|
||||||
|
*
|
||||||
|
* Ob mehrere GIFs je Nachricht moeglich sind, ist offen: der Trenner koennte
|
||||||
|
* dafuer nicht `/` sein wie bei Emotes, weil eine URL selbst Schraegstriche
|
||||||
|
* enthaelt. Beobachtet wurde bisher genau eines, und danach ist das hier
|
||||||
|
* gebaut - mehr wuerde ignoriert statt falsch dargestellt.
|
||||||
|
*/
|
||||||
|
export function parseGifs(tag: string | undefined): ChatMessage['gifs'] {
|
||||||
|
if (!tag) return [];
|
||||||
|
const teile = tag.split('|');
|
||||||
|
if (teile.length < 3) return [];
|
||||||
|
|
||||||
|
// Streng auf Ziffern pruefen und nicht auf `Number` vertrauen: `-1-4`
|
||||||
|
// zerfaellt beim Trennen in ['', '1', '4'], und `Number('')` ist 0. Der
|
||||||
|
// Bereich saehe dann gueltig aus und wuerde den falschen Textteil ersetzen.
|
||||||
|
const bereich = /^(\d+)-(\d+)$/.exec(teile[0] ?? '');
|
||||||
|
if (!bereich) return [];
|
||||||
|
const from = Number(bereich[1]);
|
||||||
|
const to = Number(bereich[2]);
|
||||||
|
const id = teile[1] ?? '';
|
||||||
|
// Alles ab dem zweiten Strich ist die Adresse - falls dort je einer auftaucht.
|
||||||
|
const url = teile.slice(2).join('|');
|
||||||
|
|
||||||
|
if (to < from) return [];
|
||||||
|
// Nur https. Der Tag kommt von Twitch und nicht vom Chatter, aber er landet
|
||||||
|
// als Bildadresse im Stream - das ist kein Ort fuer Vertrauensvorschuss.
|
||||||
|
if (!url.startsWith('https://')) return [];
|
||||||
|
|
||||||
|
return [{ id, url, from, to }];
|
||||||
|
}
|
||||||
|
|
||||||
function parseEmotes(tag: string | undefined, text: string): ChatMessage['emotes'] {
|
function parseEmotes(tag: string | undefined, text: string): ChatMessage['emotes'] {
|
||||||
if (!tag) return [];
|
if (!tag) return [];
|
||||||
const out: ChatMessage['emotes'] = [];
|
const out: ChatMessage['emotes'] = [];
|
||||||
@@ -84,6 +126,7 @@ export function toChatMessage(m: IrcMessage): ChatMessage | null {
|
|||||||
displayName: m.tags['display-name'] || login,
|
displayName: m.tags['display-name'] || login,
|
||||||
text,
|
text,
|
||||||
emotes: parseEmotes(m.tags['emotes'], text),
|
emotes: parseEmotes(m.tags['emotes'], text),
|
||||||
|
gifs: parseGifs(m.tags['gifs']),
|
||||||
isFirst: m.tags['first-msg'] === '1',
|
isFirst: m.tags['first-msg'] === '1',
|
||||||
isReturning: m.tags['returning-chatter'] === '1',
|
isReturning: m.tags['returning-chatter'] === '1',
|
||||||
isCommand: text.startsWith('!'),
|
isCommand: text.startsWith('!'),
|
||||||
@@ -95,10 +138,90 @@ export function toChatMessage(m: IrcMessage): ChatMessage | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein USERNOTICE ist alles, was Twitch als Ereignis in den Chat schreibt:
|
||||||
|
* Abos, verschenkte Abos, Raids, Ankuendigungen.
|
||||||
|
*
|
||||||
|
* Warum hier und nicht ueber EventSub: es kommt ohnehin ueber dieselbe
|
||||||
|
* Verbindung, kostet keinen zusaetzlichen Token und keine zweite
|
||||||
|
* Wiederverbindung. Was IRC NICHT liefert, sind Follows - dafuer braucht es
|
||||||
|
* EventSub, und bis dahin bleibt das Overlay bei Follows stumm.
|
||||||
|
*/
|
||||||
|
export type UserNoticeKind =
|
||||||
|
| 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'announcement' | 'other';
|
||||||
|
|
||||||
|
export type UserNotice = {
|
||||||
|
kind: UserNoticeKind;
|
||||||
|
/** Roher msg-id-Wert, falls Twitch etwas Neues schickt. */
|
||||||
|
rawKind: string;
|
||||||
|
ts: string;
|
||||||
|
login: string;
|
||||||
|
displayName: string;
|
||||||
|
/** Twitchs eigener Satz dazu, schon fertig formuliert. */
|
||||||
|
systemMessage: string;
|
||||||
|
/** Was die Person selbst dazugeschrieben hat. Oft leer. */
|
||||||
|
text: string;
|
||||||
|
months?: number;
|
||||||
|
/** Prime, 1000, 2000, 3000 - so wie Twitch es nennt. */
|
||||||
|
tier?: string;
|
||||||
|
/** Beschenkte Person bei subgift. */
|
||||||
|
recipient?: string;
|
||||||
|
/** Anzahl: verschenkte Abos oder mitgebrachte Zuschauer beim Raid. */
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOTICE_KINDS: Record<string, UserNoticeKind> = {
|
||||||
|
sub: 'sub',
|
||||||
|
resub: 'resub',
|
||||||
|
subgift: 'subgift',
|
||||||
|
submysterygift: 'massgift',
|
||||||
|
raid: 'raid',
|
||||||
|
announcement: 'announcement',
|
||||||
|
};
|
||||||
|
|
||||||
|
function num(v: string | undefined): number | undefined {
|
||||||
|
const n = Number(v);
|
||||||
|
return v !== undefined && v !== '' && Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toUserNotice(m: IrcMessage): UserNotice | null {
|
||||||
|
if (m.command !== 'USERNOTICE') return null;
|
||||||
|
const rawKind = m.tags['msg-id'] ?? '';
|
||||||
|
const login = m.tags['login'] ?? '';
|
||||||
|
const months = num(m.tags['msg-param-cumulative-months']) ?? num(m.tags['msg-param-months']);
|
||||||
|
// Beim Raid steht die Zuschauerzahl drin, beim Massengeschenk die Anzahl.
|
||||||
|
const count = num(m.tags['msg-param-viewerCount']) ?? num(m.tags['msg-param-mass-gift-count']);
|
||||||
|
const recipient = m.tags['msg-param-recipient-display-name'] ||
|
||||||
|
m.tags['msg-param-recipient-user-name'];
|
||||||
|
const tier = m.tags['msg-param-sub-plan'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: NOTICE_KINDS[rawKind] ?? 'other',
|
||||||
|
rawKind,
|
||||||
|
ts: new Date(Number(m.tags['tmi-sent-ts'] ?? Date.now())).toISOString(),
|
||||||
|
login,
|
||||||
|
displayName: m.tags['display-name'] || login,
|
||||||
|
systemMessage: m.tags['system-msg'] ?? '',
|
||||||
|
text: m.params[1] ?? '',
|
||||||
|
...(months !== undefined ? { months } : {}),
|
||||||
|
...(tier ? { tier } : {}),
|
||||||
|
...(recipient ? { recipient } : {}),
|
||||||
|
...(count !== undefined ? { count } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type IrcHandlers = {
|
export type IrcHandlers = {
|
||||||
onMessage: (msg: ChatMessage) => void;
|
onMessage: (msg: ChatMessage) => void;
|
||||||
onNotice?: (text: string) => void;
|
onNotice?: (text: string) => void;
|
||||||
onConnected?: (anonymous: boolean) => void;
|
onConnected?: (anonymous: boolean) => void;
|
||||||
|
/** Abos, Raids, Ankuendigungen. */
|
||||||
|
onUserNotice?: (notice: UserNotice) => void;
|
||||||
|
/**
|
||||||
|
* Eine Nachricht oder ein ganzer Chatter wurde geloescht. Das Overlay muss
|
||||||
|
* das mitbekommen, sonst steht eine geloeschte Beleidigung noch eine Minute
|
||||||
|
* lang im Bild - und zwar in der Aufzeichnung.
|
||||||
|
*/
|
||||||
|
onClear?: (what: { userId?: string; messageId?: string }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IrcClient = {
|
export type IrcClient = {
|
||||||
@@ -189,6 +312,22 @@ function connectOnce(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (m.command === 'NOTICE') { handlers.onNotice?.(m.params[1] ?? ''); continue; }
|
if (m.command === 'NOTICE') { handlers.onNotice?.(m.params[1] ?? ''); continue; }
|
||||||
|
if (m.command === 'USERNOTICE') {
|
||||||
|
const notice = toUserNotice(m);
|
||||||
|
if (notice) handlers.onUserNotice?.(notice);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (m.command === 'CLEARCHAT') {
|
||||||
|
// Ohne target-user-id wurde der ganze Chat geleert.
|
||||||
|
const userId = m.tags['target-user-id'];
|
||||||
|
handlers.onClear?.(userId ? { userId } : {});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (m.command === 'CLEARMSG') {
|
||||||
|
const messageId = m.tags['target-msg-id'];
|
||||||
|
if (messageId) handlers.onClear?.({ messageId });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (m.command === 'PRIVMSG') {
|
if (m.command === 'PRIVMSG') {
|
||||||
const msg = toChatMessage(m);
|
const msg = toChatMessage(m);
|
||||||
if (msg) handlers.onMessage(msg);
|
if (msg) handlers.onMessage(msg);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// OAuth-Autorisierungscode-Flow gegen Twitch. Rueckruf laeuft auf denselben
|
// OAuth-Autorisierungscode-Flow gegen Twitch. Rueckruf laeuft auf denselben
|
||||||
// Server wie die Einrichtungsseite (Port 4792) - siehe ADR-0005.
|
// Server wie die Einrichtungsseite - siehe ADR-0005.
|
||||||
|
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { loadConfig } from '#core/config.ts';
|
||||||
import { logger } from '#core/logger.ts';
|
import { logger } from '#core/logger.ts';
|
||||||
import { readSecrets, updateSecrets, type Role, type TokenSet } from './secrets.ts';
|
import { readSecrets, updateSecrets, type Role, type TokenSet } from './secrets.ts';
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ export const SCOPES: Record<Role, string[]> = {
|
|||||||
'moderator:read:chatters',
|
'moderator:read:chatters',
|
||||||
'channel:manage:raids',
|
'channel:manage:raids',
|
||||||
'channel:read:redemptions',
|
'channel:read:redemptions',
|
||||||
|
// Zum Zurueckgeben der Punkte, wenn eine Aktion scheitert oder abgelehnt
|
||||||
|
// wird. Wer Punkte nimmt und nichts liefert, verliert schneller Vertrauen
|
||||||
|
// als durch jeden Fehler im Bild.
|
||||||
|
'channel:manage:redemptions',
|
||||||
'bits:read',
|
'bits:read',
|
||||||
],
|
],
|
||||||
bot: ['chat:read', 'chat:edit'],
|
bot: ['chat:read', 'chat:edit'],
|
||||||
@@ -27,9 +32,19 @@ export const SCOPES: Record<Role, string[]> = {
|
|||||||
/** Kurzlebige, einmalig einloesbare `state`-Werte gegen CSRF. */
|
/** Kurzlebige, einmalig einloesbare `state`-Werte gegen CSRF. */
|
||||||
const pending = new Map<string, { role: Role; created: number }>();
|
const pending = new Map<string, { role: Role; created: number }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Muss exakt so in der Twitch-App hinterlegt sein - Twitch vergleicht
|
||||||
|
* zeichengenau, und ein fehlender Schraegstrich sieht im Fehlertext aus wie
|
||||||
|
* ein falsches Client-Secret.
|
||||||
|
*
|
||||||
|
* Laeuft der Dienst zentral, steht in `publicUrl` die oeffentliche Adresse.
|
||||||
|
* Dann ist der Rueckruf `https://.../callback`, und Twitch verlangt dort
|
||||||
|
* ohnehin HTTPS: `http://localhost` ist die einzige Ausnahme, die Twitch
|
||||||
|
* ohne Zertifikat akzeptiert.
|
||||||
|
*/
|
||||||
export function redirectUri(port: number): string {
|
export function redirectUri(port: number): string {
|
||||||
// Muss exakt so in der Twitch-App hinterlegt sein.
|
const base = loadConfig().publicUrl;
|
||||||
return `http://localhost:${port}/callback`;
|
return base ? `${base}/callback` : `http://localhost:${port}/callback`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authorizeUrl(role: Role, port: number): string {
|
export function authorizeUrl(role: Role, port: number): string {
|
||||||
@@ -86,6 +101,52 @@ export async function handleCallback(code: string, state: string, port: number):
|
|||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-Token (client_credentials). Gehoert keinem Nutzer und braucht keine
|
||||||
|
* Freigabe - es beweist nur, dass die Anfrage von dieser App kommt.
|
||||||
|
*
|
||||||
|
* Damit laufen die oeffentlichen Endpunkte: `GET /users` und `GET /streams`.
|
||||||
|
* Genau die beiden traegt die Stream-Erkennung, und deshalb muss sie nicht
|
||||||
|
* darauf warten, dass der Broadcaster sein Konto verbindet. Alles, was ueber
|
||||||
|
* einen Kanal etwas *tut* oder Privates liest - Clips anlegen, Chatter
|
||||||
|
* auflisten, Abos sehen - verlangt weiterhin ein Nutzer-Token.
|
||||||
|
*
|
||||||
|
* Gemerkt wird nur im Arbeitsspeicher: das Token gilt rund zwei Monate, und
|
||||||
|
* nach einem Neustart ist es eine einzige Anfrage.
|
||||||
|
*/
|
||||||
|
const appTokens = new Map<Role, { token: string; bis: number }>();
|
||||||
|
|
||||||
|
export async function appToken(role: Role): Promise<string> {
|
||||||
|
const gemerkt = appTokens.get(role);
|
||||||
|
// Fuenf Minuten Puffer, damit kein Aufruf mitten im Ablauf landet
|
||||||
|
if (gemerkt && gemerkt.bis - Date.now() > 300_000) return gemerkt.token;
|
||||||
|
|
||||||
|
const app = readSecrets().apps[role];
|
||||||
|
if (!app?.clientId || !app.clientSecret) {
|
||||||
|
throw new Error(`Fuer "${role}" fehlen Client-ID und Secret - Einrichtung unter /setup.`);
|
||||||
|
}
|
||||||
|
const res = await fetch(TOKEN, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: app.clientId,
|
||||||
|
client_secret: app.clientSecret,
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`App-Token fuer "${role}" nicht erhalten (HTTP ${res.status}). ` +
|
||||||
|
'Stimmen Client-ID und Secret?');
|
||||||
|
}
|
||||||
|
const body = await res.json() as { access_token: string; expires_in: number };
|
||||||
|
appTokens.set(role, { token: body.access_token, bis: Date.now() + body.expires_in * 1000 });
|
||||||
|
log.info(`App-Token fuer ${role} geholt (gilt ${Math.round(body.expires_in / 86400)} Tage)`);
|
||||||
|
return body.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vergisst gemerkte App-Tokens. Fuer Tests und nach einem App-Wechsel. */
|
||||||
|
export function forgetAppTokens(): void { appTokens.clear(); }
|
||||||
|
|
||||||
export async function validate(accessToken: string) {
|
export async function validate(accessToken: string) {
|
||||||
const res = await fetch(VALIDATE, { headers: { Authorization: `OAuth ${accessToken}` } });
|
const res = await fetch(VALIDATE, { headers: { Authorization: `OAuth ${accessToken}` } });
|
||||||
if (!res.ok) throw new Error(`Token ungueltig: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`Token ungueltig: HTTP ${res.status}`);
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Diese Funktion entscheidet, wann der Go-Live-Ping rausgeht. Ein Fehler hier
|
||||||
|
// ist entweder ein verpasster Stream oder ein zweiter Rundruf an Leute, die
|
||||||
|
// sich daraufhin die Rolle abnehmen. Beides merkt man erst Tage spaeter.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { streamWechsel, type StreamBeobachtung } from './stream-state.ts';
|
||||||
|
|
||||||
|
const LUECKE = 5 * 60_000; // fuenf Minuten, wie in der Vorgabe
|
||||||
|
const T = Date.parse('2026-09-17T20:00:00Z');
|
||||||
|
|
||||||
|
function lage(over: Partial<StreamBeobachtung> = {}): StreamBeobachtung {
|
||||||
|
return {
|
||||||
|
live: null, laufendeId: null, zuletztLive: 0, jetzt: T, luckeMs: LUECKE, ...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('kein Stream, nichts laeuft: nichts passiert', () => {
|
||||||
|
assert.deepEqual(streamWechsel(lage()), { art: 'unveraendert' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der erste Stream wird als Start erkannt', () => {
|
||||||
|
const w = streamWechsel(lage({ live: { id: '48212345', startedAt: '2026-09-17T19:58:00Z' } }));
|
||||||
|
assert.deepEqual(w, { art: 'gestartet', id: '48212345', startedAt: '2026-09-17T19:58:00Z' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('derselbe Stream in der naechsten Abfrage loest nichts aus', () => {
|
||||||
|
// Bei 60 Sekunden Abfrageabstand passiert das den ganzen Stream lang.
|
||||||
|
// Wuerde es hier "gestartet" heissen, kaeme jede Minute ein Ping.
|
||||||
|
const w = streamWechsel(lage({
|
||||||
|
live: { id: '48212345', startedAt: '2026-09-17T19:58:00Z' },
|
||||||
|
laufendeId: '48212345', zuletztLive: T - 60_000,
|
||||||
|
}));
|
||||||
|
assert.deepEqual(w, { art: 'unveraendert' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neue Twitch-ID nach kurzem Aussetzer ist eine Fortsetzung', () => {
|
||||||
|
// Der Fall aus dem Betrieb: das Internet hakt zwei Minuten, Twitch vergibt
|
||||||
|
// eine neue Stream-ID. Derselbe Stream, kein zweiter Ping.
|
||||||
|
const w = streamWechsel(lage({
|
||||||
|
live: { id: '48299999', startedAt: '2026-09-17T20:02:00Z' },
|
||||||
|
laufendeId: '48212345', zuletztLive: T - 2 * 60_000,
|
||||||
|
}));
|
||||||
|
assert.deepEqual(w, { art: 'fortsetzung', neueId: '48299999' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neue ID nach langer Pause ist ein neuer Stream', () => {
|
||||||
|
const w = streamWechsel(lage({
|
||||||
|
live: { id: '48299999', startedAt: '2026-09-17T20:00:00Z' },
|
||||||
|
laufendeId: '48212345', zuletztLive: T - 6 * 60_000,
|
||||||
|
}));
|
||||||
|
assert.equal(w.art, 'gestartet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('genau an der Grenze gilt es noch als Fortsetzung', () => {
|
||||||
|
// Die Grenze liegt bei "kuerzer als die Luecke". Wer sie auf <= verschiebt,
|
||||||
|
// baut sich einen Fall, der mal so und mal so ausgeht.
|
||||||
|
const knappDrunter = streamWechsel(lage({
|
||||||
|
live: { id: 'neu', startedAt: 'x' }, laufendeId: 'alt', zuletztLive: T - (LUECKE - 1),
|
||||||
|
}));
|
||||||
|
assert.equal(knappDrunter.art, 'fortsetzung');
|
||||||
|
|
||||||
|
const knappDrueber = streamWechsel(lage({
|
||||||
|
live: { id: 'neu', startedAt: 'x' }, laufendeId: 'alt', zuletztLive: T - (LUECKE + 1),
|
||||||
|
}));
|
||||||
|
assert.equal(knappDrueber.art, 'gestartet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein einzelner fehlgeschlagener Abruf beendet den Stream nicht', () => {
|
||||||
|
// Twitch antwortet gelegentlich mit einer leeren Liste, obwohl der Stream
|
||||||
|
// laeuft. Wer daraufhin die Sitzung schliesst, bekommt beim naechsten
|
||||||
|
// Abruf einen "neuen" Stream - und einen zweiten Ping.
|
||||||
|
const w = streamWechsel(lage({ laufendeId: '48212345', zuletztLive: T - 60_000 }));
|
||||||
|
assert.deepEqual(w, { art: 'unveraendert' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nach der Luecke ohne Meldung gilt der Stream als beendet', () => {
|
||||||
|
const w = streamWechsel(lage({ laufendeId: '48212345', zuletztLive: T - 6 * 60_000 }));
|
||||||
|
assert.deepEqual(w, { art: 'beendet', id: '48212345' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein beendeter Stream wird nicht zweimal beendet', () => {
|
||||||
|
const nachEnde = streamWechsel(lage({ laufendeId: null, zuletztLive: T - 60 * 60_000 }));
|
||||||
|
assert.deepEqual(nachEnde, { art: 'unveraendert' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Ablauf eines ganzen Streams ergibt genau einen Start und ein Ende', () => {
|
||||||
|
// Der Durchlauf, der zaehlt: 90 Minuten Stream, zwei Aussetzer, alle 60 s
|
||||||
|
// eine Abfrage. Am Ende muss genau ein Ping stehen.
|
||||||
|
let laufendeId: string | null = null;
|
||||||
|
let zuletztLive = 0;
|
||||||
|
const starts: string[] = [];
|
||||||
|
const enden: string[] = [];
|
||||||
|
|
||||||
|
// Minute -> was Twitch meldet. null = Aussetzer.
|
||||||
|
const verlauf: Array<string | null> = [
|
||||||
|
...Array(20).fill('A'), // 20 min normal
|
||||||
|
null, null, // 2 min Aussetzer
|
||||||
|
...Array(30).fill('B'), // Twitch vergibt eine neue ID
|
||||||
|
null, // kurzer Hakler
|
||||||
|
...Array(20).fill('B'),
|
||||||
|
...Array(10).fill(null), // Stream zu Ende
|
||||||
|
];
|
||||||
|
|
||||||
|
verlauf.forEach((id, minute) => {
|
||||||
|
const jetzt = T + minute * 60_000;
|
||||||
|
const w = streamWechsel({
|
||||||
|
live: id ? { id, startedAt: '2026-09-17T20:00:00Z' } : null,
|
||||||
|
laufendeId, zuletztLive, jetzt, luckeMs: LUECKE,
|
||||||
|
});
|
||||||
|
if (id) zuletztLive = jetzt;
|
||||||
|
if (w.art === 'gestartet') { laufendeId = w.id; starts.push(w.id); }
|
||||||
|
if (w.art === 'fortsetzung') { /* ID bewusst behalten */ }
|
||||||
|
if (w.art === 'beendet') { enden.push(w.id); laufendeId = null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(starts, ['A'], 'genau ein Ping, nicht drei');
|
||||||
|
assert.deepEqual(enden, ['A'], 'genau ein Streamende');
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Wann ist ein Stream ein *neuer* Stream?
|
||||||
|
//
|
||||||
|
// Das klingt trivial und ist es nicht. Twitch vergibt bei einem
|
||||||
|
// Verbindungsabbruch eine neue Stream-ID, auch wenn der Stream aus Sicht aller
|
||||||
|
// Beteiligten derselbe ist. Wer das fuer einen Neustart haelt, legt eine
|
||||||
|
// zweite Sitzung an und schickt einen zweiten Rundruf an alle, die die Rolle
|
||||||
|
// Live-Ping tragen. Wer zweimal in zehn Minuten angepingt wird, nimmt sich die
|
||||||
|
// Rolle ab, und dann ist der Kanal fuer diese Person fuer immer stumm.
|
||||||
|
//
|
||||||
|
// Deshalb steht die Entscheidung hier, als reine Funktion, mit Tests - statt
|
||||||
|
// wie vorher zweimal ausgeschrieben in Hub und Ingest, wo sie auseinander
|
||||||
|
// laufen kann, ohne dass es jemandem auffaellt.
|
||||||
|
|
||||||
|
export type StreamBeobachtung = {
|
||||||
|
/** Was Twitch gerade meldet. `null` heisst: gerade kein Stream. */
|
||||||
|
live: { id: string; startedAt: string } | null;
|
||||||
|
/** Die Sitzung, die wir gerade fuehren. */
|
||||||
|
laufendeId: string | null;
|
||||||
|
/** Wann Twitch zuletzt "live" gemeldet hat (ms seit Epoche). */
|
||||||
|
zuletztLive: number;
|
||||||
|
jetzt: number;
|
||||||
|
/** Kuerzere Luecken gelten als derselbe Stream. */
|
||||||
|
luckeMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamWechsel =
|
||||||
|
/** Twitch meldet dieselbe Sitzung wie bisher. Nichts zu tun. */
|
||||||
|
| { art: 'unveraendert' }
|
||||||
|
/** Ein neuer Stream. Nur hier wird angekuendigt. */
|
||||||
|
| { art: 'gestartet'; id: string; startedAt: string }
|
||||||
|
/**
|
||||||
|
* Neue Twitch-ID, aber die Unterbrechung war kurz genug. Die Sitzung laeuft
|
||||||
|
* unter der bisherigen ID weiter - und es wird nicht erneut angekuendigt.
|
||||||
|
*/
|
||||||
|
| { art: 'fortsetzung'; neueId: string }
|
||||||
|
/** Lange genug nichts mehr gemeldet: die Sitzung ist zu Ende. */
|
||||||
|
| { art: 'beendet'; id: string };
|
||||||
|
|
||||||
|
export function streamWechsel(b: StreamBeobachtung): StreamWechsel {
|
||||||
|
if (b.live) {
|
||||||
|
if (b.laufendeId === b.live.id) return { art: 'unveraendert' };
|
||||||
|
|
||||||
|
// Es lief schon etwas, und die Unterbrechung war kurz: Fortsetzung.
|
||||||
|
// Ohne diesen Zweig zerfaellt ein Stream mit zwei Aussetzern in drei
|
||||||
|
// Sitzungen - und erzeugt drei Ankuendigungen.
|
||||||
|
if (b.laufendeId !== null && b.jetzt - b.zuletztLive < b.luckeMs) {
|
||||||
|
return { art: 'fortsetzung', neueId: b.live.id };
|
||||||
|
}
|
||||||
|
return { art: 'gestartet', id: b.live.id, startedAt: b.live.startedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kein Stream gemeldet. Erst nach Ablauf der Luecke gilt er als beendet -
|
||||||
|
// sonst beendet ein einzelner fehlgeschlagener Abruf die Sitzung.
|
||||||
|
if (b.laufendeId !== null && b.jetzt - b.zuletztLive > b.luckeMs) {
|
||||||
|
return { art: 'beendet', id: b.laufendeId };
|
||||||
|
}
|
||||||
|
return { art: 'unveraendert' };
|
||||||
|
}
|
||||||
@@ -156,7 +156,10 @@ async function main(): Promise<void> {
|
|||||||
for (const dir of ['packages', 'apps']) {
|
for (const dir of ['packages', 'apps']) {
|
||||||
for (const rel of await walk(path.join(repo, dir))) {
|
for (const rel of await walk(path.join(repo, dir))) {
|
||||||
if (rel.endsWith('.test.ts')) continue;
|
if (rel.endsWith('.test.ts')) continue;
|
||||||
if (!/\.(ts|sql|json)$/.test(rel)) continue;
|
// html/css/js gehoeren seit den Overlay-Seiten dazu: die liegen in
|
||||||
|
// apps/overlay-web/public und werden zur Laufzeit von der Platte
|
||||||
|
// gelesen. Fehlen sie im Paket, liefert der Server 404 statt Overlay.
|
||||||
|
if (!/\.(ts|sql|json|html|css|js|svg|png|woff2)$/.test(rel)) continue;
|
||||||
files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) });
|
files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Den Hub auf den LXC ausrollen.
|
||||||
|
#
|
||||||
|
# Warum ein Skript und kein `git pull` im Container: der Container hat keinen
|
||||||
|
# Zugang zum Repo, und er soll auch keinen bekommen. Ausgerollt wird, was
|
||||||
|
# hier im Arbeitsverzeichnis liegt - damit ist immer nachvollziehbar, welcher
|
||||||
|
# Stand laeuft, auch wenn gerade etwas nicht gepusht ist.
|
||||||
|
#
|
||||||
|
# Aufruf: scripts/deploy-hub.sh
|
||||||
|
# Umgebung (mit Vorgaben):
|
||||||
|
# PVE_HOST=192.168.2.20 Proxmox-Host
|
||||||
|
# CT=141 Container-ID
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PVE_HOST="${PVE_HOST:-192.168.2.20}"
|
||||||
|
CT="${CT:-141}"
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
STAND="$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
|
||||||
|
echo "Packe den Arbeitsstand ..."
|
||||||
|
cd "$REPO"
|
||||||
|
# node_modules bleibt draussen: das Projekt hat keine Laufzeit-Abhaengigkeiten,
|
||||||
|
# TypeScript wird nur fuer die Typpruefung gebraucht und nicht im Betrieb.
|
||||||
|
tar czf "/tmp/abdulive-${STAND}.tgz" \
|
||||||
|
--exclude='./node_modules' \
|
||||||
|
--exclude='./.git' \
|
||||||
|
--exclude='./dist' \
|
||||||
|
--exclude='./docs/handbuch/build' \
|
||||||
|
apps packages config scripts package.json tsconfig.json
|
||||||
|
|
||||||
|
echo "Schiebe nach ${PVE_HOST} -> CT ${CT} ..."
|
||||||
|
scp -q "/tmp/abdulive-${STAND}.tgz" "root@${PVE_HOST}:/tmp/abdulive.tgz"
|
||||||
|
ssh "root@${PVE_HOST}" "pct push ${CT} /tmp/abdulive.tgz /tmp/abdulive.tgz && rm -f /tmp/abdulive.tgz"
|
||||||
|
rm -f "/tmp/abdulive-${STAND}.tgz"
|
||||||
|
|
||||||
|
echo "Entpacke und starte neu ..."
|
||||||
|
ssh "root@${PVE_HOST}" "pct exec ${CT} -- bash -lc '
|
||||||
|
set -e
|
||||||
|
# In einen frischen Ordner entpacken und erst danach umhaengen: bricht das
|
||||||
|
# Entpacken ab, laeuft der alte Stand unveraendert weiter.
|
||||||
|
rm -rf /opt/abdulive.neu
|
||||||
|
mkdir -p /opt/abdulive.neu
|
||||||
|
tar xzf /tmp/abdulive.tgz -C /opt/abdulive.neu --no-same-owner
|
||||||
|
rm -f /tmp/abdulive.tgz
|
||||||
|
rm -rf /opt/abdulive.alt
|
||||||
|
[ -d /opt/abdulive ] && mv /opt/abdulive /opt/abdulive.alt || true
|
||||||
|
mv /opt/abdulive.neu /opt/abdulive
|
||||||
|
systemctl restart abdulive-hub 2>/dev/null || true
|
||||||
|
'"
|
||||||
|
|
||||||
|
echo "Fertig. Stand ${STAND}."
|
||||||
|
ssh "root@${PVE_HOST}" "pct exec ${CT} -- systemctl is-active abdulive-hub" || true
|
||||||
Reference in new issue
Block a user