Compare commits

...

22 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
Pascal Prießnitz
a8b4713ffe [deploy] add register module backend
Some checks failed
Deploy Discord Bot / deploy (push) Failing after 20s
2025-12-03 18:08:08 +01:00
36 changed files with 9923 additions and 318 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', reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig', reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled', eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId', supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
createdAt: 'createdAt' createdAt: 'createdAt'
@@ -157,6 +161,30 @@ exports.Prisma.TicketScalarFieldEnum = {
status: 'status', status: 'status',
claimedBy: 'claimedBy', claimedBy: 'claimedBy',
transcript: 'transcript', 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', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
@@ -228,6 +256,45 @@ exports.Prisma.EventSignupScalarFieldEnum = {
canceledAt: 'canceledAt' 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 = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -262,12 +329,18 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = { exports.Prisma.ModelName = {
GuildSettings: 'GuildSettings', GuildSettings: 'GuildSettings',
Ticket: 'Ticket', Ticket: 'Ticket',
TicketAutomationRule: 'TicketAutomationRule',
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
Level: 'Level', Level: 'Level',
TicketSupportSession: 'TicketSupportSession', TicketSupportSession: 'TicketSupportSession',
Birthday: 'Birthday', Birthday: 'Birthday',
ReactionRoleSet: 'ReactionRoleSet', ReactionRoleSet: 'ReactionRoleSet',
Event: 'Event', 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', reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig', reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled', eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId', supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
createdAt: 'createdAt' createdAt: 'createdAt'
@@ -157,6 +161,30 @@ exports.Prisma.TicketScalarFieldEnum = {
status: 'status', status: 'status',
claimedBy: 'claimedBy', claimedBy: 'claimedBy',
transcript: 'transcript', 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', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
@@ -228,6 +256,45 @@ exports.Prisma.EventSignupScalarFieldEnum = {
canceledAt: 'canceledAt' 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 = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -262,12 +329,18 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = { exports.Prisma.ModelName = {
GuildSettings: 'GuildSettings', GuildSettings: 'GuildSettings',
Ticket: 'Ticket', Ticket: 'Ticket',
TicketAutomationRule: 'TicketAutomationRule',
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
Level: 'Level', Level: 'Level',
TicketSupportSession: 'TicketSupportSession', TicketSupportSession: 'TicketSupportSession',
Birthday: 'Birthday', Birthday: 'Birthday',
ReactionRoleSet: 'ReactionRoleSet', ReactionRoleSet: 'ReactionRoleSet',
Event: 'Event', 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

@@ -27,6 +27,10 @@ model GuildSettings {
reactionRolesEnabled Boolean? reactionRolesEnabled Boolean?
reactionRolesConfig Json? reactionRolesConfig Json?
eventsEnabled Boolean? eventsEnabled Boolean?
registerEnabled Boolean?
registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String? supportRoleId String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -161,3 +165,55 @@ model EventSignup {
@@unique([eventId, userId]) @@unique([eventId, userId])
@@index([guildId, eventId]) @@index([guildId, eventId])
} }
model RegisterForm {
id String @id @default(cuid())
guildId String
name String
description String?
reviewChannelId String?
notifyRoleIds String[]
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields RegisterFormField[]
@@index([guildId, isActive])
}
model RegisterFormField {
id String @id @default(cuid())
formId String
label String
type String
required Boolean @default(false)
order Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
}
model RegisterApplication {
id String @id @default(cuid())
guildId String
userId String
formId String
status String @default("pending")
reviewedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
answers RegisterApplicationAnswer[]
form RegisterForm @relation(fields: [formId], references: [id])
@@index([guildId, formId, status])
}
model RegisterApplicationAnswer {
id String @id @default(cuid())
applicationId String
fieldId String
value String
application RegisterApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
}

View File

@@ -1,73 +1,64 @@
# Papo Discord Bot # 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 ## Highlights
- Ticketsystem: Slash-Commands (/ticket, /claim, /close, /ticketpriority, /ticketstatus, /transcript, /ticketpanel), Panels, Transcripts unter `./transcripts`, Support-Login-Panel mit Rollen-Vergabe/On-Duty-Logging. - Ticketsystem mit Panels, Transcripts und Support-Login (Slash-Commands wie `/ticket`, `/claim`, `/close`).
- Automod: Link-Filter (Whitelist), Spam/Caps-Erkennung, Bad-Word-Listen (Custom), Timeouts, Logging. - Automod (Link-Whitelist, Spam/Caps, Bad-Word-Listen), Logging für relevante Events.
- Musik: play/skip/stop/pause/resume/loop, Queue, aktivierbar/deaktivierbar pro Guild. - Musik (play/skip/stop/pause/resume/loop) pro Guild aktivierbar.
- Welcome: konfigurierbare Embeds (Channel, Farbe, Texte, Bilder/Uploads), Preview im Dashboard, Text-Fallback. - Welcome, Leveling, dynamische Voice, Birthdays, Reaction Roles, Events mit Remindern.
- Logging: Join/Leave, Message Edit/Delete, Automod/Ticket/Musik-Events mit konfigurierbarem Log-Channel/Kategorien. - Statuspage-Modul, Rich Presence und modulbasierte Dashboard-Navigation.
- 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 ## Tech-Stack
- Node.js 20 (Docker-Basis), TypeScript (CommonJS) - Node.js 20, TypeScript (CommonJS)
- discord.js 14, play-dl, @discordjs/voice - discord.js 14, play-dl, @discordjs/voice
- Express + OAuth2-Login, Prisma ORM (PostgreSQL) - Express + OAuth2-Login
- Dockerfile + docker-compose (App + Postgres) - Prisma ORM (PostgreSQL)
- Dockerfile + docker-compose
## Setup (lokal, Entwicklung) ## Quickstart (lokal)
1. Repo klonen, in das Verzeichnis wechseln. 1. Repo klonen, in das Verzeichnis wechseln.
2. `cp .env.example .env` und Variablen setzen (siehe unten). 2. `.env` anlegen: `cp .env.example .env` und Werte setzen.
3. Dependencies installieren: `npm ci` (oder `npm install`). 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`. 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). 5. Start Dev: `npm run dev` (ts-node-dev). Dashboard/Bot auf `PORT` (Standard 3000).
6. Slash-Commands werden beim Start fuer die IDs in `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert. 6. Slash-Commands werden beim Start für `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert.
## Setup mit Docker ## Quickstart (Docker)
- `.dockerignore` blendet lokale node_modules/.env aus. - Dev-Stack: `docker-compose up --build` (Dockerfile + Postgres 15, env aus `.env`, startet `npm run dev`).
- Dev-Stack: `docker-compose up --build` (nutzt `Dockerfile`, Postgres 15, env aus `.env`, `npm run dev` im Container). - Eigenes Image: `docker build .` (Prisma-Generate läuft im Build). `.dockerignore` blendet lokale `node_modules`/`.env` aus.
- Eigenes Image: `docker build .` (Prisma-Generate laeuft im Build).
## Environment-Variablen ## Environment-Variablen (Auswahl)
- `DISCORD_TOKEN` (Pflicht, Bot Token) - `DISCORD_TOKEN` (Pflicht, Bot Token)
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Pflicht fuer Dashboard-OAuth) - `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Dashboard-OAuth)
- `DISCORD_GUILD_ID` (optional Einzel-Guild fuer Commands) - `DISCORD_GUILD_ID` (optional Einzel-Guild) / `DISCORD_GUILD_IDS` (kommagetrennt)
- `DISCORD_GUILD_IDS` (kommagetrennt, mehrere Guilds)
- `DATABASE_URL` (Pflicht, Postgres) - `DATABASE_URL` (Pflicht, Postgres)
- `PORT` (Webserver/Dashboard, default 3000) - `PORT` (Dashboard/Bot, default 3000)
- `SESSION_SECRET` (Express Session Secret, default `papo_dev_secret`) - `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) - `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) - `SUPPORT_ROLE_ID` (optional Ticket/Support-Login Rolle)
## Datenbank / Prisma ## 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`. - 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 dev` Entwicklung (ts-node-dev)
- `npm run build` TypeScript build - `npm run build` TypeScript build
- `npm start` Start aus `dist` - `npm start` Start aus `dist`
- Prisma-CLI: `npx prisma ...` (nutzt Schema aus `src/database/schema.prisma`) - 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`. - 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. - `/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. - Settings/Module über `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints für Events, Reaction Roles, Birthday, Statuspage.
## Deployment-Hinweise ## Deployment-Hinweise
- Produktion: `npm run build` + `npm start` oder Docker-Image nutzen. - 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 ## 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 member.ban({ reason }).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` }); 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 member.kick(reason);
await interaction.reply({ content: `${user.tag} wurde gekickt.` }); 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 member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` }); 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 member.ban({ reason: `${reason} | ${minutes} Minuten` });
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` }); 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 () => { setTimeout(async () => {
await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null); 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 member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` }); 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 member.timeout(null).catch(() => null);
await interaction.reply({ content: `${user.tag} ist nun entmuted.` }); 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 = { const command: SlashCommand = {
data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'), data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'),
async execute(interaction: ChatInputCommandInteraction) { async execute(interaction: ChatInputCommandInteraction) {
const avatar = interaction.client.user?.displayAvatarURL({ size: 256 }) ?? null;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Papo Hilfe') .setTitle('Papo Hilfe')
.setDescription('Multi-Guild ready | Admin, Tickets, Musik, Automod, Dashboard') .setColor(0xf97316)
.setThumbnail(avatar)
.setDescription('Dein All-in-One Assistant: Tickets, Automod, Musik, Stats, Dashboard.')
.addFields( .addFields(
{ name: 'Admin', value: '/ban /kick /mute /timeout /clear', inline: false }, { name: '🛡️ Admin', value: '`/ban` `/kick` `/mute` `/timeout` `/clear`', inline: false },
{ name: 'Tickets', value: '/ticket /ticketpanel /ticketpriority /ticketstatus /transcript', 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: '🎵 Musik', value: '`/play` `/pause` `/resume` `/skip` `/stop` `/queue` `/loop`', inline: false },
{ name: 'Utility', value: '/ping /configure /serverinfo /rank', 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 }); await interaction.reply({ embeds: [embed], ephemeral: true });
} }
}; };

View File

@@ -14,12 +14,16 @@ import { ReactionRoleService } from '../services/reactionRoleService';
import { EventService } from '../services/eventService'; import { EventService } from '../services/eventService';
import { TicketAutomationService } from '../services/ticketAutomationService'; import { TicketAutomationService } from '../services/ticketAutomationService';
import { KnowledgeBaseService } from '../services/knowledgeBaseService'; import { KnowledgeBaseService } from '../services/knowledgeBaseService';
import { RegisterService } from '../services/registerService';
import { StatsService } from '../services/statsService';
const logging = new LoggingService();
export const context = { export const context = {
client: null as Client | null, client: null as Client | null,
commandHandler: null as CommandHandler | null, commandHandler: null as CommandHandler | null,
automod: new AutoModService(true, true), logging,
logging: new LoggingService(), automod: new AutoModService(logging, true, true),
music: new MusicService(), music: new MusicService(),
tickets: new TicketService(), tickets: new TicketService(),
leveling: new LevelService(), leveling: new LevelService(),
@@ -31,7 +35,9 @@ export const context = {
reactionRoles: new ReactionRoleService(), reactionRoles: new ReactionRoleService(),
events: new EventService(), events: new EventService(),
ticketAutomation: new TicketAutomationService(), ticketAutomation: new TicketAutomationService(),
knowledgeBase: new KnowledgeBaseService() knowledgeBase: new KnowledgeBaseService(),
register: new RegisterService(),
stats: new StatsService()
}; };
context.modules.setHooks({ context.modules.setHooks({
@@ -61,6 +67,10 @@ context.modules.setHooks({
}, },
eventsEnabled: { eventsEnabled: {
onEnable: async (guildId: string) => context.events.tick().catch(() => undefined) 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

@@ -60,6 +60,20 @@ export interface GuildSettings {
}; };
reactionRolesEnabled?: boolean; reactionRolesEnabled?: boolean;
reactionRolesConfig?: any; reactionRolesConfig?: any;
registerEnabled?: boolean;
registerConfig?: {
reviewChannelId?: string;
notifyRoleIds?: string[];
};
serverStatsEnabled?: boolean;
serverStatsConfig?: {
enabled?: boolean;
categoryId?: string;
categoryName?: string;
refreshMinutes?: number;
cleanupOrphans?: boolean;
items?: any[];
};
supportRoleId?: string; supportRoleId?: string;
welcomeEnabled?: boolean; welcomeEnabled?: boolean;
} }
@@ -69,8 +83,7 @@ class SettingsStore {
private applyModuleDefaults(cfg: GuildSettings): GuildSettings { private applyModuleDefaults(cfg: GuildSettings): GuildSettings {
const normalized: GuildSettings = { ...cfg }; const normalized: GuildSettings = { ...cfg };
( const defaultOn = [
[
'ticketsEnabled', 'ticketsEnabled',
'automodEnabled', 'automodEnabled',
'welcomeEnabled', 'welcomeEnabled',
@@ -80,11 +93,13 @@ class SettingsStore {
'statuspageEnabled', 'statuspageEnabled',
'birthdayEnabled', 'birthdayEnabled',
'reactionRolesEnabled', 'reactionRolesEnabled',
'eventsEnabled' 'eventsEnabled',
] as const 'registerEnabled'
).forEach((key) => { ] as const;
defaultOn.forEach((key) => {
if (normalized[key] === undefined) normalized[key] = true; if (normalized[key] === undefined) normalized[key] = true;
}); });
if (normalized.serverStatsEnabled === undefined) normalized.serverStatsEnabled = false;
// keep welcomeConfig flag in sync when present // keep welcomeConfig flag in sync when present
if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) { if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) {
normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled }; normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled };
@@ -116,6 +131,10 @@ class SettingsStore {
birthdayConfig: (row as any).birthdayConfig ?? undefined, birthdayConfig: (row as any).birthdayConfig ?? undefined,
reactionRolesEnabled: (row as any).reactionRolesEnabled ?? undefined, reactionRolesEnabled: (row as any).reactionRolesEnabled ?? undefined,
reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined, 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 supportRoleId: row.supportRoleId ?? undefined
} satisfies GuildSettings; } satisfies GuildSettings;
this.cache.set(row.guildId, this.applyModuleDefaults(cfg)); this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
@@ -147,6 +166,9 @@ class SettingsStore {
} else if (partial.reactionRolesConfig?.enabled !== undefined) { } else if (partial.reactionRolesConfig?.enabled !== undefined) {
partial.reactionRolesEnabled = partial.reactionRolesConfig.enabled; partial.reactionRolesEnabled = partial.reactionRolesConfig.enabled;
} }
if (!partial.registerConfig && partial.registerEnabled !== undefined) {
partial.registerConfig = { ...(partial.registerConfig ?? {}) };
}
const merged: GuildSettings = this.applyModuleDefaults({ ...(this.cache.get(guildId) ?? {}), ...partial }); const merged: GuildSettings = this.applyModuleDefaults({ ...(this.cache.get(guildId) ?? {}), ...partial });
const mergedAutomod = { const mergedAutomod = {
...(merged.automodConfig ?? {}), ...(merged.automodConfig ?? {}),
@@ -193,6 +215,10 @@ class SettingsStore {
birthdayConfig: merged.birthdayConfig ?? null, birthdayConfig: merged.birthdayConfig ?? null,
reactionRolesEnabled: merged.reactionRolesEnabled ?? null, reactionRolesEnabled: merged.reactionRolesEnabled ?? null,
reactionRolesConfig: merged.reactionRolesConfig ?? null, 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 supportRoleId: merged.supportRoleId ?? null
}, },
create: { create: {
@@ -213,6 +239,10 @@ class SettingsStore {
birthdayConfig: merged.birthdayConfig ?? null, birthdayConfig: merged.birthdayConfig ?? null,
reactionRolesEnabled: merged.reactionRolesEnabled ?? null, reactionRolesEnabled: merged.reactionRolesEnabled ?? null,
reactionRolesConfig: merged.reactionRolesConfig ?? null, 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 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

@@ -26,6 +26,10 @@ model GuildSettings {
reactionRolesEnabled Boolean? reactionRolesEnabled Boolean?
reactionRolesConfig Json? reactionRolesConfig Json?
eventsEnabled Boolean? eventsEnabled Boolean?
registerEnabled Boolean?
registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String? supportRoleId String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -160,3 +164,55 @@ model EventSignup {
@@unique([eventId, userId]) @@unique([eventId, userId])
@@index([guildId, eventId]) @@index([guildId, eventId])
} }
model RegisterForm {
id String @id @default(cuid())
guildId String
name String
description String?
reviewChannelId String?
notifyRoleIds String[]
isActive Boolean @default(true)
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)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
}
model RegisterApplication {
id String @id @default(cuid())
guildId String
userId String
formId String
status String @default("pending")
reviewedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
answers RegisterApplicationAnswer[]
form RegisterForm @relation(fields: [formId], references: [id])
@@index([guildId, formId, status])
}
model RegisterApplicationAnswer {
id String @id @default(cuid())
applicationId String
fieldId String
value String
application RegisterApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
}

View File

@@ -7,6 +7,7 @@ const event: EventHandler = {
execute(channel: GuildChannel) { execute(channel: GuildChannel) {
if (!channel.guild) return; if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel erstellt: ${channel.name} (${channel.id})`); 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) { execute(channel: GuildChannel) {
if (!channel.guild) return; if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel gelöscht: ${channel.name} (${channel.id})`); 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 = { const event: EventHandler = {
name: 'guildBanAdd', name: 'guildBanAdd',
execute(ban: GuildBan) { 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() const embed = new EmbedBuilder()
.setTitle(welcomeCfg.embedTitle || 'Willkommen!') .setTitle(welcomeCfg.embedTitle || 'Willkommen!')
.setDescription(welcomeCfg.embedDescription || `${member} ist beigetreten.`) .setDescription(welcomeCfg.embedDescription || `${member} ist beigetreten.`)
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal) .setColor(isNaN(colorVal) ? 0x00ff99 : colorVal);
.setFooter({ text: welcomeCfg.embedFooter || '' }); const footerText = (welcomeCfg.embedFooter || '').trim();
if (footerText) {
embed.setFooter({ text: footerText });
}
if (welcomeCfg.embedThumbnailData && welcomeCfg.embedThumbnailData.startsWith('data:')) { if (welcomeCfg.embedThumbnailData && welcomeCfg.embedThumbnailData.startsWith('data:')) {
const [meta, b64] = welcomeCfg.embedThumbnailData.split(','); const [meta, b64] = welcomeCfg.embedThumbnailData.split(',');
const ext = meta.includes('gif') ? 'gif' : 'png'; const ext = meta.includes('gif') ? 'gif' : 'png';
@@ -47,6 +50,7 @@ const event: EventHandler = {
} }
} }
context.logging.logMemberJoin(member); context.logging.logMemberJoin(member);
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
} }
}; };

View File

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

View File

@@ -20,8 +20,19 @@ const event: EventHandler = {
return; return;
} }
} }
if (interaction.customId.startsWith('register:')) {
await context.register.handleButton(interaction as any);
return;
}
await context.tickets.handleButton(interaction); await context.tickets.handleButton(interaction);
} }
if (interaction.isModalSubmit()) {
if (interaction.customId.startsWith('register:submit:')) {
await context.register.handleModal(interaction as any);
return;
}
}
} }
}; };

View File

@@ -8,7 +8,7 @@ const event: EventHandler = {
async execute(message: Message) { async execute(message: Message) {
const cfg = message.guildId ? settingsStore.get(message.guildId) : undefined; const cfg = message.guildId ? settingsStore.get(message.guildId) : undefined;
if (message.guildId) context.admin.trackEvent('message', message.guildId); 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); if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
// Ticket SLA + KB // Ticket SLA + KB
await context.tickets.trackFirstResponse(message); await context.tickets.trackFirstResponse(message);

View File

@@ -38,6 +38,10 @@ const event: EventHandler = {
for (const gid of settingsStore.all().keys()) { for (const gid of settingsStore.all().keys()) {
context.reactionRoles.resyncGuild(gid).catch((err) => logger.warn(`reaction roles sync failed for ${gid}: ${err}`)); 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) { } catch (err) {
logger.warn(`Ready handler failed: ${err}`); logger.warn(`Ready handler failed: ${err}`);
} }

View File

@@ -34,6 +34,8 @@ async function bootstrap() {
context.reactionRoles.setClient(client); context.reactionRoles.setClient(client);
context.events.setClient(client); context.events.setClient(client);
context.events.startScheduler(); context.events.startScheduler();
context.register.setClient(client);
context.stats.setClient(client);
await context.reactionRoles.loadCache(); await context.reactionRoles.loadCache();
logger.setSink((entry) => context.admin.pushLog(entry)); logger.setSink((entry) => context.admin.pushLog(entry));
for (const gid of settingsStore.all().keys()) { 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 { logger } from '../utils/logger';
import { GuildSettings } from '../config/state';
import { LoggingService } from './loggingService';
export interface AutomodConfig { export interface AutomodConfig {
spamThreshold?: number; spamThreshold?: number;
@@ -37,11 +39,13 @@ export class AutoModService {
}; };
private defaultBadwords = ['badword', 'spamword']; 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) { public async checkMessage(message: Message, cfg?: AutomodConfig | GuildSettings) {
if (message.author.bot) return; if (message.author.bot || message.webhookId) return;
const config = { ...this.defaults, ...(cfg ?? {}) }; if (!message.inGuild()) return;
const guildConfig = (cfg as GuildSettings)?.automodConfig ? (cfg as GuildSettings).automodConfig : cfg;
const config = { ...this.defaults, ...(guildConfig ?? {}) };
const member = message.member; const member = message.member;
if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) { 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 (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) {
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false; await this.deleteMessageWithReason(message, `${message.author}, Links sind hier nicht erlaubt.`);
message.delete().catch(() => undefined); const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`;
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}`); 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; return true;
} }
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) { if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false; await this.deleteMessageWithReason(message, `${message.author}, bitte auf deine Wortwahl achten.`);
message.delete().catch(() => undefined); await this.logAutomodAction(message, config, 'badword', 'Badword erkannt', message.content);
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; return true;
} }
@@ -74,11 +71,9 @@ export class AutoModService {
const letters = message.content.replace(/[^a-zA-Z]/g, ''); const letters = message.content.replace(/[^a-zA-Z]/g, '');
const upper = letters.replace(/[^A-Z]/g, ''); const upper = letters.replace(/[^A-Z]/g, '');
if (letters.length >= 10 && upper.length / letters.length > 0.7) { if (letters.length >= 10 && upper.length / letters.length > 0.7) {
message.delete().catch(() => undefined); await this.deleteMessageWithReason(message, `${message.author}, bitte weniger Capslock nutzen.`);
message.channel const ratio = Math.round((upper.length / letters.length) * 100);
.send({ content: `${message.author}, bitte weniger Capslock nutzen.` }) await this.logAutomodAction(message, config, 'capslock', `Caps Anteil ${ratio}%`, message.content);
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.logAutomodAction(message, config, 'capslock', message.content);
return true; return true;
} }
} }
@@ -98,12 +93,11 @@ export class AutoModService {
if (tracker.count >= threshold) { if (tracker.count >= threshold) {
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000; const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined); message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined);
message.channel await this.deleteMessageWithReason(message, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`);
.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`); logger.warn(`Timed out ${message.author.tag} for spam`);
this.spamTracker.delete(message.author.id); 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; return true;
} }
} }
@@ -111,24 +105,52 @@ export class AutoModService {
} }
private containsBadword(content: string, custom: string[] = []) { 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; if (!combined.length) return false;
const lower = content.toLowerCase(); 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[] = []) { private containsLink(content: string, whitelist: string[] = []) {
const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean); 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; if (!match) return false;
const url = match[0].toLowerCase(); const url = match[0].toLowerCase();
return !normalized.some((w) => url.includes(w)); 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 { try {
const guild = message.guild; const guild = message.guild;
if (!guild) return; 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 loggingCfg = config.loggingConfig || {};
const flags = loggingCfg.categories || {}; const flags = loggingCfg.categories || {};
if (flags.automodActions === false) return; if (flags.automodActions === false) return;
@@ -136,8 +158,8 @@ export class AutoModService {
if (!channelId) return; if (!channelId) return;
const channel = await guild.channels.fetch(channelId).catch(() => null); const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return; if (!channel || !channel.isTextBased()) return;
const content = `[Automod] ${action} by ${message.author.tag}${details ? ` | ${details}` : ''}`; const body = `[Automod] ${action} by ${message.author.tag} | ${reason}${content ? ` | ${content.slice(0, 1800)}` : ''}`;
await channel.send({ content }); await channel.send({ content: body });
} catch (err) { } catch (err) {
logger.error('Automod log failed', err); logger.error('Automod log failed', err);
} }

View File

@@ -45,7 +45,7 @@ export class LoggingService {
private resolve(guild: Guild) { private resolve(guild: Guild) {
const cfg = settingsStore.get(guild.id); const cfg = settingsStore.get(guild.id);
const loggingCfg = cfg?.loggingConfig || cfg?.automodConfig?.loggingConfig || {}; 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 flags = loggingCfg.categories || {};
const channel = logChannelId ? guild.channels.cache.get(logChannelId) : null; const channel = logChannelId ? guild.channels.cache.get(logChannelId) : null;
return { channel: channel && channel.type === 0 ? (channel as TextChannel) : null, flags }; 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) { logAction(user: User | GuildMember, action: string, reason?: string, guild?: Guild) {
const guild = user instanceof GuildMember ? user.guild : null; const resolvedGuild = guild ?? (user instanceof GuildMember ? user.guild : null);
if (!guild) return; if (!resolvedGuild) return;
if (!this.shouldLog(guild, 'automodActions')) return; if (!this.shouldLog(resolvedGuild, 'automodActions')) return;
const { channel } = this.resolve(guild); const { channel } = this.resolve(resolvedGuild);
if (!channel) return; if (!channel) return;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Moderation') .setTitle('Moderation')
@@ -141,7 +141,7 @@ export class LoggingService {
.setColor(0x7289da) .setColor(0x7289da)
.setTimestamp(); .setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err)); 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) { if (guildId) {
adminSink?.pushGuildLog({ adminSink?.pushGuildLog({
guildId, 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[]) { logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
const guildId = member.guild.id; const guildId = member.guild.id;
adminSink?.pushGuildLog({ adminSink?.pushGuildLog({

View File

@@ -10,7 +10,9 @@ export type ModuleKey =
| 'statuspageEnabled' | 'statuspageEnabled'
| 'birthdayEnabled' | 'birthdayEnabled'
| 'reactionRolesEnabled' | 'reactionRolesEnabled'
| 'eventsEnabled'; | 'eventsEnabled'
| 'registerEnabled'
| 'serverStatsEnabled';
export interface GuildModuleState { export interface GuildModuleState {
key: ModuleKey; key: ModuleKey;
@@ -29,7 +31,9 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
statuspageEnabled: { name: 'Statuspage', description: 'Service Checks, Uptime und Status-Embed.' }, statuspageEnabled: { name: 'Statuspage', description: 'Service Checks, Uptime und Status-Embed.' },
birthdayEnabled: { name: 'Birthday', description: 'Geburtstage speichern und Glueckwuensche senden.' }, birthdayEnabled: { name: 'Birthday', description: 'Geburtstage speichern und Glueckwuensche senden.' },
reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' }, reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' },
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' } 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.' }
}; };
export class BotModuleService { export class BotModuleService {
@@ -51,6 +55,7 @@ export class BotModuleService {
if (key === 'birthdayEnabled') enabled = cfg.birthdayEnabled === true || cfg.birthdayConfig?.enabled === true; if (key === 'birthdayEnabled') enabled = cfg.birthdayEnabled === true || cfg.birthdayConfig?.enabled === true;
if (key === 'reactionRolesEnabled') enabled = cfg.reactionRolesEnabled === true || cfg.reactionRolesConfig?.enabled === true; if (key === 'reactionRolesEnabled') enabled = cfg.reactionRolesEnabled === true || cfg.reactionRolesConfig?.enabled === true;
if (key === 'eventsEnabled') enabled = (cfg as any).eventsEnabled !== false; 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 { return {
key: key as ModuleKey, key: key as ModuleKey,
name: meta.name, name: meta.name,

View File

@@ -0,0 +1,256 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
Client,
EmbedBuilder,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
ModalSubmitInteraction,
GuildMember
} from 'discord.js';
import { prisma } from '../database';
import { settingsStore } from '../config/state';
import { env } from '../config/env';
export class RegisterService {
private client: Client | null = null;
public setClient(client: Client) {
this.client = client;
}
public async listForms(guildId: string) {
return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { order: 'asc' } } }, orderBy: { createdAt: 'desc' } });
}
public async saveForm(form: {
id?: string;
guildId: string;
name: string;
description?: string;
reviewChannelId?: string;
notifyRoleIds?: string[];
isActive?: boolean;
fields: { id?: string; label: string; type: string; required?: boolean; order?: number }[];
}) {
const notify = (form.notifyRoleIds || []).filter(Boolean);
if (form.id) {
await prisma.registerForm.update({
where: { id: form.id },
data: {
name: form.name,
description: form.description,
reviewChannelId: form.reviewChannelId,
notifyRoleIds: notify,
isActive: form.isActive ?? true
}
});
await prisma.registerFormField.deleteMany({ where: { formId: form.id } });
await prisma.registerFormField.createMany({
data: (form.fields || []).map((f, idx) => ({
formId: form.id as string,
label: f.label,
type: f.type,
required: f.required ?? false,
order: f.order ?? idx
}))
});
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { order: 'asc' } } } });
}
const created = await prisma.registerForm.create({
data: {
guildId: form.guildId,
name: form.name,
description: form.description,
reviewChannelId: form.reviewChannelId,
notifyRoleIds: notify,
isActive: form.isActive ?? true,
fields: {
create: (form.fields || []).map((f, idx) => ({
label: f.label,
type: f.type,
required: f.required ?? false,
order: f.order ?? idx
}))
}
},
include: { fields: { orderBy: { order: 'asc' } } }
});
return created;
}
public async deleteForm(guildId: string, id: string) {
const form = await prisma.registerForm.findFirst({ where: { id, guildId } });
if (!form) return false;
await prisma.registerFormField.deleteMany({ where: { formId: id } });
await prisma.registerForm.delete({ where: { id } });
return true;
}
public async sendPanel(guildId: string, formId: string, channelId?: string, message?: string) {
if (!this.client) return null;
const form = await prisma.registerForm.findFirst({ where: { id: formId, guildId }, include: { fields: true } });
if (!form) return null;
const targetChannelId = channelId || form.reviewChannelId || settingsStore.get(guildId)?.registerConfig?.reviewChannelId;
if (!targetChannelId) return null;
const guild = this.client.guilds.cache.get(guildId) ?? (await this.client.guilds.fetch(guildId).catch(() => null));
if (!guild) return null;
const channel = await guild.channels.fetch(targetChannelId).catch(() => null);
if (!channel || !channel.isTextBased()) return null;
const embed = new EmbedBuilder()
.setTitle(form.name)
.setDescription(message || 'Klicke auf Registrieren, um das Formular auszufüllen.')
.setColor(0xf97316);
const btn = new ButtonBuilder().setCustomId(`register:form:${form.id}`).setLabel('Registrieren').setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(btn);
const sent = await (channel as any).send({ embeds: [embed], components: [row] });
return sent.id;
}
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' } } } });
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[] = [];
form.fields.slice(0, 5).forEach((f) => {
const input = new TextInputBuilder()
.setCustomId(f.id)
.setLabel(f.label.slice(0, 45) || 'Feld')
.setStyle(f.type === 'longText' ? TextInputStyle.Paragraph : TextInputStyle.Short)
.setRequired(f.required ?? false);
components.push(new ActionRowBuilder<TextInputBuilder>().addComponents(input));
});
modal.addComponents(components as any);
await interaction.showModal(modal);
return;
}
if (interaction.customId.startsWith('register:review:')) {
const [, , action, appId] = interaction.customId.split(':');
const app = await prisma.registerApplication.findUnique({ where: { id: appId }, include: { form: true } });
if (!app) {
await interaction.reply({ content: 'Antrag nicht gefunden.', ephemeral: true });
return;
}
const statusMap: any = { accept: 'accepted', invite: 'invited', reject: 'rejected' };
const newStatus = statusMap[action] || 'pending';
const updated = await prisma.registerApplication.update({
where: { id: appId },
data: { status: newStatus, reviewedBy: interaction.user.id }
});
await this.updateReviewMessage(interaction, updated);
const user = await this.client?.users.fetch(app.userId).catch(() => null);
if (user) {
const msg =
newStatus === 'accepted'
? 'Deine Registrierung wurde akzeptiert.'
: newStatus === 'invited'
? 'Bitte komm für ein Gespräch vorbei.'
: 'Deine Registrierung wurde abgelehnt.';
user.send(msg).catch(() => undefined);
}
await interaction.reply({ content: 'Status aktualisiert.', ephemeral: true });
return;
}
}
public async handleModal(interaction: ModalSubmitInteraction) {
if (!interaction.customId.startsWith('register:submit:')) return;
const formId = interaction.customId.split(':')[2];
const form = await prisma.registerForm.findFirst({
where: { id: formId },
include: { fields: { orderBy: { order: 'asc' } } }
});
if (!form) {
await interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
return;
}
const answersPayload = form.fields.map((f) => ({
fieldId: f.id,
value: interaction.fields.getTextInputValue(f.id) || ''
}));
const app = await prisma.registerApplication.create({
data: {
guildId: interaction.guildId ?? '',
userId: interaction.user.id,
formId: form.id,
status: 'pending',
answers: {
create: answersPayload
}
},
include: { answers: true }
});
await interaction.reply({ content: 'Registrierung gesendet.', ephemeral: true });
await this.postReviewEmbed(form, app, interaction.user.id, interaction.guildId || '');
}
private async postReviewEmbed(form: any, app: any, userId: string, guildId: string) {
if (!this.client) return;
const cfg = settingsStore.get(guildId);
const channelId = form.reviewChannelId || cfg?.registerConfig?.reviewChannelId;
if (!channelId) return;
const guild = this.client.guilds.cache.get(guildId) ?? (await this.client.guilds.fetch(guildId).catch(() => null));
if (!guild) return;
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 answers = await prisma.registerApplicationAnswer.findMany({ where: { applicationId: app.id } });
const embed = new EmbedBuilder()
.setTitle(`Registrierung: ${form.name}`)
.setDescription(form.description || '')
.setColor(0xf97316)
.addFields(
...fields.map((f) => ({
name: f.label,
value: answers.find((a) => a.fieldId === f.id)?.value || '-',
inline: false
}))
)
.setFooter({ text: `Status: ${app.status}` })
.setTimestamp(new Date(app.createdAt));
if (member) embed.setAuthor({ name: member.user.tag, iconURL: member.user.displayAvatarURL() });
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`register:review:accept:${app.id}`).setLabel('Akzeptieren').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId(`register:review:invite:${app.id}`).setLabel('Gespräch einladen').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId(`register:review:reject:${app.id}`).setLabel('Ablehnen').setStyle(ButtonStyle.Danger)
);
const notifyRoles = form.notifyRoleIds || cfg?.registerConfig?.notifyRoleIds || [];
const content = notifyRoles.length ? notifyRoles.map((id: string) => `<@&${id}>`).join(' ') : null;
await (channel as any).send({ content: content || undefined, embeds: [embed], components: [row] });
}
private async updateReviewMessage(interaction: ButtonInteraction, app: any) {
if (!interaction.message || !interaction.message.editable) return;
const embed = (interaction.message.embeds?.[0] as any) ?? null;
if (embed) {
embed.data = { ...(embed.data || {}), footer: { text: `Status: ${app.status} | Reviewer: ${interaction.user.tag}` } };
}
const components = interaction.message.components;
await interaction.message.edit({ embeds: embed ? [embed] : interaction.message.embeds, components });
}
public async listApplications(guildId: string, status?: string, formId?: string) {
const where: any = { guildId };
if (status) where.status = status;
if (formId) where.formId = formId;
return prisma.registerApplication.findMany({
where,
orderBy: { createdAt: 'desc' },
include: { form: true }
});
}
public async getApplication(id: string) {
return prisma.registerApplication.findUnique({
where: { id },
include: { form: true, answers: true }
});
}
}

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, dynamicVoiceEnabled: modules.dynamicVoiceEnabled !== false,
statuspageEnabled: (modules as any).statuspageEnabled !== false, statuspageEnabled: (modules as any).statuspageEnabled !== false,
birthdayEnabled: (modules as any).birthdayEnabled !== 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
} }
} }
}); });
@@ -543,6 +544,82 @@ router.delete('/reactionroles/:id', requireAuth, async (req, res) => {
} }
}); });
router.get('/register/forms', 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 forms = await context.register.listForms(guildId);
res.json({ forms });
});
router.post('/register/forms', 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 data = req.body || {};
const form = await context.register.saveForm({
guildId,
name: data.name || 'Formular',
description: data.description || '',
reviewChannelId: data.reviewChannelId || undefined,
notifyRoleIds: Array.isArray(data.notifyRoleIds) ? data.notifyRoleIds : typeof data.notifyRoleIds === 'string' ? data.notifyRoleIds.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
isActive: data.isActive !== false,
fields: Array.isArray(data.fields) ? data.fields : []
});
res.json({ form });
});
router.put('/register/forms/:id', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
const id = req.params.id;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const data = req.body || {};
const form = await context.register.saveForm({
id,
guildId,
name: data.name || 'Formular',
description: data.description || '',
reviewChannelId: data.reviewChannelId || undefined,
notifyRoleIds: Array.isArray(data.notifyRoleIds) ? data.notifyRoleIds : typeof data.notifyRoleIds === 'string' ? data.notifyRoleIds.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
isActive: data.isActive !== false,
fields: Array.isArray(data.fields) ? data.fields : []
});
res.json({ form });
});
router.delete('/register/forms/:id', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
const id = req.params.id;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const ok = await context.register.deleteForm(guildId, id);
if (!ok) return res.status(404).json({ error: 'not found' });
res.json({ ok: true });
});
router.post('/register/forms/:id/panel', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
const id = req.params.id;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const channelId = typeof req.body.channelId === 'string' ? req.body.channelId : undefined;
const message = typeof req.body.message === 'string' ? req.body.message : undefined;
const msgId = await context.register.sendPanel(guildId, id, channelId, message);
if (!msgId) return res.status(400).json({ error: 'panel failed' });
res.json({ ok: true, messageId: msgId });
});
router.get('/register/apps', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
const formId = typeof req.query.formId === 'string' ? req.query.formId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const apps = await context.register.listApplications(guildId, status, formId);
res.json({ applications: apps });
});
router.get('/register/apps/:id', requireAuth, async (req, res) => {
const app = await context.register.getApplication(req.params.id);
if (!app) return res.status(404).json({ error: 'not found' });
res.json({ application: app });
});
router.get('/automations', requireAuth, async (req, res) => { router.get('/automations', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined; const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' }); if (!guildId) return res.status(400).json({ error: 'guildId required' });
@@ -678,6 +755,27 @@ router.delete('/statuspage/service/:id', requireAuth, async (req, res) => {
res.json({ ok: true }); 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) => { router.post('/settings', requireAuth, async (req, res) => {
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {}; const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
const { const {
@@ -701,7 +799,11 @@ router.post('/settings', requireAuth, async (req, res) => {
birthdayEnabled, birthdayEnabled,
birthdayConfig, birthdayConfig,
reactionRolesEnabled, reactionRolesEnabled,
reactionRolesConfig reactionRolesConfig,
registerEnabled,
registerConfig,
serverStatsEnabled,
serverStatsConfig
} = req.body; } = req.body;
if (!guildId) return res.status(400).json({ error: 'guildId required' }); if (!guildId) return res.status(400).json({ error: 'guildId required' });
const normalizeArray = (val: any) => const normalizeArray = (val: any) =>
@@ -798,6 +900,22 @@ router.post('/settings', requireAuth, async (req, res) => {
channelId: reactionRolesConfig?.channelId ?? existingReactionRoles.channelId channelId: reactionRolesConfig?.channelId ?? existingReactionRoles.channelId
}; };
const parsedRegister = {
enabled:
registerConfig?.enabled ??
(typeof registerEnabled === 'string' ? registerEnabled === 'true' : registerEnabled ?? (current as any).registerEnabled),
reviewChannelId: registerConfig?.reviewChannelId ?? (current as any).registerConfig?.reviewChannelId,
notifyRoleIds:
Array.isArray(registerConfig?.notifyRoleIds) && registerConfig?.notifyRoleIds.length
? registerConfig.notifyRoleIds
: typeof registerConfig?.notifyRoleIds === 'string'
? registerConfig.notifyRoleIds
.split(',')
.map((s: string) => s.trim())
.filter(Boolean)
: (current as any).registerConfig?.notifyRoleIds || []
};
const updated = await settingsStore.set(guildId, { const updated = await settingsStore.set(guildId, {
welcomeChannelId: welcomeChannelId ?? undefined, welcomeChannelId: welcomeChannelId ?? undefined,
logChannelId: logChannelId ?? undefined, logChannelId: logChannelId ?? undefined,
@@ -817,7 +935,11 @@ router.post('/settings', requireAuth, async (req, res) => {
birthdayEnabled: parsedBirthday.enabled, birthdayEnabled: parsedBirthday.enabled,
birthdayConfig: parsedBirthday, birthdayConfig: parsedBirthday,
reactionRolesEnabled: parsedReactionRoles.enabled, reactionRolesEnabled: parsedReactionRoles.enabled,
reactionRolesConfig: parsedReactionRoles reactionRolesConfig: parsedReactionRoles,
registerEnabled: parsedRegister.enabled,
registerConfig: parsedRegister,
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
serverStatsConfig: serverStatsConfig
}); });
// Live update logging target // Live update logging target
context.logging = new LoggingService(updated.logChannelId); context.logging = new LoggingService(updated.logChannelId);

View File

@@ -46,15 +46,16 @@ router.get('/', (req, res) => {
<a class="active" href="#overview" data-target="overview"><span class="icon">🏠</span> Uebersicht</a> <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="#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="#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="#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="#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="#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="#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="#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="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</a>
<a href="#modules" data-target="modules"><span class="icon">🧩</span> Module</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="#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>
<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> <button id="logoutBtn" class="logout">Logout</button>
@@ -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 class="card" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;">
<div> <div>
<p class="section-title">Tickets</p> <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>
<div class="row" style="gap:8px; flex-wrap:wrap;"> <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="pipeline">Pipeline</button>
<button class="secondary-btn ticket-tab-btn" data-tab="sla">SLA</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> <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 style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div> <div>
<p class="section-title">Ticketliste</p> <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>
<div class="row" style="gap:10px;"> <div class="row" style="gap:10px;">
<button class="secondary-btn" id="openSupportLogin" type="button">Support-Login Einstellungen</button> <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 style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
<div> <div>
<p class="section-title">Status-Pipeline</p> <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> </div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;" id="pipelineGrid"> <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 style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div> <div>
<p class="section-title">Automationen</p> <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> </div>
<button class="secondary-btn" id="addAutomation">Neue Regel</button> <button class="secondary-btn" id="addAutomation">Neue Regel</button>
</div> </div>
@@ -467,7 +468,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;"> <div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div> <div>
<p class="section-title">Knowledge-Base</p> <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> </div>
<button class="secondary-btn" id="addKb">Neuer Artikel</button> <button class="secondary-btn" id="addKb">Neuer Artikel</button>
</div> </div>
@@ -782,6 +783,33 @@ router.get('/', (req, res) => {
<div id="statuspageServices" class="module-list"></div> <div id="statuspageServices" class="module-list"></div>
</section> </section>
</div> </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"> <div class="section" data-section="events">
<section class="card"> <section class="card">
<div class="row" style="justify-content:space-between; align-items:center;"> <div class="row" style="justify-content:space-between; align-items:center;">
@@ -1203,6 +1231,7 @@ router.get('/', (req, res) => {
let activeModal = null; let activeModal = null;
let automodConfigCache = {}; let automodConfigCache = {};
let modulesCache = {}; let modulesCache = {};
let serverStatsCache = { items: [] };
let dynamicVoiceCache = {}; let dynamicVoiceCache = {};
let isAdmin = false; let isAdmin = false;
let statuspageCache = { services: [] }; let statuspageCache = { services: [] };
@@ -1240,6 +1269,7 @@ router.get('/', (req, res) => {
const welcomeEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'welcomeEnabled') ? modulesCache['welcomeEnabled'] : true; const welcomeEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'welcomeEnabled') ? modulesCache['welcomeEnabled'] : true;
const dynamicVoiceEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'dynamicVoiceEnabled') ? modulesCache['dynamicVoiceEnabled'] : true; const dynamicVoiceEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'dynamicVoiceEnabled') ? modulesCache['dynamicVoiceEnabled'] : true;
const statuspageEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'statuspageEnabled') ? modulesCache['statuspageEnabled'] : 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 birthdayEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'birthdayEnabled') ? modulesCache['birthdayEnabled'] : true;
const reactionRolesEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'reactionRolesEnabled') ? modulesCache['reactionRolesEnabled'] : true; const reactionRolesEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'reactionRolesEnabled') ? modulesCache['reactionRolesEnabled'] : true;
const eventsEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'eventsEnabled') ? modulesCache['eventsEnabled'] : 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); if (dynamicVoiceNav) dynamicVoiceNav.classList.toggle('hidden', !dynamicVoiceEnabled);
const statuspageNav = document.querySelector('.nav .statuspage-link'); const statuspageNav = document.querySelector('.nav .statuspage-link');
if (statuspageNav) statuspageNav.classList.toggle('hidden', !statuspageEnabled); 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'); const birthdayNav = document.querySelector('.nav .birthday-link');
if (birthdayNav) birthdayNav.classList.toggle('hidden', !birthdayEnabled); if (birthdayNav) birthdayNav.classList.toggle('hidden', !birthdayEnabled);
const reactionRolesNav = document.querySelector('.nav .reactionroles-link'); const reactionRolesNav = document.querySelector('.nav .reactionroles-link');
@@ -1262,6 +1294,7 @@ router.get('/', (req, res) => {
(current === 'welcome' && !welcomeEnabled) || (current === 'welcome' && !welcomeEnabled) ||
(current === 'dynamicvoice' && !dynamicVoiceEnabled) || (current === 'dynamicvoice' && !dynamicVoiceEnabled) ||
(current === 'statuspage' && !statuspageEnabled) || (current === 'statuspage' && !statuspageEnabled) ||
(current === 'serverstats' && !serverStatsEnabled) ||
(current === 'birthday' && !birthdayEnabled) || (current === 'birthday' && !birthdayEnabled) ||
(current === 'reactionroles' && !reactionRolesEnabled) || (current === 'reactionroles' && !reactionRolesEnabled) ||
(current === 'events' && !eventsEnabled) || (current === 'events' && !eventsEnabled) ||
@@ -1372,6 +1405,103 @@ router.get('/', (req, res) => {
if (!logs.length) guildLogs.innerHTML = '<li class="muted">Keine Logs</li>'; 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() { async function loadStatuspage() {
if (!currentGuild) return; if (!currentGuild) return;
const res = await fetch('/api/statuspage?guildId=' + encodeURIComponent(currentGuild)); const res = await fetch('/api/statuspage?guildId=' + encodeURIComponent(currentGuild));
@@ -1755,7 +1885,7 @@ router.get('/', (req, res) => {
'</div>' + '</div>' +
'<div class=\"ticket-meta\">User: ' + '<div class=\"ticket-meta\">User: ' +
(t.userId || '-') + (t.userId || '-') +
(t.claimedBy ? ' <EFBFBD> Supporter: ' + t.claimedBy : '') + (t.claimedBy ? ' Supporter: ' + t.claimedBy : '') +
'</div>'; '</div>';
const select = document.createElement('select'); const select = document.createElement('select');
select.innerHTML = select.innerHTML =
@@ -1869,14 +1999,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillAutomationForm(r)); edit.addEventListener('click', () => fillAutomationForm(r));
const del = document.createElement('button'); const del = document.createElement('button');
del.className = 'danger-btn'; del.className = 'danger-btn';
del.textContent = 'L<EFBFBD>schen'; del.textContent = 'Lschen';
del.addEventListener('click', async () => { del.addEventListener('click', async () => {
const res = await fetch('/api/automations/' + r.id, { const res = await fetch('/api/automations/' + r.id, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild }) 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(); if (res.ok) loadAutomations();
}); });
actions.appendChild(edit); actions.appendChild(edit);
@@ -1936,14 +2066,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillKbForm(a)); edit.addEventListener('click', () => fillKbForm(a));
const del = document.createElement('button'); const del = document.createElement('button');
del.className = 'danger-btn'; del.className = 'danger-btn';
del.textContent = 'L<EFBFBD>schen'; del.textContent = 'Lschen';
del.addEventListener('click', async () => { del.addEventListener('click', async () => {
const res = await fetch('/api/kb/' + a.id, { const res = await fetch('/api/kb/' + a.id, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild }) 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(); if (res.ok) loadKb();
}); });
actions.appendChild(edit); actions.appendChild(edit);
@@ -1991,6 +2121,7 @@ router.get('/', (req, res) => {
modulesCache['welcomeEnabled'] = (cfg.welcomeConfig?.enabled ?? cfg.automodConfig?.welcomeConfig?.enabled ?? true) !== false; modulesCache['welcomeEnabled'] = (cfg.welcomeConfig?.enabled ?? cfg.automodConfig?.welcomeConfig?.enabled ?? true) !== false;
modulesCache['dynamicVoiceEnabled'] = cfg.dynamicVoiceEnabled !== false; modulesCache['dynamicVoiceEnabled'] = cfg.dynamicVoiceEnabled !== false;
modulesCache['statuspageEnabled'] = cfg.statuspageEnabled !== false && cfg.automodConfig?.statuspageEnabled !== 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['birthdayEnabled'] = cfg.birthdayEnabled !== false && cfg.birthdayConfig?.enabled !== false;
modulesCache['reactionRolesEnabled'] = cfg.reactionRolesEnabled !== false && cfg.reactionRolesConfig?.enabled !== false; modulesCache['reactionRolesEnabled'] = cfg.reactionRolesEnabled !== false && cfg.reactionRolesConfig?.enabled !== false;
modulesCache['eventsEnabled'] = cfg.eventsEnabled !== false; modulesCache['eventsEnabled'] = cfg.eventsEnabled !== false;
@@ -2457,6 +2588,7 @@ router.get('/', (req, res) => {
list.innerHTML = ''; list.innerHTML = '';
let ticketsActive = false; let ticketsActive = false;
let statuspageActive = false; let statuspageActive = false;
let serverStatsActive = false;
let birthdayActive = false; let birthdayActive = false;
let reactionRolesActive = false; let reactionRolesActive = false;
let eventsActive = false; let eventsActive = false;
@@ -2481,6 +2613,7 @@ router.get('/', (req, res) => {
if (m.key === 'automodEnabled') modulesCache['automodEnabled'] = willEnable; if (m.key === 'automodEnabled') modulesCache['automodEnabled'] = willEnable;
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = willEnable; if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = willEnable;
if (m.key === 'statuspageEnabled') modulesCache['statuspageEnabled'] = 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 === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable;
if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable; if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable;
if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = 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 === 'welcomeEnabled') modulesCache['welcomeEnabled'] = !!m.enabled;
if (m.key === 'dynamicVoiceEnabled') modulesCache['dynamicVoiceEnabled'] = !!m.enabled; if (m.key === 'dynamicVoiceEnabled') modulesCache['dynamicVoiceEnabled'] = !!m.enabled;
if (m.key === 'statuspageEnabled') statuspageActive = !!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 === 'birthdayEnabled') birthdayActive = !!m.enabled;
if (m.key === 'reactionRolesEnabled') reactionRolesActive = !!m.enabled; if (m.key === 'reactionRolesEnabled') reactionRolesActive = !!m.enabled;
if (m.key === 'eventsEnabled') eventsActive = !!m.enabled; if (m.key === 'eventsEnabled') eventsActive = !!m.enabled;
@@ -2504,6 +2638,7 @@ router.get('/', (req, res) => {
applyNavVisibility(); applyNavVisibility();
applyTicketsVisibility(ticketsActive); applyTicketsVisibility(ticketsActive);
if (statuspageActive) loadStatuspage(); if (statuspageActive) loadStatuspage();
if (serverStatsActive) loadServerStats();
if (birthdayActive) loadBirthday(); if (birthdayActive) loadBirthday();
if (reactionRolesActive) loadReactionRoles(); if (reactionRolesActive) loadReactionRoles();
if (eventsActive) loadEvents(); if (eventsActive) loadEvents();
@@ -2512,7 +2647,7 @@ router.get('/', (req, res) => {
async function saveModuleToggle(key, enabled) { async function saveModuleToggle(key, enabled) {
if (!currentGuild) return false; if (!currentGuild) return false;
const payload = { guildId: currentGuild }; 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]; if (modulesCache[k] !== undefined) payload[k] = modulesCache[k];
}); });
payload['dynamicVoiceEnabled'] = modulesCache['dynamicVoiceEnabled']; payload['dynamicVoiceEnabled'] = modulesCache['dynamicVoiceEnabled'];
@@ -2716,6 +2851,10 @@ router.get('/', (req, res) => {
if (statuspageInterval) statuspageInterval.addEventListener('change', saveStatuspageConfig); if (statuspageInterval) statuspageInterval.addEventListener('change', saveStatuspageConfig);
if (statuspageChannel) statuspageChannel.addEventListener('change', saveStatuspageConfig); if (statuspageChannel) statuspageChannel.addEventListener('change', saveStatuspageConfig);
if (statuspageAddService) statuspageAddService.addEventListener('click', addServicePrompt); 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) => { [welcomeTitle, welcomeDescription, welcomeFooter, welcomeColor].forEach((el) => {
if (el) el.addEventListener('input', updateWelcomePreview); if (el) el.addEventListener('input', updateWelcomePreview);
}); });