Discord: Knopf fuer die Live-Ping-Rolle statt Onboarding

Live-Ping nuetzt nur, wenn man sie sich selbst geben kann. Discords
Onboarding waere der vorgesehene Weg, laesst sich hier aber nicht
einschalten - gemessen, nicht vermutet:

  HTTP 400  Cannot enable onboarding, requirements are not met  (350000)
  sichtbar fuer @everyone: 5   davon beschreibbar: 3
  Discord verlangt:        7   davon beschreibbar: 5

Zwei Kanaele anzulegen, damit eine Schwelle passt, macht den Server leerer
statt besser. Stattdessen ein Knopf unter dem Willkommenstext: ein Druck
holt die Rolle, der naechste gibt sie ab, die Antwort sieht nur, wer
geklickt hat.

Der Knopf braucht eine dauerhafte Verbindung, weil Discord Klicks ans
Gateway oder an eine oeffentliche HTTPS-Adresse liefert und der Stream-PC
keine hat:

- gateway.ts   WebSocket mit Herzschlag, Fortsetzen und Wiederverbinden.
               Bei 4004 und Verwandten wird aufgegeben statt im Kreis zu
               laufen - dagegen hilft nur eine Aenderung an der App.
               intents: 0, der Bot sieht weder Nachrichten noch Mitglieder.
- selfroles.ts Knopfleiste und Rollenwechsel.
- bot.ts       beides zusammen, laeuft im Ingest-Daemon mit. Ohne
               hinterlegtes Token entfaellt er wortlos.

Neuer Befehl `abdulive discord:bot` zum Ausprobieren; im Normalbetrieb
haengt der Bot im Daemon und meldet sich als discordConnected auf der
Statusseite.

Die Knopfleiste gehoert zum Soll-Zustand des Willkommenstextes: fehlt sie,
wird sie nachgereicht, ohne die Nachricht neu zu posten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-15 21:59:08 +02:00
1 parent 3b744caf6a
commit 994ce4faf0
12 files changed
+787 -11

No files matched your search

+23
View File
@@ -646,6 +646,27 @@ Was jetzt noch von Hand gehoert:
verbinden, dann bekommen Subs automatisch eine Rolle.`);
}
async function discordBot(): Promise<void> {
const { createBot } = await import('#discord/bot.ts');
const bot = createBot();
if (!bot) {
throw new Error('Kein Discord-Bot hinterlegt. Erst einrichten: abdulive discord:setup');
}
const abort = new AbortController();
const stop = (sig: string): void => {
console.log(`\n${sig} empfangen, trenne die Verbindung.`);
abort.abort();
setTimeout(() => process.exit(0), 300);
};
process.on('SIGINT', () => stop('SIGINT'));
process.on('SIGTERM', () => stop('SIGTERM'));
console.log('Discord-Bot laeuft. Beenden mit Strg+C.');
console.log(`${C.dim}Im Normalbetrieb laeuft er im Ingest-Daemon mit; dieser Befehl ` +
`ist zum Ausprobieren.${C.off}`);
await bot.run(abort.signal);
}
// ---------------------------------------------------------------- help
function help(): void {
@@ -662,6 +683,7 @@ abdulive - Werkzeugkasten fuer Abdus Stream
render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern)
discord:setup [--server ID] [--vorschau]
Discord-Server nach docs/projects/discord-community.md einrichten
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
help Diese Uebersicht
Einrichtung nach dem Start: http://localhost:4792/setup
@@ -693,6 +715,7 @@ try {
}
case 'render': await renderOne(args[0], args[1]); break;
case 'discord:setup': await discordSetup(args); break;
case 'discord:bot': await discordBot(); break;
case 'help': case undefined: help(); break;
default:
console.error(`Unbekannter Befehl "${cmd}".`);
+6
View File
@@ -10,6 +10,7 @@ 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 { createObs } from '#obs/client.ts';
import { isWindows } from '#core/platform.ts';
import { helix } from '#twitch/helix.ts';
@@ -116,6 +117,7 @@ export async function runIngest(): Promise<void> {
health: () => ({
...state,
db: repo.stats(),
discordConnected: bot ? bot.isConnected() : false,
spikeArmed: spikes.isArmed(),
clipsWaitingForFile: clipper.pendingCount(),
renderQueue: renderer.pending(),
@@ -170,6 +172,10 @@ export async function runIngest(): Promise<void> {
void irc.run(abort.signal);
void obs.run(abort.signal);
// ---- Discord (Rollen-Knoepfe). Fehlt die Einrichtung, entfaellt er.
const bot = createBot();
if (bot) void bot.run(abort.signal);
// ---- Zuschauerzahlen und Stream-Sessions
void pollStream(cfg, repo, state, abort.signal);
+43 -6
View File
@@ -80,6 +80,41 @@ ein stummgeschalteter Server ist ein toter Server. Angepingt wird nur, wer sich
die Rolle selbst geholt hat. Das sind weniger Leute, aber es sind die, die
kommen.
### Der Knopf fuer Live-Ping
`Live-Ping` nuetzt nur, wenn man sie sich selbst geben kann. Discord bietet
dafuer **Onboarding** an, aber das laesst sich hier nicht einschalten:
```
HTTP 400 Cannot enable onboarding, requirements are not met (350000)
sichtbar fuer @everyone: 5 davon beschreibbar: 3
Discord verlangt: 7 davon beschreibbar: 5
```
Zwei Kanaele anzulegen, damit eine Schwelle passt, macht den Server leerer
statt besser - genau das, wogegen der ganze Entwurf gebaut ist. Also
stattdessen ein **Knopf unter dem Willkommenstext**. Ein Druck holt die Rolle,
der naechste gibt sie wieder ab, und die Antwort sieht nur, wer geklickt hat.
Der Knopf braucht einen laufenden Bot: Discord liefert Klicks entweder ans
Gateway oder an eine oeffentlich erreichbare HTTPS-Adresse, und der Stream-PC
hat keine. Die Gateway-Verbindung haengt deshalb im Ingest-Daemon mit drin
([`gateway.ts`](../../packages/discord/src/gateway.ts)) und meldet sich als
`discordConnected` auf der Statusseite. Zum Ausprobieren einzeln:
```powershell
abdulive discord:bot
```
Die Verbindung fordert **keine** Absichten an (`intents: 0`). Interaktionen
kommen trotzdem an, aber Nachrichteninhalte und Mitgliederlisten sieht der Bot
nicht - was man nicht anfordert, kann man auch nicht versehentlich mitlesen.
Welche Knoepfe es gibt, steht in `SELF_ROLES` in `plan.ts`. Die `custom_id`
steht dauerhaft in der Nachricht und darf sich nie aendern, sonst tun alte
Knoepfe nichts mehr.
**`Stammgast`** hat fast keine zusaetzlichen Rechte, und das ist Absicht. Der
Wert liegt darin, dass der Name farbig ist und in der Mitgliederliste oben
steht. Bei dieser Kanalgroesse ist sichtbare Zugehoerigkeit der wirksamste
@@ -159,7 +194,7 @@ und nie geloggt - Broadcaster, Recap und Clipper lesen sie von dort.
### 3. Von Hand: der Rest
Vier Dinge kann die API nicht oder nicht sinnvoll:
Drei Dinge kann die API nicht oder nicht sinnvoll:
1. **Dir selbst `Abdu` geben** und die Bot-Rolle in den Servereinstellungen
ueber `Abdu` ziehen. Eine Rolle kann nur Rollen unterhalb der eigenen
@@ -167,15 +202,15 @@ Vier Dinge kann die API nicht oder nicht sinnvoll:
Einladen ganz unten ein, dort ist also kein Platz fuer vier Rollen darunter.
Bis das erledigt ist, meldet das Skript die Rangfolge als uebersprungen und
nennt den Grund.
2. **Onboarding einrichten** (Servereinstellungen > Onboarding) und `Live-Ping`
als waehlbare Rolle anbieten. Das ist der Weg, auf dem sich die Leute die
Rolle selbst holen - ohne ihn bringt sie nichts.
3. **Einladungslink** erzeugen, ohne Ablaufdatum, und ins Twitch-Panel, in die
2. **Einladungslink** erzeugen, ohne Ablaufdatum, und ins Twitch-Panel, in die
Kanalinfo und in einen Chat-Befehl eintragen. Ein Server, den im Stream
niemand erwaehnt, bleibt leer.
4. **Twitch-Konto verbinden** (Servereinstellungen > Integrationen). Danach
3. **Twitch-Konto verbinden** (Servereinstellungen > Integrationen). Danach
bekommen Subs automatisch eine Rolle.
Onboarding steht bewusst nicht in der Liste - warum, steht oben unter
[Der Knopf fuer Live-Ping](#der-knopf-fuer-live-ping).
## Was das Skript nicht tut
**Es loescht nichts.** Keine Kanaele, keine Rollen, keine Nachrichten. Ein
@@ -243,3 +278,5 @@ Umgekehrt gilt genauso: ein Kanal, in dem vier Wochen nichts steht, kommt weg.
schreiben, aber einen Thread eroeffnen.
- Die Webhook-URLs stehen im verschluesselten Speicher und tauchen in keiner
Ausgabe und keinem Log auf.
- Der Knopf unter dem Willkommenstext vergibt `Live-Ping` und nimmt sie beim
zweiten Druck wieder weg; die Antwort sieht nur, wer geklickt hat.
+12
View File
@@ -9,6 +9,12 @@ Community-Servers.
| `src/api.ts` | REST-Client: Warteschlange, Rate Limits, lesbare Fehler |
| `src/plan.ts` | **Der Bauplan**: Rollen, Kanaele, Rechte, Servereinstellungen |
| `src/apply.ts` | Traegt den Bauplan auf einen bestehenden Server auf |
| `src/gateway.ts` | WebSocket-Verbindung fuer Klicks auf Knoepfe |
| `src/selfroles.ts` | Der Knopf, der `Live-Ping` gibt und wieder nimmt |
| `src/bot.ts` | Beides zusammen; laeuft im Ingest-Daemon mit |
Einrichten geschieht einmalig ueber `apply.ts`, der Betrieb laeuft ueber
`bot.ts`. Beide lesen denselben Bauplan.
Aufruf ueber die CLI:
@@ -17,6 +23,12 @@ abdulive discord:setup --server <ID> --vorschau # zeigt nur, was passieren wue
abdulive discord:setup --server <ID> # wendet es an
```
Und der laufende Teil, der die Knoepfe bedient:
```powershell
abdulive discord:bot # zum Ausprobieren; sonst im Ingest-Daemon
```
Wiederholbar: gefunden wird ueber den Namen, danach wird angeglichen statt neu
angelegt. Geloescht wird nie.
+11
View File
@@ -134,6 +134,11 @@ export class Discord {
get<T>(path: string): Promise<T> { return this.request<T>('GET', path); }
post<T>(path: string, body: unknown, reason?: string): Promise<T> { return this.request<T>('POST', path, body, reason); }
patch<T>(path: string, body: unknown, reason?: string): Promise<T> { return this.request<T>('PATCH', path, body, reason); }
put<T>(path: string, body?: unknown, reason?: string): Promise<T> { return this.request<T>('PUT', path, body, reason); }
del<T>(path: string, reason?: string): Promise<T> { return this.request<T>('DELETE', path, undefined, reason); }
/** Fuer die Gateway-Verbindung; verlaesst diese Klasse sonst nie. */
get botToken(): string { return this.token; }
}
/**
@@ -146,6 +151,12 @@ export type Rest = {
patch<T>(path: string, body: unknown, reason?: string): Promise<T>;
};
/** Zusaetzlich das, was der Bot im Betrieb braucht: Rollen setzen und loesen. */
export type RestFull = Rest & {
put<T>(path: string, body?: unknown, reason?: string): Promise<T>;
del<T>(path: string, reason?: string): Promise<T>;
};
// ---------------------------------------------------------------- Typen
export type ApiRole = { id: string; name: string; permissions: string; position: number; color: number; hoist: boolean; mentionable: boolean; managed: boolean };
+31 -2
View File
@@ -19,7 +19,7 @@ class FakeDiscord implements Rest {
channels: ApiChannel[] = [];
webhooks: ApiWebhook[] = [];
automod: Array<{ id: string; name: string; actions?: unknown[] }> = [];
messages = new Map<string, Array<{ id: string; content: string; author: { id: string } }>>();
messages = new Map<string, Array<{ id: string; content: string; author: { id: string }; components?: unknown[] }>>();
calls: string[] = [];
private next = 1000;
@@ -97,6 +97,7 @@ class FakeDiscord implements Rest {
id: this.id(),
content: String((b as Record<string, unknown>)['content'] ?? ''),
author: { id: this.botUserId },
components: ((b as Record<string, unknown>)['components'] ?? []) as unknown[],
};
const list = this.messages.get(msg[1]!) ?? [];
list.push(made);
@@ -133,7 +134,11 @@ class FakeDiscord implements Rest {
const msg = /^\/channels\/(\d+)\/messages\/(\d+)$/.exec(path);
if (msg) {
const m = (this.messages.get(msg[1]!) ?? []).find((x) => x.id === msg[2]);
if (m) m.content = String((body as Record<string, unknown>)['content'] ?? '');
if (m) {
const src = body as Record<string, unknown>;
m.content = String(src['content'] ?? '');
m.components = (src['components'] ?? []) as unknown[];
}
return structuredClone(m) as T;
}
const ch = /^\/channels\/(\d+)$/.exec(path);
@@ -448,3 +453,27 @@ test('der Meldekanal einer bestehenden Regel bleibt, wie er gesetzt wurde', asyn
const danach = (api.automod[0]!.actions as typeof actions).find((a) => a.type === 2);
assert.equal(danach?.metadata['channel_id'], '424242', 'die eigene Wahl darf nicht ueberschrieben werden');
});
test('der Willkommenstext bekommt die Knopfleiste', async () => {
const api = new FakeDiscord();
await run(api);
const post = api.messages.get(live(api, 'willkommen')!.id)![0]!;
const reihen = post.components as Array<{ type: number; components: Array<{ custom_id: string }> }>;
assert.equal(reihen.length, 1);
assert.equal(reihen[0]!.components[0]!.custom_id, 'rolle:live-ping');
});
test('eine Nachricht ohne Knopfleiste bekommt sie nachgereicht', async () => {
// So sieht der Willkommenstext aus, der vor den Knoepfen gepostet wurde.
const api = new FakeDiscord();
await run(api);
const kanal = live(api, 'willkommen')!.id;
api.messages.get(kanal)![0]!.components = [];
const res = await run(api);
const step = res.steps.find((s) => s.name === 'Willkommenstext');
assert.equal(step?.change, 'angepasst');
assert.equal((api.messages.get(kanal)![0]!.components as unknown[]).length, 1);
assert.equal(api.messages.get(kanal)!.length, 1, 'nicht neu posten');
});
+8 -2
View File
@@ -15,6 +15,7 @@ import {
type ApiWebhook, type Rest,
} from './api.ts';
import { str } from './permissions.ts';
import { selfRoleComponents } from './selfroles.ts';
import {
AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, GUILD_SETTINGS, ROLES, WELCOME,
type CategoryPlan, type ChannelPlan, type OverwritePlan, type WebhookSlot,
@@ -424,17 +425,22 @@ async function applyWelcome(
const id = mentions.get(key);
return id ? `<#${id}>` : all;
});
const components = selfRoleComponents();
const payload = {
content: body,
components,
allowed_mentions: { parse: [] }, // der Begruessungstext pingt niemanden
};
type Msg = { id: string; content: string; author?: { id: string } };
type Msg = { id: string; content: string; author?: { id: string }; components?: unknown[] };
const seen = await api.get<Msg[]>(`/channels/${channel.id}/messages?limit=50`);
const own = seen.find((m) => m.author?.id === botUserId);
if (own) {
if (own.content === body) {
// Auch die Knopfleiste gehoert zum Soll-Zustand: fehlt sie, weil die
// Nachricht aus einer Zeit vor den Knoepfen stammt, wird sie nachgereicht.
const knoepfe = (own.components ?? []).length;
if (own.content === body && knoepfe === components.length) {
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'unveraendert' });
return;
}
+44
View File
@@ -0,0 +1,44 @@
// Der laufende Teil der Discord-Anbindung: haelt die Gateway-Verbindung und
// beantwortet Klicks auf die Rollen-Knoepfe.
//
// Laeuft im Ingest-Daemon mit. Ohne hinterlegtes Bot-Token passiert nichts -
// das ist kein Fehler, sondern der Zustand vor der Einrichtung.
import { logger } from '#core/logger.ts';
import { readSecrets } from '#twitch/secrets.ts';
import { Discord } from './api.ts';
import { createGateway } from './gateway.ts';
import { createRoleButtons } from './selfroles.ts';
const log = logger('discord:bot');
export type Bot = {
run: (signal: AbortSignal) => Promise<void>;
isConnected: () => boolean;
};
/**
* Gibt null zurueck, wenn Token oder Server-ID fehlen. Der Aufrufer soll dann
* einfach ohne Discord weiterlaufen.
*/
export function createBot(): Bot | null {
const cfg = readSecrets().discord;
if (!cfg?.botToken || !cfg.guildId) {
log.info('Kein Discord-Bot hinterlegt - Rollen-Knoepfe bleiben aus. ' +
'Einrichten mit: abdulive discord:setup');
return null;
}
const api = new Discord(cfg.botToken);
const buttons = createRoleButtons(api, cfg.guildId);
const gateway = createGateway(
api.botToken,
(path) => api.get<{ url: string }>(path),
{
onInteraction: (i) => buttons.handle(i),
onReady: ({ user }) => log.info(`Rollen-Knoepfe sind scharf (${user.username})`),
},
);
return { run: (signal) => gateway.run(signal), isConnected: () => gateway.isConnected() };
}
+281
View File
@@ -0,0 +1,281 @@
// Discord-Gateway ueber WebSocket. Nur so viel, wie ein Knopf braucht:
// verbinden, am Leben halten, Interaktionen durchreichen.
//
// Warum ueberhaupt eine dauerhafte Verbindung: Discord liefert Klicks auf
// einen Knopf entweder ans Gateway oder an eine oeffentlich erreichbare
// HTTPS-Adresse. Der Stream-PC hat keine, also Gateway. Die Verbindung haengt
// im Ingest-Daemon mit drin und kostet dort praktisch nichts.
//
// Absichten (Intents) stehen auf 0: Interaktionen kommen immer an, ohne
// Berechtigung fuer Nachrichteninhalte oder Mitgliederlisten. Was wir nicht
// anfordern, koennen wir auch nicht versehentlich mitlesen.
import { logger } from '#core/logger.ts';
import { forever } from '#core/retry.ts';
const log = logger('discord:gateway');
// Betriebsarten des Gateways
const OP = {
DISPATCH: 0,
HEARTBEAT: 1,
IDENTIFY: 2,
RESUME: 6,
RECONNECT: 7,
INVALID_SESSION: 9,
HELLO: 10,
HEARTBEAT_ACK: 11,
} as const;
/**
* Schliessgruende, bei denen ein neuer Versuch nichts bringt: falsches Token,
* falsche Absichten, falsche Fassung. Dagegen hilft nur eine Aenderung an der
* App, und bis dahin waere jeder Wiederverbindungsversuch nur Laerm.
*/
const FATAL_CLOSE = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
export type Payload = { op: number; d?: unknown; s?: number | null; t?: string | null };
/** Nur die Felder einer Interaktion, die hier gebraucht werden. */
export type Interaction = {
id: string;
token: string;
type: number;
application_id: string;
guild_id?: string;
channel_id?: string;
data?: { custom_id?: string; component_type?: number };
member?: { user: { id: string; username: string }; roles: string[] };
user?: { id: string; username: string };
};
export type GatewayHandlers = {
onReady?: (info: { user: { id: string; username: string }; sessionId: string }) => void;
onInteraction?: (interaction: Interaction) => Promise<void> | void;
onClosed?: (code: number, reason: string) => void;
};
export type GatewayClient = {
/** Haelt die Verbindung; kehrt erst bei `signal.abort` oder einem harten Fehler zurueck. */
run: (signal: AbortSignal) => Promise<void>;
isConnected: () => boolean;
};
/** Was ueber eine Trennung hinweg erhalten bleibt, damit fortgesetzt statt neu begonnen wird. */
type Session = { id: string; resumeUrl: string; seq: number | null };
export function createGateway(
token: string,
restUrl: (path: string) => Promise<{ url: string }>,
handlers: GatewayHandlers,
): GatewayClient {
let session: Session | null = null;
let connected = false;
return {
isConnected: () => connected,
run: async (outer) => {
// Bei einem harten Fehler brechen wir die Schleife selbst ab, statt
// Discord alle paar Sekunden erneut mit demselben Token zu behelligen.
const own = new AbortController();
const signal = AbortSignal.any([outer, own.signal]);
await forever(
async (sig) => {
try {
await connectOnce(token, restUrl, handlers, sig, {
get: () => session,
set: (s) => { session = s; },
setConnected: (v) => { connected = v; },
giveUp: () => own.abort(),
});
} finally {
connected = false;
}
},
{
signal,
baseMs: 2000,
maxMs: 120_000,
onWait: (ms, n) => {
if (own.signal.aborted) return;
log.warn(`Gateway getrennt, neuer Versuch in ${Math.round(ms / 1000)} s (${n}.)`);
},
},
);
},
};
function connectOnce(
botToken: string,
getGateway: (path: string) => Promise<{ url: string }>,
h: GatewayHandlers,
signal: AbortSignal,
store: {
get: () => Session | null;
set: (s: Session | null) => void;
setConnected: (v: boolean) => void;
/** Bricht die Wiederverbindungsschleife ab - nur bei harten Fehlern. */
giveUp: () => void;
},
): Promise<void> {
return new Promise<void>((_resolve, reject) => {
let ws: WebSocket | null = null;
let beat: ReturnType<typeof setInterval> | null = null;
let firstBeat: ReturnType<typeof setTimeout> | null = null;
let acked = true;
let seq: number | null = store.get()?.seq ?? null;
let done = false;
const finish = (why: string): void => {
if (done) return;
done = true;
if (beat) clearInterval(beat);
if (firstBeat) clearTimeout(firstBeat);
store.setConnected(false);
try { ws?.close(); } catch { /* schon zu */ }
reject(new Error(why));
};
signal.addEventListener('abort', () => finish('abgebrochen'), { once: true });
const send = (payload: Payload): void => {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload));
};
const heartbeat = (): void => {
if (!acked) {
// Discord hat den letzten Schlag nicht bestaetigt. Die Verbindung
// sieht offen aus, ist es aber nicht mehr - abreissen und neu.
log.warn('Kein Herzschlag bestaetigt, Verbindung gilt als tot');
finish('Herzschlag ohne Antwort');
return;
}
acked = false;
send({ op: OP.HEARTBEAT, d: seq });
};
void (async () => {
let url: string;
const resume = store.get();
try {
url = resume?.resumeUrl ?? (await getGateway('/gateway/bot')).url;
} catch (err) {
finish(`Gateway-Adresse nicht abrufbar: ${(err as Error).message}`);
return;
}
if (done) return;
ws = new WebSocket(`${url}?v=10&encoding=json`);
ws.addEventListener('close', (ev) => {
const code = (ev as CloseEvent).code;
const reason = (ev as CloseEvent).reason || '';
h.onClosed?.(code, reason);
if (FATAL_CLOSE.has(code)) {
// Nicht fortsetzbar und nicht behebbar: Token oder Absichten.
log.error(`Gateway hat endgueltig geschlossen (${code} ${reason}). ` +
'Bot-Token pruefen und den Dienst neu starten.');
store.set(null);
store.giveUp();
} else if (code === 4007 || code === 4009 || code === 1000 || code === 1001) {
// Sitzung ist hin, aber ein neuer Anlauf lohnt.
store.set(null);
}
finish(`geschlossen (${code})`);
});
ws.addEventListener('error', () => finish('Verbindungsfehler'));
ws.addEventListener('message', (ev) => {
let msg: Payload;
try {
msg = JSON.parse(String((ev as MessageEvent).data)) as Payload;
} catch {
return;
}
if (typeof msg.s === 'number') seq = msg.s;
switch (msg.op) {
case OP.HELLO: {
const interval = (msg.d as { heartbeat_interval: number }).heartbeat_interval;
// Der erste Schlag kommt versetzt, damit nicht alle Verbindungen
// der Welt im selben Moment klopfen - so schreibt es Discord vor.
firstBeat = setTimeout(() => {
heartbeat();
beat = setInterval(heartbeat, interval);
}, Math.floor(interval * Math.random()));
const s = store.get();
if (s) {
send({ op: OP.RESUME, d: { token: botToken, session_id: s.id, seq: s.seq } });
} else {
send({
op: OP.IDENTIFY,
d: {
token: botToken,
intents: 0, // nur Interaktionen, nichts mitlesen
properties: { os: process.platform, browser: 'abdulive-toolkit', device: 'abdulive-toolkit' },
},
});
}
return;
}
case OP.HEARTBEAT:
// Discord darf jederzeit einen Schlag anfordern.
send({ op: OP.HEARTBEAT, d: seq });
return;
case OP.HEARTBEAT_ACK:
acked = true;
return;
case OP.RECONNECT:
log.info('Gateway bittet um Wiederverbindung');
finish('Wiederverbindung angefordert');
return;
case OP.INVALID_SESSION:
// d = true heisst: die Sitzung laesst sich noch fortsetzen.
if (msg.d !== true) store.set(null);
finish('Sitzung ungueltig');
return;
case OP.DISPATCH:
onDispatch(msg);
return;
default:
return;
}
});
function onDispatch(msg: Payload): void {
if (msg.t === 'READY') {
const d = msg.d as {
session_id: string; resume_gateway_url: string;
user: { id: string; username: string };
};
store.set({ id: d.session_id, resumeUrl: d.resume_gateway_url, seq });
store.setConnected(true);
log.info(`Gateway verbunden als ${d.user.username}`);
h.onReady?.({ user: d.user, sessionId: d.session_id });
return;
}
if (msg.t === 'RESUMED') {
store.setConnected(true);
log.info('Gateway-Sitzung fortgesetzt');
return;
}
if (msg.t === 'INTERACTION_CREATE') {
const s = store.get();
if (s) store.set({ ...s, seq });
void Promise.resolve(h.onInteraction?.(msg.d as Interaction))
.catch((err: unknown) => log.error(`Interaktion nicht verarbeitet: ${String(err)}`));
}
}
})();
});
}
}
+36 -1
View File
@@ -275,10 +275,45 @@ Das hier ist der Ort zwischen den Streams. Wer mitliest, gehört dazu.
> {ankuendigungen} - Stream-Start und die Zusammenfassung danach
**Bescheid wissen, wenn der Stream startet**
Hol dir die Rolle **Live-Ping**. Dann wird nur angepingt, wer das auch möchte.
Knopf drücken. Angepingt wird nur, wer das auch möchte, und ein zweiter Druck
nimmt die Rolle wieder weg.
Twitch: <https://twitch.tv/abdulive_>`;
// ---------------------------------------------------------- Selbstbedienung
export type SelfRolePlan = {
/** Landet im Knopf und kommt bei jedem Klick zurueck. Nie aendern. */
customId: string;
/** Name der Rolle aus ROLES. */
role: string;
label: string;
emoji: string;
/** Antwort beim Holen und beim Abgeben - sieht nur, wer geklickt hat. */
onText: string;
offText: string;
};
/**
* Rollen, die man sich per Knopf selbst gibt.
*
* Discords Onboarding kann das auch, verlangt aber sieben Vorgabekanaele mit
* fuenf beschreibbaren. Der Server hat fuenf und drei. Zwei Kanaele anzulegen,
* damit eine Schwelle passt, macht den Server leerer statt besser - also ein
* Knopf unter dem Willkommenstext. Siehe docs/projects/discord-community.md.
*/
export const SELF_ROLES: SelfRolePlan[] = [
{
customId: 'rolle:live-ping',
role: 'Live-Ping',
label: 'Sag mir Bescheid, wenn der Stream startet',
emoji: '🔴',
onText: 'Passt. Du wirst in den Ankündigungen angepingt, wenn der Stream startet. ' +
'Nochmal drücken nimmt es wieder weg.',
offText: 'Erledigt, du wirst nicht mehr angepingt.',
},
];
// ---------------------------------------------------------------- AutoMod
/**
+182
View File
@@ -0,0 +1,182 @@
// Der Knopf vergibt Rollen an echte Leute. Was hier schiefgeht, merkt man
// daran, dass jemand keinen Ping bekommt oder einen, den er abbestellt hat -
// also spaet und vermittelt. Deshalb hier direkt geprueft.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { RestFull } from './api.ts';
import type { Interaction } from './gateway.ts';
import { SELF_ROLES } from './plan.ts';
import { createRoleButtons, selfRoleComponents } from './selfroles.ts';
const GUILD = '100';
const LIVE_PING = '777';
const USER = '42';
type Call = { method: string; path: string; body?: unknown };
/** Rest-Client im Arbeitsspeicher. Merkt sich, was gerufen wurde. */
function fakeRest(opts: { roles?: Array<{ id: string; name: string }>; failRole?: boolean } = {}) {
const calls: Call[] = [];
const roles = opts.roles ?? [
{ id: GUILD, name: '@everyone' },
{ id: LIVE_PING, name: 'Live-Ping' },
];
const api: RestFull = {
async get<T>(path: string): Promise<T> {
calls.push({ method: 'GET', path });
if (path === `/guilds/${GUILD}/roles`) return roles as T;
throw new Error(`GET ${path} nicht nachgebaut`);
},
async post<T>(path: string, body: unknown): Promise<T> {
calls.push({ method: 'POST', path, body });
return undefined as T;
},
async patch<T>(path: string, body: unknown): Promise<T> {
calls.push({ method: 'PATCH', path, body });
return undefined as T;
},
async put<T>(path: string): Promise<T> {
calls.push({ method: 'PUT', path });
if (opts.failRole) throw new Error('Missing Permissions');
return undefined as T;
},
async del<T>(path: string): Promise<T> {
calls.push({ method: 'DELETE', path });
if (opts.failRole) throw new Error('Missing Permissions');
return undefined as T;
},
};
return { api, calls };
}
const klick = (customId: string, roles: string[] = []): Interaction => ({
id: 'i1',
token: 'tok',
type: 3, // Klick auf eine Komponente
application_id: 'app',
guild_id: GUILD,
data: { custom_id: customId, component_type: 2 },
member: { user: { id: USER, username: 'jemand' }, roles },
});
const antwort = (calls: Call[]): string => {
const c = calls.find((x) => x.path.startsWith('/interactions/'));
return String((c?.body as { data?: { content?: string } })?.data?.content ?? '');
};
// ---------------------------------------------------------------- Knopf
test('die Knopfleiste bildet den Bauplan ab', () => {
const rows = selfRoleComponents();
assert.equal(rows.length, 1, 'eine Reihe');
const buttons = rows[0]!['components'] as Array<Record<string, unknown>>;
assert.equal(buttons.length, SELF_ROLES.length);
assert.equal(buttons[0]!['custom_id'], SELF_ROLES[0]!.customId);
assert.equal(buttons[0]!['type'], 2, 'Typ 2 ist ein Knopf');
});
test('die custom_id traegt keine Nutzerdaten', () => {
// Sie steht dauerhaft in der Nachricht und geht an jeden, der sie sieht.
for (const r of SELF_ROLES) {
assert.match(r.customId, /^[a-z:-]+$/, `${r.customId} sollte ein fester Bezeichner sein`);
assert.ok(r.customId.length <= 100, 'Discord erlaubt hoechstens 100 Zeichen');
}
});
// ---------------------------------------------------------------- Vergeben
test('wer die Rolle nicht hat, bekommt sie', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
const put = calls.find((c) => c.method === 'PUT');
assert.equal(put?.path, `/guilds/${GUILD}/members/${USER}/roles/${LIVE_PING}`);
assert.equal(antwort(calls), SELF_ROLES[0]!.onText);
});
test('wer sie hat, wird sie los - derselbe Knopf', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping', [LIVE_PING]));
const del = calls.find((c) => c.method === 'DELETE');
assert.equal(del?.path, `/guilds/${GUILD}/members/${USER}/roles/${LIVE_PING}`);
assert.equal(calls.some((c) => c.method === 'PUT'), false, 'nicht gleichzeitig setzen');
assert.equal(antwort(calls), SELF_ROLES[0]!.offText);
});
test('die Antwort sieht nur, wer geklickt hat', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
const c = calls.find((x) => x.path.startsWith('/interactions/'))?.body as
{ type: number; data: { flags: number } };
assert.equal(c.type, 4, 'Antwort mit Text');
assert.equal(c.data.flags, 64, 'ohne flags 64 liest der ganze Kanal mit');
});
// ---------------------------------------------------------------- Abwehr
test('fremde Knoepfe werden ignoriert', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('irgendwas-anderes'));
assert.deepEqual(calls, [], 'kein Aufruf, keine Antwort');
});
test('andere Interaktionsarten werden ignoriert', async () => {
const { api, calls } = fakeRest();
const i = { ...klick('rolle:live-ping'), type: 2 }; // Slash-Befehl
await createRoleButtons(api, GUILD).handle(i);
assert.deepEqual(calls, []);
});
test('ein Klick aus der Direktnachricht vergibt keine Rolle', async () => {
const { api, calls } = fakeRest();
const ohneMitglied: Interaction = { ...klick('rolle:live-ping') };
delete ohneMitglied.member;
await createRoleButtons(api, GUILD).handle(ohneMitglied);
assert.equal(calls.some((c) => c.method === 'PUT' || c.method === 'DELETE'), false);
assert.match(antwort(calls), /nur im Server/);
});
test('fehlt die Rolle auf dem Server, wird das gesagt statt still zu scheitern', async () => {
const { api, calls } = fakeRest({ roles: [{ id: GUILD, name: '@everyone' }] });
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
assert.equal(calls.some((c) => c.method === 'PUT'), false);
assert.match(antwort(calls), /Live-Ping/);
assert.match(antwort(calls), /Mod/);
});
test('auf einen Klick kommt auch dann eine Antwort, wenn Discord nein sagt', async () => {
// Ohne Antwort steht bei der Person nur "Interaktion fehlgeschlagen".
const { api, calls } = fakeRest({ failRole: true });
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
assert.match(antwort(calls), /nicht geklappt/);
});
// ---------------------------------------------------------------- Zwischenspeicher
test('die Rollenliste wird nur einmal geholt', async () => {
const { api, calls } = fakeRest();
const buttons = createRoleButtons(api, GUILD);
await buttons.handle(klick('rolle:live-ping'));
await buttons.handle(klick('rolle:live-ping', [LIVE_PING]));
const holt = calls.filter((c) => c.path === `/guilds/${GUILD}/roles`);
assert.equal(holt.length, 1, 'die zweite Anfrage darf aus dem Speicher kommen');
});
test('eine nachtraeglich angelegte Rolle wird beim zweiten Blick gefunden', async () => {
const roles = [{ id: GUILD, name: '@everyone' }];
const { api, calls } = fakeRest({ roles });
const buttons = createRoleButtons(api, GUILD);
await buttons.handle(klick('rolle:live-ping')); // fehlt noch
roles.push({ id: LIVE_PING, name: 'Live-Ping' }); // jemand legt sie an
await buttons.handle(klick('rolle:live-ping'));
assert.equal(calls.some((c) => c.method === 'PUT'), true, 'beim zweiten Mal muss es klappen');
});
+110
View File
@@ -0,0 +1,110 @@
// Rollen, die man sich selbst per Knopf gibt.
//
// Discords eigenes Onboarding kann das auch, verlangt dafuer aber sieben
// Vorgabekanaele, von denen fuenf beschreibbar sind. Der Server hat fuenf und
// drei - nachgemessen, nicht geraten (Fehlercode 350000). Zwei Kanaele nur
// anzulegen, damit eine Schwelle passt, macht den Server schlechter. Also ein
// Knopf unter dem Willkommenstext.
import { logger } from '#core/logger.ts';
import type { ApiRole, RestFull } from './api.ts';
import type { Interaction } from './gateway.ts';
import { SELF_ROLES, type SelfRolePlan } from './plan.ts';
const log = logger('discord:rollen');
/** Interaktionsart: Klick auf eine Komponente. */
const MESSAGE_COMPONENT = 3;
/** Antwortart: sichtbare Antwort, hier zusaetzlich nur fuer den Klickenden. */
const CHANNEL_MESSAGE = 4;
const EPHEMERAL = 64;
/**
* Die Knopfleiste unter dem Willkommenstext.
* Style 2 ist der graue Knopf - er soll einladen, nicht schreien.
*/
export function selfRoleComponents(): Array<Record<string, unknown>> {
if (SELF_ROLES.length === 0) return [];
return [{
type: 1, // Reihe
components: SELF_ROLES.map((r) => ({
type: 2, // Knopf
style: 2,
label: r.label,
custom_id: r.customId,
emoji: { name: r.emoji },
})),
}];
}
export type RoleButtons = {
handle: (interaction: Interaction) => Promise<void>;
};
export function createRoleButtons(api: RestFull, guildId: string): RoleButtons {
// Rollennamen einmal aufloesen und merken. Kommt ein Name nicht vor, wird
// einmal neu geladen - eine Rolle kann nach dem Start angelegt worden sein.
let cache: Map<string, string> | null = null;
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> {
await api.post(`/interactions/${interaction.id}/${interaction.token}/callback`, {
type: CHANNEL_MESSAGE,
data: { content, flags: EPHEMERAL },
});
}
return {
async handle(interaction) {
if (interaction.type !== MESSAGE_COMPONENT) return;
const customId = interaction.data?.custom_id;
const plan: SelfRolePlan | undefined = SELF_ROLES.find((r) => r.customId === customId);
if (!plan) return;
const member = interaction.member;
if (!member) {
// Ausserhalb eines Servers gibt es keine Rollen zu vergeben.
await reply(interaction, 'Das geht nur im Server selbst.');
return;
}
try {
const id = await roleId(plan.role);
if (!id) {
await reply(interaction, `Die Rolle **${plan.role}** gibt es gerade nicht. Sag einem Mod Bescheid.`);
log.error(`Rolle "${plan.role}" nicht gefunden - Bauplan und Server laufen auseinander`);
return;
}
const user = member.user.id;
const path = `/guilds/${guildId}/members/${user}/roles/${id}`;
const hatSie = member.roles.includes(id);
if (hatSie) {
await api.del(path, 'Rolle per Knopf abgegeben');
await reply(interaction, plan.offText);
log.info(`${member.user.username} hat ${plan.role} abgegeben`);
} else {
await api.put(path, undefined, 'Rolle per Knopf geholt');
await reply(interaction, plan.onText);
log.info(`${member.user.username} hat ${plan.role} geholt`);
}
} catch (err) {
log.error(`Rollenwechsel fehlgeschlagen: ${(err as Error).message}`);
// Auf einen Klick gehoert eine Antwort, auch wenn es schiefging -
// sonst steht bei der Person nur "Interaktion fehlgeschlagen".
await reply(interaction, 'Hat nicht geklappt. Versuch es gleich noch einmal.')
.catch(() => undefined);
}
},
};
}