Files
abdulive/packages/discord/src/api.ts
T
ZeasyAlexandClaude Opus 5.5 5494c6d24d Discord: eigener Kanal fuer Beitritte, Spielvorschlaege und Reactions als Forum
Beitrittsmeldungen liefen nach #allgemein, weil Discord ohne Angabe den
ersten Kanal nimmt. Jetzt gehen sie nach #begruessung (SYSTEM_CHANNEL).

Die von Abdu angelegten Textkanaele spielvorschlaege und
reaction-einsendungen sind jetzt Foren im Bauplan: ein Beitrag je Spiel
bzw. Clip, 👍 als Standard-Reaktion, Stand-Tags nur fuer Mods. apply.ts
kann dafuer Tags und Standard-Reaktion anlegen und nachziehen; fremde
Tags bleiben mit ihrer ID stehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:52:02 +02:00

187 lines
8.4 KiB
TypeScript

// 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<unknown> = 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<T>(fn: () => Promise<T>): Promise<T> {
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<T>(method: string, path: string, body?: unknown, reason?: string): Promise<T> {
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<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; }
}
/**
* 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<T>(path: string): Promise<T>;
post<T>(path: string, body: unknown, reason?: string): Promise<T>;
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 };
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;