Add events module with dashboard UI, scheduling, signups, and settings updates; extend env/readme.
This commit is contained in:
125
src/services/adminService.ts
Normal file
125
src/services/adminService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
160
src/services/birthdayService.ts
Normal file
160
src/services/birthdayService.ts
Normal 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] });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
152
src/services/dynamicVoiceService.ts
Normal file
152
src/services/dynamicVoiceService.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
177
src/services/eventService.ts
Normal file
177
src/services/eventService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
80
src/services/moduleService.ts
Normal file
80
src/services/moduleService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
202
src/services/reactionRoleService.ts
Normal file
202
src/services/reactionRoleService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
225
src/services/statuspageService.ts
Normal file
225
src/services/statuspageService.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user