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:
parent
8616e51adc
commit
c8da9362a7
8 changed files with 222 additions and 20 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue