diff --git a/packages/discord/src/api.ts b/packages/discord/src/api.ts index a1ccf9d..af0d133 100644 --- a/packages/discord/src/api.ts +++ b/packages/discord/src/api.ts @@ -162,7 +162,15 @@ export type RestFull = Rest & { 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 ApiGuild = { + id: string; name: string; owner_id: string; roles: ApiRole[]; features: string[]; + // Die Felder aus GUILD_SETTINGS, damit sich vergleichen laesst, statt bei + // jedem Lauf blind zu schreiben. + verification_level?: number; + explicit_content_filter?: number; + default_message_notifications?: number; + system_channel_flags?: number; +}; export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string }; export type ApiUser = { id: string; username: string; bot?: boolean }; diff --git a/packages/discord/src/apply.test.ts b/packages/discord/src/apply.test.ts index 545d5cf..aa3fe3f 100644 --- a/packages/discord/src/apply.test.ts +++ b/packages/discord/src/apply.test.ts @@ -109,7 +109,13 @@ class FakeDiscord implements Rest { async patch(path: string, body: unknown): Promise { this.calls.push(`PATCH ${path}`); - if (path === `/guilds/${GUILD}`) return {} as T; + if (path === `/guilds/${GUILD}`) { + // Wie der echte Server: gesetzte Einstellungen stehen danach auch drin. + // Ohne das liesse sich nicht pruefen, ob ein zweiter Lauf sie in Ruhe + // laesst. + Object.assign(this.guild, body as Record); + return structuredClone(this.guild) as T; + } if (path === `/guilds/${GUILD}/roles`) { // Rangfolge for (const e of body as Array<{ id: string; position: number }>) { const r = this.guild.roles.find((x) => x.id === e.id); @@ -187,7 +193,7 @@ test('der erste Lauf legt Rollen, Kategorien, Kanaele und Webhooks an', async () for (const c of CHANNELS) { assert.ok(api.channels.find((x) => x.name === c.name), `Kanal ${c.name} fehlt`); } - assert.equal(res.webhooks, 3); + assert.equal(res.webhooks, 4); assert.equal(api.automod.length, 2); assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0); }); @@ -259,7 +265,7 @@ test('der zweite Lauf aendert nichts', async () => { `unerwartete Schreibzugriffe: ${schreibend.join(', ')}`); assert.equal(counts(res.steps)['erstellt'] ?? 0, 0); - assert.equal(res.webhooks, 3, 'die bestehenden Webhook-URLs muessen wiedergefunden werden'); + assert.equal(res.webhooks, 4, 'die bestehenden Webhook-URLs muessen wiedergefunden werden'); }); test('die Begruessung wird nicht ein zweites Mal gepostet', async () => { @@ -320,7 +326,7 @@ test('ein von Hand angelegter Webhook wird gemeldet, nicht ueberschrieben', asyn const step = res.steps.find((s) => s.kind === 'Webhook' && s.name.startsWith('Go-Live')); assert.equal(step?.change, 'uebersprungen'); assert.match(step?.note ?? '', /nicht auslesbar/); - assert.equal(res.webhooks, 2); + assert.equal(res.webhooks, 3, 'die drei uebrigen muessen trotzdem durchkommen'); }); test('eine von Discord verwaltete Rolle wird in Ruhe gelassen', async () => { @@ -375,7 +381,7 @@ test('Bot-Rolle ganz unten: Rangfolge wird gemeldet statt zu scheitern', async ( assert.match(step?.note ?? '', /nach oben ziehen/); // Alles andere muss trotzdem stehen. assert.equal(api.channels.length, 3 + CHANNELS.length); - assert.equal(res.webhooks, 3); + assert.equal(res.webhooks, 4); }); // ---------------------------------------------------------------- Umbenennen @@ -421,10 +427,31 @@ test('haengt Discord beim Umbenennen im Rate Limit, laeuft der Rest weiter', asy assert.equal(step?.change, 'uebersprungen'); assert.match(step?.note ?? '', /530 s/); // Die uebrigen Kanaele und die Webhooks muessen trotzdem entstehen. - assert.equal(res.webhooks, 3); + assert.equal(res.webhooks, 4); assert.equal(api.automod.length, 2); }); +test('der zweite Lauf schreibt nichts mehr - auch nicht Einstellungen und Rangfolge', async () => { + // Der Bauplan verspricht, dass ein zweiter Lauf nichts aendert. Zwei + // Schritte haben das lange gebrochen, weil sie ohne Vergleich geschrieben + // haben: die Servereinstellungen und die Rangfolge der Rollen. Folgenlos + // war das nicht - jeder Lauf hinterliess zwei Eintraege im Audit-Log und + // liess den Bericht so aussehen, als sei etwas passiert. + const api = new FakeDiscord(); + await run(api); + + api.calls.length = 0; + const res = await run(api); + + assert.equal(api.calls.some((c) => c === `PATCH /guilds/${GUILD}`), false, + 'Servereinstellungen standen schon'); + assert.equal(api.calls.some((c) => c === `PATCH /guilds/${GUILD}/roles`), false, + 'die Rangfolge stand schon'); + assert.equal(res.steps.find((s) => s.kind === 'Server')?.change, 'unveraendert'); + assert.equal(res.steps.find((s) => s.name === 'Rangfolge')?.change, 'unveraendert'); + assert.equal(res.steps.some((s) => s.change === 'erstellt'), false, 'nichts neu angelegt'); +}); + test('ein geaenderter Blocktext wird auf eine bestehende Regel nachgezogen', async () => { const api = new FakeDiscord(); await run(api); diff --git a/packages/discord/src/apply.ts b/packages/discord/src/apply.ts index 8d45ec5..68f278b 100644 --- a/packages/discord/src/apply.ts +++ b/packages/discord/src/apply.ts @@ -193,9 +193,11 @@ async function applyRoleOrder( 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); @@ -211,6 +213,15 @@ async function applyRoleOrder( 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'); @@ -480,9 +491,17 @@ export async function applyPlan( const me = await api.get('/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' }); + // 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);