Files
Papo/src/services/ticketService.ts
Pascal Prießnitz b672e2c6a2
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
[deploy] add in-ticket pipeline status buttons
2025-12-03 14:36:16 +01:00

571 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
CategoryChannelResolvable,
ChannelType,
ChatInputCommandInteraction,
EmbedBuilder,
Guild,
GuildMember,
PermissionsBitField,
TextChannel,
Client,
Message
} from 'discord.js';
import fs from 'fs';
import path from 'path';
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';
private client: Client | null = null;
constructor(private transcriptRoot = './transcripts') {}
public setClient(client: Client) {
this.client = client;
}
private formatChannelName(customId: string, username: string, ticketTag: string) {
const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 20) || 'ticket';
return `${slug(customId)}-${slug(username)}-${ticketTag}`.toLowerCase();
}
private formatTicketTag(n: number) {
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;
if (!this.isEnabled(interaction.guildId)) {
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: { notIn: ['closed', 'erledigt'] } }
});
if (existing) {
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket.', ephemeral: true });
return null;
}
return this.openTicket(interaction.guild, interaction.member as GuildMember, 'support');
}
public async handleButton(interaction: ButtonInteraction) {
if (!interaction.guild) return;
if (interaction.customId === 'support:toggle') {
await this.toggleSupport(interaction);
return;
}
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 });
return;
}
const topic = interaction.customId.split(':')[2] || 'allgemein';
const existing = await prisma.ticket.findFirst({
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 });
return;
}
const record = await this.openTicket(interaction.guild, interaction.member as GuildMember, topic);
await interaction.reply({
content: record ? 'Ticket erstellt! Schau im neuen Kanal nach.' : 'Ticket konnte nicht erstellt werden.',
ephemeral: true
});
return;
}
if (interaction.customId === 'ticket:claim') {
const ticket = await this.getTicketByChannel(interaction);
if (!ticket) {
await interaction.reply({ content: 'Kein Ticket gefunden.', ephemeral: true });
return;
}
if (ticket.userId === interaction.user.id) {
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_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;
}
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;
}
if (interaction.customId === 'ticket:transcript') {
const ticket = await this.getTicketByChannel(interaction);
if (!ticket) {
await interaction.reply({ content: 'Kein Ticket gefunden.', ephemeral: true });
return;
}
const transcriptPath = await this.exportTranscript(interaction.channel as TextChannel, ticket.id);
await interaction.reply({ content: `Transcript erstellt: ${transcriptPath}`, ephemeral: true });
return;
}
if (interaction.customId.startsWith('ticket:status:')) {
const targetStatus = interaction.customId.split(':')[2];
const ticket = await this.getTicketByChannel(interaction);
if (!ticket) {
await interaction.reply({ content: 'Kein Ticket gefunden.', ephemeral: true });
return;
}
const member = interaction.member as GuildMember;
const cfg = settingsStore.get(interaction.guild.id);
const supportRoleId = cfg?.supportRoleId || env.supportRoleId;
const canManage =
ticket.claimedBy === interaction.user.id ||
(supportRoleId && member?.roles.cache.has(supportRoleId)) ||
member?.permissions.has(PermissionsBitField.Flags.ManageMessages);
if (!canManage) {
await interaction.reply({ content: 'Keine Berechtigung, Status zu ändern.', ephemeral: true });
return;
}
await this.updateStatus(ticket.id, targetStatus as TicketStatus);
await interaction.reply({ content: `Status gesetzt auf ${targetStatus}.`, ephemeral: true });
return;
}
}
public async claimTicket(interaction: ChatInputCommandInteraction) {
const channel = interaction.channel as TextChannel;
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
if (!ticket) return false;
if (ticket.userId === interaction.user.id) {
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_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) {
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: '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');
return true;
}
public buildPanelEmbed() {
const embed = new EmbedBuilder()
.setTitle('Ticket Support')
.setDescription('Klicke auf eine Kategorie, um ein Ticket zu eröffnen.')
.setColor(0xf97316)
.addFields(
{ name: 'Ban Einspruch', value: 'Du bist gebannt worden und möchtest eine faire Klärung?' },
{ name: 'Discord-Hilfe', value: 'Dir fehlen Rollen oder du hast Probleme mit Nachrichten?' },
{ name: 'Feedback / Beschwerde', value: 'Du willst Lob, Kritik oder Vorschläge loswerden?' },
{ name: 'Allgemeine Frage', value: 'Alles andere, was nicht in die Kategorien passt.' }
);
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)
);
return { embed, buttons };
}
public buildCustomPanel(config: {
title?: string;
description?: string;
categories: { label: string; emoji?: string; customId: string }[];
}) {
const embed = new EmbedBuilder()
.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.'
)
.setColor(0xf97316);
const buttons = new ActionRowBuilder<ButtonBuilder>();
config.categories.slice(0, 5).forEach((cat) => {
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);
});
return { embed, buttons };
}
public async exportTranscript(channel: TextChannel, ticketId: string) {
const messages = await channel.messages.fetch({ limit: 100 });
const lines = messages
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
.map((m) => `[${new Date(m.createdTimestamp).toISOString()}] ${m.author.tag}: ${m.content}`)
.join('\n');
const dir = path.resolve(this.transcriptRoot);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `${ticketId}.txt`);
fs.writeFileSync(file, lines, 'utf8');
logger.info(`Transcript geschrieben: ${file}`);
return file;
}
private async openTicket(guild: Guild, member: GuildMember, topic: string): Promise<TicketRecord | null> {
if (!this.isEnabled(guild.id)) return null;
const category = await this.ensureCategory(guild);
const supportRoleId = settingsStore.get(guild.id)?.supportRoleId || env.supportRoleId || null;
const overwrites: any[] = [
{
id: guild.id,
deny: [
PermissionsBitField.Flags.ViewChannel,
PermissionsBitField.Flags.SendMessages,
PermissionsBitField.Flags.AttachFiles,
PermissionsBitField.Flags.ReadMessageHistory
]
},
{
id: member.id,
allow: [
PermissionsBitField.Flags.ViewChannel,
PermissionsBitField.Flags.SendMessages,
PermissionsBitField.Flags.AttachFiles,
PermissionsBitField.Flags.ReadMessageHistory
]
}
];
if (supportRoleId) {
overwrites.push({
id: supportRoleId,
allow: [
PermissionsBitField.Flags.ViewChannel,
PermissionsBitField.Flags.SendMessages,
PermissionsBitField.Flags.AttachFiles,
PermissionsBitField.Flags.ReadMessageHistory
]
});
}
const nextNumber = (await prisma.ticket.count({ where: { guildId: guild.id } })) + 1;
const ticketTag = this.formatTicketTag(nextNumber);
const channelName = this.formatChannelName(topic, member.user.username, ticketTag);
const channel = await guild.channels.create({
name: channelName,
type: ChannelType.GuildText,
parent: category as any,
permissionOverwrites: overwrites
});
const record = await prisma.ticket.create({
data: {
userId: member.id,
channelId: channel.id,
guildId: guild.id,
topic,
priority: 'normal',
status: 'neu',
ticketNumber: nextNumber,
createdAt: new Date()
}
});
const embed = new EmbedBuilder()
.setTitle(`Ticket: ${topic}`)
.setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.')
.setColor(0x7289da);
const controls = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('ticket:claim').setLabel('Claim').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('ticket:close').setLabel('Schliessen').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId('ticket:transcript').setLabel('Transcript').setStyle(ButtonStyle.Secondary)
);
const statusRow = this.buildStatusButtons();
const supportMention = supportRoleId ? `<@&${supportRoleId}>` : null;
await channel.send({
content: supportMention ? `${member} ${supportMention}` : `${member}`,
embeds: [embed],
components: [controls, statusRow],
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;
}
private async getTicketByChannel(interaction: ButtonInteraction) {
const channel = interaction.channel as TextChannel | null;
if (!channel) return null;
return prisma.ticket.findFirst({ where: { channelId: channel.id } });
}
private async closeTicketButton(interaction: ButtonInteraction) {
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: '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;
}
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;
const logChannel = await guild.channels.fetch(logChannelId).catch(() => null);
if (!logChannel || !logChannel.isTextBased()) return;
try {
await (logChannel as any).send({
content: `Transcript für Ticket ${ticket.id} (${ticket.channelId})`,
files: [transcriptPath]
});
} catch (err) {
logger.warn('Transcript konnte nicht im Log-Kanal gesendet werden', err);
}
}
private async sendTicketCreatedLog(
guild: Guild,
ticketChannel: TextChannel,
member: GuildMember,
topic: string,
ticketNumber: number,
supportRoleId: string | null
) {
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
if (!logChannelId || categories.ticketActions === false) return;
const logChannel = await guild.channels.fetch(logChannelId).catch(() => null);
if (!logChannel || !logChannel.isTextBased()) return;
const roleMention = supportRoleId ? `<@&${supportRoleId}>` : '';
const content = `${roleMention ? roleMention + ' ' : ''}Neues Ticket ${this.formatTicketTag(ticketNumber)} von ${member} | Thema: ${topic} | Kanal: ${ticketChannel}`;
try {
await (logChannel as any).send({
content,
allowedMentions: roleMention && supportRoleId ? { roles: [supportRoleId], users: [member.id] } : { users: [member.id] }
});
} catch (err) {
logger.warn('Ticket-Log konnte nicht gesendet werden', err);
}
}
private getLoggingConfig(guildId: string) {
const cfg = settingsStore.get(guildId) || {};
const logging = (cfg as any).loggingConfig || (cfg as any).automodConfig?.loggingConfig || {};
const categories = typeof logging.categories === 'object' && logging.categories ? logging.categories : {};
return {
logChannelId: logging.logChannelId || cfg.logChannelId,
categories
};
}
private async ensureCategory(guild: Guild): Promise<CategoryChannelResolvable> {
let category = guild.channels.cache.find(
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase().includes(this.categoryName.toLowerCase())
);
if (!category) {
category = await guild.channels.create({ name: this.categoryName, type: ChannelType.GuildCategory });
}
return category as CategoryChannelResolvable;
}
public async publishSupportPanel(guildId: string, cfg?: any) {
if (!this.client) return null;
const guild = this.client.guilds.cache.get(guildId) ?? (await this.client.guilds.fetch(guildId).catch(() => null));
if (!guild) return null;
const settings = settingsStore.get(guildId) || {};
const config = { ...(settings.supportLoginConfig ?? {}), ...(cfg ?? {}) };
if (!config.panelChannelId) return null;
const channel = await guild.channels.fetch(config.panelChannelId).catch(() => null);
if (!channel || !channel.isTextBased()) return null;
const embed = new EmbedBuilder()
.setTitle(config.title || 'Support Login')
.setDescription(config.description || 'Melde dich an/ab, um die Support-Rolle zu erhalten.')
.setColor(0xf97316);
const btn = new ButtonBuilder()
.setCustomId('support:toggle')
.setLabel(config.loginLabel || 'Ich bin jetzt im Support')
.setStyle(ButtonStyle.Success);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(btn);
let messageId = config.panelMessageId;
try {
if (messageId) {
const msg = await (channel as TextChannel).messages.fetch(messageId);
await msg.edit({ embeds: [embed], components: [row] });
} else {
const sent = await (channel as TextChannel).send({ embeds: [embed], components: [row] });
messageId = sent.id;
}
} catch {
const sent = await (channel as TextChannel).send({ embeds: [embed], components: [row] });
messageId = sent.id;
}
await settingsStore.set(guildId, { supportLoginConfig: { ...config, panelMessageId: messageId } } as any);
return messageId;
}
public async getSupportStatus(guildId: string) {
const repo: any = (prisma as any).ticketSupportSession;
if (!repo?.findMany) return { active: [], recent: [] };
const active = await repo.findMany({ where: { guildId, endedAt: null }, orderBy: { startedAt: 'desc' }, take: 25 });
const recent = await repo.findMany({
where: { guildId, endedAt: { not: null } },
orderBy: { endedAt: 'desc' },
take: 25
});
return { active, recent };
}
private async toggleSupport(interaction: ButtonInteraction) {
const guildId = interaction.guildId;
if (!guildId || !interaction.guild) return;
const cfg = settingsStore.get(guildId) || {};
const roleId = cfg.supportRoleId;
if (!roleId) {
await interaction.reply({ content: 'Keine Support-Rolle konfiguriert.', ephemeral: true });
return;
}
const member = interaction.member as GuildMember;
const hasRole = member.roles.cache.has(roleId);
if (!hasRole) {
try {
await member.roles.add(roleId, 'Support Login');
} catch {
await interaction.reply({ content: 'Rolle konnte nicht vergeben werden.', ephemeral: true });
return;
}
await this.closeOpenSession(guildId, member.id, roleId);
const repo: any = (prisma as any).ticketSupportSession;
if (repo?.create) await repo.create({ data: { guildId, userId: member.id, roleId } });
await interaction.reply({ content: 'Du bist jetzt im Support.', ephemeral: true });
await this.logSupportStatus(interaction.guild, `${member.user.tag} ist jetzt im Support.`);
} else {
try {
await member.roles.remove(roleId, 'Support Logout');
} catch {
/* ignore */
}
const duration = await this.closeOpenSession(guildId, member.id, roleId);
await interaction.reply({ content: `Support beendet. Dauer: ${duration ? Math.round(duration / 60) + 'm' : '-'}.`, ephemeral: true });
await this.logSupportStatus(interaction.guild, `${member.user.tag} hat den Support verlassen.`);
}
}
private async closeOpenSession(guildId: string, userId: string, roleId: string) {
const repo: any = (prisma as any).ticketSupportSession;
if (!repo?.findFirst) return null;
const open = await repo.findFirst({
where: { guildId, userId, roleId, endedAt: null },
orderBy: { startedAt: 'desc' }
});
if (!open) return null;
const endedAt = new Date();
const durationSeconds = Math.max(0, Math.round((endedAt.getTime() - open.startedAt.getTime()) / 1000));
await repo.update({ where: { id: open.id }, data: { endedAt, durationSeconds } });
return durationSeconds;
}
private async logSupportStatus(guild: Guild, message: string) {
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
if (!logChannelId || categories.ticketActions === false) return;
const logChannel = await guild.channels.fetch(logChannelId).catch(() => null);
if (!logChannel || !logChannel.isTextBased()) return;
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) {
const cfg = settingsStore.get(guildId);
return cfg?.ticketsEnabled === true;
}
private buildStatusButtons() {
return new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('ticket:status:neu').setLabel('Neu').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('ticket:status:in_bearbeitung').setLabel('In Bearbeitung').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('ticket:status:warten_auf_user').setLabel('Warten auf User').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('ticket:status:erledigt').setLabel('Erledigt').setStyle(ButtonStyle.Success)
);
}
}