571 lines
24 KiB
TypeScript
571 lines
24 KiB
TypeScript
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)
|
||
);
|
||
}
|
||
}
|