Discord-Community-Server: Bauplan und Einrichtungsbefehl
Der Server ist kein Tool aus der Neunerliste, aber Broadcaster, Recap und Clipper posten dorthin. Deshalb steht die Struktur als Code im Repo und nicht nur in einer Oberflaeche. packages/discord: - permissions.ts Rechtebits als BigInt, das Feld ist 64 Bit breit - api.ts REST-Client: Warteschlange, Rate Limits, lesbare Fehler - plan.ts Rollen, Kanaele, Rechte, AutoMod, Willkommenstext - apply.ts traegt den Bauplan auf einen bestehenden Server auf Neuer Befehl `abdulive discord:setup [--server ID] [--vorschau]`. Zugeschnitten auf 5 bis 30 gleichzeitige Zuschauer: fuenf sichtbare Kanaele statt zwanzig, und eine selbst vergebene Rolle Live-Ping statt eines Rundrufs an alle bei jedem Stream. Wiederholbar statt einmalig: gefunden wird ueber Namen und frueheren Namen, danach wird angeglichen. Der zweite Lauf aendert nichts. Geloescht wird nie, und von Hand gesetzte Rechte-Ausnahmen bleiben stehen. Drei Dinge, die am echten Server nachgemessen und nicht angenommen wurden: - Discord schreibt Textkanaele klein und macht aus Leerzeichen Bindestriche; Emoji, Umlaute und U+2502 bleiben. normalizeName() bildet das ab, sonst geht bei jedem Lauf dieselbe Umbenennung erneut raus. - Kanalnamen lassen sich nur zweimal je zehn Minuten aendern, danach verlangt Discord bis zu neun Minuten Pause. Der Lauf wartet hoechstens eine Minute und meldet den Rest. - Eine Rolle kann nur Rollen unterhalb der eigenen verschieben, auch mit Administrator. rolePositions() rechnet den Platz unter der Bot-Rolle aus, statt blind 3, 2, 1 zu verlangen. Zugangsdaten laufen ueber den verschluesselten Speicher (ADR-0005): der Bot-Token wird verdeckt abgefragt, die drei Webhook-URLs werden nie ausgegeben und nie geloggt. Im Quelltext bleibt es bei ae/oe/ue, alles was ein Mitglied auf Discord liest steht in richtigem Deutsch. Ein Test haelt das fest. Begruendung, Fallstricke und die Schritte von Hand: docs/projects/discord-community.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d231e1c722
commit
3b744caf6a
13 files changed
+2210
-1
No files matched your search
@@ -0,0 +1,159 @@
|
||||
// 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); }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 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 ApiChannel = { id: string; name: string; type: number; position: number; parent_id: string | null; topic?: string | null; permission_overwrites?: ApiOverwrite[] };
|
||||
export type ApiGuild = { id: string; name: string; owner_id: string; roles: ApiRole[]; features: string[] };
|
||||
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;
|
||||
Reference in new issue
Block a user