diff --git a/apps/api/src/modules/tickets/index.ts b/apps/api/src/modules/tickets/index.ts index 76c6c85..f7bcc70 100644 --- a/apps/api/src/modules/tickets/index.ts +++ b/apps/api/src/modules/tickets/index.ts @@ -73,6 +73,7 @@ export const ticketsModule: KcModule = { if (!staff) { await notify('ticket.created', { number, subject: b.subject.slice(0, 100) }, `ticket.created:${id}`, req.correlationId); await mailTo(a.user.email, 'ticket_created', { name: a.user.name, number, subject: b.subject, ticketLink: ticketLink(id) }, `mail:ticket.created:${id}`, req.correlationId); + await enqueue('discord.ticket_sync', { ticketId: id, body: b.body, authorLabel: 'Kunde' }, { idempotencyKey: `discord:ticket.created:${id}`, correlationId: req.correlationId }); } return { id, number, messageId }; }); @@ -118,6 +119,7 @@ export const ticketsModule: KcModule = { }); await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.message', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { internalNote: b.internalNote } }); if (!b.internalNote && !staff) await notify('ticket.message', { number: t.number }, `ticket.message:${id}:${Date.now()}`, req.correlationId); + if (!b.internalNote) await enqueue('discord.ticket_sync', { ticketId: id, body: b.body, authorLabel: staff ? 'Support' : 'Kunde' }, { idempotencyKey: `discord:ticket.message:${messageId}`, correlationId: req.correlationId }); let emailed = false; if (!b.internalNote && staff && b.notifyEmail) { const creator = await one('SELECT email, name FROM users WHERE id = ?', [t.created_by]); diff --git a/apps/worker/src/discord.ts b/apps/worker/src/discord.ts index e95714b..d8a4415 100644 --- a/apps/worker/src/discord.ts +++ b/apps/worker/src/discord.ts @@ -1,14 +1,17 @@ -import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction } from 'discord.js'; +import { ChannelType, Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction, type Message } from 'discord.js'; +import { randomUUID } from 'node:crypto'; import { pool } from '@kc/platform/db'; import { decrypt } from '@kc/platform/crypto'; +import { enqueue } from '@kc/platform/jobs'; -interface DiscordSettings { enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; staffUserIds: string[] } +interface DiscordSettings { enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: 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) }; + return { enabled: !!s?.enabled, token, guildId: s?.guild_id ?? null, adminChannelId: s?.admin_channel_id ?? null, ticketChannelId: s?.ticket_channel_id ?? null, staffUserIds: String(s?.staff_user_ids ?? '').split(',').map((x: string) => x.trim()).filter(Boolean) }; } +const notify = (event: string, extra: Record, key: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key }); let client: Client | null = null; let lastFingerprint = ''; @@ -37,11 +40,52 @@ async function handle(i: ChatInputCommandInteraction): Promise { } } +/** Nachricht in einem Ticket-Thread von einem Kunden: als Ticket-Nachricht übernehmen. Personal-Nachrichten im Thread werden ignoriert (Web-Oberfläche bleibt die Quelle). */ +async function handleThreadMessage(msg: Message, log: (m: string) => void): Promise { + if (msg.author.bot || !msg.channel.isThread()) return; + const t = await pool.query('SELECT tt.ticket_id, tk.number, tk.subject, tk.org_id FROM ticket_discord_threads tt JOIN tickets tk ON tk.id = tt.ticket_id WHERE tt.thread_id = ?', [msg.channel.id]).then(([r]: any) => r[0]); + if (!t) return; + const u = await pool.query("SELECT id, name FROM users WHERE discord_user_id = ? AND kind = 'customer'", [msg.author.id]).then(([r]: any) => r[0]); + if (!u) return; // unbekannt oder Personal: Web-Oberfläche bleibt die Quelle für Personal-Antworten + const member = await pool.query('SELECT 1 AS x FROM memberships WHERE user_id = ? AND org_id = ?', [u.id, t.org_id]).then(([r]: any) => r[0]); + if (!member) return; + const body = msg.content.trim().slice(0, 10000); + if (!body) return; + await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), t.ticket_id, u.id, 'customer', body]); + await pool.query("UPDATE tickets SET status = 'pending_staff', last_message_at = UTC_TIMESTAMP(3), resolved_at = NULL, closed_at = NULL WHERE id = ?", [t.ticket_id]); + await notify('ticket.message', { number: t.number }, `discord-in:${msg.id}`); + log(`Discord: Antwort im Thread zu Ticket ${t.number} übernommen.`); +} + +/** Stellt sicher, dass ein privater Thread für das Ticket existiert (nur wenn der Kunde Discord verknüpft hat), und postet die Nachricht hinein. */ +export async function postTicketMessage(ticketId: string, body: string, authorLabel: string): Promise { + if (!client?.isReady()) return; + const s = await loadSettings(); + if (!s.ticketChannelId) return; + const t = await pool.query('SELECT tk.number, tk.subject, u.discord_user_id FROM tickets tk JOIN users u ON u.id = tk.created_by WHERE tk.id = ?', [ticketId]).then(([r]: any) => r[0]); + if (!t?.discord_user_id) return; // Kunde hat kein Discord verknüpft + let threadId = await pool.query('SELECT thread_id FROM ticket_discord_threads WHERE ticket_id = ?', [ticketId]).then(([r]: any) => r[0]?.thread_id as string | undefined); + if (!threadId) { + const parent = await client.channels.fetch(s.ticketChannelId); + if (!parent || parent.type !== ChannelType.GuildText) throw new Error('Ticket-Kanal nicht gefunden oder kein Textkanal'); + const thread = await parent.threads.create({ name: `ticket-${t.number}`.slice(0, 100), type: ChannelType.PrivateThread, invitable: false, reason: `Ticket ${t.number}` }); + await thread.members.add(t.discord_user_id); + await pool.query('INSERT INTO ticket_discord_threads (ticket_id, thread_id) VALUES (?,?)', [ticketId, thread.id]); + threadId = thread.id; + await thread.send({ content: `**${t.subject}**\n\n**${authorLabel}:** ${body}`.slice(0, 2000) }); + return; + } + const ch = await client.channels.fetch(threadId); + if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Ticket-Thread nicht gefunden'); + await ch.send({ content: `**${authorLabel}:** ${body}`.slice(0, 2000) }); +} + async function connect(s: DiscordSettings, log: (m: string) => void): Promise { - client = new Client({ intents: [GatewayIntentBits.Guilds] }); + client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] }); 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('messageCreate', (m) => handleThreadMessage(m, log).catch((e) => log(`Thread-Nachricht konnte nicht übernommen werden: ${(e as Error).message}`))); client.on('error', (e) => log(`Discord-Fehler: ${e.message}`)); client.once('ready', async (c) => { log(`Discord verbunden als ${c.user.tag}`); diff --git a/apps/worker/src/jobs.ts b/apps/worker/src/jobs.ts index 3ec8cf0..2d0d8ab 100644 --- a/apps/worker/src/jobs.ts +++ b/apps/worker/src/jobs.ts @@ -1,5 +1,5 @@ import { pool } from '@kc/platform/db'; -import { notifyAdmin } from './discord.js'; +import { notifyAdmin, postTicketMessage } from './discord.js'; import { JobFailure } from '@kc/platform/jobs'; import { sendTemplateMail } from '@kc/platform/mail'; import { executeAction, executeChild, provisionOrder, syncInstance, type ChildPayload, type ExecutePayload } from '@kc/connectors'; @@ -26,6 +26,9 @@ const handlers: Record = { if (status === 'no_template') throw new Error(`Unbekannte Mailvorlage: ${p.key}`); return status === 'not_configured' ? 'übersprungen: SMTP nicht konfiguriert' : status === 'disabled' ? 'übersprungen: Vorlage deaktiviert' : 'gesendet'; }, + 'discord.ticket_sync': async (p: { ticketId: string; body: string; authorLabel: string }) => { + await postTicketMessage(p.ticketId, p.body, p.authorLabel); + }, }; handlers['connector.sync'] = async (p, j) => { const r = await syncInstance(p.instanceId, j.correlationId);