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>
187 lines
8.4 KiB
TypeScript
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;
|