Compare commits

..

21 Commits

Author SHA1 Message Date
Pascal Prießnitz
6b95e7fd85 [deploy] Fix Welcome-Embed Footer Validation
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-05 17:06:17 +01:00
Pascal Prießnitz
8c53160812 [deploy] Automod Filter greifen wieder (Links/Badwords)
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 21:47:36 +01:00
Pascal Prießnitz
c18441eb9a [deploy] Automod logging reasons und Modul-Fix
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-04 21:06:33 +01:00
Pascal Prießnitz
c95444feac [deploy] Fix stats duplication and polish help embed
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 18:41:57 +01:00
Pascal Prießnitz
544f04655c [deploy]
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 18:17:43 +01:00
Pascal Prießnitz
85951ecfb4 Add emojis to dashboard sidebar 2025-12-04 18:10:07 +01:00
Pascal Prießnitz
9579dc7510 [deploy] Restore server stats UI and sanitize dashboard encoding
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 18s
2025-12-04 15:41:34 +01:00
Pascal Prießnitz
8127e81564 [deploy] Restore dashboard sections and clean navigation icons
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 14:32:31 +01:00
Pascal Prießnitz
975d0552bf [deploy] Clean automation renderer strings
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 13:15:21 +01:00
Pascal Prießnitz
aefb5b3c72 [deploy] Guard module list rendering when element missing
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 13:00:24 +01:00
Pascal Prießnitz
f4f4efb722 [deploy] Remove leftover TS casts in dashboard settings
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-04 12:58:06 +01:00
Pascal Prießnitz
313b2c0613 [deploy] Guard settings inputs in dashboard loadSettings
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 12:53:38 +01:00
Pascal Prießnitz
71a716e214 [deploy] Define welcome file inputs to avoid runtime reference error
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 12:48:26 +01:00
Pascal Prießnitz
afac2e7c68 [deploy] Remove TS type annotation from dashboard inline script
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-04 12:32:18 +01:00
Pascal Prießnitz
cfc4559312 [deploy] Fix dashboard inline JS syntax error
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 12:22:31 +01:00
Pascal Prießnitz
bebca808b0 [deploy] Harden dashboard event listeners for missing elements
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-04 12:19:26 +01:00
Pascal Prießnitz
78578fcc1c [deploy] Fix dashboard icons and add missing register migration placeholder
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 12:15:16 +01:00
Pascal Prießnitz
5aef575f41 [deploy] Add server stats module with dashboard controls
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 11:37:49 +01:00
Pascal Prießnitz
c66da87207 Improve README structure and quickstart
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 13s
2025-12-03 22:33:21 +01:00
Pascal Prießnitz
962ee4aafc [deploy] Add RegisterForm applications relation
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-03 22:25:59 +01:00
Pascal Prießnitz
27f96092dd [deploy] Fix RegisterFormField order column
Some checks failed
Deploy Discord Bot / deploy (push) Failing after 20s
2025-12-03 22:24:21 +01:00
35 changed files with 9453 additions and 339 deletions

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

File diff suppressed because one or more lines are too long

View File

@@ -141,6 +141,10 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -157,6 +161,30 @@ 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'
};
@@ -228,6 +256,45 @@ 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'
@@ -262,12 +329,18 @@ 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'
EventSignup: 'EventSignup',
RegisterForm: 'RegisterForm',
RegisterFormField: 'RegisterFormField',
RegisterApplication: 'RegisterApplication',
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
};
/**

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,6 +141,10 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -157,6 +161,30 @@ 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'
};
@@ -228,6 +256,45 @@ 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'
@@ -262,12 +329,18 @@ 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'
EventSignup: 'EventSignup',
RegisterForm: 'RegisterForm',
RegisterFormField: 'RegisterFormField',
RegisterApplication: 'RegisterApplication',
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
};
/**

View File

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

View File

@@ -26,7 +26,12 @@ model GuildSettings {
birthdayConfig Json?
reactionRolesEnabled Boolean?
reactionRolesConfig Json?
eventsEnabled Boolean?\n registerEnabled Boolean?\n registerConfig Json?\n supportRoleId String?
eventsEnabled Boolean?
registerEnabled Boolean?
registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}
@@ -172,7 +177,6 @@ model RegisterForm {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields RegisterFormField[]
applications RegisterApplication[]
@@index([guildId, isActive])
}
@@ -183,7 +187,7 @@ model RegisterFormField {
label String
type String
required Boolean @default(false)
sortOrder Int @default(0)
order Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
}

View File

@@ -1,73 +1,64 @@
# 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..
## 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.
## 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.
## Tech-Stack
- Node.js 20 (Docker-Basis), TypeScript (CommonJS)
- Node.js 20, TypeScript (CommonJS)
- discord.js 14, play-dl, @discordjs/voice
- Express + OAuth2-Login, Prisma ORM (PostgreSQL)
- Dockerfile + docker-compose (App + Postgres)
- Express + OAuth2-Login
- Prisma ORM (PostgreSQL)
- Dockerfile + docker-compose
## Setup (lokal, Entwicklung)
## Quickstart (lokal)
1. Repo klonen, in das Verzeichnis wechseln.
2. `cp .env.example .env` und Variablen setzen (siehe unten).
3. Dependencies installieren: `npm ci` (oder `npm install`).
2. `.env` anlegen: `cp .env.example .env` und Werte setzen.
3. Abhängigkeiten: `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 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.
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.
## 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).
## 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.
## Environment-Variablen
## Environment-Variablen (Auswahl)
- `DISCORD_TOKEN` (Pflicht, Bot Token)
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Pflicht fuer Dashboard-OAuth)
- `DISCORD_GUILD_ID` (optional Einzel-Guild fuer Commands)
- `DISCORD_GUILD_IDS` (kommagetrennt, mehrere Guilds)
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Dashboard-OAuth)
- `DISCORD_GUILD_ID` (optional Einzel-Guild) / `DISCORD_GUILD_IDS` (kommagetrennt)
- `DATABASE_URL` (Pflicht, Postgres)
- `PORT` (Webserver/Dashboard, default 3000)
- `PORT` (Dashboard/Bot, default 3000)
- `SESSION_SECRET` (Express Session Secret, default `papo_dev_secret`)
- `DASHBOARD_BASE_URL` (Public Base URL, fuer OAuth Redirect)
- `DASHBOARD_BASE_URL` (Public Base URL für OAuth Redirect)
- `WEB_BASE_PATH` (Default `/ucp`, ohne Slash am Ende)
- `OWNER_IDS` (kommagetrennte Owner fuer Admin-UI)
- `OWNER_IDS` (kommagetrennte Owner für Admin-UI)
- `SUPPORT_ROLE_ID` (optional Ticket/Support-Login Rolle)
## Datenbank / Prisma
- Schema: `src/database/schema.prisma` (zweites Schema in `prisma/schema.prisma` fuer Binary Targets).
- Hauptschema: `src/database/schema.prisma` (zweites in `prisma/schema.prisma` für Binary Targets).
- Migrationen: `npx prisma migrate dev --name <name>`; danach `npx prisma generate --schema=src/database/schema.prisma`.
- Kern-Tabellen: GuildSettings (Module/Config), Ticket, TicketSupportSession, Event/EventSignup, Birthday, ReactionRoleSet, Level.
- Kern-Tabellen: GuildSettings, Ticket, TicketSupportSession, Event/EventSignup, RegisterForm/RegisterApplication, Birthday, ReactionRoleSet, Level.
## Kommandos & Scripts
## 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`)
## Dashboard / API Kurzinfo
## API/Dashboard 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.
- Module/Settings ueber `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints fuer Events, Reaction Roles, Birthday, Statuspage.
- `/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.
## Deployment-Hinweise
- Produktion: `npm run build` + `npm start` oder Docker-Image nutzen.
- Transcripts werden unter `./transcripts` abgelegt (Volume mounten, falls Container).
- Transcripts liegen unter `./transcripts` (bei Containern als Volume mounten).
## Credits/Lizenz
- Autoren/Lizenz nicht hinterlegt. Bitte vor Nutzung pruefen.
- Autoren/Lizenz nicht hinterlegt bitte vor Nutzung prüfen.

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);
context.logging.logAction(user, 'Ban', reason, interaction.guild);
}
};

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);
context.logging.logAction(user, 'Kick', reason, interaction.guild);
}
};

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);
context.logging.logAction(user, 'Mute', reason, interaction.guild);
}
};

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);
context.logging.logAction(user, 'Tempban', reason, interaction.guild);
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);
context.logging.logAction(user, 'Timeout', reason, interaction.guild);
}
};

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');
context.logging.logAction(user, 'Unmute', undefined, interaction.guild);
}
};

View File

@@ -4,15 +4,19 @@ 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')
.setDescription('Multi-Guild ready | Admin, Tickets, Musik, Automod, Dashboard')
.setTitle('Papo Hilfe')
.setColor(0xf97316)
.setThumbnail(avatar)
.setDescription('Dein All-in-One Assistant: Tickets, Automod, Musik, Stats, 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: 'Utility', value: '/ping /configure /serverinfo /rank', inline: false }
);
{ 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' });
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};

View File

@@ -15,12 +15,15 @@ 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,
automod: new AutoModService(true, true),
logging: new LoggingService(),
logging,
automod: new AutoModService(logging, true, true),
music: new MusicService(),
tickets: new TicketService(),
leveling: new LevelService(),
@@ -33,7 +36,8 @@ export const context = {
events: new EventService(),
ticketAutomation: new TicketAutomationService(),
knowledgeBase: new KnowledgeBaseService(),
register: new RegisterService()
register: new RegisterService(),
stats: new StatsService()
};
context.modules.setHooks({
@@ -63,6 +67,10 @@ 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,6 +65,15 @@ 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;
}
@@ -74,8 +83,7 @@ class SettingsStore {
private applyModuleDefaults(cfg: GuildSettings): GuildSettings {
const normalized: GuildSettings = { ...cfg };
(
[
const defaultOn = [
'ticketsEnabled',
'automodEnabled',
'welcomeEnabled',
@@ -87,10 +95,11 @@ class SettingsStore {
'reactionRolesEnabled',
'eventsEnabled',
'registerEnabled'
] as const
).forEach((key) => {
] as const;
defaultOn.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 };
@@ -124,6 +133,8 @@ 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));
@@ -206,6 +217,8 @@ 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: {
@@ -228,6 +241,8 @@ 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

@@ -0,0 +1,2 @@
-- Placeholder recreated because this migration was applied in the database already.
-- No schema changes required locally; keeps migration history aligned.

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ 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,6 +7,7 @@ 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');
context.logging.logAction(ban.user, 'Ban', undefined, ban.guild);
}
};

View File

@@ -16,8 +16,11 @@ const event: EventHandler = {
const embed = new EmbedBuilder()
.setTitle(welcomeCfg.embedTitle || 'Willkommen!')
.setDescription(welcomeCfg.embedDescription || `${member} ist beigetreten.`)
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal)
.setFooter({ text: welcomeCfg.embedFooter || '' });
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal);
const footerText = (welcomeCfg.embedFooter || '').trim();
if (footerText) {
embed.setFooter({ text: footerText });
}
if (welcomeCfg.embedThumbnailData && welcomeCfg.embedThumbnailData.startsWith('data:')) {
const [meta, b64] = welcomeCfg.embedThumbnailData.split(',');
const ext = meta.includes('gif') ? 'gif' : 'png';
@@ -47,6 +50,7 @@ const event: EventHandler = {
}
}
context.logging.logMemberJoin(member);
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
}
};

View File

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

View File

@@ -38,6 +38,10 @@ 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,6 +35,7 @@ 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,5 +1,7 @@
import { Collection, Message, PermissionFlagsBits } from 'discord.js';
import { Collection, Message } from 'discord.js';
import { logger } from '../utils/logger';
import { GuildSettings } from '../config/state';
import { LoggingService } from './loggingService';
export interface AutomodConfig {
spamThreshold?: number;
@@ -37,11 +39,13 @@ export class AutoModService {
};
private defaultBadwords = ['badword', 'spamword'];
constructor(private linkFilterEnabled = true, private antiSpamEnabled = true) {}
constructor(private logging?: LoggingService, private linkFilterEnabled = true, private antiSpamEnabled = true) {}
public async checkMessage(message: Message, cfg?: AutomodConfig) {
if (message.author.bot) return;
const config = { ...this.defaults, ...(cfg ?? {}) };
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 ?? {}) };
const member = message.member;
if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) {
@@ -50,23 +54,16 @@ export class AutoModService {
}
if (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) {
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
message.delete().catch(() => undefined);
message.channel
.send({ content: `${message.author}, Links sind hier nicht erlaubt.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.deleteMessageWithReason(message, `${message.author}, Links sind hier nicht erlaubt.`);
const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`;
logger.info(`Deleted link from ${message.author.tag}`);
await this.logAutomodAction(message, config, 'link_filter');
await this.logAutomodAction(message, config, 'link_filter', reason);
return true;
}
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
message.delete().catch(() => undefined);
message.channel
.send({ content: `${message.author}, bitte auf deine Wortwahl achten.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.logAutomodAction(message, config, 'badword', message.content);
await this.deleteMessageWithReason(message, `${message.author}, bitte auf deine Wortwahl achten.`);
await this.logAutomodAction(message, config, 'badword', 'Badword erkannt', message.content);
return true;
}
@@ -74,11 +71,9 @@ 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) {
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);
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);
return true;
}
}
@@ -98,12 +93,11 @@ export class AutoModService {
if (tracker.count >= threshold) {
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined);
message.channel
.send({ content: `${message.author}, bitte langsamer schreiben (Spam-Schutz).` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.deleteMessageWithReason(message, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`);
logger.warn(`Timed out ${message.author.tag} for spam`);
this.spamTracker.delete(message.author.id);
await this.logAutomodAction(message, config, 'spam', `Count ${tracker.count}`);
const reason = `Spam erkannt (${tracker.count}/${threshold} Nachrichten innerhalb ${config.windowMs ?? this.windowMs}ms)`;
await this.logAutomodAction(message, config, 'spam', reason);
return true;
}
}
@@ -111,24 +105,52 @@ export class AutoModService {
}
private containsBadword(content: string, custom: string[] = []) {
const combined = [...this.defaultBadwords, ...(custom || [])].filter(Boolean).map((w) => w.toLowerCase());
const combined = [...this.defaultBadwords, ...(custom || [])]
.map((w) => w?.toString().trim().toLowerCase())
.filter(Boolean);
if (!combined.length) return false;
const lower = content.toLowerCase();
return combined.some((w) => lower.includes(w));
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);
});
}
private containsLink(content: string, whitelist: string[] = []) {
const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean);
const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+)/i.exec(content);
// 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);
if (!match) return false;
const url = match[0].toLowerCase();
return !normalized.some((w) => url.includes(w));
}
private async logAutomodAction(message: Message, config: AutomodConfig, action: string, details?: string) {
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) {
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;
@@ -136,8 +158,8 @@ export class AutoModService {
if (!channelId) return;
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const content = `[Automod] ${action} by ${message.author.tag}${details ? ` | ${details}` : ''}`;
await channel.send({ content });
const body = `[Automod] ${action} by ${message.author.tag} | ${reason}${content ? ` | ${content.slice(0, 1800)}` : ''}`;
await channel.send({ content: body });
} 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?.logChannelId || this.fallbackLogChannelId;
const logChannelId = loggingCfg.logChannelId || cfg?.automodConfig?.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, 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);
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);
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 = (user as GuildMember)?.guild?.id;
const guildId = resolvedGuild.id;
if (guildId) {
adminSink?.pushGuildLog({
guildId,
@@ -154,6 +154,36 @@ 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,7 +11,8 @@ export type ModuleKey =
| 'birthdayEnabled'
| 'reactionRolesEnabled'
| 'eventsEnabled'
| 'registerEnabled';
| 'registerEnabled'
| 'serverStatsEnabled';
export interface GuildModuleState {
key: ModuleKey;
@@ -31,7 +32,8 @@ 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.' }
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' },
serverStatsEnabled: { name: 'Server Stats', description: 'Zeigt Member-/Channel-Zahlen als Voice-Statistiken an.' }
};
export class BotModuleService {
@@ -53,6 +55,7 @@ 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: { sortOrder: 'asc' } } }, orderBy: { createdAt: 'desc' } });
return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { order: '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,
sortOrder: f.order ?? idx
order: f.order ?? idx
}))
});
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { sortOrder: 'asc' } } } });
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { order: '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,
sortOrder: f.order ?? idx
order: f.order ?? idx
}))
}
},
include: { fields: { orderBy: { sortOrder: 'asc' } } }
include: { fields: { orderBy: { order: '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: { sortOrder: 'asc' } } } });
const form = await prisma.registerForm.findFirst({ where: { id: formId }, include: { fields: { orderBy: { order: '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: { sortOrder: 'asc' } } }
include: { fields: { orderBy: { order: '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: { sortOrder: 'asc' } });
const fields = await prisma.registerFormField.findMany({ where: { formId: form.id }, orderBy: { order: 'asc' } });
const answers = await prisma.registerApplicationAnswer.findMany({ where: { applicationId: app.id } });
const embed = new EmbedBuilder()
.setTitle(`Registrierung: ${form.name}`)
@@ -254,5 +254,3 @@ export class RegisterService {
});
}
}

View File

@@ -0,0 +1,271 @@
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,7 +85,8 @@ 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
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false,
serverStatsEnabled: (modules as any).serverStatsEnabled === true || (modules as any).serverStatsConfig?.enabled === true
}
}
});
@@ -754,6 +755,27 @@ 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 {
@@ -779,7 +801,9 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled,
reactionRolesConfig,
registerEnabled,
registerConfig
registerConfig,
serverStatsEnabled,
serverStatsConfig
} = req.body;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const normalizeArray = (val: any) =>
@@ -913,7 +937,9 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled: parsedReactionRoles.enabled,
reactionRolesConfig: parsedReactionRoles,
registerEnabled: parsedRegister.enabled,
registerConfig: parsedRegister
registerConfig: parsedRegister,
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
serverStatsConfig: serverStatsConfig
});
// Live update logging target
context.logging = new LoggingService(updated.logChannelId);

View File

@@ -46,17 +46,18 @@ 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="#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="#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>
`;
@@ -330,10 +331,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"><EFBFBD>bersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
<p class="section-sub">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"><EFBFBD>bersicht</button>
<button class="secondary-btn ticket-tab-btn active" data-tab="overview">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>
@@ -346,7 +347,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 ausw<EFBFBD>hlen, Details im Modal. Plus <EFBFBD>ffnet Panel-Erstellung.</p>
<p class="section-sub">Links auswhlen, Details im Modal. Plus ffnet Panel-Erstellung.</p>
</div>
<div class="row" style="gap:10px;">
<button class="secondary-btn" id="openSupportLogin" type="button">Support-Login Einstellungen</button>
@@ -381,7 +382,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 <EFBFBD>ndern.</p>
<p class="section-sub">Tickets nach Phase. Status per Dropdown ndern.</p>
</div>
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;" id="pipelineGrid">
@@ -430,7 +431,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 f<EFBFBD>r Ticket-Aktionen.</p>
<p class="section-sub">Regeln fr Ticket-Aktionen.</p>
</div>
<button class="secondary-btn" id="addAutomation">Neue Regel</button>
</div>
@@ -467,7 +468,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 f<EFBFBD>r Self-Service.</p>
<p class="section-sub">Artikel fr Self-Service.</p>
</div>
<button class="secondary-btn" id="addKb">Neuer Artikel</button>
</div>
@@ -782,6 +783,33 @@ 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;">
@@ -1203,6 +1231,7 @@ router.get('/', (req, res) => {
let activeModal = null;
let automodConfigCache = {};
let modulesCache = {};
let serverStatsCache = { items: [] };
let dynamicVoiceCache = {};
let isAdmin = false;
let statuspageCache = { services: [] };
@@ -1240,6 +1269,7 @@ 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;
@@ -1248,6 +1278,8 @@ router.get('/', (req, res) => {
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');
@@ -1262,6 +1294,7 @@ 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) ||
@@ -1372,6 +1405,103 @@ 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));
@@ -1755,7 +1885,7 @@ router.get('/', (req, res) => {
'</div>' +
'<div class=\"ticket-meta\">User: ' +
(t.userId || '-') +
(t.claimedBy ? ' <EFBFBD> Supporter: ' + t.claimedBy : '') +
(t.claimedBy ? ' Supporter: ' + t.claimedBy : '') +
'</div>';
const select = document.createElement('select');
select.innerHTML =
@@ -1869,14 +1999,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillAutomationForm(r));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'L<EFBFBD>schen';
del.textContent = 'Lschen';
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 gel<EFBFBD>scht' : 'L<EFBFBD>schen fehlgeschlagen', !res.ok);
showToast(res.ok ? 'Regel gelscht' : 'Lschen fehlgeschlagen', !res.ok);
if (res.ok) loadAutomations();
});
actions.appendChild(edit);
@@ -1936,14 +2066,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillKbForm(a));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'L<EFBFBD>schen';
del.textContent = 'Lschen';
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 gel<EFBFBD>scht' : 'L<EFBFBD>schen fehlgeschlagen', !res.ok);
showToast(res.ok ? 'Artikel gelscht' : 'Lschen fehlgeschlagen', !res.ok);
if (res.ok) loadKb();
});
actions.appendChild(edit);
@@ -1991,6 +2121,7 @@ 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;
@@ -2457,6 +2588,7 @@ router.get('/', (req, res) => {
list.innerHTML = '';
let ticketsActive = false;
let statuspageActive = false;
let serverStatsActive = false;
let birthdayActive = false;
let reactionRolesActive = false;
let eventsActive = false;
@@ -2481,6 +2613,7 @@ router.get('/', (req, res) => {
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;
@@ -2497,6 +2630,7 @@ 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;
@@ -2504,6 +2638,7 @@ router.get('/', (req, res) => {
applyNavVisibility();
applyTicketsVisibility(ticketsActive);
if (statuspageActive) loadStatuspage();
if (serverStatsActive) loadServerStats();
if (birthdayActive) loadBirthday();
if (reactionRolesActive) loadReactionRoles();
if (eventsActive) loadEvents();
@@ -2512,7 +2647,7 @@ router.get('/', (req, res) => {
async function saveModuleToggle(key, enabled) {
if (!currentGuild) return false;
const payload = { guildId: currentGuild };
['ticketsEnabled', 'automodEnabled', 'welcomeEnabled', 'musicEnabled', 'levelingEnabled', 'statuspageEnabled', 'birthdayEnabled', 'reactionRolesEnabled', 'eventsEnabled'].forEach((k) => {
['ticketsEnabled', 'automodEnabled', 'welcomeEnabled', 'musicEnabled', 'levelingEnabled', 'statuspageEnabled', 'serverStatsEnabled', 'birthdayEnabled', 'reactionRolesEnabled', 'eventsEnabled'].forEach((k) => {
if (modulesCache[k] !== undefined) payload[k] = modulesCache[k];
});
payload['dynamicVoiceEnabled'] = modulesCache['dynamicVoiceEnabled'];
@@ -2716,6 +2851,10 @@ 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);
});