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:
1 parent
3b744caf6a
commit
994ce4faf0
12 files changed
+787
-11
No files matched your search
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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)}`));
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user