feat(discord): Ticket-Threads mit bidirektionaler Synchronisierung

Zweiter Teil des Discord-Ausbaus. Hat ein Kunde sein Konto verknüpft
(siehe vorheriger Commit), bekommt sein Ticket automatisch einen
privaten Thread im konfigurierten Ticket-Kanal:

- Neuer Job discord.ticket_sync: legt den Thread beim ersten Bedarf
  an (Name "ticket-<Nummer>", Kunde wird als Mitglied hinzugefügt)
  und postet dort jede neue, nicht-interne Nachricht (sowohl Kunden-
  als auch Personal-Antworten). Wird von /tickets (Erstellung durch
  Kunden) und /tickets/:id/messages (Antworten, keine internen
  Notizen) ausgelöst.
- Umgekehrte Richtung: Antwortet der Kunde direkt im Thread, übernimmt
  ein neuer messageCreate-Listener im Bot die Nachricht als
  Ticket-Antwort – nur wenn der Absender über discord_user_id
  eindeutig als Mitglied der Organisation des Tickets verifiziert ist.
  Personal-Nachrichten im Thread werden bewusst nicht übernommen (Web-
  Oberfläche bleibt die Quelle für Personal-Antworten, kein Echo).
- Dafür nötig: privilegierte Message Content Intent (war in der
  Anleitung aus dem vorigen Schritt schon vermerkt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-28 23:44:53 +02:00
parent fcbd34b0c6
commit 1cd1a8b03a
3 changed files with 54 additions and 5 deletions

View file

@ -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<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) };
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<string, unknown>, 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<void> {
}
}
/** 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<void> {
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<void> {
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<void> {
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}`);

View file

@ -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<string, Handler> = {
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);