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

@@ -0,0 +1,125 @@
import { Client } from 'discord.js';
type LogLevel = 'INFO' | 'WARN' | 'ERROR';
export interface AdminLogEntry {
timestamp: number;
level: LogLevel;
message: string;
guildId?: string;
category?: string;
}
export class AdminService {
private startTime = Date.now();
private client: Client | null = null;
private hourBuckets = new Map<string, number>();
private activeGuilds = new Map<string, number>();
private logs: AdminLogEntry[] = [];
private logLimit = 120;
private guildLogs = new Map<string, AdminLogEntry[]>();
private guildActivity = new Map<
string,
{ messages: number[]; commands: number[]; automod: number[] }
>();
public setStartTime(ts: number) {
this.startTime = ts;
}
public setClient(client: Client) {
this.client = client;
}
public trackCommand(guildId?: string | null) {
this.bumpActivity(guildId || 'unknown');
this.trackGuildActivity(guildId || 'unknown', 'commands');
}
public trackEvent(_event: string, guildId?: string | null) {
this.bumpActivity(guildId || 'unknown');
this.trackGuildActivity(guildId || 'unknown', 'messages');
}
public trackGuildEvent(guildId: string, type: 'messages' | 'commands' | 'automod') {
this.trackGuildActivity(guildId, type);
}
private bumpActivity(guildId: string) {
const now = Date.now();
const hour = new Date(now);
hour.setMinutes(0, 0, 0);
const key = hour.toISOString();
this.hourBuckets.set(key, (this.hourBuckets.get(key) ?? 0) + 1);
this.activeGuilds.set(guildId, now);
// cleanup older than 24h
const cutoff = now - 24 * 60 * 60 * 1000;
for (const [k] of this.hourBuckets) {
if (new Date(k).getTime() < cutoff) this.hourBuckets.delete(k);
}
for (const [g, ts] of this.activeGuilds) {
if (ts < cutoff) this.activeGuilds.delete(g);
}
}
public pushLog(entry: AdminLogEntry) {
this.logs.push(entry);
if (this.logs.length > this.logLimit) this.logs.splice(0, this.logs.length - this.logLimit);
}
public pushGuildLog(entry: AdminLogEntry & { guildId: string; category?: string }) {
this.pushLog(entry);
const arr = this.guildLogs.get(entry.guildId) ?? [];
arr.push(entry);
if (arr.length > this.logLimit) arr.splice(0, arr.length - this.logLimit);
this.guildLogs.set(entry.guildId, arr);
}
public getOverview() {
const guildCount = this.client?.guilds.cache.size ?? 0;
const activeGuilds24 = this.activeGuilds.size;
return {
guildCount,
activeGuilds24,
startTime: this.startTime,
uptimeMs: Date.now() - this.startTime
};
}
public getActivity() {
const points = Array.from(this.hourBuckets.entries())
.map(([iso, count]) => ({ hour: iso, count }))
.sort((a, b) => new Date(a.hour).getTime() - new Date(b.hour).getTime());
return { points };
}
public getLogs() {
return this.logs.slice().reverse();
}
public getGuildLogs(guildId: string) {
return (this.guildLogs.get(guildId) ?? []).slice().reverse();
}
public getGuildActivity(guildId: string) {
const now = Date.now();
const cutoff24 = now - 24 * 60 * 60 * 1000;
const data = this.guildActivity.get(guildId) || { messages: [], commands: [], automod: [] };
const filter = (arr: number[]) => arr.filter((ts) => ts >= cutoff24);
const messages = filter(data.messages);
const commands = filter(data.commands);
const automod = filter(data.automod);
data.messages = messages;
data.commands = commands;
data.automod = automod;
this.guildActivity.set(guildId, data);
return { messages24h: messages.length, commands24h: commands.length, automod24h: automod.length };
}
private trackGuildActivity(guildId: string, type: 'messages' | 'commands' | 'automod') {
if (!guildId) return;
const entry = this.guildActivity.get(guildId) || { messages: [], commands: [], automod: [] };
entry[type].push(Date.now());
this.guildActivity.set(guildId, entry);
}
}

View File

@@ -1,28 +1,92 @@
import { Collection, Message } from 'discord.js';
import { logger } from '../utils/logger.js';
import { Collection, Message, PermissionFlagsBits } from 'discord.js';
import { logger } from '../utils/logger';
export interface AutomodConfig {
spamThreshold?: number;
windowMs?: number;
linkWhitelist?: string[];
spamTimeoutMinutes?: number;
deleteLinks?: boolean;
badWordFilter?: boolean;
capsFilter?: boolean;
customBadwords?: string[];
whitelistRoles?: string[];
logChannelId?: string;
loggingConfig?: {
logChannelId?: string;
categories?: {
automodActions?: boolean;
};
};
}
export class AutoModService {
private spamTracker = new Collection<string, { count: number; lastMessage: number }>();
private spamThreshold = 5;
private windowMs = 7000;
private defaults: AutomodConfig = {
spamThreshold: 5,
windowMs: 7000,
linkWhitelist: [],
spamTimeoutMinutes: 10,
deleteLinks: true,
badWordFilter: true,
capsFilter: false,
customBadwords: [],
whitelistRoles: []
};
private defaultBadwords = ['badword', 'spamword'];
constructor(private linkFilterEnabled = true, private antiSpamEnabled = true) {}
public checkMessage(message: Message) {
public async checkMessage(message: Message, cfg?: AutomodConfig) {
if (message.author.bot) return;
if (this.linkFilterEnabled && this.containsLink(message.content)) {
const config = { ...this.defaults, ...(cfg ?? {}) };
const member = message.member;
if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) {
const allowed = member.roles.cache.some((r) => config.whitelistRoles!.includes(r.id));
if (allowed) return;
}
if (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) {
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
message.delete().catch(() => undefined);
message.channel
.send({ content: `${message.author}, Links sind hier nicht erlaubt.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
logger.info(`Deleted link from ${message.author.tag}`);
await this.logAutomodAction(message, config, 'link_filter');
return true;
}
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
message.delete().catch(() => undefined);
message.channel
.send({ content: `${message.author}, bitte auf deine Wortwahl achten.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.logAutomodAction(message, config, 'badword', message.content);
return true;
}
if (config.capsFilter) {
const letters = message.content.replace(/[^a-zA-Z]/g, '');
const upper = letters.replace(/[^A-Z]/g, '');
if (letters.length >= 10 && upper.length / letters.length > 0.7) {
message.delete().catch(() => undefined);
message.channel
.send({ content: `${message.author}, bitte weniger Capslock nutzen.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.logAutomodAction(message, config, 'capslock', message.content);
return true;
}
}
if (this.antiSpamEnabled) {
const now = Date.now();
const tracker = this.spamTracker.get(message.author.id) ?? { count: 0, lastMessage: now };
if (now - tracker.lastMessage < this.windowMs) {
if (now - tracker.lastMessage < (config.windowMs ?? this.windowMs)) {
tracker.count += 1;
} else {
tracker.count = 1;
@@ -30,20 +94,52 @@ export class AutoModService {
tracker.lastMessage = now;
this.spamTracker.set(message.author.id, tracker);
if (tracker.count >= this.spamThreshold) {
message.member?.timeout(10 * 60 * 1000, 'Automod: Spam').catch(() => undefined);
const threshold = config.spamThreshold ?? this.spamThreshold;
if (tracker.count >= threshold) {
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined);
message.channel
.send({ content: `${message.author}, bitte langsamer schreiben (Spam-Schutz).` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
logger.warn(`Timed out ${message.author.tag} for spam`);
this.spamTracker.delete(message.author.id);
await this.logAutomodAction(message, config, 'spam', `Count ${tracker.count}`);
return true;
}
}
return false;
}
private containsLink(content: string) {
return /(https?:\/\/|discord\.gg|www\.)/i.test(content);
private containsBadword(content: string, custom: string[] = []) {
const combined = [...this.defaultBadwords, ...(custom || [])].filter(Boolean).map((w) => w.toLowerCase());
if (!combined.length) return false;
const lower = content.toLowerCase();
return combined.some((w) => lower.includes(w));
}
private containsLink(content: string, whitelist: string[] = []) {
const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean);
const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+)/i.exec(content);
if (!match) return false;
const url = match[0].toLowerCase();
return !normalized.some((w) => url.includes(w));
}
private async logAutomodAction(message: Message, config: AutomodConfig, action: string, details?: string) {
try {
const guild = message.guild;
if (!guild) return;
const loggingCfg = config.loggingConfig || {};
const flags = loggingCfg.categories || {};
if (flags.automodActions === false) return;
const channelId = loggingCfg.logChannelId || config.logChannelId;
if (!channelId) return;
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const content = `[Automod] ${action} by ${message.author.tag}${details ? ` | ${details}` : ''}`;
await channel.send({ content });
} catch (err) {
logger.error('Automod log failed', err);
}
}
}

View File

@@ -0,0 +1,160 @@
import { Client, EmbedBuilder, TextChannel } from 'discord.js';
import { prisma } from '../database';
import { settingsStore } from '../config/state';
import { logger } from '../utils/logger';
export function normalizeBirthdayInput(raw: string) {
const input = (raw || '').trim();
if (!input) return null;
const iso = input.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (iso) {
const year = Number(iso[1]);
const month = Number(iso[2]);
const day = Number(iso[3]);
if (isValidDate(day, month, year)) return `${year.toString().padStart(4, '0')}-${pad(month)}-${pad(day)}`;
return null;
}
const dmy = input.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (dmy) {
const day = Number(dmy[1]);
const month = Number(dmy[2]);
const year = Number(dmy[3]);
if (isValidDate(day, month, year)) return `${year.toString().padStart(4, '0')}-${pad(month)}-${pad(day)}`;
return null;
}
const dm = input.match(/^(\d{2})\.(\d{2})\.?$/);
if (dm) {
const day = Number(dm[1]);
const month = Number(dm[2]);
if (isValidDate(day, month, 2000)) return `--${pad(month)}-${pad(day)}`;
return null;
}
return null;
}
function isValidDate(day: number, month: number, year?: number) {
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
const checkYear = year ?? 2000;
const d = new Date(checkYear, month - 1, day);
return d.getFullYear() === checkYear && d.getMonth() + 1 === month && d.getDate() === day;
}
function pad(num: number) {
return String(num).padStart(2, '0');
}
export interface BirthdayRecord {
id: string;
userId: string;
guildId: string;
birthDate: string;
createdAt: Date;
updatedAt: Date;
}
export class BirthdayService {
private client: Client | null = null;
private timer: NodeJS.Timeout | null = null;
private lastSent = new Map<string, string>();
public setClient(client: Client) {
this.client = client;
}
public startScheduler(intervalMs = 60 * 60 * 1000) {
if (this.timer) clearInterval(this.timer);
const interval = Math.max(10 * 60 * 1000, intervalMs);
this.timer = setInterval(() => this.checkAndSend().catch((err) => logger.warn(`birthday check failed: ${err}`)), interval);
}
public stopScheduler() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
public async setBirthday(guildId: string, userId: string, birthDate: string) {
await prisma.birthday.upsert({
where: { birthday_user_guild: { guildId, userId } },
update: { birthDate },
create: { guildId, userId, birthDate }
});
}
public async getBirthday(guildId: string, userId: string) {
return prisma.birthday.findUnique({ where: { birthday_user_guild: { guildId, userId } } });
}
public async listBirthdays(guildId: string) {
return prisma.birthday.findMany({ where: { guildId }, orderBy: { birthDate: 'asc' } });
}
public async checkAndSend(force = false) {
if (!this.client) return;
const now = new Date();
const month = now.getMonth() + 1;
const day = now.getDate();
const todayKey = `${now.getFullYear()}-${pad(month)}-${pad(day)}`;
for (const [guildId, cfgRaw] of settingsStore.all()) {
const cfg = cfgRaw ?? {};
const enabled = cfg.birthdayEnabled ?? cfg.birthdayConfig?.enabled ?? true;
if (!enabled) continue;
const channelId = cfg.birthdayConfig?.channelId;
if (!channelId) continue;
const sendHour = cfg.birthdayConfig?.sendHour ?? 9;
if (!force) {
if (this.lastSent.get(guildId) === todayKey) continue;
if (now.getHours() < sendHour) continue;
}
const matches = await prisma.birthday.findMany({
where: { guildId, birthDate: { endsWith: `-${pad(month)}-${pad(day)}` } }
});
if (!matches.length) continue;
await this.publishBirthdays(guildId, channelId, matches, cfg.birthdayConfig?.messageTemplate);
this.lastSent.set(guildId, todayKey);
}
}
public invalidate(guildId: string) {
this.lastSent.delete(guildId);
}
private async publishBirthdays(guildId: string, channelId: string, birthdays: BirthdayRecord[], template?: string) {
if (!this.client) return;
const channel = await this.client.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const guild = this.client.guilds.cache.get(guildId) ?? (await this.client.guilds.fetch(guildId).catch(() => null));
const members = new Map<string, any>();
if (guild) {
await Promise.all(
birthdays.map(async (entry) => {
const mem = await guild.members.fetch(entry.userId).catch(() => null);
if (mem) members.set(entry.userId, mem);
})
);
}
const mentionList = birthdays.map((b) => `<@${b.userId}>`).join(' ');
const embed = new EmbedBuilder()
.setTitle('Happy Birthday!')
.setColor(0xf97316)
.setTimestamp(new Date());
const lines: string[] = [];
birthdays.forEach((entry, idx) => {
const member = members?.get(entry.userId);
if (idx === 0 && member) {
embed.setThumbnail(member.user.displayAvatarURL() || null);
embed.setAuthor({ name: member.displayName || member.user.username, iconURL: member.user.displayAvatarURL() || undefined });
}
const line = (template || 'Alles Gute zum Geburtstag, {user}!').replace(/\{user\}/g, `<@${entry.userId}>`);
lines.push(line);
});
embed.setDescription(lines.join('\n'));
await (channel as TextChannel).send({ content: mentionList || undefined, embeds: [embed] });
}
}

View File

@@ -1,14 +1,43 @@
import { REST, Routes, Collection, Client, ChatInputCommandInteraction, GatewayIntentBits } from 'discord.js';
import fs from 'fs';
import path from 'path';
import { SlashCommand } from '../utils/types.js';
import { env } from '../config/env.js';
import { logger } from '../utils/logger.js';
import { SlashCommand } from '../utils/types';
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { AdminService } from './adminService';
import { StatuspageService } from './statuspageService';
import { settingsStore } from '../config/state';
import { ModuleKey } from './moduleService';
export class CommandHandler {
private commands = new Collection<string, SlashCommand>();
private moduleMap: Record<string, ModuleKey> = {
// Tickets
ticket: 'ticketsEnabled',
ticketpanel: 'ticketsEnabled',
transcript: 'ticketsEnabled',
close: 'ticketsEnabled',
claim: 'ticketsEnabled',
// Music
play: 'musicEnabled',
skip: 'musicEnabled',
stop: 'musicEnabled',
pause: 'musicEnabled',
resume: 'musicEnabled',
loop: 'musicEnabled',
queue: 'musicEnabled',
// Level
rank: 'levelingEnabled',
// Statuspage
status: 'statuspageEnabled',
// Birthday
birthday: 'birthdayEnabled',
// Events
event: 'eventsEnabled',
events: 'eventsEnabled'
};
constructor(private client: Client) {}
constructor(private client: Client, private admin?: AdminService, private statuspage?: StatuspageService) {}
public async loadCommands() {
const commandsPath = path.join(process.cwd(), 'src', 'commands');
@@ -80,7 +109,20 @@ export class CommandHandler {
await interaction.reply({ content: 'Dieser Befehl funktioniert nur auf Servern.', ephemeral: true });
return;
}
if (interaction.inGuild()) {
const moduleKey = this.moduleMap[interaction.commandName];
if (moduleKey) {
const cfg = settingsStore.get(interaction.guildId!);
const enabled = cfg?.[moduleKey];
if (enabled === false) {
await interaction.reply({ content: 'Dieses Modul ist fuer diese Guild deaktiviert.', ephemeral: true });
return;
}
}
}
try {
this.admin?.trackCommand(interaction.guildId);
if (interaction.guildId) this.admin?.trackGuildEvent(interaction.guildId, 'commands');
await command.execute(interaction, this.client);
} catch (err) {
logger.error(`Command ${interaction.commandName} failed`, err);

View File

@@ -0,0 +1,152 @@
import { ChannelType, PermissionsBitField, VoiceState, Guild, Client } from 'discord.js';
import { settingsStore } from '../config/state';
import { logger } from '../utils/logger';
import { context } from '../config/context';
export class DynamicVoiceService {
private created = new Map<string, Set<string>>();
private getSet(guildId: string) {
if (!this.created.has(guildId)) this.created.set(guildId, new Set());
return this.created.get(guildId)!;
}
private track(guildId: string, channelId: string) {
this.getSet(guildId).add(channelId);
}
private untrack(guildId: string, channelId: string) {
this.getSet(guildId).delete(channelId);
}
private isManaged(guildId: string, channelId?: string | null) {
if (!channelId) return false;
return this.getSet(guildId).has(channelId);
}
public async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
const guild = newState.guild || oldState.guild;
if (!guild) return;
const cfg = settingsStore.get(guild.id);
const dvCfg = (cfg?.dynamicVoiceConfig as any) || (cfg as any)?.automodConfig?.dynamicVoiceConfig || {};
const enabled = cfg?.dynamicVoiceEnabled === true || dvCfg.enabled === true;
const lobbyId = dvCfg.lobbyChannelId;
if (!enabled || !lobbyId) {
if (oldState.channelId) await this.cleanupIfEmpty(guild, oldState.channelId);
return;
}
// User joins the lobby
if (newState.channelId === lobbyId && oldState.channelId !== lobbyId) {
await this.createAndMove(guild, newState, dvCfg);
}
// Clean up old managed channel when empty
if (oldState.channelId && this.isManaged(guild.id, oldState.channelId)) {
await this.cleanupIfEmpty(guild, oldState.channelId);
}
}
private async createAndMove(guild: Guild, state: VoiceState, dvCfg: any) {
try {
const member = state.member;
const lobby = state.channel;
if (!member || !lobby) return;
const parentId = dvCfg.categoryId || lobby.parentId || undefined;
const template = dvCfg.template || '{user}s Channel';
const name = template.replace('{user}', member.displayName || member.user.username);
const channel = await guild.channels.create({
name,
type: ChannelType.GuildVoice,
parent: parentId ?? null,
userLimit: dvCfg.userLimit ?? undefined,
bitrate: dvCfg.bitrate ?? undefined,
permissionOverwrites: [
{ id: guild.roles.everyone, allow: [PermissionsBitField.Flags.Connect, PermissionsBitField.Flags.ViewChannel] },
{
id: member.id,
allow: [
PermissionsBitField.Flags.ManageChannels,
PermissionsBitField.Flags.MoveMembers,
PermissionsBitField.Flags.MuteMembers,
PermissionsBitField.Flags.DeafenMembers,
PermissionsBitField.Flags.Connect,
PermissionsBitField.Flags.ViewChannel
]
}
]
});
this.track(guild.id, channel.id);
context.logging.logSystem(guild, `DynamicVoice erstellt: ${channel.name} (${channel.id})`);
context.admin.pushGuildLog({
guildId: guild.id,
level: 'INFO',
message: `DynamicVoice erstellt: ${channel.name} (${channel.id})`,
timestamp: Date.now(),
category: 'dynamicVoice'
});
// Move user if still in lobby
if (state.channelId === lobby.id) {
await state.setChannel(channel.id).catch(() => undefined);
}
} catch (err) {
logger.error('Dynamic voice create failed', err);
context.logging.logSystem(guild, 'DynamicVoice Fehler beim Erstellen');
context.admin.pushGuildLog({
guildId: guild.id,
level: 'ERROR',
message: 'DynamicVoice Fehler beim Erstellen: ' + (err as any)?.message,
timestamp: Date.now(),
category: 'dynamicVoice'
});
}
}
private async cleanupIfEmpty(guild: Guild, channelId: string) {
try {
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || channel.type !== ChannelType.GuildVoice) return;
if (channel.members.size === 0 && this.isManaged(guild.id, channelId)) {
await channel.delete('Dynamic voice cleanup').catch(() => undefined);
this.untrack(guild.id, channelId);
context.logging.logSystem(guild, `DynamicVoice entfernt: ${channel.name} (${channel.id})`);
context.admin.pushGuildLog({
guildId: guild.id,
level: 'INFO',
message: `DynamicVoice entfernt: ${channel.name} (${channel.id})`,
timestamp: Date.now(),
category: 'dynamicVoice'
});
}
} catch (err) {
logger.error('Dynamic voice cleanup failed', err);
context.logging.logSystem(guild, 'DynamicVoice Cleanup Fehler');
context.admin.pushGuildLog({
guildId: guild.id,
level: 'ERROR',
message: 'DynamicVoice Cleanup Fehler: ' + (err as any)?.message,
timestamp: Date.now(),
category: 'dynamicVoice'
});
}
}
public async cleanupGuild(guildId: string, client?: Client | null) {
const channelIds = Array.from(this.getSet(guildId).values());
if (!channelIds.length) return;
if (client) {
const guild = client.guilds.cache.get(guildId) ?? (await client.guilds.fetch(guildId).catch(() => null));
if (guild) {
for (const id of channelIds) {
const channel = await guild.channels.fetch(id).catch(() => null);
if (channel && channel.type === ChannelType.GuildVoice && channel.members.size === 0) {
await channel.delete('Dynamic voice cleanup (module disabled)').catch(() => undefined);
}
this.untrack(guildId, id);
}
return;
}
}
channelIds.forEach((id) => this.untrack(guildId, id));
}
}

View File

@@ -1,8 +1,9 @@
import fs from 'fs';
import path from 'path';
import { Client } from 'discord.js';
import { EventHandler } from '../utils/types.js';
import { logger } from '../utils/logger.js';
import { EventHandler } from '../utils/types';
import { logger } from '../utils/logger';
import { context } from '../config/context';
export class EventHandlerService {
constructor(private client: Client) {}
@@ -15,9 +16,15 @@ export class EventHandlerService {
const event: EventHandler = mod.default;
if (!event?.name || !event.execute) continue;
if (event.once) {
this.client.once(event.name, (...args) => event.execute(...args));
this.client.once(event.name, (...args) => {
this.track(event.name, args);
event.execute(...args);
});
} else {
this.client.on(event.name, (...args) => event.execute(...args));
this.client.on(event.name, (...args) => {
this.track(event.name, args);
event.execute(...args);
});
}
logger.info(`Bound event ${event.name}`);
}
@@ -33,4 +40,16 @@ export class EventHandlerService {
}
return files;
}
private track(name: string, args: any[]) {
try {
const guildId =
(args[0]?.guild?.id as string | undefined) ||
(args[0]?.guildId as string | undefined) ||
(args[0]?.message?.guildId as string | undefined);
context.admin.trackEvent(name, guildId);
} catch {
/* ignore */
}
}
}

View File

@@ -0,0 +1,177 @@
import { Client, EmbedBuilder, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle, ButtonInteraction, Guild } from 'discord.js';
import { prisma } from '../database';
import { settingsStore } from '../config/state';
import { logger } from '../utils/logger';
export type RepeatType = 'none' | 'daily' | 'weekly' | 'monthly';
export class EventService {
private client: Client | null = null;
private timer: NodeJS.Timeout | null = null;
public setClient(client: Client) {
this.client = client;
}
public startScheduler(intervalMs = 60000) {
if (this.timer) clearInterval(this.timer);
const interval = Math.max(30000, intervalMs);
this.timer = setInterval(() => this.tick().catch((err) => logger.warn('event scheduler failed', err)), interval);
}
public stopScheduler() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
public async tick() {
const now = new Date();
const soon = new Date(now.getTime() + 30 * 60 * 1000);
const events = await prisma.event.findMany({
where: { isActive: true, startTime: { lte: soon } },
orderBy: { startTime: 'asc' }
});
for (const ev of events) {
await this.processEvent(ev, now);
}
}
private async processEvent(ev: any, now: Date) {
const reminderAt = new Date(ev.startTime.getTime() - (ev.reminderOffsetMinutes ?? 60) * 60000);
if (ev.lastReminderAt) {
// already reminded for this start time
} else if (now >= reminderAt) {
await this.sendReminder(ev);
await prisma.event.update({ where: { id: ev.id }, data: { lastReminderAt: new Date() } });
}
if (now >= ev.startTime) {
if (ev.repeatType && ev.repeatType !== 'none') {
const nextTime = this.computeNextStart(ev.startTime, ev.repeatType, ev.repeatConfig);
if (nextTime) {
await prisma.event.update({ where: { id: ev.id }, data: { startTime: nextTime, lastReminderAt: null } });
} else {
await prisma.event.update({ where: { id: ev.id }, data: { isActive: false } });
}
} else {
await prisma.event.update({ where: { id: ev.id }, data: { isActive: false } });
}
}
}
private computeNextStart(current: Date, repeat: RepeatType, cfg: any) {
const base = new Date(current.getTime());
if (repeat === 'daily') {
base.setDate(base.getDate() + 1);
return base;
}
if (repeat === 'weekly') {
const days = Array.isArray(cfg?.days) ? cfg.days.map((d: any) => Number(d)).filter((n: number) => !isNaN(n)) : [];
if (!days.length) {
base.setDate(base.getDate() + 7);
return base;
}
const currentDay = current.getDay();
const sorted = days.sort((a: number, b: number) => a - b);
const next = sorted.find((d: number) => d > currentDay);
const targetDay = next ?? sorted[0];
const diff = targetDay > currentDay ? targetDay - currentDay : 7 - (currentDay - targetDay);
base.setDate(base.getDate() + diff);
return base;
}
if (repeat === 'monthly') {
const day = Number(cfg?.day || current.getDate());
const nextMonth = current.getMonth() + 1;
const year = current.getFullYear() + Math.floor(nextMonth / 12);
const month = nextMonth % 12;
const res = new Date(current.getTime());
res.setFullYear(year);
res.setMonth(month);
res.setDate(Math.min(day || 1, 28));
return res;
}
return null;
}
private async sendReminder(ev: any) {
if (!this.client) return;
const guild = this.client.guilds.cache.get(ev.guildId) ?? (await this.client.guilds.fetch(ev.guildId).catch(() => null));
if (!guild) return;
const settings = settingsStore.get(ev.guildId) || {};
if ((settings as any).eventsEnabled === false) return;
const channel = await guild.channels.fetch(ev.channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const signups = await prisma.eventSignup.count({ where: { eventId: ev.id, canceledAt: null } });
const embed = new EmbedBuilder()
.setTitle(ev.title || 'Event')
.setDescription(ev.description || 'Event Erinnerung')
.addFields(
{ name: 'Start', value: `<t:${Math.floor(ev.startTime.getTime() / 1000)}:f>`, inline: true },
{ name: 'Wiederholung', value: ev.repeatType || 'none', inline: true },
{ name: 'Anmeldungen', value: String(signups || 0), inline: true }
)
.setColor(0xf97316);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`event:signup:${ev.id}`).setStyle(ButtonStyle.Success).setLabel('Anmelden'),
new ButtonBuilder().setCustomId(`event:signoff:${ev.id}`).setStyle(ButtonStyle.Secondary).setLabel('Abmelden')
);
const content = ev.roleId ? `<@&${ev.roleId}>` : undefined;
await (channel as TextChannel).send({ content, embeds: [embed], components: [row] }).catch(() => undefined);
}
public async handleButton(interaction: ButtonInteraction, action: 'signup' | 'signoff', eventId: string) {
if (!interaction.guildId) return;
const ev = await prisma.event.findFirst({ where: { id: eventId, guildId: interaction.guildId } });
if (!ev) {
await interaction.reply({ content: 'Event nicht gefunden.', ephemeral: true });
return;
}
if (!ev.isActive) {
await interaction.reply({ content: 'Dieses Event ist inaktiv.', ephemeral: true });
return;
}
if (action === 'signup') {
await prisma.eventSignup.upsert({
where: { eventId_userId: { eventId: ev.id, userId: interaction.user.id } },
update: { canceledAt: null },
create: { eventId: ev.id, guildId: interaction.guildId, userId: interaction.user.id }
});
await interaction.reply({ content: 'Du bist angemeldet.', ephemeral: true });
} else {
const existing = await prisma.eventSignup.findFirst({ where: { eventId: ev.id, userId: interaction.user.id, canceledAt: null } });
if (!existing) {
await interaction.reply({ content: 'Du warst nicht angemeldet.', ephemeral: true });
return;
}
await prisma.eventSignup.update({ where: { id: existing.id }, data: { canceledAt: new Date() } });
await interaction.reply({ content: 'Abmeldung gespeichert.', ephemeral: true });
}
}
public async listEvents(guildId: string) {
return prisma.event.findMany({ where: { guildId }, orderBy: { startTime: 'asc' }, include: { _count: { select: { signups: { where: { canceledAt: null } } as any } } as any } as any } as any);
}
public async saveEvent(data: any) {
const base = {
guildId: data.guildId,
title: data.title,
description: data.description,
channelId: data.channelId,
startTime: new Date(data.startTime),
repeatType: data.repeatType || 'none',
repeatConfig: data.repeatConfig || {},
reminderOffsetMinutes: data.reminderOffsetMinutes ?? 60,
roleId: data.roleId || null,
isActive: data.isActive !== false
};
if (data.id) {
return prisma.event.update({ where: { id: data.id }, data: base });
}
return prisma.event.create({ data: base });
}
public async deleteEvent(guildId: string, id: string) {
await prisma.eventSignup.deleteMany({ where: { eventId: id, guildId } }).catch(() => undefined);
return prisma.event.delete({ where: { id } }).catch(() => undefined);
}
}

View File

@@ -1,17 +1,20 @@
import { ForumRoleSync, ForumTicketLink, ForumUser } from '../utils/types.js';
import { ForumRoleSync, ForumTicketLink, ForumUser } from '../utils/types';
export class ForumService {
async linkDiscordToForum(discordId: string, forumUserId: string): Promise<ForumUser> {
// TODO: TICKETS: Forum-Account-Linking mit Dashboard-Flow (OAuth/Token) und Persistenz verknüpfen.
return { discordId, forumUserId };
}
async syncForumRoles(): Promise<ForumRoleSync[]> {
// Placeholder: integrate with Forum API
// TODO: MODULE: Forum-Sync als optionales Modul führen (per Dashboard togglen, Rollen-Mapping speichern).
return [];
}
async exportTicketToForum(ticketId: string): Promise<ForumTicketLink> {
// Placeholder: integrate with Forum API
// TODO: TICKETS: Ticket-Threads automatisiert im Forum anlegen und Status-Sync (Dashboard <-> Forum) implementieren.
return { ticketId };
}
}

View File

@@ -1,6 +1,7 @@
import { Collection, Message } from 'discord.js';
import { logger } from '../utils/logger.js';
import { settings } from '../config/state.js';
import { logger } from '../utils/logger';
import { settingsStore } from '../config/state';
import { prisma } from '../database';
interface LevelData {
xp: number;
@@ -8,32 +9,53 @@ interface LevelData {
}
export class LevelService {
private data = new Collection<string, LevelData>();
private cache = new Collection<string, LevelData>();
private cooldown = new Set<string>();
handleMessage(message: Message) {
if (!message.guild || message.author.bot) return;
const guildConfig = settings.get(message.guild.id);
if (!guildConfig?.levelingEnabled) return;
private key(guildId: string, userId: string) {
return `${guildId}:${userId}`;
}
const key = `${message.guild.id}:${message.author.id}`;
async handleMessage(message: Message) {
if (!message.guild || message.author.bot) return;
const guildConfig = settingsStore.get(message.guild.id);
if (guildConfig?.levelingEnabled !== true) return;
const key = this.key(message.guild.id, message.author.id);
if (this.cooldown.has(key)) return;
this.cooldown.add(key);
setTimeout(() => this.cooldown.delete(key), 60_000);
const entry = this.data.get(key) ?? { xp: 0, level: 0 };
entry.xp += 10;
const entry = await this.loadLevel(message.guild.id, message.author.id);
const xpGain = 10;
entry.xp += xpGain;
const nextLevel = Math.floor(0.2 * Math.sqrt(entry.xp));
if (nextLevel > entry.level) {
entry.level = nextLevel;
message.channel.send({ content: `${message.author} hat Level ${entry.level} erreicht!` }).catch(() => undefined);
logger.info(`Level up: ${message.author.tag} -> ${entry.level}`);
}
this.data.set(key, entry);
this.cache.set(key, entry);
await prisma.level.upsert({
where: { userId_guildId: { userId: message.author.id, guildId: message.guild.id } },
update: { xp: entry.xp, level: entry.level },
create: { userId: message.author.id, guildId: message.guild.id, xp: entry.xp, level: entry.level }
});
}
getLevel(userId: string, guildId: string) {
const key = `${guildId}:${userId}`;
return this.data.get(key) ?? { xp: 0, level: 0 };
async getLevel(userId: string, guildId: string) {
const key = this.key(guildId, userId);
const cached = this.cache.get(key);
if (cached) return cached;
return this.loadLevel(guildId, userId);
}
private async loadLevel(guildId: string, userId: string): Promise<LevelData> {
const key = this.key(guildId, userId);
if (this.cache.has(key)) return this.cache.get(key)!;
const row = await prisma.level.findUnique({ where: { userId_guildId: { guildId, userId } } }).catch(() => null);
const entry: LevelData = { xp: row?.xp ?? 0, level: row?.level ?? 0 };
this.cache.set(key, entry);
return entry;
}
}

View File

@@ -1,18 +1,65 @@
import { TextChannel, Guild, Message, GuildMember, User, EmbedBuilder } from 'discord.js';
import { logger } from '../utils/logger.js';
import { TextChannel, Guild, Message, GuildMember, User, EmbedBuilder, GuildChannel } from 'discord.js';
import { logger } from '../utils/logger';
import { settingsStore } from '../config/state';
import type { AdminService } from './adminService';
let adminSink: AdminService | null = null;
export const setLoggingAdmin = (admin: AdminService) => {
adminSink = admin;
};
type LogCategory =
| 'joinLeave'
| 'messageEdit'
| 'messageDelete'
| 'automodActions'
| 'ticketActions'
| 'musicEvents'
| 'system';
export class LoggingService {
constructor(private logChannelId?: string) {}
constructor(private fallbackLogChannelId?: string) {}
private getChannel(guild: Guild): TextChannel | null {
if (!this.logChannelId) return null;
const channel = guild.channels.cache.get(this.logChannelId);
if (!channel || channel.type !== 0) return null;
return channel as TextChannel;
private safeField(value?: string | null) {
const text = (value ?? '').toString();
const normalized = text.trim();
if (!normalized.length) return '(leer)';
return normalized.length > 1024 ? normalized.slice(0, 1021) + '...' : normalized;
}
logSystem(guild: Guild, message: string) {
if (!this.shouldLog(guild, 'system')) return;
const { channel } = this.resolve(guild);
if (!channel) return;
const embed = new EmbedBuilder().setTitle('System').setDescription(message).setColor(0xf97316).setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log system', err));
adminSink?.pushGuildLog({
guildId: guild.id,
level: 'INFO',
message,
timestamp: Date.now(),
category: 'system'
});
}
private resolve(guild: Guild) {
const cfg = settingsStore.get(guild.id);
const loggingCfg = cfg?.loggingConfig || cfg?.automodConfig?.loggingConfig || {};
const logChannelId = loggingCfg.logChannelId || cfg?.logChannelId || this.fallbackLogChannelId;
const flags = loggingCfg.categories || {};
const channel = logChannelId ? guild.channels.cache.get(logChannelId) : null;
return { channel: channel && channel.type === 0 ? (channel as TextChannel) : null, flags };
}
private shouldLog(guild: Guild, category: LogCategory) {
const { flags } = this.resolve(guild);
if (flags[category] === false) return false;
return true;
}
logMemberJoin(member: GuildMember) {
const channel = this.getChannel(member.guild);
if (!this.shouldLog(member.guild, 'joinLeave')) return;
const { channel } = this.resolve(member.guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Member joined')
@@ -20,10 +67,12 @@ export class LoggingService {
.setColor(0x00ff99)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log join', err));
adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'INFO', message: 'Member joined: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' });
}
logMemberLeave(member: GuildMember) {
const channel = this.getChannel(member.guild);
if (!this.shouldLog(member.guild, 'joinLeave')) return;
const { channel } = this.resolve(member.guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Member left')
@@ -31,48 +80,88 @@ export class LoggingService {
.setColor(0xff9900)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log leave', err));
adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'WARN', message: 'Member left: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' });
}
logMessageDelete(message: Message) {
if (!message.guild) return;
const channel = this.getChannel(message.guild);
if (!this.shouldLog(message.guild, 'messageDelete')) return;
const { channel } = this.resolve(message.guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Nachricht gelöscht')
.setTitle('Nachricht geloescht')
.setDescription(`Von: ${message.author?.tag ?? 'Unbekannt'}`)
.addFields({ name: 'Inhalt', value: message.content || '(leer)' })
.addFields({ name: 'Inhalt', value: this.safeField(message.content) })
.setColor(0xff0000)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message delete', err));
adminSink?.pushGuildLog({
guildId: message.guild.id,
level: 'WARN',
message: 'Message deleted by ' + (message.author?.tag ?? 'Unknown'),
timestamp: Date.now(),
category: 'messageDelete'
});
}
logMessageEdit(oldMessage: Message, newMessage: Message) {
if (!oldMessage.guild) return;
const channel = this.getChannel(oldMessage.guild);
if (!this.shouldLog(oldMessage.guild, 'messageEdit')) return;
const { channel } = this.resolve(oldMessage.guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Nachricht bearbeitet')
.setDescription(`Von: ${oldMessage.author?.tag ?? 'Unbekannt'}`)
.addFields(
{ name: 'Alt', value: oldMessage.content || '(leer)' },
{ name: 'Neu', value: newMessage.content || '(leer)' }
{ name: 'Alt', value: this.safeField(oldMessage.content) },
{ name: 'Neu', value: this.safeField(newMessage.content) }
)
.setColor(0xffff00)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message edit', err));
adminSink?.pushGuildLog({
guildId: oldMessage.guild.id,
level: 'INFO',
message: 'Message edited by ' + (oldMessage.author?.tag ?? 'Unknown'),
timestamp: Date.now(),
category: 'messageEdit'
});
}
logAction(user: User, action: string, reason?: string) {
const guild = user instanceof GuildMember ? user.guild : null;
if (!guild) return;
const channel = this.getChannel(guild);
if (!this.shouldLog(guild, 'automodActions')) return;
const { channel } = this.resolve(guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Moderation')
.setDescription(`${user.tag} -> ${action}`)
.addFields({ name: 'Grund', value: reason || 'Nicht angegeben' })
.addFields({ name: 'Grund', value: this.safeField(reason || 'Nicht angegeben') })
.setColor(0x7289da)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err));
const guildId = (user as GuildMember)?.guild?.id;
if (guildId) {
adminSink?.pushGuildLog({
guildId,
level: 'INFO',
message: `Moderation action: ${action} (${user.tag})`,
timestamp: Date.now(),
category: 'automodActions'
});
adminSink?.trackGuildEvent(guildId, 'automod');
}
}
logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
const guildId = member.guild.id;
adminSink?.pushGuildLog({
guildId,
level: 'INFO',
message: `Roles changed for ${member.user.tag}: +${added.length} / -${removed.length}`,
timestamp: Date.now(),
category: 'roles'
});
}
}

View File

@@ -0,0 +1,80 @@
import { settingsStore } from '../config/state';
export type ModuleKey =
| 'ticketsEnabled'
| 'automodEnabled'
| 'levelingEnabled'
| 'musicEnabled'
| 'welcomeEnabled'
| 'dynamicVoiceEnabled'
| 'statuspageEnabled'
| 'birthdayEnabled'
| 'reactionRolesEnabled'
| 'eventsEnabled';
export interface GuildModuleState {
key: ModuleKey;
name: string;
description: string;
enabled: boolean;
}
const MODULES: Record<ModuleKey, { name: string; description: string }> = {
ticketsEnabled: { name: 'Ticketsystem', description: 'Ticket-Panel, Buttons, Transcript-Export.' },
automodEnabled: { name: 'Automod', description: 'Linkfilter und Anti-Spam Timeout.' },
levelingEnabled: { name: 'Leveling', description: 'XP/Level-Tracking und /rank.' },
musicEnabled: { name: 'Musik', description: 'Wiedergabe, Queue, Loop, Pause/Resume.' },
welcomeEnabled: { name: 'Willkommensnachrichten', description: 'Begruessungs-Embed beim Join.' },
dynamicVoiceEnabled: { name: 'Dynamische Voice Channels', description: 'Erzeugt private Voice-Channels aus einer Lobby.' },
statuspageEnabled: { name: 'Statuspage', description: 'Service Checks, Uptime und Status-Embed.' },
birthdayEnabled: { name: 'Birthday', description: 'Geburtstage speichern und Glueckwuensche senden.' },
reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' },
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' }
};
export class BotModuleService {
private hooks: Partial<Record<ModuleKey, { onEnable?: (guildId: string) => Promise<void> | void; onDisable?: (guildId: string) => Promise<void> | void }>> =
{};
public setHooks(hooks: Partial<Record<ModuleKey, { onEnable?: (guildId: string) => Promise<void> | void; onDisable?: (guildId: string) => Promise<void> | void }>>) {
this.hooks = hooks;
}
public async getModulesForGuild(guildId: string): Promise<GuildModuleState[]> {
const cfg = settingsStore.get(guildId) ?? {};
return Object.entries(MODULES).map(([key, meta]) => {
let enabled = cfg[key as ModuleKey] === true;
if (key === 'automodEnabled') enabled = cfg.automodEnabled === true;
if (key === 'welcomeEnabled') enabled = cfg.welcomeConfig?.enabled === true || cfg.automodConfig?.welcomeConfig?.enabled === true;
if (key === 'dynamicVoiceEnabled') enabled = cfg.dynamicVoiceEnabled === true || (cfg.dynamicVoiceConfig as any)?.enabled === true;
if (key === 'statuspageEnabled') enabled = cfg.statuspageEnabled === true || cfg.automodConfig?.statuspageEnabled === true;
if (key === 'birthdayEnabled') enabled = cfg.birthdayEnabled === true || cfg.birthdayConfig?.enabled === true;
if (key === 'reactionRolesEnabled') enabled = cfg.reactionRolesEnabled === true || cfg.reactionRolesConfig?.enabled === true;
if (key === 'eventsEnabled') enabled = (cfg as any).eventsEnabled !== false;
return {
key: key as ModuleKey,
name: meta.name,
description: meta.description,
enabled
};
});
}
public async enableModule(guildId: string, key: ModuleKey) {
if (key === 'welcomeEnabled') {
await settingsStore.set(guildId, { welcomeConfig: { enabled: true } } as any);
} else {
await settingsStore.set(guildId, { [key]: true } as any);
}
await this.hooks[key]?.onEnable?.(guildId);
}
public async disableModule(guildId: string, key: ModuleKey) {
if (key === 'welcomeEnabled') {
await settingsStore.set(guildId, { welcomeConfig: { enabled: false } } as any);
} else {
await settingsStore.set(guildId, { [key]: false } as any);
}
await this.hooks[key]?.onDisable?.(guildId);
}
}

View File

@@ -1,7 +1,8 @@
import { AudioPlayer, AudioPlayerStatus, AudioResource, VoiceConnection, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection } from '@discordjs/voice';
import { ChatInputCommandInteraction, GuildMember, TextChannel } from 'discord.js';
import play from 'play-dl';
import { logger } from '../utils/logger.js';
import { logger } from '../utils/logger';
import { settingsStore } from '../config/state';
export type LoopMode = 'off' | 'song' | 'queue';
@@ -9,6 +10,7 @@ interface QueueItem {
title: string;
url: string;
requester: string;
originalQuery?: string;
}
interface QueueState {
@@ -24,39 +26,63 @@ export class MusicService {
private queues = new Map<string, QueueState>();
private getQueue(guildId: string) {
const cfg = settingsStore.get(guildId);
if (cfg?.musicEnabled === false) return undefined;
return this.queues.get(guildId);
}
private ensureConnection(interaction: ChatInputCommandInteraction): VoiceConnection | null {
private async ensureConnection(interaction: ChatInputCommandInteraction): Promise<VoiceConnection | null> {
const member = interaction.member as GuildMember;
const voice = member?.voice?.channel;
if (!voice || !interaction.guildId) return null;
const existing = getVoiceConnection(interaction.guildId);
if (existing) return existing;
const connection = joinVoiceChannel({
const connection = existing ?? joinVoiceChannel({
channelId: voice.id,
guildId: interaction.guildId,
adapterCreator: interaction.guild!.voiceAdapterCreator
});
return connection;
try {
await entersState(connection, VoiceConnectionStatus.Ready, 15000);
return connection;
} catch {
connection.destroy();
return null;
}
}
public async play(interaction: ChatInputCommandInteraction, query: string) {
if (!interaction.guildId) return;
const connection = this.ensureConnection(interaction);
const cfg = settingsStore.get(interaction.guildId);
if (cfg?.musicEnabled === false) {
await interaction.reply({ content: 'Musik ist fuer diese Guild deaktiviert.', ephemeral: true });
return;
}
const trimmedQuery = (query || '').trim();
if (!trimmedQuery) {
await interaction.reply({ content: 'Bitte gib einen Titel oder Link an.', ephemeral: true });
return;
}
const connection = await this.ensureConnection(interaction);
if (!connection) {
await interaction.reply({ content: 'Du musst in einem Voice-Channel sein.', ephemeral: true });
await interaction.reply({ content: 'Voice-Verbindung konnte nicht hergestellt werden. Bitte versuche es erneut.', ephemeral: true });
return;
}
const search = await play.search(query, { source: { youtube: 'video' }, limit: 1 });
if (!search.length) {
await interaction.reply({ content: 'Nichts gefunden.', ephemeral: true });
let track: { title: string; url: string } | null = null;
try {
track = await this.resolveTrack(trimmedQuery);
} catch (err) {
logger.error('Music resolve fatal', err);
}
if (!track) {
await interaction.reply({ content: 'Nichts gefunden oder URL ungueltig.', ephemeral: true });
return;
}
const info = search[0];
const queueItem: QueueItem = { title: info.title ?? 'Unbekannt', url: info.url, requester: interaction.user.tag };
if (!/^https?:\/\//i.test(track.url ?? '')) {
await interaction.reply({ content: 'Der gefundene Link ist ungueltig.', ephemeral: true });
return;
}
const queueItem: QueueItem = { title: track.title ?? 'Unbekannt', url: track.url, requester: interaction.user.tag, originalQuery: trimmedQuery };
const queue = this.getQueue(interaction.guildId);
if (!queue) {
const player = createAudioPlayer();
@@ -74,7 +100,7 @@ export class MusicService {
this.processQueue(interaction.guildId);
} else {
queue.queue.push(queueItem);
await interaction.reply({ content: `Zur Queue hinzugefügt: **${queueItem.title}**` });
await interaction.reply({ content: `Zur Queue hinzugefuegt: **${queueItem.title}**` });
}
}
@@ -92,6 +118,12 @@ export class MusicService {
this.queues.delete(guildId);
}
public stopAll() {
for (const guildId of Array.from(this.queues.keys())) {
this.stop(guildId);
}
}
public pause(guildId: string) {
this.getQueue(guildId)?.player.pause(true);
}
@@ -120,22 +152,53 @@ export class MusicService {
const queue = this.getQueue(guildId);
if (!queue || queue.player.state.status === AudioPlayerStatus.Playing) return;
let next = queue.queue.shift();
if (!next && queue.loop === 'queue' && queue.current) {
next = queue.current;
let next: QueueItem | undefined = undefined;
let safety = 0;
while (safety++ < 5) {
next = queue.queue.shift();
if (!next && queue.loop === 'queue' && queue.current) {
next = queue.current;
}
if (!next) break;
const streamUrlCheck = typeof next.url === 'string' ? next.url.trim() : '';
if (streamUrlCheck && streamUrlCheck !== 'undefined' && /^https?:\/\//i.test(streamUrlCheck)) {
break;
}
logger.error('Music stream error', { reason: 'invalid_url', item: next });
queue.channel.send({ content: `Ungueltiger Track-Link, ueberspringe: **${next.title ?? 'Unbekannt'}**.` }).catch(() => undefined);
next = undefined;
}
if (!next) {
queue.channel.send({ content: 'Queue beendet.' }).catch(() => undefined);
return;
}
const streamUrl = (next.url || '').trim();
queue.current = next;
const stream = await play.stream(next.url);
const resource: AudioResource = createAudioResource(stream.stream, {
inputType: stream.type
});
queue.player.play(resource);
queue.connection.subscribe(queue.player);
try {
const kind = await play.validate(streamUrl);
if (kind !== 'so_track') {
logger.error('Music stream error', { reason: 'unsupported_url', kind, item: next });
queue.channel.send({ content: `Nur SoundCloud wird unterstuetzt, ueberspringe: **${next.title ?? 'Unbekannt'}**.` }).catch(() => undefined);
queue.current = undefined;
this.processQueue(guildId);
return;
}
const finalUrl = streamUrl;
if (!finalUrl || !/^https?:\/\//i.test(finalUrl) || finalUrl === 'undefined') throw new Error('soundcloud_url_invalid');
const stream = await play.stream(finalUrl);
if (!stream?.stream) throw new Error('stream_invalid');
const resource: AudioResource = createAudioResource(stream.stream, {
inputType: stream.type
});
queue.player.play(resource);
queue.connection.subscribe(queue.player);
} catch (err) {
logger.error('Music stream error', err);
queue.channel.send({ content: `Fehler beim Laden von **${next.title}**.` }).catch(() => undefined);
queue.current = undefined;
this.processQueue(guildId);
}
}
private registerPlayer(queue: QueueState, guildId: string) {
@@ -150,6 +213,60 @@ export class MusicService {
this.queues.delete(guildId);
});
queue.player.on('error', (err) => logger.error('Audio player error', err));
queue.player.on('error', (err) => {
logger.error('Audio player error', err);
this.processQueue(guildId);
});
}
public getStatus() {
const sessions = Array.from(this.queues.entries())
.filter(([guildId]) => settingsStore.get(guildId)?.musicEnabled !== false)
.map(([guildId, q]) => ({
guildId,
nowPlaying: q.current,
queueLength: q.queue.length,
loop: q.loop
}));
return { activeGuilds: sessions.length, sessions };
}
private async resolveTrack(query: string, opts?: { skipPlaylist?: boolean }): Promise<{ title: string; url: string } | null> {
const trimmed = query.trim();
if (!trimmed) return null;
try {
let validation: string | null = null;
try {
validation = await play.validate(trimmed);
} catch (err) {
logger.warn('Music validate error', err);
}
if (validation === 'so_track') {
return { title: trimmed, url: trimmed };
}
// nur SoundCloud erlaubt, alles andere ignorieren
} catch (err) {
logger.error('Music resolve error', err);
}
const scSearch = await play.search(trimmed, { source: { soundcloud: 'tracks' }, limit: 1 }).catch((err) => {
logger.warn('SoundCloud search skipped', err?.message || err);
return [];
});
if (scSearch && scSearch.length) {
const sc = scSearch[0];
const url = sc.url || '';
if (url && /^https?:\/\//i.test(url)) return { title: sc.title ?? 'Unbekannt', url };
}
return null;
}
private buildVideoUrl(details: any): string | null {
if (!details) return null;
const url = details.url || details.permalink;
if (typeof url === 'string' && /^https?:\/\//i.test(url)) return url;
if (details.id) return `https://www.youtube.com/watch?v=${details.id}`;
if (details.videoId) return `https://www.youtube.com/watch?v=${details.videoId}`;
return null;
}
}

View File

@@ -0,0 +1,202 @@
import { Client, EmbedBuilder, MessageReaction, TextChannel, User, PermissionsBitField } from 'discord.js';
import { prisma } from '../database';
import { settingsStore } from '../config/state';
import { logger } from '../utils/logger';
export interface ReactionRoleEntry {
emoji: string;
roleId: string;
label?: string;
description?: string;
}
export interface ReactionRoleSetInput {
id?: string;
guildId: string;
channelId: string;
messageId?: string | null;
title?: string;
description?: string;
entries?: ReactionRoleEntry[];
}
export class ReactionRoleService {
private client: Client | null = null;
private cache = new Map<string, ReactionRoleEntry[]>();
public setClient(client: Client) {
this.client = client;
}
public async loadCache() {
this.cache.clear();
const sets = await prisma.reactionRoleSet.findMany({ where: { messageId: { not: null } } });
sets.forEach((set) => {
if (set.messageId) this.cache.set(set.messageId, set.entries as ReactionRoleEntry[]);
});
}
public async listSets(guildId: string) {
return prisma.reactionRoleSet.findMany({ where: { guildId }, orderBy: { createdAt: 'desc' } });
}
public async saveSet(input: ReactionRoleSetInput) {
const entries = this.sanitizeEntries(input.entries);
const base = {
guildId: input.guildId,
channelId: input.channelId,
messageId: input.messageId || null,
title: input.title,
description: input.description,
entries
};
let record: any;
if (input.id) {
const existing = await prisma.reactionRoleSet.findFirst({ where: { id: input.id, guildId: input.guildId } });
if (!existing) throw new Error('set not found');
record = await prisma.reactionRoleSet.update({ where: { id: input.id }, data: base });
} else {
record = await prisma.reactionRoleSet.create({ data: base });
}
const messageId = await this.syncMessage(record.guildId, record.channelId, record.messageId, record.title, record.description, entries);
if (messageId && messageId !== record.messageId) {
record = await prisma.reactionRoleSet.update({ where: { id: record.id }, data: { messageId } });
}
if (record.messageId) this.cache.set(record.messageId, entries);
return record;
}
public async deleteSet(guildId: string, id: string) {
const record = await prisma.reactionRoleSet.findFirst({ where: { id, guildId } });
if (!record) return;
if (record.messageId) this.cache.delete(record.messageId);
await prisma.reactionRoleSet.delete({ where: { id } });
}
public async handleReaction(reaction: MessageReaction, user: User, add: boolean) {
if (!reaction.message.guildId || user.bot) return;
const guildId = reaction.message.guildId;
const cfg = settingsStore.get(guildId) || {};
const enabled = cfg.reactionRolesEnabled ?? cfg.reactionRolesConfig?.enabled ?? true;
if (!enabled) return;
if (!reaction.message.partial && reaction.message.author?.id === user.id && add) return;
const entries = await this.getEntriesForMessage(reaction.message.id);
if (!entries?.length) return;
const entry = entries.find((e) => this.matchesEmoji(e.emoji, reaction));
if (!entry) return;
const guild = this.client?.guilds.cache.get(guildId) ?? (await this.client?.guilds.fetch(guildId).catch(() => null));
if (!guild) return;
const member = await guild.members.fetch(user.id).catch(() => null);
if (!member) return;
const role = guild.roles.cache.get(entry.roleId);
if (!role) return;
const me = guild.members.me;
if (!me?.permissions.has(PermissionsBitField.Flags.ManageRoles) || me.roles.highest.comparePositionTo(role) <= 0) return;
if (add) {
await member.roles.add(role, 'Reaction role');
} else {
await member.roles.remove(role, 'Reaction role removed');
}
}
public async resyncGuild(guildId: string) {
const cfg = settingsStore.get(guildId) || {};
const enabled = cfg.reactionRolesEnabled ?? cfg.reactionRolesConfig?.enabled ?? true;
if (!enabled) return;
const sets = await prisma.reactionRoleSet.findMany({ where: { guildId } });
for (const set of sets) {
const messageId = await this.syncMessage(set.guildId, set.channelId, set.messageId, set.title, set.description, set.entries as ReactionRoleEntry[]);
if (messageId && messageId !== set.messageId) {
await prisma.reactionRoleSet.update({ where: { id: set.id }, data: { messageId } });
}
if (messageId) this.cache.set(messageId, set.entries as ReactionRoleEntry[]);
}
}
public async ensureMessage(guildId: string, id: string) {
const set = await prisma.reactionRoleSet.findFirst({ where: { id, guildId } });
if (!set) return null;
const messageId = await this.syncMessage(set.guildId, set.channelId, set.messageId, set.title, set.description, set.entries as ReactionRoleEntry[]);
if (messageId && messageId !== set.messageId) {
await prisma.reactionRoleSet.update({ where: { id: set.id }, data: { messageId } });
}
if (messageId) this.cache.set(messageId, set.entries as ReactionRoleEntry[]);
return messageId;
}
private async getEntriesForMessage(messageId: string) {
if (this.cache.has(messageId)) return this.cache.get(messageId);
const set = await prisma.reactionRoleSet.findFirst({ where: { messageId } });
if (set) {
const entries = (set.entries as ReactionRoleEntry[]) || [];
this.cache.set(messageId, entries);
return entries;
}
return null;
}
private sanitizeEntries(entries?: ReactionRoleEntry[]) {
if (!Array.isArray(entries)) return [];
return entries
.map((e) => ({
emoji: (e.emoji || '').trim(),
roleId: (e.roleId || '').trim(),
label: e.label?.trim(),
description: e.description?.trim()
}))
.filter((e) => e.emoji && e.roleId);
}
private matchesEmoji(target: string, reaction: MessageReaction) {
const normalized = (target || '').replace(/[<>]/g, '').replace(/^:/, '').trim();
const id = reaction.emoji.id;
const name = reaction.emoji.name || '';
if (!normalized) return false;
if (id && (normalized === id || normalized.endsWith(id) || normalized === `${name}:${id}`)) return true;
if (name && (normalized === name || normalized === reaction.emoji.toString())) return true;
if (reaction.emoji.identifier && normalized === reaction.emoji.identifier) return true;
return false;
}
private async syncMessage(
guildId: string,
channelId: string,
messageId: string | null | undefined,
title: string | null | undefined,
description: string | null | undefined,
entries: ReactionRoleEntry[]
) {
if (!this.client) return null;
const channel = await this.client.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return null;
const perms = channel.permissionsFor(this.client.user?.id ?? '');
const canReact = perms?.has(PermissionsBitField.Flags.AddReactions);
if (!perms?.has(PermissionsBitField.Flags.SendMessages)) return null;
const embed = new EmbedBuilder()
.setTitle(title || 'Reaction Roles')
.setColor(0xf97316)
.setDescription(description || 'Reagiere, um Rollen zu erhalten.');
const lines = entries.map((e) => `${e.emoji} <@&${e.roleId}>${e.label ? ' — ' + e.label : ''}${e.description ? ' · ' + e.description : ''}`);
if (lines.length) embed.addFields({ name: 'Rollen', value: lines.join('\n') });
let message = null as any;
if (messageId) {
message = await (channel as TextChannel).messages.fetch(messageId).catch(() => null);
if (message) {
await message.edit({ embeds: [embed] });
}
}
if (!message) {
message = await (channel as TextChannel).send({ embeds: [embed] });
}
if (canReact) {
for (const entry of entries) {
try {
await message.react(entry.emoji);
} catch (err) {
logger.warn(`Failed to react with ${entry.emoji}: ${err}`);
}
}
}
return message.id as string;
}
}

View File

@@ -0,0 +1,225 @@
import { settingsStore } from '../config/state';
import { logger } from '../utils/logger';
import { Client, EmbedBuilder, TextChannel } from 'discord.js';
import net from 'net';
export type StatusServiceType = 'http' | 'ping' | 'tcp' | 'custom' | 'unknown';
export interface StatusService {
id: string;
name: string;
type?: StatusServiceType;
target: string;
description?: string;
enabled?: boolean;
status?: 'up' | 'down' | 'unknown';
lastChecked?: number;
upChecks?: number;
totalChecks?: number;
}
export interface StatuspageConfig {
enabled?: boolean;
intervalMs?: number;
services?: StatusService[];
statusChannelId?: string;
statusMessageId?: string;
}
export class StatuspageService {
private timers = new Map<string, NodeJS.Timeout>();
private client: Client | null = null;
private fetcher: any = (globalThis as any).fetch;
public setClient(client: Client) {
this.client = client;
}
public async getConfig(guildId: string): Promise<StatuspageConfig> {
const cfg = settingsStore.get(guildId) || {};
const sp = (cfg as any).statuspageConfig || {};
return {
enabled: cfg.statuspageEnabled ?? sp.enabled ?? true,
intervalMs: sp.intervalMs ?? 60000,
services: sp.services ?? [],
statusChannelId: sp.statusChannelId,
statusMessageId: sp.statusMessageId
};
}
public async saveConfig(guildId: string, cfg: StatuspageConfig) {
await settingsStore.set(guildId, {
statuspageEnabled: cfg.enabled,
statuspageConfig: cfg
} as any);
this.restartTimer(guildId, cfg);
}
public async addService(guildId: string, service: Omit<StatusService, 'id'>) {
const cfg = await this.getConfig(guildId);
const id = `svc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const svc: StatusService = {
id,
name: service.name,
type: service.type || 'unknown',
target: service.target,
description: service.description,
enabled: service.enabled ?? true,
status: 'unknown',
upChecks: 0,
totalChecks: 0
};
cfg.services = [...(cfg.services ?? []), svc];
await this.saveConfig(guildId, cfg);
return svc;
}
public async updateService(guildId: string, id: string, patch: Partial<StatusService>) {
const cfg = await this.getConfig(guildId);
cfg.services = (cfg.services ?? []).map((s) => (s.id === id ? { ...s, ...patch } : s));
await this.saveConfig(guildId, cfg);
}
public async deleteService(guildId: string, id: string) {
const cfg = await this.getConfig(guildId);
cfg.services = (cfg.services ?? []).filter((s) => s.id !== id);
await this.saveConfig(guildId, cfg);
}
public async getStatus(guildId: string) {
const cfg = await this.getConfig(guildId);
return {
enabled: cfg.enabled !== false,
services: cfg.services ?? []
};
}
public async runChecks(guildId: string) {
const cfg = await this.getConfig(guildId);
if (cfg.enabled === false) return;
const services = cfg.services ?? [];
const updated = await Promise.all(
services.map(async (svc) => {
if (svc.enabled === false) return svc;
const result = await this.checkService(svc);
return result;
})
);
cfg.services = updated;
await settingsStore.set(guildId, { statuspageEnabled: cfg.enabled, statuspageConfig: cfg } as any);
if (cfg.statusChannelId) {
const messageId = await this.publishStatus(guildId, cfg.statusChannelId, cfg);
if (messageId && cfg.statusMessageId !== messageId) {
cfg.statusMessageId = messageId;
await settingsStore.set(guildId, { statuspageConfig: cfg } as any);
}
}
}
private async checkService(svc: StatusService): Promise<StatusService> {
const next = { ...svc };
next.totalChecks = (next.totalChecks ?? 0) + 1;
next.lastChecked = Date.now();
try {
if (!this.fetcher) {
next.status = 'unknown';
return next;
}
const type = (svc.type || 'unknown').toLowerCase();
let target = svc.target || '';
if (!target) {
next.status = 'unknown';
return next;
}
if ((type === 'http' || type === 'https') && !/^https?:\/\//i.test(target)) {
target = 'http://' + target;
}
if (type === 'http' || type === 'https' || /^https?:\/\//i.test(target)) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 8000);
const res = await this.fetcher(target, { method: 'GET', signal: controller.signal });
clearTimeout(t);
if (res.ok) {
next.status = 'up';
next.upChecks = (next.upChecks ?? 0) + 1;
} else {
next.status = 'down';
}
} else if (type === 'tcp' || type === 'ping') {
const [host, portRaw] = target.split(':');
const port = Number(portRaw) || 80;
const ok = await this.checkTcp(host, port);
if (ok) {
next.status = 'up';
next.upChecks = (next.upChecks ?? 0) + 1;
} else {
next.status = 'down';
}
} else {
next.status = 'unknown';
}
} catch {
next.status = 'down';
}
return next;
}
private restartTimer(guildId: string, cfg: StatuspageConfig) {
const existing = this.timers.get(guildId);
if (existing) clearInterval(existing);
if (cfg.enabled === false) return;
const interval = Math.max(30000, cfg.intervalMs ?? 60000);
const timer = setInterval(() => this.runChecks(guildId).catch((err) => logger.warn(`status checks failed ${guildId}: ${err}`)), interval);
this.timers.set(guildId, timer);
}
public async publishStatus(guildId: string, channelId: string, cfg?: StatuspageConfig) {
if (!this.client) return;
const channel = await this.client.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const current = cfg ?? (await this.getConfig(guildId));
const services = current.services ?? [];
const embed = new EmbedBuilder()
.setTitle('Service Status')
.setColor(services.some((s) => s.status === 'down') ? 0xef4444 : 0x22c55e)
.setTimestamp(new Date());
const lines = services.map((s) => {
const icon = s.status === 'up' ? '✅' : s.status === 'down' ? '❌' : '⚪';
const upPct =
s.upChecks && s.totalChecks
? Math.round(((s.upChecks ?? 0) / Math.max(1, s.totalChecks ?? 1)) * 100)
: 0;
const last = s.lastChecked ? new Date(s.lastChecked).toLocaleString() : 'n/a';
return `${icon} ${s.name}${upPct}% (${last})`;
});
embed.setDescription(lines.join('\n') || 'Keine Services konfiguriert.');
let messageId = current.statusMessageId;
try {
if (messageId) {
const msg = await (channel as TextChannel).messages.fetch(messageId);
await msg.edit({ embeds: [embed] });
} else {
const sent = await (channel as TextChannel).send({ embeds: [embed] });
messageId = sent.id;
}
} catch {
const sent = await (channel as TextChannel).send({ embeds: [embed] });
messageId = sent.id;
}
return messageId;
}
private checkTcp(host: string, port: number) {
return new Promise<boolean>((resolve) => {
const socket = new net.Socket();
const done = (ok: boolean) => {
socket.destroy();
resolve(ok);
};
socket.setTimeout(6000);
socket.once('error', () => done(false));
socket.once('timeout', () => done(false));
socket.connect(port, host, () => done(true));
});
}
}

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;
}
}