Discord: Tickets als eigene Kanäle in einer Kategorie, Ticket-Meldungen nur für den Support

Mit gesetzter Ticket-Kategorie bekommt jedes offene Ticket einen eigenen Kanal,
sichtbar nur für die Support-Rollen, den Bot und die per Discord verknüpften
Mitglieder des Kunden. Ein minütlicher Abgleich im Worker legt fehlende Kanäle an
(auch für Tickets aus Mail-Eingang/Vertragsverlängerung) und entfernt Kanäle
geschlossener Tickets. Ticket-Meldungen gehen in einen eigenen Support-Kanal
(#ticket-log, wird automatisch angelegt) statt in den Systemmeldungs-Kanal.
Ohne Kategorie bleibt das bisherige Verhalten (private Threads) unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-10-02 12:11:44 +02:00
parent 711b99bb34
commit 8ef0b3bc05
6 changed files with 163 additions and 20 deletions

View file

@ -1,15 +1,24 @@
import { ChannelType, Client, GatewayIntentBits, PermissionFlagsBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction, type Message } from 'discord.js';
import { ChannelType, Client, GatewayIntentBits, OverwriteType, PermissionFlagsBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction, type Guild, type Message, type OverwriteResolvable, type TextChannel } 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';
import { config } from '@kc/platform/config';
interface DiscordSettings { enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null }
interface DiscordSettings {
enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null;
/** Kategorie-Modus: je offenem Ticket ein eigener Kanal in dieser Kategorie (hat Vorrang vor ticketChannelId/Threads). */
ticketCategoryId: string | null; ticketLogChannelId: string | null; supportRoleIds: 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, ticketChannelId: s?.ticket_channel_id ?? null };
return {
enabled: !!s?.enabled, token, guildId: s?.guild_id ?? null, adminChannelId: s?.admin_channel_id ?? null, ticketChannelId: s?.ticket_channel_id ?? null,
ticketCategoryId: s?.ticket_category_id ?? null, ticketLogChannelId: s?.ticket_log_channel_id ?? null,
supportRoleIds: String(s?.support_role_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 });
async function nextTicketNumber(): Promise<string> {
@ -63,7 +72,8 @@ async function handleTicketCreate(i: ChatInputCommandInteraction): Promise<void>
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), ticketId, customer.id, 'customer', body]);
await notify('ticket.created', { number, subject: subject.slice(0, 100) }, `discord-cmd:${ticketId}`);
await postTicketMessage(ticketId, body, 'Kunde');
await i.editReply({ content: `Ticket ${number} wurde erstellt. Den zugehörigen Thread findest du weiter unten im Kanal.` });
const ch = await ticketChannelId(ticketId);
await i.editReply({ content: ch ? `Ticket ${number} wurde erstellt: <#${ch}>` : `Ticket ${number} wurde erstellt.` });
return;
}
@ -81,7 +91,9 @@ async function handleTicketCreate(i: ChatInputCommandInteraction): Promise<void>
// Von Personal angelegt: wartet auf den Kunden, wie beim Anlegen über die Web-Oberfläche.
await pool.query('INSERT INTO tickets (id, number, org_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?)', [ticketId, number, org.id, subject, 'pending_customer', 'normal', staff.id]);
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), ticketId, staff.id, 'staff', body]);
await i.editReply({ content: `Ticket ${number} für ${org.name} (${org.customer_number}) wurde angelegt.` });
await postTicketMessage(ticketId, body, 'Support');
const ch = await ticketChannelId(ticketId);
await i.editReply({ content: `Ticket ${number} für ${org.name} (${org.customer_number}) wurde angelegt.${ch ? ` <#${ch}>` : ''}` });
}
/** Weist das Ticket des aktuellen Threads einer per Discord verknüpften Personal-Person zu. */
async function handleAssign(i: ChatInputCommandInteraction): Promise<void> {
@ -100,7 +112,8 @@ async function handleClose(i: ChatInputCommandInteraction): Promise<void> {
const t = await ticketForThread(i.channelId);
if (!t) { await i.reply({ content: 'Dieser Befehl funktioniert nur innerhalb eines Ticket-Threads.', ephemeral: true }); return; }
await pool.query("UPDATE tickets SET status = 'closed', closed_at = UTC_TIMESTAMP(3) WHERE id = ?", [t.ticket_id]);
await i.reply({ content: `Ticket ${t.number} wurde geschlossen.` });
const categoryMode = !!(await loadSettings()).ticketCategoryId;
await i.reply({ content: `Ticket ${t.number} wurde geschlossen.${categoryMode ? ' Dieser Kanal wird innerhalb einer Minute entfernt, der Verlauf bleibt im Kundencenter.' : ''}` });
}
async function handle(i: ChatInputCommandInteraction): Promise<void> {
@ -120,9 +133,9 @@ 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). */
/** Nachricht im Ticket-Thread bzw. Ticket-Kanal von einem Kunden: als Ticket-Nachricht übernehmen. Personal-Nachrichten 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;
if (msg.author.bot || !msg.inGuild()) return;
const t = await pool.query('SELECT tt.ticket_id, tk.number, tk.subject, tk.org_id, tk.status 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;
if (t.status === 'closed') { await msg.reply('Dieses Ticket ist geschlossen. Bitte im Kundencenter ein neues Ticket eröffnen oder den Befehl `/ticket` nutzen.').catch(() => undefined); return; }
@ -138,10 +151,114 @@ async function handleThreadMessage(msg: Message, log: (m: string) => void): Prom
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. */
// ---- Kategorie-Modus: je offenem Ticket ein eigener Kanal ------------------------------------------------------------
// Sichtbar nur für die Support-Rollen, den Bot und die per Discord verknüpften Mitglieder des Kunden (@everyone: nein).
// Die Kategorie zeigt damit genau die offenen Tickets; beim Schließen wird der Kanal gelöscht (Verlauf bleibt im Kundencenter).
const CHANNEL_PERMS = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.AttachFiles];
const BOT_PERMS = [...CHANNEL_PERMS, PermissionFlagsBits.ManageChannels];
const slug = (v: string) => v.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
const ticketLink = (id: string) => `${config.baseUrl}/tickets/${id}`;
/** Discord-Konten der Kunden-Mitglieder (nur verknüpfte, aktive Benutzer der Organisation). */
const orgDiscordIds = (orgId: string) => pool.query("SELECT u.discord_user_id FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? AND u.kind = 'customer' AND u.status = 'active' AND u.discord_user_id IS NOT NULL", [orgId])
.then(([r]: any) => (r as { discord_user_id: string }[]).map((x) => x.discord_user_id));
function overwrites(guild: Guild, s: DiscordSettings, customerIds: string[]): OverwriteResolvable[] {
return [
{ id: guild.roles.everyone.id, type: OverwriteType.Role, deny: [PermissionFlagsBits.ViewChannel] },
{ id: guild.client.user.id, type: OverwriteType.Member, allow: BOT_PERMS },
...s.supportRoleIds.map((id) => ({ id, type: OverwriteType.Role, allow: CHANNEL_PERMS })),
...customerIds.map((id) => ({ id, type: OverwriteType.Member, allow: CHANNEL_PERMS })),
];
}
/** Seriell je Ticket, damit Abgleich und Auftrag nicht gleichzeitig zwei Kanäle für dasselbe Ticket anlegen. */
const locks = new Map<string, Promise<unknown>>();
function serial<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prev = locks.get(key) ?? Promise.resolve();
const next = prev.catch(() => undefined).then(fn);
locks.set(key, next);
void next.finally(() => { if (locks.get(key) === next) locks.delete(key); }).catch(() => undefined);
return next;
}
async function fetchChannel(id: string): Promise<TextChannel | null> {
const ch = await client!.channels.fetch(id).catch((e) => { if ((e as { code?: number }).code === 10003) return null; throw e; }); // 10003 = Unknown Channel
return ch && ch.type === ChannelType.GuildText ? ch : null;
}
/** Liefert den Kanal des Tickets und legt ihn bei Bedarf an (mit Kopfzeile und, falls gewünscht, der Eröffnungsnachricht). */
async function ensureTicketChannel(s: DiscordSettings, ticketId: string, withOpening: boolean): Promise<{ channel: TextChannel; created: boolean } | null> {
return serial(ticketId, async () => {
const t = await pool.query('SELECT tk.id, tk.number, tk.subject, tk.status, tk.org_id, o.name AS org_name, o.customer_number FROM tickets tk JOIN organizations o ON o.id = tk.org_id WHERE tk.id = ?', [ticketId]).then(([r]: any) => r[0]);
if (!t || t.status === 'closed') return null;
const known = 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 (known) {
const ch = await fetchChannel(known);
if (ch) return { channel: ch, created: false };
await pool.query('DELETE FROM ticket_discord_threads WHERE ticket_id = ?', [ticketId]); // in Discord gelöscht: neu anlegen
}
const category = await client!.channels.fetch(s.ticketCategoryId!);
if (!category || category.type !== ChannelType.GuildCategory) throw new Error('Ticket-Kategorie nicht gefunden oder keine Kategorie');
const customerIds = await orgDiscordIds(t.org_id);
const channel = await category.guild.channels.create({
name: `${slug(t.number)}-${slug(t.org_name)}`.slice(0, 100), type: ChannelType.GuildText, parent: category.id,
topic: `${t.number} · ${t.subject}`.slice(0, 1024), permissionOverwrites: overwrites(category.guild, s, customerIds), reason: `Ticket ${t.number}`,
});
await pool.query('INSERT INTO ticket_discord_threads (ticket_id, thread_id) VALUES (?,?)', [ticketId, channel.id]);
await channel.send({ content: `**${t.number} · ${t.subject}**\nKunde: ${t.org_name} (${t.customer_number})\nIm Kundencenter: ${ticketLink(t.id)}\n_Antworten des Kunden hier werden ins Ticket übernommen. Antworten des Supports bitte im Kundencenter schreiben._`.slice(0, 2000), allowedMentions: { parse: [] } });
if (withOpening) {
const first = await pool.query('SELECT m.body, m.author_kind FROM ticket_messages m WHERE m.ticket_id = ? AND m.internal_note = 0 ORDER BY m.created_at LIMIT 1', [ticketId]).then(([r]: any) => r[0]);
if (first) await channel.send({ content: `**${first.author_kind === 'staff' ? 'Support' : 'Kunde'}:** ${first.body}`.slice(0, 2000), allowedMentions: { parse: [] } });
}
return { channel, created: true };
});
}
/** Ergänzt Kunden-Mitglieder, die ihr Discord erst nach dem Anlegen des Kanals verknüpft haben (nur wenn etwas fehlt). */
async function grantCustomers(channel: TextChannel, orgId: string): Promise<void> {
for (const id of await orgDiscordIds(orgId)) {
if (!channel.permissionOverwrites.cache.has(id)) await channel.permissionOverwrites.create(id, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true, AttachFiles: true }, { type: OverwriteType.Member, reason: 'Kunde hat Discord verknüpft' });
}
}
/** Abgleich (minütlich): Kanäle für offene Tickets anlegen, Kanäle geschlossener Tickets löschen, Support-Meldekanal anlegen. */
export async function reconcileTicketChannels(log: (m: string) => void): Promise<void> {
if (!client?.isReady()) return;
const s = await loadSettings();
if (!s.ticketCategoryId) return;
if (!s.ticketLogChannelId) {
const category = await client.channels.fetch(s.ticketCategoryId);
if (category?.type === ChannelType.GuildCategory) {
const ch = await category.guild.channels.create({ name: 'ticket-log', type: ChannelType.GuildText, parent: category.id, topic: 'Meldungen zu neuen Tickets und Antworten (nur Support)', permissionOverwrites: overwrites(category.guild, s, []), reason: 'Ticket-Meldungen nur für den Support' });
await pool.query('UPDATE discord_settings SET ticket_log_channel_id = ? WHERE id = 1', [ch.id]);
log(`Discord: Support-Kanal #ticket-log angelegt (${ch.id}).`);
}
}
// Geschlossene Tickets: Kanal entfernen
const [closed] = await pool.query("SELECT tt.ticket_id, tt.thread_id, tk.number FROM ticket_discord_threads tt JOIN tickets tk ON tk.id = tt.ticket_id WHERE tk.status = 'closed'") as any;
for (const r of closed) {
await serial(r.ticket_id, async () => {
const ch = await fetchChannel(r.thread_id);
if (ch) await ch.delete(`Ticket ${r.number} geschlossen`);
await pool.query('DELETE FROM ticket_discord_threads WHERE ticket_id = ?', [r.ticket_id]);
});
log(`Discord: Kanal zu Ticket ${r.number} entfernt (geschlossen).`);
}
// Offene Tickets ohne Kanal (z. B. aus Mail-Eingang oder Vertragsverlängerung). Erst nach 2 Minuten, damit der
// Auftrag zur Eröffnungsnachricht zuerst greift und die Nachricht nicht doppelt erscheint.
const [open] = await pool.query("SELECT tk.id, tk.org_id, tt.thread_id FROM tickets tk LEFT JOIN ticket_discord_threads tt ON tt.ticket_id = tk.id WHERE tk.status <> 'closed' AND (tt.thread_id IS NOT NULL OR tk.created_at < DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 MINUTE))") as any;
for (const r of open) {
const res = await ensureTicketChannel(s, r.id, true);
if (res && !res.created) await grantCustomers(res.channel, r.org_id);
}
}
/** Nachricht ins Ticket in Discord übernehmen. Kategorie-Modus: Kanal je Ticket (legt ihn bei Bedarf an).
* Sonst (Altmodus): privater Thread im Ticket-Kanal, nur wenn der Ersteller Discord verknüpft hat. */
export async function postTicketMessage(ticketId: string, body: string, authorLabel: string): Promise<void> {
if (!client?.isReady()) return;
const s = await loadSettings();
if (s.ticketCategoryId) {
const res = await ensureTicketChannel(s, ticketId, false);
if (!res) return; // Ticket geschlossen oder unbekannt
const t = await pool.query('SELECT org_id FROM tickets WHERE id = ?', [ticketId]).then(([r]: any) => r[0]);
if (t && !res.created) await grantCustomers(res.channel, t.org_id);
await res.channel.send({ content: `**${authorLabel}:** ${body}`.slice(0, 2000), allowedMentions: { parse: [] } });
return;
}
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
@ -160,6 +277,8 @@ export async function postTicketMessage(ticketId: string, body: string, authorLa
if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Ticket-Thread nicht gefunden');
await ch.send({ content: `**${authorLabel}:** ${body}`.slice(0, 2000) });
}
/** Kanal-ID des Tickets in Discord (Thread oder Kanal), falls vorhanden. */
export const ticketChannelId = (ticketId: string) => pool.query('SELECT thread_id FROM ticket_discord_threads WHERE ticket_id = ?', [ticketId]).then(([r]: any) => r[0]?.thread_id as string | undefined);
async function connect(s: DiscordSettings, log: (m: string) => void): Promise<void> {
client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
@ -196,13 +315,14 @@ export async function syncDiscord(log: (m: string) => void): Promise<void> {
}
}
/** Sendet eine Nachricht in den Admin-Kanal. Wirft bei Fehlern, damit der Job wiederholt wird. */
export async function notifyAdmin(text: string): Promise<'sent' | 'skipped'> {
/** Sendet eine Nachricht in den Admin-Kanal (Ticket-Meldungen in den Support-Kanal #ticket-log, falls angelegt). Wirft bei Fehlern, damit der Job wiederholt wird. */
export async function notifyAdmin(text: string, target: 'admin' | 'tickets' = 'admin'): Promise<'sent' | 'skipped'> {
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');
const channelId = target === 'tickets' && s.ticketLogChannelId ? s.ticketLogChannelId : s.adminChannelId;
if (!channelId) return 'skipped';
const ch = await client.channels.fetch(channelId);
if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Meldekanal nicht gefunden oder kein Textkanal');
await ch.send({ content: text, allowedMentions: { parse: [] } });
return 'sent';
}

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, syncDiscord, stopDiscord } from './discord.js';
import { discordEnabled, discordReady, reconcileTicketChannels, syncDiscord, stopDiscord } from './discord.js';
import { recoverStale, runOnce } from './jobs.js';
import { enqueue } from '@kc/platform/jobs';
import { processContractLifecycle, scheduleDueSyncs } from '@kc/connectors';
@ -24,6 +24,8 @@ await recoverStale(log);
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);
// Discord-Ticketkanäle: offene Tickets bekommen einen Kanal, geschlossene verlieren ihn (nur im Kategorie-Modus)
setInterval(() => void reconcileTicketChannels(log).catch((e) => log(`Ticket-Kanäle: Abgleich fehlgeschlagen: ${(e as Error).message}`)), 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}`));
setInterval(schedule, 30_000); void schedule();

View file

@ -23,7 +23,7 @@ const handlers: Record<string, Handler> = {
: p.event === 'ticket.message' ? `🎫 Neue Nachricht zu Ticket ${p.number}`
: p.event === 'invoice.issued' ? `🧾 Rechnung ${p.number} ausgestellt (${(Number(p.gross ?? 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })})` : null; // keine personenbezogenen Daten
if (!text) throw new Error(`Unbekanntes Ereignis: ${p.event}`);
const r = await notifyAdmin(text);
const r = await notifyAdmin(text, String(p.event).startsWith('ticket.') ? 'tickets' : 'admin');
if (r === 'skipped') return 'übersprungen: Discord nicht konfiguriert';
},
'mail.template': async (p: { to: string; key: string; vars: Record<string, string> }) => {