feat: initial Papo bot scaffold
This commit is contained in:
213
src/services/ticketService.ts
Normal file
213
src/services/ticketService.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonInteraction,
|
||||
ButtonStyle,
|
||||
CategoryChannelResolvable,
|
||||
ChannelType,
|
||||
ChatInputCommandInteraction,
|
||||
EmbedBuilder,
|
||||
Guild,
|
||||
GuildMember,
|
||||
PermissionsBitField,
|
||||
TextChannel
|
||||
} from 'discord.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { TicketRecord } from '../utils/types.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export class TicketService {
|
||||
private categoryName = 'Tickets';
|
||||
|
||||
constructor(private transcriptRoot = './transcripts') {}
|
||||
|
||||
public async createTicket(interaction: ChatInputCommandInteraction): Promise<TicketRecord | null> {
|
||||
if (!interaction.guildId || !interaction.guild) return null;
|
||||
const existing = await prisma.ticket.findFirst({
|
||||
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
|
||||
});
|
||||
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.startsWith('ticket:create:')) {
|
||||
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' } }
|
||||
});
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === 'ticket:claim') {
|
||||
const ticket = await this.getTicketByChannel(interaction);
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: 'Kein Ticket gefunden.', 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.` });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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.` });
|
||||
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: 'closed', transcript: transcriptPath } });
|
||||
await channel.send({ content: `Ticket geschlossen. Grund: ${reason ?? 'Kein Grund angegeben'}` });
|
||||
return true;
|
||||
}
|
||||
|
||||
public buildPanelEmbed() {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Ticket Support')
|
||||
.setDescription('Klicke auf eine Kategorie, um ein Ticket zu eröffnen.')
|
||||
.setColor(0x5865f2)
|
||||
.addFields(
|
||||
{ name: 'Support', value: 'Allgemeine Fragen oder Hilfe' },
|
||||
{ name: 'Report', value: 'Melde Regelverstöße' },
|
||||
{ name: 'Team', value: 'Bewerbungen oder interne Themen' }
|
||||
);
|
||||
|
||||
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder().setCustomId('ticket:create:support').setLabel('Support').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('ticket:create:report').setLabel('Report').setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder().setCustomId('ticket:create:team').setLabel('Team').setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
return { embed, buttons };
|
||||
}
|
||||
|
||||
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 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> {
|
||||
const category = await this.ensureCategory(guild);
|
||||
const channel = await guild.channels.create({
|
||||
name: `ticket-${member.user.username}`.toLowerCase(),
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id,
|
||||
deny: [PermissionsBitField.Flags.ViewChannel]
|
||||
},
|
||||
{
|
||||
id: member.id,
|
||||
allow: [PermissionsBitField.Flags.ViewChannel, PermissionsBitField.Flags.SendMessages]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const record = await prisma.ticket.create({
|
||||
data: {
|
||||
userId: member.id,
|
||||
channelId: channel.id,
|
||||
guildId: guild.id,
|
||||
topic,
|
||||
priority: 'normal',
|
||||
status: 'open'
|
||||
}
|
||||
});
|
||||
|
||||
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('Schließen').setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder().setCustomId('ticket:transcript').setLabel('Transcript').setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
await channel.send({ content: `${member}`, embeds: [embed], components: [controls] });
|
||||
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: 'closed', transcript: transcriptPath } });
|
||||
await channel.send({ content: `Ticket von ${interaction.user} geschlossen.` });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user