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>
This commit is contained in:
ZeasyAlexandClaude Opus 5.5 committed 2026-10-17 21:52:02 +02:00
1 parent 81ab865de0
commit 5494c6d24d
7 files changed
+211 -9

No files matched your search

+9 -1
View File
@@ -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 };
+39 -1
View File
@@ -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);
});
+41 -5
View File
@@ -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) {
+24 -1
View File
@@ -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`);
}
});
+73
View File
@@ -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**