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:
ZeasyAlexandClaude Opus 5 committed 2026-09-15 20:43:21 +02:00
1 parent d231e1c722
commit 3b744caf6a
13 files changed
+2210 -1

No files matched your search

+521
View File
@@ -0,0 +1,521 @@
// Traegt den Bauplan aus plan.ts auf einen bestehenden Server auf.
//
// Wiederholbar: gefunden wird ueber Name und frueheren Namen, danach wird
// angepasst statt neu angelegt. Zweimal laufen lassen aendert nichts. Was die
// Hand am Server zusaetzlich eingerichtet hat - eigene Kanaele, eigene
// Rechte-Ausnahmen - bleibt stehen; wir setzen nur, was im Plan steht.
//
// Was hier NICHT passiert: loeschen. Ein Einrichtungsskript, das Kanaele
// wegraeumt, ist ein Einrichtungsskript, das irgendwann den falschen wegraeumt.
import { logger } from '#core/logger.ts';
import { updateSecrets } from '#twitch/secrets.ts';
import {
CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiUser,
type ApiWebhook, type Rest,
} from './api.ts';
import { str } from './permissions.ts';
import {
AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, GUILD_SETTINGS, ROLES, WELCOME,
type CategoryPlan, type ChannelPlan, type OverwritePlan, type WebhookSlot,
} from './plan.ts';
const log = logger('discord:setup');
export type Change = 'erstellt' | 'angepasst' | 'unveraendert' | 'uebersprungen';
export type Step = { kind: string; name: string; change: Change; note?: string };
export type ApplyResult = {
guild: string;
steps: Step[];
/** Nur die Anzahl - die URLs selbst werden nie zurueckgegeben oder geloggt. */
webhooks: number;
};
export type ApplyOptions = {
/** Nur zeigen, was passieren wuerde. Schreibt nichts. */
dryRun?: boolean;
/** Webhook-URLs in den verschluesselten Speicher schreiben. */
saveSecrets?: boolean;
};
// ---------------------------------------------------------------- Helfer
const eq = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
const byName = <T extends { name: string }>(list: T[], name: string): T | undefined =>
list.find((x) => eq(x.name, name));
/** Findet einen Kanal ueber den geplanten Namen oder einen frueheren Namen. */
export function findChannel(
existing: ApiChannel[], plan: { name: string; aliases?: string[]; type: number },
): ApiChannel | undefined {
for (const name of [plan.name, ...(plan.aliases ?? [])]) {
const hit = existing.find((c) => c.type === plan.type && eq(c.name, name));
if (hit) return hit;
}
return undefined;
}
/**
* Setzt die geplanten Rechte-Ausnahmen, laesst fremde stehen.
* Gibt null zurueck, wenn sich nichts aendert - dann sparen wir das PATCH.
*/
export function mergeOverwrites(
existing: ApiOverwrite[],
planned: OverwritePlan[],
roleId: (name: string) => string | undefined,
): ApiOverwrite[] | null {
const out = existing.map((o) => ({ ...o }));
let changed = false;
for (const p of planned) {
const id = roleId(p.role);
if (!id) continue; // Rolle gibt es (noch) nicht
const allow = str(p.allow ?? 0n);
const deny = str(p.deny ?? 0n);
const found = out.find((o) => o.id === id && o.type === 0);
if (!found) {
out.push({ id, type: 0, allow, deny });
changed = true;
} else if (found.allow !== allow || found.deny !== deny) {
found.allow = allow;
found.deny = deny;
changed = true;
}
}
return changed ? out : null;
}
/**
* Discord normalisiert Kanalnamen: Textkanaele werden kleingeschrieben und
* Leerzeichen werden zu Bindestrichen. Emoji, Umlaute und das Trennzeichen
* U+2502 bleiben stehen. Wer das nicht mitrechnet, schickt bei jedem Lauf
* dieselbe Umbenennung erneut - und laeuft in das Limit von zwei
* Umbenennungen je Kanal und zehn Minuten.
*/
export function normalizeName(name: string, type: number): string {
if (type !== CH.TEXT) return name;
return name.toLowerCase().replace(/\s+/g, '-');
}
// ---------------------------------------------------------------- Rollen
/**
* Wohin die Rollen in der Rangfolge duerfen.
*
* Eine Rolle kann nur Rollen unterhalb der eigenen bearbeiten - auch mit
* Administrator. Die Rolle, die der Bot mitbringt, haengt Discord beim
* Einladen ganz unten ein, dort ist also erst einmal kein Platz. Statt blind
* 3, 2, 1 zu verlangen und ein "Missing Permissions" zu kassieren, rechnen wir
* aus, was geht, und sagen sonst, was zu tun ist.
*/
export function rolePositions(
count: number, botTop: number,
): { positions: number[] } | { problem: string } {
// Frei sind die Plaetze 1 bis botTop-1; Platz 0 gehoert @everyone.
const room = botTop - 1;
if (room < count) {
return {
problem:
`Bot-Rolle steht auf Platz ${botTop}, darunter ist Platz fuer ${Math.max(0, room)} ` +
`von ${count} Rollen. In den Servereinstellungen unter "Rollen" die Bot-Rolle ` +
'nach oben ziehen und den Befehl erneut ausfuehren.',
};
}
return { positions: Array.from({ length: count }, (_, i) => botTop - 1 - i) };
}
async function applyRoles(
api: Rest, guild: ApiGuild, botUserId: string, steps: Step[], dry: boolean,
): Promise<Map<string, string>> {
const ids = new Map<string, string>();
const roles = [...guild.roles];
// @everyone traegt in Discord die ID des Servers.
ids.set('@everyone', guild.id);
const everyone = roles.find((r) => r.id === guild.id);
if (everyone && everyone.permissions !== str(EVERYONE)) {
if (!dry) await api.patch(`/guilds/${guild.id}/roles/${guild.id}`, { permissions: str(EVERYONE) }, 'Grundrechte aus dem Bauplan');
steps.push({ kind: 'Rolle', name: '@everyone', change: 'angepasst' });
} else {
steps.push({ kind: 'Rolle', name: '@everyone', change: 'unveraendert' });
}
for (const plan of ROLES) {
const found = byName(roles, plan.name);
const want = {
name: plan.name,
permissions: str(plan.permissions),
color: plan.color,
hoist: plan.hoist,
mentionable: plan.mentionable,
};
if (!found) {
if (dry) {
steps.push({ kind: 'Rolle', name: plan.name, change: 'erstellt', note: 'Vorschau' });
continue;
}
const made = await api.post<ApiRole>(`/guilds/${guild.id}/roles`, want, `Rolle aus dem Bauplan: ${plan.purpose}`);
ids.set(plan.name, made.id);
steps.push({ kind: 'Rolle', name: plan.name, change: 'erstellt' });
continue;
}
ids.set(plan.name, found.id);
const differs = found.permissions !== want.permissions || found.color !== want.color ||
found.hoist !== want.hoist || found.mentionable !== want.mentionable;
if (!differs) {
steps.push({ kind: 'Rolle', name: plan.name, change: 'unveraendert' });
continue;
}
if (found.managed) {
// Rollen, die zu einer App oder zur Twitch-Anbindung gehoeren, gehoeren
// Discord. Anfassen geht nicht und waere auch falsch.
steps.push({ kind: 'Rolle', name: plan.name, change: 'uebersprungen', note: 'von Discord verwaltet' });
continue;
}
if (!dry) await api.patch(`/guilds/${guild.id}/roles/${found.id}`, want, 'Rolle aus dem Bauplan angeglichen');
steps.push({ kind: 'Rolle', name: plan.name, change: 'angepasst' });
}
await applyRoleOrder(api, guild, botUserId, ids, steps, dry);
return ids;
}
async function applyRoleOrder(
api: Rest, guild: ApiGuild, botUserId: string, ids: Map<string, string>,
steps: Step[], dry: boolean,
): Promise<void> {
const wanted = ROLES.map((r) => ids.get(r.name)).filter((id): id is string => Boolean(id));
if (dry || wanted.length === 0) return;
let botTop: number;
try {
const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`);
const fresh = await api.get<ApiRole[]>(`/guilds/${guild.id}/roles`);
botTop = fresh
.filter((r) => member.roles.includes(r.id))
.reduce((max, r) => Math.max(max, r.position), 0);
} catch (err) {
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: (err as Error).message });
return;
}
const fit = rolePositions(wanted.length, botTop);
if ('problem' in fit) {
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: fit.problem });
log.warn(fit.problem);
return;
}
try {
await api.patch(`/guilds/${guild.id}/roles`,
wanted.map((id, i) => ({ id, position: fit.positions[i] })), 'Rangfolge aus dem Bauplan');
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'angepasst' });
} catch (err) {
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: (err as Error).message });
log.warn(`Rangfolge nicht gesetzt: ${(err as Error).message}`);
}
}
// ---------------------------------------------------------------- Kanaele
function kindOf(type: number): string {
if (type === CH.CATEGORY) return 'Kategorie';
if (type === CH.VOICE) return 'Sprachkanal';
return 'Kanal';
}
/** Kategorie und Kanal unterscheiden sich fuers Auftragen nur im Typ. */
type AnyPlan = CategoryPlan & { type: number; topic?: string };
async function applyChannel(
api: Rest, guild: ApiGuild, existing: ApiChannel[], steps: Step[], dry: boolean,
plan: AnyPlan,
parentId: string | null,
roleId: (name: string) => string | undefined,
): Promise<ApiChannel | null> {
const kind = kindOf(plan.type);
const found = findChannel(existing, plan);
if (!found) {
if (dry) {
steps.push({ kind, name: plan.name, change: 'erstellt', note: 'Vorschau' });
return null;
}
const overwrites: ApiOverwrite[] = (plan.overwrites ?? []).flatMap((p) => {
const id = roleId(p.role);
return id ? [{ id, type: 0 as const, allow: str(p.allow ?? 0n), deny: str(p.deny ?? 0n) }] : [];
});
const made = await api.post<ApiChannel>(`/guilds/${guild.id}/channels`, {
name: plan.name,
type: plan.type,
...(parentId ? { parent_id: parentId } : {}),
...(plan.topic ? { topic: plan.topic } : {}),
...(overwrites.length ? { permission_overwrites: overwrites } : {}),
}, 'Kanal aus dem Bauplan');
steps.push({ kind, name: plan.name, change: 'erstellt' });
return made;
}
const patch: Record<string, unknown> = {};
const rename = !eq(found.name, normalizeName(plan.name, plan.type));
if (rename) patch['name'] = plan.name;
if (plan.topic && (found.topic ?? '') !== plan.topic) patch['topic'] = plan.topic;
if (parentId && found.parent_id !== parentId) patch['parent_id'] = parentId;
const merged = mergeOverwrites(found.permission_overwrites ?? [], plan.overwrites ?? [], roleId);
if (merged) patch['permission_overwrites'] = merged;
if (Object.keys(patch).length === 0) {
steps.push({ kind, name: plan.name, change: 'unveraendert' });
return found;
}
const label = rename ? `${found.name} -> ${plan.name}` : plan.name;
if (dry) {
steps.push({ kind, name: label, change: 'angepasst', note: `Vorschau: ${Object.keys(patch).join(', ')}` });
return found;
}
try {
await api.patch(`/channels/${found.id}`, patch, 'Kanal aus dem Bauplan angeglichen');
} catch (err) {
// Umbenennungen sind bei Discord auf zwei je Kanal und zehn Minuten
// begrenzt. Das darf den Rest des Laufs nicht aufhalten.
steps.push({ kind, name: label, change: 'uebersprungen', note: (err as Error).message });
log.warn(`${label}: ${(err as Error).message}`);
return found;
}
steps.push({ kind, name: label, change: 'angepasst', note: Object.keys(patch).join(', ') });
return found;
}
// ---------------------------------------------------------------- Webhooks
async function applyWebhooks(
api: Rest, channel: ApiChannel, plan: ChannelPlan, steps: Step[], dry: boolean,
urls: Map<WebhookSlot, string>,
): Promise<void> {
if (!plan.webhooks?.length) return;
const existing = await api.get<ApiWebhook[]>(`/channels/${channel.id}/webhooks`);
for (const w of plan.webhooks) {
const found = byName(existing, w.name);
if (found?.token) {
urls.set(w.slot, `https://discord.com/api/webhooks/${found.id}/${found.token}`);
steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'unveraendert' });
continue;
}
if (found && !found.token) {
// Von Hand angelegte Webhooks geben ihren Token nicht an uns heraus.
steps.push({
kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'uebersprungen',
note: 'von Hand angelegt, URL nicht auslesbar - umbenennen oder loeschen',
});
continue;
}
if (dry) {
steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'erstellt', note: 'Vorschau' });
continue;
}
const made = await api.post<ApiWebhook>(`/channels/${channel.id}/webhooks`, { name: w.name }, 'Webhook aus dem Bauplan');
if (made.token) urls.set(w.slot, `https://discord.com/api/webhooks/${made.id}/${made.token}`);
steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'erstellt' });
}
}
// ---------------------------------------------------------------- AutoMod
/**
* Die Aktionen einer Regel: Nachricht blocken, und wenn es den Log-Kanal gibt,
* dort melden. Ist die Regel schon da, behalten wir ihren bestehenden
* Meldekanal - den kann jemand bewusst umgestellt haben.
*/
function actionsFor(
rule: { name: string },
logChannelId: string | null,
vorhanden: { actions?: Array<{ type: number; metadata?: { channel_id?: string } }> } | undefined,
): Array<Record<string, unknown>> {
const alterKanal = vorhanden?.actions?.find((a) => a.type === 2)?.metadata?.channel_id;
const ziel = alterKanal ?? logChannelId;
return [
{ type: 1, metadata: { custom_message: AUTOMOD_BLOCK } },
...(ziel ? [{ type: 2, metadata: { channel_id: ziel } }] : []),
];
}
async function applyAutomod(
api: Rest, guild: ApiGuild, logChannelId: string | null, steps: Step[], dry: boolean,
): Promise<void> {
type Action = { type: number; metadata?: { custom_message?: string; channel_id?: string } };
type Rule = { id: string; name: string; actions?: Action[] };
let existing: Rule[];
try {
existing = await api.get<Rule[]>(`/guilds/${guild.id}/auto-moderation/rules`);
} catch (err) {
steps.push({ kind: 'AutoMod', name: 'alle Regeln', change: 'uebersprungen', note: (err as Error).message });
return;
}
for (const rule of AUTOMOD) {
// Wie bei Kanaelen: erst unter dem geplanten Namen suchen, dann unter dem
// frueheren. Sonst steht nach einer Umbenennung dieselbe Regel zweimal da.
const found = [rule.name, ...(rule.aliases ?? [])]
.map((n) => byName(existing, n)).find((r) => r !== undefined);
if (found) {
const patch: Record<string, unknown> = {};
if (!eq(found.name, rule.name)) patch['name'] = rule.name;
// Der Blocktext steht im Bauplan, also gilt der Bauplan. Ohne diesen
// Abgleich behaelt eine einmal angelegte Regel ihren alten Text fuer immer.
const blockText = found.actions?.find((a) => a.type === 1)?.metadata?.custom_message;
if (blockText !== AUTOMOD_BLOCK) patch['actions'] = actionsFor(rule, logChannelId, found);
if (Object.keys(patch).length === 0) {
steps.push({ kind: 'AutoMod', name: rule.name, change: 'unveraendert' });
continue;
}
const label = patch['name'] ? `${found.name} -> ${rule.name}` : rule.name;
const was = Object.keys(patch).join(', ');
if (dry) {
steps.push({ kind: 'AutoMod', name: label, change: 'angepasst', note: `Vorschau: ${was}` });
continue;
}
await api.patch(`/guilds/${guild.id}/auto-moderation/rules/${found.id}`,
patch, 'AutoMod-Regel aus dem Bauplan angeglichen');
steps.push({ kind: 'AutoMod', name: label, change: 'angepasst', note: was });
continue;
}
if (dry) {
steps.push({ kind: 'AutoMod', name: rule.name, change: 'erstellt', note: 'Vorschau' });
continue;
}
await api.post(`/guilds/${guild.id}/auto-moderation/rules`, {
name: rule.name,
event_type: 1, // beim Senden pruefen
trigger_type: rule.trigger_type,
trigger_metadata: rule.trigger_metadata,
enabled: true,
actions: actionsFor(rule, logChannelId, undefined),
}, 'AutoMod-Regel aus dem Bauplan');
steps.push({ kind: 'AutoMod', name: rule.name, change: 'erstellt' });
}
}
// ---------------------------------------------------------------- Willkommen
/**
* Der Begruessungstext.
*
* Nachrichten von Menschen fasst das Skript nie an. Die eigene schon: bei
* Discord kann nur der Verfasser eine Nachricht bearbeiten, eine vom Bot
* gepostete kann also niemand sonst veraendert haben. So bleibt der Bauplan
* die massgebliche Fassung, auch wenn sich der Text spaeter noch aendert.
*/
async function applyWelcome(
api: Rest, channel: ApiChannel, botUserId: string, mentions: Map<string, string>,
steps: Step[], dry: boolean,
): Promise<void> {
const body = WELCOME.replace(/\{(\w[\w-]*)\}/g, (all, key: string) => {
const id = mentions.get(key);
return id ? `<#${id}>` : all;
});
const payload = {
content: body,
allowed_mentions: { parse: [] }, // der Begruessungstext pingt niemanden
};
type Msg = { id: string; content: string; author?: { id: string } };
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) {
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'unveraendert' });
return;
}
if (dry) {
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'angepasst', note: 'Vorschau: Text' });
return;
}
await api.patch(`/channels/${channel.id}/messages/${own.id}`, payload);
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'angepasst' });
return;
}
if (seen.length > 0) {
steps.push({
kind: 'Nachricht', name: 'Willkommenstext', change: 'uebersprungen',
note: 'im Kanal steht schon etwas, das nicht vom Bot ist',
});
return;
}
if (dry) {
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'erstellt', note: 'Vorschau' });
return;
}
await api.post(`/channels/${channel.id}/messages`, payload);
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'erstellt' });
}
// ---------------------------------------------------------------- Hauptlauf
export async function applyPlan(
api: Rest, guildId: string, opts: ApplyOptions = {},
): Promise<ApplyResult> {
const dry = opts.dryRun ?? false;
const steps: Step[] = [];
const guild = await api.get<ApiGuild>(`/guilds/${guildId}`);
const me = await api.get<ApiUser>('/users/@me');
log.info(`Server "${guild.name}"${dry ? ' (Vorschau, es wird nichts geaendert)' : ''}`);
// 1. Servereinstellungen
if (!dry) await api.patch(`/guilds/${guild.id}`, GUILD_SETTINGS, 'Servereinstellungen aus dem Bauplan');
steps.push({ kind: 'Server', name: guild.name, change: dry ? 'unveraendert' : 'angepasst' });
// 2. Rollen
const roleIds = await applyRoles(api, guild, me.id, steps, dry);
const roleId = (name: string): string | undefined => roleIds.get(name);
// 3. Kategorien, dann Kanaele
const channels = await api.get<ApiChannel[]>(`/guilds/${guild.id}/channels`);
const categoryIds = new Map<string, string>();
for (const cat of CATEGORIES) {
const made = await applyChannel(api, guild, channels, steps, dry, { ...cat, type: CH.CATEGORY }, null, roleId);
if (made) categoryIds.set(cat.key, made.id);
}
const urls = new Map<WebhookSlot, string>();
const made = new Map<string, ApiChannel>();
for (const plan of CHANNELS) {
const parent = plan.category ? categoryIds.get(plan.category) ?? null : null;
const channel = await applyChannel(api, guild, channels, steps, dry, plan, parent, roleId);
if (!channel) continue;
made.set(plan.key, channel);
await applyWebhooks(api, channel, plan, steps, dry, urls);
}
// 4. AutoMod und Begruessung - beides braucht Kanaele, die es geben muss.
await applyAutomod(api, guild, made.get('technik-log')?.id ?? null, steps, dry);
const welcome = made.get('willkommen');
if (welcome) {
const mentions = new Map([...made].map(([key, c]) => [key, c.id]));
await applyWelcome(api, welcome, me.id, mentions, steps, dry);
}
// 5. Webhook-URLs wegschliessen. Sie werden nie ausgegeben - siehe ADR-0005.
if (!dry && opts.saveSecrets !== false && urls.size > 0) {
updateSecrets((s) => {
s.discord = { ...s.discord, guildId: guild.id };
for (const [slot, url] of urls) s.discord[slot] = url;
});
log.info(`${urls.size} Webhook-URLs im verschluesselten Speicher abgelegt`);
}
return { guild: guild.name, steps, webhooks: urls.size };
}