Files
abdulive/packages/discord/src/plan.ts
T
ZeasyAlexandClaude Opus 5.5 5494c6d24d Discord: eigener Kanal fuer Beitritte, Spielvorschlaege und Reactions als Forum
Beitrittsmeldungen liefen nach #allgemein, weil Discord ohne Angabe den
ersten Kanal nimmt. Jetzt gehen sie nach #begruessung (SYSTEM_CHANNEL).

Die von Abdu angelegten Textkanaele spielvorschlaege und
reaction-einsendungen sind jetzt Foren im Bauplan: ein Beitrag je Spiel
bzw. Clip, 👍 als Standard-Reaktion, Stand-Tags nur fuer Mods. apply.ts
kann dafuer Tags und Standard-Reaktion anlegen und nachziehen; fremde
Tags bleiben mit ihrer ID stehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:52:02 +02:00

421 lines
15 KiB
TypeScript

// 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' | 'technikWebhook';
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[];
};
/** Ein Stichwort, mit dem Forenbeitraege markiert werden. */
export type TagPlan = {
/** Hoechstens 20 Zeichen, sonst lehnt Discord ab. */
name: string;
emoji: string;
/** Setzen duerfen nur Mods (MANAGE_THREADS), nicht der Verfasser. */
moderated?: boolean;
};
export type ChannelPlan = {
key: string;
name: string;
aliases?: string[];
type: number;
/** Bei Foren sind das die Richtlinien, die ueber jedem neuen Beitrag stehen. */
topic?: string;
/** `key` einer Kategorie aus CATEGORIES. */
category?: string;
overwrites?: OverwritePlan[];
webhooks?: Array<{ name: string; slot: WebhookSlot }>;
/** Nur Foren. Von Hand angelegte Tags bleiben daneben stehen. */
tags?: TagPlan[];
/** Nur Foren: die Reaktion, die unter jedem Beitrag schon angeboten wird. */
reaction?: string;
};
// ---------------------------------------------------------------- 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,
},
{
// Hier landen Discords Beitrittsmeldungen (SYSTEM_CHANNEL). Beschreibbar,
// damit der Winke-Knopf unter der Meldung funktioniert - der antwortet im
// Namen dessen, der klickt.
key: 'begruessung',
name: '🙋│begrüßung',
aliases: ['begrüßung', 'begruessung'],
type: CH.TEXT,
category: 'info',
topic: 'Wer neu ist, taucht hier auf. Sag Hallo.',
},
{
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' }],
},
{
// Von Abdu als Textkanal angelegt, seit 2026-10-17 ein Forum: ein Beitrag
// je Spiel, abgestimmt wird per Daumen. Im Textkanal schlaegt nach drei
// Wochen jemand dasselbe Spiel noch einmal vor, weil der erste Vorschlag
// weggescrollt ist. Der erste Satz der Richtlinien ist Abdus eigener.
// Die Stand-Tags setzen nur Mods, sonst markiert jeder sein Spiel als
// "wird gespielt".
key: 'spielvorschlaege',
name: '🎮│spielvorschläge',
aliases: ['spielvorschläge', 'spielvorschlaege'],
type: CH.FORUM,
category: 'community',
topic: 'Ihr wollt sehen wie ich Spiele zocke, die euch gefallen? Schreibt gerne eure Vorschläge rein!\n\n' +
'Ein Beitrag pro Spiel. Gibt es das Spiel schon, Daumen hoch drücken statt noch einmal posten - ' +
'so sehe ich, was ihr am meisten sehen wollt.',
reaction: '👍',
tags: [
{ name: 'Wird gespielt', emoji: '🎮', moderated: true },
{ name: 'Gespielt', emoji: '✅', moderated: true },
{ name: 'Eher nicht', emoji: '❌', moderated: true },
],
},
{
// Ebenfalls von Abdu angelegt und ebenfalls ein Forum: ein Beitrag je
// Clip, damit man sieht, was schon dran war.
key: 'reaction',
name: '👀│reaction-einsendungen',
aliases: ['reaction-einsendungen'],
type: CH.FORUM,
category: 'community',
topic: 'Ihr habt Clips oder Videos worauf ich reagieren sollte? Schickt hier die Clips rein!\n\n' +
'Ein Beitrag pro Clip, Link in den Beitrag.',
reaction: '👍',
tags: [
{ name: 'Kommt dran', emoji: '🔜', moderated: true },
{ name: 'Angeschaut', emoji: '✅', moderated: true },
],
},
{
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.',
// Der Entwurfsmodus des Go-Live-Posts landet hier: dieselbe Nachricht,
// die sonst in die Ankuendigungen ginge, nur ohne Ping und nur fuer Mods.
// So sieht man das Ergebnis, bevor es der Server sieht.
webhooks: [{ name: 'Technik', slot: 'technikWebhook' }],
},
];
// ---------------------------------------------------------------- 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
};
/**
* `key` des Kanals, in den Discord "X ist beigetreten" schreibt. Ohne Angabe
* nimmt Discord den ersten Kanal des Servers, und das war #allgemein: dort
* gingen die Beitritte zwischen den Gespraechen unter, und umgekehrt.
* Steht nicht in GUILD_SETTINGS, weil die ID erst nach den Kanaelen feststeht.
*/
export const SYSTEM_CHANNEL = 'begruessung';
/**
* 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
> {spielvorschlaege} - was Abdu mal spielen sollte
> {reaction} - Clips, auf die Abdu reagieren soll
> {ankuendigungen} - Stream-Start und die Zusammenfassung danach
**Bescheid wissen, wenn der Stream startet**
Knopf drücken. Angepingt wird nur, wer das auch möchte, und ein zweiter Druck
nimmt die Rolle wieder weg.
Twitch: <https://twitch.tv/abdulive_>`;
// ---------------------------------------------------------- Selbstbedienung
export type SelfRolePlan = {
/** Landet im Knopf und kommt bei jedem Klick zurueck. Nie aendern. */
customId: string;
/** Name der Rolle aus ROLES. */
role: string;
label: string;
emoji: string;
/** Antwort beim Holen und beim Abgeben - sieht nur, wer geklickt hat. */
onText: string;
offText: string;
};
/**
* Rollen, die man sich per Knopf selbst gibt.
*
* Discords Onboarding kann das auch, verlangt aber sieben Vorgabekanaele mit
* fuenf beschreibbaren. Der Server hat fuenf und drei. Zwei Kanaele anzulegen,
* damit eine Schwelle passt, macht den Server leerer statt besser - also ein
* Knopf unter dem Willkommenstext. Siehe docs/projects/discord-community.md.
*/
export const SELF_ROLES: SelfRolePlan[] = [
{
customId: 'rolle:live-ping',
role: 'Live-Ping',
label: 'Sag mir Bescheid, wenn der Stream startet',
emoji: '🔴',
onText: 'Passt. Du wirst in den Ankündigungen angepingt, wenn der Stream startet. ' +
'Nochmal drücken nimmt es wieder weg.',
offText: 'Erledigt, du wirst nicht mehr angepingt.',
},
];
// ---------------------------------------------------------------- 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.';