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 { 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().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(); 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 { 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().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 { 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().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().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) ); } }