diff --git a/docs/handoff.md b/docs/handoff.md index f22a62e..b783f0c 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -216,7 +216,11 @@ und [Clips](projects/clip-shorts.md) liegen fertig im verschlüsselten Speicher unter `goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Wer diese Werkzeuge baut, muss sich um Discord nicht mehr kümmern, sondern nur noch posten. -Sieben Kanäle, vier Rollen, zwei AutoMod-Regeln. Der Entwurf und seine +Zehn Kanäle, vier Rollen, zwei AutoMod-Regeln. Seit 2026-10-17 dabei: +`🙋│begrüßung` für Discords Beitrittsmeldungen (liefen vorher in +`#allgemein`), und die Foren `🎮│spielvorschläge` und +`👀│reaction-einsendungen`. Beide hatte Abdu als Textkanäle angelegt, sie sind +jetzt Foren mit Tags, die nur Mods setzen, und 👍 als Standard-Reaktion. Der Entwurf und seine Begründung stehen in [`projects/discord-community.md`](projects/discord-community.md); die Kurzfassung ist, dass bei 5 bis 30 Zuschauern fünf sichtbare Kanäle besser sind als zwanzig leere. diff --git a/docs/projects/discord-community.md b/docs/projects/discord-community.md index 9a1f8e8..a5b59fc 100644 --- a/docs/projects/discord-community.md +++ b/docs/projects/discord-community.md @@ -33,9 +33,12 @@ und ist die massgebliche Fassung. Was hier steht, ist die Begruendung dazu. | Kategorie | Kanal | Wer schreibt | Wofuer | |---|---|---|---| | 📌 Info | `👋│willkommen` | niemand | Die drei Regeln, einmal gepostet | +| 📌 Info | `🙋│begrüßung` | alle | Discords Beitrittsmeldungen | | 📌 Info | `📢│ankündigungen` | nur Webhooks | Stream-Start und Recap; Diskussion in Threads | | 🎮 Community | `💬│allgemein` | alle | Der Hauptkanal. Im Zweifel hier | | 🎮 Community | `🎬│clips` | alle + Clip-Knopf | Gute Momente | +| 🎮 Community | `🎮│spielvorschläge` | alle (Forum) | Ein Beitrag je Spiel, abgestimmt per 👍 | +| 🎮 Community | `👀│reaction-einsendungen` | alle (Forum) | Ein Beitrag je Clip, auf den Abdu reagieren soll | | 🎮 Community | `🔊 Zusammen zocken` | — | Sprachkanal | | 🔒 Intern | `🔨│mod-chat` | Mods | Fuer alle anderen unsichtbar | | 🔒 Intern | `🤖│technik-log` | Toolkit | Probelaeufe, Fehler, AutoMod-Treffer | @@ -54,6 +57,23 @@ Server ist der erste Eindruck, den jemand davon bekommt. So bleibt die Kanalansicht eine saubere Liste von Streams, und die Diskussion ueber das Recap haengt trotzdem direkt daran. +`🙋│begrüßung` gibt es, weil Discord die Beitrittsmeldungen sonst in den +ersten Kanal des Servers schreibt, und das war `💬│allgemein`. Dort gingen die +Meldungen im Gespräch unter und das Gespräch in den Meldungen. Der Kanal ist +beschreibbar, sonst tut der Winke-Knopf unter der Meldung nichts. Welcher +Kanal es ist, steht in `SYSTEM_CHANNEL`. + +Die beiden Foren hat Abdu als Textkanäle angelegt, seit 2026-10-17 stehen sie +als Foren im Bauplan. In einem Textkanal schlägt nach drei Wochen jemand +dasselbe Spiel noch einmal vor, weil der erste Vorschlag weggescrollt ist. Im +Forum gibt es einen Beitrag je Spiel, darunter wird per 👍 abgestimmt. Die +Tags für den Stand („Wird gespielt", „Gespielt", „Eher nicht" bzw. „Kommt +dran", „Angeschaut") setzen nur Mods. Sonst markiert jeder sein eigenes Spiel +als „wird gespielt". Discord wandelt einen Textkanal nicht in ein Forum um: +die leeren Textkanäle wurden einmalig von Hand gelöscht, das Skript selbst +löscht weiterhin nichts. Foren gehen auch ohne Community-Server, am echten +Server nachgeprüft. + Der Sprachkanal ist bewusst dabei, obwohl er anfangs leer sein wird. Ein Sprachkanal, den zwei Leute nutzen, ist der Anfang; einer, den es nicht gibt, wird nie einer. diff --git a/packages/discord/src/api.ts b/packages/discord/src/api.ts index af0d133..e360f7a 100644 --- a/packages/discord/src/api.ts +++ b/packages/discord/src/api.ts @@ -161,7 +161,14 @@ export type RestFull = Rest & { export type ApiRole = { id: string; name: string; permissions: string; position: number; color: number; hoist: boolean; mentionable: boolean; managed: boolean }; export type ApiOverwrite = { id: string; type: 0 | 1; allow: string; deny: string }; -export type ApiChannel = { id: string; name: string; type: number; position: number; parent_id: string | null; topic?: string | null; permission_overwrites?: ApiOverwrite[] }; +export type ApiTag = { id?: string; name: string; emoji_id?: string | null; emoji_name: string | null; moderated: boolean }; +export type ApiChannel = { + id: string; name: string; type: number; position: number; parent_id: string | null; + topic?: string | null; permission_overwrites?: ApiOverwrite[]; + // Nur Foren. + available_tags?: ApiTag[]; + default_reaction_emoji?: { emoji_id: string | null; emoji_name: string | null } | null; +}; export type ApiGuild = { id: string; name: string; owner_id: string; roles: ApiRole[]; features: string[]; // Die Felder aus GUILD_SETTINGS, damit sich vergleichen laesst, statt bei @@ -170,6 +177,7 @@ export type ApiGuild = { explicit_content_filter?: number; default_message_notifications?: number; system_channel_flags?: number; + system_channel_id?: string | null; }; export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string }; export type ApiUser = { id: string; username: string; bot?: boolean }; diff --git a/packages/discord/src/apply.test.ts b/packages/discord/src/apply.test.ts index aa3fe3f..fb64e89 100644 --- a/packages/discord/src/apply.test.ts +++ b/packages/discord/src/apply.test.ts @@ -7,7 +7,7 @@ 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 { applyPlan, mergeTags, type Step } from './apply.ts'; import { CATEGORIES, CHANNELS, EVERYONE, ROLES } from './plan.ts'; import { str } from './permissions.ts'; @@ -504,3 +504,41 @@ test('eine Nachricht ohne Knopfleiste bekommt sie nachgereicht', async () => { assert.equal((api.messages.get(kanal)![0]!.components as unknown[]).length, 1); assert.equal(api.messages.get(kanal)!.length, 1, 'nicht neu posten'); }); + +// ---------------------------------------------------------------- Foren + +test('ein Forum bekommt Tags und Standard-Reaktion aus dem Bauplan', async () => { + const api = new FakeDiscord(); + await run(api); + const plan = CHANNELS.find((c) => c.key === 'spielvorschlaege')!; + const forum = live(api, 'spielvorschlaege')!; + assert.equal(forum.type, CH.FORUM); + assert.deepEqual(forum.available_tags?.map((t) => t.name), plan.tags?.map((t) => t.name)); + assert.equal(forum.default_reaction_emoji?.emoji_name, plan.reaction); +}); + +test('ein Textkanal gleichen Namens wird nicht fuer das Forum gehalten', async () => { + // Discord kann einen Textkanal nicht in ein Forum umwandeln. Der alte steht + // also daneben, bis ihn jemand loescht - angepasst werden darf er nicht. + const api = new FakeDiscord(); + api.channels.push({ id: '1', name: '🎮│spielvorschläge', type: CH.TEXT, position: 0, parent_id: null }); + await run(api); + assert.equal(api.channels.filter((c) => c.name === '🎮│spielvorschläge').length, 2); + assert.equal(api.channels.find((c) => c.id === '1')?.available_tags, undefined); +}); + +test('von Hand angelegte Tags bleiben mit ihrer ID stehen', async () => { + const fremd = { id: '77', name: 'Horror', emoji_name: '👻', moderated: false }; + const eigen = { id: '78', name: 'gespielt', emoji_name: '✔️', moderated: false }; + const out = mergeTags([fremd, eigen], [ + { name: 'Gespielt', emoji: '✅', moderated: true }, + { name: 'Eher nicht', emoji: '❌', moderated: true }, + ]); + assert.ok(out); + assert.deepEqual(out.find((t) => t.name === 'Horror'), fremd); + const g = out.find((t) => t.name === 'Gespielt')!; + assert.equal(g.id, '78', 'ohne ID verlieren die Beitraege ihre Markierung'); + assert.equal(g.emoji_name, '✅'); + assert.ok(out.find((t) => t.name === 'Eher nicht')); + assert.equal(mergeTags(out, [{ name: 'Gespielt', emoji: '✅', moderated: true }]), null); +}); diff --git a/packages/discord/src/apply.ts b/packages/discord/src/apply.ts index 68f278b..32a68c4 100644 --- a/packages/discord/src/apply.ts +++ b/packages/discord/src/apply.ts @@ -11,14 +11,14 @@ import { logger } from '#core/logger.ts'; import { updateSecrets } from '#twitch/secrets.ts'; import { - CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiUser, + CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiTag, type ApiUser, type ApiWebhook, type Rest, } from './api.ts'; import { str } from './permissions.ts'; import { selfRoleComponents } from './selfroles.ts'; import { - AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, GUILD_SETTINGS, ROLES, WELCOME, - type CategoryPlan, type ChannelPlan, type OverwritePlan, type WebhookSlot, + AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, GUILD_SETTINGS, ROLES, SYSTEM_CHANNEL, WELCOME, + type CategoryPlan, type ChannelPlan, type OverwritePlan, type TagPlan, type WebhookSlot, } from './plan.ts'; const log = logger('discord:setup'); @@ -241,7 +241,30 @@ function kindOf(type: number): string { } /** Kategorie und Kanal unterscheiden sich fuers Auftragen nur im Typ. */ -type AnyPlan = CategoryPlan & { type: number; topic?: string }; +type AnyPlan = CategoryPlan & { type: number; topic?: string; tags?: TagPlan[]; reaction?: string }; + +/** + * Tags eines Forums: die aus dem Plan nachziehen, fremde stehen lassen. + * Discord ersetzt beim PATCH die ganze Liste, deshalb gehen die vorhandenen + * mit ihrer ID wieder mit - ohne ID waere es ein neuer Tag, und Beitraege + * verloeren ihre Markierung. Gibt null zurueck, wenn alles passt. + */ +export function mergeTags(existing: ApiTag[], plan: TagPlan[]): ApiTag[] | null { + let changed = false; + const out = existing.map((t) => ({ ...t })); + for (const p of plan) { + const want = { name: p.name, emoji_name: p.emoji, moderated: p.moderated ?? false }; + const hit = out.find((t) => eq(t.name, p.name)); + if (!hit) { + out.push(want); + changed = true; + } else if (hit.name !== want.name || hit.emoji_name !== want.emoji_name || hit.moderated !== want.moderated) { + Object.assign(hit, want, { emoji_id: null }); + changed = true; + } + } + return changed ? out : null; +} async function applyChannel( api: Rest, guild: ApiGuild, existing: ApiChannel[], steps: Step[], dry: boolean, @@ -268,6 +291,8 @@ async function applyChannel( ...(parentId ? { parent_id: parentId } : {}), ...(plan.topic ? { topic: plan.topic } : {}), ...(overwrites.length ? { permission_overwrites: overwrites } : {}), + ...(plan.tags?.length ? { available_tags: mergeTags([], plan.tags) } : {}), + ...(plan.reaction ? { default_reaction_emoji: { emoji_id: null, emoji_name: plan.reaction } } : {}), }, 'Kanal aus dem Bauplan'); steps.push({ kind, name: plan.name, change: 'erstellt' }); return made; @@ -280,6 +305,11 @@ async function applyChannel( 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; + const tags = plan.tags?.length ? mergeTags(found.available_tags ?? [], plan.tags) : null; + if (tags) patch['available_tags'] = tags; + if (plan.reaction && found.default_reaction_emoji?.emoji_name !== plan.reaction) { + patch['default_reaction_emoji'] = { emoji_id: null, emoji_name: plan.reaction }; + } if (Object.keys(patch).length === 0) { steps.push({ kind, name: plan.name, change: 'unveraendert' }); @@ -525,7 +555,13 @@ export async function applyPlan( await applyWebhooks(api, channel, plan, steps, dry, urls); } - // 4. AutoMod und Begruessung - beides braucht Kanaele, die es geben muss. + // 4. Beitrittsmeldungen, AutoMod und Begruessung - alles braucht Kanaele, + // die es geben muss. + const system = made.get(SYSTEM_CHANNEL); + if (system && guild.system_channel_id !== system.id) { + if (!dry) await api.patch(`/guilds/${guild.id}`, { system_channel_id: system.id }, 'Beitrittsmeldungen aus dem Bauplan'); + steps.push({ kind: 'Server', name: `Beitrittsmeldungen nach ${system.name}`, change: dry ? 'erstellt' : 'angepasst' }); + } await applyAutomod(api, guild, made.get('technik-log')?.id ?? null, steps, dry); const welcome = made.get('willkommen'); if (welcome) { diff --git a/packages/discord/src/plan.test.ts b/packages/discord/src/plan.test.ts index 026d3dc..b76a8ac 100644 --- a/packages/discord/src/plan.test.ts +++ b/packages/discord/src/plan.test.ts @@ -6,7 +6,7 @@ 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 { AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, ROLES, SYSTEM_CHANNEL, WELCOME } from './plan.ts'; import { findChannel, mergeOverwrites, normalizeName, rolePositions } from './apply.ts'; import { CH } from './api.ts'; @@ -180,6 +180,16 @@ test('der Begruessungstext verweist nur auf Kanaele, die angelegt werden', () => } }); +test('Beitritte landen in einem eigenen, beschreibbaren Kanal', () => { + // Ohne Angabe nimmt Discord den ersten Kanal, und das war #allgemein. + const c = CHANNELS.find((x) => x.key === SYSTEM_CHANNEL); + assert.ok(c, `${SYSTEM_CHANNEL} gibt es nicht`); + assert.notEqual(c.key, 'allgemein'); + // Der Winke-Knopf unter der Meldung schreibt im Namen dessen, der klickt. + const deny = c.overwrites?.find((o) => o.role === '@everyone')?.deny ?? 0n; + assert.equal(deny & P.SEND_MESSAGES, 0n); +}); + // ---------------------------------------------------------------- Merge const ID = (name: string): string | undefined => @@ -334,3 +344,16 @@ test('die AutoMod-Regel kennt ihren frueheren Namen', () => { assert.ok(regel?.aliases?.includes('Erwaehnungs-Spam'), 'sonst steht die Regel nach der Umbenennung zweimal da'); }); + +test('Tags und Standard-Reaktion stehen nur an Foren und passen in Discords Grenzen', () => { + for (const c of CHANNELS) { + if (c.type !== CH.FORUM) { + assert.equal(c.tags, undefined, `${c.name}: Tags gibt es nur in Foren`); + assert.equal(c.reaction, undefined, `${c.name}: Standard-Reaktion gibt es nur in Foren`); + continue; + } + assert.ok((c.tags?.length ?? 0) <= 20, `${c.name}: hoechstens 20 Tags`); + for (const t of c.tags ?? []) assert.ok(t.name.length <= 20, `Tag ${t.name} ist laenger als 20 Zeichen`); + assert.ok((c.topic?.length ?? 0) <= 4096, `${c.name}: Richtlinien zu lang`); + } +}); diff --git a/packages/discord/src/plan.ts b/packages/discord/src/plan.ts index 6244a05..bc731e7 100644 --- a/packages/discord/src/plan.ts +++ b/packages/discord/src/plan.ts @@ -49,16 +49,30 @@ export type CategoryPlan = { 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 @@ -184,6 +198,17 @@ export const CHANNELS: ChannelPlan[] = [ 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', @@ -214,6 +239,44 @@ export const CHANNELS: ChannelPlan[] = [ 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', @@ -260,6 +323,14 @@ export const GUILD_SETTINGS = { 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. @@ -276,6 +347,8 @@ Das hier ist der Ort zwischen den Streams. Wer mitliest, gehört dazu. **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**