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,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 (<>