Compare commits

..

4 Commits

Author SHA1 Message Date
Pascal Prießnitz
e21d9e11b6 [deploy]
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-03 18:53:48 +01:00
Pascal Prießnitz
5681e14f8d [deploy] restore dashboard after register addition 2025-12-03 18:22:25 +01:00
Pascal Prießnitz
67643cb54d [deploy] fix register form relation
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-03 18:12:36 +01:00
Pascal Prießnitz
86282fbe07 [deploy] fix register schema sortOrder 2025-12-03 18:11:44 +01:00
35 changed files with 803 additions and 9456 deletions

81
node_modules/.prisma/client/edge.js generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -141,10 +141,6 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -161,30 +157,6 @@ exports.Prisma.TicketScalarFieldEnum = {
status: 'status',
claimedBy: 'claimedBy',
transcript: 'transcript',
firstClaimAt: 'firstClaimAt',
firstResponseAt: 'firstResponseAt',
kbSuggestionSentAt: 'kbSuggestionSentAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.TicketAutomationRuleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
condition: 'condition',
action: 'action',
active: 'active',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.KnowledgeBaseArticleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
title: 'title',
keywords: 'keywords',
content: 'content',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -256,45 +228,6 @@ exports.Prisma.EventSignupScalarFieldEnum = {
canceledAt: 'canceledAt'
};
exports.Prisma.RegisterFormScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
description: 'description',
reviewChannelId: 'reviewChannelId',
notifyRoleIds: 'notifyRoleIds',
isActive: 'isActive',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterFormFieldScalarFieldEnum = {
id: 'id',
formId: 'formId',
label: 'label',
type: 'type',
required: 'required',
order: 'order'
};
exports.Prisma.RegisterApplicationScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
formId: 'formId',
status: 'status',
reviewedBy: 'reviewedBy',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
id: 'id',
applicationId: 'applicationId',
fieldId: 'fieldId',
value: 'value'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -329,18 +262,12 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = {
GuildSettings: 'GuildSettings',
Ticket: 'Ticket',
TicketAutomationRule: 'TicketAutomationRule',
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
Level: 'Level',
TicketSupportSession: 'TicketSupportSession',
Birthday: 'Birthday',
ReactionRoleSet: 'ReactionRoleSet',
Event: 'Event',
EventSignup: 'EventSignup',
RegisterForm: 'RegisterForm',
RegisterFormField: 'RegisterFormField',
RegisterApplication: 'RegisterApplication',
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
EventSignup: 'EventSignup'
};
/**

8569
node_modules/.prisma/client/index.d.ts generated vendored

File diff suppressed because it is too large Load Diff

81
node_modules/.prisma/client/index.js generated vendored

File diff suppressed because one or more lines are too long

75
node_modules/.prisma/client/wasm.js generated vendored
View File

@@ -141,10 +141,6 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -161,30 +157,6 @@ exports.Prisma.TicketScalarFieldEnum = {
status: 'status',
claimedBy: 'claimedBy',
transcript: 'transcript',
firstClaimAt: 'firstClaimAt',
firstResponseAt: 'firstResponseAt',
kbSuggestionSentAt: 'kbSuggestionSentAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.TicketAutomationRuleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
condition: 'condition',
action: 'action',
active: 'active',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.KnowledgeBaseArticleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
title: 'title',
keywords: 'keywords',
content: 'content',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -256,45 +228,6 @@ exports.Prisma.EventSignupScalarFieldEnum = {
canceledAt: 'canceledAt'
};
exports.Prisma.RegisterFormScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
description: 'description',
reviewChannelId: 'reviewChannelId',
notifyRoleIds: 'notifyRoleIds',
isActive: 'isActive',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterFormFieldScalarFieldEnum = {
id: 'id',
formId: 'formId',
label: 'label',
type: 'type',
required: 'required',
order: 'order'
};
exports.Prisma.RegisterApplicationScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
formId: 'formId',
status: 'status',
reviewedBy: 'reviewedBy',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
id: 'id',
applicationId: 'applicationId',
fieldId: 'fieldId',
value: 'value'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -329,18 +262,12 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = {
GuildSettings: 'GuildSettings',
Ticket: 'Ticket',
TicketAutomationRule: 'TicketAutomationRule',
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
Level: 'Level',
TicketSupportSession: 'TicketSupportSession',
Birthday: 'Birthday',
ReactionRoleSet: 'ReactionRoleSet',
Event: 'Event',
EventSignup: 'EventSignup',
RegisterForm: 'RegisterForm',
RegisterFormField: 'RegisterFormField',
RegisterApplication: 'RegisterApplication',
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
EventSignup: 'EventSignup'
};
/**

View File

@@ -1,2 +0,0 @@
-- Placeholder recreated because migration was already applied in the database.
-- Schema changes are already present; this file keeps the migration timeline consistent.

View File

@@ -9,31 +9,26 @@ datasource db {
}
model GuildSettings {
guildId String @id
welcomeChannelId String?
logChannelId String?
automodEnabled Boolean?
automodConfig Json?
levelingEnabled Boolean?
ticketsEnabled Boolean?
musicEnabled Boolean?
statuspageEnabled Boolean?
statuspageConfig Json?
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?
supportLoginConfig Json?
birthdayEnabled Boolean?
birthdayConfig Json?
reactionRolesEnabled Boolean?
reactionRolesConfig Json?
eventsEnabled Boolean?
registerEnabled Boolean?
registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
eventsEnabled Boolean?\n registerEnabled Boolean?\n registerConfig Json?\n supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}
model Ticket {
@@ -177,17 +172,18 @@ model RegisterForm {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields RegisterFormField[]
applications RegisterApplication[]
@@index([guildId, isActive])
}
model RegisterFormField {
id String @id @default(cuid())
formId String
label String
type String
required Boolean @default(false)
order Int @default(0)
id String @id @default(cuid())
formId String
label String
type String
required Boolean @default(false)
sortOrder Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
}

View File

@@ -1,64 +1,73 @@
# Papo Discord Bot
Discord-Bot (discord.js 14, TypeScript) mit Web-Dashboard, Prisma/PostgreSQL und Docker-Support..
Discord-Bot (discord.js 14, TypeScript) mit Web-Dashboard, Prisma/PostgreSQL und Docker-Support.
## Highlights
- Ticketsystem mit Panels, Transcripts und Support-Login (Slash-Commands wie `/ticket`, `/claim`, `/close`).
- Automod (Link-Whitelist, Spam/Caps, Bad-Word-Listen), Logging für relevante Events.
- Musik (play/skip/stop/pause/resume/loop) pro Guild aktivierbar.
- Welcome, Leveling, dynamische Voice, Birthdays, Reaction Roles, Events mit Remindern.
- Statuspage-Modul, Rich Presence und modulbasierte Dashboard-Navigation.
## Was drin ist
- Ticketsystem: Slash-Commands (/ticket, /claim, /close, /ticketpriority, /ticketstatus, /transcript, /ticketpanel), Panels, Transcripts unter `./transcripts`, Support-Login-Panel mit Rollen-Vergabe/On-Duty-Logging.
- Automod: Link-Filter (Whitelist), Spam/Caps-Erkennung, Bad-Word-Listen (Custom), Timeouts, Logging.
- Musik: play/skip/stop/pause/resume/loop, Queue, aktivierbar/deaktivierbar pro Guild.
- Welcome: konfigurierbare Embeds (Channel, Farbe, Texte, Bilder/Uploads), Preview im Dashboard, Text-Fallback.
- Logging: Join/Leave, Message Edit/Delete, Automod/Ticket/Musik-Events mit konfigurierbarem Log-Channel/Kategorien.
- Leveling: XP/Level pro Nachricht, /rank, toggelbar.
- Dynamische Voice: Lobby erzeugt private Voice-Channels mit Template/Userlimit.
- Birthday: /birthday + geplante Glueckwuensche mit Template/Channel.
- Reaction Roles: Verwaltung im Dashboard, Sync/Loeschen/Erstellen.
- Events: Einmalig/recurring, Reminder, Signups, Buttons.
- Statuspage-Modul vorhanden (Config/API), plus Modul-Toggles im Dashboard.
- Dashboard: OAuth2 (Scopes identify, guilds), zeigt nur Guilds, die der Nutzer besitzt oder mit Manage Guild/Admin-Rechten verwalten darf **und** in denen der Bot ist. Modulabhaengige Navigation.
- Rich Presence: rotiert mit `/help`, Dashboard-URL und Guild-Zaehler.
## Tech-Stack
- Node.js 20, TypeScript (CommonJS)
- Node.js 20 (Docker-Basis), TypeScript (CommonJS)
- discord.js 14, play-dl, @discordjs/voice
- Express + OAuth2-Login
- Prisma ORM (PostgreSQL)
- Dockerfile + docker-compose
- Express + OAuth2-Login, Prisma ORM (PostgreSQL)
- Dockerfile + docker-compose (App + Postgres)
## Quickstart (lokal)
## Setup (lokal, Entwicklung)
1. Repo klonen, in das Verzeichnis wechseln.
2. `.env` anlegen: `cp .env.example .env` und Werte setzen.
3. Abhängigkeiten: `npm ci` (oder `npm install`).
2. `cp .env.example .env` und Variablen setzen (siehe unten).
3. Dependencies installieren: `npm ci` (oder `npm install`).
4. Prisma: `npx prisma generate --schema=src/database/schema.prisma` und `npx prisma migrate dev --name init`.
5. Start Dev: `npm run dev` (ts-node-dev). Dashboard/Bot auf `PORT` (Standard 3000).
6. Slash-Commands werden beim Start für `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert.
5. Start Dev: `npm run dev` (ts-node-dev). Dashboard und Bot laufen auf `PORT` (default 3000).
6. Slash-Commands werden beim Start fuer die IDs in `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert.
## Quickstart (Docker)
- Dev-Stack: `docker-compose up --build` (Dockerfile + Postgres 15, env aus `.env`, startet `npm run dev`).
- Eigenes Image: `docker build .` (Prisma-Generate läuft im Build). `.dockerignore` blendet lokale `node_modules`/`.env` aus.
## Setup mit Docker
- `.dockerignore` blendet lokale node_modules/.env aus.
- Dev-Stack: `docker-compose up --build` (nutzt `Dockerfile`, Postgres 15, env aus `.env`, `npm run dev` im Container).
- Eigenes Image: `docker build .` (Prisma-Generate laeuft im Build).
## Environment-Variablen (Auswahl)
## Environment-Variablen
- `DISCORD_TOKEN` (Pflicht, Bot Token)
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Dashboard-OAuth)
- `DISCORD_GUILD_ID` (optional Einzel-Guild) / `DISCORD_GUILD_IDS` (kommagetrennt)
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Pflicht fuer Dashboard-OAuth)
- `DISCORD_GUILD_ID` (optional Einzel-Guild fuer Commands)
- `DISCORD_GUILD_IDS` (kommagetrennt, mehrere Guilds)
- `DATABASE_URL` (Pflicht, Postgres)
- `PORT` (Dashboard/Bot, default 3000)
- `PORT` (Webserver/Dashboard, default 3000)
- `SESSION_SECRET` (Express Session Secret, default `papo_dev_secret`)
- `DASHBOARD_BASE_URL` (Public Base URL für OAuth Redirect)
- `DASHBOARD_BASE_URL` (Public Base URL, fuer OAuth Redirect)
- `WEB_BASE_PATH` (Default `/ucp`, ohne Slash am Ende)
- `OWNER_IDS` (kommagetrennte Owner für Admin-UI)
- `OWNER_IDS` (kommagetrennte Owner fuer Admin-UI)
- `SUPPORT_ROLE_ID` (optional Ticket/Support-Login Rolle)
## Datenbank / Prisma
- Hauptschema: `src/database/schema.prisma` (zweites in `prisma/schema.prisma` für Binary Targets).
- Schema: `src/database/schema.prisma` (zweites Schema in `prisma/schema.prisma` fuer Binary Targets).
- Migrationen: `npx prisma migrate dev --name <name>`; danach `npx prisma generate --schema=src/database/schema.prisma`.
- Kern-Tabellen: GuildSettings, Ticket, TicketSupportSession, Event/EventSignup, RegisterForm/RegisterApplication, Birthday, ReactionRoleSet, Level.
- Kern-Tabellen: GuildSettings (Module/Config), Ticket, TicketSupportSession, Event/EventSignup, Birthday, ReactionRoleSet, Level.
## Scripts
## Kommandos & Scripts
- `npm run dev` Entwicklung (ts-node-dev)
- `npm run build` TypeScript build
- `npm start` Start aus `dist`
- Prisma-CLI: `npx prisma ...` (nutzt Schema aus `src/database/schema.prisma`)
## API/Dashboard Kurzinfo
## Dashboard / API Kurzinfo
- Auth-Gate (`/api/*`), Login `/auth/discord`, Callback `/auth/callback`, Logout `/auth/logout`.
- `/api/guilds` filtert auf Guilds, die der eingeloggte User besitzt oder managen darf **und** in denen der Bot ist.
- Settings/Module über `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints für Events, Reaction Roles, Birthday, Statuspage.
- `/api/guilds` filtert auf Guilds, die der eingeloggte User besitzt oder managen darf und in denen der Bot ist.
- Module/Settings ueber `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints fuer Events, Reaction Roles, Birthday, Statuspage.
## Deployment-Hinweise
- Produktion: `npm run build` + `npm start` oder Docker-Image nutzen.
- Transcripts liegen unter `./transcripts` (bei Containern als Volume mounten).
- Transcripts werden unter `./transcripts` abgelegt (Volume mounten, falls Container).
## Credits/Lizenz
- Autoren/Lizenz nicht hinterlegt bitte vor Nutzung prüfen.
- Autoren/Lizenz nicht hinterlegt. Bitte vor Nutzung pruefen.

View File

@@ -23,7 +23,7 @@ const command: SlashCommand = {
await member.ban({ reason }).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` });
context.logging.logAction(user, 'Ban', reason, interaction.guild);
context.logging.logAction(user, 'Ban', reason);
}
};

View File

@@ -21,7 +21,7 @@ const command: SlashCommand = {
}
await member.kick(reason);
await interaction.reply({ content: `${user.tag} wurde gekickt.` });
context.logging.logAction(user, 'Kick', reason, interaction.guild);
context.logging.logAction(user, 'Kick', reason);
}
};

View File

@@ -23,7 +23,7 @@ const command: SlashCommand = {
}
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` });
context.logging.logAction(user, 'Mute', reason, interaction.guild);
context.logging.logAction(user, 'Mute', reason);
}
};

View File

@@ -25,7 +25,7 @@ const command: SlashCommand = {
await member.ban({ reason: `${reason} | ${minutes} Minuten` });
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` });
context.logging.logAction(user, 'Tempban', reason, interaction.guild);
context.logging.logAction(user, 'Tempban', reason);
setTimeout(async () => {
await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null);

View File

@@ -23,7 +23,7 @@ const command: SlashCommand = {
}
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` });
context.logging.logAction(user, 'Timeout', reason, interaction.guild);
context.logging.logAction(user, 'Timeout', reason);
}
};

View File

@@ -19,7 +19,7 @@ const command: SlashCommand = {
}
await member.timeout(null).catch(() => null);
await interaction.reply({ content: `${user.tag} ist nun entmuted.` });
context.logging.logAction(user, 'Unmute', undefined, interaction.guild);
context.logging.logAction(user, 'Unmute');
}
};

View File

@@ -4,19 +4,15 @@ import { SlashCommand } from '../../utils/types';
const command: SlashCommand = {
data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'),
async execute(interaction: ChatInputCommandInteraction) {
const avatar = interaction.client.user?.displayAvatarURL({ size: 256 }) ?? null;
const embed = new EmbedBuilder()
.setTitle('Papo Hilfe')
.setColor(0xf97316)
.setThumbnail(avatar)
.setDescription('Dein All-in-One Assistant: Tickets, Automod, Musik, Stats, Dashboard.')
.setTitle('Papo Hilfe')
.setDescription('Multi-Guild ready | Admin, Tickets, Musik, Automod, Dashboard')
.addFields(
{ name: '🛡️ Admin', value: '`/ban` `/kick` `/mute` `/timeout` `/clear`', inline: false },
{ name: '🎫 Tickets', value: '`/ticket` `/ticketpanel` `/ticketpriority` `/ticketstatus` `/transcript`', inline: false },
{ name: '🎵 Musik', value: '`/play` `/pause` `/resume` `/skip` `/stop` `/queue` `/loop`', inline: false },
{ name: '📊 Server-Tools', value: '`/configure` `/serverinfo` `/rank`', inline: false }
)
.setFooter({ text: 'Tipp: Nutze /configure für Module & Dashboard-Link' });
{ name: 'Admin', value: '/ban /kick /mute /timeout /clear', inline: false },
{ name: 'Tickets', value: '/ticket /ticketpanel /ticketpriority /ticketstatus /transcript', inline: false },
{ name: 'Musik', value: '/play /pause /resume /skip /stop /queue /loop', inline: false },
{ name: 'Utility', value: '/ping /configure /serverinfo /rank', inline: false }
);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};

View File

@@ -15,15 +15,12 @@ import { EventService } from '../services/eventService';
import { TicketAutomationService } from '../services/ticketAutomationService';
import { KnowledgeBaseService } from '../services/knowledgeBaseService';
import { RegisterService } from '../services/registerService';
import { StatsService } from '../services/statsService';
const logging = new LoggingService();
export const context = {
client: null as Client | null,
commandHandler: null as CommandHandler | null,
logging,
automod: new AutoModService(logging, true, true),
automod: new AutoModService(true, true),
logging: new LoggingService(),
music: new MusicService(),
tickets: new TicketService(),
leveling: new LevelService(),
@@ -36,8 +33,7 @@ export const context = {
events: new EventService(),
ticketAutomation: new TicketAutomationService(),
knowledgeBase: new KnowledgeBaseService(),
register: new RegisterService(),
stats: new StatsService()
register: new RegisterService()
};
context.modules.setHooks({
@@ -67,10 +63,6 @@ context.modules.setHooks({
},
eventsEnabled: {
onEnable: async (guildId: string) => context.events.tick().catch(() => undefined)
},
serverStatsEnabled: {
onEnable: async (guildId: string) => context.stats.refreshGuild(guildId).catch(() => undefined),
onDisable: async (guildId: string) => context.stats.disableGuild(guildId).catch(() => undefined)
}
});

View File

@@ -65,15 +65,6 @@ export interface GuildSettings {
reviewChannelId?: string;
notifyRoleIds?: string[];
};
serverStatsEnabled?: boolean;
serverStatsConfig?: {
enabled?: boolean;
categoryId?: string;
categoryName?: string;
refreshMinutes?: number;
cleanupOrphans?: boolean;
items?: any[];
};
supportRoleId?: string;
welcomeEnabled?: boolean;
}
@@ -83,23 +74,23 @@ class SettingsStore {
private applyModuleDefaults(cfg: GuildSettings): GuildSettings {
const normalized: GuildSettings = { ...cfg };
const defaultOn = [
'ticketsEnabled',
'automodEnabled',
'welcomeEnabled',
'levelingEnabled',
'musicEnabled',
'dynamicVoiceEnabled',
'statuspageEnabled',
'birthdayEnabled',
'reactionRolesEnabled',
'eventsEnabled',
'registerEnabled'
] as const;
defaultOn.forEach((key) => {
(
[
'ticketsEnabled',
'automodEnabled',
'welcomeEnabled',
'levelingEnabled',
'musicEnabled',
'dynamicVoiceEnabled',
'statuspageEnabled',
'birthdayEnabled',
'reactionRolesEnabled',
'eventsEnabled',
'registerEnabled'
] as const
).forEach((key) => {
if (normalized[key] === undefined) normalized[key] = true;
});
if (normalized.serverStatsEnabled === undefined) normalized.serverStatsEnabled = false;
// keep welcomeConfig flag in sync when present
if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) {
normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled };
@@ -133,8 +124,6 @@ class SettingsStore {
reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined,
registerEnabled: (row as any).registerEnabled ?? undefined,
registerConfig: (row as any).registerConfig ?? undefined,
serverStatsEnabled: (row as any).serverStatsEnabled ?? undefined,
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
supportRoleId: row.supportRoleId ?? undefined
} satisfies GuildSettings;
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
@@ -217,8 +206,6 @@ class SettingsStore {
reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null
},
create: {
@@ -241,8 +228,6 @@ class SettingsStore {
reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null
}
});

View File

@@ -1,2 +1,69 @@
-- Placeholder recreated because this migration was applied in the database already.
-- No schema changes required locally; keeps migration history aligned.
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "registerConfig" JSONB,
ADD COLUMN "registerEnabled" BOOLEAN;
-- CreateTable
CREATE TABLE "RegisterForm" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"reviewChannelId" TEXT,
"notifyRoleIds" TEXT[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RegisterForm_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterFormField" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"type" TEXT NOT NULL,
"required" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "RegisterFormField_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterApplication" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"reviewedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RegisterApplication_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterApplicationAnswer" (
"id" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"fieldId" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "RegisterApplicationAnswer_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RegisterForm_guildId_isActive_idx" ON "RegisterForm"("guildId", "isActive");
-- CreateIndex
CREATE INDEX "RegisterApplication_guildId_formId_status_idx" ON "RegisterApplication"("guildId", "formId", "status");
-- AddForeignKey
ALTER TABLE "RegisterFormField" ADD CONSTRAINT "RegisterFormField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "RegisterForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegisterApplication" ADD CONSTRAINT "RegisterApplication_formId_fkey" FOREIGN KEY ("formId") REFERENCES "RegisterForm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegisterApplicationAnswer" ADD CONSTRAINT "RegisterApplicationAnswer_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "RegisterApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,4 +0,0 @@
-- Add server stats module configuration
ALTER TABLE "GuildSettings"
ADD COLUMN "serverStatsEnabled" BOOLEAN,
ADD COLUMN "serverStatsConfig" JSONB;

View File

@@ -28,8 +28,6 @@ model GuildSettings {
eventsEnabled Boolean?
registerEnabled Boolean?
registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
@@ -182,12 +180,12 @@ model RegisterForm {
}
model RegisterFormField {
id String @id @default(cuid())
formId String
label String
type String
required Boolean @default(false)
order Int @default(0)
id String @id @default(cuid())
formId String
label String
type String
required Boolean @default(false)
sortOrder Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
}

View File

@@ -7,7 +7,6 @@ const event: EventHandler = {
execute(channel: GuildChannel) {
if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel erstellt: ${channel.name} (${channel.id})`);
context.stats.refreshGuild(channel.guild.id).catch(() => undefined);
}
};

View File

@@ -7,7 +7,6 @@ const event: EventHandler = {
execute(channel: GuildChannel) {
if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel gelöscht: ${channel.name} (${channel.id})`);
context.stats.refreshGuild(channel.guild.id).catch(() => undefined);
}
};

View File

@@ -5,7 +5,7 @@ import { context } from '../config/context';
const event: EventHandler = {
name: 'guildBanAdd',
execute(ban: GuildBan) {
context.logging.logAction(ban.user, 'Ban', undefined, ban.guild);
context.logging.logAction(ban.user, 'Ban');
}
};

View File

@@ -16,11 +16,8 @@ const event: EventHandler = {
const embed = new EmbedBuilder()
.setTitle(welcomeCfg.embedTitle || 'Willkommen!')
.setDescription(welcomeCfg.embedDescription || `${member} ist beigetreten.`)
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal);
const footerText = (welcomeCfg.embedFooter || '').trim();
if (footerText) {
embed.setFooter({ text: footerText });
}
.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';
@@ -50,7 +47,6 @@ const event: EventHandler = {
}
}
context.logging.logMemberJoin(member);
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
}
};

View File

@@ -6,7 +6,6 @@ const event: EventHandler = {
name: 'guildMemberRemove',
execute(member: GuildMember) {
context.logging.logMemberLeave(member);
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
}
};

View File

@@ -8,7 +8,7 @@ const event: EventHandler = {
async execute(message: 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);
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg.automodConfig);
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
// Ticket SLA + KB
await context.tickets.trackFirstResponse(message);

View File

@@ -38,10 +38,6 @@ const event: EventHandler = {
for (const gid of settingsStore.all().keys()) {
context.reactionRoles.resyncGuild(gid).catch((err) => logger.warn(`reaction roles sync failed for ${gid}: ${err}`));
}
context.stats.startScheduler();
for (const [gid] of client.guilds.cache) {
context.stats.refreshGuild(gid).catch((err) => logger.warn(`stats refresh failed for ${gid}: ${err}`));
}
} catch (err) {
logger.warn(`Ready handler failed: ${err}`);
}

View File

@@ -35,7 +35,6 @@ async function bootstrap() {
context.events.setClient(client);
context.events.startScheduler();
context.register.setClient(client);
context.stats.setClient(client);
await context.reactionRoles.loadCache();
logger.setSink((entry) => context.admin.pushLog(entry));
for (const gid of settingsStore.all().keys()) {

View File

@@ -1,7 +1,5 @@
import { Collection, Message } from 'discord.js';
import { Collection, Message, PermissionFlagsBits } from 'discord.js';
import { logger } from '../utils/logger';
import { GuildSettings } from '../config/state';
import { LoggingService } from './loggingService';
export interface AutomodConfig {
spamThreshold?: number;
@@ -39,13 +37,11 @@ export class AutoModService {
};
private defaultBadwords = ['badword', 'spamword'];
constructor(private logging?: LoggingService, private linkFilterEnabled = true, private antiSpamEnabled = true) {}
constructor(private linkFilterEnabled = true, private antiSpamEnabled = true) {}
public async checkMessage(message: Message, cfg?: AutomodConfig | GuildSettings) {
if (message.author.bot || message.webhookId) return;
if (!message.inGuild()) return;
const guildConfig = (cfg as GuildSettings)?.automodConfig ? (cfg as GuildSettings).automodConfig : cfg;
const config = { ...this.defaults, ...(guildConfig ?? {}) };
public async checkMessage(message: Message, cfg?: AutomodConfig) {
if (message.author.bot) return;
const config = { ...this.defaults, ...(cfg ?? {}) };
const member = message.member;
if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) {
@@ -54,16 +50,23 @@ export class AutoModService {
}
if (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) {
await this.deleteMessageWithReason(message, `${message.author}, Links sind hier nicht erlaubt.`);
const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`;
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', reason);
await this.logAutomodAction(message, config, 'link_filter');
return true;
}
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
await this.deleteMessageWithReason(message, `${message.author}, bitte auf deine Wortwahl achten.`);
await this.logAutomodAction(message, config, 'badword', 'Badword erkannt', message.content);
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;
}
@@ -71,9 +74,11 @@ export class AutoModService {
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) {
await this.deleteMessageWithReason(message, `${message.author}, bitte weniger Capslock nutzen.`);
const ratio = Math.round((upper.length / letters.length) * 100);
await this.logAutomodAction(message, config, 'capslock', `Caps Anteil ${ratio}%`, message.content);
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;
}
}
@@ -93,11 +98,12 @@ export class AutoModService {
if (tracker.count >= threshold) {
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined);
await this.deleteMessageWithReason(message, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`);
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);
const reason = `Spam erkannt (${tracker.count}/${threshold} Nachrichten innerhalb ${config.windowMs ?? this.windowMs}ms)`;
await this.logAutomodAction(message, config, 'spam', reason);
await this.logAutomodAction(message, config, 'spam', `Count ${tracker.count}`);
return true;
}
}
@@ -105,52 +111,24 @@ export class AutoModService {
}
private containsBadword(content: string, custom: string[] = []) {
const combined = [...this.defaultBadwords, ...(custom || [])]
.map((w) => w?.toString().trim().toLowerCase())
.filter(Boolean);
const combined = [...this.defaultBadwords, ...(custom || [])].filter(Boolean).map((w) => w.toLowerCase());
if (!combined.length) return false;
const lower = content.toLowerCase();
return combined.some((w) => {
// Try to match word boundaries first, fall back to substring to remain permissive
const escaped = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`\\b${escaped}\\b`, 'i');
return regex.test(lower) || lower.includes(w);
});
return combined.some((w) => lower.includes(w));
}
private containsLink(content: string, whitelist: string[] = []) {
const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean);
// Match common link formats, even without protocol
const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+|[a-z0-9.-]+\.[a-z]{2,}\/?[^\s]*)/i.exec(content);
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 deleteMessageWithReason(message: Message, response: string) {
await message.delete().catch(() => undefined);
await message.channel
.send({ content: response })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000))
.catch(() => undefined);
}
private async logAutomodAction(message: Message, config: AutomodConfig, action: string, reason: string, content?: string) {
private async logAutomodAction(message: Message, config: AutomodConfig, action: string, details?: string) {
try {
const guild = message.guild;
if (!guild) return;
if (this.logging) {
this.logging.logAutomodAction(guild, {
userTag: message.author.tag,
userId: message.author.id,
action,
reason,
content,
channel: guild.channels.cache.get(message.channelId) ?? null,
messageUrl: message.url
});
return;
}
const loggingCfg = config.loggingConfig || {};
const flags = loggingCfg.categories || {};
if (flags.automodActions === false) return;
@@ -158,8 +136,8 @@ export class AutoModService {
if (!channelId) return;
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const body = `[Automod] ${action} by ${message.author.tag} | ${reason}${content ? ` | ${content.slice(0, 1800)}` : ''}`;
await channel.send({ content: body });
const content = `[Automod] ${action} by ${message.author.tag}${details ? ` | ${details}` : ''}`;
await channel.send({ content });
} catch (err) {
logger.error('Automod log failed', err);
}

View File

@@ -45,7 +45,7 @@ export class LoggingService {
private resolve(guild: Guild) {
const cfg = settingsStore.get(guild.id);
const loggingCfg = cfg?.loggingConfig || cfg?.automodConfig?.loggingConfig || {};
const logChannelId = loggingCfg.logChannelId || cfg?.automodConfig?.logChannelId || cfg?.logChannelId || this.fallbackLogChannelId;
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 };
@@ -128,11 +128,11 @@ export class LoggingService {
});
}
logAction(user: User | GuildMember, action: string, reason?: string, guild?: Guild) {
const resolvedGuild = guild ?? (user instanceof GuildMember ? user.guild : null);
if (!resolvedGuild) return;
if (!this.shouldLog(resolvedGuild, 'automodActions')) return;
const { channel } = this.resolve(resolvedGuild);
logAction(user: User, action: string, reason?: string) {
const guild = user instanceof GuildMember ? user.guild : null;
if (!guild) return;
if (!this.shouldLog(guild, 'automodActions')) return;
const { channel } = this.resolve(guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Moderation')
@@ -141,7 +141,7 @@ export class LoggingService {
.setColor(0x7289da)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err));
const guildId = resolvedGuild.id;
const guildId = (user as GuildMember)?.guild?.id;
if (guildId) {
adminSink?.pushGuildLog({
guildId,
@@ -154,36 +154,6 @@ export class LoggingService {
}
}
logAutomodAction(guild: Guild, options: { userTag: string; userId: string; action: string; reason: string; content?: string; channel?: GuildChannel | null; messageUrl?: string }) {
if (!this.shouldLog(guild, 'automodActions')) return;
const { channel } = this.resolve(guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Automod')
.setDescription(`${options.userTag} (${options.userId}) -> ${options.action}`)
.addFields(
{ name: 'Grund', value: this.safeField(options.reason) },
{ name: 'Kanal', value: options.channel ? `<#${options.channel.id}>` : 'Unbekannt' }
)
.setColor(0xff006e)
.setTimestamp();
if (options.content) {
embed.addFields({ name: 'Nachricht', value: this.safeField(options.content) });
}
if (options.messageUrl) {
embed.addFields({ name: 'Link', value: options.messageUrl });
}
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log automod action', err));
adminSink?.pushGuildLog({
guildId: guild.id,
level: 'INFO',
message: `Automod: ${options.action} (${options.userTag})`,
timestamp: Date.now(),
category: 'automodActions'
});
adminSink?.trackGuildEvent(guild.id, 'automod');
}
logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
const guildId = member.guild.id;
adminSink?.pushGuildLog({

View File

@@ -11,8 +11,7 @@ export type ModuleKey =
| 'birthdayEnabled'
| 'reactionRolesEnabled'
| 'eventsEnabled'
| 'registerEnabled'
| 'serverStatsEnabled';
| 'registerEnabled';
export interface GuildModuleState {
key: ModuleKey;
@@ -32,8 +31,7 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
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.' },
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' },
serverStatsEnabled: { name: 'Server Stats', description: 'Zeigt Member-/Channel-Zahlen als Voice-Statistiken an.' }
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' }
};
export class BotModuleService {
@@ -55,7 +53,6 @@ export class BotModuleService {
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;
if (key === 'serverStatsEnabled') enabled = (cfg as any).serverStatsEnabled === true || (cfg as any).serverStatsConfig?.enabled === true;
return {
key: key as ModuleKey,
name: meta.name,

View File

@@ -23,7 +23,7 @@ export class RegisterService {
}
public async listForms(guildId: string) {
return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { order: 'asc' } } }, orderBy: { createdAt: 'desc' } });
return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { sortOrder: 'asc' } } }, orderBy: { createdAt: 'desc' } });
}
public async saveForm(form: {
@@ -55,10 +55,10 @@ export class RegisterService {
label: f.label,
type: f.type,
required: f.required ?? false,
order: f.order ?? idx
sortOrder: f.order ?? idx
}))
});
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { order: 'asc' } } } });
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { sortOrder: 'asc' } } } });
}
const created = await prisma.registerForm.create({
data: {
@@ -73,11 +73,11 @@ export class RegisterService {
label: f.label,
type: f.type,
required: f.required ?? false,
order: f.order ?? idx
sortOrder: f.order ?? idx
}))
}
},
include: { fields: { orderBy: { order: 'asc' } } }
include: { fields: { orderBy: { sortOrder: 'asc' } } }
});
return created;
}
@@ -113,7 +113,7 @@ export class RegisterService {
public async handleButton(interaction: ButtonInteraction) {
if (interaction.customId.startsWith('register:form:')) {
const formId = interaction.customId.split(':')[2];
const form = await prisma.registerForm.findFirst({ where: { id: formId }, include: { fields: { orderBy: { order: 'asc' } } } });
const form = await prisma.registerForm.findFirst({ where: { id: formId }, include: { fields: { orderBy: { sortOrder: 'asc' } } } });
if (!form) return interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
const modal = new ModalBuilder().setTitle(form.name).setCustomId(`register:submit:${form.id}`);
const components: any[] = [];
@@ -164,7 +164,7 @@ export class RegisterService {
const formId = interaction.customId.split(':')[2];
const form = await prisma.registerForm.findFirst({
where: { id: formId },
include: { fields: { orderBy: { order: 'asc' } } }
include: { fields: { orderBy: { sortOrder: 'asc' } } }
});
if (!form) {
await interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
@@ -200,7 +200,7 @@ export class RegisterService {
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const member = await guild.members.fetch(userId).catch(() => null);
const fields = await prisma.registerFormField.findMany({ where: { formId: form.id }, orderBy: { order: 'asc' } });
const fields = await prisma.registerFormField.findMany({ where: { formId: form.id }, orderBy: { sortOrder: 'asc' } });
const answers = await prisma.registerApplicationAnswer.findMany({ where: { applicationId: app.id } });
const embed = new EmbedBuilder()
.setTitle(`Registrierung: ${form.name}`)
@@ -254,3 +254,5 @@ export class RegisterService {
});
}
}

View File

@@ -1,271 +0,0 @@
import { randomUUID } from 'crypto';
import { CategoryChannel, ChannelType, Client, Guild, PermissionFlagsBits } from 'discord.js';
import { settingsStore } from '../config/state';
import { logger } from '../utils/logger';
export type StatCounterType =
| 'members_total'
| 'members_humans'
| 'members_bots'
| 'boosts'
| 'text_channels'
| 'voice_channels'
| 'roles';
export interface StatCounter {
id: string;
type: StatCounterType;
label?: string;
format?: string;
channelId?: string;
}
export interface ServerStatsConfig {
enabled: boolean;
categoryId?: string;
categoryName?: string;
refreshMinutes: number;
cleanupOrphans?: boolean;
items: StatCounter[];
}
const STAT_META: Record<
StatCounterType,
{
label: string;
defaultFormat: string;
}
> = {
members_total: { label: 'Mitglieder', defaultFormat: '{label}: {value}' },
members_humans: { label: 'Menschen', defaultFormat: '{label}: {value}' },
members_bots: { label: 'Bots', defaultFormat: '{label}: {value}' },
boosts: { label: 'Boosts', defaultFormat: '{label}: {value}' },
text_channels: { label: 'Text Channels', defaultFormat: '{label}: {value}' },
voice_channels: { label: 'Voice Channels', defaultFormat: '{label}: {value}' },
roles: { label: 'Rollen', defaultFormat: '{label}: {value}' }
};
export class StatsService {
private client: Client | null = null;
private interval?: NodeJS.Timeout;
private lastRun = new Map<string, number>();
private syncLocks = new Map<string, Promise<void>>();
public setClient(client: Client) {
this.client = client;
}
public stop() {
if (this.interval) clearInterval(this.interval);
this.interval = undefined;
}
public startScheduler() {
this.stop();
this.interval = setInterval(() => this.tick(), 60 * 1000);
}
public async getConfig(guildId: string): Promise<ServerStatsConfig> {
const cfg = settingsStore.get(guildId);
const statsCfg = (cfg as any)?.serverStatsConfig || {};
const enabled = (cfg as any)?.serverStatsEnabled ?? statsCfg.enabled ?? false;
return this.normalizeConfig({ ...statsCfg, enabled });
}
public async saveConfig(guildId: string, config: Partial<ServerStatsConfig>) {
return this.withGuildLock(guildId, async () => {
const previous = await this.getConfig(guildId);
const normalized = this.normalizeConfig({ ...previous, ...config });
const synced = await this.syncGuild(guildId, normalized, previous);
await settingsStore.set(guildId, { serverStatsEnabled: synced.enabled, serverStatsConfig: synced } as any);
this.lastRun.set(guildId, Date.now());
return synced;
});
}
public async refreshGuild(guildId: string) {
return this.withGuildLock(guildId, async () => {
const cfg = await this.getConfig(guildId);
if (!cfg.enabled) return cfg;
const synced = await this.syncGuild(guildId, cfg, cfg);
await settingsStore.set(guildId, { serverStatsEnabled: synced.enabled, serverStatsConfig: synced } as any);
this.lastRun.set(guildId, Date.now());
return synced;
});
}
public async disableGuild(guildId: string) {
await settingsStore.set(guildId, { serverStatsEnabled: false } as any);
}
private normalizeConfig(config: Partial<ServerStatsConfig>): ServerStatsConfig {
const fallbackItems = Array.isArray(config.items) ? config.items : [];
const items = fallbackItems
.filter((i) => i && (i as any).type && STAT_META[(i as any).type as StatCounterType])
.slice(0, 8)
.map((i) => {
const type = (i as any).type as StatCounterType;
const meta = STAT_META[type];
return {
id: i.id || randomUUID(),
type,
label: i.label || meta.label,
format: i.format || meta.defaultFormat,
channelId: i.channelId
} as StatCounter;
});
const refreshMinutes = Number.isFinite(config.refreshMinutes) ? Number(config.refreshMinutes) : 10;
return {
enabled: !!config.enabled,
categoryId: config.categoryId || undefined,
categoryName: config.categoryName || '📊 Server Stats',
refreshMinutes: Math.max(1, Math.min(180, refreshMinutes)),
cleanupOrphans: config.cleanupOrphans ?? false,
items: items.length ? items : [this.defaultItem()]
};
}
private defaultItem(): StatCounter {
return {
id: randomUUID(),
type: 'members_total',
label: STAT_META['members_total'].label,
format: STAT_META['members_total'].defaultFormat
};
}
private formatName(item: StatCounter, value: number) {
const meta = STAT_META[item.type];
const label = item.label || meta.label;
const base = (item.format || meta.defaultFormat || '{label}: {value}')
.replace('{label}', label)
.replace('{value}', value.toLocaleString('de-DE'));
return base.slice(0, 96);
}
private async syncGuild(guildId: string, cfg: ServerStatsConfig, previous?: ServerStatsConfig): Promise<ServerStatsConfig> {
if (!this.client) return cfg;
const guild = await this.client.guilds.fetch(guildId).catch(() => null);
if (!guild) return cfg;
const category = await this.ensureCategory(guild, cfg);
const managedIds = new Set<string>();
for (const item of cfg.items) {
const value = this.computeValue(guild, item.type);
const desiredName = this.formatName(item, value);
let channel =
(item.channelId && (await guild.channels.fetch(item.channelId).catch(() => null))) ||
null;
if (!channel) {
channel = await guild.channels
.create({
name: desiredName,
type: ChannelType.GuildVoice,
parent: category?.id,
permissionOverwrites: [{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.Connect] }],
userLimit: 0,
bitrate: 8000
})
.catch((err) => {
logger.warn(`Failed to create stats channel in ${guild.id}: ${err?.message || err}`);
return null;
});
if (channel) item.channelId = channel.id;
} else if (channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildStageVoice) {
const needsParent = category && channel.parentId !== category.id;
const overwritesMissing = !channel.permissionOverwrites.cache.some(
(ow) => ow.id === guild.roles.everyone.id && ow.deny.has(PermissionFlagsBits.Connect)
);
const editData: any = { name: desiredName };
if (needsParent) editData.parent = category.id;
if (overwritesMissing) editData.permissionOverwrites = [{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.Connect] }];
if ((channel as any).userLimit !== 0) editData.userLimit = 0;
await channel.edit(editData).catch(() => undefined);
}
if (channel?.id) managedIds.add(channel.id);
}
if (cfg.cleanupOrphans && previous?.items) {
for (const old of previous.items) {
if (old.channelId && !managedIds.has(old.channelId)) {
const ch = await guild.channels.fetch(old.channelId).catch(() => null);
if (ch && ch.parentId === category?.id) {
await ch.delete('Papo Server Stats entfernt').catch(() => undefined);
}
}
}
}
return { ...cfg, categoryId: category?.id };
}
private async ensureCategory(guild: Guild, cfg: ServerStatsConfig) {
if (cfg.categoryId) {
const existing = await guild.channels.fetch(cfg.categoryId).catch(() => null);
if (existing && existing.type === ChannelType.GuildCategory) return existing as CategoryChannel;
}
const name = cfg.categoryName || '📊 Server Stats';
const found = guild.channels.cache.find(
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase() === name.toLowerCase()
) as CategoryChannel | undefined;
if (found) return found;
const created = await guild.channels
.create({
name,
type: ChannelType.GuildCategory,
permissionOverwrites: [{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.Connect] }]
})
.catch(() => null);
return created as CategoryChannel | null;
}
private computeValue(guild: Guild, type: StatCounterType): number {
switch (type) {
case 'members_total':
return guild.memberCount ?? 0;
case 'members_humans': {
const humans = guild.members.cache.filter((m) => !m.user.bot).size;
return humans || Math.max(0, (guild.memberCount ?? 0) - guild.members.cache.filter((m) => m.user.bot).size);
}
case 'members_bots':
return guild.members.cache.filter((m) => m.user.bot).size;
case 'boosts':
return guild.premiumSubscriptionCount ?? 0;
case 'text_channels':
return guild.channels.cache.filter((c) => c.isTextBased() && c.type !== ChannelType.GuildVoice && c.type !== ChannelType.GuildStageVoice).size;
case 'voice_channels':
return guild.channels.cache.filter((c) => c.isVoiceBased()).size;
case 'roles':
return guild.roles.cache.size;
default:
return guild.memberCount ?? 0;
}
}
private async tick() {
const now = Date.now();
for (const guildId of settingsStore.all().keys()) {
const cfg = await this.getConfig(guildId);
if (!cfg.enabled) continue;
const last = this.lastRun.get(guildId) || 0;
const intervalMs = Math.max(1, cfg.refreshMinutes) * 60 * 1000;
if (now - last < intervalMs) continue;
await this.refreshGuild(guildId).catch(() => undefined);
}
}
private async withGuildLock<T>(guildId: string, task: () => Promise<T>): Promise<T> {
const waitFor = this.syncLocks.get(guildId) || Promise.resolve();
const run = (async () => {
await waitFor.catch(() => undefined);
return task();
})();
this.syncLocks.set(guildId, run.then(() => undefined, () => undefined));
try {
return await run;
} finally {
const current = this.syncLocks.get(guildId);
if (current === run) this.syncLocks.delete(guildId);
}
}
}

View File

@@ -85,8 +85,7 @@ router.get('/guild/info', requireAuth, async (req, res) => {
dynamicVoiceEnabled: modules.dynamicVoiceEnabled !== false,
statuspageEnabled: (modules as any).statuspageEnabled !== false,
birthdayEnabled: (modules as any).birthdayEnabled !== false,
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false,
serverStatsEnabled: (modules as any).serverStatsEnabled === true || (modules as any).serverStatsConfig?.enabled === true
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false
}
}
});
@@ -755,27 +754,6 @@ router.delete('/statuspage/service/:id', requireAuth, async (req, res) => {
res.json({ ok: true });
});
router.get('/server-stats', 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.stats.getConfig(guildId);
res.json({ config: cfg });
});
router.post('/server-stats', 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 cfg = await context.stats.saveConfig(guildId, req.body.config || {});
res.json({ config: cfg });
});
router.post('/server-stats/refresh', 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.stats.refreshGuild(guildId);
res.json({ ok: true });
});
router.post('/settings', requireAuth, async (req, res) => {
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
const {
@@ -801,9 +779,7 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled,
reactionRolesConfig,
registerEnabled,
registerConfig,
serverStatsEnabled,
serverStatsConfig
registerConfig
} = req.body;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const normalizeArray = (val: any) =>
@@ -937,9 +913,7 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled: parsedReactionRoles.enabled,
reactionRolesConfig: parsedReactionRoles,
registerEnabled: parsedRegister.enabled,
registerConfig: parsedRegister,
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
serverStatsConfig: serverStatsConfig
registerConfig: parsedRegister
});
// Live update logging target
context.logging = new LoggingService(updated.logChannelId);

View File

@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
const router = Router();
@@ -46,18 +46,17 @@ router.get('/', (req, res) => {
<a class="active" href="#overview" data-target="overview"><span class="icon">🏠</span> Uebersicht</a>
<a href="#tickets" data-target="tickets"><span class="icon">🎫</span> Ticketsystem</a>
<a href="#automod" data-target="automod" class="automod-link"><span class="icon">🛡️</span> Automod</a>
<a href="#welcome" data-target="welcome" class="welcome-link"><span class="icon">👋</span> Willkommen</a>
<a href="#dynamicvoice" data-target="dynamicvoice" class="dynamicvoice-link"><span class="icon">🎙️</span> Dynamic Voice</a>
<a href="#welcome" data-target="welcome" class="welcome-link"><span class="icon"></span> Willkommen</a>
<a href="#dynamicvoice" data-target="dynamicvoice" class="dynamicvoice-link"><span class="icon">🎧</span> Dynamic Voice</a>
<a href="#birthday" data-target="birthday" class="birthday-link"><span class="icon">🎂</span> Birthday</a>
<a href="#reactionroles" data-target="reactionroles" class="reactionroles-link"><span class="icon">🎭</span> Reaction Roles</a>
<a href="#statuspage" data-target="statuspage" class="statuspage-link"><span class="icon">📡</span> Statuspage</a>
<a href="#serverstats" data-target="serverstats" class="serverstats-link"><span class="icon">📈</span> Server Stats</a>
<a href="#reactionroles" data-target="reactionroles" class="reactionroles-link"><span class="icon">😎</span> Reaction Roles</a>
<a href="#statuspage" data-target="statuspage" class="statuspage-link"><span class="icon">🖥️</span> Statuspage</a>
<a href="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</a>
<a href="#modules" data-target="modules"><span class="icon">🧩</span> Module</a>
<a href="#events" data-target="events" class="events-link"><span class="icon">📅</span> Events</a>
<a href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛠️</span> Admin</a>
<a href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛡</span> Admin</a>
</div>
<div class="muted">Angemeldet als <span id="userInfo"></span></div>
<div class="muted">Angemeldet als <span id="userInfo"></span></div>
<button id="logoutBtn" class="logout">Logout</button>
</aside>
`;
@@ -215,6 +214,11 @@ router.get('/', (req, res) => {
.module-meta { display:flex; flex-direction:column; gap:4px; }
.module-title { font-weight:700; color:var(--text); }
.module-desc { color:var(--muted); font-size:13px; }
.register-tabs { display:flex; gap:8px; flex-wrap:wrap; margin-top:6px; }
.register-tab { display:none; }
.register-tab.active { display:block; }
.register-meta { display:flex; gap:8px; flex-wrap:wrap; color:var(--muted); font-size:12px; }
.register-answer { padding:10px 12px; border-radius:12px; border:1px solid rgba(255,255,255,0.08); background:rgba(255,255,255,0.04); }
.toast { position:fixed; top:20px; right:24px; padding:12px 14px; border-radius:12px; background:rgba(249,115,22,0.18); border:1px solid rgba(249,115,22,0.45); color:#ffe6d0; font-weight:700; box-shadow:0 12px 32px rgba(0,0,0,0.34); opacity:0; transform:translateY(-10px); transition:opacity 150ms ease, transform 150ms ease; z-index:1100; backdrop-filter:blur(10px); }
.toast.error { background:rgba(239,68,68,0.18); border-color:rgba(239,68,68,0.4); color:#ffe4e6; }
.toast.show { opacity:1; transform:translateY(0); }
@@ -331,10 +335,10 @@ router.get('/', (req, res) => {
<div class="card" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;">
<div>
<p class="section-title">Tickets</p>
<p class="section-sub">bersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
<p class="section-sub"><EFBFBD>bersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
</div>
<div class="row" style="gap:8px; flex-wrap:wrap;">
<button class="secondary-btn ticket-tab-btn active" data-tab="overview">bersicht</button>
<button class="secondary-btn ticket-tab-btn active" data-tab="overview"><EFBFBD>bersicht</button>
<button class="secondary-btn ticket-tab-btn" data-tab="pipeline">Pipeline</button>
<button class="secondary-btn ticket-tab-btn" data-tab="sla">SLA</button>
<button class="secondary-btn ticket-tab-btn" data-tab="automations">Automationen</button>
@@ -347,7 +351,7 @@ router.get('/', (req, res) => {
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div>
<p class="section-title">Ticketliste</p>
<p class="section-sub">Links auswhlen, Details im Modal. Plus ffnet Panel-Erstellung.</p>
<p class="section-sub">Links ausw<EFBFBD>hlen, Details im Modal. Plus <EFBFBD>ffnet Panel-Erstellung.</p>
</div>
<div class="row" style="gap:10px;">
<button class="secondary-btn" id="openSupportLogin" type="button">Support-Login Einstellungen</button>
@@ -382,7 +386,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
<div>
<p class="section-title">Status-Pipeline</p>
<p class="section-sub">Tickets nach Phase. Status per Dropdown ndern.</p>
<p class="section-sub">Tickets nach Phase. Status per Dropdown <EFBFBD>ndern.</p>
</div>
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;" id="pipelineGrid">
@@ -431,7 +435,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Automationen</p>
<p class="section-sub">Regeln fr Ticket-Aktionen.</p>
<p class="section-sub">Regeln f<EFBFBD>r Ticket-Aktionen.</p>
</div>
<button class="secondary-btn" id="addAutomation">Neue Regel</button>
</div>
@@ -468,7 +472,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Knowledge-Base</p>
<p class="section-sub">Artikel fr Self-Service.</p>
<p class="section-sub">Artikel f<EFBFBD>r Self-Service.</p>
</div>
<button class="secondary-btn" id="addKb">Neuer Artikel</button>
</div>
@@ -671,6 +675,79 @@ router.get('/', (req, res) => {
<div id="moduleList" class="module-list"></div>
</section>
</div>
<div class="section" data-section="register">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Register Modul</p>
<p class="section-sub">Formulare verwalten und Antraege einsehen.</p>
</div>
<span class="badge" id="registerStatus">Aktiv</span>
</div>
<div class="register-tabs">
<button class="ticket-tab-btn register-tab-btn active" data-tab="forms">Formulare</button>
<button class="ticket-tab-btn register-tab-btn" data-tab="apps">Registrierungen</button>
</div>
</section>
<div class="register-tab ticket-tab active" data-tab="forms">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Formulare</p>
<p class="section-sub">Formulare anlegen, bearbeiten und Panels senden.</p>
</div>
<div class="row" style="gap:8px; flex-wrap:wrap;">
<button class="secondary-btn" id="registerFormsReload" type="button">Reload</button>
<button class="secondary-btn" id="registerFormNew" type="button">Neues Formular</button>
</div>
</div>
<div id="registerFormList" class="module-list" style="margin-top:12px;"></div>
</section>
<section class="card">
<form id="registerFormForm" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:12px;">
<input type="hidden" id="registerFormId" />
<div class="form-field"><label class="form-label">Name</label><input id="registerFormName" placeholder="Bewerbung" /></div>
<div class="form-field"><label class="form-label">Review Channel ID</label><input id="registerFormChannel" placeholder="123456789012345678" /></div>
<div class="form-field"><label class="form-label">Notify Rollen (kommagetrennt)</label><input id="registerFormRoles" placeholder="123,456" /></div>
<div class="form-field"><label class="form-label">Aktiv</label><div id="registerFormActive" class="switch on"></div></div>
<div class="form-field" style="grid-column:1/-1;"><label class="form-label">Beschreibung</label><textarea id="registerFormDescription" rows="2" placeholder="Kurze Beschreibung"></textarea></div>
<div class="form-field" style="grid-column:1/-1;"><label class="form-label">Felder (Label | Typ | Pflicht)</label><textarea id="registerFormFields" rows="4" placeholder="Name | shortText | true&#10;Begruendung | longText | true"></textarea><p class="muted">Typ: shortText oder longText. Eine Zeile pro Feld.</p></div>
<div class="form-field" style="grid-column:1/-1; display:flex; gap:8px;">
<button type="submit">Speichern</button>
<button type="button" class="secondary-btn" id="registerFormReset">Reset</button>
</div>
</form>
<p class="muted" id="registerFormStatus"></p>
</section>
</div>
<div class="register-tab ticket-tab" data-tab="apps">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Registrierungen</p>
<p class="section-sub">Antraege filtern und einsehen.</p>
</div>
<div class="row" style="gap:8px; flex-wrap:wrap; align-items:center;">
<select id="registerAppsFilter" class="muted" style="min-width:140px;">
<option value="">Alle Status</option>
<option value="pending">Pending</option>
<option value="accepted">Accepted</option>
<option value="invited">Invited</option>
<option value="rejected">Rejected</option>
</select>
<select id="registerAppsFormFilter" class="muted" style="min-width:160px;">
<option value="">Alle Formulare</option>
</select>
<button class="secondary-btn" id="registerAppsReload" type="button">Reload</button>
</div>
</div>
<div id="registerAppsList" class="ticket-list-pane"></div>
</section>
<section class="card">
<div id="registerAppDetail" class="muted">Waehle einen Antrag aus der Liste.</div>
</section>
</div>
</div>
<div class="section" data-section="birthday">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:flex-start; gap:12px;">
@@ -783,33 +860,6 @@ router.get('/', (req, res) => {
<div id="statuspageServices" class="module-list"></div>
</section>
</div>
<div class="section" data-section="serverstats">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
<div>
<p class="section-title">Server Stats</p>
<p class="section-sub">Kategorie und Counter verwalten.</p>
</div>
<div class="row" style="gap:8px; align-items:center;">
<label class="form-label">Kategorie-Name</label>
<input id="statsCategoryName" placeholder="Server Stats" />
<label class="form-label">Refresh (Min)</label>
<input id="statsRefresh" type="number" min="1" step="1" placeholder="10" />
<div id="statsToggle" class="switch"></div>
</div>
</div>
</section>
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center;">
<div>
<p class="section-title">Statistiken</p>
<p class="section-sub">Counter und Format anpassen.</p>
</div>
<button class="icon-button" id="statsAddItem">+</button>
</div>
<div id="statsItems" class="module-list"></div>
</section>
</div>
<div class="section" data-section="events">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center;">
@@ -1231,7 +1281,6 @@ router.get('/', (req, res) => {
let activeModal = null;
let automodConfigCache = {};
let modulesCache = {};
let serverStatsCache = { items: [] };
let dynamicVoiceCache = {};
let isAdmin = false;
let statuspageCache = { services: [] };
@@ -1240,6 +1289,10 @@ router.get('/', (req, res) => {
let editingReactionRole = null;
let supportLoginCache = {};
let eventsCache = [];
let registerFormsCache = [];
let registerAppsCache = [];
let registerSelectedApp = null;
let registerConfigCache = {};
function activateSection(key) {
sections.forEach((s) => s.classList.toggle('active', s.dataset.section === key));
@@ -1269,23 +1322,27 @@ router.get('/', (req, res) => {
const welcomeEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'welcomeEnabled') ? modulesCache['welcomeEnabled'] : true;
const dynamicVoiceEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'dynamicVoiceEnabled') ? modulesCache['dynamicVoiceEnabled'] : true;
const statuspageEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'statuspageEnabled') ? modulesCache['statuspageEnabled'] : true;
const serverStatsEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'serverStatsEnabled') ? modulesCache['serverStatsEnabled'] : false;
const birthdayEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'birthdayEnabled') ? modulesCache['birthdayEnabled'] : true;
const reactionRolesEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'reactionRolesEnabled') ? modulesCache['reactionRolesEnabled'] : true;
const eventsEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'eventsEnabled') ? modulesCache['eventsEnabled'] : true;
const registerEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'registerEnabled') ? modulesCache['registerEnabled'] : true;
if (automodNav) automodNav.classList.toggle('hidden', !autoEnabled);
if (welcomeNav) welcomeNav.classList.toggle('hidden', !welcomeEnabled);
if (dynamicVoiceNav) dynamicVoiceNav.classList.toggle('hidden', !dynamicVoiceEnabled);
const statuspageNav = document.querySelector('.nav .statuspage-link');
if (statuspageNav) statuspageNav.classList.toggle('hidden', !statuspageEnabled);
const serverstatsNav = document.querySelector('.nav .serverstats-link');
if (serverstatsNav) serverstatsNav.classList.toggle('hidden', !serverStatsEnabled);
const birthdayNav = document.querySelector('.nav .birthday-link');
if (birthdayNav) birthdayNav.classList.toggle('hidden', !birthdayEnabled);
const reactionRolesNav = document.querySelector('.nav .reactionroles-link');
if (reactionRolesNav) reactionRolesNav.classList.toggle('hidden', !reactionRolesEnabled);
const eventsNav = document.querySelector('.nav .events-link');
if (eventsNav) eventsNav.classList.toggle('hidden', !eventsEnabled);
if (registerNav) registerNav.classList.toggle('hidden', !registerEnabled);
if (registerSection) registerSection.classList.toggle('hidden', !registerEnabled);
if (registerStatus) {
registerStatus.textContent = registerEnabled ? 'Aktiv' : 'Deaktiviert';
registerStatus.className = 'badge' + (registerEnabled ? ' active' : '');
}
const adminNav = document.querySelector('.nav .admin-link');
if (adminNav) adminNav.classList.toggle('hidden', !isAdmin);
const current = location.hash.replace('#','') || 'overview';
@@ -1294,10 +1351,10 @@ router.get('/', (req, res) => {
(current === 'welcome' && !welcomeEnabled) ||
(current === 'dynamicvoice' && !dynamicVoiceEnabled) ||
(current === 'statuspage' && !statuspageEnabled) ||
(current === 'serverstats' && !serverStatsEnabled) ||
(current === 'birthday' && !birthdayEnabled) ||
(current === 'reactionroles' && !reactionRolesEnabled) ||
(current === 'events' && !eventsEnabled) ||
(current === 'register' && !registerEnabled) ||
(current === 'admin' && !isAdmin)
) {
activateSection('overview');
@@ -1405,103 +1462,6 @@ router.get('/', (req, res) => {
if (!logs.length) guildLogs.innerHTML = '<li class="muted">Keine Logs</li>';
}
const STAT_LABELS = {
members_total: 'Mitglieder (gesamt)',
members_humans: 'Mitglieder (ohne Bots)',
members_bots: 'Bots',
boosts: 'Server Boosts',
text_channels: 'Text Channels',
voice_channels: 'Voice Channels',
roles: 'Rollen'
};
async function loadServerStats() {
if (!currentGuild) return;
const res = await fetch('/api/server-stats?guildId=' + encodeURIComponent(currentGuild));
if (!res.ok) return;
const data = await res.json();
serverStatsCache = data.config || { items: [] };
setSwitch(statsToggle, serverStatsCache.enabled !== false);
if (statsCategoryName) statsCategoryName.value = serverStatsCache.categoryName || 'Server Stats';
if (statsRefresh) statsRefresh.value = serverStatsCache.refreshMinutes ?? 10;
renderServerStats();
}
function renderServerStats() {
if (!statsItems) return;
statsItems.innerHTML = '';
(serverStatsCache.items || []).forEach((item) => {
const row = document.createElement('div');
row.className = 'module-item';
const meta = document.createElement('div');
meta.className = 'module-meta';
const label = STAT_LABELS[item.type] || item.type;
meta.innerHTML =
'<div class="module-title">' + label + '</div><div class="module-desc">' + (item.label || label) + ' - ' + (item.format || '{label}: {value}') + '</div>';
const buttons = document.createElement('div');
buttons.className = 'row';
const edit = document.createElement('button');
edit.className = 'secondary-btn';
edit.textContent = 'Bearbeiten';
edit.addEventListener('click', () => editServerStat(item));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'Loeschen';
del.addEventListener('click', () => {
serverStatsCache.items = (serverStatsCache.items || []).filter((x) => x !== item);
renderServerStats();
saveServerStats();
});
buttons.appendChild(edit);
buttons.appendChild(del);
row.appendChild(meta);
row.appendChild(buttons);
statsItems.appendChild(row);
});
if (!(serverStatsCache.items || []).length) statsItems.innerHTML = '<div class="muted">Keine Statistiken</div>';
}
function editServerStat(item) {
const typeKeys = Object.keys(STAT_LABELS);
const nextType = prompt('Typ (' + typeKeys.join(', ') + ')', item?.type || 'members_total');
if (!nextType || !STAT_LABELS[nextType]) return;
const nextLabel = prompt('Label', item?.label || STAT_LABELS[nextType]) || STAT_LABELS[nextType];
const nextFormat = prompt('Format ({label} / {value})', item?.format || '{label}: {value}') || '{label}: {value}';
if (item) {
item.type = nextType;
item.label = nextLabel;
item.format = nextFormat;
} else {
(serverStatsCache.items = serverStatsCache.items || []).push({
id: (crypto.randomUUID && crypto.randomUUID()) || String(Date.now()),
type: nextType,
label: nextLabel,
format: nextFormat
});
}
renderServerStats();
saveServerStats();
}
async function saveServerStats() {
if (!currentGuild) return;
const payload = {
guildId: currentGuild,
config: {
enabled: getSwitch(statsToggle),
categoryName: statsCategoryName?.value || undefined,
refreshMinutes: statsRefresh?.value ? Number(statsRefresh.value) : undefined,
items: serverStatsCache.items || []
}
};
const res = await fetch('/api/server-stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) showToast('Server Stats speichern fehlgeschlagen', true);
}
async function loadStatuspage() {
if (!currentGuild) return;
const res = await fetch('/api/statuspage?guildId=' + encodeURIComponent(currentGuild));
@@ -1885,7 +1845,7 @@ router.get('/', (req, res) => {
'</div>' +
'<div class=\"ticket-meta\">User: ' +
(t.userId || '-') +
(t.claimedBy ? ' Supporter: ' + t.claimedBy : '') +
(t.claimedBy ? ' <EFBFBD> Supporter: ' + t.claimedBy : '') +
'</div>';
const select = document.createElement('select');
select.innerHTML =
@@ -1999,14 +1959,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillAutomationForm(r));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'Lschen';
del.textContent = 'L<EFBFBD>schen';
del.addEventListener('click', async () => {
const res = await fetch('/api/automations/' + r.id, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild })
});
showToast(res.ok ? 'Regel gelscht' : 'Lschen fehlgeschlagen', !res.ok);
showToast(res.ok ? 'Regel gel<EFBFBD>scht' : 'L<EFBFBD>schen fehlgeschlagen', !res.ok);
if (res.ok) loadAutomations();
});
actions.appendChild(edit);
@@ -2066,14 +2026,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillKbForm(a));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'Lschen';
del.textContent = 'L<EFBFBD>schen';
del.addEventListener('click', async () => {
const res = await fetch('/api/kb/' + a.id, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild })
});
showToast(res.ok ? 'Artikel gelscht' : 'Lschen fehlgeschlagen', !res.ok);
showToast(res.ok ? 'Artikel gel<EFBFBD>scht' : 'L<EFBFBD>schen fehlgeschlagen', !res.ok);
if (res.ok) loadKb();
});
actions.appendChild(edit);
@@ -2121,7 +2081,6 @@ router.get('/', (req, res) => {
modulesCache['welcomeEnabled'] = (cfg.welcomeConfig?.enabled ?? cfg.automodConfig?.welcomeConfig?.enabled ?? true) !== false;
modulesCache['dynamicVoiceEnabled'] = cfg.dynamicVoiceEnabled !== false;
modulesCache['statuspageEnabled'] = cfg.statuspageEnabled !== false && cfg.automodConfig?.statuspageEnabled !== false;
modulesCache['serverStatsEnabled'] = cfg.serverStatsEnabled === true || cfg.serverStatsConfig?.enabled === true;
modulesCache['birthdayEnabled'] = cfg.birthdayEnabled !== false && cfg.birthdayConfig?.enabled !== false;
modulesCache['reactionRolesEnabled'] = cfg.reactionRolesEnabled !== false && cfg.reactionRolesConfig?.enabled !== false;
modulesCache['eventsEnabled'] = cfg.eventsEnabled !== false;
@@ -2520,7 +2479,7 @@ router.get('/', (req, res) => {
meta.className = 'module-meta';
const descParts = ['Channel: ' + (set.channelId || '-')];
if (set.messageId) descParts.push('Message: ' + set.messageId);
meta.innerHTML = '<div class="module-title">' + (set.title || 'Reaction Role') + '</div><div class="module-desc">' + descParts.join(' ') + '</div>';
meta.innerHTML = '<div class="module-title">' + (set.title || 'Reaction Role') + '</div><div class="module-desc">' + descParts.join(' - ') + '</div>';
const actions = document.createElement('div');
actions.className = 'row';
const editBtn = document.createElement('button');
@@ -2576,6 +2535,262 @@ router.get('/', (req, res) => {
}
}
function parseRegisterFields(raw) {
return (raw || '')
.split('\n')
.map((l, idx) => {
const parts = l.split('|').map((p) => p.trim());
const label = parts[0];
if (!label) return null;
const typeRaw = (parts[1] || 'shortText').toLowerCase();
const type = typeRaw.includes('long') ? 'longText' : 'shortText';
const requiredRaw = (parts[2] || '').toLowerCase();
const required = ['true', '1', 'yes', 'ja'].includes(requiredRaw);
return { label, type, required, order: idx };
})
.filter(Boolean);
}
function formatRegisterFields(fields) {
return (fields || [])
.slice()
.sort((a, b) => (a.sortOrder ?? a.order ?? 0) - (b.sortOrder ?? b.order ?? 0))
.map((f) => [f.label || 'Feld', f.type || 'shortText', f.required ? 'true' : 'false'].join(' | '))
.join('\n');
}
function setRegisterFormDefaults() {
if (registerFormId) registerFormId.value = '';
if (registerFormName) registerFormName.value = '';
if (registerFormDescription) registerFormDescription.value = '';
if (registerFormChannel) registerFormChannel.value = registerConfigCache.reviewChannelId || '';
if (registerFormRoles) registerFormRoles.value = (registerConfigCache.notifyRoleIds || []).join(', ');
if (registerFormFields) registerFormFields.value = '';
setSwitch(registerFormActive, true);
if (registerFormStatus) registerFormStatus.textContent = '';
}
function fillRegisterForm(form) {
if (!form) return;
if (registerFormId) registerFormId.value = form.id || '';
if (registerFormName) registerFormName.value = form.name || '';
if (registerFormDescription) registerFormDescription.value = form.description || '';
if (registerFormChannel) registerFormChannel.value = form.reviewChannelId || registerConfigCache.reviewChannelId || '';
if (registerFormRoles) registerFormRoles.value = (form.notifyRoleIds || []).join(', ');
setSwitch(registerFormActive, form.isActive !== false);
if (registerFormFields) registerFormFields.value = formatRegisterFields(form.fields || []);
if (registerFormStatus) registerFormStatus.textContent = 'Bearbeitung aktiv';
}
function clearRegisterUi() {
if (registerFormList) registerFormList.innerHTML = '<div class="muted">Modul deaktiviert.</div>';
if (registerAppsList) registerAppsList.innerHTML = '<div class="muted">Modul deaktiviert.</div>';
if (registerAppDetail) registerAppDetail.innerHTML = '<div class="muted">Register deaktiviert.</div>';
setRegisterFormDefaults();
}
async function loadRegisterForms() {
if (!currentGuild) return;
if (modulesCache['registerEnabled'] === false) { clearRegisterUi(); return; }
const res = await fetch('/api/register/forms?guildId=' + encodeURIComponent(currentGuild));
if (!res.ok) return;
const data = await res.json();
registerFormsCache = data.forms || [];
renderRegisterForms();
}
function renderRegisterForms() {
if (!registerFormList) return;
registerFormList.innerHTML = '';
if (!registerFormsCache.length) {
registerFormList.innerHTML = '<div class="muted">Keine Formulare.</div>';
} else {
registerFormsCache.forEach((form) => {
const row = document.createElement('div');
row.className = 'module-item';
const meta = document.createElement('div');
meta.className = 'module-meta';
const descParts = [];
descParts.push('Felder: ' + ((form.fields || []).length));
if (form.reviewChannelId) descParts.push('Review: ' + form.reviewChannelId);
if (form.notifyRoleIds?.length) descParts.push('Notify: ' + form.notifyRoleIds.join(', '));
meta.innerHTML = '<div class="module-title">' + (form.name || 'Formular') + '</div><div class="module-desc">' + descParts.join(' - ') + '</div>';
const actions = document.createElement('div');
actions.className = 'row';
const activeBadge = document.createElement('span');
activeBadge.className = 'badge' + (form.isActive !== false ? ' active' : '');
activeBadge.textContent = form.isActive !== false ? 'Aktiv' : 'Inaktiv';
actions.appendChild(activeBadge);
const editBtn = document.createElement('button');
editBtn.className = 'secondary-btn';
editBtn.style.padding = '8px 10px';
editBtn.style.fontSize = '12px';
editBtn.textContent = 'Bearbeiten';
editBtn.addEventListener('click', () => fillRegisterForm(form));
const panelBtn = document.createElement('button');
panelBtn.className = 'secondary-btn';
panelBtn.style.padding = '8px 10px';
panelBtn.style.fontSize = '12px';
panelBtn.textContent = 'Panel senden';
panelBtn.addEventListener('click', () => sendRegisterPanel(form));
const delBtn = document.createElement('button');
delBtn.className = 'danger-btn';
delBtn.style.padding = '8px 10px';
delBtn.style.fontSize = '12px';
delBtn.textContent = 'Loeschen';
delBtn.addEventListener('click', () => deleteRegisterForm(form.id));
actions.appendChild(editBtn);
actions.appendChild(panelBtn);
actions.appendChild(delBtn);
row.appendChild(meta);
row.appendChild(actions);
registerFormList.appendChild(row);
});
}
populateRegisterFormFilter();
}
async function sendRegisterPanel(form) {
if (!currentGuild || !form?.id) return;
const channelId = prompt('Channel ID fuer Panel', form.reviewChannelId || registerConfigCache.reviewChannelId || '') || '';
if (!channelId.trim()) return;
const message = prompt('Nachricht im Panel (optional)', 'Klicke auf Registrieren, um das Formular zu oeffnen.');
const res = await fetch('/api/register/forms/' + form.id + '/panel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild, channelId, message: message || undefined })
});
showToast(res.ok ? 'Panel gesendet' : 'Panel fehlgeschlagen', !res.ok);
}
async function deleteRegisterForm(id) {
if (!currentGuild || !id) return;
const res = await fetch('/api/register/forms/' + id, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ guildId: currentGuild }) });
showToast(res.ok ? 'Formular geloescht' : 'Loeschen fehlgeschlagen', !res.ok);
if (res.ok) await loadRegisterForms();
}
function parseRegisterRoles(raw) {
return (raw || '')
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
}
function populateRegisterFormFilter() {
if (!registerAppsFormFilter) return;
const current = registerAppsFormFilter.value;
registerAppsFormFilter.innerHTML = '<option value="">Alle Formulare</option>';
registerFormsCache.forEach((f) => {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name || 'Formular';
registerAppsFormFilter.appendChild(opt);
});
if (current) registerAppsFormFilter.value = current;
}
async function saveRegisterForm(e) {
if (e) e.preventDefault();
if (!currentGuild) return;
const fields = parseRegisterFields(registerFormFields?.value || '');
const payload = {
guildId: currentGuild,
name: registerFormName?.value || 'Formular',
description: registerFormDescription?.value || '',
reviewChannelId: registerFormChannel?.value || undefined,
notifyRoleIds: parseRegisterRoles(registerFormRoles?.value || ''),
isActive: getSwitch(registerFormActive),
fields
};
const id = registerFormId?.value;
const url = id ? '/api/register/forms/' + id : '/api/register/forms';
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (registerFormStatus) registerFormStatus.textContent = res.ok ? 'Gespeichert' : 'Fehler';
showToast(res.ok ? 'Formular gespeichert' : 'Speichern fehlgeschlagen', !res.ok);
if (res.ok) {
setRegisterFormDefaults();
await loadRegisterForms();
}
}
async function loadRegisterApps() {
if (!currentGuild) return;
if (modulesCache['registerEnabled'] === false) { clearRegisterUi(); return; }
registerSelectedApp = null;
if (registerAppDetail) registerAppDetail.innerHTML = 'Waehle einen Antrag aus der Liste.';
const qs = new URLSearchParams({ guildId: currentGuild });
if (registerAppsFilter?.value) qs.set('status', registerAppsFilter.value);
if (registerAppsFormFilter?.value) qs.set('formId', registerAppsFormFilter.value);
const res = await fetch('/api/register/apps?' + qs.toString());
if (!res.ok) return;
const data = await res.json();
registerAppsCache = data.applications || [];
renderRegisterApps();
}
function registerStatusClass(status) {
const val = (status || '').toLowerCase();
if (val === 'accepted') return 'status-open';
if (val === 'invited') return 'status-in-progress';
if (val === 'rejected') return 'status-closed';
return 'status-open';
}
function renderRegisterApps() {
if (!registerAppsList) return;
registerAppsList.innerHTML = '';
if (!registerAppsCache.length) {
registerSelectedApp = null;
registerAppsList.innerHTML = '<div class="ticket-empty">Keine Antraege.</div>';
if (registerAppDetail) registerAppDetail.innerHTML = 'Waehle einen Antrag aus der Liste.';
return;
}
registerAppsCache.forEach((app) => {
const row = document.createElement('div');
row.className = 'ticket-list-item';
row.innerHTML = '<div class="ticket-item-top"><div class="ticket-title">' + (app.form?.name || 'Formular') + '</div><span class="ticket-status-badge ' + registerStatusClass(app.status) + '">' + (app.status || 'pending') + '</span></div>' +
'<div class="ticket-meta">User: ' + (app.userId || '-') + ' - Erstellt: ' + formatDate(app.createdAt) + '</div>';
row.addEventListener('click', () => loadRegisterApplication(app.id));
registerAppsList.appendChild(row);
});
}
async function loadRegisterApplication(id) {
if (!id) return;
const res = await fetch('/api/register/apps/' + id);
if (!res.ok) return;
const data = await res.json();
registerSelectedApp = data.application || null;
renderRegisterDetail();
}
function renderRegisterDetail() {
if (!registerAppDetail) return;
if (!registerSelectedApp) {
registerAppDetail.innerHTML = '<div class="muted">Waehle einen Antrag.</div>';
return;
}
const app = registerSelectedApp;
const formFields = registerFormsCache.find((f) => f.id === app.formId)?.fields || [];
const answers = app.answers || [];
const answersHtml = answers
.map((a) => {
const field = formFields.find((f) => f.id === a.fieldId);
const label = field?.label || 'Feld';
const value = (a.value || '').replace(/</g, '&lt;');
return '<div class="register-answer"><div class="form-label">' + label + '</div><div class="module-desc">' + value + '</div></div>';
})
.join('');
registerAppDetail.innerHTML =
'<div class="row" style="justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">' +
'<div><p class="section-title">' + (app.form?.name || 'Formular') + '</p><p class="section-sub">User: ' + (app.userId || '-') + '</p></div>' +
'<span class="ticket-status-badge ' + registerStatusClass(app.status) + '">' + (app.status || 'pending') + '</span>' +
'</div>' +
'<div class="module-list" style="margin-top:12px;">' + (answersHtml || '<div class="muted">Keine Antworten vorhanden.</div>') + '</div>';
}
// TODO: MODULE: Liste um Musik/Forum/Automod-Konfiguration ergaenzen.
// - Module-Status inkl. Direktlinks zu Detailseiten (Automod/Welcome/Musik) rendern.
// - Module-Flags aus BotModuleService spiegeln statt doppeltem Fetch.
@@ -2588,10 +2803,10 @@ router.get('/', (req, res) => {
list.innerHTML = '';
let ticketsActive = false;
let statuspageActive = false;
let serverStatsActive = false;
let birthdayActive = false;
let reactionRolesActive = false;
let eventsActive = false;
let registerActive = false;
(data.modules || []).forEach((m) => {
modulesCache[m.key] = !!m.enabled;
const row = document.createElement('div');
@@ -2610,18 +2825,20 @@ router.get('/', (req, res) => {
showToast(willEnable ? m.name + ' aktiviert' : m.name + ' deaktiviert');
modulesCache[m.key] = willEnable;
if (m.key === 'ticketsEnabled') applyTicketsVisibility(willEnable);
if (m.key === 'automodEnabled') modulesCache['automodEnabled'] = willEnable;
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = willEnable;
if (m.key === 'statuspageEnabled') modulesCache['statuspageEnabled'] = willEnable;
if (m.key === 'serverStatsEnabled') modulesCache['serverStatsEnabled'] = willEnable;
if (m.key === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable;
if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable;
if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable;
applyNavVisibility();
} else {
showToast('Speichern fehlgeschlagen', true);
}
});
if (m.key === 'automodEnabled') modulesCache['automodEnabled'] = willEnable;
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = willEnable;
if (m.key === 'statuspageEnabled') modulesCache['statuspageEnabled'] = willEnable;
if (m.key === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable;
if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable;
if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable;
if (m.key === 'registerEnabled') modulesCache['registerEnabled'] = willEnable;
applyNavVisibility();
if (m.key === 'registerEnabled' && willEnable) { await loadRegisterForms(); await loadRegisterApps(); }
if (m.key === 'registerEnabled' && !willEnable) { clearRegisterUi(); }
} else {
showToast('Speichern fehlgeschlagen', true);
}
});
row.appendChild(meta);
row.appendChild(toggle);
list.appendChild(row);
@@ -2630,24 +2847,24 @@ router.get('/', (req, res) => {
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = !!m.enabled;
if (m.key === 'dynamicVoiceEnabled') modulesCache['dynamicVoiceEnabled'] = !!m.enabled;
if (m.key === 'statuspageEnabled') statuspageActive = !!m.enabled;
if (m.key === 'serverStatsEnabled') serverStatsActive = !!m.enabled;
if (m.key === 'birthdayEnabled') birthdayActive = !!m.enabled;
if (m.key === 'reactionRolesEnabled') reactionRolesActive = !!m.enabled;
if (m.key === 'eventsEnabled') eventsActive = !!m.enabled;
if (m.key === 'registerEnabled') registerActive = !!m.enabled;
});
applyNavVisibility();
applyTicketsVisibility(ticketsActive);
if (statuspageActive) loadStatuspage();
if (serverStatsActive) loadServerStats();
if (birthdayActive) loadBirthday();
if (reactionRolesActive) loadReactionRoles();
if (eventsActive) loadEvents();
if (registerActive) { loadRegisterForms(); loadRegisterApps(); }
}
async function saveModuleToggle(key, enabled) {
if (!currentGuild) return false;
const payload = { guildId: currentGuild };
['ticketsEnabled', 'automodEnabled', 'welcomeEnabled', 'musicEnabled', 'levelingEnabled', 'statuspageEnabled', 'serverStatsEnabled', 'birthdayEnabled', 'reactionRolesEnabled', 'eventsEnabled'].forEach((k) => {
['ticketsEnabled', 'automodEnabled', 'welcomeEnabled', 'musicEnabled', 'levelingEnabled', 'statuspageEnabled', 'birthdayEnabled', 'reactionRolesEnabled', 'eventsEnabled', 'registerEnabled'].forEach((k) => {
if (modulesCache[k] !== undefined) payload[k] = modulesCache[k];
});
payload['dynamicVoiceEnabled'] = modulesCache['dynamicVoiceEnabled'];
@@ -2758,6 +2975,26 @@ router.get('/', (req, res) => {
});
});
const registerTabs = Array.from(document.querySelectorAll('.register-tab'));
document.querySelectorAll('.register-tab-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
document.querySelectorAll('.register-tab-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab || 'forms';
registerTabs.forEach((t) => t.classList.toggle('active', t.dataset.tab === tab));
if (tab === 'forms') await loadRegisterForms();
if (tab === 'apps') await loadRegisterApps();
});
});
if (registerFormForm) registerFormForm.addEventListener('submit', saveRegisterForm);
if (registerFormReset) registerFormReset.addEventListener('click', (e) => { e.preventDefault(); setRegisterFormDefaults(); });
if (registerFormNew) registerFormNew.addEventListener('click', () => setRegisterFormDefaults());
if (registerFormsReload) registerFormsReload.addEventListener('click', loadRegisterForms);
if (registerAppsReload) registerAppsReload.addEventListener('click', loadRegisterApps);
if (registerAppsFilter) registerAppsFilter.addEventListener('change', loadRegisterApps);
if (registerAppsFormFilter) registerAppsFormFilter.addEventListener('change', loadRegisterApps);
const slaRange = document.getElementById('slaRange');
if (slaRange) slaRange.addEventListener('change', loadSla);
@@ -2841,7 +3078,7 @@ router.get('/', (req, res) => {
document.getElementById('logoutBtn').addEventListener('click', () => window.location.href = BASE_AUTH + '/logout');
[automodToggle, badWordToggle, linkFilterToggle, spamFilterToggle, capsFilterToggle, logJoinLeave, logMsgEdit, logMsgDelete, logAutomod, logTickets, logMusic, dynamicVoiceToggle, supportLoginAuto].forEach((el) => {
[automodToggle, badWordToggle, linkFilterToggle, spamFilterToggle, capsFilterToggle, logJoinLeave, logMsgEdit, logMsgDelete, logAutomod, logTickets, logMusic, dynamicVoiceToggle, supportLoginAuto, registerFormActive].forEach((el) => {
if (el) el.addEventListener('click', () => el.classList.toggle('on'));
});
if (logSystem) logSystem.addEventListener('click', () => logSystem.classList.toggle('on'));
@@ -2851,10 +3088,6 @@ router.get('/', (req, res) => {
if (statuspageInterval) statuspageInterval.addEventListener('change', saveStatuspageConfig);
if (statuspageChannel) statuspageChannel.addEventListener('change', saveStatuspageConfig);
if (statuspageAddService) statuspageAddService.addEventListener('click', addServicePrompt);
if (statsToggle) statsToggle.addEventListener('click', async () => { statsToggle.classList.toggle('on'); await saveServerStats(); });
if (statsCategoryName) statsCategoryName.addEventListener('change', saveServerStats);
if (statsRefresh) statsRefresh.addEventListener('change', saveServerStats);
if (statsAddItem) statsAddItem.addEventListener('click', () => editServerStat(null));
[welcomeTitle, welcomeDescription, welcomeFooter, welcomeColor].forEach((el) => {
if (el) el.addEventListener('input', updateWelcomePreview);
});
@@ -3022,3 +3255,23 @@ router.get('/settings', (_req, res) => {
export default router;