feat: Discord-Bot über die Oberfläche konfigurierbar

Bisher kam die Discord-Konfiguration (Token, Server-ID, Kanal-ID,
erlaubte Nutzer-IDs) ausschließlich aus Umgebungsvariablen. Jetzt
unter Einstellungen > Discord einstellbar, inkl. Schritt-für-Schritt-
Anleitung zum Einrichten einer Discord-Anwendung/eines Bots (Token
erzeugen, einladen, IDs ermitteln).

- Migration 026: discord_settings (Token verschlüsselt, wie SMTP/IMAP).
- apps/worker/src/discord.ts liest die Einstellungen jetzt aus der DB
  statt aus process.env; syncDiscord() läuft periodisch (60s) und
  verbindet automatisch neu, wenn sich Token/Server/Aktivierung
  geändert haben (Trennung von Verbindungs- und Laufzeitdaten: Kanal-ID
  und erlaubte Nutzer werden bei jeder Nutzung frisch aus der DB
  gelesen, ohne Neuverbindung).
- Neues API-Modul apps/api/src/modules/discord: Einstellungen lesen/
  schreiben, Token- und Testnachricht-Prüfung direkt über die Discord-
  REST-API (unabhängig vom laufenden Bot-Prozess im Worker).
- Rechte discord.read (Admin+) / discord.write (Superadmin), wie beim
  E-Mail-Modul.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-28 23:28:24 +02:00
parent 8616e51adc
commit c8da9362a7
8 changed files with 222 additions and 20 deletions

View file

@ -0,0 +1,77 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { one, run } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { encrypt, decrypt } from '../../core/crypto.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest } from '../../core/errors.js';
import { rl } from '../../core/config.js';
import type { KcModule } from '../../core/module.js';
const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs
const settingsView = (s: any) => ({
enabled: !!s.enabled, hasToken: !!s.token_enc, guildId: s.guild_id, adminChannelId: s.admin_channel_id,
staffUserIds: String(s.staff_user_ids ?? '').split(',').map((x: string) => x.trim()).filter(Boolean),
configured: !!s.token_enc, lastConnectedAt: s.last_connected_at, lastError: s.last_error, updatedAt: s.updated_at,
});
export const discordModule: KcModule = {
name: 'discord',
permissions: { staff: { admin: ['discord.read'], superadmin: ['discord.read', 'discord.write'] } },
register(app: FastifyInstance) {
app.get('/admin/discord/settings', async (req) => {
requirePermission(req, 'discord.read');
return settingsView((await one('SELECT * FROM discord_settings WHERE id = 1'))!);
});
app.put('/admin/discord/settings', async (req) => {
const a = requirePermission(req, 'discord.write');
const b = z.object({
token: z.string().max(200).nullable().optional(), // undefined = unverändert lassen, null = löschen
guildId: z.string().trim().regex(ID).nullable(), adminChannelId: z.string().trim().regex(ID).nullable(),
staffUserIds: z.array(z.string().trim().regex(ID)).max(50), enabled: z.boolean().default(false),
}).parse(req.body);
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'staff_user_ids = ?', 'enabled = ?', 'updated_by = ?'];
const params: unknown[] = [b.guildId, b.adminChannelId, b.staffUserIds.join(','), b.enabled ? 1 : 0, a.user.id];
if (b.token !== undefined) { sets.push('token_enc = ?'); params.push(b.token ? encrypt(JSON.stringify({ token: b.token })) : null); }
await run(`UPDATE discord_settings SET ${sets.join(', ')} WHERE id = 1`, params);
await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.update', resourceType: 'discord_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, token: b.token !== undefined ? (b.token ? '***' : null) : undefined } });
return { ok: true };
});
/** Prüft nur den Token gegen die Discord-API (GET /users/@me), ohne den laufenden Bot zu berühren. */
app.post('/admin/discord/settings/test-token', { config: rl(5, '1 minute') }, async (req) => {
const a = requirePermission(req, 'discord.write');
const s = await one('SELECT token_enc FROM discord_settings WHERE id = 1');
if (!s?.token_enc) throw badRequest('Bitte zuerst einen Token speichern.', 'NO_TOKEN');
const token = JSON.parse(decrypt(s.token_enc)).token as string;
let ok = false; let error: string | null = null; let botTag: string | null = null;
try {
const r = await fetch('https://discord.com/api/v10/users/@me', { headers: { authorization: `Bot ${token}` } });
if (!r.ok) throw new Error(r.status === 401 ? 'Token ungültig' : `Discord antwortete mit Status ${r.status}`);
const me = await r.json() as { username: string; discriminator?: string };
botTag = me.discriminator && me.discriminator !== '0' ? `${me.username}#${me.discriminator}` : me.username;
ok = true;
} catch (e) { error = String((e as Error).message).slice(0, 300); }
await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.test_token', resourceType: 'discord_settings', resourceId: '1', result: ok ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { ok, botTag } });
return { ok, botTag, error };
});
/** Sendet eine Testnachricht in den hinterlegten Admin-Kanal, unabhängig vom laufenden Bot-Prozess. */
app.post('/admin/discord/settings/test-message', { config: rl(5, '1 minute') }, async (req) => {
const a = requirePermission(req, 'discord.write');
const s = await one('SELECT token_enc, admin_channel_id FROM discord_settings WHERE id = 1');
if (!s?.token_enc) throw badRequest('Bitte zuerst einen Token speichern.', 'NO_TOKEN');
if (!s.admin_channel_id) throw badRequest('Bitte zuerst einen Kanal hinterlegen.', 'NO_CHANNEL');
const token = JSON.parse(decrypt(s.token_enc)).token as string;
let ok = false; let error: string | null = null;
try {
const r = await fetch(`https://discord.com/api/v10/channels/${s.admin_channel_id}/messages`, {
method: 'POST', headers: { authorization: `Bot ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ content: 'Testnachricht vom Kundencenter — die Verbindung funktioniert.' }),
});
if (!r.ok) throw new Error(r.status === 403 ? 'Der Bot hat keinen Zugriff auf diesen Kanal (Berechtigungen prüfen).' : r.status === 404 ? 'Kanal nicht gefunden.' : `Discord antwortete mit Status ${r.status}`);
ok = true;
} catch (e) { error = String((e as Error).message).slice(0, 300); }
await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.test_message', resourceType: 'discord_settings', resourceId: '1', result: ok ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { ok } });
return { ok, error };
});
},
};

View file

@ -12,6 +12,7 @@ import { invoicesModule } from './invoices/index.js';
import { ordersModule } from './orders/index.js';
import { backupModule } from './backup/index.js';
import { mailModule } from './mail/index.js';
import { discordModule } from './discord/index.js';
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule];
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule];