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>
547 lines
21 KiB
TypeScript
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 };
|
|
}
|