// 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 { selfRoleComponents } from './selfroles.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 = (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> { const ids = new Map(); 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(`/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, steps: Step[], dry: boolean, ): Promise { 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; let istPosition = new Map(); try { const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`); const fresh = await api.get(`/guilds/${guild.id}/roles`); istPosition = new Map(fresh.map((r) => [r.id, r.position])); 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; } // Stimmt die Rangfolge schon, nicht schreiben. Ein PATCH mit denselben // Werten ist zwar folgenlos, kostet aber bei jedem Lauf einen Aufruf und // einen Eintrag im Audit-Log - und laesst den Lauf so aussehen, als haette // er etwas geaendert. if (wanted.every((id, i) => istPosition.get(id) === fit.positions[i])) { steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'unveraendert' }); 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 { 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(`/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 = {}; 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, ): Promise { if (!plan.webhooks?.length) return; const existing = await api.get(`/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(`/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> { 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 { 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(`/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 = {}; 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, steps: Step[], dry: boolean, ): Promise { const body = WELCOME.replace(/\{(\w[\w-]*)\}/g, (all, key: string) => { const id = mentions.get(key); return id ? `<#${id}>` : all; }); const components = selfRoleComponents(); const payload = { content: body, components, allowed_mentions: { parse: [] }, // der Begruessungstext pingt niemanden }; type Msg = { id: string; content: string; author?: { id: string }; components?: unknown[] }; const seen = await api.get(`/channels/${channel.id}/messages?limit=50`); const own = seen.find((m) => m.author?.id === botUserId); if (own) { // Auch die Knopfleiste gehoert zum Soll-Zustand: fehlt sie, weil die // Nachricht aus einer Zeit vor den Knoepfen stammt, wird sie nachgereicht. const knoepfe = (own.components ?? []).length; if (own.content === body && knoepfe === components.length) { 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 { const dry = opts.dryRun ?? false; const steps: Step[] = []; const guild = await api.get(`/guilds/${guildId}`); const me = await api.get('/users/@me'); log.info(`Server "${guild.name}"${dry ? ' (Vorschau, es wird nichts geaendert)' : ''}`); // 1. Servereinstellungen. Vergleichen statt blind schreiben: sonst meldet // jeder Lauf eine Aenderung, auch wenn es keine gab, und die Vorschau sagt // etwas anderes als der echte Lauf. const abweichend = (Object.entries(GUILD_SETTINGS) as Array<[keyof ApiGuild, number]>) .some(([k, v]) => guild[k] !== v); if (abweichend) { if (!dry) await api.patch(`/guilds/${guild.id}`, GUILD_SETTINGS, 'Servereinstellungen aus dem Bauplan'); steps.push({ kind: 'Server', name: guild.name, change: dry ? 'erstellt' : 'angepasst' }); } else { steps.push({ kind: 'Server', name: guild.name, change: 'unveraendert' }); } // 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(`/guilds/${guild.id}/channels`); const categoryIds = new Map(); 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(); const made = new Map(); 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 }; }