diff --git a/apps/api/src/modules/discord/index.ts b/apps/api/src/modules/discord/index.ts new file mode 100644 index 0000000..f48b166 --- /dev/null +++ b/apps/api/src/modules/discord/index.ts @@ -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 }; + }); + }, +}; diff --git a/apps/api/src/modules/index.ts b/apps/api/src/modules/index.ts index 9533bd5..06db281 100644 --- a/apps/api/src/modules/index.ts +++ b/apps/api/src/modules/index.ts @@ -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]; diff --git a/apps/web/src/app/(app)/einstellungen/discord/page.tsx b/apps/web/src/app/(app)/einstellungen/discord/page.tsx new file mode 100644 index 0000000..d28cb5b --- /dev/null +++ b/apps/web/src/app/(app)/einstellungen/discord/page.tsx @@ -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 (
+ Anleitung: Discord-Bot einrichten (aufklappen) +
    +
  1. Anwendung anlegen: Auf discord.com/developers/applications auf „New Application“ klicken, einen Namen vergeben (z. B. „Kundencenter“).
  2. +
  3. Bot-Token erzeugen: 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.
  4. +
  5. Bot einladen: Im Reiter „OAuth2 → URL Generator“ die Scopes bot und applications.commands 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.
  6. +
  7. IDs ermitteln: 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.
  8. +
  9. Hier eintragen: 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 /kc-status (Systemzustand) und /kc-kunde (Kundensuche) nutzen dürfen.
  10. +
+
); +} + +export default function DiscordSettings() { + const { can } = useSession(); const w = can('discord.write'); + const [s, setS] = useState(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('GET', '/admin/discord/settings').then(setS).catch((e) => setErr(errMsg(e))), []); + useEffect(() => { void load(); }, [load]); + + async function save(e: FormEvent) { + 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 ? {err} :

Wird geladen …

; + return (<> +

Discord

+ {err && {err}} + +
+

Bot-Verbindung

+

Der Bot meldet Systemereignisse (neue Tickets, Rechnungen, Backup-Probleme) im gewählten Kanal und beantwortet die Befehle /kc-status und /kc-kunde für die unten eingetragenen Personen.

+ {!s.configured && Noch kein Token gespeichert.} + {s.configured && !s.enabled && Token gespeichert, aber deaktiviert – der Bot verbindet sich nicht.} + {s.lastError && Letzte Störung: {s.lastError}} + {msg && {msg.t}} +
+
+ + + + +
+ + {w &&
+ {s.hasToken && } + {s.hasToken && s.adminChannelId && } +
} +
+

Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}

+
+ ); +} diff --git a/apps/web/src/app/(app)/einstellungen/layout.tsx b/apps/web/src/app/(app)/einstellungen/layout.tsx index 479d5df..d12f32c 100644 --- a/apps/web/src/app/(app)/einstellungen/layout.tsx +++ b/apps/web/src/app/(app)/einstellungen/layout.tsx @@ -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 Keine Berechtigung.; return (<> diff --git a/apps/worker/src/discord.ts b/apps/worker/src/discord.ts index 0ae1a62..e95714b 100644 --- a/apps/worker/src/discord.ts +++ b/apps/worker/src/discord.ts @@ -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 { + 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 { // 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 { } } -export async function startDiscord(log: (m: string) => void): Promise { - if (!env.discord.token) { log('Discord-Bot deaktiviert (DISCORD_BOT_TOKEN nicht gesetzt)'); return; } +async function connect(s: DiscordSettings, log: (m: string) => void): Promise { 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 { + 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 { await client?.destroy(); } +export async function stopDiscord(): Promise { await client?.destroy().catch(() => undefined); client = null; } diff --git a/apps/worker/src/env.ts b/apps/worker/src/env.ts index 7ce8b28..824b38c 100644 --- a/apps/worker/src/env.ts +++ b/apps/worker/src/env.ts @@ -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), }; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 131a934..1e2d0c5 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -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}`)); diff --git a/migrations/026_discord_settings.sql b/migrations/026_discord_settings.sql new file mode 100644 index 0000000..532a681 --- /dev/null +++ b/migrations/026_discord_settings.sql @@ -0,0 +1,15 @@ +-- Discord-Bot-Konfiguration über die Oberfläche statt nur über Umgebungsvariablen (analog SMTP/IMAP). +CREATE TABLE discord_settings ( + id TINYINT PRIMARY KEY DEFAULT 1, + enabled TINYINT(1) NOT NULL DEFAULT 0, + token_enc TEXT NULL, + guild_id VARCHAR(32) NULL, + admin_channel_id VARCHAR(32) NULL, + staff_user_ids VARCHAR(1000) NULL, + last_error VARCHAR(500) NULL, + last_connected_at DATETIME(3) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by CHAR(36) NULL REFERENCES users(id), + CONSTRAINT chk_discord_singleton CHECK (id = 1) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +INSERT INTO discord_settings (id) VALUES (1);