Add events module with dashboard UI, scheduling, signups, and settings updates; extend env/readme.

This commit is contained in:
Pascal Prießnitz
2025-12-02 23:52:10 +01:00
parent 874b01c999
commit 829d160164
578 changed files with 37647 additions and 11590 deletions

View File

@@ -10,23 +10,43 @@ import {
Guild,
GuildMember,
PermissionsBitField,
TextChannel
TextChannel,
Client
} from 'discord.js';
import { PrismaClient } from '@prisma/client';
import { prisma } from '../database';
import fs from 'fs';
import path from 'path';
import { TicketRecord } from '../utils/types.js';
import { logger } from '../utils/logger.js';
const prisma = new PrismaClient();
import { TicketRecord } from '../utils/types';
import { logger } from '../utils/logger';
import { settingsStore } from '../config/state';
import { env } from '../config/env';
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')}`;
}
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 });
return null;
}
const existing = await prisma.ticket.findFirst({
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
});
@@ -38,14 +58,23 @@ 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);
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: { not: 'closed' } }
});
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;
}
@@ -58,23 +87,30 @@ export class TicketService {
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) {
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-progress' } });
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
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) {
@@ -90,18 +126,25 @@ export class TicketService {
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-progress' } });
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
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 channel.send({ content: `Ticket geschlossen. Grund: ${reason ?? 'Kein Grund angegeben'}` });
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;
}
@@ -109,33 +152,53 @@ export class TicketService {
const embed = new EmbedBuilder()
.setTitle('Ticket Support')
.setDescription('Klicke auf eine Kategorie, um ein Ticket zu eröffnen.')
.setColor(0x5865f2)
.setColor(0xf97316)
.addFields(
{ name: 'Support', value: 'Allgemeine Fragen oder Hilfe' },
{ name: 'Report', value: 'Melde Regelverstöße' },
{ name: 'Team', value: 'Bewerbungen oder interne Themen' }
{ 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: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)
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 };
}
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 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>();
// 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);
if (cat.emoji) btn.setEmoji(cat.emoji);
buttons.addComponents(btn);
});
return { embed, buttons };
}
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)
@@ -151,23 +214,51 @@ export class TicketService {
}
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: `ticket-${member.user.username}`.toLowerCase(),
name: channelName,
type: ChannelType.GuildText,
parent: category.id,
permissionOverwrites: [
{
id: guild.id,
deny: [PermissionsBitField.Flags.ViewChannel]
},
{
id: member.id,
allow: [PermissionsBitField.Flags.ViewChannel, PermissionsBitField.Flags.SendMessages]
}
]
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,
@@ -175,22 +266,31 @@ export class TicketService {
guildId: guild.id,
topic,
priority: 'normal',
status: 'open'
status: 'open',
ticketNumber: nextNumber
}
});
const embed = new EmbedBuilder()
.setTitle(`Ticket: ${topic}`)
.setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.')
.setDescription('Ein Teammitglied wird sich gleich kuemmern. Nutze `/claim`, um den Fall zu uebernehmen.')
.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:close').setLabel('Schliessen').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId('ticket:transcript').setLabel('Transcript').setStyle(ButtonStyle.Secondary)
);
await channel.send({ content: `${member}`, embeds: [embed], components: [controls] });
const supportMention = supportRoleId ? `<@&${supportRoleId}>` : null;
await channel.send({
content: supportMention ? `${member} ${supportMention}` : `${member}`,
embeds: [embed],
components: [controls],
allowedMentions: supportMention ? { roles: [supportRoleId as string], users: [member.id] } : { users: [member.id] }
});
await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId);
return record as TicketRecord;
}
@@ -201,13 +301,186 @@ 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 channel.send({ content: `Ticket von ${interaction.user} geschlossen.` });
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;
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);
}
private isEnabled(guildId: string) {
// TODO: MODULE: Modul-Status ueber BotModuleService/SettingsStore vereinheitlichen.
const cfg = settingsStore.get(guildId);
return cfg?.ticketsEnabled === true;
}
}