Discord-Community-Server: Bauplan und Einrichtungsbefehl
Der Server ist kein Tool aus der Neunerliste, aber Broadcaster, Recap und Clipper posten dorthin. Deshalb steht die Struktur als Code im Repo und nicht nur in einer Oberflaeche. packages/discord: - permissions.ts Rechtebits als BigInt, das Feld ist 64 Bit breit - api.ts REST-Client: Warteschlange, Rate Limits, lesbare Fehler - plan.ts Rollen, Kanaele, Rechte, AutoMod, Willkommenstext - apply.ts traegt den Bauplan auf einen bestehenden Server auf Neuer Befehl `abdulive discord:setup [--server ID] [--vorschau]`. Zugeschnitten auf 5 bis 30 gleichzeitige Zuschauer: fuenf sichtbare Kanaele statt zwanzig, und eine selbst vergebene Rolle Live-Ping statt eines Rundrufs an alle bei jedem Stream. Wiederholbar statt einmalig: gefunden wird ueber Namen und frueheren Namen, danach wird angeglichen. Der zweite Lauf aendert nichts. Geloescht wird nie, und von Hand gesetzte Rechte-Ausnahmen bleiben stehen. Drei Dinge, die am echten Server nachgemessen und nicht angenommen wurden: - Discord schreibt Textkanaele klein und macht aus Leerzeichen Bindestriche; Emoji, Umlaute und U+2502 bleiben. normalizeName() bildet das ab, sonst geht bei jedem Lauf dieselbe Umbenennung erneut raus. - Kanalnamen lassen sich nur zweimal je zehn Minuten aendern, danach verlangt Discord bis zu neun Minuten Pause. Der Lauf wartet hoechstens eine Minute und meldet den Rest. - Eine Rolle kann nur Rollen unterhalb der eigenen verschieben, auch mit Administrator. rolePositions() rechnet den Platz unter der Bot-Rolle aus, statt blind 3, 2, 1 zu verlangen. Zugangsdaten laufen ueber den verschluesselten Speicher (ADR-0005): der Bot-Token wird verdeckt abgefragt, die drei Webhook-URLs werden nie ausgegeben und nie geloggt. Im Quelltext bleibt es bei ae/oe/ue, alles was ein Mitglied auf Discord liest steht in richtigem Deutsch. Ein Test haelt das fest. Begruendung, Fallstricke und die Schritte von Hand: docs/projects/discord-community.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d231e1c722
commit
3b744caf6a
13 files changed
+2210
-1
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# @abdulive/discord
|
||||
|
||||
Discord-Anbindung: ein kleiner REST-Client und das Einrichten des
|
||||
Community-Servers.
|
||||
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| `src/permissions.ts` | Rechtebits als `BigInt` (das Feld ist 64 Bit breit) |
|
||||
| `src/api.ts` | REST-Client: Warteschlange, Rate Limits, lesbare Fehler |
|
||||
| `src/plan.ts` | **Der Bauplan**: Rollen, Kanaele, Rechte, Servereinstellungen |
|
||||
| `src/apply.ts` | Traegt den Bauplan auf einen bestehenden Server auf |
|
||||
|
||||
Aufruf ueber die CLI:
|
||||
|
||||
```powershell
|
||||
abdulive discord:setup --server <ID> --vorschau # zeigt nur, was passieren wuerde
|
||||
abdulive discord:setup --server <ID> # wendet es an
|
||||
```
|
||||
|
||||
Wiederholbar: gefunden wird ueber den Namen, danach wird angeglichen statt neu
|
||||
angelegt. Geloescht wird nie.
|
||||
|
||||
Begruendung der Struktur, die Schritte, die von Hand gehoeren, und die
|
||||
Fallstricke: [docs/projects/discord-community.md](../../docs/projects/discord-community.md)
|
||||
@@ -0,0 +1,159 @@
|
||||
// Discord-REST-Client. Nur das, was fuers Einrichten und fuers Posten
|
||||
// gebraucht wird - kein Gateway, keine Bibliothek.
|
||||
//
|
||||
// Discord drosselt pro Route mit eigenen Eimern. Wir schicken die Anfragen
|
||||
// nacheinander und werten `X-RateLimit-Remaining` aus: sinkt es auf 0, warten
|
||||
// wir `X-RateLimit-Reset-After` ab, bevor die naechste Anfrage rausgeht. Das
|
||||
// ist bei ~25 Anfragen pro Lauf reichlich und spart uns eine Eimerverwaltung.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { retry, sleep } from '#core/retry.ts';
|
||||
|
||||
const log = logger('discord');
|
||||
const BASE = 'https://discord.com/api/v10';
|
||||
|
||||
/** Laenger warten wir bei einem 429 nicht - dann wird der Schritt gemeldet. */
|
||||
const MAX_WAIT_MS = 60_000;
|
||||
|
||||
export type RestError = Error & { status: number; code: number };
|
||||
|
||||
function restError(status: number, code: number, message: string): RestError {
|
||||
const err = new Error(message) as RestError;
|
||||
err.status = status;
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlertexte von Discord sind verschachtelt:
|
||||
* `{ code: 50013, message: "Missing Permissions", errors: { name: { _errors: [...] } } }`
|
||||
* Wir ziehen die tiefen Feldfehler raus, sonst steht da nur "Invalid Form Body".
|
||||
*/
|
||||
function describe(body: unknown): { code: number; message: string } {
|
||||
if (!body || typeof body !== 'object') return { code: 0, message: '' };
|
||||
const b = body as { code?: number; message?: string; errors?: unknown };
|
||||
const parts: string[] = [];
|
||||
const walk = (node: unknown, path: string): void => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
const n = node as { _errors?: Array<{ message?: string }> };
|
||||
if (Array.isArray(n._errors)) {
|
||||
for (const e of n._errors) parts.push(`${path}: ${e.message ?? ''}`.trim());
|
||||
return;
|
||||
}
|
||||
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k);
|
||||
};
|
||||
walk(b.errors, '');
|
||||
const detail = parts.length ? ` (${parts.join('; ')})` : '';
|
||||
return { code: b.code ?? 0, message: `${b.message ?? 'Unbekannter Fehler'}${detail}` };
|
||||
}
|
||||
|
||||
export class Discord {
|
||||
private token: string;
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
/** Zeitpunkt, ab dem wieder gesendet werden darf (ms seit Epoche). */
|
||||
private notBefore = 0;
|
||||
|
||||
constructor(botToken: string) {
|
||||
if (!botToken) throw new Error('Kein Discord-Bot-Token angegeben.');
|
||||
this.token = botToken;
|
||||
}
|
||||
|
||||
/** Reiht die Anfrage ein - es laeuft immer nur eine gleichzeitig. */
|
||||
private run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.queue.then(fn, fn);
|
||||
// Fehler duerfen die Kette nicht vergiften, der Aufrufer bekommt sie.
|
||||
this.queue = next.catch(() => undefined);
|
||||
return next;
|
||||
}
|
||||
|
||||
async request<T>(method: string, path: string, body?: unknown, reason?: string): Promise<T> {
|
||||
return this.run(() => retry(async () => {
|
||||
const wait = this.notBefore - Date.now();
|
||||
if (wait > 0) await sleep(wait);
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${this.token}`,
|
||||
'User-Agent': 'abdulive-toolkit (https://git.zeasy.dev/ZeasyAlex/abdulive, 0.1.0)',
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
// Landet im Audit-Log des Servers - so ist spaeter nachvollziehbar,
|
||||
// woher eine Rolle oder ein Kanal kam.
|
||||
...(reason ? { 'X-Audit-Log-Reason': encodeURIComponent(reason) } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
const remaining = Number(res.headers.get('x-ratelimit-remaining') ?? '1');
|
||||
const resetAfter = Number(res.headers.get('x-ratelimit-reset-after') ?? '0');
|
||||
if (remaining <= 0 && resetAfter > 0) this.notBefore = Date.now() + resetAfter * 1000 + 50;
|
||||
|
||||
if (res.status === 429) {
|
||||
const info = await res.json().catch(() => ({})) as { retry_after?: number; global?: boolean };
|
||||
const retryAfter = Math.max(1, (info.retry_after ?? 1)) * 1000 + 100;
|
||||
// Umbenennen eines Kanals ist auf zwei je zehn Minuten begrenzt. Dort
|
||||
// verlangt Discord schon mal neun Minuten Pause - so lange darf ein
|
||||
// Einrichtungslauf nicht stehen. Lieber melden und weitermachen.
|
||||
if (retryAfter > MAX_WAIT_MS) {
|
||||
const err = restError(429, 0,
|
||||
`${method} ${path}: Discord verlangt ${Math.round(retryAfter / 1000)} s Pause ` +
|
||||
'(bei Kanalnamen sind nur zwei Aenderungen je zehn Minuten erlaubt) - spaeter erneut ausfuehren');
|
||||
throw Object.assign(err, { final: true });
|
||||
}
|
||||
this.notBefore = Date.now() + retryAfter;
|
||||
log.warn(`Rate Limit${info.global ? ' (global)' : ''} auf ${method} ${path}, warte ${Math.round(retryAfter)} ms`);
|
||||
throw new Error('Rate Limit erreicht');
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
const text = await res.text();
|
||||
const parsed: unknown = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!res.ok) {
|
||||
const { code, message } = describe(parsed);
|
||||
// 401/403/404 sind Einrichtungsfehler und werden durch Wiederholen
|
||||
// nicht besser - sofort durchreichen.
|
||||
const err = restError(res.status, code, `${method} ${path}: HTTP ${res.status} ${message}`);
|
||||
if (res.status === 401 || res.status === 403 || res.status === 404) throw Object.assign(err, { final: true });
|
||||
throw err;
|
||||
}
|
||||
return parsed as T;
|
||||
}, {
|
||||
attempts: 4,
|
||||
// `retry` bietet keinen Abbruch, aber `onRetry` laeuft innerhalb des
|
||||
// catch-Blocks: wirft es, verlaesst der Fehler die Schleife sofort.
|
||||
// So werden Einrichtungsfehler nicht viermal wiederholt.
|
||||
onRetry: (err, n, ms) => {
|
||||
if ((err as { final?: boolean }).final) throw err;
|
||||
log.warn(`Wiederhole ${method} ${path} (${n}) in ${ms} ms`, { err: String(err) });
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
get<T>(path: string): Promise<T> { return this.request<T>('GET', path); }
|
||||
post<T>(path: string, body: unknown, reason?: string): Promise<T> { return this.request<T>('POST', path, body, reason); }
|
||||
patch<T>(path: string, body: unknown, reason?: string): Promise<T> { return this.request<T>('PATCH', path, body, reason); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Was apply.ts vom Client braucht. Als Typ statt als Klasse, damit die Tests
|
||||
* einen nachgebauten Server unterschieben koennen, ohne ins Netz zu gehen.
|
||||
*/
|
||||
export type Rest = {
|
||||
get<T>(path: string): Promise<T>;
|
||||
post<T>(path: string, body: unknown, reason?: string): Promise<T>;
|
||||
patch<T>(path: string, body: unknown, reason?: string): Promise<T>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Typen
|
||||
|
||||
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 ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string };
|
||||
export type ApiUser = { id: string; username: string; bot?: boolean };
|
||||
|
||||
/** Kanaltypen, die hier vorkommen. */
|
||||
export const CH = { TEXT: 0, VOICE: 2, CATEGORY: 4, FORUM: 15 } as const;
|
||||
@@ -0,0 +1,450 @@
|
||||
// Der ganze Durchlauf gegen einen nachgebauten Server. Kein Netz, kein Token.
|
||||
//
|
||||
// Die Frage, die hier beantwortet wird, ist nicht "kompiliert es", sondern:
|
||||
// Was passiert beim zweiten Lauf? Ein Einrichtungsskript, das beim zweiten Mal
|
||||
// alles doppelt anlegt, richtet mehr Schaden an, als es Arbeit spart.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiWebhook, type Rest } from './api.ts';
|
||||
import { applyPlan, type Step } from './apply.ts';
|
||||
import { CATEGORIES, CHANNELS, EVERYONE, ROLES } from './plan.ts';
|
||||
import { str } from './permissions.ts';
|
||||
|
||||
const GUILD = '100';
|
||||
|
||||
/** Ein Discord-Server im Arbeitsspeicher. Kennt nur die Routen, die wir nutzen. */
|
||||
class FakeDiscord implements Rest {
|
||||
guild: ApiGuild;
|
||||
channels: ApiChannel[] = [];
|
||||
webhooks: ApiWebhook[] = [];
|
||||
automod: Array<{ id: string; name: string; actions?: unknown[] }> = [];
|
||||
messages = new Map<string, Array<{ id: string; content: string; author: { id: string } }>>();
|
||||
calls: string[] = [];
|
||||
private next = 1000;
|
||||
|
||||
/** Platz der Bot-Rolle. Discord haengt sie beim Einladen auf 1. */
|
||||
botRolePosition: number;
|
||||
readonly botUserId = '5000';
|
||||
readonly botRoleId = '5001';
|
||||
|
||||
constructor(opts: { botRolePosition?: number } = {}) {
|
||||
this.botRolePosition = opts.botRolePosition ?? 8;
|
||||
this.guild = {
|
||||
id: GUILD, name: 'abdulive', owner_id: '1', features: [],
|
||||
roles: [
|
||||
{
|
||||
id: GUILD, name: '@everyone', permissions: '0', position: 0,
|
||||
color: 0, hoist: false, mentionable: false, managed: false,
|
||||
},
|
||||
{
|
||||
id: this.botRoleId, name: 'abdulive_ Bot', permissions: '8',
|
||||
position: this.botRolePosition, color: 0, hoist: false,
|
||||
mentionable: false, managed: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private id(): string { return String(this.next++); }
|
||||
|
||||
async get<T>(path: string): Promise<T> {
|
||||
this.calls.push(`GET ${path}`);
|
||||
if (path === '/users/@me') return { id: this.botUserId, username: 'abdulive_ Bot', bot: true } as T;
|
||||
if (path === `/guilds/${GUILD}/members/${this.botUserId}`) return { roles: [this.botRoleId] } as T;
|
||||
if (path === `/guilds/${GUILD}/roles`) return structuredClone(this.guild.roles) as T;
|
||||
if (path === `/guilds/${GUILD}`) return structuredClone(this.guild) as T;
|
||||
if (path === `/guilds/${GUILD}/channels`) return structuredClone(this.channels) as T;
|
||||
if (path === `/guilds/${GUILD}/auto-moderation/rules`) return structuredClone(this.automod) as T;
|
||||
const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path);
|
||||
if (wh) return this.webhooks.filter((w) => w.channel_id === wh[1]) as T;
|
||||
const msg = /^\/channels\/(\d+)\/messages\?limit=(\d+)$/.exec(path);
|
||||
if (msg) return structuredClone(this.messages.get(msg[1]!) ?? []).slice(0, Number(msg[2])) as T;
|
||||
throw new Error(`GET ${path} nicht nachgebaut`);
|
||||
}
|
||||
|
||||
async post<T>(path: string, body: unknown): Promise<T> {
|
||||
this.calls.push(`POST ${path}`);
|
||||
const b = body as Record<string, never>;
|
||||
if (path === `/guilds/${GUILD}/roles`) {
|
||||
const role = { id: this.id(), position: 1, managed: false, ...b } as unknown as ApiRole;
|
||||
this.guild.roles.push(role);
|
||||
return structuredClone(role) as T;
|
||||
}
|
||||
if (path === `/guilds/${GUILD}/channels`) {
|
||||
const ch = {
|
||||
id: this.id(), position: this.channels.length, parent_id: null,
|
||||
permission_overwrites: [], ...b,
|
||||
} as unknown as ApiChannel;
|
||||
this.channels.push(ch);
|
||||
return structuredClone(ch) as T;
|
||||
}
|
||||
if (path === `/guilds/${GUILD}/auto-moderation/rules`) {
|
||||
const src = body as Record<string, unknown>;
|
||||
const rule = { id: this.id(), name: String(src['name']), actions: src['actions'] as unknown[] };
|
||||
this.automod.push(rule);
|
||||
return structuredClone(rule) as T;
|
||||
}
|
||||
const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path);
|
||||
if (wh) {
|
||||
const made: ApiWebhook = { id: this.id(), name: String(b['name']), channel_id: wh[1]!, token: 'tok-' + this.id() };
|
||||
this.webhooks.push(made);
|
||||
return structuredClone(made) as T;
|
||||
}
|
||||
const msg = /^\/channels\/(\d+)\/messages$/.exec(path);
|
||||
if (msg) {
|
||||
const made = {
|
||||
id: this.id(),
|
||||
content: String((b as Record<string, unknown>)['content'] ?? ''),
|
||||
author: { id: this.botUserId },
|
||||
};
|
||||
const list = this.messages.get(msg[1]!) ?? [];
|
||||
list.push(made);
|
||||
this.messages.set(msg[1]!, list);
|
||||
return structuredClone(made) as T;
|
||||
}
|
||||
throw new Error(`POST ${path} nicht nachgebaut`);
|
||||
}
|
||||
|
||||
async patch<T>(path: string, body: unknown): Promise<T> {
|
||||
this.calls.push(`PATCH ${path}`);
|
||||
if (path === `/guilds/${GUILD}`) return {} 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);
|
||||
if (!r) continue;
|
||||
if (e.position >= this.botRolePosition) throw new Error('Missing Permissions');
|
||||
r.position = e.position;
|
||||
}
|
||||
return structuredClone(this.guild.roles) as T;
|
||||
}
|
||||
const role = /^\/guilds\/\d+\/roles\/(\d+)$/.exec(path);
|
||||
if (role) {
|
||||
const r = this.guild.roles.find((x) => x.id === role[1]);
|
||||
if (r) Object.assign(r, body);
|
||||
return structuredClone(r) as T;
|
||||
}
|
||||
const rule = /^\/guilds\/\d+\/auto-moderation\/rules\/(\d+)$/.exec(path);
|
||||
if (rule) {
|
||||
const r = this.automod.find((x) => x.id === rule[1]);
|
||||
if (r) Object.assign(r, body);
|
||||
return structuredClone(r) as T;
|
||||
}
|
||||
const msg = /^\/channels\/(\d+)\/messages\/(\d+)$/.exec(path);
|
||||
if (msg) {
|
||||
const m = (this.messages.get(msg[1]!) ?? []).find((x) => x.id === msg[2]);
|
||||
if (m) m.content = String((body as Record<string, unknown>)['content'] ?? '');
|
||||
return structuredClone(m) as T;
|
||||
}
|
||||
const ch = /^\/channels\/(\d+)$/.exec(path);
|
||||
if (ch) {
|
||||
const c = this.channels.find((x) => x.id === ch[1]);
|
||||
if (c) Object.assign(c, body);
|
||||
return structuredClone(c) as T;
|
||||
}
|
||||
throw new Error(`PATCH ${path} nicht nachgebaut`);
|
||||
}
|
||||
}
|
||||
|
||||
const run = (api: Rest, dryRun = false) =>
|
||||
applyPlan(api, GUILD, { dryRun, saveSecrets: false });
|
||||
|
||||
/** Der Kanal aus dem Bauplan, so wie er im nachgebauten Server gelandet ist. */
|
||||
const live = (api: FakeDiscord, key: string): ApiChannel | undefined => {
|
||||
const plan = CHANNELS.find((c) => c.key === key);
|
||||
assert.ok(plan, `key ${key} gibt es im Bauplan nicht`);
|
||||
return api.channels.find((c) => c.name === plan.name && c.type === plan.type);
|
||||
};
|
||||
|
||||
const katName = (key: string): string => {
|
||||
const k = CATEGORIES.find((c) => c.key === key);
|
||||
assert.ok(k);
|
||||
return k.name;
|
||||
};
|
||||
|
||||
const counts = (steps: Step[]): Record<string, number> => {
|
||||
const out: Record<string, number> = {};
|
||||
for (const s of steps) out[s.change] = (out[s.change] ?? 0) + 1;
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Erstlauf
|
||||
|
||||
test('der erste Lauf legt Rollen, Kategorien, Kanaele und Webhooks an', async () => {
|
||||
const api = new FakeDiscord();
|
||||
const res = await run(api);
|
||||
|
||||
assert.equal(res.guild, 'abdulive');
|
||||
for (const r of ROLES) {
|
||||
assert.ok(api.guild.roles.find((x) => x.name === r.name), `Rolle ${r.name} fehlt`);
|
||||
}
|
||||
assert.equal(api.channels.filter((c) => c.type === CH.CATEGORY).length, 3);
|
||||
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(api.automod.length, 2);
|
||||
assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0);
|
||||
});
|
||||
|
||||
test('@everyone bekommt die Grundrechte aus dem Bauplan', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const everyone = api.guild.roles.find((r) => r.id === GUILD);
|
||||
assert.equal(everyone?.permissions, str(EVERYONE));
|
||||
});
|
||||
|
||||
test('Kanaele haengen unter der richtigen Kategorie', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const kat = (key: string) =>
|
||||
api.channels.find((c) => c.name === katName(key) && c.type === CH.CATEGORY)?.id;
|
||||
for (const plan of CHANNELS) {
|
||||
const c = live(api, plan.key);
|
||||
assert.equal(c?.parent_id, kat(plan.category!), `${plan.name} haengt falsch`);
|
||||
}
|
||||
});
|
||||
|
||||
test('der interne Bereich ist fuer @everyone gesperrt und fuer Mod offen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const intern = api.channels.find((c) => c.name === katName('intern'));
|
||||
const mod = api.guild.roles.find((r) => r.name === 'Mod');
|
||||
const ow = intern?.permission_overwrites ?? [];
|
||||
const jeder = ow.find((o) => o.id === GUILD);
|
||||
assert.ok(jeder && (BigInt(jeder.deny) & 1024n) === 1024n, 'VIEW_CHANNEL muss fuer @everyone gesperrt sein');
|
||||
const fuerMod = ow.find((o) => o.id === mod?.id);
|
||||
assert.ok(fuerMod && (BigInt(fuerMod.allow) & 1024n) === 1024n, 'Mod muss hineinsehen koennen');
|
||||
});
|
||||
|
||||
test('die Begruessung wird gepostet und die Kanalnamen sind aufgeloest', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const willkommen = live(api, 'willkommen');
|
||||
const posts = api.messages.get(willkommen!.id) ?? [];
|
||||
assert.equal(posts.length, 1);
|
||||
assert.match(posts[0]!.content, /<#\d+> - alles andere/, 'die Kanalnamen muessen aufgeloest sein');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Zweitlauf
|
||||
|
||||
test('der zweite Lauf aendert nichts', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const nachErstlauf = {
|
||||
rollen: api.guild.roles.length,
|
||||
kanaele: api.channels.length,
|
||||
webhooks: api.webhooks.length,
|
||||
automod: api.automod.length,
|
||||
};
|
||||
|
||||
api.calls = [];
|
||||
const res = await run(api);
|
||||
|
||||
assert.deepEqual({
|
||||
rollen: api.guild.roles.length,
|
||||
kanaele: api.channels.length,
|
||||
webhooks: api.webhooks.length,
|
||||
automod: api.automod.length,
|
||||
}, nachErstlauf, 'beim zweiten Lauf wurde etwas doppelt angelegt');
|
||||
|
||||
// Kein POST ausser den Lesevorgaengen - nichts Neues, nichts Geaendertes.
|
||||
const schreibend = api.calls.filter((c) => c.startsWith('POST') || (c.startsWith('PATCH') && !c.endsWith('/roles')));
|
||||
assert.deepEqual(schreibend.filter((c) => c !== `PATCH /guilds/${GUILD}`), [],
|
||||
`unerwartete Schreibzugriffe: ${schreibend.join(', ')}`);
|
||||
|
||||
assert.equal(counts(res.steps)['erstellt'] ?? 0, 0);
|
||||
assert.equal(res.webhooks, 3, 'die bestehenden Webhook-URLs muessen wiedergefunden werden');
|
||||
});
|
||||
|
||||
test('die Begruessung wird nicht ein zweites Mal gepostet', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
await run(api);
|
||||
const willkommen = live(api, 'willkommen');
|
||||
assert.equal((api.messages.get(willkommen!.id) ?? []).length, 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Vorschau
|
||||
|
||||
test('die Vorschau schreibt nichts', async () => {
|
||||
const api = new FakeDiscord();
|
||||
const res = await run(api, true);
|
||||
|
||||
assert.equal(api.guild.roles.length, 2, 'nur @everyone und die Bot-Rolle, die es schon gab');
|
||||
assert.equal(api.channels.length, 0);
|
||||
assert.equal(api.webhooks.length, 0);
|
||||
assert.equal(api.automod.length, 0);
|
||||
assert.deepEqual(api.calls.filter((c) => !c.startsWith('GET')), []);
|
||||
assert.ok((counts(res.steps)['erstellt'] ?? 0) > 10, 'die Vorschau muss zeigen, was entstehen wuerde');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Bestand
|
||||
|
||||
test('von Hand angelegte Kanaele und Rechte bleiben stehen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
api.channels.push({
|
||||
id: '900', name: 'memes', type: CH.TEXT, position: 0, parent_id: null,
|
||||
permission_overwrites: [{ id: '777', type: 1, allow: '2048', deny: '0' }],
|
||||
});
|
||||
api.channels.push({
|
||||
id: '901', name: 'allgemein', type: CH.TEXT, position: 1, parent_id: null,
|
||||
permission_overwrites: [{ id: '777', type: 1, allow: '0', deny: '2048' }],
|
||||
});
|
||||
|
||||
await run(api);
|
||||
|
||||
assert.ok(api.channels.find((c) => c.name === 'memes'), 'ein fremder Kanal darf nicht verschwinden');
|
||||
const allgemein = api.channels.find((c) => c.id === '901');
|
||||
assert.equal(live(api, 'allgemein')?.id, '901', 'der bestehende Kanal muss umbenannt werden');
|
||||
assert.equal(api.channels.filter((c) => c.name.includes('allgemein')).length, 1, 'nicht doppelt anlegen');
|
||||
const fremd = (allgemein?.permission_overwrites ?? []).find((o: ApiOverwrite) => o.id === '777');
|
||||
assert.deepEqual(fremd, { id: '777', type: 1, allow: '0', deny: '2048' },
|
||||
'eine von Hand gesetzte Ausnahme darf nicht wegfallen');
|
||||
});
|
||||
|
||||
test('ein von Hand angelegter Webhook wird gemeldet, nicht ueberschrieben', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api); // legt alles an
|
||||
// Jemand loescht den Go-Live-Webhook und baut ihn in der Oberflaeche nach -
|
||||
// dann fehlt uns der Token, und das muss auffallen statt still zu scheitern.
|
||||
const i = api.webhooks.findIndex((w) => w.name === 'Go-Live');
|
||||
delete api.webhooks[i]!.token;
|
||||
|
||||
const res = await run(api);
|
||||
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);
|
||||
});
|
||||
|
||||
test('eine von Discord verwaltete Rolle wird in Ruhe gelassen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
// Die Twitch-Anbindung legt eigene Rollen an. Die gehoeren Discord.
|
||||
api.guild.roles.push({
|
||||
id: '800', name: 'Stammgast', permissions: '0', position: 2,
|
||||
color: 0, hoist: false, mentionable: false, managed: true,
|
||||
});
|
||||
|
||||
const res = await run(api);
|
||||
const step = res.steps.find((s) => s.kind === 'Rolle' && s.name === 'Stammgast');
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.equal(api.guild.roles.find((r) => r.id === '800')?.permissions, '0', 'unveraendert geblieben');
|
||||
});
|
||||
|
||||
test('scheitert die Rangfolge, laeuft der Rest trotzdem durch', async () => {
|
||||
const api = new FakeDiscord();
|
||||
const echt = api.patch.bind(api);
|
||||
api.patch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
if (path === `/guilds/${GUILD}/roles`) throw new Error('Missing Permissions');
|
||||
return echt<T>(path, body);
|
||||
};
|
||||
|
||||
const res = await run(api);
|
||||
const step = res.steps.find((s) => s.name === 'Rangfolge');
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.equal(api.channels.length, 3 + CHANNELS.length, 'die Kanaele muessen trotzdem entstehen');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Rangfolge
|
||||
|
||||
test('die Rollen landen unter der Bot-Rolle, in der Reihenfolge des Bauplans', async () => {
|
||||
const api = new FakeDiscord({ botRolePosition: 8 });
|
||||
await run(api);
|
||||
|
||||
const pos = (name: string) => api.guild.roles.find((r) => r.name === name)?.position;
|
||||
assert.deepEqual(ROLES.map((r) => pos(r.name)), [7, 6, 5, 4]);
|
||||
for (const r of ROLES) {
|
||||
assert.ok(pos(r.name)! < api.botRolePosition, `${r.name} steht ueber der Bot-Rolle`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Bot-Rolle ganz unten: Rangfolge wird gemeldet statt zu scheitern', async () => {
|
||||
// Der reale Fall direkt nach dem Einladen. Discord haengt die Bot-Rolle auf
|
||||
// Platz 1, darunter ist kein Platz - frueher gab das ein Missing Permissions.
|
||||
const api = new FakeDiscord({ botRolePosition: 1 });
|
||||
const res = await run(api);
|
||||
|
||||
const step = res.steps.find((s) => s.name === 'Rangfolge');
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
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);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Umbenennen
|
||||
|
||||
test('ein Kanal unter altem Namen wird umbenannt, nicht verdoppelt', async () => {
|
||||
const api = new FakeDiscord();
|
||||
// So sah der Server nach dem ersten Lauf ohne Emoji aus.
|
||||
const alt = new Map<string, string>();
|
||||
CHANNELS.forEach((plan, i) => {
|
||||
const id = String(900 + i); // der Fake erwartet Ziffern-IDs
|
||||
alt.set(plan.key, id);
|
||||
api.channels.push({
|
||||
id, name: plan.aliases![0]!, type: plan.type,
|
||||
position: 0, parent_id: null, permission_overwrites: [],
|
||||
});
|
||||
});
|
||||
|
||||
const res = await run(api);
|
||||
|
||||
for (const plan of CHANNELS) {
|
||||
assert.equal(live(api, plan.key)?.id, alt.get(plan.key), `${plan.key} wurde neu angelegt statt umbenannt`);
|
||||
}
|
||||
assert.equal(api.channels.filter((c) => c.type !== CH.CATEGORY).length, CHANNELS.length,
|
||||
'es darf kein Kanal dazugekommen sein');
|
||||
assert.ok(res.steps.some((s) => s.name.includes('->')), 'die Umbenennung muss im Bericht stehen');
|
||||
});
|
||||
|
||||
test('haengt Discord beim Umbenennen im Rate Limit, laeuft der Rest weiter', async () => {
|
||||
const api = new FakeDiscord();
|
||||
api.channels.push({
|
||||
id: '901', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null,
|
||||
permission_overwrites: [],
|
||||
});
|
||||
const echt = api.patch.bind(api);
|
||||
api.patch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
if (path === '/channels/901') throw new Error('Discord verlangt 530 s Pause');
|
||||
return echt<T>(path, body);
|
||||
};
|
||||
|
||||
const res = await run(api);
|
||||
|
||||
const step = res.steps.find((s) => s.name.includes('allgemein'));
|
||||
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(api.automod.length, 2);
|
||||
});
|
||||
|
||||
test('ein geaenderter Blocktext wird auf eine bestehende Regel nachgezogen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const regel = api.automod[0]!;
|
||||
(regel.actions as Array<{ type: number; metadata: { custom_message: string } }>)[0]!
|
||||
.metadata.custom_message = 'alter Text';
|
||||
|
||||
const res = await run(api);
|
||||
|
||||
const step = res.steps.find((s) => s.kind === 'AutoMod' && s.name === regel.name);
|
||||
assert.equal(step?.change, 'angepasst');
|
||||
assert.match(step?.note ?? '', /actions/);
|
||||
assert.equal(api.automod.length, 2, 'die Regel darf nicht neu angelegt werden');
|
||||
});
|
||||
|
||||
test('der Meldekanal einer bestehenden Regel bleibt, wie er gesetzt wurde', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const regel = api.automod[0]!;
|
||||
const actions = regel.actions as Array<{ type: number; metadata: Record<string, string> }>;
|
||||
actions[0]!.metadata['custom_message'] = 'alter Text';
|
||||
actions[1]!.metadata['channel_id'] = '424242'; // jemand hat umgestellt
|
||||
|
||||
await run(api);
|
||||
|
||||
const danach = (api.automod[0]!.actions as typeof actions).find((a) => a.type === 2);
|
||||
assert.equal(danach?.metadata['channel_id'], '424242', 'die eigene Wahl darf nicht ueberschrieben werden');
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
// 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 {
|
||||
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;
|
||||
try {
|
||||
const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`);
|
||||
const fresh = await api.get<ApiRole[]>(`/guilds/${guild.id}/roles`);
|
||||
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;
|
||||
}
|
||||
|
||||
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 payload = {
|
||||
content: body,
|
||||
allowed_mentions: { parse: [] }, // der Begruessungstext pingt niemanden
|
||||
};
|
||||
|
||||
type Msg = { id: string; content: string; author?: { id: string } };
|
||||
const seen = await api.get<Msg[]>(`/channels/${channel.id}/messages?limit=50`);
|
||||
const own = seen.find((m) => m.author?.id === botUserId);
|
||||
|
||||
if (own) {
|
||||
if (own.content === body) {
|
||||
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
|
||||
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' });
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Discord-Rechtebits. Die Werte sind ein 64-Bit-Feld und werden in der API
|
||||
// als Dezimalstring uebertragen - deshalb BigInt und nicht number.
|
||||
//
|
||||
// Kein `enum`: Node fuehrt die .ts-Dateien direkt aus und entfernt nur Typen,
|
||||
// ein enum waere zur Laufzeit weg (ADR-0001, `erasableSyntaxOnly`).
|
||||
|
||||
export const P = {
|
||||
CREATE_INSTANT_INVITE: 1n << 0n,
|
||||
KICK_MEMBERS: 1n << 1n,
|
||||
BAN_MEMBERS: 1n << 2n,
|
||||
ADMINISTRATOR: 1n << 3n,
|
||||
MANAGE_CHANNELS: 1n << 4n,
|
||||
MANAGE_GUILD: 1n << 5n,
|
||||
ADD_REACTIONS: 1n << 6n,
|
||||
VIEW_AUDIT_LOG: 1n << 7n,
|
||||
PRIORITY_SPEAKER: 1n << 8n,
|
||||
STREAM: 1n << 9n,
|
||||
VIEW_CHANNEL: 1n << 10n,
|
||||
SEND_MESSAGES: 1n << 11n,
|
||||
SEND_TTS_MESSAGES: 1n << 12n,
|
||||
MANAGE_MESSAGES: 1n << 13n,
|
||||
EMBED_LINKS: 1n << 14n,
|
||||
ATTACH_FILES: 1n << 15n,
|
||||
READ_MESSAGE_HISTORY: 1n << 16n,
|
||||
MENTION_EVERYONE: 1n << 17n,
|
||||
USE_EXTERNAL_EMOJIS: 1n << 18n,
|
||||
VIEW_GUILD_INSIGHTS: 1n << 19n,
|
||||
CONNECT: 1n << 20n,
|
||||
SPEAK: 1n << 21n,
|
||||
MUTE_MEMBERS: 1n << 22n,
|
||||
DEAFEN_MEMBERS: 1n << 23n,
|
||||
MOVE_MEMBERS: 1n << 24n,
|
||||
USE_VAD: 1n << 25n,
|
||||
CHANGE_NICKNAME: 1n << 26n,
|
||||
MANAGE_NICKNAMES: 1n << 27n,
|
||||
MANAGE_ROLES: 1n << 28n,
|
||||
MANAGE_WEBHOOKS: 1n << 29n,
|
||||
MANAGE_GUILD_EXPRESSIONS: 1n << 30n,
|
||||
USE_APPLICATION_COMMANDS: 1n << 31n,
|
||||
REQUEST_TO_SPEAK: 1n << 32n,
|
||||
MANAGE_EVENTS: 1n << 33n,
|
||||
MANAGE_THREADS: 1n << 34n,
|
||||
CREATE_PUBLIC_THREADS: 1n << 35n,
|
||||
CREATE_PRIVATE_THREADS: 1n << 36n,
|
||||
USE_EXTERNAL_STICKERS: 1n << 37n,
|
||||
SEND_MESSAGES_IN_THREADS: 1n << 38n,
|
||||
USE_EMBEDDED_ACTIVITIES: 1n << 39n,
|
||||
MODERATE_MEMBERS: 1n << 40n,
|
||||
USE_SOUNDBOARD: 1n << 42n,
|
||||
CREATE_EVENTS: 1n << 44n,
|
||||
USE_EXTERNAL_SOUNDS: 1n << 45n,
|
||||
SEND_VOICE_MESSAGES: 1n << 46n,
|
||||
SEND_POLLS: 1n << 49n,
|
||||
} as const;
|
||||
|
||||
export type PermName = keyof typeof P;
|
||||
|
||||
/** Vereinigt mehrere Rechte zu einem Bitfeld. */
|
||||
export function bits(...names: PermName[]): bigint {
|
||||
let out = 0n;
|
||||
for (const n of names) out |= P[n];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Wie die API es erwartet: Dezimalstring. */
|
||||
export const str = (v: bigint): string => v.toString();
|
||||
|
||||
/** Namen eines Bitfelds - nur fuer die Vorschau und Fehlermeldungen. */
|
||||
export function names(value: bigint): PermName[] {
|
||||
const out: PermName[] = [];
|
||||
for (const [name, bit] of Object.entries(P) as Array<[PermName, bigint]>) {
|
||||
if ((value & bit) === bit) out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// Der Bauplan wird gegen einen echten Server gefahren, auf dem danach Leute
|
||||
// sitzen. Ein vertipptes Recht faellt dort nicht auf - es faellt auf, wenn
|
||||
// jemand es ausnutzt. Deshalb pruefen diese Tests vor allem das, was nicht
|
||||
// drinstehen darf.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { P, bits, names, str } from './permissions.ts';
|
||||
import { AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, ROLES, WELCOME } from './plan.ts';
|
||||
import { findChannel, mergeOverwrites, normalizeName, rolePositions } from './apply.ts';
|
||||
import { CH } from './api.ts';
|
||||
|
||||
const role = (name: string) => {
|
||||
const r = ROLES.find((x) => x.name === name);
|
||||
assert.ok(r, `Rolle ${name} fehlt im Bauplan`);
|
||||
return r;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Bits
|
||||
|
||||
test('Rechtebits entsprechen den Werten der Discord-API', () => {
|
||||
assert.equal(P.VIEW_CHANNEL, 1024n);
|
||||
assert.equal(P.SEND_MESSAGES, 2048n);
|
||||
assert.equal(P.ADMINISTRATOR, 8n);
|
||||
assert.equal(P.MENTION_EVERYONE, 131072n);
|
||||
// Jenseits von 2^31 - hier wuerde eine number-Rechnung still falsch werden.
|
||||
assert.equal(P.CREATE_PUBLIC_THREADS, 34359738368n);
|
||||
assert.equal(P.MODERATE_MEMBERS, 1099511627776n);
|
||||
assert.equal(P.SEND_POLLS, 562949953421312n);
|
||||
});
|
||||
|
||||
test('bits vereinigt, str liefert den Dezimalstring der API', () => {
|
||||
assert.equal(bits('VIEW_CHANNEL', 'SEND_MESSAGES'), 3072n);
|
||||
assert.equal(str(bits('VIEW_CHANNEL', 'SEND_MESSAGES')), '3072');
|
||||
assert.equal(bits(), 0n);
|
||||
// Doppelt genannt bleibt einfach gesetzt.
|
||||
assert.equal(bits('VIEW_CHANNEL', 'VIEW_CHANNEL'), 1024n);
|
||||
});
|
||||
|
||||
test('names liest ein Bitfeld wieder auf', () => {
|
||||
assert.deepEqual(names(bits('KICK_MEMBERS', 'BAN_MEMBERS')), ['KICK_MEMBERS', 'BAN_MEMBERS']);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Rollen
|
||||
|
||||
test('@everyone bekommt kein Recht, mit dem sich der Server sprengen laesst', () => {
|
||||
for (const verboten of [
|
||||
'ADMINISTRATOR', 'MENTION_EVERYONE', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS',
|
||||
'MANAGE_MESSAGES', 'MANAGE_WEBHOOKS', 'KICK_MEMBERS', 'BAN_MEMBERS', 'SEND_TTS_MESSAGES',
|
||||
] as const) {
|
||||
assert.equal(EVERYONE & P[verboten], 0n, `@everyone darf nicht ${verboten}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('@everyone kann das Noetige: lesen, schreiben, einladen', () => {
|
||||
for (const noetig of ['VIEW_CHANNEL', 'SEND_MESSAGES', 'READ_MESSAGE_HISTORY', 'CREATE_INSTANT_INVITE'] as const) {
|
||||
assert.equal(EVERYONE & P[noetig], P[noetig], `@everyone braucht ${noetig}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Mod darf moderieren, aber nicht umbauen', () => {
|
||||
const mod = role('Mod').permissions;
|
||||
for (const darf of ['KICK_MEMBERS', 'BAN_MEMBERS', 'MODERATE_MEMBERS', 'MANAGE_MESSAGES'] as const) {
|
||||
assert.equal(mod & P[darf], P[darf], `Mod braucht ${darf}`);
|
||||
}
|
||||
for (const nicht of ['ADMINISTRATOR', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS', 'MANAGE_WEBHOOKS'] as const) {
|
||||
assert.equal(mod & P[nicht], 0n, `Mod darf nicht ${nicht}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Administrator traegt genau eine Rolle, und das ist Abdu', () => {
|
||||
const mitAdmin = ROLES.filter((r) => (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR);
|
||||
assert.deepEqual(mitAdmin.map((r) => r.name), ['Abdu']);
|
||||
});
|
||||
|
||||
test('Abdu steht ganz oben in der Rangfolge', () => {
|
||||
// Die Reihenfolge in ROLES ist die Rangfolge auf dem Server.
|
||||
assert.equal(ROLES[0]?.name, 'Abdu');
|
||||
});
|
||||
|
||||
test('Stammgast ist Auszeichnung, keine Macht', () => {
|
||||
const s = role('Stammgast');
|
||||
assert.equal(s.hoist, true, 'muss in der Mitgliederliste sichtbar sein - das ist der ganze Zweck');
|
||||
// Ueber @everyone hinaus nur Kosmetik und Sprachkanal-Vorrang.
|
||||
assert.equal(s.permissions & ~(EVERYONE | P.PRIORITY_SPEAKER), 0n);
|
||||
});
|
||||
|
||||
test('Live-Ping ist erwaehnbar, sonst nichts', () => {
|
||||
const l = role('Live-Ping');
|
||||
assert.equal(l.mentionable, true, 'sonst kann die Go-Live-Meldung sie nicht anpingen');
|
||||
assert.equal(l.permissions & ~EVERYONE, 0n);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Kanaele
|
||||
|
||||
test('Kanalnamen sind eindeutig und ueberleben Discords Normalisierung', () => {
|
||||
const seen = new Set<string>();
|
||||
for (const c of CHANNELS) {
|
||||
// Discord schreibt Textkanaele klein. Steht im Plan ein Grossbuchstabe,
|
||||
// weicht der gespeicherte Name ab und wir benennen bei jedem Lauf erneut um.
|
||||
if (c.type === CH.TEXT) {
|
||||
assert.equal(c.name, normalizeName(c.name, c.type),
|
||||
`${c.name}: Discord wuerde den Namen veraendern`);
|
||||
}
|
||||
assert.equal(seen.has(c.name), false, `${c.name} kommt doppelt vor`);
|
||||
seen.add(c.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Eintrag hat einen eindeutigen key', () => {
|
||||
const keys = [...CATEGORIES.map((c) => c.key), ...CHANNELS.map((c) => c.key)];
|
||||
assert.equal(new Set(keys).size, keys.length, 'keys muessen eindeutig sein');
|
||||
});
|
||||
|
||||
test('der alte Name steht als alias drin, sonst entsteht ein zweiter Kanal', () => {
|
||||
// Beim Umstellen auf Emoji-Namen war genau das die Gefahr.
|
||||
for (const c of CHANNELS) {
|
||||
assert.ok(c.aliases?.length, `${c.key} hat keinen frueheren Namen hinterlegt`);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Kanal haengt in einer Kategorie, die es gibt', () => {
|
||||
for (const c of CHANNELS) {
|
||||
assert.ok(c.category, `${c.name} hat keine Kategorie`);
|
||||
assert.ok(CATEGORIES.some((k) => k.key === c.category),
|
||||
`${c.name}: Kategorie ${c.category} ist unbekannt`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Rechte-Ausnahmen verweisen nur auf Rollen aus dem Bauplan', () => {
|
||||
const bekannt = new Set(['@everyone', ...ROLES.map((r) => r.name)]);
|
||||
const alle = [...CHANNELS.flatMap((c) => c.overwrites ?? []), ...CATEGORIES.flatMap((c) => c.overwrites ?? [])];
|
||||
for (const o of alle) assert.equal(bekannt.has(o.role), true, `unbekannte Rolle: ${o.role}`);
|
||||
});
|
||||
|
||||
test('die interne Kategorie ist fuer Mitglieder unsichtbar, fuer Mods nicht', () => {
|
||||
const ow = CATEGORIES.find((c) => c.key === 'intern')?.overwrites;
|
||||
assert.ok(ow);
|
||||
const jeder = ow.find((o) => o.role === '@everyone');
|
||||
const mod = ow.find((o) => o.role === 'Mod');
|
||||
assert.equal(jeder?.deny! & P.VIEW_CHANNEL, P.VIEW_CHANNEL);
|
||||
assert.equal(mod?.allow! & P.VIEW_CHANNEL, P.VIEW_CHANNEL);
|
||||
});
|
||||
|
||||
test('alle internen Kanaele liegen wirklich in der internen Kategorie', () => {
|
||||
for (const key of ['mod-chat', 'technik-log']) {
|
||||
const c = CHANNELS.find((x) => x.key === key);
|
||||
assert.equal(c?.category, 'intern', `${key} waere sonst oeffentlich`);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Webhook-Platz kommt genau einmal vor', () => {
|
||||
const slots = CHANNELS.flatMap((c) => (c.webhooks ?? []).map((w) => w.slot));
|
||||
assert.deepEqual([...slots].sort(), ['clipsWebhook', 'goLiveWebhook', 'recapWebhook']);
|
||||
});
|
||||
|
||||
test('Webhooks haengen nur an Kanaelen, in denen Mitglieder nicht dazwischenfunken', () => {
|
||||
// Go-Live und Recap muessen obenauf bleiben; in #clips darf jeder posten.
|
||||
const ank = CHANNELS.find((c) => c.key === 'ankuendigungen');
|
||||
const deny = ank?.overwrites?.find((o) => o.role === '@everyone')?.deny ?? 0n;
|
||||
assert.equal(deny & P.SEND_MESSAGES, P.SEND_MESSAGES);
|
||||
});
|
||||
|
||||
test('der Begruessungstext verweist nur auf Kanaele, die angelegt werden', () => {
|
||||
const keys = new Set(CHANNELS.map((c) => c.key));
|
||||
for (const [, key] of WELCOME.matchAll(/\{(\w[\w-]*)\}/g)) {
|
||||
assert.equal(keys.has(key!), true, `${key} aus dem Willkommenstext gibt es nicht`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Merge
|
||||
|
||||
const ID = (name: string): string | undefined =>
|
||||
({ '@everyone': '1', Mod: '2', Stammgast: '3' })[name];
|
||||
|
||||
test('fremde Rechte-Ausnahmen bleiben stehen', () => {
|
||||
// "99" hat jemand von Hand gesetzt. Das Skript darf das nicht wegraeumen.
|
||||
const vorher = [{ id: '99', type: 0 as const, allow: '8', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID);
|
||||
assert.ok(out);
|
||||
assert.equal(out.length, 2);
|
||||
assert.deepEqual(out.find((o) => o.id === '99'), vorher[0]);
|
||||
assert.deepEqual(out.find((o) => o.id === '2'), { id: '2', type: 0, allow: '1024', deny: '0' });
|
||||
});
|
||||
|
||||
test('nichts zu tun heisst null - dann faellt das PATCH weg', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '2048' }];
|
||||
assert.equal(mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID), null);
|
||||
});
|
||||
|
||||
test('abweichende Rechte werden ueberschrieben, nicht ergaenzt', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '64', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID);
|
||||
assert.deepEqual(out, [{ id: '1', type: 0, allow: '0', deny: '2048' }]);
|
||||
});
|
||||
|
||||
test('eine Ausnahme fuer ein Mitglied wird nicht mit einer Rolle verwechselt', () => {
|
||||
// type 1 ist ein einzelnes Mitglied. Gleiche ID, anderer Typ - darf nicht
|
||||
// ueberschrieben werden.
|
||||
const vorher = [{ id: '2', type: 1 as const, allow: '64', deny: '0' }];
|
||||
const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID);
|
||||
assert.equal(out?.length, 2);
|
||||
assert.deepEqual(out?.[0], vorher[0]);
|
||||
});
|
||||
|
||||
test('Rollen, die es noch nicht gibt, werden uebergangen statt zu raten', () => {
|
||||
assert.equal(mergeOverwrites([], [{ role: 'Gibtsnicht', allow: 1024n }], ID), null);
|
||||
});
|
||||
|
||||
test('die Vorlage wird nicht verbogen', () => {
|
||||
const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '0' }];
|
||||
const kopie = structuredClone(vorher);
|
||||
mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID);
|
||||
assert.deepEqual(vorher, kopie, 'mergeOverwrites darf die Eingabe nicht veraendern');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Namen
|
||||
|
||||
test('normalizeName bildet ab, was Discord mit dem Namen macht', () => {
|
||||
// Am echten Server nachgemessen: kleinschreiben, Leerzeichen zu Bindestrich,
|
||||
// Emoji, Umlaute und U+2502 bleiben unveraendert.
|
||||
assert.equal(normalizeName('Aü Test Kanal', CH.TEXT), 'aü-test-kanal');
|
||||
assert.equal(normalizeName('📢│ankündigungen', CH.TEXT), '📢│ankündigungen');
|
||||
// Sprachkanaele und Kategorien behalten Grossbuchstaben und Leerzeichen.
|
||||
assert.equal(normalizeName('🔊 Zusammen zocken', CH.VOICE), '🔊 Zusammen zocken');
|
||||
assert.equal(normalizeName('📌 Info', CH.CATEGORY), '📌 Info');
|
||||
});
|
||||
|
||||
test('findChannel erkennt den Kanal am frueheren Namen', () => {
|
||||
const vorhanden = [
|
||||
{ id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null },
|
||||
{ id: '2', name: 'sonstwas', type: CH.TEXT, position: 1, parent_id: null },
|
||||
];
|
||||
const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT };
|
||||
assert.equal(findChannel(vorhanden, plan)?.id, '1');
|
||||
});
|
||||
|
||||
test('findChannel bevorzugt den neuen Namen vor dem alten', () => {
|
||||
const vorhanden = [
|
||||
{ id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null },
|
||||
{ id: '2', name: '💬│allgemein', type: CH.TEXT, position: 1, parent_id: null },
|
||||
];
|
||||
const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT };
|
||||
assert.equal(findChannel(vorhanden, plan)?.id, '2');
|
||||
});
|
||||
|
||||
test('findChannel verwechselt Sprach- und Textkanal nicht', () => {
|
||||
const vorhanden = [{ id: '1', name: 'clips', type: CH.VOICE, position: 0, parent_id: null }];
|
||||
assert.equal(findChannel(vorhanden, { name: '🎬│clips', aliases: ['clips'], type: CH.TEXT }), undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Rangfolge
|
||||
|
||||
test('rolePositions setzt die Rollen unter die Bot-Rolle', () => {
|
||||
// Bot auf Platz 5, drei Rollen -> 4, 3, 2. Platz 1 bleibt frei, 0 ist @everyone.
|
||||
assert.deepEqual(rolePositions(3, 5), { positions: [4, 3, 2] });
|
||||
});
|
||||
|
||||
test('rolePositions nutzt den Platz genau aus', () => {
|
||||
assert.deepEqual(rolePositions(3, 4), { positions: [3, 2, 1] });
|
||||
});
|
||||
|
||||
test('steht die Bot-Rolle zu weit unten, sagt rolePositions was zu tun ist', () => {
|
||||
// Der Normalfall direkt nach dem Einladen: Bot auf Platz 1, kein Platz darunter.
|
||||
const out = rolePositions(3, 1);
|
||||
assert.ok('problem' in out);
|
||||
assert.match(out.problem, /Platz fuer 0 von 3/);
|
||||
assert.match(out.problem, /nach oben ziehen/);
|
||||
});
|
||||
|
||||
test('rolePositions verlangt nie einen Platz ueber der Bot-Rolle', () => {
|
||||
// Genau das hat Discord mit "Missing Permissions" quittiert.
|
||||
for (let botTop = 1; botTop <= 8; botTop++) {
|
||||
const out = rolePositions(3, botTop);
|
||||
if ('problem' in out) continue;
|
||||
for (const p of out.positions) {
|
||||
assert.ok(p < botTop, `Platz ${p} liegt nicht unter der Bot-Rolle (${botTop})`);
|
||||
assert.ok(p > 0, `Platz ${p} wuerde @everyone verdraengen`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Deutsch
|
||||
|
||||
test('was Mitglieder lesen, steht in richtigem Deutsch', () => {
|
||||
// Im Quelltext ist ae/oe/ue Konvention. In allem, was auf Discord sichtbar
|
||||
// ist, waere es ein Tippfehler - "Sei anstaendig" liest sich wie einer.
|
||||
const sichtbar = [
|
||||
WELCOME,
|
||||
...CHANNELS.map((c) => c.name),
|
||||
...CHANNELS.map((c) => c.topic ?? ''),
|
||||
...CATEGORIES.map((c) => c.name),
|
||||
...ROLES.map((r) => r.name),
|
||||
...AUTOMOD.map((r) => r.name),
|
||||
AUTOMOD_BLOCK,
|
||||
].join('\n');
|
||||
|
||||
// Wortstaemme, die im Deutschen praktisch immer einen Umlaut tragen.
|
||||
for (const falsch of ['ae', 'oe', 'ue']) {
|
||||
const treffer = sichtbar.match(new RegExp('\\w*' + falsch + '\\w*', 'g')) ?? [];
|
||||
// "Neuer", "Feuer" und Aehnliches sind echte Vorkommen - nur die bekannten
|
||||
// Umschreibungen aus diesem Projekt duerfen nicht auftauchen.
|
||||
for (const wort of treffer) {
|
||||
assert.equal(
|
||||
/gehoert|anstaendig|Probelaeufe|Erwaehnung|aendert|laeuft|zusaetzlich|Aenderung/i.test(wort),
|
||||
false,
|
||||
`"${wort}" gehoert in richtiges Deutsch geschrieben`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Abdu traegt Administrator, sonst niemand', () => {
|
||||
for (const r of ROLES) {
|
||||
const admin = (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR;
|
||||
assert.equal(admin, r.name === 'Abdu', `${r.name}: Administrator=${admin}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('die AutoMod-Regel kennt ihren frueheren Namen', () => {
|
||||
const regel = AUTOMOD.find((r) => r.name === 'Erwähnungs-Spam');
|
||||
assert.ok(regel?.aliases?.includes('Erwaehnungs-Spam'),
|
||||
'sonst steht die Regel nach der Umbenennung zweimal da');
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
// Der Bauplan des Community-Servers. Deklarativ, damit man ihn lesen und
|
||||
// im Pull Request diskutieren kann, statt ihn in einer Oberflaeche zu suchen.
|
||||
//
|
||||
// Leitgedanke: der Kanal hat 5 bis 30 gleichzeitige Zuschauer. Ein Server mit
|
||||
// zwanzig Kanaelen wirkt bei dieser Groesse leer, und ein leerer Kanal haelt
|
||||
// niemanden. Fuenf sichtbare Kanaele, in denen etwas passiert, sind besser.
|
||||
// Wann welcher Kanal dazukommt, steht in docs/projects/discord-community.md.
|
||||
//
|
||||
// Jeder Eintrag hat einen `key`, der sich nie aendert, und einen `name`, der
|
||||
// sich aendern darf. Gefunden wird ueber `name` und `aliases`, verwiesen wird
|
||||
// ueber `key` - so wird aus einer Umbenennung eine Umbenennung und kein
|
||||
// zweiter Kanal daneben.
|
||||
|
||||
import { bits } from './permissions.ts';
|
||||
import { CH } from './api.ts';
|
||||
|
||||
// Zur Schreibweise: Kommentare und Bezeichner bleiben bei ae/oe/ue wie im
|
||||
// restlichen Repo. Alles, was ein Mitglied auf Discord zu sehen bekommt -
|
||||
// Kanalnamen, Themen, Rollennamen, der Willkommenstext, AutoMod-Meldungen -
|
||||
// steht in richtigem Deutsch. "Sei anstaendig" liest sich wie ein Tippfehler,
|
||||
// und der erste Eindruck eines Servers ist der Text darin.
|
||||
|
||||
/** Schluessel, unter denen eine Webhook-URL im Zugangsdaten-Speicher landet. */
|
||||
export type WebhookSlot = 'goLiveWebhook' | 'recapWebhook' | 'clipsWebhook';
|
||||
|
||||
export type RolePlan = {
|
||||
name: string;
|
||||
color: number;
|
||||
/** Eigene Gruppe in der Mitgliederliste. */
|
||||
hoist: boolean;
|
||||
mentionable: boolean;
|
||||
permissions: bigint;
|
||||
/** Wofuer es die Rolle gibt - landet in der Vorschau und in der Doku. */
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
export type OverwritePlan = {
|
||||
/** Rollenname, oder '@everyone' fuer die Grundrolle. */
|
||||
role: string;
|
||||
allow?: bigint;
|
||||
deny?: bigint;
|
||||
};
|
||||
|
||||
export type CategoryPlan = {
|
||||
key: string;
|
||||
name: string;
|
||||
/** Frueher benutzte Namen. Wird einer gefunden, wird umbenannt. */
|
||||
aliases?: string[];
|
||||
overwrites?: OverwritePlan[];
|
||||
};
|
||||
|
||||
export type ChannelPlan = {
|
||||
key: string;
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
type: number;
|
||||
topic?: string;
|
||||
/** `key` einer Kategorie aus CATEGORIES. */
|
||||
category?: string;
|
||||
overwrites?: OverwritePlan[];
|
||||
webhooks?: Array<{ name: string; slot: WebhookSlot }>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Rechte
|
||||
|
||||
/**
|
||||
* Was ein normales Mitglied ueberall darf. Bewusst als vollstaendige Liste
|
||||
* gesetzt statt auf Discords Vorgabe zu vertrauen - die aendert sich, und
|
||||
* "jeder darf alle anpingen" ist der Unterschied zwischen einem Server, den
|
||||
* man anlaesst, und einem, den man stummschaltet.
|
||||
*/
|
||||
export const EVERYONE = bits(
|
||||
'CREATE_INSTANT_INVITE', // Mitglieder duerfen einladen. Wachstum vor Kontrolle.
|
||||
'VIEW_CHANNEL', 'READ_MESSAGE_HISTORY',
|
||||
'SEND_MESSAGES', 'SEND_MESSAGES_IN_THREADS', 'CREATE_PUBLIC_THREADS',
|
||||
'ADD_REACTIONS', 'EMBED_LINKS', 'ATTACH_FILES',
|
||||
'USE_EXTERNAL_EMOJIS', 'USE_EXTERNAL_STICKERS', 'USE_APPLICATION_COMMANDS',
|
||||
'SEND_POLLS', 'CHANGE_NICKNAME',
|
||||
'CONNECT', 'SPEAK', 'STREAM', 'USE_VAD', 'USE_SOUNDBOARD',
|
||||
);
|
||||
// Nicht enthalten und damit verboten: MENTION_EVERYONE, SEND_TTS_MESSAGES,
|
||||
// CREATE_PRIVATE_THREADS und saemtliche Verwaltungsrechte.
|
||||
|
||||
const MOD = EVERYONE | bits(
|
||||
'KICK_MEMBERS', 'BAN_MEMBERS', 'MODERATE_MEMBERS',
|
||||
'MANAGE_MESSAGES', 'MANAGE_THREADS', 'MANAGE_NICKNAMES',
|
||||
'MUTE_MEMBERS', 'DEAFEN_MEMBERS', 'MOVE_MEMBERS',
|
||||
'VIEW_AUDIT_LOG', 'MENTION_EVERYONE',
|
||||
);
|
||||
// Kein MANAGE_GUILD, kein MANAGE_ROLES, kein MANAGE_CHANNELS: Moderation
|
||||
// heisst aufraeumen, nicht umbauen. Wer umbauen soll, bekommt es einzeln.
|
||||
|
||||
// Abdu bekommt ADMINISTRATOR. Das Recht schliesst alle anderen ein und setzt
|
||||
// jede Kanal-Ausnahme ausser Kraft - auch kuenftige. Wer diese Rolle traegt,
|
||||
// kann den Server komplett umbauen und loeschen, deshalb gehoert sie an genau
|
||||
// ein Konto und an kein zweites.
|
||||
const ABDU = bits('ADMINISTRATOR');
|
||||
|
||||
export const ROLES: RolePlan[] = [
|
||||
{
|
||||
name: 'Abdu',
|
||||
color: 0x9146ff,
|
||||
hoist: true,
|
||||
mentionable: true,
|
||||
permissions: ABDU,
|
||||
purpose:
|
||||
'Der Kanal selbst. Steht ganz oben in der Mitgliederliste und traegt ' +
|
||||
'Administrator, darf also alles. Nur fuer Abdus eigenes Konto.',
|
||||
},
|
||||
{
|
||||
name: 'Mod',
|
||||
color: 0x5865f2,
|
||||
hoist: true,
|
||||
mentionable: true,
|
||||
permissions: MOD,
|
||||
purpose: 'Raeumt auf: loeschen, stummschalten, im Zweifel rauswerfen.',
|
||||
},
|
||||
{
|
||||
name: 'Stammgast',
|
||||
color: 0x4fd1c5,
|
||||
hoist: true,
|
||||
mentionable: false,
|
||||
permissions: EVERYONE | bits('PRIORITY_SPEAKER'),
|
||||
purpose:
|
||||
'Auszeichnung fuer Leute, die regelmaessig da sind. Bei dieser Kanalgroesse ' +
|
||||
'ist sichtbare Zugehoerigkeit der wirksamste Hebel - die Rolle faerbt den ' +
|
||||
'Namen und sortiert ihn in der Mitgliederliste nach oben.',
|
||||
},
|
||||
{
|
||||
name: 'Live-Ping',
|
||||
color: 0x99aab5,
|
||||
hoist: false,
|
||||
mentionable: true,
|
||||
permissions: EVERYONE,
|
||||
purpose:
|
||||
'Wird bei Stream-Start angepingt - selbst vergeben, nicht automatisch. ' +
|
||||
'Ein Rundruf an alle bei jedem Stream ist der schnellste Weg, dass der ' +
|
||||
'Server stummgeschaltet wird.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------- Kanaele
|
||||
//
|
||||
// Zu den Namen: Discord schreibt Textkanaele klein und macht aus Leerzeichen
|
||||
// Bindestriche. Emoji, das Trennzeichen U+2502 und Umlaute bleiben unveraendert
|
||||
// stehen - nachgemessen am echten Server, nicht angenommen.
|
||||
|
||||
export const CATEGORIES: CategoryPlan[] = [
|
||||
{ key: 'info', name: '📌 Info', aliases: ['Info', 'Informationen'] },
|
||||
{ key: 'community', name: '🎮 Community', aliases: ['Community', 'Textkanäle'] },
|
||||
{
|
||||
key: 'intern',
|
||||
name: '🔒 Intern',
|
||||
aliases: ['Intern'],
|
||||
// Fuer normale Mitglieder gibt es diese Kategorie nicht.
|
||||
overwrites: [
|
||||
{ role: '@everyone', deny: bits('VIEW_CHANNEL') },
|
||||
{ role: 'Mod', allow: bits('VIEW_CHANNEL') },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Nur lesen: schreiben darf hier niemand ausser Mods und Webhooks. */
|
||||
const READONLY: OverwritePlan[] = [
|
||||
{ role: '@everyone', deny: bits('SEND_MESSAGES', 'CREATE_PUBLIC_THREADS') },
|
||||
];
|
||||
|
||||
/** Lesen, aber diskutieren nur in Threads - haelt die Ankuendigungen sauber. */
|
||||
const ANNOUNCE: OverwritePlan[] = [
|
||||
{
|
||||
role: '@everyone',
|
||||
deny: bits('SEND_MESSAGES'),
|
||||
allow: bits('ADD_REACTIONS', 'CREATE_PUBLIC_THREADS', 'SEND_MESSAGES_IN_THREADS'),
|
||||
},
|
||||
];
|
||||
|
||||
export const CHANNELS: ChannelPlan[] = [
|
||||
{
|
||||
key: 'willkommen',
|
||||
name: '👋│willkommen',
|
||||
aliases: ['willkommen', 'willkommen-und-richtlinien'],
|
||||
type: CH.TEXT,
|
||||
category: 'info',
|
||||
topic: 'Was das hier ist und die drei Regeln.',
|
||||
overwrites: READONLY,
|
||||
},
|
||||
{
|
||||
key: 'ankuendigungen',
|
||||
name: '📢│ankündigungen',
|
||||
aliases: ['ankuendigungen', 'ankündigungen'],
|
||||
type: CH.TEXT,
|
||||
category: 'info',
|
||||
topic: 'Stream-Start und die Zusammenfassung danach. Reden gerne in den Threads.',
|
||||
overwrites: ANNOUNCE,
|
||||
webhooks: [
|
||||
{ name: 'Go-Live', slot: 'goLiveWebhook' },
|
||||
{ name: 'Stream-Recap', slot: 'recapWebhook' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'allgemein',
|
||||
name: '💬│allgemein',
|
||||
aliases: ['allgemein'],
|
||||
type: CH.TEXT,
|
||||
category: 'community',
|
||||
topic: 'Der Hauptkanal. Im Zweifel hier.',
|
||||
},
|
||||
{
|
||||
key: 'clips',
|
||||
name: '🎬│clips',
|
||||
aliases: ['clips'],
|
||||
type: CH.TEXT,
|
||||
category: 'community',
|
||||
topic: 'Gute Momente aus dem Stream. Der Clip-Knopf postet hier hin.',
|
||||
webhooks: [{ name: 'Clips', slot: 'clipsWebhook' }],
|
||||
},
|
||||
{
|
||||
key: 'voice',
|
||||
name: '🔊 Zusammen zocken',
|
||||
aliases: ['zusammen-zocken', 'Zusammen zocken'],
|
||||
type: CH.VOICE,
|
||||
category: 'community',
|
||||
},
|
||||
{
|
||||
key: 'mod-chat',
|
||||
name: '🔨│mod-chat',
|
||||
aliases: ['mod-chat'],
|
||||
type: CH.TEXT,
|
||||
category: 'intern',
|
||||
topic: 'Nur Mods.',
|
||||
},
|
||||
{
|
||||
key: 'technik-log',
|
||||
name: '🤖│technik-log',
|
||||
aliases: ['technik-log'],
|
||||
type: CH.TEXT,
|
||||
category: 'intern',
|
||||
topic: 'Meldungen des Toolkits: Probeläufe, Fehler, AutoMod-Treffer.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------- Server
|
||||
|
||||
/**
|
||||
* Servereinstellungen.
|
||||
* - verification_level 2 verlangt ein Konto, das aelter als fuenf Minuten
|
||||
* ist. Kostet echte Leute nichts und haelt Wegwerf-Konten draussen, was bei
|
||||
* einem oeffentlich von Twitch aus verlinkten Server der Normalfall ist.
|
||||
* - default_message_notifications 1 heisst: nur bei Erwaehnung. Sonst
|
||||
* klingelt jede Nachricht bei jedem, und der Server wird stummgeschaltet.
|
||||
*/
|
||||
export const GUILD_SETTINGS = {
|
||||
verification_level: 2,
|
||||
explicit_content_filter: 2,
|
||||
default_message_notifications: 1,
|
||||
system_channel_flags: 4, // Einrichtungstipps unterdruecken, Beitritte zeigen
|
||||
};
|
||||
|
||||
/**
|
||||
* Wird einmalig in #willkommen gepostet, wenn der Kanal noch leer ist.
|
||||
* {key} wird durch die Erwaehnung des Kanals mit diesem `key` ersetzt.
|
||||
*/
|
||||
export const WELCOME = `**Willkommen bei abdulive_**
|
||||
|
||||
Das hier ist der Ort zwischen den Streams. Wer mitliest, gehört dazu.
|
||||
|
||||
**Drei Regeln**
|
||||
> **1.** Sei anständig. Keine Beleidigungen, kein Rassismus, kein Sexismus.
|
||||
> **2.** Kein Spam, keine Werbung, keine fremden Streams im Chat.
|
||||
> **3.** Wenn ein Mod etwas sagt, ist das erledigt. Diskussion per DM.
|
||||
|
||||
**Wo was passiert**
|
||||
> {allgemein} - alles andere
|
||||
> {clips} - die guten Momente
|
||||
> {ankuendigungen} - Stream-Start und die Zusammenfassung danach
|
||||
|
||||
**Bescheid wissen, wenn der Stream startet**
|
||||
Hol dir die Rolle **Live-Ping**. Dann wird nur angepingt, wer das auch möchte.
|
||||
|
||||
Twitch: <https://twitch.tv/abdulive_>`;
|
||||
|
||||
// ---------------------------------------------------------------- AutoMod
|
||||
|
||||
/**
|
||||
* Zwei Regeln, die Discord selbst durchsetzt - ohne Bot, auch nachts um drei.
|
||||
* Mehr braucht es bei dieser Groesse nicht; Wortfilter kosten mehr Aerger
|
||||
* durch Fehlalarme, als sie an Spam verhindern.
|
||||
*/
|
||||
export type AutomodPlan = {
|
||||
name: string;
|
||||
/** Frueher benutzte Namen, damit umbenannt statt doppelt angelegt wird. */
|
||||
aliases?: string[];
|
||||
trigger_type: number;
|
||||
trigger_metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const AUTOMOD: AutomodPlan[] = [
|
||||
{
|
||||
name: 'Erwähnungs-Spam',
|
||||
aliases: ['Erwaehnungs-Spam'],
|
||||
trigger_type: 5,
|
||||
trigger_metadata: { mention_total_limit: 5 },
|
||||
},
|
||||
{ name: 'Spam-Erkennung', trigger_type: 3, trigger_metadata: {} },
|
||||
];
|
||||
|
||||
/** Was jemand zu sehen bekommt, dessen Nachricht AutoMod abgefangen hat. */
|
||||
export const AUTOMOD_BLOCK = 'Das hat die Spam-Erkennung abgefangen. Bei Fragen an die Mods.';
|
||||
@@ -33,7 +33,15 @@ export type SecretStore = {
|
||||
apps: Partial<Record<Role, { clientId: string; clientSecret: string }>>;
|
||||
tokens: Partial<Record<Role, TokenSet>>;
|
||||
obs?: { password: string };
|
||||
discord?: { goLiveWebhook?: string; recapWebhook?: string };
|
||||
discord?: {
|
||||
/** Bot-Token der Discord-App. Nur fuers Einrichten und spaeter fuer den Bot. */
|
||||
botToken?: string;
|
||||
/** Server-ID ("Guild"), damit das Einrichten kein Argument braucht. */
|
||||
guildId?: string;
|
||||
goLiveWebhook?: string;
|
||||
recapWebhook?: string;
|
||||
clipsWebhook?: string;
|
||||
};
|
||||
bluesky?: { handle: string; appPassword: string };
|
||||
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
|
||||
update?: { token: string };
|
||||
|
||||
Reference in new issue
Block a user