// Discord-REST-Client. Nur das, was fuers Einrichten und fuers Posten // gebraucht wird - kein Gateway, keine Bibliothek. // // Discord drosselt pro Route mit eigenen Eimern. Wir schicken die Anfragen // nacheinander und werten `X-RateLimit-Remaining` aus: sinkt es auf 0, warten // wir `X-RateLimit-Reset-After` ab, bevor die naechste Anfrage rausgeht. Das // ist bei ~25 Anfragen pro Lauf reichlich und spart uns eine Eimerverwaltung. import { logger } from '#core/logger.ts'; import { retry, sleep } from '#core/retry.ts'; const log = logger('discord'); const BASE = 'https://discord.com/api/v10'; /** Laenger warten wir bei einem 429 nicht - dann wird der Schritt gemeldet. */ const MAX_WAIT_MS = 60_000; export type RestError = Error & { status: number; code: number }; function restError(status: number, code: number, message: string): RestError { const err = new Error(message) as RestError; err.status = status; err.code = code; return err; } /** * Fehlertexte von Discord sind verschachtelt: * `{ code: 50013, message: "Missing Permissions", errors: { name: { _errors: [...] } } }` * Wir ziehen die tiefen Feldfehler raus, sonst steht da nur "Invalid Form Body". */ function describe(body: unknown): { code: number; message: string } { if (!body || typeof body !== 'object') return { code: 0, message: '' }; const b = body as { code?: number; message?: string; errors?: unknown }; const parts: string[] = []; const walk = (node: unknown, path: string): void => { if (!node || typeof node !== 'object') return; const n = node as { _errors?: Array<{ message?: string }> }; if (Array.isArray(n._errors)) { for (const e of n._errors) parts.push(`${path}: ${e.message ?? ''}`.trim()); return; } for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k); }; walk(b.errors, ''); const detail = parts.length ? ` (${parts.join('; ')})` : ''; return { code: b.code ?? 0, message: `${b.message ?? 'Unbekannter Fehler'}${detail}` }; } export class Discord { private token: string; private queue: Promise = Promise.resolve(); /** Zeitpunkt, ab dem wieder gesendet werden darf (ms seit Epoche). */ private notBefore = 0; constructor(botToken: string) { if (!botToken) throw new Error('Kein Discord-Bot-Token angegeben.'); this.token = botToken; } /** Reiht die Anfrage ein - es laeuft immer nur eine gleichzeitig. */ private run(fn: () => Promise): Promise { const next = this.queue.then(fn, fn); // Fehler duerfen die Kette nicht vergiften, der Aufrufer bekommt sie. this.queue = next.catch(() => undefined); return next; } async request(method: string, path: string, body?: unknown, reason?: string): Promise { return this.run(() => retry(async () => { const wait = this.notBefore - Date.now(); if (wait > 0) await sleep(wait); const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bot ${this.token}`, 'User-Agent': 'abdulive-toolkit (https://git.zeasy.dev/ZeasyAlex/abdulive, 0.1.0)', ...(body !== undefined ? { 'content-type': 'application/json' } : {}), // Landet im Audit-Log des Servers - so ist spaeter nachvollziehbar, // woher eine Rolle oder ein Kanal kam. ...(reason ? { 'X-Audit-Log-Reason': encodeURIComponent(reason) } : {}), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); const remaining = Number(res.headers.get('x-ratelimit-remaining') ?? '1'); const resetAfter = Number(res.headers.get('x-ratelimit-reset-after') ?? '0'); if (remaining <= 0 && resetAfter > 0) this.notBefore = Date.now() + resetAfter * 1000 + 50; if (res.status === 429) { const info = await res.json().catch(() => ({})) as { retry_after?: number; global?: boolean }; const retryAfter = Math.max(1, (info.retry_after ?? 1)) * 1000 + 100; // Umbenennen eines Kanals ist auf zwei je zehn Minuten begrenzt. Dort // verlangt Discord schon mal neun Minuten Pause - so lange darf ein // Einrichtungslauf nicht stehen. Lieber melden und weitermachen. if (retryAfter > MAX_WAIT_MS) { const err = restError(429, 0, `${method} ${path}: Discord verlangt ${Math.round(retryAfter / 1000)} s Pause ` + '(bei Kanalnamen sind nur zwei Aenderungen je zehn Minuten erlaubt) - spaeter erneut ausfuehren'); throw Object.assign(err, { final: true }); } this.notBefore = Date.now() + retryAfter; log.warn(`Rate Limit${info.global ? ' (global)' : ''} auf ${method} ${path}, warte ${Math.round(retryAfter)} ms`); throw new Error('Rate Limit erreicht'); } if (res.status === 204) return undefined as T; const text = await res.text(); const parsed: unknown = text ? JSON.parse(text) : null; if (!res.ok) { const { code, message } = describe(parsed); // 401/403/404 sind Einrichtungsfehler und werden durch Wiederholen // nicht besser - sofort durchreichen. const err = restError(res.status, code, `${method} ${path}: HTTP ${res.status} ${message}`); if (res.status === 401 || res.status === 403 || res.status === 404) throw Object.assign(err, { final: true }); throw err; } return parsed as T; }, { attempts: 4, // `retry` bietet keinen Abbruch, aber `onRetry` laeuft innerhalb des // catch-Blocks: wirft es, verlaesst der Fehler die Schleife sofort. // So werden Einrichtungsfehler nicht viermal wiederholt. onRetry: (err, n, ms) => { if ((err as { final?: boolean }).final) throw err; log.warn(`Wiederhole ${method} ${path} (${n}) in ${ms} ms`, { err: String(err) }); }, })); } get(path: string): Promise { return this.request('GET', path); } post(path: string, body: unknown, reason?: string): Promise { return this.request('POST', path, body, reason); } patch(path: string, body: unknown, reason?: string): Promise { return this.request('PATCH', path, body, reason); } put(path: string, body?: unknown, reason?: string): Promise { return this.request('PUT', path, body, reason); } del(path: string, reason?: string): Promise { return this.request('DELETE', path, undefined, reason); } /** Fuer die Gateway-Verbindung; verlaesst diese Klasse sonst nie. */ get botToken(): string { return this.token; } } /** * Was apply.ts vom Client braucht. Als Typ statt als Klasse, damit die Tests * einen nachgebauten Server unterschieben koennen, ohne ins Netz zu gehen. */ export type Rest = { get(path: string): Promise; post(path: string, body: unknown, reason?: string): Promise; patch(path: string, body: unknown, reason?: string): Promise; }; /** Zusaetzlich das, was der Bot im Betrieb braucht: Rollen setzen und loesen. */ export type RestFull = Rest & { put(path: string, body?: unknown, reason?: string): Promise; del(path: string, reason?: string): Promise; }; // ---------------------------------------------------------------- Typen 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 ApiTag = { id?: string; name: string; emoji_id?: string | null; emoji_name: string | null; moderated: boolean }; export type ApiChannel = { id: string; name: string; type: number; position: number; parent_id: string | null; topic?: string | null; permission_overwrites?: ApiOverwrite[]; // Nur Foren. available_tags?: ApiTag[]; default_reaction_emoji?: { emoji_id: string | null; emoji_name: string | null } | null; }; 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; system_channel_id?: string | null; }; export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string }; export type ApiUser = { id: string; username: string; bot?: boolean }; /** Kanaltypen, die hier vorkommen. */ export const CH = { TEXT: 0, VOICE: 2, CATEGORY: 4, FORUM: 15 } as const;