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:
parent
711b99bb34
commit
8ef0b3bc05
6 changed files with 163 additions and 20 deletions
|
|
@ -12,6 +12,7 @@ const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs
|
|||
const settingsView = (s: any) => ({
|
||||
enabled: !!s.enabled, hasToken: !!s.token_enc, guildId: s.guild_id, adminChannelId: s.admin_channel_id,
|
||||
clientId: s.client_id, hasClientSecret: !!s.client_secret_enc, ticketChannelId: s.ticket_channel_id,
|
||||
ticketCategoryId: s.ticket_category_id, ticketLogChannelId: s.ticket_log_channel_id, supportRoleIds: s.support_role_ids,
|
||||
configured: !!s.token_enc, lastConnectedAt: s.last_connected_at, lastError: s.last_error, updatedAt: s.updated_at,
|
||||
});
|
||||
const redirectUri = () => `${config.baseUrl}/api/discord/oauth/callback`;
|
||||
|
|
@ -30,11 +31,17 @@ export const discordModule: KcModule = {
|
|||
token: z.string().max(200).nullable().optional(), // undefined = unverändert lassen, null = löschen
|
||||
guildId: z.string().trim().regex(ID).nullable(), adminChannelId: z.string().trim().regex(ID).nullable(),
|
||||
ticketChannelId: z.string().trim().regex(ID).nullable(),
|
||||
// optional, damit ältere Formulare die Werte nicht versehentlich löschen
|
||||
ticketCategoryId: z.string().trim().regex(ID).nullable().optional(), ticketLogChannelId: z.string().trim().regex(ID).nullable().optional(),
|
||||
supportRoleIds: z.string().trim().regex(/^\d{15,25}(\s*,\s*\d{15,25})*$/).max(500).nullable().optional(),
|
||||
enabled: z.boolean().default(false),
|
||||
clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(),
|
||||
}).parse(req.body);
|
||||
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'ticket_channel_id = ?', 'enabled = ?', 'client_id = ?', 'updated_by = ?'];
|
||||
const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.enabled ? 1 : 0, b.clientId, a.user.id];
|
||||
if (b.ticketCategoryId !== undefined) { sets.push('ticket_category_id = ?'); params.push(b.ticketCategoryId); }
|
||||
if (b.ticketLogChannelId !== undefined) { sets.push('ticket_log_channel_id = ?'); params.push(b.ticketLogChannelId); }
|
||||
if (b.supportRoleIds !== undefined) { sets.push('support_role_ids = ?'); params.push(b.supportRoleIds ? b.supportRoleIds.split(',').map((x) => x.trim()).join(',') : null); }
|
||||
if (b.token !== undefined) { sets.push('token_enc = ?'); params.push(b.token ? encrypt(JSON.stringify({ token: b.token })) : null); }
|
||||
if (b.clientSecret !== undefined) { sets.push('client_secret_enc = ?'); params.push(b.clientSecret ? encrypt(JSON.stringify({ secret: b.clientSecret })) : null); }
|
||||
await run(`UPDATE discord_settings SET ${sets.join(', ')} WHERE id = 1`, params);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { api, errMsg } from '@/lib/api';
|
|||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Field, fmt } from '@/components/ui';
|
||||
|
||||
interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string }
|
||||
interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; ticketCategoryId: string | null; ticketLogChannelId: string | null; supportRoleIds: string | null; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string }
|
||||
|
||||
/** Schritt-für-Schritt-Anleitung, damit auch ohne Discord-Vorwissen ein Bot eingerichtet werden kann. */
|
||||
function Guide({ redirectUri }: { redirectUri: string }) {
|
||||
|
|
@ -13,7 +13,7 @@ function Guide({ redirectUri }: { redirectUri: string }) {
|
|||
<ol className="small" style={{ margin: '16px 0 0', paddingLeft: 20, display: 'grid', gap: 12 }}>
|
||||
<li><strong>Anwendung anlegen:</strong> Auf <a href="https://discord.com/developers/applications" target="_blank" rel="noreferrer">discord.com/developers/applications</a> auf „New Application“ klicken, einen Namen vergeben (z. B. „Kundencenter“).</li>
|
||||
<li><strong>Bot-Token erzeugen:</strong> Im Reiter „Bot“ auf „Reset Token“ klicken und den Token kopieren. Er wird nur dieses eine Mal angezeigt – am besten direkt unten einfügen und speichern. Dort außerdem die <strong>Message Content Intent</strong> aktivieren (wird benötigt, damit Kundenantworten in Ticket-Threads gelesen werden können).</li>
|
||||
<li><strong>Bot einladen:</strong> Im Reiter „OAuth2 → URL Generator“ die Scopes <code className="mono">bot</code> und <code className="mono">applications.commands</code> ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „Create Private Threads“, „View Channels“ und „Read Message History“ auswählen. Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.</li>
|
||||
<li><strong>Bot einladen:</strong> Im Reiter „OAuth2 → URL Generator“ die Scopes <code className="mono">bot</code> und <code className="mono">applications.commands</code> ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „Create Private Threads“, „View Channels“ und „Read Message History“ auswählen (für Tickets als eigene Kanäle zusätzlich „Manage Channels“, „Manage Roles“ und „Attach Files“). Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.</li>
|
||||
<li><strong>Kunden-Anmeldung (OAuth) einrichten:</strong> Im Reiter „OAuth2 → General“ die <strong>Client ID</strong> und (unter „Reset Secret“) das <strong>Client Secret</strong> kopieren, unten eintragen. Unter „Redirects“ genau diese Adresse eintragen: <code className="mono">{redirectUri}</code></li>
|
||||
<li><strong>IDs ermitteln:</strong> In Discord unter Einstellungen → Erweitert den „Entwicklermodus“ aktivieren. Danach mit Rechtsklick auf den Server, die gewünschten Kanäle und die eigene Person jeweils „ID kopieren“ wählen.</li>
|
||||
<li><strong>Hier eintragen:</strong> Token, Server-ID, Kanal-ID für Systemmeldungen (Backup-Warnungen, neue Tickets) und Kanal-ID für Ticket-Threads unten speichern.</li>
|
||||
|
|
@ -37,7 +37,7 @@ export default function DiscordSettings() {
|
|||
const f = new FormData(form); const v = (k: string) => (String(f.get(k) ?? '').trim() || null);
|
||||
const token = String(f.get('token') ?? ''); const clientSecret = String(f.get('clientSecret') ?? '');
|
||||
try {
|
||||
await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) });
|
||||
await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), ticketCategoryId: v('ticketCategoryId'), ticketLogChannelId: v('ticketLogChannelId'), supportRoleIds: v('supportRoleIds'), enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) });
|
||||
setMsg({ k: 'ok', t: 'Gespeichert. Die Verbindung wird innerhalb einer Minute automatisch aufgebaut.' }); (form.elements.namedItem('token') as HTMLInputElement).value = ''; (form.elements.namedItem('clientSecret') as HTMLInputElement).value = ''; void load();
|
||||
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
|
||||
}
|
||||
|
|
@ -69,7 +69,14 @@ export default function DiscordSettings() {
|
|||
<Field id="token" label="Bot-Token" hint={s.hasToken ? 'Ein Token ist gespeichert. Leer lassen, um ihn unverändert zu lassen.' : 'Noch kein Token gespeichert.'}><input id="token" name="token" type="password" autoComplete="new-password" disabled={!w} /></Field>
|
||||
<Field id="guildId" label="Server-ID (Guild-ID)"><input id="guildId" name="guildId" defaultValue={s.guildId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
|
||||
<Field id="adminChannelId" label="Kanal-ID für Systemmeldungen"><input id="adminChannelId" name="adminChannelId" defaultValue={s.adminChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
|
||||
<Field id="ticketChannelId" label="Kanal-ID für Ticket-Threads"><input id="ticketChannelId" name="ticketChannelId" defaultValue={s.ticketChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
|
||||
<Field id="ticketChannelId" label="Kanal-ID für Ticket-Threads" hint="Nur ohne Ticket-Kategorie: private Threads, nur für Kunden mit verknüpftem Discord."><input id="ticketChannelId" name="ticketChannelId" defaultValue={s.ticketChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
|
||||
</div>
|
||||
<h3>Tickets als eigene Kanäle</h3>
|
||||
<p className="muted small">Mit einer Ticket-Kategorie bekommt jedes offene Ticket einen eigenen Kanal darin. Sehen können ihn nur die Support-Rollen und die Mitglieder des Kunden, die ihr Discord verknüpft haben. Beim Schließen wird der Kanal entfernt, der Verlauf bleibt im Kundencenter. Meldungen zu neuen Tickets gehen in den Support-Kanal (wird bei leerem Feld automatisch als <code className="mono">#ticket-log</code> angelegt). Der Bot braucht dafür auf dem Server die Berechtigungen „Kanäle verwalten“ und „Rollen verwalten“.</p>
|
||||
<div className="cols">
|
||||
<Field id="ticketCategoryId" label="Kategorie-ID für Tickets"><input id="ticketCategoryId" name="ticketCategoryId" defaultValue={s.ticketCategoryId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
|
||||
<Field id="supportRoleIds" label="Rollen-IDs Support (kommagetrennt)"><input id="supportRoleIds" name="supportRoleIds" defaultValue={s.supportRoleIds ?? ''} placeholder="123456789012345678, 234567890123456789" disabled={!w} /></Field>
|
||||
<Field id="ticketLogChannelId" label="Kanal-ID für Ticket-Meldungen (Support)"><input id="ticketLogChannelId" name="ticketLogChannelId" defaultValue={s.ticketLogChannelId ?? ''} placeholder="leer = automatisch anlegen" disabled={!w} /></Field>
|
||||
</div>
|
||||
<label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="enabled" defaultChecked={s.enabled} disabled={!w} style={{ width: 20, minHeight: 20 }} /> Bot aktiv</label>
|
||||
<p className="small muted">Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}</p>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> }) => {
|
||||
|
|
|
|||
7
migrations/038_discord_ticket_channels.sql
Normal file
7
migrations/038_discord_ticket_channels.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-- Discord: je offenem Ticket ein eigener Kanal in einer Kategorie (statt privater Threads), sichtbar nur für die
|
||||
-- Support-Rollen und die verknüpften Mitglieder des Kunden. Ticket-Meldungen gehen in einen eigenen Support-Kanal.
|
||||
-- ticket_discord_threads.thread_id enthält im Kategorie-Modus die Kanal-ID.
|
||||
ALTER TABLE discord_settings
|
||||
ADD COLUMN ticket_category_id VARCHAR(32) NULL,
|
||||
ADD COLUMN ticket_log_channel_id VARCHAR(32) NULL,
|
||||
ADD COLUMN support_role_ids VARCHAR(500) NULL;
|
||||
Loading…
Add table
Add a link
Reference in a new issue