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];

View file

@ -0,0 +1,80 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui';
interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; staffUserIds: string[]; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string }
/** Schritt-für-Schritt-Anleitung, damit auch ohne Discord-Vorwissen ein Bot eingerichtet werden kann. */
function Guide() {
return (<details className="card">
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Anleitung: Discord-Bot einrichten (aufklappen)</summary>
<ol className="small" style={{ margin: '16px 0 0', paddingLeft: 20, display: 'grid', gap: 12 }}>
<li><strong>Anwendung anlegen:</strong> Auf <a href="https://discord.com/developers/applications" target="_blank" rel="noreferrer">discord.com/developers/applications</a> auf „New Application“ klicken, einen Namen vergeben (z. B. „Kundencenter“).</li>
<li><strong>Bot-Token erzeugen:</strong> Im Reiter „Bot“ auf „Reset Token“ klicken und den Token kopieren. Er wird nur dieses eine Mal angezeigt – am besten direkt unten einfügen und speichern. Privilegierte Intents (Message Content etc.) werden nicht benötigt.</li>
<li><strong>Bot einladen:</strong> Im Reiter „OAuth2 → URL Generator“ die Scopes <code className="mono">bot</code> und <code className="mono">applications.commands</code> ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „View Channels“ und „Read Message History“ auswählen. Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.</li>
<li><strong>IDs ermitteln:</strong> In Discord unter Einstellungen → Erweitert den „Entwicklermodus“ aktivieren. Danach mit Rechtsklick auf den Server, den gewünschten Kanal und die eigene Person jeweils „ID kopieren“ wählen.</li>
<li><strong>Hier eintragen:</strong> Token, Server-ID und Kanal-ID (für Systemmeldungen wie Backup-Warnungen oder neue Tickets) unten speichern. Unter „Erlaubte Nutzer-IDs“ die Discord-IDs aller Personen eintragen, die im Server die Befehle <code className="mono">/kc-status</code> (Systemzustand) und <code className="mono">/kc-kunde</code> (Kundensuche) nutzen dürfen.</li>
</ol>
</details>);
}
export default function DiscordSettings() {
const { can } = useSession(); const w = can('discord.write');
const [s, setS] = useState<Settings | null>(null); const [err, setErr] = useState('');
const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null);
const [tokenBusy, setTokenBusy] = useState(false); const [msgBusy, setMsgBusy] = useState(false);
const load = useCallback(() => api<Settings>('GET', '/admin/discord/settings').then(setS).catch((e) => setErr(errMsg(e))), []);
useEffect(() => { void load(); }, [load]);
async function save(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); if (!w) return; setBusy(true); setMsg(null);
const form = e.currentTarget;
const f = new FormData(form); const v = (k: string) => (String(f.get(k) ?? '').trim() || null);
const token = String(f.get('token') ?? '');
const staffUserIds = String(f.get('staffUserIds') ?? '').split(',').map((x) => x.trim()).filter(Boolean);
try {
await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), staffUserIds, enabled: f.get('enabled') === 'on', ...(token ? { token } : {}) });
setMsg({ k: 'ok', t: 'Gespeichert. Die Verbindung wird innerhalb einer Minute automatisch aufgebaut.' }); (form.elements.namedItem('token') as HTMLInputElement).value = ''; void load();
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
}
async function testToken() {
setTokenBusy(true); setMsg(null);
try { const r = await api<{ ok: boolean; botTag: string | null; error: string | null }>('POST', '/admin/discord/settings/test-token'); setMsg({ k: r.ok ? 'ok' : 'err', t: r.ok ? `Token gültig, angemeldet als ${r.botTag}.` : `Fehlgeschlagen – ${r.error}` }); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setTokenBusy(false); }
}
async function testMessage() {
setMsgBusy(true); setMsg(null);
try { const r = await api<{ ok: boolean; error: string | null }>('POST', '/admin/discord/settings/test-message'); setMsg({ k: r.ok ? 'ok' : 'err', t: r.ok ? 'Testnachricht gesendet, bitte im Kanal prüfen.' : `Fehlgeschlagen – ${r.error}` }); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setMsgBusy(false); }
}
if (!s) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
return (<>
<h2>Discord</h2>
{err && <Alert kind="err">{err}</Alert>}
<Guide />
<div className="card" style={{ marginTop: 16 }}>
<h2>Bot-Verbindung</h2>
<p className="muted small">Der Bot meldet Systemereignisse (neue Tickets, Rechnungen, Backup-Probleme) im gewählten Kanal und beantwortet die Befehle <code className="mono">/kc-status</code> und <code className="mono">/kc-kunde</code> für die unten eingetragenen Personen.</p>
{!s.configured && <Alert kind="warn">Noch kein Token gespeichert.</Alert>}
{s.configured && !s.enabled && <Alert kind="info">Token gespeichert, aber deaktiviert – der Bot verbindet sich nicht.</Alert>}
{s.lastError && <Alert kind="err">Letzte Störung: {s.lastError}</Alert>}
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
<form onSubmit={save}>
<div className="cols">
<Field id="token" label="Bot-Token" hint={s.hasToken ? 'Ein Token ist gespeichert. Leer lassen, um ihn unverändert zu lassen.' : 'Noch kein Token gespeichert.'}><input id="token" name="token" type="password" autoComplete="new-password" disabled={!w} /></Field>
<Field id="guildId" label="Server-ID (Guild-ID)"><input id="guildId" name="guildId" defaultValue={s.guildId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="adminChannelId" label="Kanal-ID für Systemmeldungen"><input id="adminChannelId" name="adminChannelId" defaultValue={s.adminChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="staffUserIds" label="Erlaubte Nutzer-IDs (kommagetrennt)" hint="Dürfen /kc-status und /kc-kunde nutzen."><input id="staffUserIds" name="staffUserIds" defaultValue={s.staffUserIds.join(', ')} disabled={!w} /></Field>
</div>
<label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="enabled" defaultChecked={s.enabled} disabled={!w} style={{ width: 20, minHeight: 20 }} /> Bot aktiv</label>
{w && <div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Speichern'}</button>
{s.hasToken && <button className="btn" type="button" disabled={tokenBusy} onClick={testToken}>{tokenBusy ? 'Bitte warten …' : 'Token prüfen'}</button>}
{s.hasToken && s.adminChannelId && <button className="btn" type="button" disabled={msgBusy} onClick={testMessage}>{msgBusy ? 'Bitte warten …' : 'Testnachricht senden'}</button>}
</div>}
</form>
<p className="small muted" style={{ marginTop: 8 }}>Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}</p>
</div>
</>);
}

View file

@ -13,6 +13,7 @@ export default function SettingsLayout({ children }: { children: ReactNode }) {
{ href: '/einstellungen/backup', label: 'Backup', show: can('backup.read') },
{ href: '/einstellungen/firma', label: 'Firma', show: can('invoices.read') },
{ href: '/einstellungen/email', label: 'E-Mail', show: can('email.read') },
{ href: '/einstellungen/discord', label: 'Discord', show: can('discord.read') },
].filter((t) => t.show);
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
return (<>

View file

@ -1,9 +1,19 @@
import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction } from 'discord.js';
import { env } from './env.js';
import { pool } from '@kc/platform/db';
import { decrypt } from '@kc/platform/crypto';
interface DiscordSettings { enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; staffUserIds: string[] }
async function loadSettings(): Promise<DiscordSettings> {
const [rows] = await pool.query('SELECT * FROM discord_settings WHERE id = 1') as any;
const s = rows[0];
const token = s?.token_enc ? (JSON.parse(decrypt(s.token_enc)).token as string) : null;
return { enabled: !!s?.enabled, token, guildId: s?.guild_id ?? null, adminChannelId: s?.admin_channel_id ?? null, staffUserIds: String(s?.staff_user_ids ?? '').split(',').map((x: string) => x.trim()).filter(Boolean) };
}
let client: Client | null = null;
export const discordEnabled = () => !!env.discord.token;
let lastFingerprint = '';
let cachedEnabled = false;
export const discordEnabled = () => cachedEnabled;
export const discordReady = () => !!client?.isReady();
const commands = [
@ -14,7 +24,8 @@ const commands = [
async function handle(i: ChatInputCommandInteraction): Promise<void> {
// Alle Antworten ephemeral: nie vertrauliche Daten in Kanälen sichtbar machen.
if (!env.discord.staffUserIds.includes(i.user.id)) { await i.reply({ content: 'Keine Berechtigung.', ephemeral: true }); return; }
const s = await loadSettings();
if (!s.staffUserIds.includes(i.user.id)) { await i.reply({ content: 'Keine Berechtigung.', ephemeral: true }); return; }
if (i.commandName === 'kc-status') {
const [rows] = await pool.query('SELECT status, COUNT(*) n FROM jobs GROUP BY status') as any;
const jobs = rows.length ? rows.map((r: any) => `${r.status}: ${r.n}`).join(', ') : 'keine';
@ -26,26 +37,48 @@ async function handle(i: ChatInputCommandInteraction): Promise<void> {
}
}
export async function startDiscord(log: (m: string) => void): Promise<void> {
if (!env.discord.token) { log('Discord-Bot deaktiviert (DISCORD_BOT_TOKEN nicht gesetzt)'); return; }
async function connect(s: DiscordSettings, log: (m: string) => void): Promise<void> {
client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.on('interactionCreate', (i) => {
if (i.isChatInputCommand()) handle(i).catch((e) => { log(`Befehl fehlgeschlagen: ${(e as Error).message}`); i.replied || i.deferred ? undefined : i.reply({ content: 'Fehler.', ephemeral: true }).catch(() => undefined); });
});
client.on('error', (e) => log(`Discord-Fehler: ${e.message}`));
client.once('ready', async (c) => {
log(`Discord verbunden als ${c.user.tag}`);
if (env.discord.guildId) await new REST().setToken(env.discord.token!).put(Routes.applicationGuildCommands(c.user.id, env.discord.guildId), { body: commands });
else log('DISCORD_GUILD_ID fehlt: Slash-Befehle nicht registriert');
await pool.query('UPDATE discord_settings SET last_connected_at = UTC_TIMESTAMP(3), last_error = NULL WHERE id = 1');
if (s.guildId) {
await new REST().setToken(s.token!).put(Routes.applicationGuildCommands(c.user.id, s.guildId), { body: commands })
.catch(async (e) => { const msg = `Server-Befehle konnten nicht registriert werden: ${(e as Error).message}`.slice(0, 500); log(msg); await pool.query('UPDATE discord_settings SET last_error = ? WHERE id = 1', [msg]); });
} else log('Keine Server-ID hinterlegt: Slash-Befehle nicht registriert.');
});
await client.login(env.discord.token);
await client.login(s.token!);
}
/** Prüft, ob sich Token/Server/Aktivierung geändert haben, und verbindet bei Bedarf neu. Für periodischen Aufruf gedacht. */
export async function syncDiscord(log: (m: string) => void): Promise<void> {
const s = await loadSettings();
cachedEnabled = s.enabled && !!s.token;
const fp = `${s.enabled}|${s.token}|${s.guildId}`;
if (fp === lastFingerprint) return;
lastFingerprint = fp;
if (client) { await client.destroy().catch(() => undefined); client = null; }
if (!cachedEnabled) { log('Discord-Bot deaktiviert oder kein Token hinterlegt.'); return; }
try { await connect(s, log); }
catch (e) {
const msg = String((e as Error).message).slice(0, 500);
log(`Discord-Verbindung fehlgeschlagen: ${msg}`);
await pool.query('UPDATE discord_settings SET last_error = ? WHERE id = 1', [msg]);
}
}
/** Sendet eine Nachricht in den Admin-Kanal. Wirft bei Fehlern, damit der Job wiederholt wird. */
export async function notifyAdmin(text: string): Promise<'sent' | 'skipped'> {
if (!client || !env.discord.adminChannelId) return 'skipped';
const ch = await client.channels.fetch(env.discord.adminChannelId);
if (!client?.isReady()) return 'skipped';
const s = await loadSettings();
if (!s.adminChannelId) return 'skipped';
const ch = await client.channels.fetch(s.adminChannelId);
if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Admin-Kanal nicht gefunden oder kein Textkanal');
await ch.send({ content: text, allowedMentions: { parse: [] } });
return 'sent';
}
export async function stopDiscord(): Promise<void> { await client?.destroy(); }
export async function stopDiscord(): Promise<void> { await client?.destroy().catch(() => undefined); client = null; }

View file

@ -3,11 +3,5 @@ import { config } from '@kc/platform/config';
/** Worker-spezifische Optionen; DB/Secrets kommen aus @kc/platform. */
export const env = {
db: config.db,
discord: {
token: process.env.DISCORD_BOT_TOKEN || null,
guildId: process.env.DISCORD_GUILD_ID || null,
adminChannelId: process.env.DISCORD_ADMIN_CHANNEL_ID || null,
staffUserIds: (process.env.DISCORD_STAFF_USER_IDS ?? '').split(',').map((s) => s.trim()).filter(Boolean),
},
healthPort: Number(process.env.KC_WORKER_HEALTH_PORT ?? 4102),
};

View file

@ -1,7 +1,7 @@
import http from 'node:http';
import { env } from './env.js';
import { pool } from '@kc/platform/db';
import { discordEnabled, discordReady, startDiscord, stopDiscord } from './discord.js';
import { discordEnabled, discordReady, syncDiscord, stopDiscord } from './discord.js';
import { recoverStale, runOnce } from './jobs.js';
import { enqueue } from '@kc/platform/jobs';
import { processContractLifecycle, scheduleDueSyncs } from '@kc/connectors';
@ -21,7 +21,8 @@ http.createServer(async (req, res) => {
}).listen(env.healthPort, '127.0.0.1');
await recoverStale(log);
startDiscord(log).catch((e) => log(`Discord-Start fehlgeschlagen: ${(e as Error).message}`));
void syncDiscord(log);
setInterval(() => void syncDiscord(log), 60_000); // erkennt geänderte Einstellungen (Einstellungen > Discord) und verbindet bei Bedarf neu
setInterval(() => recoverStale(log).catch(() => undefined), 60_000);
// Regelmäßiger Abgleich: fällige Connector-Instanzen als Aufträge einplanen (idempotent pro Zeitfenster)
const schedule = () => scheduleDueSyncs((t, p, o) => enqueue(t, p, o)).catch((e) => log(`Planung fehlgeschlagen: ${(e as Error).message}`));