[deploy] add ticket sla, pipeline, automations, kb
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
This commit is contained in:
@@ -11,15 +11,19 @@ import {
|
||||
GuildMember,
|
||||
PermissionsBitField,
|
||||
TextChannel,
|
||||
Client
|
||||
Client,
|
||||
Message
|
||||
} from 'discord.js';
|
||||
import { prisma } from '../database';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { TicketRecord } from '../utils/types';
|
||||
import { prisma } from '../database';
|
||||
import { TicketRecord, TicketStatus } from '../utils/types';
|
||||
import { logger } from '../utils/logger';
|
||||
import { settingsStore } from '../config/state';
|
||||
import { env } from '../config/env';
|
||||
import { context } from '../config/context';
|
||||
|
||||
const PIPELINE_STATUS: TicketStatus[] = ['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt'];
|
||||
|
||||
export class TicketService {
|
||||
private categoryName = 'Tickets';
|
||||
@@ -40,15 +44,22 @@ export class TicketService {
|
||||
return `#${String(n).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private normalizeStatus(status: string): TicketStatus {
|
||||
if (PIPELINE_STATUS.includes(status as TicketStatus)) return status as TicketStatus;
|
||||
if (status === 'open') return 'neu';
|
||||
if (status === 'in-progress') return 'in_bearbeitung';
|
||||
if (status === 'closed') return 'erledigt';
|
||||
return 'neu';
|
||||
}
|
||||
|
||||
public async createTicket(interaction: ChatInputCommandInteraction): Promise<TicketRecord | null> {
|
||||
if (!interaction.guildId || !interaction.guild) return null;
|
||||
// TODO: TICKETS: Modul-Check ueber BotModuleService kapseln (Dashboard-Toggle statt direktem Flag).
|
||||
if (!this.isEnabled(interaction.guildId)) {
|
||||
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
|
||||
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
|
||||
return null;
|
||||
}
|
||||
const existing = await prisma.ticket.findFirst({
|
||||
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
|
||||
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { notIn: ['closed', 'erledigt'] } }
|
||||
});
|
||||
if (existing) {
|
||||
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket.', ephemeral: true });
|
||||
@@ -58,7 +69,6 @@ export class TicketService {
|
||||
}
|
||||
|
||||
public async handleButton(interaction: ButtonInteraction) {
|
||||
// TODO: TICKETS: Button-Handling modularisieren und Kategorien aus Dashboard-Konfig laden.
|
||||
if (!interaction.guild) return;
|
||||
if (interaction.customId === 'support:toggle') {
|
||||
await this.toggleSupport(interaction);
|
||||
@@ -66,28 +76,26 @@ export class TicketService {
|
||||
}
|
||||
if (interaction.customId.startsWith('ticket:create:')) {
|
||||
if (!this.isEnabled(interaction.guild.id)) {
|
||||
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
|
||||
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const topic = interaction.customId.split(':')[2] || 'allgemein';
|
||||
const existing = await prisma.ticket.findFirst({
|
||||
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
|
||||
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { notIn: ['closed', 'erledigt'] } }
|
||||
});
|
||||
if (existing) {
|
||||
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schlieÃe es zuerst.', ephemeral: true });
|
||||
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schließe es zuerst.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const record = await this.openTicket(interaction.guild, interaction.member as GuildMember, topic);
|
||||
if (record) {
|
||||
await interaction.reply({ content: 'Ticket erstellt! Schau im neuen Kanal nach.', ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content: 'Ticket konnte nicht erstellt werden.', ephemeral: true });
|
||||
}
|
||||
await interaction.reply({
|
||||
content: record ? 'Ticket erstellt! Schau im neuen Kanal nach.' : 'Ticket konnte nicht erstellt werden.',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: TICKETS: Claim-Flow per Rollen/SLAs konfigurierbar machen und ins Dashboard syncen.
|
||||
if (interaction.customId === 'ticket:claim') {
|
||||
const ticket = await this.getTicketByChannel(interaction);
|
||||
if (!ticket) {
|
||||
@@ -98,19 +106,21 @@ export class TicketService {
|
||||
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
|
||||
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
|
||||
await prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { claimedBy: interaction.user.id, status: 'in_bearbeitung', firstClaimAt: ticket.firstClaimAt ?? new Date() }
|
||||
});
|
||||
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
|
||||
await this.runAutomations({ ...ticket, status: 'in_bearbeitung', guildId: interaction.guild.id });
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: TICKETS: Close-Flow modularisieren (Feedback, Transcript-Optionen) und Dashboard-Status sofort aktualisieren.
|
||||
if (interaction.customId === 'ticket:close') {
|
||||
const ok = await this.closeTicketButton(interaction);
|
||||
if (!ok) await interaction.reply({ content: 'Ticket konnte nicht geschlossen werden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: TICKETS: Transcript-Export in Storage/DB ablegen und Download-Link ins Dashboard liefern.
|
||||
if (interaction.customId === 'ticket:transcript') {
|
||||
const ticket = await this.getTicketByChannel(interaction);
|
||||
if (!ticket) {
|
||||
@@ -130,18 +140,21 @@ export class TicketService {
|
||||
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
|
||||
return true;
|
||||
}
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
|
||||
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
|
||||
await prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { claimedBy: interaction.user.id, status: 'in_bearbeitung', firstClaimAt: ticket.firstClaimAt ?? new Date() }
|
||||
});
|
||||
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
|
||||
await this.runAutomations({ ...ticket, status: 'in_bearbeitung', guildId: channel.guildId });
|
||||
return true;
|
||||
}
|
||||
|
||||
public async closeTicket(interaction: ChatInputCommandInteraction, reason?: string) {
|
||||
// TODO: TICKETS: Dashboard-Action zum Schliessen mit optionalen Vorlagen/Gründen anbinden und Bot/DB synchronisieren.
|
||||
const channel = interaction.channel as TextChannel;
|
||||
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
|
||||
if (!ticket) return false;
|
||||
const transcriptPath = await this.exportTranscript(channel, ticket.id);
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'erledigt', transcript: transcriptPath } });
|
||||
await channel.send({ content: `Ticket geschlossen. Grund: ${reason ?? 'Kein Grund angegeben'}` }).catch(() => undefined);
|
||||
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
|
||||
await channel.delete('Ticket geschlossen');
|
||||
@@ -161,10 +174,10 @@ export class TicketService {
|
||||
);
|
||||
|
||||
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder().setCustomId('ticket:create:ban').setLabel('Ban Einspruch').setEmoji('🛡').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('ticket:create:help').setLabel('Discord-Hilfe').setEmoji('🛠').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('ticket:create:feedback').setLabel('Feedback').setEmoji('💬').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('ticket:create:other').setLabel('Allgemeine Frage').setEmoji('❓').setStyle(ButtonStyle.Secondary)
|
||||
new ButtonBuilder().setCustomId('ticket:create:ban').setLabel('Ban Einspruch').setEmoji('🚫').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('ticket:create:help').setLabel('Discord-Hilfe').setEmoji('💬').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('ticket:create:feedback').setLabel('Feedback').setEmoji('📝').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('ticket:create:other').setLabel('Allgemeine Frage').setEmoji('❔').setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
return { embed, buttons };
|
||||
@@ -179,17 +192,13 @@ export class TicketService {
|
||||
.setTitle(config.title || 'Ticket Support')
|
||||
.setDescription(
|
||||
config.description ||
|
||||
'Bitte wähle eine Kategorie, um ein Ticket zu erstellen. Sei respektvoll & sachlich. Missbrauch führt zu Konsequenzen.'
|
||||
'Bitte wähle eine Kategorie, um ein Ticket zu erstellen. Sei respektvoll & sachlich. Missbrauch führt zu Konsequenzen.'
|
||||
)
|
||||
.setColor(0xf97316);
|
||||
|
||||
const buttons = new ActionRowBuilder<ButtonBuilder>();
|
||||
// TODO: TICKETS: Panel-Definitionen versionieren und im Dashboard editierbar machen (Labels/Emojis/Permissions).
|
||||
config.categories.slice(0, 5).forEach((cat) => {
|
||||
const btn = new ButtonBuilder()
|
||||
.setCustomId(`ticket:create:${cat.customId}`)
|
||||
.setLabel(cat.label)
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
const btn = new ButtonBuilder().setCustomId(`ticket:create:${cat.customId}`).setLabel(cat.label).setStyle(ButtonStyle.Secondary);
|
||||
if (cat.emoji) btn.setEmoji(cat.emoji);
|
||||
buttons.addComponents(btn);
|
||||
});
|
||||
@@ -197,8 +206,6 @@ export class TicketService {
|
||||
}
|
||||
|
||||
public async exportTranscript(channel: TextChannel, ticketId: string) {
|
||||
// TODO: TICKETS: Transcript-Speicherung abstrahieren (z.B. S3/DB) und Status ans Dashboard senden.
|
||||
// Discord API limit: max 100 per fetch
|
||||
const messages = await channel.messages.fetch({ limit: 100 });
|
||||
const lines = messages
|
||||
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
|
||||
@@ -254,11 +261,10 @@ export class TicketService {
|
||||
const channel = await guild.channels.create({
|
||||
name: channelName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
parent: category as any,
|
||||
permissionOverwrites: overwrites
|
||||
});
|
||||
|
||||
// TODO: TICKETS: Status/Metadaten beim Erstellen direkt ans Dashboard pushen (WebSocket) und SLA speichern.
|
||||
const record = await prisma.ticket.create({
|
||||
data: {
|
||||
userId: member.id,
|
||||
@@ -266,14 +272,15 @@ export class TicketService {
|
||||
guildId: guild.id,
|
||||
topic,
|
||||
priority: 'normal',
|
||||
status: 'open',
|
||||
ticketNumber: nextNumber
|
||||
status: 'neu',
|
||||
ticketNumber: nextNumber,
|
||||
createdAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`Ticket: ${topic}`)
|
||||
.setDescription('Ein Teammitglied wird sich gleich kuemmern. Nutze `/claim`, um den Fall zu uebernehmen.')
|
||||
.setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.')
|
||||
.setColor(0x7289da);
|
||||
|
||||
const controls = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
@@ -290,7 +297,7 @@ export class TicketService {
|
||||
allowedMentions: supportMention ? { roles: [supportRoleId as string], users: [member.id] } : { users: [member.id] }
|
||||
});
|
||||
await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId);
|
||||
|
||||
await this.runAutomations({ ...record });
|
||||
return record as TicketRecord;
|
||||
}
|
||||
|
||||
@@ -301,20 +308,18 @@ export class TicketService {
|
||||
}
|
||||
|
||||
private async closeTicketButton(interaction: ButtonInteraction) {
|
||||
// TODO: TICKETS: Gemeinsamen Close-Pfad fuer Bot/Dashboard extrahieren, um Status-Divergenzen zu vermeiden.
|
||||
const channel = interaction.channel as TextChannel | null;
|
||||
if (!channel) return false;
|
||||
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
|
||||
if (!ticket) return false;
|
||||
const transcriptPath = await this.exportTranscript(channel, ticket.id);
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'erledigt', transcript: transcriptPath } });
|
||||
await interaction.reply({ content: 'Ticket geschlossen.', ephemeral: true }).catch(() => undefined);
|
||||
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
|
||||
await channel.delete('Ticket geschlossen');
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: TICKETS: Log-Ziel und Storage ueber Dashboard konfigurierbar machen (z.B. Channel, Forum, Webhook).
|
||||
private async sendTranscriptToLog(guild: Guild, transcriptPath: string, ticket: { id: string; channelId: string }) {
|
||||
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
|
||||
if (!logChannelId || categories.ticketActions === false) return;
|
||||
@@ -322,7 +327,7 @@ export class TicketService {
|
||||
if (!logChannel || !logChannel.isTextBased()) return;
|
||||
try {
|
||||
await (logChannel as any).send({
|
||||
content: `Transcript für Ticket ${ticket.id} (${ticket.channelId})`,
|
||||
content: `Transcript für Ticket ${ticket.id} (${ticket.channelId})`,
|
||||
files: [transcriptPath]
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -478,8 +483,53 @@ export class TicketService {
|
||||
await (logChannel as any).send({ content: `[Support] ${message}` }).catch(() => undefined);
|
||||
}
|
||||
|
||||
public async trackFirstResponse(message: Message) {
|
||||
if (!message.guildId || !message.channelId || message.author?.bot) return;
|
||||
const ticket = await prisma.ticket.findFirst({ where: { channelId: message.channelId } });
|
||||
if (!ticket || ticket.firstResponseAt) return;
|
||||
const cfg = settingsStore.get(message.guildId);
|
||||
const supportRoleId = cfg?.supportRoleId || env.supportRoleId;
|
||||
const member = message.member as GuildMember | null;
|
||||
const isSupport =
|
||||
(supportRoleId && member?.roles.cache.has(supportRoleId)) ||
|
||||
member?.permissions.has(PermissionsBitField.Flags.ManageMessages) ||
|
||||
ticket.userId !== message.author.id;
|
||||
if (!isSupport) return;
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { firstResponseAt: new Date() } });
|
||||
}
|
||||
|
||||
public async suggestKnowledgeBase(message: Message) {
|
||||
if (!message.guildId || !message.channelId || message.author?.bot) return;
|
||||
const ticket = await prisma.ticket.findFirst({ where: { channelId: message.channelId } });
|
||||
if (!ticket || ticket.kbSuggestionSentAt) return;
|
||||
if (ticket.userId !== message.author.id) return;
|
||||
const matches = await context.knowledgeBase.match(message.guildId, message.content || '');
|
||||
if (!matches.length) return;
|
||||
const channel = message.channel as TextChannel | null;
|
||||
if (!channel || !channel.isTextBased()) return;
|
||||
const lines = matches.map((m: any) => `• **${m.title}** – ${m.content}`).join('\n');
|
||||
await channel.send({ content: `Ich habe vielleicht passende Lösungen gefunden:\n${lines}` }).catch(() => undefined);
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { kbSuggestionSentAt: new Date() } });
|
||||
}
|
||||
|
||||
public async updateStatus(ticketId: string, status: TicketStatus) {
|
||||
const normalized = this.normalizeStatus(status);
|
||||
const ticket = await prisma.ticket.findFirst({ where: { id: ticketId } });
|
||||
if (!ticket) return null;
|
||||
const updated = await prisma.ticket.update({ where: { id: ticketId }, data: { status: normalized } });
|
||||
await this.runAutomations(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async runAutomations(ticket: any) {
|
||||
try {
|
||||
await context.ticketAutomation.checkTicket(ticket);
|
||||
} catch (err) {
|
||||
logger.warn('Automation check failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
private isEnabled(guildId: string) {
|
||||
// TODO: MODULE: Modul-Status ueber BotModuleService/SettingsStore vereinheitlichen.
|
||||
const cfg = settingsStore.get(guildId);
|
||||
return cfg?.ticketsEnabled === true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user