Add events module with dashboard UI, scheduling, signups, and settings updates; extend env/readme.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
35
src/commands/utility/birthday.ts
Normal file
35
src/commands/utility/birthday.ts
Normal 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;
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
29
src/commands/utility/event.ts
Normal file
29
src/commands/utility/event.ts
Normal 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;
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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!'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
33
src/commands/utility/statuspage.ts
Normal file
33
src/commands/utility/statuspage.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
63
src/commands/utility/welcome.ts
Normal file
63
src/commands/utility/welcome.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
29
src/database/migrations/20251201111117_init/migration.sql
Normal file
29
src/database/migrations/20251201111117_init/migration.sql
Normal 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")
|
||||
);
|
||||
@@ -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")
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "GuildSettings" ADD COLUMN "ticketsEnabled" BOOLEAN;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "GuildSettings" ADD COLUMN "supportRoleId" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Ticket" ADD COLUMN "ticketNumber" SERIAL NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "GuildSettings" ADD COLUMN "musicEnabled" BOOLEAN;
|
||||
ALTER TABLE "GuildSettings" ADD COLUMN "automodConfig" JSONB;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Level_userId_guildId_key" ON "Level"("userId", "guildId");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Placeholder migration to keep history consistent.
|
||||
-- Welcome module fields are already present in the current schema.
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "GuildSettings" ADD COLUMN "dynamicVoiceEnabled" BOOLEAN;
|
||||
ALTER TABLE "GuildSettings" ADD COLUMN "dynamicVoiceConfig" JSONB;
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
3
src/database/migrations/migration_lock.toml
Normal file
3
src/database/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
13
src/events/channelCreate.ts
Normal file
13
src/events/channelCreate.ts
Normal 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;
|
||||
13
src/events/channelDelete.ts
Normal file
13
src/events/channelDelete.ts
Normal 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;
|
||||
19
src/events/channelUpdate.ts
Normal file
19
src/events/channelUpdate.ts
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
18
src/events/guildMemberUpdate.ts
Normal file
18
src/events/guildMemberUpdate.ts
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
17
src/events/messageReactionAdd.ts
Normal file
17
src/events/messageReactionAdd.ts
Normal 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;
|
||||
17
src/events/messageReactionRemove.ts
Normal file
17
src/events/messageReactionRemove.ts
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
12
src/events/voiceStateUpdate.ts
Normal file
12
src/events/voiceStateUpdate.ts
Normal 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;
|
||||
37
src/index.ts
37
src/index.ts
@@ -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();
|
||||
|
||||
125
src/services/adminService.ts
Normal file
125
src/services/adminService.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Client } from 'discord.js';
|
||||
|
||||
type LogLevel = 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
export interface AdminLogEntry {
|
||||
timestamp: number;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
guildId?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export class AdminService {
|
||||
private startTime = Date.now();
|
||||
private client: Client | null = null;
|
||||
private hourBuckets = new Map<string, number>();
|
||||
private activeGuilds = new Map<string, number>();
|
||||
private logs: AdminLogEntry[] = [];
|
||||
private logLimit = 120;
|
||||
private guildLogs = new Map<string, AdminLogEntry[]>();
|
||||
private guildActivity = new Map<
|
||||
string,
|
||||
{ messages: number[]; commands: number[]; automod: number[] }
|
||||
>();
|
||||
|
||||
public setStartTime(ts: number) {
|
||||
this.startTime = ts;
|
||||
}
|
||||
|
||||
public setClient(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public trackCommand(guildId?: string | null) {
|
||||
this.bumpActivity(guildId || 'unknown');
|
||||
this.trackGuildActivity(guildId || 'unknown', 'commands');
|
||||
}
|
||||
|
||||
public trackEvent(_event: string, guildId?: string | null) {
|
||||
this.bumpActivity(guildId || 'unknown');
|
||||
this.trackGuildActivity(guildId || 'unknown', 'messages');
|
||||
}
|
||||
|
||||
public trackGuildEvent(guildId: string, type: 'messages' | 'commands' | 'automod') {
|
||||
this.trackGuildActivity(guildId, type);
|
||||
}
|
||||
|
||||
private bumpActivity(guildId: string) {
|
||||
const now = Date.now();
|
||||
const hour = new Date(now);
|
||||
hour.setMinutes(0, 0, 0);
|
||||
const key = hour.toISOString();
|
||||
this.hourBuckets.set(key, (this.hourBuckets.get(key) ?? 0) + 1);
|
||||
this.activeGuilds.set(guildId, now);
|
||||
// cleanup older than 24h
|
||||
const cutoff = now - 24 * 60 * 60 * 1000;
|
||||
for (const [k] of this.hourBuckets) {
|
||||
if (new Date(k).getTime() < cutoff) this.hourBuckets.delete(k);
|
||||
}
|
||||
for (const [g, ts] of this.activeGuilds) {
|
||||
if (ts < cutoff) this.activeGuilds.delete(g);
|
||||
}
|
||||
}
|
||||
|
||||
public pushLog(entry: AdminLogEntry) {
|
||||
this.logs.push(entry);
|
||||
if (this.logs.length > this.logLimit) this.logs.splice(0, this.logs.length - this.logLimit);
|
||||
}
|
||||
|
||||
public pushGuildLog(entry: AdminLogEntry & { guildId: string; category?: string }) {
|
||||
this.pushLog(entry);
|
||||
const arr = this.guildLogs.get(entry.guildId) ?? [];
|
||||
arr.push(entry);
|
||||
if (arr.length > this.logLimit) arr.splice(0, arr.length - this.logLimit);
|
||||
this.guildLogs.set(entry.guildId, arr);
|
||||
}
|
||||
|
||||
public getOverview() {
|
||||
const guildCount = this.client?.guilds.cache.size ?? 0;
|
||||
const activeGuilds24 = this.activeGuilds.size;
|
||||
return {
|
||||
guildCount,
|
||||
activeGuilds24,
|
||||
startTime: this.startTime,
|
||||
uptimeMs: Date.now() - this.startTime
|
||||
};
|
||||
}
|
||||
|
||||
public getActivity() {
|
||||
const points = Array.from(this.hourBuckets.entries())
|
||||
.map(([iso, count]) => ({ hour: iso, count }))
|
||||
.sort((a, b) => new Date(a.hour).getTime() - new Date(b.hour).getTime());
|
||||
return { points };
|
||||
}
|
||||
|
||||
public getLogs() {
|
||||
return this.logs.slice().reverse();
|
||||
}
|
||||
|
||||
public getGuildLogs(guildId: string) {
|
||||
return (this.guildLogs.get(guildId) ?? []).slice().reverse();
|
||||
}
|
||||
|
||||
public getGuildActivity(guildId: string) {
|
||||
const now = Date.now();
|
||||
const cutoff24 = now - 24 * 60 * 60 * 1000;
|
||||
const data = this.guildActivity.get(guildId) || { messages: [], commands: [], automod: [] };
|
||||
const filter = (arr: number[]) => arr.filter((ts) => ts >= cutoff24);
|
||||
const messages = filter(data.messages);
|
||||
const commands = filter(data.commands);
|
||||
const automod = filter(data.automod);
|
||||
data.messages = messages;
|
||||
data.commands = commands;
|
||||
data.automod = automod;
|
||||
this.guildActivity.set(guildId, data);
|
||||
return { messages24h: messages.length, commands24h: commands.length, automod24h: automod.length };
|
||||
}
|
||||
|
||||
private trackGuildActivity(guildId: string, type: 'messages' | 'commands' | 'automod') {
|
||||
if (!guildId) return;
|
||||
const entry = this.guildActivity.get(guildId) || { messages: [], commands: [], automod: [] };
|
||||
entry[type].push(Date.now());
|
||||
this.guildActivity.set(guildId, entry);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,92 @@
|
||||
import { Collection, Message } from 'discord.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { Collection, Message, PermissionFlagsBits } from 'discord.js';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface AutomodConfig {
|
||||
spamThreshold?: number;
|
||||
windowMs?: number;
|
||||
linkWhitelist?: string[];
|
||||
spamTimeoutMinutes?: number;
|
||||
deleteLinks?: boolean;
|
||||
badWordFilter?: boolean;
|
||||
capsFilter?: boolean;
|
||||
customBadwords?: string[];
|
||||
whitelistRoles?: string[];
|
||||
logChannelId?: string;
|
||||
loggingConfig?: {
|
||||
logChannelId?: string;
|
||||
categories?: {
|
||||
automodActions?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class AutoModService {
|
||||
private spamTracker = new Collection<string, { count: number; lastMessage: number }>();
|
||||
private spamThreshold = 5;
|
||||
private windowMs = 7000;
|
||||
private defaults: AutomodConfig = {
|
||||
spamThreshold: 5,
|
||||
windowMs: 7000,
|
||||
linkWhitelist: [],
|
||||
spamTimeoutMinutes: 10,
|
||||
deleteLinks: true,
|
||||
badWordFilter: true,
|
||||
capsFilter: false,
|
||||
customBadwords: [],
|
||||
whitelistRoles: []
|
||||
};
|
||||
private defaultBadwords = ['badword', 'spamword'];
|
||||
|
||||
constructor(private linkFilterEnabled = true, private antiSpamEnabled = true) {}
|
||||
|
||||
public checkMessage(message: Message) {
|
||||
public async checkMessage(message: Message, cfg?: AutomodConfig) {
|
||||
if (message.author.bot) return;
|
||||
if (this.linkFilterEnabled && this.containsLink(message.content)) {
|
||||
const config = { ...this.defaults, ...(cfg ?? {}) };
|
||||
const member = message.member;
|
||||
|
||||
if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) {
|
||||
const allowed = member.roles.cache.some((r) => config.whitelistRoles!.includes(r.id));
|
||||
if (allowed) return;
|
||||
}
|
||||
|
||||
if (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) {
|
||||
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
|
||||
message.delete().catch(() => undefined);
|
||||
message.channel
|
||||
.send({ content: `${message.author}, Links sind hier nicht erlaubt.` })
|
||||
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
|
||||
logger.info(`Deleted link from ${message.author.tag}`);
|
||||
await this.logAutomodAction(message, config, 'link_filter');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
|
||||
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
|
||||
message.delete().catch(() => undefined);
|
||||
message.channel
|
||||
.send({ content: `${message.author}, bitte auf deine Wortwahl achten.` })
|
||||
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
|
||||
await this.logAutomodAction(message, config, 'badword', message.content);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (config.capsFilter) {
|
||||
const letters = message.content.replace(/[^a-zA-Z]/g, '');
|
||||
const upper = letters.replace(/[^A-Z]/g, '');
|
||||
if (letters.length >= 10 && upper.length / letters.length > 0.7) {
|
||||
message.delete().catch(() => undefined);
|
||||
message.channel
|
||||
.send({ content: `${message.author}, bitte weniger Capslock nutzen.` })
|
||||
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
|
||||
await this.logAutomodAction(message, config, 'capslock', message.content);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.antiSpamEnabled) {
|
||||
const now = Date.now();
|
||||
const tracker = this.spamTracker.get(message.author.id) ?? { count: 0, lastMessage: now };
|
||||
if (now - tracker.lastMessage < this.windowMs) {
|
||||
if (now - tracker.lastMessage < (config.windowMs ?? this.windowMs)) {
|
||||
tracker.count += 1;
|
||||
} else {
|
||||
tracker.count = 1;
|
||||
@@ -30,20 +94,52 @@ export class AutoModService {
|
||||
tracker.lastMessage = now;
|
||||
this.spamTracker.set(message.author.id, tracker);
|
||||
|
||||
if (tracker.count >= this.spamThreshold) {
|
||||
message.member?.timeout(10 * 60 * 1000, 'Automod: Spam').catch(() => undefined);
|
||||
const threshold = config.spamThreshold ?? this.spamThreshold;
|
||||
if (tracker.count >= threshold) {
|
||||
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
|
||||
message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined);
|
||||
message.channel
|
||||
.send({ content: `${message.author}, bitte langsamer schreiben (Spam-Schutz).` })
|
||||
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
|
||||
logger.warn(`Timed out ${message.author.tag} for spam`);
|
||||
this.spamTracker.delete(message.author.id);
|
||||
await this.logAutomodAction(message, config, 'spam', `Count ${tracker.count}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private containsLink(content: string) {
|
||||
return /(https?:\/\/|discord\.gg|www\.)/i.test(content);
|
||||
private containsBadword(content: string, custom: string[] = []) {
|
||||
const combined = [...this.defaultBadwords, ...(custom || [])].filter(Boolean).map((w) => w.toLowerCase());
|
||||
if (!combined.length) return false;
|
||||
const lower = content.toLowerCase();
|
||||
return combined.some((w) => lower.includes(w));
|
||||
}
|
||||
|
||||
private containsLink(content: string, whitelist: string[] = []) {
|
||||
const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean);
|
||||
const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+)/i.exec(content);
|
||||
if (!match) return false;
|
||||
const url = match[0].toLowerCase();
|
||||
return !normalized.some((w) => url.includes(w));
|
||||
}
|
||||
|
||||
private async logAutomodAction(message: Message, config: AutomodConfig, action: string, details?: string) {
|
||||
try {
|
||||
const guild = message.guild;
|
||||
if (!guild) return;
|
||||
const loggingCfg = config.loggingConfig || {};
|
||||
const flags = loggingCfg.categories || {};
|
||||
if (flags.automodActions === false) return;
|
||||
const channelId = loggingCfg.logChannelId || config.logChannelId;
|
||||
if (!channelId) return;
|
||||
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel || !channel.isTextBased()) return;
|
||||
const content = `[Automod] ${action} by ${message.author.tag}${details ? ` | ${details}` : ''}`;
|
||||
await channel.send({ content });
|
||||
} catch (err) {
|
||||
logger.error('Automod log failed', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
160
src/services/birthdayService.ts
Normal file
160
src/services/birthdayService.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Client, EmbedBuilder, TextChannel } from 'discord.js';
|
||||
import { prisma } from '../database';
|
||||
import { settingsStore } from '../config/state';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export function normalizeBirthdayInput(raw: string) {
|
||||
const input = (raw || '').trim();
|
||||
if (!input) return null;
|
||||
|
||||
const iso = input.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (iso) {
|
||||
const year = Number(iso[1]);
|
||||
const month = Number(iso[2]);
|
||||
const day = Number(iso[3]);
|
||||
if (isValidDate(day, month, year)) return `${year.toString().padStart(4, '0')}-${pad(month)}-${pad(day)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const dmy = input.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (dmy) {
|
||||
const day = Number(dmy[1]);
|
||||
const month = Number(dmy[2]);
|
||||
const year = Number(dmy[3]);
|
||||
if (isValidDate(day, month, year)) return `${year.toString().padStart(4, '0')}-${pad(month)}-${pad(day)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const dm = input.match(/^(\d{2})\.(\d{2})\.?$/);
|
||||
if (dm) {
|
||||
const day = Number(dm[1]);
|
||||
const month = Number(dm[2]);
|
||||
if (isValidDate(day, month, 2000)) return `--${pad(month)}-${pad(day)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidDate(day: number, month: number, year?: number) {
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
|
||||
const checkYear = year ?? 2000;
|
||||
const d = new Date(checkYear, month - 1, day);
|
||||
return d.getFullYear() === checkYear && d.getMonth() + 1 === month && d.getDate() === day;
|
||||
}
|
||||
|
||||
function pad(num: number) {
|
||||
return String(num).padStart(2, '0');
|
||||
}
|
||||
|
||||
export interface BirthdayRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
guildId: string;
|
||||
birthDate: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class BirthdayService {
|
||||
private client: Client | null = null;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private lastSent = new Map<string, string>();
|
||||
|
||||
public setClient(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public startScheduler(intervalMs = 60 * 60 * 1000) {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
const interval = Math.max(10 * 60 * 1000, intervalMs);
|
||||
this.timer = setInterval(() => this.checkAndSend().catch((err) => logger.warn(`birthday check failed: ${err}`)), interval);
|
||||
}
|
||||
|
||||
public stopScheduler() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
public async setBirthday(guildId: string, userId: string, birthDate: string) {
|
||||
await prisma.birthday.upsert({
|
||||
where: { birthday_user_guild: { guildId, userId } },
|
||||
update: { birthDate },
|
||||
create: { guildId, userId, birthDate }
|
||||
});
|
||||
}
|
||||
|
||||
public async getBirthday(guildId: string, userId: string) {
|
||||
return prisma.birthday.findUnique({ where: { birthday_user_guild: { guildId, userId } } });
|
||||
}
|
||||
|
||||
public async listBirthdays(guildId: string) {
|
||||
return prisma.birthday.findMany({ where: { guildId }, orderBy: { birthDate: 'asc' } });
|
||||
}
|
||||
|
||||
public async checkAndSend(force = false) {
|
||||
if (!this.client) return;
|
||||
const now = new Date();
|
||||
const month = now.getMonth() + 1;
|
||||
const day = now.getDate();
|
||||
const todayKey = `${now.getFullYear()}-${pad(month)}-${pad(day)}`;
|
||||
|
||||
for (const [guildId, cfgRaw] of settingsStore.all()) {
|
||||
const cfg = cfgRaw ?? {};
|
||||
const enabled = cfg.birthdayEnabled ?? cfg.birthdayConfig?.enabled ?? true;
|
||||
if (!enabled) continue;
|
||||
const channelId = cfg.birthdayConfig?.channelId;
|
||||
if (!channelId) continue;
|
||||
const sendHour = cfg.birthdayConfig?.sendHour ?? 9;
|
||||
if (!force) {
|
||||
if (this.lastSent.get(guildId) === todayKey) continue;
|
||||
if (now.getHours() < sendHour) continue;
|
||||
}
|
||||
|
||||
const matches = await prisma.birthday.findMany({
|
||||
where: { guildId, birthDate: { endsWith: `-${pad(month)}-${pad(day)}` } }
|
||||
});
|
||||
if (!matches.length) continue;
|
||||
await this.publishBirthdays(guildId, channelId, matches, cfg.birthdayConfig?.messageTemplate);
|
||||
this.lastSent.set(guildId, todayKey);
|
||||
}
|
||||
}
|
||||
|
||||
public invalidate(guildId: string) {
|
||||
this.lastSent.delete(guildId);
|
||||
}
|
||||
|
||||
private async publishBirthdays(guildId: string, channelId: string, birthdays: BirthdayRecord[], template?: string) {
|
||||
if (!this.client) return;
|
||||
const channel = await this.client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel || !channel.isTextBased()) return;
|
||||
const guild = this.client.guilds.cache.get(guildId) ?? (await this.client.guilds.fetch(guildId).catch(() => null));
|
||||
const members = new Map<string, any>();
|
||||
if (guild) {
|
||||
await Promise.all(
|
||||
birthdays.map(async (entry) => {
|
||||
const mem = await guild.members.fetch(entry.userId).catch(() => null);
|
||||
if (mem) members.set(entry.userId, mem);
|
||||
})
|
||||
);
|
||||
}
|
||||
const mentionList = birthdays.map((b) => `<@${b.userId}>`).join(' ');
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Happy Birthday!')
|
||||
.setColor(0xf97316)
|
||||
.setTimestamp(new Date());
|
||||
|
||||
const lines: string[] = [];
|
||||
birthdays.forEach((entry, idx) => {
|
||||
const member = members?.get(entry.userId);
|
||||
if (idx === 0 && member) {
|
||||
embed.setThumbnail(member.user.displayAvatarURL() || null);
|
||||
embed.setAuthor({ name: member.displayName || member.user.username, iconURL: member.user.displayAvatarURL() || undefined });
|
||||
}
|
||||
const line = (template || 'Alles Gute zum Geburtstag, {user}!').replace(/\{user\}/g, `<@${entry.userId}>`);
|
||||
lines.push(line);
|
||||
});
|
||||
embed.setDescription(lines.join('\n'));
|
||||
await (channel as TextChannel).send({ content: mentionList || undefined, embeds: [embed] });
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,43 @@
|
||||
import { REST, Routes, Collection, Client, ChatInputCommandInteraction, GatewayIntentBits } from 'discord.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { SlashCommand } from '../utils/types.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { SlashCommand } from '../utils/types';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../utils/logger';
|
||||
import { AdminService } from './adminService';
|
||||
import { StatuspageService } from './statuspageService';
|
||||
import { settingsStore } from '../config/state';
|
||||
import { ModuleKey } from './moduleService';
|
||||
|
||||
export class CommandHandler {
|
||||
private commands = new Collection<string, SlashCommand>();
|
||||
private moduleMap: Record<string, ModuleKey> = {
|
||||
// Tickets
|
||||
ticket: 'ticketsEnabled',
|
||||
ticketpanel: 'ticketsEnabled',
|
||||
transcript: 'ticketsEnabled',
|
||||
close: 'ticketsEnabled',
|
||||
claim: 'ticketsEnabled',
|
||||
// Music
|
||||
play: 'musicEnabled',
|
||||
skip: 'musicEnabled',
|
||||
stop: 'musicEnabled',
|
||||
pause: 'musicEnabled',
|
||||
resume: 'musicEnabled',
|
||||
loop: 'musicEnabled',
|
||||
queue: 'musicEnabled',
|
||||
// Level
|
||||
rank: 'levelingEnabled',
|
||||
// Statuspage
|
||||
status: 'statuspageEnabled',
|
||||
// Birthday
|
||||
birthday: 'birthdayEnabled',
|
||||
// Events
|
||||
event: 'eventsEnabled',
|
||||
events: 'eventsEnabled'
|
||||
};
|
||||
|
||||
constructor(private client: Client) {}
|
||||
constructor(private client: Client, private admin?: AdminService, private statuspage?: StatuspageService) {}
|
||||
|
||||
public async loadCommands() {
|
||||
const commandsPath = path.join(process.cwd(), 'src', 'commands');
|
||||
@@ -80,7 +109,20 @@ export class CommandHandler {
|
||||
await interaction.reply({ content: 'Dieser Befehl funktioniert nur auf Servern.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
if (interaction.inGuild()) {
|
||||
const moduleKey = this.moduleMap[interaction.commandName];
|
||||
if (moduleKey) {
|
||||
const cfg = settingsStore.get(interaction.guildId!);
|
||||
const enabled = cfg?.[moduleKey];
|
||||
if (enabled === false) {
|
||||
await interaction.reply({ content: 'Dieses Modul ist fuer diese Guild deaktiviert.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.admin?.trackCommand(interaction.guildId);
|
||||
if (interaction.guildId) this.admin?.trackGuildEvent(interaction.guildId, 'commands');
|
||||
await command.execute(interaction, this.client);
|
||||
} catch (err) {
|
||||
logger.error(`Command ${interaction.commandName} failed`, err);
|
||||
|
||||
152
src/services/dynamicVoiceService.ts
Normal file
152
src/services/dynamicVoiceService.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { ChannelType, PermissionsBitField, VoiceState, Guild, Client } from 'discord.js';
|
||||
import { settingsStore } from '../config/state';
|
||||
import { logger } from '../utils/logger';
|
||||
import { context } from '../config/context';
|
||||
|
||||
export class DynamicVoiceService {
|
||||
private created = new Map<string, Set<string>>();
|
||||
|
||||
private getSet(guildId: string) {
|
||||
if (!this.created.has(guildId)) this.created.set(guildId, new Set());
|
||||
return this.created.get(guildId)!;
|
||||
}
|
||||
|
||||
private track(guildId: string, channelId: string) {
|
||||
this.getSet(guildId).add(channelId);
|
||||
}
|
||||
|
||||
private untrack(guildId: string, channelId: string) {
|
||||
this.getSet(guildId).delete(channelId);
|
||||
}
|
||||
|
||||
private isManaged(guildId: string, channelId?: string | null) {
|
||||
if (!channelId) return false;
|
||||
return this.getSet(guildId).has(channelId);
|
||||
}
|
||||
|
||||
public async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
|
||||
const guild = newState.guild || oldState.guild;
|
||||
if (!guild) return;
|
||||
const cfg = settingsStore.get(guild.id);
|
||||
const dvCfg = (cfg?.dynamicVoiceConfig as any) || (cfg as any)?.automodConfig?.dynamicVoiceConfig || {};
|
||||
const enabled = cfg?.dynamicVoiceEnabled === true || dvCfg.enabled === true;
|
||||
const lobbyId = dvCfg.lobbyChannelId;
|
||||
if (!enabled || !lobbyId) {
|
||||
if (oldState.channelId) await this.cleanupIfEmpty(guild, oldState.channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
// User joins the lobby
|
||||
if (newState.channelId === lobbyId && oldState.channelId !== lobbyId) {
|
||||
await this.createAndMove(guild, newState, dvCfg);
|
||||
}
|
||||
|
||||
// Clean up old managed channel when empty
|
||||
if (oldState.channelId && this.isManaged(guild.id, oldState.channelId)) {
|
||||
await this.cleanupIfEmpty(guild, oldState.channelId);
|
||||
}
|
||||
}
|
||||
|
||||
private async createAndMove(guild: Guild, state: VoiceState, dvCfg: any) {
|
||||
try {
|
||||
const member = state.member;
|
||||
const lobby = state.channel;
|
||||
if (!member || !lobby) return;
|
||||
const parentId = dvCfg.categoryId || lobby.parentId || undefined;
|
||||
const template = dvCfg.template || '{user}s Channel';
|
||||
const name = template.replace('{user}', member.displayName || member.user.username);
|
||||
const channel = await guild.channels.create({
|
||||
name,
|
||||
type: ChannelType.GuildVoice,
|
||||
parent: parentId ?? null,
|
||||
userLimit: dvCfg.userLimit ?? undefined,
|
||||
bitrate: dvCfg.bitrate ?? undefined,
|
||||
permissionOverwrites: [
|
||||
{ id: guild.roles.everyone, allow: [PermissionsBitField.Flags.Connect, PermissionsBitField.Flags.ViewChannel] },
|
||||
{
|
||||
id: member.id,
|
||||
allow: [
|
||||
PermissionsBitField.Flags.ManageChannels,
|
||||
PermissionsBitField.Flags.MoveMembers,
|
||||
PermissionsBitField.Flags.MuteMembers,
|
||||
PermissionsBitField.Flags.DeafenMembers,
|
||||
PermissionsBitField.Flags.Connect,
|
||||
PermissionsBitField.Flags.ViewChannel
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
this.track(guild.id, channel.id);
|
||||
context.logging.logSystem(guild, `DynamicVoice erstellt: ${channel.name} (${channel.id})`);
|
||||
context.admin.pushGuildLog({
|
||||
guildId: guild.id,
|
||||
level: 'INFO',
|
||||
message: `DynamicVoice erstellt: ${channel.name} (${channel.id})`,
|
||||
timestamp: Date.now(),
|
||||
category: 'dynamicVoice'
|
||||
});
|
||||
// Move user if still in lobby
|
||||
if (state.channelId === lobby.id) {
|
||||
await state.setChannel(channel.id).catch(() => undefined);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Dynamic voice create failed', err);
|
||||
context.logging.logSystem(guild, 'DynamicVoice Fehler beim Erstellen');
|
||||
context.admin.pushGuildLog({
|
||||
guildId: guild.id,
|
||||
level: 'ERROR',
|
||||
message: 'DynamicVoice Fehler beim Erstellen: ' + (err as any)?.message,
|
||||
timestamp: Date.now(),
|
||||
category: 'dynamicVoice'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupIfEmpty(guild: Guild, channelId: string) {
|
||||
try {
|
||||
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel || channel.type !== ChannelType.GuildVoice) return;
|
||||
if (channel.members.size === 0 && this.isManaged(guild.id, channelId)) {
|
||||
await channel.delete('Dynamic voice cleanup').catch(() => undefined);
|
||||
this.untrack(guild.id, channelId);
|
||||
context.logging.logSystem(guild, `DynamicVoice entfernt: ${channel.name} (${channel.id})`);
|
||||
context.admin.pushGuildLog({
|
||||
guildId: guild.id,
|
||||
level: 'INFO',
|
||||
message: `DynamicVoice entfernt: ${channel.name} (${channel.id})`,
|
||||
timestamp: Date.now(),
|
||||
category: 'dynamicVoice'
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Dynamic voice cleanup failed', err);
|
||||
context.logging.logSystem(guild, 'DynamicVoice Cleanup Fehler');
|
||||
context.admin.pushGuildLog({
|
||||
guildId: guild.id,
|
||||
level: 'ERROR',
|
||||
message: 'DynamicVoice Cleanup Fehler: ' + (err as any)?.message,
|
||||
timestamp: Date.now(),
|
||||
category: 'dynamicVoice'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanupGuild(guildId: string, client?: Client | null) {
|
||||
const channelIds = Array.from(this.getSet(guildId).values());
|
||||
if (!channelIds.length) return;
|
||||
if (client) {
|
||||
const guild = client.guilds.cache.get(guildId) ?? (await client.guilds.fetch(guildId).catch(() => null));
|
||||
if (guild) {
|
||||
for (const id of channelIds) {
|
||||
const channel = await guild.channels.fetch(id).catch(() => null);
|
||||
if (channel && channel.type === ChannelType.GuildVoice && channel.members.size === 0) {
|
||||
await channel.delete('Dynamic voice cleanup (module disabled)').catch(() => undefined);
|
||||
}
|
||||
this.untrack(guildId, id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
channelIds.forEach((id) => this.untrack(guildId, id));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Client } from 'discord.js';
|
||||
import { EventHandler } from '../utils/types.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { EventHandler } from '../utils/types';
|
||||
import { logger } from '../utils/logger';
|
||||
import { context } from '../config/context';
|
||||
|
||||
export class EventHandlerService {
|
||||
constructor(private client: Client) {}
|
||||
@@ -15,9 +16,15 @@ export class EventHandlerService {
|
||||
const event: EventHandler = mod.default;
|
||||
if (!event?.name || !event.execute) continue;
|
||||
if (event.once) {
|
||||
this.client.once(event.name, (...args) => event.execute(...args));
|
||||
this.client.once(event.name, (...args) => {
|
||||
this.track(event.name, args);
|
||||
event.execute(...args);
|
||||
});
|
||||
} else {
|
||||
this.client.on(event.name, (...args) => event.execute(...args));
|
||||
this.client.on(event.name, (...args) => {
|
||||
this.track(event.name, args);
|
||||
event.execute(...args);
|
||||
});
|
||||
}
|
||||
logger.info(`Bound event ${event.name}`);
|
||||
}
|
||||
@@ -33,4 +40,16 @@ export class EventHandlerService {
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private track(name: string, args: any[]) {
|
||||
try {
|
||||
const guildId =
|
||||
(args[0]?.guild?.id as string | undefined) ||
|
||||
(args[0]?.guildId as string | undefined) ||
|
||||
(args[0]?.message?.guildId as string | undefined);
|
||||
context.admin.trackEvent(name, guildId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
177
src/services/eventService.ts
Normal file
177
src/services/eventService.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Client, EmbedBuilder, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle, ButtonInteraction, Guild } from 'discord.js';
|
||||
import { prisma } from '../database';
|
||||
import { settingsStore } from '../config/state';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export type RepeatType = 'none' | 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
export class EventService {
|
||||
private client: Client | null = null;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
public setClient(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public startScheduler(intervalMs = 60000) {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
const interval = Math.max(30000, intervalMs);
|
||||
this.timer = setInterval(() => this.tick().catch((err) => logger.warn('event scheduler failed', err)), interval);
|
||||
}
|
||||
|
||||
public stopScheduler() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
public async tick() {
|
||||
const now = new Date();
|
||||
const soon = new Date(now.getTime() + 30 * 60 * 1000);
|
||||
const events = await prisma.event.findMany({
|
||||
where: { isActive: true, startTime: { lte: soon } },
|
||||
orderBy: { startTime: 'asc' }
|
||||
});
|
||||
for (const ev of events) {
|
||||
await this.processEvent(ev, now);
|
||||
}
|
||||
}
|
||||
|
||||
private async processEvent(ev: any, now: Date) {
|
||||
const reminderAt = new Date(ev.startTime.getTime() - (ev.reminderOffsetMinutes ?? 60) * 60000);
|
||||
if (ev.lastReminderAt) {
|
||||
// already reminded for this start time
|
||||
} else if (now >= reminderAt) {
|
||||
await this.sendReminder(ev);
|
||||
await prisma.event.update({ where: { id: ev.id }, data: { lastReminderAt: new Date() } });
|
||||
}
|
||||
if (now >= ev.startTime) {
|
||||
if (ev.repeatType && ev.repeatType !== 'none') {
|
||||
const nextTime = this.computeNextStart(ev.startTime, ev.repeatType, ev.repeatConfig);
|
||||
if (nextTime) {
|
||||
await prisma.event.update({ where: { id: ev.id }, data: { startTime: nextTime, lastReminderAt: null } });
|
||||
} else {
|
||||
await prisma.event.update({ where: { id: ev.id }, data: { isActive: false } });
|
||||
}
|
||||
} else {
|
||||
await prisma.event.update({ where: { id: ev.id }, data: { isActive: false } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private computeNextStart(current: Date, repeat: RepeatType, cfg: any) {
|
||||
const base = new Date(current.getTime());
|
||||
if (repeat === 'daily') {
|
||||
base.setDate(base.getDate() + 1);
|
||||
return base;
|
||||
}
|
||||
if (repeat === 'weekly') {
|
||||
const days = Array.isArray(cfg?.days) ? cfg.days.map((d: any) => Number(d)).filter((n: number) => !isNaN(n)) : [];
|
||||
if (!days.length) {
|
||||
base.setDate(base.getDate() + 7);
|
||||
return base;
|
||||
}
|
||||
const currentDay = current.getDay();
|
||||
const sorted = days.sort((a: number, b: number) => a - b);
|
||||
const next = sorted.find((d: number) => d > currentDay);
|
||||
const targetDay = next ?? sorted[0];
|
||||
const diff = targetDay > currentDay ? targetDay - currentDay : 7 - (currentDay - targetDay);
|
||||
base.setDate(base.getDate() + diff);
|
||||
return base;
|
||||
}
|
||||
if (repeat === 'monthly') {
|
||||
const day = Number(cfg?.day || current.getDate());
|
||||
const nextMonth = current.getMonth() + 1;
|
||||
const year = current.getFullYear() + Math.floor(nextMonth / 12);
|
||||
const month = nextMonth % 12;
|
||||
const res = new Date(current.getTime());
|
||||
res.setFullYear(year);
|
||||
res.setMonth(month);
|
||||
res.setDate(Math.min(day || 1, 28));
|
||||
return res;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async sendReminder(ev: any) {
|
||||
if (!this.client) return;
|
||||
const guild = this.client.guilds.cache.get(ev.guildId) ?? (await this.client.guilds.fetch(ev.guildId).catch(() => null));
|
||||
if (!guild) return;
|
||||
const settings = settingsStore.get(ev.guildId) || {};
|
||||
if ((settings as any).eventsEnabled === false) return;
|
||||
const channel = await guild.channels.fetch(ev.channelId).catch(() => null);
|
||||
if (!channel || !channel.isTextBased()) return;
|
||||
const signups = await prisma.eventSignup.count({ where: { eventId: ev.id, canceledAt: null } });
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(ev.title || 'Event')
|
||||
.setDescription(ev.description || 'Event Erinnerung')
|
||||
.addFields(
|
||||
{ name: 'Start', value: `<t:${Math.floor(ev.startTime.getTime() / 1000)}:f>`, inline: true },
|
||||
{ name: 'Wiederholung', value: ev.repeatType || 'none', inline: true },
|
||||
{ name: 'Anmeldungen', value: String(signups || 0), inline: true }
|
||||
)
|
||||
.setColor(0xf97316);
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder().setCustomId(`event:signup:${ev.id}`).setStyle(ButtonStyle.Success).setLabel('Anmelden'),
|
||||
new ButtonBuilder().setCustomId(`event:signoff:${ev.id}`).setStyle(ButtonStyle.Secondary).setLabel('Abmelden')
|
||||
);
|
||||
const content = ev.roleId ? `<@&${ev.roleId}>` : undefined;
|
||||
await (channel as TextChannel).send({ content, embeds: [embed], components: [row] }).catch(() => undefined);
|
||||
}
|
||||
|
||||
public async handleButton(interaction: ButtonInteraction, action: 'signup' | 'signoff', eventId: string) {
|
||||
if (!interaction.guildId) return;
|
||||
const ev = await prisma.event.findFirst({ where: { id: eventId, guildId: interaction.guildId } });
|
||||
if (!ev) {
|
||||
await interaction.reply({ content: 'Event nicht gefunden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
if (!ev.isActive) {
|
||||
await interaction.reply({ content: 'Dieses Event ist inaktiv.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
if (action === 'signup') {
|
||||
await prisma.eventSignup.upsert({
|
||||
where: { eventId_userId: { eventId: ev.id, userId: interaction.user.id } },
|
||||
update: { canceledAt: null },
|
||||
create: { eventId: ev.id, guildId: interaction.guildId, userId: interaction.user.id }
|
||||
});
|
||||
await interaction.reply({ content: 'Du bist angemeldet.', ephemeral: true });
|
||||
} else {
|
||||
const existing = await prisma.eventSignup.findFirst({ where: { eventId: ev.id, userId: interaction.user.id, canceledAt: null } });
|
||||
if (!existing) {
|
||||
await interaction.reply({ content: 'Du warst nicht angemeldet.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await prisma.eventSignup.update({ where: { id: existing.id }, data: { canceledAt: new Date() } });
|
||||
await interaction.reply({ content: 'Abmeldung gespeichert.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
public async listEvents(guildId: string) {
|
||||
return prisma.event.findMany({ where: { guildId }, orderBy: { startTime: 'asc' }, include: { _count: { select: { signups: { where: { canceledAt: null } } as any } } as any } as any } as any);
|
||||
}
|
||||
|
||||
public async saveEvent(data: any) {
|
||||
const base = {
|
||||
guildId: data.guildId,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
channelId: data.channelId,
|
||||
startTime: new Date(data.startTime),
|
||||
repeatType: data.repeatType || 'none',
|
||||
repeatConfig: data.repeatConfig || {},
|
||||
reminderOffsetMinutes: data.reminderOffsetMinutes ?? 60,
|
||||
roleId: data.roleId || null,
|
||||
isActive: data.isActive !== false
|
||||
};
|
||||
if (data.id) {
|
||||
return prisma.event.update({ where: { id: data.id }, data: base });
|
||||
}
|
||||
return prisma.event.create({ data: base });
|
||||
}
|
||||
|
||||
public async deleteEvent(guildId: string, id: string) {
|
||||
await prisma.eventSignup.deleteMany({ where: { eventId: id, guildId } }).catch(() => undefined);
|
||||
return prisma.event.delete({ where: { id } }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { ForumRoleSync, ForumTicketLink, ForumUser } from '../utils/types.js';
|
||||
import { ForumRoleSync, ForumTicketLink, ForumUser } from '../utils/types';
|
||||
|
||||
export class ForumService {
|
||||
async linkDiscordToForum(discordId: string, forumUserId: string): Promise<ForumUser> {
|
||||
// TODO: TICKETS: Forum-Account-Linking mit Dashboard-Flow (OAuth/Token) und Persistenz verknüpfen.
|
||||
return { discordId, forumUserId };
|
||||
}
|
||||
|
||||
async syncForumRoles(): Promise<ForumRoleSync[]> {
|
||||
// Placeholder: integrate with Forum API
|
||||
// TODO: MODULE: Forum-Sync als optionales Modul führen (per Dashboard togglen, Rollen-Mapping speichern).
|
||||
return [];
|
||||
}
|
||||
|
||||
async exportTicketToForum(ticketId: string): Promise<ForumTicketLink> {
|
||||
// Placeholder: integrate with Forum API
|
||||
// TODO: TICKETS: Ticket-Threads automatisiert im Forum anlegen und Status-Sync (Dashboard <-> Forum) implementieren.
|
||||
return { ticketId };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Collection, Message } from 'discord.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { settings } from '../config/state.js';
|
||||
import { logger } from '../utils/logger';
|
||||
import { settingsStore } from '../config/state';
|
||||
import { prisma } from '../database';
|
||||
|
||||
interface LevelData {
|
||||
xp: number;
|
||||
@@ -8,32 +9,53 @@ interface LevelData {
|
||||
}
|
||||
|
||||
export class LevelService {
|
||||
private data = new Collection<string, LevelData>();
|
||||
private cache = new Collection<string, LevelData>();
|
||||
private cooldown = new Set<string>();
|
||||
|
||||
handleMessage(message: Message) {
|
||||
if (!message.guild || message.author.bot) return;
|
||||
const guildConfig = settings.get(message.guild.id);
|
||||
if (!guildConfig?.levelingEnabled) return;
|
||||
private key(guildId: string, userId: string) {
|
||||
return `${guildId}:${userId}`;
|
||||
}
|
||||
|
||||
const key = `${message.guild.id}:${message.author.id}`;
|
||||
async handleMessage(message: Message) {
|
||||
if (!message.guild || message.author.bot) return;
|
||||
const guildConfig = settingsStore.get(message.guild.id);
|
||||
if (guildConfig?.levelingEnabled !== true) return;
|
||||
|
||||
const key = this.key(message.guild.id, message.author.id);
|
||||
if (this.cooldown.has(key)) return;
|
||||
this.cooldown.add(key);
|
||||
setTimeout(() => this.cooldown.delete(key), 60_000);
|
||||
|
||||
const entry = this.data.get(key) ?? { xp: 0, level: 0 };
|
||||
entry.xp += 10;
|
||||
const entry = await this.loadLevel(message.guild.id, message.author.id);
|
||||
const xpGain = 10;
|
||||
entry.xp += xpGain;
|
||||
const nextLevel = Math.floor(0.2 * Math.sqrt(entry.xp));
|
||||
if (nextLevel > entry.level) {
|
||||
entry.level = nextLevel;
|
||||
message.channel.send({ content: `${message.author} hat Level ${entry.level} erreicht!` }).catch(() => undefined);
|
||||
logger.info(`Level up: ${message.author.tag} -> ${entry.level}`);
|
||||
}
|
||||
this.data.set(key, entry);
|
||||
this.cache.set(key, entry);
|
||||
await prisma.level.upsert({
|
||||
where: { userId_guildId: { userId: message.author.id, guildId: message.guild.id } },
|
||||
update: { xp: entry.xp, level: entry.level },
|
||||
create: { userId: message.author.id, guildId: message.guild.id, xp: entry.xp, level: entry.level }
|
||||
});
|
||||
}
|
||||
|
||||
getLevel(userId: string, guildId: string) {
|
||||
const key = `${guildId}:${userId}`;
|
||||
return this.data.get(key) ?? { xp: 0, level: 0 };
|
||||
async getLevel(userId: string, guildId: string) {
|
||||
const key = this.key(guildId, userId);
|
||||
const cached = this.cache.get(key);
|
||||
if (cached) return cached;
|
||||
return this.loadLevel(guildId, userId);
|
||||
}
|
||||
|
||||
private async loadLevel(guildId: string, userId: string): Promise<LevelData> {
|
||||
const key = this.key(guildId, userId);
|
||||
if (this.cache.has(key)) return this.cache.get(key)!;
|
||||
const row = await prisma.level.findUnique({ where: { userId_guildId: { guildId, userId } } }).catch(() => null);
|
||||
const entry: LevelData = { xp: row?.xp ?? 0, level: row?.level ?? 0 };
|
||||
this.cache.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,65 @@
|
||||
import { TextChannel, Guild, Message, GuildMember, User, EmbedBuilder } from 'discord.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { TextChannel, Guild, Message, GuildMember, User, EmbedBuilder, GuildChannel } from 'discord.js';
|
||||
import { logger } from '../utils/logger';
|
||||
import { settingsStore } from '../config/state';
|
||||
import type { AdminService } from './adminService';
|
||||
|
||||
let adminSink: AdminService | null = null;
|
||||
export const setLoggingAdmin = (admin: AdminService) => {
|
||||
adminSink = admin;
|
||||
};
|
||||
|
||||
type LogCategory =
|
||||
| 'joinLeave'
|
||||
| 'messageEdit'
|
||||
| 'messageDelete'
|
||||
| 'automodActions'
|
||||
| 'ticketActions'
|
||||
| 'musicEvents'
|
||||
| 'system';
|
||||
|
||||
export class LoggingService {
|
||||
constructor(private logChannelId?: string) {}
|
||||
constructor(private fallbackLogChannelId?: string) {}
|
||||
|
||||
private getChannel(guild: Guild): TextChannel | null {
|
||||
if (!this.logChannelId) return null;
|
||||
const channel = guild.channels.cache.get(this.logChannelId);
|
||||
if (!channel || channel.type !== 0) return null;
|
||||
return channel as TextChannel;
|
||||
private safeField(value?: string | null) {
|
||||
const text = (value ?? '').toString();
|
||||
const normalized = text.trim();
|
||||
if (!normalized.length) return '(leer)';
|
||||
return normalized.length > 1024 ? normalized.slice(0, 1021) + '...' : normalized;
|
||||
}
|
||||
|
||||
logSystem(guild: Guild, message: string) {
|
||||
if (!this.shouldLog(guild, 'system')) return;
|
||||
const { channel } = this.resolve(guild);
|
||||
if (!channel) return;
|
||||
const embed = new EmbedBuilder().setTitle('System').setDescription(message).setColor(0xf97316).setTimestamp();
|
||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log system', err));
|
||||
adminSink?.pushGuildLog({
|
||||
guildId: guild.id,
|
||||
level: 'INFO',
|
||||
message,
|
||||
timestamp: Date.now(),
|
||||
category: 'system'
|
||||
});
|
||||
}
|
||||
|
||||
private resolve(guild: Guild) {
|
||||
const cfg = settingsStore.get(guild.id);
|
||||
const loggingCfg = cfg?.loggingConfig || cfg?.automodConfig?.loggingConfig || {};
|
||||
const logChannelId = loggingCfg.logChannelId || cfg?.logChannelId || this.fallbackLogChannelId;
|
||||
const flags = loggingCfg.categories || {};
|
||||
const channel = logChannelId ? guild.channels.cache.get(logChannelId) : null;
|
||||
return { channel: channel && channel.type === 0 ? (channel as TextChannel) : null, flags };
|
||||
}
|
||||
|
||||
private shouldLog(guild: Guild, category: LogCategory) {
|
||||
const { flags } = this.resolve(guild);
|
||||
if (flags[category] === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
logMemberJoin(member: GuildMember) {
|
||||
const channel = this.getChannel(member.guild);
|
||||
if (!this.shouldLog(member.guild, 'joinLeave')) return;
|
||||
const { channel } = this.resolve(member.guild);
|
||||
if (!channel) return;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Member joined')
|
||||
@@ -20,10 +67,12 @@ export class LoggingService {
|
||||
.setColor(0x00ff99)
|
||||
.setTimestamp();
|
||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log join', err));
|
||||
adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'INFO', message: 'Member joined: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' });
|
||||
}
|
||||
|
||||
logMemberLeave(member: GuildMember) {
|
||||
const channel = this.getChannel(member.guild);
|
||||
if (!this.shouldLog(member.guild, 'joinLeave')) return;
|
||||
const { channel } = this.resolve(member.guild);
|
||||
if (!channel) return;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Member left')
|
||||
@@ -31,48 +80,88 @@ export class LoggingService {
|
||||
.setColor(0xff9900)
|
||||
.setTimestamp();
|
||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log leave', err));
|
||||
adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'WARN', message: 'Member left: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' });
|
||||
}
|
||||
|
||||
logMessageDelete(message: Message) {
|
||||
if (!message.guild) return;
|
||||
const channel = this.getChannel(message.guild);
|
||||
if (!this.shouldLog(message.guild, 'messageDelete')) return;
|
||||
const { channel } = this.resolve(message.guild);
|
||||
if (!channel) return;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Nachricht gelöscht')
|
||||
.setTitle('Nachricht geloescht')
|
||||
.setDescription(`Von: ${message.author?.tag ?? 'Unbekannt'}`)
|
||||
.addFields({ name: 'Inhalt', value: message.content || '(leer)' })
|
||||
.addFields({ name: 'Inhalt', value: this.safeField(message.content) })
|
||||
.setColor(0xff0000)
|
||||
.setTimestamp();
|
||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message delete', err));
|
||||
adminSink?.pushGuildLog({
|
||||
guildId: message.guild.id,
|
||||
level: 'WARN',
|
||||
message: 'Message deleted by ' + (message.author?.tag ?? 'Unknown'),
|
||||
timestamp: Date.now(),
|
||||
category: 'messageDelete'
|
||||
});
|
||||
}
|
||||
|
||||
logMessageEdit(oldMessage: Message, newMessage: Message) {
|
||||
if (!oldMessage.guild) return;
|
||||
const channel = this.getChannel(oldMessage.guild);
|
||||
if (!this.shouldLog(oldMessage.guild, 'messageEdit')) return;
|
||||
const { channel } = this.resolve(oldMessage.guild);
|
||||
if (!channel) return;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Nachricht bearbeitet')
|
||||
.setDescription(`Von: ${oldMessage.author?.tag ?? 'Unbekannt'}`)
|
||||
.addFields(
|
||||
{ name: 'Alt', value: oldMessage.content || '(leer)' },
|
||||
{ name: 'Neu', value: newMessage.content || '(leer)' }
|
||||
{ name: 'Alt', value: this.safeField(oldMessage.content) },
|
||||
{ name: 'Neu', value: this.safeField(newMessage.content) }
|
||||
)
|
||||
.setColor(0xffff00)
|
||||
.setTimestamp();
|
||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message edit', err));
|
||||
adminSink?.pushGuildLog({
|
||||
guildId: oldMessage.guild.id,
|
||||
level: 'INFO',
|
||||
message: 'Message edited by ' + (oldMessage.author?.tag ?? 'Unknown'),
|
||||
timestamp: Date.now(),
|
||||
category: 'messageEdit'
|
||||
});
|
||||
}
|
||||
|
||||
logAction(user: User, action: string, reason?: string) {
|
||||
const guild = user instanceof GuildMember ? user.guild : null;
|
||||
if (!guild) return;
|
||||
const channel = this.getChannel(guild);
|
||||
if (!this.shouldLog(guild, 'automodActions')) return;
|
||||
const { channel } = this.resolve(guild);
|
||||
if (!channel) return;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Moderation')
|
||||
.setDescription(`${user.tag} -> ${action}`)
|
||||
.addFields({ name: 'Grund', value: reason || 'Nicht angegeben' })
|
||||
.addFields({ name: 'Grund', value: this.safeField(reason || 'Nicht angegeben') })
|
||||
.setColor(0x7289da)
|
||||
.setTimestamp();
|
||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err));
|
||||
const guildId = (user as GuildMember)?.guild?.id;
|
||||
if (guildId) {
|
||||
adminSink?.pushGuildLog({
|
||||
guildId,
|
||||
level: 'INFO',
|
||||
message: `Moderation action: ${action} (${user.tag})`,
|
||||
timestamp: Date.now(),
|
||||
category: 'automodActions'
|
||||
});
|
||||
adminSink?.trackGuildEvent(guildId, 'automod');
|
||||
}
|
||||
}
|
||||
|
||||
logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
|
||||
const guildId = member.guild.id;
|
||||
adminSink?.pushGuildLog({
|
||||
guildId,
|
||||
level: 'INFO',
|
||||
message: `Roles changed for ${member.user.tag}: +${added.length} / -${removed.length}`,
|
||||
timestamp: Date.now(),
|
||||
category: 'roles'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
80
src/services/moduleService.ts
Normal file
80
src/services/moduleService.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { settingsStore } from '../config/state';
|
||||
|
||||
export type ModuleKey =
|
||||
| 'ticketsEnabled'
|
||||
| 'automodEnabled'
|
||||
| 'levelingEnabled'
|
||||
| 'musicEnabled'
|
||||
| 'welcomeEnabled'
|
||||
| 'dynamicVoiceEnabled'
|
||||
| 'statuspageEnabled'
|
||||
| 'birthdayEnabled'
|
||||
| 'reactionRolesEnabled'
|
||||
| 'eventsEnabled';
|
||||
|
||||
export interface GuildModuleState {
|
||||
key: ModuleKey;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const MODULES: Record<ModuleKey, { name: string; description: string }> = {
|
||||
ticketsEnabled: { name: 'Ticketsystem', description: 'Ticket-Panel, Buttons, Transcript-Export.' },
|
||||
automodEnabled: { name: 'Automod', description: 'Linkfilter und Anti-Spam Timeout.' },
|
||||
levelingEnabled: { name: 'Leveling', description: 'XP/Level-Tracking und /rank.' },
|
||||
musicEnabled: { name: 'Musik', description: 'Wiedergabe, Queue, Loop, Pause/Resume.' },
|
||||
welcomeEnabled: { name: 'Willkommensnachrichten', description: 'Begruessungs-Embed beim Join.' },
|
||||
dynamicVoiceEnabled: { name: 'Dynamische Voice Channels', description: 'Erzeugt private Voice-Channels aus einer Lobby.' },
|
||||
statuspageEnabled: { name: 'Statuspage', description: 'Service Checks, Uptime und Status-Embed.' },
|
||||
birthdayEnabled: { name: 'Birthday', description: 'Geburtstage speichern und Glueckwuensche senden.' },
|
||||
reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' },
|
||||
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' }
|
||||
};
|
||||
|
||||
export class BotModuleService {
|
||||
private hooks: Partial<Record<ModuleKey, { onEnable?: (guildId: string) => Promise<void> | void; onDisable?: (guildId: string) => Promise<void> | void }>> =
|
||||
{};
|
||||
|
||||
public setHooks(hooks: Partial<Record<ModuleKey, { onEnable?: (guildId: string) => Promise<void> | void; onDisable?: (guildId: string) => Promise<void> | void }>>) {
|
||||
this.hooks = hooks;
|
||||
}
|
||||
|
||||
public async getModulesForGuild(guildId: string): Promise<GuildModuleState[]> {
|
||||
const cfg = settingsStore.get(guildId) ?? {};
|
||||
return Object.entries(MODULES).map(([key, meta]) => {
|
||||
let enabled = cfg[key as ModuleKey] === true;
|
||||
if (key === 'automodEnabled') enabled = cfg.automodEnabled === true;
|
||||
if (key === 'welcomeEnabled') enabled = cfg.welcomeConfig?.enabled === true || cfg.automodConfig?.welcomeConfig?.enabled === true;
|
||||
if (key === 'dynamicVoiceEnabled') enabled = cfg.dynamicVoiceEnabled === true || (cfg.dynamicVoiceConfig as any)?.enabled === true;
|
||||
if (key === 'statuspageEnabled') enabled = cfg.statuspageEnabled === true || cfg.automodConfig?.statuspageEnabled === true;
|
||||
if (key === 'birthdayEnabled') enabled = cfg.birthdayEnabled === true || cfg.birthdayConfig?.enabled === true;
|
||||
if (key === 'reactionRolesEnabled') enabled = cfg.reactionRolesEnabled === true || cfg.reactionRolesConfig?.enabled === true;
|
||||
if (key === 'eventsEnabled') enabled = (cfg as any).eventsEnabled !== false;
|
||||
return {
|
||||
key: key as ModuleKey,
|
||||
name: meta.name,
|
||||
description: meta.description,
|
||||
enabled
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async enableModule(guildId: string, key: ModuleKey) {
|
||||
if (key === 'welcomeEnabled') {
|
||||
await settingsStore.set(guildId, { welcomeConfig: { enabled: true } } as any);
|
||||
} else {
|
||||
await settingsStore.set(guildId, { [key]: true } as any);
|
||||
}
|
||||
await this.hooks[key]?.onEnable?.(guildId);
|
||||
}
|
||||
|
||||
public async disableModule(guildId: string, key: ModuleKey) {
|
||||
if (key === 'welcomeEnabled') {
|
||||
await settingsStore.set(guildId, { welcomeConfig: { enabled: false } } as any);
|
||||
} else {
|
||||
await settingsStore.set(guildId, { [key]: false } as any);
|
||||
}
|
||||
await this.hooks[key]?.onDisable?.(guildId);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { AudioPlayer, AudioPlayerStatus, AudioResource, VoiceConnection, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection } from '@discordjs/voice';
|
||||
import { ChatInputCommandInteraction, GuildMember, TextChannel } from 'discord.js';
|
||||
import play from 'play-dl';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { logger } from '../utils/logger';
|
||||
import { settingsStore } from '../config/state';
|
||||
|
||||
export type LoopMode = 'off' | 'song' | 'queue';
|
||||
|
||||
@@ -9,6 +10,7 @@ interface QueueItem {
|
||||
title: string;
|
||||
url: string;
|
||||
requester: string;
|
||||
originalQuery?: string;
|
||||
}
|
||||
|
||||
interface QueueState {
|
||||
@@ -24,39 +26,63 @@ export class MusicService {
|
||||
private queues = new Map<string, QueueState>();
|
||||
|
||||
private getQueue(guildId: string) {
|
||||
const cfg = settingsStore.get(guildId);
|
||||
if (cfg?.musicEnabled === false) return undefined;
|
||||
return this.queues.get(guildId);
|
||||
}
|
||||
|
||||
private ensureConnection(interaction: ChatInputCommandInteraction): VoiceConnection | null {
|
||||
private async ensureConnection(interaction: ChatInputCommandInteraction): Promise<VoiceConnection | null> {
|
||||
const member = interaction.member as GuildMember;
|
||||
const voice = member?.voice?.channel;
|
||||
if (!voice || !interaction.guildId) return null;
|
||||
const existing = getVoiceConnection(interaction.guildId);
|
||||
if (existing) return existing;
|
||||
const connection = joinVoiceChannel({
|
||||
const connection = existing ?? joinVoiceChannel({
|
||||
channelId: voice.id,
|
||||
guildId: interaction.guildId,
|
||||
adapterCreator: interaction.guild!.voiceAdapterCreator
|
||||
});
|
||||
return connection;
|
||||
try {
|
||||
await entersState(connection, VoiceConnectionStatus.Ready, 15000);
|
||||
return connection;
|
||||
} catch {
|
||||
connection.destroy();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async play(interaction: ChatInputCommandInteraction, query: string) {
|
||||
if (!interaction.guildId) return;
|
||||
const connection = this.ensureConnection(interaction);
|
||||
const cfg = settingsStore.get(interaction.guildId);
|
||||
if (cfg?.musicEnabled === false) {
|
||||
await interaction.reply({ content: 'Musik ist fuer diese Guild deaktiviert.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const trimmedQuery = (query || '').trim();
|
||||
if (!trimmedQuery) {
|
||||
await interaction.reply({ content: 'Bitte gib einen Titel oder Link an.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const connection = await this.ensureConnection(interaction);
|
||||
if (!connection) {
|
||||
await interaction.reply({ content: 'Du musst in einem Voice-Channel sein.', ephemeral: true });
|
||||
await interaction.reply({ content: 'Voice-Verbindung konnte nicht hergestellt werden. Bitte versuche es erneut.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const search = await play.search(query, { source: { youtube: 'video' }, limit: 1 });
|
||||
if (!search.length) {
|
||||
await interaction.reply({ content: 'Nichts gefunden.', ephemeral: true });
|
||||
let track: { title: string; url: string } | null = null;
|
||||
try {
|
||||
track = await this.resolveTrack(trimmedQuery);
|
||||
} catch (err) {
|
||||
logger.error('Music resolve fatal', err);
|
||||
}
|
||||
if (!track) {
|
||||
await interaction.reply({ content: 'Nichts gefunden oder URL ungueltig.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const info = search[0];
|
||||
const queueItem: QueueItem = { title: info.title ?? 'Unbekannt', url: info.url, requester: interaction.user.tag };
|
||||
if (!/^https?:\/\//i.test(track.url ?? '')) {
|
||||
await interaction.reply({ content: 'Der gefundene Link ist ungueltig.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const queueItem: QueueItem = { title: track.title ?? 'Unbekannt', url: track.url, requester: interaction.user.tag, originalQuery: trimmedQuery };
|
||||
const queue = this.getQueue(interaction.guildId);
|
||||
if (!queue) {
|
||||
const player = createAudioPlayer();
|
||||
@@ -74,7 +100,7 @@ export class MusicService {
|
||||
this.processQueue(interaction.guildId);
|
||||
} else {
|
||||
queue.queue.push(queueItem);
|
||||
await interaction.reply({ content: `Zur Queue hinzugefügt: **${queueItem.title}**` });
|
||||
await interaction.reply({ content: `Zur Queue hinzugefuegt: **${queueItem.title}**` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +118,12 @@ export class MusicService {
|
||||
this.queues.delete(guildId);
|
||||
}
|
||||
|
||||
public stopAll() {
|
||||
for (const guildId of Array.from(this.queues.keys())) {
|
||||
this.stop(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
public pause(guildId: string) {
|
||||
this.getQueue(guildId)?.player.pause(true);
|
||||
}
|
||||
@@ -120,22 +152,53 @@ export class MusicService {
|
||||
const queue = this.getQueue(guildId);
|
||||
if (!queue || queue.player.state.status === AudioPlayerStatus.Playing) return;
|
||||
|
||||
let next = queue.queue.shift();
|
||||
if (!next && queue.loop === 'queue' && queue.current) {
|
||||
next = queue.current;
|
||||
let next: QueueItem | undefined = undefined;
|
||||
let safety = 0;
|
||||
while (safety++ < 5) {
|
||||
next = queue.queue.shift();
|
||||
if (!next && queue.loop === 'queue' && queue.current) {
|
||||
next = queue.current;
|
||||
}
|
||||
if (!next) break;
|
||||
const streamUrlCheck = typeof next.url === 'string' ? next.url.trim() : '';
|
||||
if (streamUrlCheck && streamUrlCheck !== 'undefined' && /^https?:\/\//i.test(streamUrlCheck)) {
|
||||
break;
|
||||
}
|
||||
logger.error('Music stream error', { reason: 'invalid_url', item: next });
|
||||
queue.channel.send({ content: `Ungueltiger Track-Link, ueberspringe: **${next.title ?? 'Unbekannt'}**.` }).catch(() => undefined);
|
||||
next = undefined;
|
||||
}
|
||||
if (!next) {
|
||||
queue.channel.send({ content: 'Queue beendet.' }).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const streamUrl = (next.url || '').trim();
|
||||
|
||||
queue.current = next;
|
||||
const stream = await play.stream(next.url);
|
||||
const resource: AudioResource = createAudioResource(stream.stream, {
|
||||
inputType: stream.type
|
||||
});
|
||||
queue.player.play(resource);
|
||||
queue.connection.subscribe(queue.player);
|
||||
try {
|
||||
const kind = await play.validate(streamUrl);
|
||||
if (kind !== 'so_track') {
|
||||
logger.error('Music stream error', { reason: 'unsupported_url', kind, item: next });
|
||||
queue.channel.send({ content: `Nur SoundCloud wird unterstuetzt, ueberspringe: **${next.title ?? 'Unbekannt'}**.` }).catch(() => undefined);
|
||||
queue.current = undefined;
|
||||
this.processQueue(guildId);
|
||||
return;
|
||||
}
|
||||
const finalUrl = streamUrl;
|
||||
if (!finalUrl || !/^https?:\/\//i.test(finalUrl) || finalUrl === 'undefined') throw new Error('soundcloud_url_invalid');
|
||||
const stream = await play.stream(finalUrl);
|
||||
if (!stream?.stream) throw new Error('stream_invalid');
|
||||
const resource: AudioResource = createAudioResource(stream.stream, {
|
||||
inputType: stream.type
|
||||
});
|
||||
queue.player.play(resource);
|
||||
queue.connection.subscribe(queue.player);
|
||||
} catch (err) {
|
||||
logger.error('Music stream error', err);
|
||||
queue.channel.send({ content: `Fehler beim Laden von **${next.title}**.` }).catch(() => undefined);
|
||||
queue.current = undefined;
|
||||
this.processQueue(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
private registerPlayer(queue: QueueState, guildId: string) {
|
||||
@@ -150,6 +213,60 @@ export class MusicService {
|
||||
this.queues.delete(guildId);
|
||||
});
|
||||
|
||||
queue.player.on('error', (err) => logger.error('Audio player error', err));
|
||||
queue.player.on('error', (err) => {
|
||||
logger.error('Audio player error', err);
|
||||
this.processQueue(guildId);
|
||||
});
|
||||
}
|
||||
|
||||
public getStatus() {
|
||||
const sessions = Array.from(this.queues.entries())
|
||||
.filter(([guildId]) => settingsStore.get(guildId)?.musicEnabled !== false)
|
||||
.map(([guildId, q]) => ({
|
||||
guildId,
|
||||
nowPlaying: q.current,
|
||||
queueLength: q.queue.length,
|
||||
loop: q.loop
|
||||
}));
|
||||
return { activeGuilds: sessions.length, sessions };
|
||||
}
|
||||
|
||||
private async resolveTrack(query: string, opts?: { skipPlaylist?: boolean }): Promise<{ title: string; url: string } | null> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
let validation: string | null = null;
|
||||
try {
|
||||
validation = await play.validate(trimmed);
|
||||
} catch (err) {
|
||||
logger.warn('Music validate error', err);
|
||||
}
|
||||
if (validation === 'so_track') {
|
||||
return { title: trimmed, url: trimmed };
|
||||
}
|
||||
// nur SoundCloud erlaubt, alles andere ignorieren
|
||||
} catch (err) {
|
||||
logger.error('Music resolve error', err);
|
||||
}
|
||||
|
||||
const scSearch = await play.search(trimmed, { source: { soundcloud: 'tracks' }, limit: 1 }).catch((err) => {
|
||||
logger.warn('SoundCloud search skipped', err?.message || err);
|
||||
return [];
|
||||
});
|
||||
if (scSearch && scSearch.length) {
|
||||
const sc = scSearch[0];
|
||||
const url = sc.url || '';
|
||||
if (url && /^https?:\/\//i.test(url)) return { title: sc.title ?? 'Unbekannt', url };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildVideoUrl(details: any): string | null {
|
||||
if (!details) return null;
|
||||
const url = details.url || details.permalink;
|
||||
if (typeof url === 'string' && /^https?:\/\//i.test(url)) return url;
|
||||
if (details.id) return `https://www.youtube.com/watch?v=${details.id}`;
|
||||
if (details.videoId) return `https://www.youtube.com/watch?v=${details.videoId}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
202
src/services/reactionRoleService.ts
Normal file
202
src/services/reactionRoleService.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Client, EmbedBuilder, MessageReaction, TextChannel, User, PermissionsBitField } from 'discord.js';
|
||||
import { prisma } from '../database';
|
||||
import { settingsStore } from '../config/state';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface ReactionRoleEntry {
|
||||
emoji: string;
|
||||
roleId: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ReactionRoleSetInput {
|
||||
id?: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
messageId?: string | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
entries?: ReactionRoleEntry[];
|
||||
}
|
||||
|
||||
export class ReactionRoleService {
|
||||
private client: Client | null = null;
|
||||
private cache = new Map<string, ReactionRoleEntry[]>();
|
||||
|
||||
public setClient(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public async loadCache() {
|
||||
this.cache.clear();
|
||||
const sets = await prisma.reactionRoleSet.findMany({ where: { messageId: { not: null } } });
|
||||
sets.forEach((set) => {
|
||||
if (set.messageId) this.cache.set(set.messageId, set.entries as ReactionRoleEntry[]);
|
||||
});
|
||||
}
|
||||
|
||||
public async listSets(guildId: string) {
|
||||
return prisma.reactionRoleSet.findMany({ where: { guildId }, orderBy: { createdAt: 'desc' } });
|
||||
}
|
||||
|
||||
public async saveSet(input: ReactionRoleSetInput) {
|
||||
const entries = this.sanitizeEntries(input.entries);
|
||||
const base = {
|
||||
guildId: input.guildId,
|
||||
channelId: input.channelId,
|
||||
messageId: input.messageId || null,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
entries
|
||||
};
|
||||
let record: any;
|
||||
if (input.id) {
|
||||
const existing = await prisma.reactionRoleSet.findFirst({ where: { id: input.id, guildId: input.guildId } });
|
||||
if (!existing) throw new Error('set not found');
|
||||
record = await prisma.reactionRoleSet.update({ where: { id: input.id }, data: base });
|
||||
} else {
|
||||
record = await prisma.reactionRoleSet.create({ data: base });
|
||||
}
|
||||
const messageId = await this.syncMessage(record.guildId, record.channelId, record.messageId, record.title, record.description, entries);
|
||||
if (messageId && messageId !== record.messageId) {
|
||||
record = await prisma.reactionRoleSet.update({ where: { id: record.id }, data: { messageId } });
|
||||
}
|
||||
if (record.messageId) this.cache.set(record.messageId, entries);
|
||||
return record;
|
||||
}
|
||||
|
||||
public async deleteSet(guildId: string, id: string) {
|
||||
const record = await prisma.reactionRoleSet.findFirst({ where: { id, guildId } });
|
||||
if (!record) return;
|
||||
if (record.messageId) this.cache.delete(record.messageId);
|
||||
await prisma.reactionRoleSet.delete({ where: { id } });
|
||||
}
|
||||
|
||||
public async handleReaction(reaction: MessageReaction, user: User, add: boolean) {
|
||||
if (!reaction.message.guildId || user.bot) return;
|
||||
const guildId = reaction.message.guildId;
|
||||
const cfg = settingsStore.get(guildId) || {};
|
||||
const enabled = cfg.reactionRolesEnabled ?? cfg.reactionRolesConfig?.enabled ?? true;
|
||||
if (!enabled) return;
|
||||
if (!reaction.message.partial && reaction.message.author?.id === user.id && add) return;
|
||||
const entries = await this.getEntriesForMessage(reaction.message.id);
|
||||
if (!entries?.length) return;
|
||||
const entry = entries.find((e) => this.matchesEmoji(e.emoji, reaction));
|
||||
if (!entry) return;
|
||||
const guild = this.client?.guilds.cache.get(guildId) ?? (await this.client?.guilds.fetch(guildId).catch(() => null));
|
||||
if (!guild) return;
|
||||
const member = await guild.members.fetch(user.id).catch(() => null);
|
||||
if (!member) return;
|
||||
const role = guild.roles.cache.get(entry.roleId);
|
||||
if (!role) return;
|
||||
const me = guild.members.me;
|
||||
if (!me?.permissions.has(PermissionsBitField.Flags.ManageRoles) || me.roles.highest.comparePositionTo(role) <= 0) return;
|
||||
if (add) {
|
||||
await member.roles.add(role, 'Reaction role');
|
||||
} else {
|
||||
await member.roles.remove(role, 'Reaction role removed');
|
||||
}
|
||||
}
|
||||
|
||||
public async resyncGuild(guildId: string) {
|
||||
const cfg = settingsStore.get(guildId) || {};
|
||||
const enabled = cfg.reactionRolesEnabled ?? cfg.reactionRolesConfig?.enabled ?? true;
|
||||
if (!enabled) return;
|
||||
const sets = await prisma.reactionRoleSet.findMany({ where: { guildId } });
|
||||
for (const set of sets) {
|
||||
const messageId = await this.syncMessage(set.guildId, set.channelId, set.messageId, set.title, set.description, set.entries as ReactionRoleEntry[]);
|
||||
if (messageId && messageId !== set.messageId) {
|
||||
await prisma.reactionRoleSet.update({ where: { id: set.id }, data: { messageId } });
|
||||
}
|
||||
if (messageId) this.cache.set(messageId, set.entries as ReactionRoleEntry[]);
|
||||
}
|
||||
}
|
||||
|
||||
public async ensureMessage(guildId: string, id: string) {
|
||||
const set = await prisma.reactionRoleSet.findFirst({ where: { id, guildId } });
|
||||
if (!set) return null;
|
||||
const messageId = await this.syncMessage(set.guildId, set.channelId, set.messageId, set.title, set.description, set.entries as ReactionRoleEntry[]);
|
||||
if (messageId && messageId !== set.messageId) {
|
||||
await prisma.reactionRoleSet.update({ where: { id: set.id }, data: { messageId } });
|
||||
}
|
||||
if (messageId) this.cache.set(messageId, set.entries as ReactionRoleEntry[]);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
private async getEntriesForMessage(messageId: string) {
|
||||
if (this.cache.has(messageId)) return this.cache.get(messageId);
|
||||
const set = await prisma.reactionRoleSet.findFirst({ where: { messageId } });
|
||||
if (set) {
|
||||
const entries = (set.entries as ReactionRoleEntry[]) || [];
|
||||
this.cache.set(messageId, entries);
|
||||
return entries;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private sanitizeEntries(entries?: ReactionRoleEntry[]) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
return entries
|
||||
.map((e) => ({
|
||||
emoji: (e.emoji || '').trim(),
|
||||
roleId: (e.roleId || '').trim(),
|
||||
label: e.label?.trim(),
|
||||
description: e.description?.trim()
|
||||
}))
|
||||
.filter((e) => e.emoji && e.roleId);
|
||||
}
|
||||
|
||||
private matchesEmoji(target: string, reaction: MessageReaction) {
|
||||
const normalized = (target || '').replace(/[<>]/g, '').replace(/^:/, '').trim();
|
||||
const id = reaction.emoji.id;
|
||||
const name = reaction.emoji.name || '';
|
||||
if (!normalized) return false;
|
||||
if (id && (normalized === id || normalized.endsWith(id) || normalized === `${name}:${id}`)) return true;
|
||||
if (name && (normalized === name || normalized === reaction.emoji.toString())) return true;
|
||||
if (reaction.emoji.identifier && normalized === reaction.emoji.identifier) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private async syncMessage(
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
messageId: string | null | undefined,
|
||||
title: string | null | undefined,
|
||||
description: string | null | undefined,
|
||||
entries: ReactionRoleEntry[]
|
||||
) {
|
||||
if (!this.client) return null;
|
||||
const channel = await this.client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel || !channel.isTextBased()) return null;
|
||||
const perms = channel.permissionsFor(this.client.user?.id ?? '');
|
||||
const canReact = perms?.has(PermissionsBitField.Flags.AddReactions);
|
||||
if (!perms?.has(PermissionsBitField.Flags.SendMessages)) return null;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(title || 'Reaction Roles')
|
||||
.setColor(0xf97316)
|
||||
.setDescription(description || 'Reagiere, um Rollen zu erhalten.');
|
||||
const lines = entries.map((e) => `${e.emoji} <@&${e.roleId}>${e.label ? ' — ' + e.label : ''}${e.description ? ' · ' + e.description : ''}`);
|
||||
if (lines.length) embed.addFields({ name: 'Rollen', value: lines.join('\n') });
|
||||
let message = null as any;
|
||||
if (messageId) {
|
||||
message = await (channel as TextChannel).messages.fetch(messageId).catch(() => null);
|
||||
if (message) {
|
||||
await message.edit({ embeds: [embed] });
|
||||
}
|
||||
}
|
||||
if (!message) {
|
||||
message = await (channel as TextChannel).send({ embeds: [embed] });
|
||||
}
|
||||
if (canReact) {
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await message.react(entry.emoji);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to react with ${entry.emoji}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return message.id as string;
|
||||
}
|
||||
}
|
||||
225
src/services/statuspageService.ts
Normal file
225
src/services/statuspageService.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { settingsStore } from '../config/state';
|
||||
import { logger } from '../utils/logger';
|
||||
import { Client, EmbedBuilder, TextChannel } from 'discord.js';
|
||||
import net from 'net';
|
||||
|
||||
export type StatusServiceType = 'http' | 'ping' | 'tcp' | 'custom' | 'unknown';
|
||||
|
||||
export interface StatusService {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: StatusServiceType;
|
||||
target: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
status?: 'up' | 'down' | 'unknown';
|
||||
lastChecked?: number;
|
||||
upChecks?: number;
|
||||
totalChecks?: number;
|
||||
}
|
||||
|
||||
export interface StatuspageConfig {
|
||||
enabled?: boolean;
|
||||
intervalMs?: number;
|
||||
services?: StatusService[];
|
||||
statusChannelId?: string;
|
||||
statusMessageId?: string;
|
||||
}
|
||||
|
||||
export class StatuspageService {
|
||||
private timers = new Map<string, NodeJS.Timeout>();
|
||||
private client: Client | null = null;
|
||||
private fetcher: any = (globalThis as any).fetch;
|
||||
|
||||
public setClient(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public async getConfig(guildId: string): Promise<StatuspageConfig> {
|
||||
const cfg = settingsStore.get(guildId) || {};
|
||||
const sp = (cfg as any).statuspageConfig || {};
|
||||
return {
|
||||
enabled: cfg.statuspageEnabled ?? sp.enabled ?? true,
|
||||
intervalMs: sp.intervalMs ?? 60000,
|
||||
services: sp.services ?? [],
|
||||
statusChannelId: sp.statusChannelId,
|
||||
statusMessageId: sp.statusMessageId
|
||||
};
|
||||
}
|
||||
|
||||
public async saveConfig(guildId: string, cfg: StatuspageConfig) {
|
||||
await settingsStore.set(guildId, {
|
||||
statuspageEnabled: cfg.enabled,
|
||||
statuspageConfig: cfg
|
||||
} as any);
|
||||
this.restartTimer(guildId, cfg);
|
||||
}
|
||||
|
||||
public async addService(guildId: string, service: Omit<StatusService, 'id'>) {
|
||||
const cfg = await this.getConfig(guildId);
|
||||
const id = `svc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const svc: StatusService = {
|
||||
id,
|
||||
name: service.name,
|
||||
type: service.type || 'unknown',
|
||||
target: service.target,
|
||||
description: service.description,
|
||||
enabled: service.enabled ?? true,
|
||||
status: 'unknown',
|
||||
upChecks: 0,
|
||||
totalChecks: 0
|
||||
};
|
||||
cfg.services = [...(cfg.services ?? []), svc];
|
||||
await this.saveConfig(guildId, cfg);
|
||||
return svc;
|
||||
}
|
||||
|
||||
public async updateService(guildId: string, id: string, patch: Partial<StatusService>) {
|
||||
const cfg = await this.getConfig(guildId);
|
||||
cfg.services = (cfg.services ?? []).map((s) => (s.id === id ? { ...s, ...patch } : s));
|
||||
await this.saveConfig(guildId, cfg);
|
||||
}
|
||||
|
||||
public async deleteService(guildId: string, id: string) {
|
||||
const cfg = await this.getConfig(guildId);
|
||||
cfg.services = (cfg.services ?? []).filter((s) => s.id !== id);
|
||||
await this.saveConfig(guildId, cfg);
|
||||
}
|
||||
|
||||
public async getStatus(guildId: string) {
|
||||
const cfg = await this.getConfig(guildId);
|
||||
return {
|
||||
enabled: cfg.enabled !== false,
|
||||
services: cfg.services ?? []
|
||||
};
|
||||
}
|
||||
|
||||
public async runChecks(guildId: string) {
|
||||
const cfg = await this.getConfig(guildId);
|
||||
if (cfg.enabled === false) return;
|
||||
const services = cfg.services ?? [];
|
||||
const updated = await Promise.all(
|
||||
services.map(async (svc) => {
|
||||
if (svc.enabled === false) return svc;
|
||||
const result = await this.checkService(svc);
|
||||
return result;
|
||||
})
|
||||
);
|
||||
cfg.services = updated;
|
||||
await settingsStore.set(guildId, { statuspageEnabled: cfg.enabled, statuspageConfig: cfg } as any);
|
||||
if (cfg.statusChannelId) {
|
||||
const messageId = await this.publishStatus(guildId, cfg.statusChannelId, cfg);
|
||||
if (messageId && cfg.statusMessageId !== messageId) {
|
||||
cfg.statusMessageId = messageId;
|
||||
await settingsStore.set(guildId, { statuspageConfig: cfg } as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async checkService(svc: StatusService): Promise<StatusService> {
|
||||
const next = { ...svc };
|
||||
next.totalChecks = (next.totalChecks ?? 0) + 1;
|
||||
next.lastChecked = Date.now();
|
||||
try {
|
||||
if (!this.fetcher) {
|
||||
next.status = 'unknown';
|
||||
return next;
|
||||
}
|
||||
const type = (svc.type || 'unknown').toLowerCase();
|
||||
let target = svc.target || '';
|
||||
if (!target) {
|
||||
next.status = 'unknown';
|
||||
return next;
|
||||
}
|
||||
if ((type === 'http' || type === 'https') && !/^https?:\/\//i.test(target)) {
|
||||
target = 'http://' + target;
|
||||
}
|
||||
if (type === 'http' || type === 'https' || /^https?:\/\//i.test(target)) {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), 8000);
|
||||
const res = await this.fetcher(target, { method: 'GET', signal: controller.signal });
|
||||
clearTimeout(t);
|
||||
if (res.ok) {
|
||||
next.status = 'up';
|
||||
next.upChecks = (next.upChecks ?? 0) + 1;
|
||||
} else {
|
||||
next.status = 'down';
|
||||
}
|
||||
} else if (type === 'tcp' || type === 'ping') {
|
||||
const [host, portRaw] = target.split(':');
|
||||
const port = Number(portRaw) || 80;
|
||||
const ok = await this.checkTcp(host, port);
|
||||
if (ok) {
|
||||
next.status = 'up';
|
||||
next.upChecks = (next.upChecks ?? 0) + 1;
|
||||
} else {
|
||||
next.status = 'down';
|
||||
}
|
||||
} else {
|
||||
next.status = 'unknown';
|
||||
}
|
||||
} catch {
|
||||
next.status = 'down';
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private restartTimer(guildId: string, cfg: StatuspageConfig) {
|
||||
const existing = this.timers.get(guildId);
|
||||
if (existing) clearInterval(existing);
|
||||
if (cfg.enabled === false) return;
|
||||
const interval = Math.max(30000, cfg.intervalMs ?? 60000);
|
||||
const timer = setInterval(() => this.runChecks(guildId).catch((err) => logger.warn(`status checks failed ${guildId}: ${err}`)), interval);
|
||||
this.timers.set(guildId, timer);
|
||||
}
|
||||
|
||||
public async publishStatus(guildId: string, channelId: string, cfg?: StatuspageConfig) {
|
||||
if (!this.client) return;
|
||||
const channel = await this.client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel || !channel.isTextBased()) return;
|
||||
const current = cfg ?? (await this.getConfig(guildId));
|
||||
const services = current.services ?? [];
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Service Status')
|
||||
.setColor(services.some((s) => s.status === 'down') ? 0xef4444 : 0x22c55e)
|
||||
.setTimestamp(new Date());
|
||||
const lines = services.map((s) => {
|
||||
const icon = s.status === 'up' ? '✅' : s.status === 'down' ? '❌' : '⚪';
|
||||
const upPct =
|
||||
s.upChecks && s.totalChecks
|
||||
? Math.round(((s.upChecks ?? 0) / Math.max(1, s.totalChecks ?? 1)) * 100)
|
||||
: 0;
|
||||
const last = s.lastChecked ? new Date(s.lastChecked).toLocaleString() : 'n/a';
|
||||
return `${icon} ${s.name} — ${upPct}% (${last})`;
|
||||
});
|
||||
embed.setDescription(lines.join('\n') || 'Keine Services konfiguriert.');
|
||||
let messageId = current.statusMessageId;
|
||||
try {
|
||||
if (messageId) {
|
||||
const msg = await (channel as TextChannel).messages.fetch(messageId);
|
||||
await msg.edit({ embeds: [embed] });
|
||||
} else {
|
||||
const sent = await (channel as TextChannel).send({ embeds: [embed] });
|
||||
messageId = sent.id;
|
||||
}
|
||||
} catch {
|
||||
const sent = await (channel as TextChannel).send({ embeds: [embed] });
|
||||
messageId = sent.id;
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
private checkTcp(host: string, port: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const done = (ok: boolean) => {
|
||||
socket.destroy();
|
||||
resolve(ok);
|
||||
};
|
||||
socket.setTimeout(6000);
|
||||
socket.once('error', () => done(false));
|
||||
socket.once('timeout', () => done(false));
|
||||
socket.connect(port, host, () => done(true));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,23 +10,43 @@ import {
|
||||
Guild,
|
||||
GuildMember,
|
||||
PermissionsBitField,
|
||||
TextChannel
|
||||
TextChannel,
|
||||
Client
|
||||
} from 'discord.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '../database';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { TicketRecord } from '../utils/types.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { TicketRecord } from '../utils/types';
|
||||
import { logger } from '../utils/logger';
|
||||
import { settingsStore } from '../config/state';
|
||||
import { env } from '../config/env';
|
||||
|
||||
export class TicketService {
|
||||
private categoryName = 'Tickets';
|
||||
private client: Client | null = null;
|
||||
|
||||
constructor(private transcriptRoot = './transcripts') {}
|
||||
|
||||
public setClient(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
private formatChannelName(customId: string, username: string, ticketTag: string) {
|
||||
const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 20) || 'ticket';
|
||||
return `${slug(customId)}-${slug(username)}-${ticketTag}`.toLowerCase();
|
||||
}
|
||||
|
||||
private formatTicketTag(n: number) {
|
||||
return `#${String(n).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
public async createTicket(interaction: ChatInputCommandInteraction): Promise<TicketRecord | null> {
|
||||
if (!interaction.guildId || !interaction.guild) return null;
|
||||
// TODO: TICKETS: Modul-Check ueber BotModuleService kapseln (Dashboard-Toggle statt direktem Flag).
|
||||
if (!this.isEnabled(interaction.guildId)) {
|
||||
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
|
||||
return null;
|
||||
}
|
||||
const existing = await prisma.ticket.findFirst({
|
||||
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
|
||||
});
|
||||
@@ -38,14 +58,23 @@ export class TicketService {
|
||||
}
|
||||
|
||||
public async handleButton(interaction: ButtonInteraction) {
|
||||
// TODO: TICKETS: Button-Handling modularisieren und Kategorien aus Dashboard-Konfig laden.
|
||||
if (!interaction.guild) return;
|
||||
if (interaction.customId === 'support:toggle') {
|
||||
await this.toggleSupport(interaction);
|
||||
return;
|
||||
}
|
||||
if (interaction.customId.startsWith('ticket:create:')) {
|
||||
if (!this.isEnabled(interaction.guild.id)) {
|
||||
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const topic = interaction.customId.split(':')[2] || 'allgemein';
|
||||
const existing = await prisma.ticket.findFirst({
|
||||
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
|
||||
});
|
||||
if (existing) {
|
||||
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schließe es zuerst.', ephemeral: true });
|
||||
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schlieÃe es zuerst.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,23 +87,30 @@ export class TicketService {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: TICKETS: Claim-Flow per Rollen/SLAs konfigurierbar machen und ins Dashboard syncen.
|
||||
if (interaction.customId === 'ticket:claim') {
|
||||
const ticket = await this.getTicketByChannel(interaction);
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: 'Kein Ticket gefunden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
if (ticket.userId === interaction.user.id) {
|
||||
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
|
||||
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
|
||||
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: TICKETS: Close-Flow modularisieren (Feedback, Transcript-Optionen) und Dashboard-Status sofort aktualisieren.
|
||||
if (interaction.customId === 'ticket:close') {
|
||||
const ok = await this.closeTicketButton(interaction);
|
||||
if (!ok) await interaction.reply({ content: 'Ticket konnte nicht geschlossen werden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: TICKETS: Transcript-Export in Storage/DB ablegen und Download-Link ins Dashboard liefern.
|
||||
if (interaction.customId === 'ticket:transcript') {
|
||||
const ticket = await this.getTicketByChannel(interaction);
|
||||
if (!ticket) {
|
||||
@@ -90,18 +126,25 @@ export class TicketService {
|
||||
const channel = interaction.channel as TextChannel;
|
||||
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
|
||||
if (!ticket) return false;
|
||||
if (ticket.userId === interaction.user.id) {
|
||||
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
|
||||
return true;
|
||||
}
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
|
||||
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
|
||||
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
|
||||
return true;
|
||||
}
|
||||
|
||||
public async closeTicket(interaction: ChatInputCommandInteraction, reason?: string) {
|
||||
// TODO: TICKETS: Dashboard-Action zum Schliessen mit optionalen Vorlagen/Gründen anbinden und Bot/DB synchronisieren.
|
||||
const channel = interaction.channel as TextChannel;
|
||||
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
|
||||
if (!ticket) return false;
|
||||
const transcriptPath = await this.exportTranscript(channel, ticket.id);
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
|
||||
await channel.send({ content: `Ticket geschlossen. Grund: ${reason ?? 'Kein Grund angegeben'}` });
|
||||
await channel.send({ content: `Ticket geschlossen. Grund: ${reason ?? 'Kein Grund angegeben'}` }).catch(() => undefined);
|
||||
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
|
||||
await channel.delete('Ticket geschlossen');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -109,33 +152,53 @@ export class TicketService {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Ticket Support')
|
||||
.setDescription('Klicke auf eine Kategorie, um ein Ticket zu eröffnen.')
|
||||
.setColor(0x5865f2)
|
||||
.setColor(0xf97316)
|
||||
.addFields(
|
||||
{ name: 'Support', value: 'Allgemeine Fragen oder Hilfe' },
|
||||
{ name: 'Report', value: 'Melde Regelverstöße' },
|
||||
{ name: 'Team', value: 'Bewerbungen oder interne Themen' }
|
||||
{ name: 'Ban Einspruch', value: 'Du bist gebannt worden und möchtest eine faire Klärung?' },
|
||||
{ name: 'Discord-Hilfe', value: 'Dir fehlen Rollen oder du hast Probleme mit Nachrichten?' },
|
||||
{ name: 'Feedback / Beschwerde', value: 'Du willst Lob, Kritik oder Vorschläge loswerden?' },
|
||||
{ name: 'Allgemeine Frage', value: 'Alles andere, was nicht in die Kategorien passt.' }
|
||||
);
|
||||
|
||||
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder().setCustomId('ticket:create:support').setLabel('Support').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('ticket:create:report').setLabel('Report').setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder().setCustomId('ticket:create:team').setLabel('Team').setStyle(ButtonStyle.Secondary)
|
||||
new ButtonBuilder().setCustomId('ticket:create:ban').setLabel('Ban Einspruch').setEmoji('🛡').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('ticket:create:help').setLabel('Discord-Hilfe').setEmoji('🛠').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('ticket:create:feedback').setLabel('Feedback').setEmoji('💬').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('ticket:create:other').setLabel('Allgemeine Frage').setEmoji('❓').setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
return { embed, buttons };
|
||||
}
|
||||
|
||||
private async ensureCategory(guild: Guild): Promise<CategoryChannelResolvable> {
|
||||
let category = guild.channels.cache.find(
|
||||
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase().includes(this.categoryName.toLowerCase())
|
||||
);
|
||||
if (!category) {
|
||||
category = await guild.channels.create({ name: this.categoryName, type: ChannelType.GuildCategory });
|
||||
}
|
||||
return category as CategoryChannelResolvable;
|
||||
public buildCustomPanel(config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
categories: { label: string; emoji?: string; customId: string }[];
|
||||
}) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(config.title || 'Ticket Support')
|
||||
.setDescription(
|
||||
config.description ||
|
||||
'Bitte wähle eine Kategorie, um ein Ticket zu erstellen. Sei respektvoll & sachlich. Missbrauch führt zu Konsequenzen.'
|
||||
)
|
||||
.setColor(0xf97316);
|
||||
|
||||
const buttons = new ActionRowBuilder<ButtonBuilder>();
|
||||
// TODO: TICKETS: Panel-Definitionen versionieren und im Dashboard editierbar machen (Labels/Emojis/Permissions).
|
||||
config.categories.slice(0, 5).forEach((cat) => {
|
||||
const btn = new ButtonBuilder()
|
||||
.setCustomId(`ticket:create:${cat.customId}`)
|
||||
.setLabel(cat.label)
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
if (cat.emoji) btn.setEmoji(cat.emoji);
|
||||
buttons.addComponents(btn);
|
||||
});
|
||||
return { embed, buttons };
|
||||
}
|
||||
|
||||
public async exportTranscript(channel: TextChannel, ticketId: string) {
|
||||
// TODO: TICKETS: Transcript-Speicherung abstrahieren (z.B. S3/DB) und Status ans Dashboard senden.
|
||||
// Discord API limit: max 100 per fetch
|
||||
const messages = await channel.messages.fetch({ limit: 100 });
|
||||
const lines = messages
|
||||
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
|
||||
@@ -151,23 +214,51 @@ export class TicketService {
|
||||
}
|
||||
|
||||
private async openTicket(guild: Guild, member: GuildMember, topic: string): Promise<TicketRecord | null> {
|
||||
if (!this.isEnabled(guild.id)) return null;
|
||||
const category = await this.ensureCategory(guild);
|
||||
const supportRoleId = settingsStore.get(guild.id)?.supportRoleId || env.supportRoleId || null;
|
||||
const overwrites: any[] = [
|
||||
{
|
||||
id: guild.id,
|
||||
deny: [
|
||||
PermissionsBitField.Flags.ViewChannel,
|
||||
PermissionsBitField.Flags.SendMessages,
|
||||
PermissionsBitField.Flags.AttachFiles,
|
||||
PermissionsBitField.Flags.ReadMessageHistory
|
||||
]
|
||||
},
|
||||
{
|
||||
id: member.id,
|
||||
allow: [
|
||||
PermissionsBitField.Flags.ViewChannel,
|
||||
PermissionsBitField.Flags.SendMessages,
|
||||
PermissionsBitField.Flags.AttachFiles,
|
||||
PermissionsBitField.Flags.ReadMessageHistory
|
||||
]
|
||||
}
|
||||
];
|
||||
if (supportRoleId) {
|
||||
overwrites.push({
|
||||
id: supportRoleId,
|
||||
allow: [
|
||||
PermissionsBitField.Flags.ViewChannel,
|
||||
PermissionsBitField.Flags.SendMessages,
|
||||
PermissionsBitField.Flags.AttachFiles,
|
||||
PermissionsBitField.Flags.ReadMessageHistory
|
||||
]
|
||||
});
|
||||
}
|
||||
const nextNumber = (await prisma.ticket.count({ where: { guildId: guild.id } })) + 1;
|
||||
const ticketTag = this.formatTicketTag(nextNumber);
|
||||
const channelName = this.formatChannelName(topic, member.user.username, ticketTag);
|
||||
const channel = await guild.channels.create({
|
||||
name: `ticket-${member.user.username}`.toLowerCase(),
|
||||
name: channelName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id,
|
||||
deny: [PermissionsBitField.Flags.ViewChannel]
|
||||
},
|
||||
{
|
||||
id: member.id,
|
||||
allow: [PermissionsBitField.Flags.ViewChannel, PermissionsBitField.Flags.SendMessages]
|
||||
}
|
||||
]
|
||||
permissionOverwrites: overwrites
|
||||
});
|
||||
|
||||
// TODO: TICKETS: Status/Metadaten beim Erstellen direkt ans Dashboard pushen (WebSocket) und SLA speichern.
|
||||
const record = await prisma.ticket.create({
|
||||
data: {
|
||||
userId: member.id,
|
||||
@@ -175,22 +266,31 @@ export class TicketService {
|
||||
guildId: guild.id,
|
||||
topic,
|
||||
priority: 'normal',
|
||||
status: 'open'
|
||||
status: 'open',
|
||||
ticketNumber: nextNumber
|
||||
}
|
||||
});
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`Ticket: ${topic}`)
|
||||
.setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.')
|
||||
.setDescription('Ein Teammitglied wird sich gleich kuemmern. Nutze `/claim`, um den Fall zu uebernehmen.')
|
||||
.setColor(0x7289da);
|
||||
|
||||
const controls = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder().setCustomId('ticket:claim').setLabel('Claim').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('ticket:close').setLabel('Schließen').setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder().setCustomId('ticket:close').setLabel('Schliessen').setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder().setCustomId('ticket:transcript').setLabel('Transcript').setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
await channel.send({ content: `${member}`, embeds: [embed], components: [controls] });
|
||||
const supportMention = supportRoleId ? `<@&${supportRoleId}>` : null;
|
||||
await channel.send({
|
||||
content: supportMention ? `${member} ${supportMention}` : `${member}`,
|
||||
embeds: [embed],
|
||||
components: [controls],
|
||||
allowedMentions: supportMention ? { roles: [supportRoleId as string], users: [member.id] } : { users: [member.id] }
|
||||
});
|
||||
await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId);
|
||||
|
||||
return record as TicketRecord;
|
||||
}
|
||||
|
||||
@@ -201,13 +301,186 @@ export class TicketService {
|
||||
}
|
||||
|
||||
private async closeTicketButton(interaction: ButtonInteraction) {
|
||||
// TODO: TICKETS: Gemeinsamen Close-Pfad fuer Bot/Dashboard extrahieren, um Status-Divergenzen zu vermeiden.
|
||||
const channel = interaction.channel as TextChannel | null;
|
||||
if (!channel) return false;
|
||||
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
|
||||
if (!ticket) return false;
|
||||
const transcriptPath = await this.exportTranscript(channel, ticket.id);
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
|
||||
await channel.send({ content: `Ticket von ${interaction.user} geschlossen.` });
|
||||
await interaction.reply({ content: 'Ticket geschlossen.', ephemeral: true }).catch(() => undefined);
|
||||
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
|
||||
await channel.delete('Ticket geschlossen');
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: TICKETS: Log-Ziel und Storage ueber Dashboard konfigurierbar machen (z.B. Channel, Forum, Webhook).
|
||||
private async sendTranscriptToLog(guild: Guild, transcriptPath: string, ticket: { id: string; channelId: string }) {
|
||||
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
|
||||
if (!logChannelId || categories.ticketActions === false) return;
|
||||
const logChannel = await guild.channels.fetch(logChannelId).catch(() => null);
|
||||
if (!logChannel || !logChannel.isTextBased()) return;
|
||||
try {
|
||||
await (logChannel as any).send({
|
||||
content: `Transcript für Ticket ${ticket.id} (${ticket.channelId})`,
|
||||
files: [transcriptPath]
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Transcript konnte nicht im Log-Kanal gesendet werden', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendTicketCreatedLog(
|
||||
guild: Guild,
|
||||
ticketChannel: TextChannel,
|
||||
member: GuildMember,
|
||||
topic: string,
|
||||
ticketNumber: number,
|
||||
supportRoleId: string | null
|
||||
) {
|
||||
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
|
||||
if (!logChannelId || categories.ticketActions === false) return;
|
||||
const logChannel = await guild.channels.fetch(logChannelId).catch(() => null);
|
||||
if (!logChannel || !logChannel.isTextBased()) return;
|
||||
const roleMention = supportRoleId ? `<@&${supportRoleId}>` : '';
|
||||
const content = `${roleMention ? roleMention + ' ' : ''}Neues Ticket ${this.formatTicketTag(ticketNumber)} von ${member} | Thema: ${topic} | Kanal: ${ticketChannel}`;
|
||||
try {
|
||||
await (logChannel as any).send({
|
||||
content,
|
||||
allowedMentions: roleMention && supportRoleId ? { roles: [supportRoleId], users: [member.id] } : { users: [member.id] }
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Ticket-Log konnte nicht gesendet werden', err);
|
||||
}
|
||||
}
|
||||
|
||||
private getLoggingConfig(guildId: string) {
|
||||
const cfg = settingsStore.get(guildId) || {};
|
||||
const logging = (cfg as any).loggingConfig || (cfg as any).automodConfig?.loggingConfig || {};
|
||||
const categories = typeof logging.categories === 'object' && logging.categories ? logging.categories : {};
|
||||
return {
|
||||
logChannelId: logging.logChannelId || cfg.logChannelId,
|
||||
categories
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureCategory(guild: Guild): Promise<CategoryChannelResolvable> {
|
||||
let category = guild.channels.cache.find(
|
||||
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase().includes(this.categoryName.toLowerCase())
|
||||
);
|
||||
if (!category) {
|
||||
category = await guild.channels.create({ name: this.categoryName, type: ChannelType.GuildCategory });
|
||||
}
|
||||
return category as CategoryChannelResolvable;
|
||||
}
|
||||
|
||||
public async publishSupportPanel(guildId: string, cfg?: any) {
|
||||
if (!this.client) return null;
|
||||
const guild = this.client.guilds.cache.get(guildId) ?? (await this.client.guilds.fetch(guildId).catch(() => null));
|
||||
if (!guild) return null;
|
||||
const settings = settingsStore.get(guildId) || {};
|
||||
const config = { ...(settings.supportLoginConfig ?? {}), ...(cfg ?? {}) };
|
||||
if (!config.panelChannelId) return null;
|
||||
const channel = await guild.channels.fetch(config.panelChannelId).catch(() => null);
|
||||
if (!channel || !channel.isTextBased()) return null;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(config.title || 'Support Login')
|
||||
.setDescription(config.description || 'Melde dich an/ab, um die Support-Rolle zu erhalten.')
|
||||
.setColor(0xf97316);
|
||||
const btn = new ButtonBuilder()
|
||||
.setCustomId('support:toggle')
|
||||
.setLabel(config.loginLabel || 'Ich bin jetzt im Support')
|
||||
.setStyle(ButtonStyle.Success);
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(btn);
|
||||
let messageId = config.panelMessageId;
|
||||
try {
|
||||
if (messageId) {
|
||||
const msg = await (channel as TextChannel).messages.fetch(messageId);
|
||||
await msg.edit({ embeds: [embed], components: [row] });
|
||||
} else {
|
||||
const sent = await (channel as TextChannel).send({ embeds: [embed], components: [row] });
|
||||
messageId = sent.id;
|
||||
}
|
||||
} catch {
|
||||
const sent = await (channel as TextChannel).send({ embeds: [embed], components: [row] });
|
||||
messageId = sent.id;
|
||||
}
|
||||
await settingsStore.set(guildId, { supportLoginConfig: { ...config, panelMessageId: messageId } } as any);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
public async getSupportStatus(guildId: string) {
|
||||
const repo: any = (prisma as any).ticketSupportSession;
|
||||
if (!repo?.findMany) return { active: [], recent: [] };
|
||||
const active = await repo.findMany({ where: { guildId, endedAt: null }, orderBy: { startedAt: 'desc' }, take: 25 });
|
||||
const recent = await repo.findMany({
|
||||
where: { guildId, endedAt: { not: null } },
|
||||
orderBy: { endedAt: 'desc' },
|
||||
take: 25
|
||||
});
|
||||
return { active, recent };
|
||||
}
|
||||
|
||||
private async toggleSupport(interaction: ButtonInteraction) {
|
||||
const guildId = interaction.guildId;
|
||||
if (!guildId || !interaction.guild) return;
|
||||
const cfg = settingsStore.get(guildId) || {};
|
||||
const roleId = cfg.supportRoleId;
|
||||
if (!roleId) {
|
||||
await interaction.reply({ content: 'Keine Support-Rolle konfiguriert.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const member = interaction.member as GuildMember;
|
||||
const hasRole = member.roles.cache.has(roleId);
|
||||
if (!hasRole) {
|
||||
try {
|
||||
await member.roles.add(roleId, 'Support Login');
|
||||
} catch {
|
||||
await interaction.reply({ content: 'Rolle konnte nicht vergeben werden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await this.closeOpenSession(guildId, member.id, roleId);
|
||||
const repo: any = (prisma as any).ticketSupportSession;
|
||||
if (repo?.create) await repo.create({ data: { guildId, userId: member.id, roleId } });
|
||||
await interaction.reply({ content: 'Du bist jetzt im Support.', ephemeral: true });
|
||||
await this.logSupportStatus(interaction.guild, `${member.user.tag} ist jetzt im Support.`);
|
||||
} else {
|
||||
try {
|
||||
await member.roles.remove(roleId, 'Support Logout');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const duration = await this.closeOpenSession(guildId, member.id, roleId);
|
||||
await interaction.reply({ content: `Support beendet. Dauer: ${duration ? Math.round(duration / 60) + 'm' : '-'}.`, ephemeral: true });
|
||||
await this.logSupportStatus(interaction.guild, `${member.user.tag} hat den Support verlassen.`);
|
||||
}
|
||||
}
|
||||
|
||||
private async closeOpenSession(guildId: string, userId: string, roleId: string) {
|
||||
const repo: any = (prisma as any).ticketSupportSession;
|
||||
if (!repo?.findFirst) return null;
|
||||
const open = await repo.findFirst({
|
||||
where: { guildId, userId, roleId, endedAt: null },
|
||||
orderBy: { startedAt: 'desc' }
|
||||
});
|
||||
if (!open) return null;
|
||||
const endedAt = new Date();
|
||||
const durationSeconds = Math.max(0, Math.round((endedAt.getTime() - open.startedAt.getTime()) / 1000));
|
||||
await repo.update({ where: { id: open.id }, data: { endedAt, durationSeconds } });
|
||||
return durationSeconds;
|
||||
}
|
||||
|
||||
private async logSupportStatus(guild: Guild, message: string) {
|
||||
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
|
||||
if (!logChannelId || categories.ticketActions === false) return;
|
||||
const logChannel = await guild.channels.fetch(logChannelId).catch(() => null);
|
||||
if (!logChannel || !logChannel.isTextBased()) return;
|
||||
await (logChannel as any).send({ content: `[Support] ${message}` }).catch(() => undefined);
|
||||
}
|
||||
|
||||
private isEnabled(guildId: string) {
|
||||
// TODO: MODULE: Modul-Status ueber BotModuleService/SettingsStore vereinheitlichen.
|
||||
const cfg = settingsStore.get(guildId);
|
||||
return cfg?.ticketsEnabled === true;
|
||||
}
|
||||
}
|
||||
|
||||
13
src/types/express-session.d.ts
vendored
Normal file
13
src/types/express-session.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user