Files
abdulive/packages/discord/src/apply.ts
T
ZeasyAlexandClaude Opus 5 e18336c57f discord:setup: der zweite Lauf schreibt wirklich nichts mehr
Der Bauplan verspricht, dass ein zweiter Lauf nichts aendert. Zwei Schritte
haben das gebrochen, weil sie ohne Vergleich geschrieben haben: die
Servereinstellungen und die Rangfolge der Rollen.

Aufgefallen beim Einrichten des Technik-Webhooks - der Lauf meldete "GESETZT
abdulive_" und "GESETZT Rangfolge", obwohl die Vorschau zuvor "steht" gesagt
hatte. Vorschau und echter Lauf sagten also Verschiedenes.

Folgenlos war es nicht: jeder Lauf kostete zwei Aufrufe und hinterliess zwei
Eintraege im Audit-Log des Servers, einen davon eine Rollen-Umsortierung.

Jetzt wird beides vorher verglichen. ApiGuild traegt dafuer die vier Felder
aus GUILD_SETTINGS. Ein Test haelt es fest: nach einem Lauf darf der zweite
weder PATCH /guilds/{id} noch PATCH /guilds/{id}/roles schicken.

Am echten Server nachgeprueft - der dritte Lauf meldet keine Aenderung mehr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:12:58 +02:00

547 lines
21 KiB
TypeScript

// 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 = <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;
let istPosition = new Map<string, number>();
try {
const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`);
const fresh = await api.get<ApiRole[]>(`/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<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 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<Msg[]>(`/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<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. 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<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 };
}