Files
abdulive/packages/discord/src/plan.ts
T
ZeasyAlexandClaude Opus 5 c4dc6ee04f Go-Live-Ping: Discord meldet den Streamstart
Der billigste Hebel im Repo, und bis jetzt nur Vorarbeit: die Webhooks lagen
seit der Discord-Einrichtung bereit, gepostet hat nie jemand.

Ablauf: neuer Stream erkannt, eine Minute warten (Twitch setzt Titel und
Kategorie erst kurz nach dem Start, wer sofort postet kuendigt den Titel des
letzten Streams an), Daten frisch holen, Webhook posten, Rolle Live-Ping
anpingen.

Der Ping funktioniert nur mit `allowed_mentions` im Nachrichtenkoerper. `<@&ID>`
allein faerbt die Erwaehnung lila ein und benachrichtigt niemanden - der
Fehler, den man erst nach drei leeren Streams bemerkt. Dagegen steht ein
eigener Test.

Gegen den doppelten Post stehen drei Dinge, denn wer zweimal in zehn Minuten
angepingt wird, nimmt sich die Rolle wieder ab und ist damit fuer immer stumm:

- Tabelle `posts` mit eindeutigem Index je Stream und Plattform. Nur
  *gesendete* Posts sperren; ein fehlgeschlagener Versuch darf wiederholt
  werden, sonst kostet ein Netzwerkaussetzer die ganze Ankuendigung.
- Abklingzeit von sechs Stunden, gegen die neue Stream-ID nach einem
  Verbindungsabbruch.
- Ausgeloest wird nur beim erkannten Neustart, nie im Fortsetzungszweig.

`stream_id` ist bewusst kein Fremdschluessel: ein Post ist die Quittung fuer
etwas, das den Rechner verlassen hat, und muss auch dann schreibbar sein,
wenn es die Sitzung nicht gibt - beim Probelauf etwa.

Dazu ein vierter Webhook in #technik-log. Der Entwurfsmodus postet dieselbe
Nachricht dorthin, ohne Ping und nur fuer Mods sichtbar. So sieht man das
Ergebnis, bevor es der Server sieht.

Die Rollenaufloesung aus selfroles.ts liegt jetzt in roles.ts, weil der Ping
sie auch braucht: ein Webhook kann eine Rolle nicht ueber ihren Namen
anpingen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:12:35 +02:00

348 lines
12 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[];
};
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.',
// 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
};
/**
* 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**
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.';