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

@ -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}`));