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

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

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,5 +1,5 @@
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { SlashCommand } from '../../utils/types';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,7 +1,7 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { LoopMode } from '../../services/musicService.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
import { LoopMode } from '../../services/musicService';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,
@@ -15,3 +15,4 @@ const command: SlashCommand = {
};
export default command;

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionFlagsBits, ChannelType, TextChannel } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { prisma } from '../../database/index.js';
import { SlashCommand } from '../../utils/types';
import { prisma } from '../../database/index';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { prisma } from '../../database/index.js';
import { SlashCommand } from '../../utils/types';
import { prisma } from '../../database/index';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,7 +1,7 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { prisma } from '../../database/index.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
import { prisma } from '../../database/index';
import path from 'path';
import fs from 'fs';

View File

@@ -0,0 +1,35 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { normalizeBirthdayInput } from '../../services/birthdayService';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('birthday')
.setDescription('Speichert dein Geburtsdatum.')
.addStringOption((opt) => opt.setName('datum').setDescription('Format: DD.MM.YYYY, DD.MM oder YYYY-MM-DD').setRequired(true)),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) {
await interaction.reply({ content: 'Dieser Befehl funktioniert nur auf Servern.', ephemeral: true });
return;
}
const raw = interaction.options.getString('datum', true);
const normalized = normalizeBirthdayInput(raw);
if (!normalized) {
await interaction.reply({ content: 'Bitte gib ein gueltiges Datum an (DD.MM.YYYY, DD.MM oder YYYY-MM-DD).', ephemeral: true });
return;
}
await context.birthdays.setBirthday(interaction.guildId, interaction.user.id, normalized);
const formatted = normalized.startsWith('--')
? normalized
.replace('--', '')
.split('-')
.reverse()
.join('.') + '.'
: normalized.split('-').reverse().join('.');
await interaction.reply({ content: `Geburtstag gespeichert: ${formatted}`, ephemeral: true });
}
};
export default command;

View File

@@ -1,33 +1,82 @@
import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionFlagsBits, ChannelType } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { settings } from '../../config/state.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { settingsStore } from '../../config/state';
import { context } from '../../config/context';
import { LoggingService } from '../../services/loggingService';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('configure')
.setDescription('Setzt Basis-Einstellungen des Bots (gildenspezifisch).')
.addChannelOption((opt) => opt.setName('welcome_channel').setDescription('Kanal für Willkommensnachrichten').addChannelTypes(ChannelType.GuildText))
.addChannelOption((opt) => opt.setName('log_channel').setDescription('Kanal für Logs').addChannelTypes(ChannelType.GuildText))
.addChannelOption((opt) =>
opt.setName('welcome_channel').setDescription('Kanal fuer Willkommensnachrichten').addChannelTypes(ChannelType.GuildText)
)
.addChannelOption((opt) => opt.setName('log_channel').setDescription('Kanal fuer Logs').addChannelTypes(ChannelType.GuildText))
.addBooleanOption((opt) => opt.setName('automod').setDescription('Automod an/aus'))
.addNumberOption((opt) => opt.setName('spam_threshold').setDescription('Spam-Schwelle (Nachrichten im Zeitfenster)'))
.addNumberOption((opt) => opt.setName('spam_window_ms').setDescription('Spam-Zeitfenster in Millisekunden'))
.addNumberOption((opt) => opt.setName('spam_timeout_minutes').setDescription('Timeout-Dauer (Minuten) bei Spam'))
.addStringOption((opt) => opt.setName('link_whitelist').setDescription('Kommagetrennte Link-Whitelist (Domains)'))
.addBooleanOption((opt) => opt.setName('leveling').setDescription('Level-System an/aus'))
.addBooleanOption((opt) => opt.setName('dynamic_voice').setDescription('Dynamische Voice Channels an/aus'))
.addChannelOption((opt) =>
opt.setName('voice_lobby').setDescription('Voice-Lobby fuer dynamische Channels').addChannelTypes(ChannelType.GuildVoice)
)
.addStringOption((opt) => opt.setName('voice_template').setDescription('Name-Template, z.B. {user}s Channel'))
.addIntegerOption((opt) => opt.setName('voice_user_limit').setDescription('Userlimit fuer dynamische Channels'))
.addRoleOption((opt) => opt.setName('support_role').setDescription('Support-Rolle fuer Tickets'))
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return;
const guildSetting = settings.get(interaction.guildId) ?? {};
const guildSetting: any = settingsStore.get(interaction.guildId) ?? {};
const welcome = interaction.options.getChannel('welcome_channel');
const logChannel = interaction.options.getChannel('log_channel');
const automod = interaction.options.getBoolean('automod');
const spamThreshold = interaction.options.getNumber('spam_threshold');
const spamWindowMs = interaction.options.getNumber('spam_window_ms');
const spamTimeoutMinutes = interaction.options.getNumber('spam_timeout_minutes');
const linkWhitelist = interaction.options.getString('link_whitelist');
const leveling = interaction.options.getBoolean('leveling');
const dynamicVoice = interaction.options.getBoolean('dynamic_voice');
const voiceLobby = interaction.options.getChannel('voice_lobby');
const voiceTemplate = interaction.options.getString('voice_template');
const voiceUserLimit = interaction.options.getInteger('voice_user_limit');
const supportRole = interaction.options.getRole('support_role');
if (welcome) guildSetting.welcomeChannelId = welcome.id;
if (logChannel) guildSetting.logChannelId = logChannel.id;
if (automod !== null) guildSetting.automodEnabled = automod;
if (spamThreshold !== null || spamWindowMs !== null || spamTimeoutMinutes !== null || linkWhitelist !== null) {
guildSetting.automodConfig = {
...(guildSetting.automodConfig ?? {}),
spamThreshold: spamThreshold ?? guildSetting.automodConfig?.spamThreshold,
windowMs: spamWindowMs ?? guildSetting.automodConfig?.windowMs,
spamTimeoutMinutes: spamTimeoutMinutes ?? guildSetting.automodConfig?.spamTimeoutMinutes,
linkWhitelist:
linkWhitelist !== null
? linkWhitelist
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: guildSetting.automodConfig?.linkWhitelist
};
}
if (leveling !== null) guildSetting.levelingEnabled = leveling;
if (dynamicVoice !== null) guildSetting.dynamicVoiceEnabled = dynamicVoice;
if (voiceLobby || voiceTemplate || voiceUserLimit !== null) {
guildSetting.dynamicVoiceConfig = {
...(guildSetting.dynamicVoiceConfig ?? {}),
lobbyChannelId: voiceLobby?.id ?? guildSetting.dynamicVoiceConfig?.lobbyChannelId,
categoryId: voiceLobby?.parentId ?? guildSetting.dynamicVoiceConfig?.categoryId,
template: voiceTemplate ?? guildSetting.dynamicVoiceConfig?.template,
userLimit: voiceUserLimit ?? guildSetting.dynamicVoiceConfig?.userLimit
};
}
if (supportRole) guildSetting.supportRoleId = supportRole.id;
settings.set(interaction.guildId, guildSetting);
context.logging = new (context.logging.constructor as any)(guildSetting.logChannelId);
const updated = await settingsStore.set(interaction.guildId, guildSetting);
context.logging = new LoggingService(updated.logChannelId);
await interaction.reply({ content: 'Einstellungen gespeichert.', ephemeral: true });
}

View File

@@ -0,0 +1,29 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
import { settingsStore } from '../../config/state';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('event')
.setDescription('Zeigt Events an.')
.addStringOption((opt) => opt.setName('list').setDescription('Zeigt naechste Events').setChoices({ name: 'show', value: 'show' })),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return;
const cfg = settingsStore.get(interaction.guildId);
if ((cfg as any)?.eventsEnabled === false) {
await interaction.reply({ content: 'Events sind deaktiviert.', ephemeral: true });
return;
}
const events = await (context as any).events.listEvents(interaction.guildId);
if (!events?.length) {
await interaction.reply({ content: 'Keine Events vorhanden.', ephemeral: true });
return;
}
const lines = events.slice(0, 5).map((ev: any) => `${ev.title} - <t:${Math.floor(new Date(ev.startTime).getTime() / 1000)}:f>`);
await interaction.reply({ content: lines.join('\n'), ephemeral: true });
}
};
export default command;

View File

@@ -1,5 +1,5 @@
import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { SlashCommand } from '../../utils/types';
const command: SlashCommand = {
data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'),

View File

@@ -1,5 +1,5 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { SlashCommand } from '../../utils/types';
const command: SlashCommand = {
data: new SlashCommandBuilder().setName('ping').setDescription('Antwortet mit Pong!'),

View File

@@ -1,6 +1,6 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { context } from '../../config/context.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -1,5 +1,5 @@
import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types.js';
import { SlashCommand } from '../../utils/types';
const command: SlashCommand = {
guildOnly: true,

View File

@@ -0,0 +1,33 @@
import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
import { context } from '../../config/context';
export default {
data: new SlashCommandBuilder().setName('status').setDescription('Zeigt Statuspage-Services.'),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) {
await interaction.reply({ content: 'Nur in Guilds verfuegbar.', ephemeral: true });
return;
}
const cfg = await context.statuspage.getConfig(interaction.guildId);
const services = cfg.services ?? [];
const embed = new EmbedBuilder()
.setTitle('Service Status')
.setColor(services.some((s) => s.status === 'down') ? 0xef4444 : 0x22c55e)
.setDescription(services.length ? '' : 'Keine Services konfiguriert.')
.setTimestamp(new Date());
services.forEach((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';
embed.addFields({
name: `${icon} ${s.name}`,
value: `Status: ${s.status ?? 'unknown'} | Uptime: ${upPct}% | Letzter Check: ${last}`,
inline: false
});
});
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};

View File

@@ -0,0 +1,63 @@
import { ChatInputCommandInteraction, EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { settingsStore } from '../../config/state';
import { SlashCommand } from '../../utils/types';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('welcome')
.setDescription('Loest die konfigurierte Willkommensnachricht aus.')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId || !interaction.guild) {
await interaction.reply({ content: 'Dieser Befehl ist nur in Guilds verfuegbar.', ephemeral: true });
return;
}
const cfg = settingsStore.get(interaction.guildId) ?? {};
const welcome = cfg.welcomeConfig || cfg.automodConfig?.welcomeConfig;
if (!welcome?.enabled || !welcome.channelId) {
await interaction.reply({ content: 'Willkommensnachrichten sind fuer diesen Server nicht konfiguriert.', ephemeral: true });
return;
}
const channel = interaction.guild.channels.cache.get(welcome.channelId) || (await interaction.guild.channels.fetch(welcome.channelId).catch(() => null));
if (!channel || !channel.isTextBased()) {
await interaction.reply({ content: 'Ziel-Channel fuer Willkommensnachrichten ist ungueltig.', ephemeral: true });
return;
}
const colorVal = parseInt((welcome.embedColor || '00ff99').replace('#', ''), 16);
const embed = new EmbedBuilder()
.setTitle(welcome.embedTitle || 'Willkommen!')
.setDescription(welcome.embedDescription || 'Schön, dass du da bist.')
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal)
.setFooter({ text: welcome.embedFooter || '' });
const files: any[] = [];
if (welcome.embedThumbnailData && welcome.embedThumbnailData.startsWith('data:')) {
const [meta, b64] = welcome.embedThumbnailData.split(',');
const ext = meta.includes('gif') ? 'gif' : 'png';
const buf = Buffer.from(b64, 'base64');
const name = `welcome-thumb.${ext}`;
files.push({ attachment: buf, name });
embed.setThumbnail(`attachment://${name}`);
} else if (welcome.embedThumbnail) {
embed.setThumbnail(welcome.embedThumbnail);
}
if (welcome.embedImageData && welcome.embedImageData.startsWith('data:')) {
const [meta, b64] = welcome.embedImageData.split(',');
const ext = meta.includes('gif') ? 'gif' : 'png';
const buf = Buffer.from(b64, 'base64');
const name = `welcome-image.${ext}`;
files.push({ attachment: buf, name });
embed.setImage(`attachment://${name}`);
} else if (welcome.embedImage) {
embed.setImage(welcome.embedImage);
}
await channel.send({ embeds: [embed], files }).catch(async () => {
await interaction.reply({ content: 'Senden der Willkommensnachricht ist fehlgeschlagen.', ephemeral: true });
});
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ content: 'Willkommensnachricht gesendet.', ephemeral: true });
}
}
};
export default command;

View File

@@ -1,15 +1,63 @@
import { CommandHandler } from '../services/commandHandler.js';
import { AutoModService } from '../services/automodService.js';
import { LoggingService } from '../services/loggingService.js';
import { MusicService } from '../services/musicService.js';
import { TicketService } from '../services/ticketService.js';
import { LevelService } from '../services/levelService.js';
import { CommandHandler } from '../services/commandHandler';
import { AutoModService } from '../services/automodService';
import { LoggingService, setLoggingAdmin } from '../services/loggingService';
import { MusicService } from '../services/musicService';
import { TicketService } from '../services/ticketService';
import { LevelService } from '../services/levelService';
import { Client } from 'discord.js';
import { BotModuleService } from '../services/moduleService';
import { DynamicVoiceService } from '../services/dynamicVoiceService';
import { AdminService } from '../services/adminService';
import { StatuspageService } from '../services/statuspageService';
import { BirthdayService } from '../services/birthdayService';
import { ReactionRoleService } from '../services/reactionRoleService';
import { EventService } from '../services/eventService';
export const context = {
client: null as Client | null,
commandHandler: null as CommandHandler | null,
automod: new AutoModService(true, true),
logging: new LoggingService(),
music: new MusicService(),
tickets: new TicketService(),
leveling: new LevelService()
leveling: new LevelService(),
dynamicVoice: new DynamicVoiceService(),
modules: new BotModuleService(),
admin: new AdminService(),
statuspage: new StatuspageService(),
birthdays: new BirthdayService(),
reactionRoles: new ReactionRoleService(),
events: new EventService()
};
context.modules.setHooks({
musicEnabled: {
onDisable: async () => context.music.stopAll()
},
dynamicVoiceEnabled: {
onDisable: async (guildId: string) => context.dynamicVoice.cleanupGuild(guildId, context.client)
},
statuspageEnabled: {
onEnable: async (guildId: string) => {
const cfg = await context.statuspage.getConfig(guildId);
await context.statuspage.saveConfig(guildId, { ...cfg, enabled: true });
},
onDisable: async (guildId: string) => {
const cfg = await context.statuspage.getConfig(guildId);
await context.statuspage.saveConfig(guildId, { ...cfg, enabled: false });
}
},
birthdayEnabled: {
onEnable: async (guildId: string) => context.birthdays.invalidate(guildId),
onDisable: async (guildId: string) => context.birthdays.invalidate(guildId)
},
reactionRolesEnabled: {
onEnable: async (guildId: string) => context.reactionRoles.resyncGuild(guildId),
onDisable: async (guildId: string) => context.reactionRoles.resyncGuild(guildId)
},
eventsEnabled: {
onEnable: async (guildId: string) => context.events.tick().catch(() => undefined)
}
});
setLoggingAdmin(context.admin);

View File

@@ -3,7 +3,7 @@ import path from 'path';
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
const required = ['DISCORD_TOKEN', 'DISCORD_CLIENT_ID', 'DISCORD_GUILD_ID', 'DATABASE_URL'] as const;
const required = ['DISCORD_TOKEN', 'DISCORD_CLIENT_ID', 'DISCORD_CLIENT_SECRET', 'DATABASE_URL'] as const;
required.forEach((key) => {
if (!process.env[key]) {
@@ -11,9 +11,16 @@ required.forEach((key) => {
}
});
const normalizeBasePath = (raw?: string) => {
if (!raw) return '';
const withSlash = raw.startsWith('/') ? raw : `/${raw}`;
return withSlash.replace(/\/+$/, '');
};
export const env = {
token: process.env.DISCORD_TOKEN ?? '',
clientId: process.env.DISCORD_CLIENT_ID ?? '',
clientSecret: process.env.DISCORD_CLIENT_SECRET ?? '',
guildId: process.env.DISCORD_GUILD_ID ?? '',
guildIds: (process.env.DISCORD_GUILD_IDS ?? process.env.DISCORD_GUILD_ID ?? '')
.split(',')
@@ -21,5 +28,12 @@ export const env = {
.filter(Boolean),
databaseUrl: process.env.DATABASE_URL ?? '',
port: Number(process.env.PORT ?? 3000),
sessionSecret: process.env.SESSION_SECRET ?? 'papo_dev_secret'
sessionSecret: process.env.SESSION_SECRET ?? 'papo_dev_secret',
supportRoleId: process.env.SUPPORT_ROLE_ID ?? '',
webBasePath: normalizeBasePath(process.env.WEB_BASE_PATH ?? '/ucp'),
publicBaseUrl: process.env.DASHBOARD_BASE_URL ? process.env.DASHBOARD_BASE_URL.replace(/\/+$/, '') : undefined,
ownerIds: (process.env.OWNER_IDS ?? process.env.OWNER_ID ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
};

View File

@@ -1,8 +1,225 @@
import { prisma } from '../database';
export interface GuildSettings {
welcomeChannelId?: string;
logChannelId?: string;
automodEnabled?: boolean;
automodConfig?: any;
statuspageEnabled?: boolean;
statuspageConfig?: any;
welcomeConfig?: {
enabled?: boolean;
channelId?: string;
embedTitle?: string;
embedDescription?: string;
embedColor?: string;
embedFooter?: string;
embedThumbnail?: string;
embedImage?: string;
embedThumbnailData?: string;
embedImageData?: string;
};
loggingConfig?: {
logChannelId?: string;
categories?: {
joinLeave?: boolean;
messageEdit?: boolean;
messageDelete?: boolean;
automodActions?: boolean;
ticketActions?: boolean;
musicEvents?: boolean;
};
};
levelingEnabled?: boolean;
ticketsEnabled?: boolean;
musicEnabled?: boolean;
dynamicVoiceEnabled?: boolean;
dynamicVoiceConfig?: {
lobbyChannelId?: string;
categoryId?: string;
userLimit?: number;
bitrate?: number;
template?: string;
};
eventsEnabled?: boolean;
supportLoginConfig?: {
panelChannelId?: string;
panelMessageId?: string;
title?: string;
description?: string;
loginLabel?: string;
logoutLabel?: string;
autoRefresh?: boolean;
};
birthdayEnabled?: boolean;
birthdayConfig?: {
enabled?: boolean;
channelId?: string;
sendHour?: number;
messageTemplate?: string;
};
reactionRolesEnabled?: boolean;
reactionRolesConfig?: any;
supportRoleId?: string;
welcomeEnabled?: boolean;
}
export const settings = new Map<string, GuildSettings>();
class SettingsStore {
private cache = new Map<string, GuildSettings>();
private applyModuleDefaults(cfg: GuildSettings): GuildSettings {
const normalized: GuildSettings = { ...cfg };
(
[
'ticketsEnabled',
'automodEnabled',
'welcomeEnabled',
'levelingEnabled',
'musicEnabled',
'dynamicVoiceEnabled',
'statuspageEnabled',
'birthdayEnabled',
'reactionRolesEnabled',
'eventsEnabled'
] as const
).forEach((key) => {
if (normalized[key] === undefined) normalized[key] = true;
});
// keep welcomeConfig flag in sync when present
if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) {
normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled };
}
return normalized;
}
public async init() {
const rows = await prisma.guildSettings.findMany();
rows.forEach((row) => {
const cfg = {
welcomeChannelId: row.welcomeChannelId ?? undefined,
logChannelId: row.logChannelId ?? undefined,
automodEnabled: row.automodEnabled ?? undefined,
automodConfig: (row as any).automodConfig ?? undefined,
statuspageEnabled: (row as any).statuspageEnabled ?? (row as any).automodConfig?.statuspageEnabled ?? undefined,
statuspageConfig: (row as any).statuspageConfig ?? (row as any).automodConfig?.statuspageConfig ?? undefined,
welcomeEnabled: (row as any).welcomeEnabled ?? undefined,
welcomeConfig: (row as any).automodConfig?.welcomeConfig ?? undefined,
loggingConfig: (row as any).automodConfig?.loggingConfig ?? undefined,
levelingEnabled: row.levelingEnabled ?? undefined,
ticketsEnabled: row.ticketsEnabled ?? undefined,
musicEnabled: (row as any).musicEnabled ?? undefined,
dynamicVoiceEnabled: (row as any).dynamicVoiceEnabled ?? undefined,
dynamicVoiceConfig: (row as any).dynamicVoiceConfig ?? undefined,
eventsEnabled: (row as any).eventsEnabled ?? undefined,
supportLoginConfig: (row as any).supportLoginConfig ?? undefined,
birthdayEnabled: (row as any).birthdayEnabled ?? undefined,
birthdayConfig: (row as any).birthdayConfig ?? undefined,
reactionRolesEnabled: (row as any).reactionRolesEnabled ?? undefined,
reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined,
supportRoleId: row.supportRoleId ?? undefined
} satisfies GuildSettings;
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
});
return this.cache;
}
public get(guildId: string) {
const cached = this.cache.get(guildId);
return cached ? this.applyModuleDefaults(cached) : undefined;
}
public all() {
return this.cache;
}
public async set(guildId: string, partial: GuildSettings) {
if ((partial as any).welcomeEnabled !== undefined) {
partial.welcomeConfig = { ...(partial.welcomeConfig ?? {}), enabled: (partial as any).welcomeEnabled };
delete (partial as any).welcomeEnabled;
}
if (partial.birthdayEnabled !== undefined) {
partial.birthdayConfig = { ...(partial.birthdayConfig ?? {}), enabled: partial.birthdayEnabled };
} else if (partial.birthdayConfig?.enabled !== undefined) {
partial.birthdayEnabled = partial.birthdayConfig.enabled;
}
if (partial.reactionRolesEnabled !== undefined) {
partial.reactionRolesConfig = { ...(partial.reactionRolesConfig ?? {}), enabled: partial.reactionRolesEnabled };
} else if (partial.reactionRolesConfig?.enabled !== undefined) {
partial.reactionRolesEnabled = partial.reactionRolesConfig.enabled;
}
const merged: GuildSettings = this.applyModuleDefaults({ ...(this.cache.get(guildId) ?? {}), ...partial });
const mergedAutomod = {
...(merged.automodConfig ?? {}),
...(partial.automodConfig ?? {}),
loggingConfig: partial.loggingConfig ?? merged.loggingConfig ?? merged.automodConfig?.loggingConfig,
welcomeConfig: partial.welcomeConfig ?? merged.welcomeConfig ?? merged.automodConfig?.welcomeConfig,
statuspageEnabled: merged.statuspageEnabled,
statuspageConfig: partial.statuspageConfig ?? merged.statuspageConfig ?? merged.automodConfig?.statuspageConfig
};
if (partial.dynamicVoiceConfig) {
merged.dynamicVoiceConfig = { ...(merged.dynamicVoiceConfig ?? {}), ...partial.dynamicVoiceConfig };
}
if (partial.supportLoginConfig) {
merged.supportLoginConfig = { ...(merged.supportLoginConfig ?? {}), ...partial.supportLoginConfig };
}
if (partial.birthdayConfig) {
merged.birthdayConfig = { ...(merged.birthdayConfig ?? {}), ...partial.birthdayConfig };
}
if (partial.reactionRolesConfig) {
merged.reactionRolesConfig = { ...(merged.reactionRolesConfig ?? {}), ...partial.reactionRolesConfig };
}
merged.automodConfig = { ...mergedAutomod, supportLoginConfig: merged.supportLoginConfig ?? mergedAutomod['supportLoginConfig'] };
merged.statuspageEnabled = mergedAutomod.statuspageEnabled;
merged.statuspageConfig = mergedAutomod.statuspageConfig;
merged.loggingConfig = mergedAutomod.loggingConfig;
merged.welcomeConfig = mergedAutomod.welcomeConfig;
this.cache.set(guildId, merged);
await prisma.guildSettings.upsert({
where: { guildId },
update: {
welcomeChannelId: merged.welcomeChannelId ?? null,
logChannelId: merged.logChannelId ?? null,
automodEnabled: merged.automodEnabled ?? null,
automodConfig: merged.automodConfig ?? null,
levelingEnabled: merged.levelingEnabled ?? null,
ticketsEnabled: merged.ticketsEnabled ?? null,
musicEnabled: merged.musicEnabled ?? null,
statuspageEnabled: merged.statuspageEnabled ?? null,
statuspageConfig: merged.statuspageConfig ?? null,
dynamicVoiceEnabled: merged.dynamicVoiceEnabled ?? null,
dynamicVoiceConfig: merged.dynamicVoiceConfig ?? null,
eventsEnabled: (merged as any).eventsEnabled ?? null,
birthdayEnabled: merged.birthdayEnabled ?? null,
birthdayConfig: merged.birthdayConfig ?? null,
reactionRolesEnabled: merged.reactionRolesEnabled ?? null,
reactionRolesConfig: merged.reactionRolesConfig ?? null,
supportRoleId: merged.supportRoleId ?? null
},
create: {
guildId,
welcomeChannelId: merged.welcomeChannelId ?? null,
logChannelId: merged.logChannelId ?? null,
automodEnabled: merged.automodEnabled ?? null,
automodConfig: merged.automodConfig ?? null,
levelingEnabled: merged.levelingEnabled ?? null,
ticketsEnabled: merged.ticketsEnabled ?? null,
musicEnabled: merged.musicEnabled ?? null,
statuspageEnabled: merged.statuspageEnabled ?? null,
statuspageConfig: merged.statuspageConfig ?? null,
dynamicVoiceEnabled: merged.dynamicVoiceEnabled ?? null,
dynamicVoiceConfig: merged.dynamicVoiceConfig ?? null,
eventsEnabled: (merged as any).eventsEnabled ?? null,
birthdayEnabled: merged.birthdayEnabled ?? null,
birthdayConfig: merged.birthdayConfig ?? null,
reactionRolesEnabled: merged.reactionRolesEnabled ?? null,
reactionRolesConfig: merged.reactionRolesConfig ?? null,
supportRoleId: merged.supportRoleId ?? null
}
});
return merged;
}
}
export const settingsStore = new SettingsStore();
// Backwards compatibility for existing imports (read-only)
export const settings = settingsStore.all();

View File

@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "Ticket" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"topic" TEXT,
"priority" TEXT NOT NULL DEFAULT 'normal',
"status" TEXT NOT NULL,
"claimedBy" TEXT,
"transcript" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Level" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"xp" INTEGER NOT NULL DEFAULT 0,
"level" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Level_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "GuildSettings" (
"guildId" TEXT NOT NULL,
"welcomeChannelId" TEXT,
"logChannelId" TEXT,
"automodEnabled" BOOLEAN,
"levelingEnabled" BOOLEAN,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GuildSettings_pkey" PRIMARY KEY ("guildId")
);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "ticketsEnabled" BOOLEAN;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "supportRoleId" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Ticket" ADD COLUMN "ticketNumber" SERIAL NOT NULL;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "musicEnabled" BOOLEAN;
ALTER TABLE "GuildSettings" ADD COLUMN "automodConfig" JSONB;

View File

@@ -0,0 +1,2 @@
-- CreateIndex
CREATE UNIQUE INDEX "Level_userId_guildId_key" ON "Level"("userId", "guildId");

View File

@@ -0,0 +1,2 @@
-- Placeholder migration to keep history consistent.
-- Welcome module fields are already present in the current schema.

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "dynamicVoiceEnabled" BOOLEAN;
ALTER TABLE "GuildSettings" ADD COLUMN "dynamicVoiceConfig" JSONB;

View File

@@ -0,0 +1,46 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "birthdayConfig" JSONB,
ADD COLUMN "birthdayEnabled" BOOLEAN,
ADD COLUMN "reactionRolesConfig" JSONB,
ADD COLUMN "reactionRolesEnabled" BOOLEAN,
ADD COLUMN "statuspageConfig" JSONB,
ADD COLUMN "statuspageEnabled" BOOLEAN;
-- CreateTable
CREATE TABLE "Birthday" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"birthDate" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Birthday_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReactionRoleSet" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"title" TEXT,
"description" TEXT,
"entries" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReactionRoleSet_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Birthday_guildId_idx" ON "Birthday"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "Birthday_userId_guildId_key" ON "Birthday"("userId", "guildId");
-- CreateIndex
CREATE INDEX "ReactionRoleSet_guildId_idx" ON "ReactionRoleSet"("guildId");
-- CreateIndex
CREATE INDEX "ReactionRoleSet_guildId_messageId_idx" ON "ReactionRoleSet"("guildId", "messageId");

View File

@@ -0,0 +1,66 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "eventsEnabled" BOOLEAN,
ADD COLUMN "supportLoginConfig" JSONB;
-- CreateTable
CREATE TABLE "TicketSupportSession" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endedAt" TIMESTAMP(3),
"durationSeconds" INTEGER,
CONSTRAINT "TicketSupportSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Event" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"channelId" TEXT NOT NULL,
"startTime" TIMESTAMP(3) NOT NULL,
"repeatType" TEXT NOT NULL DEFAULT 'none',
"repeatConfig" JSONB,
"reminderOffsetMinutes" INTEGER NOT NULL DEFAULT 60,
"roleId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastReminderAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventSignup" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"canceledAt" TIMESTAMP(3),
CONSTRAINT "EventSignup_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TicketSupportSession_guildId_userId_endedAt_idx" ON "TicketSupportSession"("guildId", "userId", "endedAt");
-- CreateIndex
CREATE INDEX "Event_guildId_idx" ON "Event"("guildId");
-- CreateIndex
CREATE INDEX "Event_guildId_startTime_idx" ON "Event"("guildId", "startTime");
-- CreateIndex
CREATE INDEX "EventSignup_guildId_eventId_idx" ON "EventSignup"("guildId", "eventId");
-- CreateIndex
CREATE UNIQUE INDEX "EventSignup_eventId_userId_key" ON "EventSignup"("eventId", "userId");
-- AddForeignKey
ALTER TABLE "EventSignup" ADD CONSTRAINT "EventSignup_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -7,8 +7,33 @@ datasource db {
url = env("DATABASE_URL")
}
model GuildSettings {
guildId String @id
welcomeChannelId String?
logChannelId String?
automodEnabled Boolean?
automodConfig Json?
levelingEnabled Boolean?
ticketsEnabled Boolean?
musicEnabled Boolean?
statuspageEnabled Boolean?
statuspageConfig Json?
dynamicVoiceEnabled Boolean?
dynamicVoiceConfig Json?
supportLoginConfig Json?
birthdayEnabled Boolean?
birthdayConfig Json?
reactionRolesEnabled Boolean?
reactionRolesConfig Json?
eventsEnabled Boolean?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}
model Ticket {
id String @id @default(cuid())
ticketNumber Int @default(autoincrement())
userId String
channelId String
guildId String
@@ -29,4 +54,81 @@ model Level {
level Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, guildId], name: "userId_guildId")
}
model TicketSupportSession {
id String @id @default(cuid())
guildId String
userId String
roleId String
startedAt DateTime @default(now())
endedAt DateTime?
durationSeconds Int?
@@index([guildId, userId, endedAt])
}
model Birthday {
id String @id @default(cuid())
userId String
guildId String
birthDate String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, guildId], name: "birthday_user_guild")
@@index([guildId])
}
model ReactionRoleSet {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
title String?
description String?
entries Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([guildId])
@@index([guildId, messageId])
}
model Event {
id String @id @default(cuid())
guildId String
title String
description String?
channelId String
startTime DateTime
repeatType String @default("none")
repeatConfig Json?
reminderOffsetMinutes Int @default(60)
roleId String?
isActive Boolean @default(true)
lastReminderAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
signups EventSignup[]
@@index([guildId])
@@index([guildId, startTime])
}
model EventSignup {
id String @id @default(cuid())
eventId String
guildId String
userId String
createdAt DateTime @default(now())
canceledAt DateTime?
event Event @relation(fields: [eventId], references: [id])
@@unique([eventId, userId])
@@index([guildId, eventId])
}

View File

@@ -0,0 +1,13 @@
import { GuildChannel } from 'discord.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
const event: EventHandler = {
name: 'channelCreate',
execute(channel: GuildChannel) {
if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel erstellt: ${channel.name} (${channel.id})`);
}
};
export default event;

View File

@@ -0,0 +1,13 @@
import { GuildChannel } from 'discord.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
const event: EventHandler = {
name: 'channelDelete',
execute(channel: GuildChannel) {
if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel gelöscht: ${channel.name} (${channel.id})`);
}
};
export default event;

View File

@@ -0,0 +1,19 @@
import { GuildChannel } from 'discord.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
const event: EventHandler = {
name: 'channelUpdate',
execute(oldChannel: GuildChannel, newChannel: GuildChannel) {
if (!newChannel.guild) return;
const nameChanged = oldChannel.name !== newChannel.name;
if (nameChanged) {
context.logging.logSystem(
newChannel.guild,
`Channel umbenannt: ${oldChannel.name} -> ${newChannel.name} (${newChannel.id})`
);
}
}
};
export default event;

View File

@@ -1,6 +1,6 @@
import { GuildBan } from 'discord.js';
import { EventHandler } from '../utils/types.js';
import { context } from '../config/context.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
const event: EventHandler = {
name: 'guildBanAdd',

View File

@@ -1,7 +1,7 @@
import { Guild } from 'discord.js';
import { EventHandler } from '../utils/types.js';
import { context } from '../config/context.js';
import { logger } from '../utils/logger.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
import { logger } from '../utils/logger';
const event: EventHandler = {
name: 'guildCreate',

View File

@@ -1,13 +1,46 @@
import { GuildMember } from 'discord.js';
import { EventHandler } from '../utils/types.js';
import { context } from '../config/context.js';
import { settings } from '../config/state.js';
import { GuildMember, EmbedBuilder } from 'discord.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
import { settingsStore } from '../config/state';
const event: EventHandler = {
name: 'guildMemberAdd',
execute(member: GuildMember) {
const guildConfig = settings.get(member.guild.id);
if (guildConfig?.welcomeChannelId) {
const guildConfig = settingsStore.get(member.guild.id);
const welcomeCfg = guildConfig?.welcomeConfig || guildConfig?.automodConfig?.welcomeConfig;
if (welcomeCfg?.enabled && welcomeCfg.channelId) {
const channel = member.guild.channels.cache.get(welcomeCfg.channelId);
if (channel && channel.isTextBased()) {
const files: any[] = [];
const colorVal = parseInt((welcomeCfg.embedColor || '00ff99').replace('#', ''), 16);
const embed = new EmbedBuilder()
.setTitle(welcomeCfg.embedTitle || 'Willkommen!')
.setDescription(welcomeCfg.embedDescription || `${member} ist beigetreten.`)
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal)
.setFooter({ text: welcomeCfg.embedFooter || '' });
if (welcomeCfg.embedThumbnailData && welcomeCfg.embedThumbnailData.startsWith('data:')) {
const [meta, b64] = welcomeCfg.embedThumbnailData.split(',');
const ext = meta.includes('gif') ? 'gif' : 'png';
const buf = Buffer.from(b64, 'base64');
const name = `welcome-thumb.${ext}`;
files.push({ attachment: buf, name });
embed.setThumbnail(`attachment://${name}`);
} else if (welcomeCfg.embedThumbnail) {
embed.setThumbnail(welcomeCfg.embedThumbnail);
}
if (welcomeCfg.embedImageData && welcomeCfg.embedImageData.startsWith('data:')) {
const [meta, b64] = welcomeCfg.embedImageData.split(',');
const ext = meta.includes('gif') ? 'gif' : 'png';
const buf = Buffer.from(b64, 'base64');
const name = `welcome-image.${ext}`;
files.push({ attachment: buf, name });
embed.setImage(`attachment://${name}`);
} else if (welcomeCfg.embedImage) {
embed.setImage(welcomeCfg.embedImage);
}
channel.send({ embeds: [embed], files }).catch(() => undefined);
}
} else if (guildConfig?.welcomeChannelId) {
const channel = member.guild.channels.cache.get(guildConfig.welcomeChannelId);
if (channel && channel.isTextBased()) {
channel.send({ content: `Willkommen ${member}!` }).catch(() => undefined);

View File

@@ -1,6 +1,6 @@
import { GuildMember } from 'discord.js';
import { EventHandler } from '../utils/types.js';
import { context } from '../config/context.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
const event: EventHandler = {
name: 'guildMemberRemove',

View File

@@ -0,0 +1,18 @@
import { GuildMember, PartialGuildMember } from 'discord.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
const event: EventHandler = {
name: 'guildMemberUpdate',
execute(oldMember: GuildMember | PartialGuildMember, newMember: GuildMember) {
if (!newMember.guild) return;
const before = new Set(oldMember.roles?.cache?.keys?.() || []);
const after = new Set(newMember.roles.cache.keys());
const added = Array.from(after).filter((id) => !before.has(id));
const removed = Array.from(before).filter((id) => !after.has(id));
if (!added.length && !removed.length) return;
context.logging.logRoleUpdate(newMember, added, removed);
}
};
export default event;

View File

@@ -1,6 +1,6 @@
import { Interaction } from 'discord.js';
import { EventHandler } from '../utils/types.js';
import { context } from '../config/context.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
const event: EventHandler = {
name: 'interactionCreate',
@@ -13,6 +13,13 @@ const event: EventHandler = {
}
if (interaction.isButton()) {
if (interaction.customId.startsWith('event:')) {
const [_, action, eventId] = interaction.customId.split(':');
if (action === 'signup' || action === 'signoff') {
await context.events.handleButton(interaction as any, action as any, eventId);
return;
}
}
await context.tickets.handleButton(interaction);
}
}

View File

@@ -1,12 +1,15 @@
import { Message } from 'discord.js';
import { EventHandler } from '../utils/types.js';
import { context } from '../config/context.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
import { settingsStore } from '../config/state';
const event: EventHandler = {
name: 'messageCreate',
async execute(message: Message) {
context.automod.checkMessage(message);
context.leveling.handleMessage(message);
const cfg = message.guildId ? settingsStore.get(message.guildId) : undefined;
if (message.guildId) context.admin.trackEvent('message', message.guildId);
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg.automodConfig);
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
}
};

View File

@@ -1,6 +1,6 @@
import { Message } from 'discord.js';
import { EventHandler } from '../utils/types.js';
import { context } from '../config/context.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
const event: EventHandler = {
name: 'messageDelete',

View File

@@ -0,0 +1,17 @@
import { EventHandler } from '../utils/types';
import { MessageReaction, User } from 'discord.js';
import { context } from '../config/context';
const event: EventHandler = {
name: 'messageReactionAdd',
async execute(reaction: MessageReaction, user: User) {
try {
if (reaction.partial) await reaction.fetch();
} catch {
return;
}
await context.reactionRoles.handleReaction(reaction, user, true);
}
};
export default event;

View File

@@ -0,0 +1,17 @@
import { EventHandler } from '../utils/types';
import { MessageReaction, User } from 'discord.js';
import { context } from '../config/context';
const event: EventHandler = {
name: 'messageReactionRemove',
async execute(reaction: MessageReaction, user: User) {
try {
if (reaction.partial) await reaction.fetch();
} catch {
return;
}
await context.reactionRoles.handleReaction(reaction, user, false);
}
};
export default event;

View File

@@ -1,6 +1,6 @@
import { Message } from 'discord.js';
import { EventHandler } from '../utils/types.js';
import { context } from '../config/context.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
const event: EventHandler = {
name: 'messageUpdate',

View File

@@ -1,15 +1,33 @@
import { Client } from 'discord.js';
import { EventHandler } from '../utils/types.js';
import { logger } from '../utils/logger.js';
import { env } from '../config/env.js';
import { EventHandler } from '../utils/types';
import { logger } from '../utils/logger';
import { env } from '../config/env';
import { context } from '../config/context';
import { settingsStore } from '../config/state';
const event: EventHandler = {
name: 'ready',
once: true,
execute(client: Client) {
logger.info(`Bot eingeloggt als ${client.user?.tag}`);
client.user?.setPresence({ activities: [{ name: 'Papo | /help', type: 0 }], status: 'online' });
logger.info(`Ready on ${client.guilds.cache.size} Guilds`);
async execute(client: Client) {
try {
logger.info(`Bot eingeloggt als ${client.user?.tag}`);
client.user?.setPresence({ activities: [{ name: 'Papo | /help', type: 0 }], status: 'online' });
logger.info(`Ready on ${client.guilds.cache.size} Guilds`);
// force guild command registration so new commands wie /birthday sofort erscheinen
if (context.commandHandler) {
for (const [gid] of client.guilds.cache) {
await context.commandHandler.registerGuildCommands(gid).catch((err) => logger.warn(`register commands failed for ${gid}: ${err}`));
}
}
context.birthdays.startScheduler();
context.birthdays.checkAndSend(true).catch(() => undefined);
await context.reactionRoles.loadCache().catch(() => undefined);
for (const gid of settingsStore.all().keys()) {
context.reactionRoles.resyncGuild(gid).catch((err) => logger.warn(`reaction roles sync failed for ${gid}: ${err}`));
}
} catch (err) {
logger.warn(`Ready handler failed: ${err}`);
}
}
};

View File

@@ -0,0 +1,12 @@
import { VoiceState } from 'discord.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
const event: EventHandler = {
name: 'voiceStateUpdate',
async execute(oldState: VoiceState, newState: VoiceState) {
await context.dynamicVoice.handleVoiceStateUpdate(oldState, newState);
}
};
export default event;

View File

@@ -1,25 +1,44 @@
import { Client, GatewayIntentBits, Partials } from 'discord.js';
import { env } from './config/env.js';
import { CommandHandler } from './services/commandHandler.js';
import { EventHandlerService } from './services/eventHandler.js';
import { logger } from './utils/logger.js';
import { context } from './config/context.js';
import { createWebServer } from './web/server.js';
import { env } from './config/env';
import { CommandHandler } from './services/commandHandler';
import { EventHandlerService } from './services/eventHandler';
import { logger } from './utils/logger';
import { context } from './config/context';
import { createWebServer } from './web/server';
import { settingsStore } from './config/state';
async function bootstrap() {
context.admin.setStartTime(Date.now());
await settingsStore.init();
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessageReactions
],
partials: [Partials.Channel, Partials.GuildMember, Partials.Message]
partials: [Partials.Channel, Partials.GuildMember, Partials.Message, Partials.Reaction, Partials.User]
});
const commandHandler = new CommandHandler(client);
const commandHandler = new CommandHandler(client, context.admin, context.statuspage);
context.commandHandler = commandHandler;
context.client = client;
context.admin.setClient(client);
context.statuspage.setClient(client);
context.tickets.setClient(client);
context.birthdays.setClient(client);
context.reactionRoles.setClient(client);
context.events.setClient(client);
context.events.startScheduler();
await context.reactionRoles.loadCache();
logger.setSink((entry) => context.admin.pushLog(entry));
for (const gid of settingsStore.all().keys()) {
const cfg = await context.statuspage.getConfig(gid);
await context.statuspage.saveConfig(gid, cfg);
}
await commandHandler.loadCommands();
await commandHandler.registerSlashCommands();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,23 +10,43 @@ import {
Guild,
GuildMember,
PermissionsBitField,
TextChannel
TextChannel,
Client
} from 'discord.js';
import { PrismaClient } from '@prisma/client';
import { prisma } from '../database';
import fs from 'fs';
import path from 'path';
import { TicketRecord } from '../utils/types.js';
import { logger } from '../utils/logger.js';
const prisma = new PrismaClient();
import { TicketRecord } from '../utils/types';
import { logger } from '../utils/logger';
import { settingsStore } from '../config/state';
import { env } from '../config/env';
export class TicketService {
private categoryName = 'Tickets';
private client: Client | null = null;
constructor(private transcriptRoot = './transcripts') {}
public setClient(client: Client) {
this.client = client;
}
private formatChannelName(customId: string, username: string, ticketTag: string) {
const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 20) || 'ticket';
return `${slug(customId)}-${slug(username)}-${ticketTag}`.toLowerCase();
}
private formatTicketTag(n: number) {
return `#${String(n).padStart(4, '0')}`;
}
public async createTicket(interaction: ChatInputCommandInteraction): Promise<TicketRecord | null> {
if (!interaction.guildId || !interaction.guild) return null;
// TODO: TICKETS: Modul-Check ueber BotModuleService kapseln (Dashboard-Toggle statt direktem Flag).
if (!this.isEnabled(interaction.guildId)) {
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
return null;
}
const existing = await prisma.ticket.findFirst({
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
});
@@ -38,14 +58,23 @@ export class TicketService {
}
public async handleButton(interaction: ButtonInteraction) {
// TODO: TICKETS: Button-Handling modularisieren und Kategorien aus Dashboard-Konfig laden.
if (!interaction.guild) return;
if (interaction.customId === 'support:toggle') {
await this.toggleSupport(interaction);
return;
}
if (interaction.customId.startsWith('ticket:create:')) {
if (!this.isEnabled(interaction.guild.id)) {
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
return;
}
const topic = interaction.customId.split(':')[2] || 'allgemein';
const existing = await prisma.ticket.findFirst({
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
});
if (existing) {
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schließe es zuerst.', ephemeral: true });
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schließe es zuerst.', ephemeral: true });
return;
}
@@ -58,23 +87,30 @@ export class TicketService {
return;
}
// TODO: TICKETS: Claim-Flow per Rollen/SLAs konfigurierbar machen und ins Dashboard syncen.
if (interaction.customId === 'ticket:claim') {
const ticket = await this.getTicketByChannel(interaction);
if (!ticket) {
await interaction.reply({ content: 'Kein Ticket gefunden.', ephemeral: true });
return;
}
if (ticket.userId === interaction.user.id) {
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
return;
}
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
return;
}
// TODO: TICKETS: Close-Flow modularisieren (Feedback, Transcript-Optionen) und Dashboard-Status sofort aktualisieren.
if (interaction.customId === 'ticket:close') {
const ok = await this.closeTicketButton(interaction);
if (!ok) await interaction.reply({ content: 'Ticket konnte nicht geschlossen werden.', ephemeral: true });
return;
}
// TODO: TICKETS: Transcript-Export in Storage/DB ablegen und Download-Link ins Dashboard liefern.
if (interaction.customId === 'ticket:transcript') {
const ticket = await this.getTicketByChannel(interaction);
if (!ticket) {
@@ -90,18 +126,25 @@ export class TicketService {
const channel = interaction.channel as TextChannel;
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
if (!ticket) return false;
if (ticket.userId === interaction.user.id) {
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
return true;
}
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
return true;
}
public async closeTicket(interaction: ChatInputCommandInteraction, reason?: string) {
// TODO: TICKETS: Dashboard-Action zum Schliessen mit optionalen Vorlagen/Gründen anbinden und Bot/DB synchronisieren.
const channel = interaction.channel as TextChannel;
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
if (!ticket) return false;
const transcriptPath = await this.exportTranscript(channel, ticket.id);
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
await channel.send({ content: `Ticket geschlossen. Grund: ${reason ?? 'Kein Grund angegeben'}` });
await channel.send({ content: `Ticket geschlossen. Grund: ${reason ?? 'Kein Grund angegeben'}` }).catch(() => undefined);
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
await channel.delete('Ticket geschlossen');
return true;
}
@@ -109,33 +152,53 @@ export class TicketService {
const embed = new EmbedBuilder()
.setTitle('Ticket Support')
.setDescription('Klicke auf eine Kategorie, um ein Ticket zu eröffnen.')
.setColor(0x5865f2)
.setColor(0xf97316)
.addFields(
{ name: 'Support', value: 'Allgemeine Fragen oder Hilfe' },
{ name: 'Report', value: 'Melde Regelverstöße' },
{ name: 'Team', value: 'Bewerbungen oder interne Themen' }
{ name: 'Ban Einspruch', value: 'Du bist gebannt worden und möchtest eine faire Klärung?' },
{ name: 'Discord-Hilfe', value: 'Dir fehlen Rollen oder du hast Probleme mit Nachrichten?' },
{ name: 'Feedback / Beschwerde', value: 'Du willst Lob, Kritik oder Vorschläge loswerden?' },
{ name: 'Allgemeine Frage', value: 'Alles andere, was nicht in die Kategorien passt.' }
);
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('ticket:create:support').setLabel('Support').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('ticket:create:report').setLabel('Report').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId('ticket:create:team').setLabel('Team').setStyle(ButtonStyle.Secondary)
new ButtonBuilder().setCustomId('ticket:create:ban').setLabel('Ban Einspruch').setEmoji('🛡').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('ticket:create:help').setLabel('Discord-Hilfe').setEmoji('🛠').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('ticket:create:feedback').setLabel('Feedback').setEmoji('💬').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('ticket:create:other').setLabel('Allgemeine Frage').setEmoji('❓').setStyle(ButtonStyle.Secondary)
);
return { embed, buttons };
}
private async ensureCategory(guild: Guild): Promise<CategoryChannelResolvable> {
let category = guild.channels.cache.find(
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase().includes(this.categoryName.toLowerCase())
);
if (!category) {
category = await guild.channels.create({ name: this.categoryName, type: ChannelType.GuildCategory });
}
return category as CategoryChannelResolvable;
public buildCustomPanel(config: {
title?: string;
description?: string;
categories: { label: string; emoji?: string; customId: string }[];
}) {
const embed = new EmbedBuilder()
.setTitle(config.title || 'Ticket Support')
.setDescription(
config.description ||
'Bitte wähle eine Kategorie, um ein Ticket zu erstellen. Sei respektvoll & sachlich. Missbrauch führt zu Konsequenzen.'
)
.setColor(0xf97316);
const buttons = new ActionRowBuilder<ButtonBuilder>();
// TODO: TICKETS: Panel-Definitionen versionieren und im Dashboard editierbar machen (Labels/Emojis/Permissions).
config.categories.slice(0, 5).forEach((cat) => {
const btn = new ButtonBuilder()
.setCustomId(`ticket:create:${cat.customId}`)
.setLabel(cat.label)
.setStyle(ButtonStyle.Secondary);
if (cat.emoji) btn.setEmoji(cat.emoji);
buttons.addComponents(btn);
});
return { embed, buttons };
}
public async exportTranscript(channel: TextChannel, ticketId: string) {
// TODO: TICKETS: Transcript-Speicherung abstrahieren (z.B. S3/DB) und Status ans Dashboard senden.
// Discord API limit: max 100 per fetch
const messages = await channel.messages.fetch({ limit: 100 });
const lines = messages
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
@@ -151,23 +214,51 @@ export class TicketService {
}
private async openTicket(guild: Guild, member: GuildMember, topic: string): Promise<TicketRecord | null> {
if (!this.isEnabled(guild.id)) return null;
const category = await this.ensureCategory(guild);
const supportRoleId = settingsStore.get(guild.id)?.supportRoleId || env.supportRoleId || null;
const overwrites: any[] = [
{
id: guild.id,
deny: [
PermissionsBitField.Flags.ViewChannel,
PermissionsBitField.Flags.SendMessages,
PermissionsBitField.Flags.AttachFiles,
PermissionsBitField.Flags.ReadMessageHistory
]
},
{
id: member.id,
allow: [
PermissionsBitField.Flags.ViewChannel,
PermissionsBitField.Flags.SendMessages,
PermissionsBitField.Flags.AttachFiles,
PermissionsBitField.Flags.ReadMessageHistory
]
}
];
if (supportRoleId) {
overwrites.push({
id: supportRoleId,
allow: [
PermissionsBitField.Flags.ViewChannel,
PermissionsBitField.Flags.SendMessages,
PermissionsBitField.Flags.AttachFiles,
PermissionsBitField.Flags.ReadMessageHistory
]
});
}
const nextNumber = (await prisma.ticket.count({ where: { guildId: guild.id } })) + 1;
const ticketTag = this.formatTicketTag(nextNumber);
const channelName = this.formatChannelName(topic, member.user.username, ticketTag);
const channel = await guild.channels.create({
name: `ticket-${member.user.username}`.toLowerCase(),
name: channelName,
type: ChannelType.GuildText,
parent: category.id,
permissionOverwrites: [
{
id: guild.id,
deny: [PermissionsBitField.Flags.ViewChannel]
},
{
id: member.id,
allow: [PermissionsBitField.Flags.ViewChannel, PermissionsBitField.Flags.SendMessages]
}
]
permissionOverwrites: overwrites
});
// TODO: TICKETS: Status/Metadaten beim Erstellen direkt ans Dashboard pushen (WebSocket) und SLA speichern.
const record = await prisma.ticket.create({
data: {
userId: member.id,
@@ -175,22 +266,31 @@ export class TicketService {
guildId: guild.id,
topic,
priority: 'normal',
status: 'open'
status: 'open',
ticketNumber: nextNumber
}
});
const embed = new EmbedBuilder()
.setTitle(`Ticket: ${topic}`)
.setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.')
.setDescription('Ein Teammitglied wird sich gleich kuemmern. Nutze `/claim`, um den Fall zu uebernehmen.')
.setColor(0x7289da);
const controls = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('ticket:claim').setLabel('Claim').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('ticket:close').setLabel('Schließen').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId('ticket:close').setLabel('Schliessen').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId('ticket:transcript').setLabel('Transcript').setStyle(ButtonStyle.Secondary)
);
await channel.send({ content: `${member}`, embeds: [embed], components: [controls] });
const supportMention = supportRoleId ? `<@&${supportRoleId}>` : null;
await channel.send({
content: supportMention ? `${member} ${supportMention}` : `${member}`,
embeds: [embed],
components: [controls],
allowedMentions: supportMention ? { roles: [supportRoleId as string], users: [member.id] } : { users: [member.id] }
});
await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId);
return record as TicketRecord;
}
@@ -201,13 +301,186 @@ export class TicketService {
}
private async closeTicketButton(interaction: ButtonInteraction) {
// TODO: TICKETS: Gemeinsamen Close-Pfad fuer Bot/Dashboard extrahieren, um Status-Divergenzen zu vermeiden.
const channel = interaction.channel as TextChannel | null;
if (!channel) return false;
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
if (!ticket) return false;
const transcriptPath = await this.exportTranscript(channel, ticket.id);
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
await channel.send({ content: `Ticket von ${interaction.user} geschlossen.` });
await interaction.reply({ content: 'Ticket geschlossen.', ephemeral: true }).catch(() => undefined);
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
await channel.delete('Ticket geschlossen');
return true;
}
// TODO: TICKETS: Log-Ziel und Storage ueber Dashboard konfigurierbar machen (z.B. Channel, Forum, Webhook).
private async sendTranscriptToLog(guild: Guild, transcriptPath: string, ticket: { id: string; channelId: string }) {
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
if (!logChannelId || categories.ticketActions === false) return;
const logChannel = await guild.channels.fetch(logChannelId).catch(() => null);
if (!logChannel || !logChannel.isTextBased()) return;
try {
await (logChannel as any).send({
content: `Transcript für Ticket ${ticket.id} (${ticket.channelId})`,
files: [transcriptPath]
});
} catch (err) {
logger.warn('Transcript konnte nicht im Log-Kanal gesendet werden', err);
}
}
private async sendTicketCreatedLog(
guild: Guild,
ticketChannel: TextChannel,
member: GuildMember,
topic: string,
ticketNumber: number,
supportRoleId: string | null
) {
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
if (!logChannelId || categories.ticketActions === false) return;
const logChannel = await guild.channels.fetch(logChannelId).catch(() => null);
if (!logChannel || !logChannel.isTextBased()) return;
const roleMention = supportRoleId ? `<@&${supportRoleId}>` : '';
const content = `${roleMention ? roleMention + ' ' : ''}Neues Ticket ${this.formatTicketTag(ticketNumber)} von ${member} | Thema: ${topic} | Kanal: ${ticketChannel}`;
try {
await (logChannel as any).send({
content,
allowedMentions: roleMention && supportRoleId ? { roles: [supportRoleId], users: [member.id] } : { users: [member.id] }
});
} catch (err) {
logger.warn('Ticket-Log konnte nicht gesendet werden', err);
}
}
private getLoggingConfig(guildId: string) {
const cfg = settingsStore.get(guildId) || {};
const logging = (cfg as any).loggingConfig || (cfg as any).automodConfig?.loggingConfig || {};
const categories = typeof logging.categories === 'object' && logging.categories ? logging.categories : {};
return {
logChannelId: logging.logChannelId || cfg.logChannelId,
categories
};
}
private async ensureCategory(guild: Guild): Promise<CategoryChannelResolvable> {
let category = guild.channels.cache.find(
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase().includes(this.categoryName.toLowerCase())
);
if (!category) {
category = await guild.channels.create({ name: this.categoryName, type: ChannelType.GuildCategory });
}
return category as CategoryChannelResolvable;
}
public async publishSupportPanel(guildId: string, cfg?: any) {
if (!this.client) return null;
const guild = this.client.guilds.cache.get(guildId) ?? (await this.client.guilds.fetch(guildId).catch(() => null));
if (!guild) return null;
const settings = settingsStore.get(guildId) || {};
const config = { ...(settings.supportLoginConfig ?? {}), ...(cfg ?? {}) };
if (!config.panelChannelId) return null;
const channel = await guild.channels.fetch(config.panelChannelId).catch(() => null);
if (!channel || !channel.isTextBased()) return null;
const embed = new EmbedBuilder()
.setTitle(config.title || 'Support Login')
.setDescription(config.description || 'Melde dich an/ab, um die Support-Rolle zu erhalten.')
.setColor(0xf97316);
const btn = new ButtonBuilder()
.setCustomId('support:toggle')
.setLabel(config.loginLabel || 'Ich bin jetzt im Support')
.setStyle(ButtonStyle.Success);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(btn);
let messageId = config.panelMessageId;
try {
if (messageId) {
const msg = await (channel as TextChannel).messages.fetch(messageId);
await msg.edit({ embeds: [embed], components: [row] });
} else {
const sent = await (channel as TextChannel).send({ embeds: [embed], components: [row] });
messageId = sent.id;
}
} catch {
const sent = await (channel as TextChannel).send({ embeds: [embed], components: [row] });
messageId = sent.id;
}
await settingsStore.set(guildId, { supportLoginConfig: { ...config, panelMessageId: messageId } } as any);
return messageId;
}
public async getSupportStatus(guildId: string) {
const repo: any = (prisma as any).ticketSupportSession;
if (!repo?.findMany) return { active: [], recent: [] };
const active = await repo.findMany({ where: { guildId, endedAt: null }, orderBy: { startedAt: 'desc' }, take: 25 });
const recent = await repo.findMany({
where: { guildId, endedAt: { not: null } },
orderBy: { endedAt: 'desc' },
take: 25
});
return { active, recent };
}
private async toggleSupport(interaction: ButtonInteraction) {
const guildId = interaction.guildId;
if (!guildId || !interaction.guild) return;
const cfg = settingsStore.get(guildId) || {};
const roleId = cfg.supportRoleId;
if (!roleId) {
await interaction.reply({ content: 'Keine Support-Rolle konfiguriert.', ephemeral: true });
return;
}
const member = interaction.member as GuildMember;
const hasRole = member.roles.cache.has(roleId);
if (!hasRole) {
try {
await member.roles.add(roleId, 'Support Login');
} catch {
await interaction.reply({ content: 'Rolle konnte nicht vergeben werden.', ephemeral: true });
return;
}
await this.closeOpenSession(guildId, member.id, roleId);
const repo: any = (prisma as any).ticketSupportSession;
if (repo?.create) await repo.create({ data: { guildId, userId: member.id, roleId } });
await interaction.reply({ content: 'Du bist jetzt im Support.', ephemeral: true });
await this.logSupportStatus(interaction.guild, `${member.user.tag} ist jetzt im Support.`);
} else {
try {
await member.roles.remove(roleId, 'Support Logout');
} catch {
/* ignore */
}
const duration = await this.closeOpenSession(guildId, member.id, roleId);
await interaction.reply({ content: `Support beendet. Dauer: ${duration ? Math.round(duration / 60) + 'm' : '-'}.`, ephemeral: true });
await this.logSupportStatus(interaction.guild, `${member.user.tag} hat den Support verlassen.`);
}
}
private async closeOpenSession(guildId: string, userId: string, roleId: string) {
const repo: any = (prisma as any).ticketSupportSession;
if (!repo?.findFirst) return null;
const open = await repo.findFirst({
where: { guildId, userId, roleId, endedAt: null },
orderBy: { startedAt: 'desc' }
});
if (!open) return null;
const endedAt = new Date();
const durationSeconds = Math.max(0, Math.round((endedAt.getTime() - open.startedAt.getTime()) / 1000));
await repo.update({ where: { id: open.id }, data: { endedAt, durationSeconds } });
return durationSeconds;
}
private async logSupportStatus(guild: Guild, message: string) {
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
if (!logChannelId || categories.ticketActions === false) return;
const logChannel = await guild.channels.fetch(logChannelId).catch(() => null);
if (!logChannel || !logChannel.isTextBased()) return;
await (logChannel as any).send({ content: `[Support] ${message}` }).catch(() => undefined);
}
private isEnabled(guildId: string) {
// TODO: MODULE: Modul-Status ueber BotModuleService/SettingsStore vereinheitlichen.
const cfg = settingsStore.get(guildId);
return cfg?.ticketsEnabled === true;
}
}

13
src/types/express-session.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
import 'express-session';
declare module 'express-session' {
interface SessionData {
user?: any;
guilds?: any[];
token?: {
access_token: string;
refresh_token?: string;
expires_at?: number;
};
}
}

View File

@@ -1,5 +1,25 @@
type LogLevel = 'INFO' | 'WARN' | 'ERROR';
type LogSink = (entry: { level: LogLevel; message: string; timestamp: number }) => void;
let sink: LogSink | null = null;
export const logger = {
info: (msg: string) => console.log(`[INFO] ${msg}`),
warn: (msg: string) => console.warn(`[WARN] ${msg}`),
error: (msg: string, err?: unknown) => console.error(`[ERROR] ${msg}`, err)
info: (msg: string) => {
const entry = { level: 'INFO' as LogLevel, message: msg, timestamp: Date.now() };
if (sink) sink(entry);
console.log(`[INFO] ${msg}`);
},
warn: (msg: string) => {
const entry = { level: 'WARN' as LogLevel, message: msg, timestamp: Date.now() };
if (sink) sink(entry);
console.warn(`[WARN] ${msg}`);
},
error: (msg: string, err?: unknown) => {
const entry = { level: 'ERROR' as LogLevel, message: msg, timestamp: Date.now() };
if (sink) sink(entry);
console.error(`[ERROR] ${msg}`, err);
},
setSink: (fn: LogSink | null) => {
sink = fn;
}
};

View File

@@ -1,47 +1,630 @@
import { Router } from 'express';
import { prisma } from '../../database/index.js';
import { settings } from '../../config/state.js';
import { NextFunction, Request, Response, Router } from 'express';
import { prisma } from '../../database/index';
import { settingsStore } from '../../config/state';
import { context } from '../../config/context';
import { LoggingService } from '../../services/loggingService';
import { env } from '../../config/env';
const router = Router();
const moduleService = context.modules;
router.get('/overview', async (_req, res) => {
function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!req.session?.user) {
return res.status(401).json({ error: 'unauthorized', login: '/auth/discord' });
}
next();
}
function requireAdmin(req: Request, res: Response, next: NextFunction) {
const userId = req.session?.user?.id;
const allowed = Array.isArray(env.ownerIds) ? env.ownerIds.filter(Boolean) : [];
if (!userId || !allowed.includes(userId)) {
return res.status(403).json({ error: 'forbidden' });
}
next();
}
router.get('/me', requireAuth, (req, res) => {
const allowed = Array.isArray(env.ownerIds) ? env.ownerIds.filter(Boolean) : [];
const isAdmin = !!req.session.user && allowed.includes(req.session.user.id);
res.json({ user: { ...req.session.user, isAdmin } });
});
router.get('/guilds', requireAuth, (_req, res) => {
const guilds =
context.client?.guilds.cache.map((g) => ({
id: g.id,
name: g.name,
icon: g.icon
})) ?? [];
res.json({ guilds });
});
router.get('/guild/info', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const guild =
context.client?.guilds.cache.get(guildId) || (await context.client?.guilds.fetch(guildId).catch(() => null));
if (!guild) return res.status(404).json({ error: 'guild not found' });
const owner = await guild.fetchOwner().catch(() => null);
const channels = guild.channels.cache;
const textCount = channels.filter((c) => c.isTextBased()).size;
const voiceCount = channels.filter((c) => c.isVoiceBased()).size;
const modules = settingsStore.get(guildId) || {};
res.json({
guild: {
id: guild.id,
name: guild.name,
icon: guild.icon,
memberCount: guild.memberCount,
createdAt: guild.createdAt?.getTime?.() || null,
owner: owner ? { id: owner.id, tag: owner.user?.tag } : null,
textCount,
voiceCount,
modules: {
ticketsEnabled: modules.ticketsEnabled !== false,
automodEnabled: modules.automodEnabled !== false,
musicEnabled: modules.musicEnabled !== false,
welcomeEnabled: modules.welcomeConfig?.enabled !== false,
dynamicVoiceEnabled: modules.dynamicVoiceEnabled !== false,
statuspageEnabled: (modules as any).statuspageEnabled !== false,
birthdayEnabled: (modules as any).birthdayEnabled !== false,
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false
}
}
});
});
router.get('/guild/activity', requireAuth, (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const activity = context.admin.getGuildActivity(guildId);
res.json({ activity });
});
router.get('/guild/logs', requireAuth, (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const logs = context.admin.getGuildLogs(guildId).slice(0, 100);
res.json({ logs });
});
router.get('/overview', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
try {
const [open, inProgress, closed] = await Promise.all([
prisma.ticket.count({ where: { status: 'open' } }),
prisma.ticket.count({ where: { status: 'in-progress' } }),
prisma.ticket.count({ where: { status: 'closed' } })
prisma.ticket.count({ where: { status: 'open', ...(guildId ? { guildId } : {}) } }),
prisma.ticket.count({ where: { status: 'in-progress', ...(guildId ? { guildId } : {}) } }),
prisma.ticket.count({ where: { status: 'closed', ...(guildId ? { guildId } : {}) } })
]);
res.json({ tickets: { open, inProgress, closed } });
// TODO: MODULE: Musik-Status mit Modul-Flag und pro Guild Sessions anreichern (aktiv/inaktiv, aktueller Track).
const music = context.music.getStatus();
res.json({ tickets: { open, inProgress, closed }, music });
} catch (err) {
res.json({ tickets: { open: 0, inProgress: 0, closed: 0 }, error: 'DB unavailable' });
}
});
router.get('/tickets', async (_req, res) => {
router.get('/admin/overview', requireAuth, requireAdmin, (_req, res) => {
const overview = context.admin.getOverview();
res.json({ overview });
});
router.get('/admin/activity', requireAuth, requireAdmin, (_req, res) => {
const activity = context.admin.getActivity();
res.json(activity);
});
router.get('/admin/logs', requireAuth, requireAdmin, (_req, res) => {
res.json({ logs: context.admin.getLogs() });
});
router.get('/tickets', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
const take = Math.min(Number(req.query.take ?? 20) || 20, 100);
const where: Record<string, any> = {};
if (guildId) where.guildId = guildId;
if (status) where.status = status;
try {
const tickets = await prisma.ticket.findMany({ orderBy: { createdAt: 'desc' }, take: 20 });
// TODO: TICKETS: Filter/Suche/Sortierung per Query-Params erweitern (Status, Claim, Priorität, Zeitfenster) für Dashboard.
const tickets = await prisma.ticket.findMany({ where, orderBy: { createdAt: 'desc' }, take });
res.json({ tickets });
} catch (err) {
res.json({ tickets: [], error: 'DB unavailable' });
}
});
router.get('/settings', (_req, res) => {
res.json({ guilds: Array.from(settings.entries()) });
router.get('/tickets/:id/transcript', requireAuth, async (req, res) => {
const id = req.params.id;
try {
const ticket = await prisma.ticket.findFirst({ where: { id } });
if (!ticket || !ticket.transcript) return res.status(404).send('Transcript not found');
return res.sendFile(ticket.transcript);
} catch {
return res.status(500).send('Transcript error');
}
});
router.post('/settings', (req, res) => {
const { guildId, welcomeChannelId, logChannelId, automodEnabled, levelingEnabled } = req.body;
router.get('/tickets/:id/messages', requireAuth, async (req, res) => {
const id = req.params.id;
try {
const ticket = await prisma.ticket.findFirst({ where: { id } });
if (!ticket) return res.status(404).json({ error: 'not found' });
const channel = ticket.channelId ? await context.client?.channels.fetch(ticket.channelId) : null;
if (!channel || !channel.isTextBased()) return res.status(404).json({ error: 'channel missing' });
// TODO: TICKETS: Live-Messages per WebSocket/Server-Sent-Events streamen statt polling, inkl. Author-Rich-Info.
const msgs = await (channel as any).messages.fetch({ limit: 50 });
const data = msgs
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
.map((m: any) => ({
id: m.id,
author: { tag: m.author?.tag ?? 'Unknown', avatar: m.author?.displayAvatarURL?.() ?? null },
content: m.content,
createdAt: m.createdTimestamp
}));
res.json({ messages: data });
} catch {
res.status(500).json({ error: 'message fetch failed' });
}
});
router.post('/tickets/:id/close', requireAuth, async (req, res) => {
const id = req.params.id;
try {
const ticket = await prisma.ticket.findFirst({ where: { id } });
if (!ticket) return res.status(404).json({ error: 'not found' });
if (!context.client) return res.status(500).json({ error: 'client unavailable' });
const channel = await context.client.channels.fetch(ticket.channelId).catch(() => null);
if (channel && channel.isTextBased()) {
const transcriptPath = await context.tickets.exportTranscript(channel as any, ticket.id);
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
await context.tickets['sendTranscriptToLog'](channel.guild, transcriptPath, ticket as any).catch(() => undefined);
await (channel as any).delete('Ticket geschlossen');
} else {
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed' } });
}
res.json({ ok: true });
} catch {
res.status(500).json({ error: 'close failed' });
}
});
router.post('/tickets/panel', requireAuth, async (req, res) => {
const { guildId, channelId, title, description, categories } = req.body as {
guildId?: string;
channelId?: string;
title?: string;
description?: string;
categories?: { label: string; emoji?: string; customId: string }[];
};
if (!guildId || !channelId) return res.status(400).json({ error: 'guildId and channelId required' });
try {
// TODO: TICKETS: Panel-Template als Dashboard-Entwurf speichern (DB) statt ad-hoc zu senden; Mehrsprachigkeit berücksichtigen.
const cfg = settingsStore.get(guildId);
if (cfg?.ticketsEnabled === false) return res.status(403).json({ error: 'tickets disabled' });
if (!context.client) return res.status(503).json({ error: 'bot client unavailable' });
const guild = await context.client.guilds.fetch(guildId);
const channel = await guild.channels.fetch(channelId);
if (!channel || !channel.isTextBased()) return res.status(400).json({ error: 'channel not text' });
const cats = (categories ?? []).filter((c) => c?.label && c?.customId).slice(0, 5);
const panel =
cats.length > 0
? context.tickets.buildCustomPanel({ title, description, categories: cats })
: context.tickets.buildPanelEmbed();
const { embed, buttons } = panel;
await (channel as any).send({ embeds: [embed], components: [buttons] });
return res.json({ ok: true });
} catch (err: any) {
const code = err?.code || err?.status || err?.name;
const message = err?.message || 'failed to send panel';
if (code === 50001 || code === 'Missing Access') return res.status(403).json({ error: 'missing access' });
if (code === 50013 || /Missing Permissions/i.test(message)) return res.status(403).json({ error: 'missing permissions', detail: message });
if (code === 10003 || code === 'Unknown Channel') return res.status(404).json({ error: 'channel not found' });
console.error('tickets/panel error', code, message);
return res.status(500).json({ error: 'failed to send panel', detail: message });
}
});
router.get('/tickets/support-login', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const current = settings.get(guildId) ?? {};
settings.set(guildId, {
...current,
welcomeChannelId: welcomeChannelId ?? current.welcomeChannelId,
logChannelId: logChannelId ?? current.logChannelId,
automodEnabled: automodEnabled ?? current.automodEnabled,
levelingEnabled: levelingEnabled ?? current.levelingEnabled
});
const cfg = settingsStore.get(guildId) || {};
const config = {
panelChannelId: cfg.supportLoginConfig?.panelChannelId || '',
panelMessageId: cfg.supportLoginConfig?.panelMessageId || '',
title: cfg.supportLoginConfig?.title || 'Support Login',
description: cfg.supportLoginConfig?.description || 'Melde dich als Support an/ab.',
loginLabel: cfg.supportLoginConfig?.loginLabel || 'Ich bin jetzt im Support',
logoutLabel: cfg.supportLoginConfig?.logoutLabel || 'Ich bin nicht mehr im Support',
autoRefresh: cfg.supportLoginConfig?.autoRefresh !== false
};
const status = await context.tickets.getSupportStatus(guildId);
res.json({ config, status, supportRoleId: cfg.supportRoleId || null });
});
router.post('/tickets/support-login', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const config = {
panelChannelId: typeof req.body.panelChannelId === 'string' ? req.body.panelChannelId : undefined,
title: typeof req.body.title === 'string' ? req.body.title : undefined,
description: typeof req.body.description === 'string' ? req.body.description : undefined,
loginLabel: typeof req.body.loginLabel === 'string' ? req.body.loginLabel : undefined,
logoutLabel: typeof req.body.logoutLabel === 'string' ? req.body.logoutLabel : undefined,
autoRefresh: req.body.autoRefresh !== undefined ? !!req.body.autoRefresh : undefined
};
await settingsStore.set(guildId, { supportLoginConfig: config } as any);
const msgId = await context.tickets.publishSupportPanel(guildId, config).catch(() => null);
if (msgId) await settingsStore.set(guildId, { supportLoginConfig: { ...config, panelMessageId: msgId } } as any);
res.json({ ok: true, config: { ...config, panelMessageId: msgId || config['panelMessageId'] } });
});
router.get('/events', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const events = await prisma.event.findMany({
where: { guildId },
orderBy: { startTime: 'asc' },
include: { _count: { select: { signups: { where: { canceledAt: null } } as any } as any } as any } as any
} as any);
res.json({ events });
});
router.post('/events', requireAuth, async (req, res) => {
const {
id,
guildId,
title,
description,
channelId,
startTime,
repeatType,
repeatConfig,
reminderOffsetMinutes,
roleId,
isActive
} = req.body || {};
if (!guildId) return res.status(400).json({ error: 'guildId required' });
try {
const saved = await context.events.saveEvent({
id,
guildId,
title,
description,
channelId,
startTime,
repeatType,
repeatConfig,
reminderOffsetMinutes,
roleId,
isActive
});
res.json({ ok: true, event: saved });
} catch {
res.status(500).json({ error: 'save failed' });
}
});
router.delete('/events/:id', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
const id = req.params.id;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
await context.events.deleteEvent(guildId, id);
res.json({ ok: true });
});
router.get('/settings', requireAuth, (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (guildId) {
return res.json({ guildId, settings: settingsStore.get(guildId) ?? {} });
}
res.json({ guilds: Array.from(settingsStore.all().entries()) });
});
router.get('/modules', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const modules = await moduleService.getModulesForGuild(guildId);
res.json({ modules });
});
router.get('/birthday', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const cfg = settingsStore.get(guildId) || {};
const config = {
enabled: cfg.birthdayEnabled ?? cfg.birthdayConfig?.enabled ?? true,
channelId: cfg.birthdayConfig?.channelId ?? '',
sendHour: cfg.birthdayConfig?.sendHour ?? 9,
messageTemplate: cfg.birthdayConfig?.messageTemplate ?? 'Alles Gute zum Geburtstag, {user}!'
};
const birthdays = await prisma.birthday.findMany({ where: { guildId }, orderBy: { birthDate: 'asc' }, take: 200 });
res.json({ config, birthdays });
});
router.post('/birthday', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const enabledRaw = req.body.enabled;
const channelId = typeof req.body.channelId === 'string' ? req.body.channelId.trim() : undefined;
const template = typeof req.body.messageTemplate === 'string' ? req.body.messageTemplate.slice(0, 500) : undefined;
const hour = Number(req.body.sendHour);
const sendHour = Number.isFinite(hour) ? Math.min(23, Math.max(0, Math.round(hour))) : undefined;
const enabled = typeof enabledRaw === 'string' ? enabledRaw === 'true' : enabledRaw;
const config = {
enabled,
channelId: channelId || undefined,
sendHour,
messageTemplate: template
};
await settingsStore.set(guildId, { birthdayEnabled: enabled, birthdayConfig: config } as any);
context.birthdays.invalidate(guildId);
res.json({ ok: true, config });
});
router.get('/reactionroles', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const sets = await context.reactionRoles.listSets(guildId);
res.json({ sets });
});
router.post('/reactionroles', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
if (!req.body.channelId) return res.status(400).json({ error: 'channelId required' });
const entries = Array.isArray(req.body.entries)
? req.body.entries
.map((e: any) => ({
emoji: typeof e.emoji === 'string' ? e.emoji.trim() : '',
roleId: typeof e.roleId === 'string' ? e.roleId.trim() : '',
label: typeof e.label === 'string' ? e.label.trim() : undefined,
description: typeof e.description === 'string' ? e.description.trim() : undefined
}))
.filter((e: any) => e.emoji && e.roleId)
: [];
if (!entries.length) return res.status(400).json({ error: 'entries required' });
try {
const set = await context.reactionRoles.saveSet({
guildId,
channelId: typeof req.body.channelId === 'string' ? req.body.channelId : '',
messageId: typeof req.body.messageId === 'string' ? req.body.messageId : undefined,
title: typeof req.body.title === 'string' ? req.body.title : undefined,
description: typeof req.body.description === 'string' ? req.body.description : undefined,
entries
});
res.json({ set });
} catch (err) {
res.status(400).json({ error: 'save failed' });
}
});
router.put('/reactionroles/:id', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const id = req.params.id;
if (!req.body.channelId) return res.status(400).json({ error: 'channelId required' });
const entries = Array.isArray(req.body.entries)
? req.body.entries
.map((e: any) => ({
emoji: typeof e.emoji === 'string' ? e.emoji.trim() : '',
roleId: typeof e.roleId === 'string' ? e.roleId.trim() : '',
label: typeof e.label === 'string' ? e.label.trim() : undefined,
description: typeof e.description === 'string' ? e.description.trim() : undefined
}))
.filter((e: any) => e.emoji && e.roleId)
: [];
if (!entries.length) return res.status(400).json({ error: 'entries required' });
try {
const set = await context.reactionRoles.saveSet({
id,
guildId,
channelId: typeof req.body.channelId === 'string' ? req.body.channelId : '',
messageId: typeof req.body.messageId === 'string' ? req.body.messageId : undefined,
title: typeof req.body.title === 'string' ? req.body.title : undefined,
description: typeof req.body.description === 'string' ? req.body.description : undefined,
entries
});
res.json({ set });
} catch (err) {
res.status(400).json({ error: 'save failed' });
}
});
router.delete('/reactionroles/:id', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const id = req.params.id;
try {
await context.reactionRoles.deleteSet(guildId, id);
res.json({ ok: true });
} catch {
res.status(400).json({ error: 'delete failed' });
}
});
router.get('/statuspage', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const cfg = await context.statuspage.getConfig(guildId);
res.json({ config: cfg });
});
router.post('/statuspage', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
await context.statuspage.saveConfig(guildId, req.body.config || {});
res.json({ ok: true });
});
router.post('/statuspage/service', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const svc = await context.statuspage.addService(guildId, req.body.service || {});
res.json({ service: svc });
});
router.put('/statuspage/service/:id', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
await context.statuspage.updateService(guildId, req.params.id, req.body.service || {});
res.json({ ok: true });
});
router.delete('/statuspage/service/:id', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
await context.statuspage.deleteService(guildId, req.params.id);
res.json({ ok: true });
});
router.post('/settings', requireAuth, async (req, res) => {
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
const {
guildId,
welcomeChannelId,
logChannelId,
automodEnabled,
automodConfig,
loggingConfig,
welcomeConfig,
welcomeEnabled,
levelingEnabled,
ticketsEnabled,
musicEnabled,
dynamicVoiceEnabled,
dynamicVoiceConfig,
supportRoleId,
statuspageEnabled,
statuspageConfig,
eventsEnabled,
birthdayEnabled,
birthdayConfig,
reactionRolesEnabled,
reactionRolesConfig
} = req.body;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const normalizeArray = (val: any) =>
Array.isArray(val)
? val.map((s) => String(s).trim()).filter(Boolean)
: typeof val === 'string'
? val
.split(/[,\\n]/)
.map((s: string) => s.trim())
.filter(Boolean)
: [];
const existingLogging = (current as any).loggingConfig || (current as any).automodConfig?.loggingConfig || {};
const parsedLogging = {
logChannelId: loggingConfig?.logChannelId ?? logChannelId ?? existingLogging.logChannelId ?? undefined,
categories: {
joinLeave: loggingConfig?.categories?.joinLeave ?? loggingConfig?.joinLeave ?? existingLogging.categories?.joinLeave ?? true,
messageEdit: loggingConfig?.categories?.messageEdit ?? loggingConfig?.messageEdit ?? existingLogging.categories?.messageEdit ?? true,
messageDelete: loggingConfig?.categories?.messageDelete ?? loggingConfig?.messageDelete ?? existingLogging.categories?.messageDelete ?? true,
automodActions: loggingConfig?.categories?.automodActions ?? loggingConfig?.automodActions ?? existingLogging.categories?.automodActions ?? true,
ticketActions: loggingConfig?.categories?.ticketActions ?? loggingConfig?.ticketActions ?? existingLogging.categories?.ticketActions ?? true,
musicEvents: loggingConfig?.categories?.musicEvents ?? loggingConfig?.musicEvents ?? existingLogging.categories?.musicEvents ?? true,
system: loggingConfig?.categories?.system ?? existingLogging.categories?.system ?? true
}
};
let parsedAutomod =
typeof automodConfig === 'object'
? { ...(current as any).automodConfig, ...(automodConfig ?? {}) }
: automodConfig === undefined
? { ...(current as any).automodConfig }
: {
...(current as any).automodConfig,
spamThreshold: automodConfig?.spamThreshold ? Number(automodConfig.spamThreshold) : undefined,
windowMs: automodConfig?.windowMs ? Number(automodConfig.windowMs) : undefined,
linkWhitelist:
typeof automodConfig?.linkWhitelist === 'string'
? automodConfig.linkWhitelist
.split(',')
.map((s: string) => s.trim())
.filter(Boolean)
: undefined,
spamTimeoutMinutes: automodConfig?.spamTimeoutMinutes ? Number(automodConfig.spamTimeoutMinutes) : undefined,
deleteLinks: automodConfig?.deleteLinks !== undefined ? automodConfig.deleteLinks === 'true' || automodConfig.deleteLinks === true : undefined
};
parsedAutomod.customBadwords = normalizeArray(automodConfig?.customBadwords);
parsedAutomod.whitelistRoles = normalizeArray(automodConfig?.whitelistRoles);
parsedAutomod.logChannelId = automodConfig?.logChannelId ?? logChannelId ?? parsedLogging.logChannelId;
parsedAutomod.loggingConfig = parsedLogging;
parsedAutomod.statuspageEnabled =
typeof statuspageEnabled === 'string' ? statuspageEnabled === 'true' : statuspageEnabled ?? (current as any).statuspageEnabled;
parsedAutomod.statuspageConfig = statuspageConfig ?? (current as any).statuspageConfig;
const normalizedWelcomeEnabled = typeof welcomeEnabled === 'string' ? welcomeEnabled === 'true' : welcomeEnabled;
const parsedWelcome = {
enabled: welcomeConfig?.enabled ?? normalizedWelcomeEnabled ?? (current as any).welcomeConfig?.enabled ?? false,
channelId: welcomeConfig?.channelId ?? (current as any).welcomeConfig?.channelId ?? (current as any).welcomeChannelId ?? undefined,
embedTitle: welcomeConfig?.embedTitle ?? (current as any).welcomeConfig?.embedTitle,
embedDescription: welcomeConfig?.embedDescription ?? (current as any).welcomeConfig?.embedDescription,
embedColor: welcomeConfig?.embedColor ?? (current as any).welcomeConfig?.embedColor,
embedFooter: welcomeConfig?.embedFooter ?? (current as any).welcomeConfig?.embedFooter,
embedThumbnail: welcomeConfig?.embedThumbnail ?? (current as any).welcomeConfig?.embedThumbnail,
embedImage: welcomeConfig?.embedImage ?? (current as any).welcomeConfig?.embedImage,
embedThumbnailData: welcomeConfig?.embedThumbnailData ?? (current as any).welcomeConfig?.embedThumbnailData,
embedImageData: welcomeConfig?.embedImageData ?? (current as any).welcomeConfig?.embedImageData
};
parsedAutomod.welcomeConfig = parsedWelcome;
const parsedDynamicVoice = {
lobbyChannelId: dynamicVoiceConfig?.lobbyChannelId ?? (current as any).dynamicVoiceConfig?.lobbyChannelId,
categoryId: dynamicVoiceConfig?.categoryId ?? (current as any).dynamicVoiceConfig?.categoryId,
template: dynamicVoiceConfig?.template ?? (current as any).dynamicVoiceConfig?.template,
userLimit: dynamicVoiceConfig?.userLimit ?? (current as any).dynamicVoiceConfig?.userLimit,
bitrate: dynamicVoiceConfig?.bitrate ?? (current as any).dynamicVoiceConfig?.bitrate
};
const existingBirthday = (current as any).birthdayConfig || {};
const sendHourVal = birthdayConfig?.sendHour ?? existingBirthday.sendHour;
const parsedBirthday = {
enabled:
birthdayConfig?.enabled ??
(typeof birthdayEnabled === 'string' ? birthdayEnabled === 'true' : birthdayEnabled ?? (current as any).birthdayEnabled),
channelId: birthdayConfig?.channelId ?? existingBirthday.channelId,
sendHour: Number.isFinite(Number(sendHourVal)) ? Math.min(23, Math.max(0, Number(sendHourVal))) : existingBirthday.sendHour,
messageTemplate: birthdayConfig?.messageTemplate ?? existingBirthday.messageTemplate
};
const existingReactionRoles = (current as any).reactionRolesConfig || {};
const parsedReactionRoles = {
enabled:
reactionRolesConfig?.enabled ??
(typeof reactionRolesEnabled === 'string'
? reactionRolesEnabled === 'true'
: reactionRolesEnabled ?? (current as any).reactionRolesEnabled),
channelId: reactionRolesConfig?.channelId ?? existingReactionRoles.channelId
};
const updated = await settingsStore.set(guildId, {
welcomeChannelId: welcomeChannelId ?? undefined,
logChannelId: logChannelId ?? undefined,
automodEnabled: typeof automodEnabled === 'string' ? automodEnabled === 'true' : automodEnabled,
automodConfig: parsedAutomod,
welcomeConfig: parsedWelcome,
loggingConfig: parsedLogging,
levelingEnabled: typeof levelingEnabled === 'string' ? levelingEnabled === 'true' : levelingEnabled,
ticketsEnabled: typeof ticketsEnabled === 'string' ? ticketsEnabled === 'true' : ticketsEnabled,
musicEnabled: typeof musicEnabled === 'string' ? musicEnabled === 'true' : musicEnabled,
dynamicVoiceEnabled: typeof dynamicVoiceEnabled === 'string' ? dynamicVoiceEnabled === 'true' : dynamicVoiceEnabled,
dynamicVoiceConfig: parsedDynamicVoice,
supportRoleId: supportRoleId ?? undefined,
statuspageEnabled: typeof statuspageEnabled === 'string' ? statuspageEnabled === 'true' : statuspageEnabled,
statuspageConfig: parsedAutomod.statuspageConfig,
eventsEnabled: typeof eventsEnabled === 'string' ? eventsEnabled === 'true' : eventsEnabled,
birthdayEnabled: parsedBirthday.enabled,
birthdayConfig: parsedBirthday,
reactionRolesEnabled: parsedReactionRoles.enabled,
reactionRolesConfig: parsedReactionRoles
});
// Live update logging target
context.logging = new LoggingService(updated.logChannelId);
const { setLoggingAdmin } = await import('../../services/loggingService');
setLoggingAdmin(context.admin);
res.json({ ok: true, settings: updated });
});
export default router;

View File

@@ -1,21 +1,75 @@
import { Router } from 'express';
import { env } from '../../config/env.js';
import { env } from '../../config/env';
const router = Router();
const baseUrl = () => {
const origin = env.publicBaseUrl || `http://localhost:${env.port}`;
const path = env.webBasePath || '/ucp';
return `${origin}${path}`;
};
router.get('/discord', (_req, res) => {
const redirect = encodeURIComponent('http://localhost:' + env.port + '/auth/callback');
const redirect = encodeURIComponent(`${baseUrl()}/auth/callback`);
const url =
`https://discord.com/api/oauth2/authorize?client_id=${env.clientId}` +
`&redirect_uri=${redirect}&response_type=code&scope=identify%20guilds`;
res.redirect(url);
});
router.get('/callback', (req, res) => {
router.get('/callback', async (req, res) => {
const code = req.query.code;
if (!code) return res.status(400).send('No code provided');
// TODO: exchange code via Discord OAuth2 token endpoint
res.send('OAuth2 Callback erhalten (Stub).');
if (!env.clientSecret) return res.status(500).send('Missing DISCORD_CLIENT_SECRET');
try {
const params = new URLSearchParams({
client_id: env.clientId,
client_secret: env.clientSecret,
grant_type: 'authorization_code',
code: String(code),
redirect_uri: `${baseUrl()}/auth/callback`
});
const tokenRes = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
if (!tokenRes.ok) {
const errText = await tokenRes.text();
return res.status(500).send(`Token exchange failed: ${errText}`);
}
const token = await tokenRes.json();
const authHeader = { Authorization: `Bearer ${token.access_token}` };
const [userRes, guildRes] = await Promise.all([
fetch('https://discord.com/api/users/@me', { headers: authHeader }),
fetch('https://discord.com/api/users/@me/guilds', { headers: authHeader })
]);
const user = await userRes.json();
const guilds = await guildRes.json();
req.session.user = user;
req.session.guilds = guilds;
req.session.token = {
access_token: token.access_token,
refresh_token: token.refresh_token,
expires_at: Date.now() + (token.expires_in ?? 0) * 1000
};
const basePath = env.webBasePath || '/ucp';
req.session.save(() => res.redirect(`${basePath}/dashboard`));
} catch (err) {
res.status(500).send('OAuth failed');
}
});
router.get('/logout', (req, res) => {
const basePath = env.webBasePath || '/ucp';
req.session?.destroy(() => res.redirect(`${basePath || '/'}`));
});
export default router;

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,17 @@ import express from 'express';
import session from 'express-session';
import cookieParser from 'cookie-parser';
import path from 'path';
import authRouter from './routes/auth.js';
import dashboardRouter from './routes/dashboard.js';
import apiRouter from './routes/api.js';
import { env } from '../config/env.js';
import authRouter from './routes/auth';
import dashboardRouter from './routes/dashboard';
import apiRouter from './routes/api';
import { env } from '../config/env';
export function createWebServer() {
const app = express();
app.use(express.json());
const basePath = env.webBasePath || '/ucp';
const dashboardPath = `${basePath}/dashboard`;
const apiPath = `${basePath}/api`;
app.use(express.json({ limit: '5mb' }));
app.use(cookieParser());
app.use(
session({
@@ -19,14 +22,52 @@ export function createWebServer() {
})
);
app.use('/auth', authRouter);
app.use('/dashboard', dashboardRouter);
app.use('/api', apiRouter);
const mount = (suffix: string) => (basePath ? `${basePath}${suffix}` : suffix);
app.use(mount('/auth'), authRouter);
app.use(dashboardPath, dashboardRouter);
app.use(mount('/api'), apiRouter);
// fallback mounts if proxy strips base path
if (basePath) {
app.use('/api', apiRouter);
app.use('/dashboard', dashboardRouter);
}
app.get('/', (_req, res) => {
res.send(`<h1>Papo Dashboard</h1><p><a href="/dashboard">Zum Dashboard</a></p>`);
// Redirect bare auth calls to the prefixed path when a base path is set
if (basePath) {
app.use('/auth', (_req, res) => res.redirect(`${basePath}${_req.originalUrl}`));
}
// Landing pages
app.get('/', (_req, res) => res.redirect(dashboardPath));
app.get(basePath || '/', (_req, res) => {
res.send(`
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Papo Dashboard</title>
<style>
:root { --bg:#0b0f17; --card:rgba(18,20,30,0.72); --text:#f8fafc; --muted:#a5b4c3; --accent:#f97316; --border:rgba(255,255,255,0.06); }
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:radial-gradient(circle at 18% 20%, rgba(249,115,22,0.16), transparent 32%), radial-gradient(circle at 82% -8%, rgba(255,166,99,0.12), transparent 28%), linear-gradient(140deg, #080c15 0%, #0c1220 48%, #080c15 100%); font-family:'Inter', system-ui, sans-serif; color:var(--text); }
.shell { padding:32px 36px; border-radius:18px; background:var(--card); border:1px solid var(--border); box-shadow:0 20px 50px rgba(0,0,0,0.45); backdrop-filter:blur(12px); max-width:520px; width:calc(100% - 32px); text-align:center; }
h1 { margin:0 0 10px; font-size:28px; letter-spacing:0.4px; }
p { margin:0 0 18px; color:var(--muted); }
a { display:inline-flex; align-items:center; gap:10px; padding:12px 18px; border-radius:14px; text-decoration:none; font-weight:800; color:white; background:linear-gradient(130deg, #ff9b3d, #f97316); border:1px solid rgba(249,115,22,0.45); box-shadow:0 14px 34px rgba(249,115,22,0.35); transition:transform 140ms ease, box-shadow 140ms ease; }
a:hover { transform:translateY(-1px); box-shadow:0 16px 40px rgba(249,115,22,0.4); }
</style>
</head>
<body>
<div class="shell">
<h1>Papo Dashboard</h1>
<p>Verwalte Tickets, Module und Automod.</p>
<a href="${dashboardPath}">Zum Dashboard</a>
</div>
</body>
</html>
`);
});
app.use('/static', express.static(path.join(process.cwd(), 'static')));
app.use(mount('/static'), express.static(path.join(process.cwd(), 'static')));
return app;
}