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>
This commit is contained in:
1 parent
c4dc6ee04f
commit
e18336c57f
3 files changed
+63
-9
No files matched your search
@@ -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 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 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 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 ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string };
|
||||||
export type ApiUser = { id: string; username: string; bot?: boolean };
|
export type ApiUser = { id: string; username: string; bot?: boolean };
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,13 @@ class FakeDiscord implements Rest {
|
|||||||
|
|
||||||
async patch<T>(path: string, body: unknown): Promise<T> {
|
async patch<T>(path: string, body: unknown): Promise<T> {
|
||||||
this.calls.push(`PATCH ${path}`);
|
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<string, unknown>);
|
||||||
|
return structuredClone(this.guild) as T;
|
||||||
|
}
|
||||||
if (path === `/guilds/${GUILD}/roles`) { // Rangfolge
|
if (path === `/guilds/${GUILD}/roles`) { // Rangfolge
|
||||||
for (const e of body as Array<{ id: string; position: number }>) {
|
for (const e of body as Array<{ id: string; position: number }>) {
|
||||||
const r = this.guild.roles.find((x) => x.id === e.id);
|
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) {
|
for (const c of CHANNELS) {
|
||||||
assert.ok(api.channels.find((x) => x.name === c.name), `Kanal ${c.name} fehlt`);
|
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(api.automod.length, 2);
|
||||||
assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0);
|
assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0);
|
||||||
});
|
});
|
||||||
@@ -259,7 +265,7 @@ test('der zweite Lauf aendert nichts', async () => {
|
|||||||
`unerwartete Schreibzugriffe: ${schreibend.join(', ')}`);
|
`unerwartete Schreibzugriffe: ${schreibend.join(', ')}`);
|
||||||
|
|
||||||
assert.equal(counts(res.steps)['erstellt'] ?? 0, 0);
|
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 () => {
|
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'));
|
const step = res.steps.find((s) => s.kind === 'Webhook' && s.name.startsWith('Go-Live'));
|
||||||
assert.equal(step?.change, 'uebersprungen');
|
assert.equal(step?.change, 'uebersprungen');
|
||||||
assert.match(step?.note ?? '', /nicht auslesbar/);
|
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 () => {
|
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/);
|
assert.match(step?.note ?? '', /nach oben ziehen/);
|
||||||
// Alles andere muss trotzdem stehen.
|
// Alles andere muss trotzdem stehen.
|
||||||
assert.equal(api.channels.length, 3 + CHANNELS.length);
|
assert.equal(api.channels.length, 3 + CHANNELS.length);
|
||||||
assert.equal(res.webhooks, 3);
|
assert.equal(res.webhooks, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Umbenennen
|
// ---------------------------------------------------------------- Umbenennen
|
||||||
@@ -421,10 +427,31 @@ test('haengt Discord beim Umbenennen im Rate Limit, laeuft der Rest weiter', asy
|
|||||||
assert.equal(step?.change, 'uebersprungen');
|
assert.equal(step?.change, 'uebersprungen');
|
||||||
assert.match(step?.note ?? '', /530 s/);
|
assert.match(step?.note ?? '', /530 s/);
|
||||||
// Die uebrigen Kanaele und die Webhooks muessen trotzdem entstehen.
|
// 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);
|
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 () => {
|
test('ein geaenderter Blocktext wird auf eine bestehende Regel nachgezogen', async () => {
|
||||||
const api = new FakeDiscord();
|
const api = new FakeDiscord();
|
||||||
await run(api);
|
await run(api);
|
||||||
|
|||||||
@@ -193,9 +193,11 @@ async function applyRoleOrder(
|
|||||||
if (dry || wanted.length === 0) return;
|
if (dry || wanted.length === 0) return;
|
||||||
|
|
||||||
let botTop: number;
|
let botTop: number;
|
||||||
|
let istPosition = new Map<string, number>();
|
||||||
try {
|
try {
|
||||||
const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`);
|
const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`);
|
||||||
const fresh = await api.get<ApiRole[]>(`/guilds/${guild.id}/roles`);
|
const fresh = await api.get<ApiRole[]>(`/guilds/${guild.id}/roles`);
|
||||||
|
istPosition = new Map(fresh.map((r) => [r.id, r.position]));
|
||||||
botTop = fresh
|
botTop = fresh
|
||||||
.filter((r) => member.roles.includes(r.id))
|
.filter((r) => member.roles.includes(r.id))
|
||||||
.reduce((max, r) => Math.max(max, r.position), 0);
|
.reduce((max, r) => Math.max(max, r.position), 0);
|
||||||
@@ -211,6 +213,15 @@ async function applyRoleOrder(
|
|||||||
return;
|
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 {
|
try {
|
||||||
await api.patch(`/guilds/${guild.id}/roles`,
|
await api.patch(`/guilds/${guild.id}/roles`,
|
||||||
wanted.map((id, i) => ({ id, position: fit.positions[i] })), 'Rangfolge aus dem Bauplan');
|
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<ApiUser>('/users/@me');
|
const me = await api.get<ApiUser>('/users/@me');
|
||||||
log.info(`Server "${guild.name}"${dry ? ' (Vorschau, es wird nichts geaendert)' : ''}`);
|
log.info(`Server "${guild.name}"${dry ? ' (Vorschau, es wird nichts geaendert)' : ''}`);
|
||||||
|
|
||||||
// 1. Servereinstellungen
|
// 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');
|
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' });
|
steps.push({ kind: 'Server', name: guild.name, change: dry ? 'erstellt' : 'angepasst' });
|
||||||
|
} else {
|
||||||
|
steps.push({ kind: 'Server', name: guild.name, change: 'unveraendert' });
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Rollen
|
// 2. Rollen
|
||||||
const roleIds = await applyRoles(api, guild, me.id, steps, dry);
|
const roleIds = await applyRoles(api, guild, me.id, steps, dry);
|
||||||
|
|||||||
Reference in new issue
Block a user