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:
parent
fcbd34b0c6
commit
1cd1a8b03a
3 changed files with 54 additions and 5 deletions
|
|
@ -73,6 +73,7 @@ export const ticketsModule: KcModule = {
|
||||||
if (!staff) {
|
if (!staff) {
|
||||||
await notify('ticket.created', { number, subject: b.subject.slice(0, 100) }, `ticket.created:${id}`, req.correlationId);
|
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 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 };
|
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 } });
|
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 && !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;
|
let emailed = false;
|
||||||
if (!b.internalNote && staff && b.notifyEmail) {
|
if (!b.internalNote && staff && b.notifyEmail) {
|
||||||
const creator = await one('SELECT email, name FROM users WHERE id = ?', [t.created_by]);
|
const creator = await one('SELECT email, name FROM users WHERE id = ?', [t.created_by]);
|
||||||
|
|
|
||||||
|
|
@ -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 { pool } from '@kc/platform/db';
|
||||||
import { decrypt } from '@kc/platform/crypto';
|
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> {
|
async function loadSettings(): Promise<DiscordSettings> {
|
||||||
const [rows] = await pool.query('SELECT * FROM discord_settings WHERE id = 1') as any;
|
const [rows] = await pool.query('SELECT * FROM discord_settings WHERE id = 1') as any;
|
||||||
const s = rows[0];
|
const s = rows[0];
|
||||||
const token = s?.token_enc ? (JSON.parse(decrypt(s.token_enc)).token as string) : null;
|
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 client: Client | null = null;
|
||||||
let lastFingerprint = '';
|
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> {
|
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) => {
|
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); });
|
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.on('error', (e) => log(`Discord-Fehler: ${e.message}`));
|
||||||
client.once('ready', async (c) => {
|
client.once('ready', async (c) => {
|
||||||
log(`Discord verbunden als ${c.user.tag}`);
|
log(`Discord verbunden als ${c.user.tag}`);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { pool } from '@kc/platform/db';
|
import { pool } from '@kc/platform/db';
|
||||||
import { notifyAdmin } from './discord.js';
|
import { notifyAdmin, postTicketMessage } from './discord.js';
|
||||||
import { JobFailure } from '@kc/platform/jobs';
|
import { JobFailure } from '@kc/platform/jobs';
|
||||||
import { sendTemplateMail } from '@kc/platform/mail';
|
import { sendTemplateMail } from '@kc/platform/mail';
|
||||||
import { executeAction, executeChild, provisionOrder, syncInstance, type ChildPayload, type ExecutePayload } from '@kc/connectors';
|
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}`);
|
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';
|
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) => {
|
handlers['connector.sync'] = async (p, j) => {
|
||||||
const r = await syncInstance(p.instanceId, j.correlationId);
|
const r = await syncInstance(p.instanceId, j.correlationId);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue