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:
1 parent
81ab865de0
commit
5494c6d24d
7 files changed
+211
-9
No files matched your search
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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**
|
||||
|
||||
Reference in new issue
Block a user