Compare commits
21 Commits
67643cb54d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b95e7fd85 | ||
|
|
8c53160812 | ||
|
|
c18441eb9a | ||
|
|
c95444feac | ||
|
|
544f04655c | ||
|
|
85951ecfb4 | ||
|
|
9579dc7510 | ||
|
|
8127e81564 | ||
|
|
975d0552bf | ||
|
|
aefb5b3c72 | ||
|
|
f4f4efb722 | ||
|
|
313b2c0613 | ||
|
|
71a716e214 | ||
|
|
afac2e7c68 | ||
|
|
cfc4559312 | ||
|
|
bebca808b0 | ||
|
|
78578fcc1c | ||
|
|
5aef575f41 | ||
|
|
c66da87207 | ||
|
|
962ee4aafc | ||
|
|
27f96092dd |
81
node_modules/.prisma/client/edge.js
generated
vendored
81
node_modules/.prisma/client/edge.js
generated
vendored
File diff suppressed because one or more lines are too long
75
node_modules/.prisma/client/index-browser.js
generated
vendored
75
node_modules/.prisma/client/index-browser.js
generated
vendored
@@ -141,6 +141,10 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
|
||||
reactionRolesEnabled: 'reactionRolesEnabled',
|
||||
reactionRolesConfig: 'reactionRolesConfig',
|
||||
eventsEnabled: 'eventsEnabled',
|
||||
registerEnabled: 'registerEnabled',
|
||||
registerConfig: 'registerConfig',
|
||||
serverStatsEnabled: 'serverStatsEnabled',
|
||||
serverStatsConfig: 'serverStatsConfig',
|
||||
supportRoleId: 'supportRoleId',
|
||||
updatedAt: 'updatedAt',
|
||||
createdAt: 'createdAt'
|
||||
@@ -157,6 +161,30 @@ exports.Prisma.TicketScalarFieldEnum = {
|
||||
status: 'status',
|
||||
claimedBy: 'claimedBy',
|
||||
transcript: 'transcript',
|
||||
firstClaimAt: 'firstClaimAt',
|
||||
firstResponseAt: 'firstResponseAt',
|
||||
kbSuggestionSentAt: 'kbSuggestionSentAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.TicketAutomationRuleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
name: 'name',
|
||||
condition: 'condition',
|
||||
action: 'action',
|
||||
active: 'active',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.KnowledgeBaseArticleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
title: 'title',
|
||||
keywords: 'keywords',
|
||||
content: 'content',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
@@ -228,6 +256,45 @@ exports.Prisma.EventSignupScalarFieldEnum = {
|
||||
canceledAt: 'canceledAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterFormScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
reviewChannelId: 'reviewChannelId',
|
||||
notifyRoleIds: 'notifyRoleIds',
|
||||
isActive: 'isActive',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterFormFieldScalarFieldEnum = {
|
||||
id: 'id',
|
||||
formId: 'formId',
|
||||
label: 'label',
|
||||
type: 'type',
|
||||
required: 'required',
|
||||
order: 'order'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterApplicationScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
userId: 'userId',
|
||||
formId: 'formId',
|
||||
status: 'status',
|
||||
reviewedBy: 'reviewedBy',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
|
||||
id: 'id',
|
||||
applicationId: 'applicationId',
|
||||
fieldId: 'fieldId',
|
||||
value: 'value'
|
||||
};
|
||||
|
||||
exports.Prisma.SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -262,12 +329,18 @@ exports.Prisma.NullsOrder = {
|
||||
exports.Prisma.ModelName = {
|
||||
GuildSettings: 'GuildSettings',
|
||||
Ticket: 'Ticket',
|
||||
TicketAutomationRule: 'TicketAutomationRule',
|
||||
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
|
||||
Level: 'Level',
|
||||
TicketSupportSession: 'TicketSupportSession',
|
||||
Birthday: 'Birthday',
|
||||
ReactionRoleSet: 'ReactionRoleSet',
|
||||
Event: 'Event',
|
||||
EventSignup: 'EventSignup'
|
||||
EventSignup: 'EventSignup',
|
||||
RegisterForm: 'RegisterForm',
|
||||
RegisterFormField: 'RegisterFormField',
|
||||
RegisterApplication: 'RegisterApplication',
|
||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
8569
node_modules/.prisma/client/index.d.ts
generated
vendored
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
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
75
node_modules/.prisma/client/wasm.js
generated
vendored
@@ -141,6 +141,10 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
|
||||
reactionRolesEnabled: 'reactionRolesEnabled',
|
||||
reactionRolesConfig: 'reactionRolesConfig',
|
||||
eventsEnabled: 'eventsEnabled',
|
||||
registerEnabled: 'registerEnabled',
|
||||
registerConfig: 'registerConfig',
|
||||
serverStatsEnabled: 'serverStatsEnabled',
|
||||
serverStatsConfig: 'serverStatsConfig',
|
||||
supportRoleId: 'supportRoleId',
|
||||
updatedAt: 'updatedAt',
|
||||
createdAt: 'createdAt'
|
||||
@@ -157,6 +161,30 @@ exports.Prisma.TicketScalarFieldEnum = {
|
||||
status: 'status',
|
||||
claimedBy: 'claimedBy',
|
||||
transcript: 'transcript',
|
||||
firstClaimAt: 'firstClaimAt',
|
||||
firstResponseAt: 'firstResponseAt',
|
||||
kbSuggestionSentAt: 'kbSuggestionSentAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.TicketAutomationRuleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
name: 'name',
|
||||
condition: 'condition',
|
||||
action: 'action',
|
||||
active: 'active',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.KnowledgeBaseArticleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
title: 'title',
|
||||
keywords: 'keywords',
|
||||
content: 'content',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
@@ -228,6 +256,45 @@ exports.Prisma.EventSignupScalarFieldEnum = {
|
||||
canceledAt: 'canceledAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterFormScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
reviewChannelId: 'reviewChannelId',
|
||||
notifyRoleIds: 'notifyRoleIds',
|
||||
isActive: 'isActive',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterFormFieldScalarFieldEnum = {
|
||||
id: 'id',
|
||||
formId: 'formId',
|
||||
label: 'label',
|
||||
type: 'type',
|
||||
required: 'required',
|
||||
order: 'order'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterApplicationScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
userId: 'userId',
|
||||
formId: 'formId',
|
||||
status: 'status',
|
||||
reviewedBy: 'reviewedBy',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
|
||||
id: 'id',
|
||||
applicationId: 'applicationId',
|
||||
fieldId: 'fieldId',
|
||||
value: 'value'
|
||||
};
|
||||
|
||||
exports.Prisma.SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -262,12 +329,18 @@ exports.Prisma.NullsOrder = {
|
||||
exports.Prisma.ModelName = {
|
||||
GuildSettings: 'GuildSettings',
|
||||
Ticket: 'Ticket',
|
||||
TicketAutomationRule: 'TicketAutomationRule',
|
||||
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
|
||||
Level: 'Level',
|
||||
TicketSupportSession: 'TicketSupportSession',
|
||||
Birthday: 'Birthday',
|
||||
ReactionRoleSet: 'ReactionRoleSet',
|
||||
Event: 'Event',
|
||||
EventSignup: 'EventSignup'
|
||||
EventSignup: 'EventSignup',
|
||||
RegisterForm: 'RegisterForm',
|
||||
RegisterFormField: 'RegisterFormField',
|
||||
RegisterApplication: 'RegisterApplication',
|
||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
@@ -9,26 +9,31 @@ datasource db {
|
||||
}
|
||||
|
||||
model GuildSettings {
|
||||
guildId String @id
|
||||
welcomeChannelId String?
|
||||
logChannelId String?
|
||||
automodEnabled Boolean?
|
||||
automodConfig Json?
|
||||
levelingEnabled Boolean?
|
||||
ticketsEnabled Boolean?
|
||||
musicEnabled Boolean?
|
||||
statuspageEnabled Boolean?
|
||||
statuspageConfig Json?
|
||||
guildId String @id
|
||||
welcomeChannelId String?
|
||||
logChannelId String?
|
||||
automodEnabled Boolean?
|
||||
automodConfig Json?
|
||||
levelingEnabled Boolean?
|
||||
ticketsEnabled Boolean?
|
||||
musicEnabled Boolean?
|
||||
statuspageEnabled Boolean?
|
||||
statuspageConfig Json?
|
||||
dynamicVoiceEnabled Boolean?
|
||||
dynamicVoiceConfig Json?
|
||||
supportLoginConfig Json?
|
||||
birthdayEnabled Boolean?
|
||||
birthdayConfig Json?
|
||||
supportLoginConfig Json?
|
||||
birthdayEnabled Boolean?
|
||||
birthdayConfig Json?
|
||||
reactionRolesEnabled Boolean?
|
||||
reactionRolesConfig Json?
|
||||
eventsEnabled Boolean?\n registerEnabled Boolean?\n registerConfig Json?\n supportRoleId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
eventsEnabled Boolean?
|
||||
registerEnabled Boolean?
|
||||
registerConfig Json?
|
||||
serverStatsEnabled Boolean?
|
||||
serverStatsConfig Json?
|
||||
supportRoleId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
@@ -172,18 +177,17 @@ model RegisterForm {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
fields RegisterFormField[]
|
||||
applications RegisterApplication[]
|
||||
|
||||
@@index([guildId, isActive])
|
||||
}
|
||||
|
||||
model RegisterFormField {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
label String
|
||||
type String
|
||||
required Boolean @default(false)
|
||||
sortOrder Int @default(0)
|
||||
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)
|
||||
}
|
||||
|
||||
75
readme.md
75
readme.md
@@ -1,73 +1,64 @@
|
||||
# Papo Discord Bot
|
||||
|
||||
Discord-Bot (discord.js 14, TypeScript) mit Web-Dashboard, Prisma/PostgreSQL und Docker-Support.
|
||||
Discord-Bot (discord.js 14, TypeScript) mit Web-Dashboard, Prisma/PostgreSQL und Docker-Support..
|
||||
|
||||
## Was drin ist
|
||||
- Ticketsystem: Slash-Commands (/ticket, /claim, /close, /ticketpriority, /ticketstatus, /transcript, /ticketpanel), Panels, Transcripts unter `./transcripts`, Support-Login-Panel mit Rollen-Vergabe/On-Duty-Logging.
|
||||
- Automod: Link-Filter (Whitelist), Spam/Caps-Erkennung, Bad-Word-Listen (Custom), Timeouts, Logging.
|
||||
- Musik: play/skip/stop/pause/resume/loop, Queue, aktivierbar/deaktivierbar pro Guild.
|
||||
- Welcome: konfigurierbare Embeds (Channel, Farbe, Texte, Bilder/Uploads), Preview im Dashboard, Text-Fallback.
|
||||
- Logging: Join/Leave, Message Edit/Delete, Automod/Ticket/Musik-Events mit konfigurierbarem Log-Channel/Kategorien.
|
||||
- Leveling: XP/Level pro Nachricht, /rank, toggelbar.
|
||||
- Dynamische Voice: Lobby erzeugt private Voice-Channels mit Template/Userlimit.
|
||||
- Birthday: /birthday + geplante Glueckwuensche mit Template/Channel.
|
||||
- Reaction Roles: Verwaltung im Dashboard, Sync/Loeschen/Erstellen.
|
||||
- Events: Einmalig/recurring, Reminder, Signups, Buttons.
|
||||
- Statuspage-Modul vorhanden (Config/API), plus Modul-Toggles im Dashboard.
|
||||
- Dashboard: OAuth2 (Scopes identify, guilds), zeigt nur Guilds, die der Nutzer besitzt oder mit Manage Guild/Admin-Rechten verwalten darf **und** in denen der Bot ist. Modulabhaengige Navigation.
|
||||
- Rich Presence: rotiert mit `/help`, Dashboard-URL und Guild-Zaehler.
|
||||
## Highlights
|
||||
- Ticketsystem mit Panels, Transcripts und Support-Login (Slash-Commands wie `/ticket`, `/claim`, `/close`).
|
||||
- Automod (Link-Whitelist, Spam/Caps, Bad-Word-Listen), Logging für relevante Events.
|
||||
- Musik (play/skip/stop/pause/resume/loop) pro Guild aktivierbar.
|
||||
- Welcome, Leveling, dynamische Voice, Birthdays, Reaction Roles, Events mit Remindern.
|
||||
- Statuspage-Modul, Rich Presence und modulbasierte Dashboard-Navigation.
|
||||
|
||||
## Tech-Stack
|
||||
- Node.js 20 (Docker-Basis), TypeScript (CommonJS)
|
||||
- Node.js 20, TypeScript (CommonJS)
|
||||
- discord.js 14, play-dl, @discordjs/voice
|
||||
- Express + OAuth2-Login, Prisma ORM (PostgreSQL)
|
||||
- Dockerfile + docker-compose (App + Postgres)
|
||||
- Express + OAuth2-Login
|
||||
- Prisma ORM (PostgreSQL)
|
||||
- Dockerfile + docker-compose
|
||||
|
||||
## Setup (lokal, Entwicklung)
|
||||
## Quickstart (lokal)
|
||||
1. Repo klonen, in das Verzeichnis wechseln.
|
||||
2. `cp .env.example .env` und Variablen setzen (siehe unten).
|
||||
3. Dependencies installieren: `npm ci` (oder `npm install`).
|
||||
2. `.env` anlegen: `cp .env.example .env` und Werte setzen.
|
||||
3. Abhängigkeiten: `npm ci` (oder `npm install`).
|
||||
4. Prisma: `npx prisma generate --schema=src/database/schema.prisma` und `npx prisma migrate dev --name init`.
|
||||
5. Start Dev: `npm run dev` (ts-node-dev). Dashboard und Bot laufen auf `PORT` (default 3000).
|
||||
6. Slash-Commands werden beim Start fuer die IDs in `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert.
|
||||
5. Start Dev: `npm run dev` (ts-node-dev). Dashboard/Bot auf `PORT` (Standard 3000).
|
||||
6. Slash-Commands werden beim Start für `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert.
|
||||
|
||||
## Setup mit Docker
|
||||
- `.dockerignore` blendet lokale node_modules/.env aus.
|
||||
- Dev-Stack: `docker-compose up --build` (nutzt `Dockerfile`, Postgres 15, env aus `.env`, `npm run dev` im Container).
|
||||
- Eigenes Image: `docker build .` (Prisma-Generate laeuft im Build).
|
||||
## Quickstart (Docker)
|
||||
- Dev-Stack: `docker-compose up --build` (Dockerfile + Postgres 15, env aus `.env`, startet `npm run dev`).
|
||||
- Eigenes Image: `docker build .` (Prisma-Generate läuft im Build). `.dockerignore` blendet lokale `node_modules`/`.env` aus.
|
||||
|
||||
## Environment-Variablen
|
||||
## Environment-Variablen (Auswahl)
|
||||
- `DISCORD_TOKEN` (Pflicht, Bot Token)
|
||||
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Pflicht fuer Dashboard-OAuth)
|
||||
- `DISCORD_GUILD_ID` (optional Einzel-Guild fuer Commands)
|
||||
- `DISCORD_GUILD_IDS` (kommagetrennt, mehrere Guilds)
|
||||
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Dashboard-OAuth)
|
||||
- `DISCORD_GUILD_ID` (optional Einzel-Guild) / `DISCORD_GUILD_IDS` (kommagetrennt)
|
||||
- `DATABASE_URL` (Pflicht, Postgres)
|
||||
- `PORT` (Webserver/Dashboard, default 3000)
|
||||
- `PORT` (Dashboard/Bot, default 3000)
|
||||
- `SESSION_SECRET` (Express Session Secret, default `papo_dev_secret`)
|
||||
- `DASHBOARD_BASE_URL` (Public Base URL, fuer OAuth Redirect)
|
||||
- `DASHBOARD_BASE_URL` (Public Base URL für OAuth Redirect)
|
||||
- `WEB_BASE_PATH` (Default `/ucp`, ohne Slash am Ende)
|
||||
- `OWNER_IDS` (kommagetrennte Owner fuer Admin-UI)
|
||||
- `OWNER_IDS` (kommagetrennte Owner für Admin-UI)
|
||||
- `SUPPORT_ROLE_ID` (optional Ticket/Support-Login Rolle)
|
||||
|
||||
## Datenbank / Prisma
|
||||
- Schema: `src/database/schema.prisma` (zweites Schema in `prisma/schema.prisma` fuer Binary Targets).
|
||||
- Hauptschema: `src/database/schema.prisma` (zweites in `prisma/schema.prisma` für Binary Targets).
|
||||
- Migrationen: `npx prisma migrate dev --name <name>`; danach `npx prisma generate --schema=src/database/schema.prisma`.
|
||||
- Kern-Tabellen: GuildSettings (Module/Config), Ticket, TicketSupportSession, Event/EventSignup, Birthday, ReactionRoleSet, Level.
|
||||
- Kern-Tabellen: GuildSettings, Ticket, TicketSupportSession, Event/EventSignup, RegisterForm/RegisterApplication, Birthday, ReactionRoleSet, Level.
|
||||
|
||||
## Kommandos & Scripts
|
||||
## Scripts
|
||||
- `npm run dev` – Entwicklung (ts-node-dev)
|
||||
- `npm run build` – TypeScript build
|
||||
- `npm start` – Start aus `dist`
|
||||
- Prisma-CLI: `npx prisma ...` (nutzt Schema aus `src/database/schema.prisma`)
|
||||
|
||||
## Dashboard / API Kurzinfo
|
||||
## API/Dashboard Kurzinfo
|
||||
- Auth-Gate (`/api/*`), Login `/auth/discord`, Callback `/auth/callback`, Logout `/auth/logout`.
|
||||
- `/api/guilds` filtert auf Guilds, die der eingeloggte User besitzt oder managen darf und in denen der Bot ist.
|
||||
- Module/Settings ueber `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints fuer Events, Reaction Roles, Birthday, Statuspage.
|
||||
- `/api/guilds` filtert auf Guilds, die der eingeloggte User besitzt oder managen darf **und** in denen der Bot ist.
|
||||
- Settings/Module über `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints für Events, Reaction Roles, Birthday, Statuspage.
|
||||
|
||||
## Deployment-Hinweise
|
||||
- Produktion: `npm run build` + `npm start` oder Docker-Image nutzen.
|
||||
- Transcripts werden unter `./transcripts` abgelegt (Volume mounten, falls Container).
|
||||
- Transcripts liegen unter `./transcripts` (bei Containern als Volume mounten).
|
||||
|
||||
## Credits/Lizenz
|
||||
- Autoren/Lizenz nicht hinterlegt. Bitte vor Nutzung pruefen.
|
||||
- Autoren/Lizenz nicht hinterlegt – bitte vor Nutzung prüfen.
|
||||
|
||||
@@ -23,7 +23,7 @@ const command: SlashCommand = {
|
||||
|
||||
await member.ban({ reason }).catch(() => null);
|
||||
await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` });
|
||||
context.logging.logAction(user, 'Ban', reason);
|
||||
context.logging.logAction(user, 'Ban', reason, interaction.guild);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const command: SlashCommand = {
|
||||
}
|
||||
await member.kick(reason);
|
||||
await interaction.reply({ content: `${user.tag} wurde gekickt.` });
|
||||
context.logging.logAction(user, 'Kick', reason);
|
||||
context.logging.logAction(user, 'Kick', reason, interaction.guild);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const command: SlashCommand = {
|
||||
}
|
||||
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
|
||||
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` });
|
||||
context.logging.logAction(user, 'Mute', reason);
|
||||
context.logging.logAction(user, 'Mute', reason, interaction.guild);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const command: SlashCommand = {
|
||||
|
||||
await member.ban({ reason: `${reason} | ${minutes} Minuten` });
|
||||
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` });
|
||||
context.logging.logAction(user, 'Tempban', reason);
|
||||
context.logging.logAction(user, 'Tempban', reason, interaction.guild);
|
||||
|
||||
setTimeout(async () => {
|
||||
await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null);
|
||||
|
||||
@@ -23,7 +23,7 @@ const command: SlashCommand = {
|
||||
}
|
||||
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
|
||||
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` });
|
||||
context.logging.logAction(user, 'Timeout', reason);
|
||||
context.logging.logAction(user, 'Timeout', reason, interaction.guild);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const command: SlashCommand = {
|
||||
}
|
||||
await member.timeout(null).catch(() => null);
|
||||
await interaction.reply({ content: `${user.tag} ist nun entmuted.` });
|
||||
context.logging.logAction(user, 'Unmute');
|
||||
context.logging.logAction(user, 'Unmute', undefined, interaction.guild);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,15 +4,19 @@ import { SlashCommand } from '../../utils/types';
|
||||
const command: SlashCommand = {
|
||||
data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const avatar = interaction.client.user?.displayAvatarURL({ size: 256 }) ?? null;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Papo Hilfe')
|
||||
.setDescription('Multi-Guild ready | Admin, Tickets, Musik, Automod, Dashboard')
|
||||
.setTitle('✨ Papo Hilfe')
|
||||
.setColor(0xf97316)
|
||||
.setThumbnail(avatar)
|
||||
.setDescription('Dein All-in-One Assistant: Tickets, Automod, Musik, Stats, Dashboard.')
|
||||
.addFields(
|
||||
{ name: 'Admin', value: '/ban /kick /mute /timeout /clear', inline: false },
|
||||
{ name: 'Tickets', value: '/ticket /ticketpanel /ticketpriority /ticketstatus /transcript', inline: false },
|
||||
{ name: 'Musik', value: '/play /pause /resume /skip /stop /queue /loop', inline: false },
|
||||
{ name: 'Utility', value: '/ping /configure /serverinfo /rank', inline: false }
|
||||
);
|
||||
{ name: '🛡️ Admin', value: '`/ban` `/kick` `/mute` `/timeout` `/clear`', inline: false },
|
||||
{ name: '🎫 Tickets', value: '`/ticket` `/ticketpanel` `/ticketpriority` `/ticketstatus` `/transcript`', inline: false },
|
||||
{ name: '🎵 Musik', value: '`/play` `/pause` `/resume` `/skip` `/stop` `/queue` `/loop`', inline: false },
|
||||
{ name: '📊 Server-Tools', value: '`/configure` `/serverinfo` `/rank`', inline: false }
|
||||
)
|
||||
.setFooter({ text: 'Tipp: Nutze /configure für Module & Dashboard-Link' });
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,12 +15,15 @@ import { EventService } from '../services/eventService';
|
||||
import { TicketAutomationService } from '../services/ticketAutomationService';
|
||||
import { KnowledgeBaseService } from '../services/knowledgeBaseService';
|
||||
import { RegisterService } from '../services/registerService';
|
||||
import { StatsService } from '../services/statsService';
|
||||
|
||||
const logging = new LoggingService();
|
||||
|
||||
export const context = {
|
||||
client: null as Client | null,
|
||||
commandHandler: null as CommandHandler | null,
|
||||
automod: new AutoModService(true, true),
|
||||
logging: new LoggingService(),
|
||||
logging,
|
||||
automod: new AutoModService(logging, true, true),
|
||||
music: new MusicService(),
|
||||
tickets: new TicketService(),
|
||||
leveling: new LevelService(),
|
||||
@@ -33,7 +36,8 @@ export const context = {
|
||||
events: new EventService(),
|
||||
ticketAutomation: new TicketAutomationService(),
|
||||
knowledgeBase: new KnowledgeBaseService(),
|
||||
register: new RegisterService()
|
||||
register: new RegisterService(),
|
||||
stats: new StatsService()
|
||||
};
|
||||
|
||||
context.modules.setHooks({
|
||||
@@ -63,6 +67,10 @@ context.modules.setHooks({
|
||||
},
|
||||
eventsEnabled: {
|
||||
onEnable: async (guildId: string) => context.events.tick().catch(() => undefined)
|
||||
},
|
||||
serverStatsEnabled: {
|
||||
onEnable: async (guildId: string) => context.stats.refreshGuild(guildId).catch(() => undefined),
|
||||
onDisable: async (guildId: string) => context.stats.disableGuild(guildId).catch(() => undefined)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -65,6 +65,15 @@ export interface GuildSettings {
|
||||
reviewChannelId?: string;
|
||||
notifyRoleIds?: string[];
|
||||
};
|
||||
serverStatsEnabled?: boolean;
|
||||
serverStatsConfig?: {
|
||||
enabled?: boolean;
|
||||
categoryId?: string;
|
||||
categoryName?: string;
|
||||
refreshMinutes?: number;
|
||||
cleanupOrphans?: boolean;
|
||||
items?: any[];
|
||||
};
|
||||
supportRoleId?: string;
|
||||
welcomeEnabled?: boolean;
|
||||
}
|
||||
@@ -74,23 +83,23 @@ class SettingsStore {
|
||||
|
||||
private applyModuleDefaults(cfg: GuildSettings): GuildSettings {
|
||||
const normalized: GuildSettings = { ...cfg };
|
||||
(
|
||||
[
|
||||
'ticketsEnabled',
|
||||
'automodEnabled',
|
||||
'welcomeEnabled',
|
||||
'levelingEnabled',
|
||||
'musicEnabled',
|
||||
'dynamicVoiceEnabled',
|
||||
'statuspageEnabled',
|
||||
'birthdayEnabled',
|
||||
'reactionRolesEnabled',
|
||||
'eventsEnabled',
|
||||
'registerEnabled'
|
||||
] as const
|
||||
).forEach((key) => {
|
||||
const defaultOn = [
|
||||
'ticketsEnabled',
|
||||
'automodEnabled',
|
||||
'welcomeEnabled',
|
||||
'levelingEnabled',
|
||||
'musicEnabled',
|
||||
'dynamicVoiceEnabled',
|
||||
'statuspageEnabled',
|
||||
'birthdayEnabled',
|
||||
'reactionRolesEnabled',
|
||||
'eventsEnabled',
|
||||
'registerEnabled'
|
||||
] as const;
|
||||
defaultOn.forEach((key) => {
|
||||
if (normalized[key] === undefined) normalized[key] = true;
|
||||
});
|
||||
if (normalized.serverStatsEnabled === undefined) normalized.serverStatsEnabled = false;
|
||||
// keep welcomeConfig flag in sync when present
|
||||
if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) {
|
||||
normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled };
|
||||
@@ -124,6 +133,8 @@ class SettingsStore {
|
||||
reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined,
|
||||
registerEnabled: (row as any).registerEnabled ?? undefined,
|
||||
registerConfig: (row as any).registerConfig ?? undefined,
|
||||
serverStatsEnabled: (row as any).serverStatsEnabled ?? undefined,
|
||||
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
|
||||
supportRoleId: row.supportRoleId ?? undefined
|
||||
} satisfies GuildSettings;
|
||||
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
|
||||
@@ -206,6 +217,8 @@ class SettingsStore {
|
||||
reactionRolesConfig: merged.reactionRolesConfig ?? null,
|
||||
registerEnabled: merged.registerEnabled ?? null,
|
||||
registerConfig: merged.registerConfig ?? null,
|
||||
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
|
||||
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
|
||||
supportRoleId: merged.supportRoleId ?? null
|
||||
},
|
||||
create: {
|
||||
@@ -228,6 +241,8 @@ class SettingsStore {
|
||||
reactionRolesConfig: merged.reactionRolesConfig ?? null,
|
||||
registerEnabled: merged.registerEnabled ?? null,
|
||||
registerConfig: merged.registerConfig ?? null,
|
||||
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
|
||||
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
|
||||
supportRoleId: merged.supportRoleId ?? null
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Placeholder recreated because this migration was applied in the database already.
|
||||
-- No schema changes required locally; keeps migration history aligned.
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add server stats module configuration
|
||||
ALTER TABLE "GuildSettings"
|
||||
ADD COLUMN "serverStatsEnabled" BOOLEAN,
|
||||
ADD COLUMN "serverStatsConfig" JSONB;
|
||||
@@ -28,6 +28,8 @@ model GuildSettings {
|
||||
eventsEnabled Boolean?
|
||||
registerEnabled Boolean?
|
||||
registerConfig Json?
|
||||
serverStatsEnabled Boolean?
|
||||
serverStatsConfig Json?
|
||||
supportRoleId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
@@ -180,12 +182,12 @@ model RegisterForm {
|
||||
}
|
||||
|
||||
model RegisterFormField {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
label String
|
||||
type String
|
||||
required Boolean @default(false)
|
||||
sortOrder Int @default(0)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const event: EventHandler = {
|
||||
execute(channel: GuildChannel) {
|
||||
if (!channel.guild) return;
|
||||
context.logging.logSystem(channel.guild, `Channel erstellt: ${channel.name} (${channel.id})`);
|
||||
context.stats.refreshGuild(channel.guild.id).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const event: EventHandler = {
|
||||
execute(channel: GuildChannel) {
|
||||
if (!channel.guild) return;
|
||||
context.logging.logSystem(channel.guild, `Channel gelöscht: ${channel.name} (${channel.id})`);
|
||||
context.stats.refreshGuild(channel.guild.id).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { context } from '../config/context';
|
||||
const event: EventHandler = {
|
||||
name: 'guildBanAdd',
|
||||
execute(ban: GuildBan) {
|
||||
context.logging.logAction(ban.user, 'Ban');
|
||||
context.logging.logAction(ban.user, 'Ban', undefined, ban.guild);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,8 +16,11 @@ const event: EventHandler = {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(welcomeCfg.embedTitle || 'Willkommen!')
|
||||
.setDescription(welcomeCfg.embedDescription || `${member} ist beigetreten.`)
|
||||
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal)
|
||||
.setFooter({ text: welcomeCfg.embedFooter || '' });
|
||||
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal);
|
||||
const footerText = (welcomeCfg.embedFooter || '').trim();
|
||||
if (footerText) {
|
||||
embed.setFooter({ text: footerText });
|
||||
}
|
||||
if (welcomeCfg.embedThumbnailData && welcomeCfg.embedThumbnailData.startsWith('data:')) {
|
||||
const [meta, b64] = welcomeCfg.embedThumbnailData.split(',');
|
||||
const ext = meta.includes('gif') ? 'gif' : 'png';
|
||||
@@ -47,6 +50,7 @@ const event: EventHandler = {
|
||||
}
|
||||
}
|
||||
context.logging.logMemberJoin(member);
|
||||
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const event: EventHandler = {
|
||||
name: 'guildMemberRemove',
|
||||
execute(member: GuildMember) {
|
||||
context.logging.logMemberLeave(member);
|
||||
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const event: EventHandler = {
|
||||
async execute(message: Message) {
|
||||
const cfg = message.guildId ? settingsStore.get(message.guildId) : undefined;
|
||||
if (message.guildId) context.admin.trackEvent('message', message.guildId);
|
||||
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg.automodConfig);
|
||||
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg);
|
||||
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
|
||||
// Ticket SLA + KB
|
||||
await context.tickets.trackFirstResponse(message);
|
||||
|
||||
@@ -38,6 +38,10 @@ const event: EventHandler = {
|
||||
for (const gid of settingsStore.all().keys()) {
|
||||
context.reactionRoles.resyncGuild(gid).catch((err) => logger.warn(`reaction roles sync failed for ${gid}: ${err}`));
|
||||
}
|
||||
context.stats.startScheduler();
|
||||
for (const [gid] of client.guilds.cache) {
|
||||
context.stats.refreshGuild(gid).catch((err) => logger.warn(`stats refresh failed for ${gid}: ${err}`));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Ready handler failed: ${err}`);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ async function bootstrap() {
|
||||
context.events.setClient(client);
|
||||
context.events.startScheduler();
|
||||
context.register.setClient(client);
|
||||
context.stats.setClient(client);
|
||||
await context.reactionRoles.loadCache();
|
||||
logger.setSink((entry) => context.admin.pushLog(entry));
|
||||
for (const gid of settingsStore.all().keys()) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Collection, Message, PermissionFlagsBits } from 'discord.js';
|
||||
import { Collection, Message } from 'discord.js';
|
||||
import { logger } from '../utils/logger';
|
||||
import { GuildSettings } from '../config/state';
|
||||
import { LoggingService } from './loggingService';
|
||||
|
||||
export interface AutomodConfig {
|
||||
spamThreshold?: number;
|
||||
@@ -37,11 +39,13 @@ export class AutoModService {
|
||||
};
|
||||
private defaultBadwords = ['badword', 'spamword'];
|
||||
|
||||
constructor(private linkFilterEnabled = true, private antiSpamEnabled = true) {}
|
||||
constructor(private logging?: LoggingService, private linkFilterEnabled = true, private antiSpamEnabled = true) {}
|
||||
|
||||
public async checkMessage(message: Message, cfg?: AutomodConfig) {
|
||||
if (message.author.bot) return;
|
||||
const config = { ...this.defaults, ...(cfg ?? {}) };
|
||||
public async checkMessage(message: Message, cfg?: AutomodConfig | GuildSettings) {
|
||||
if (message.author.bot || message.webhookId) return;
|
||||
if (!message.inGuild()) return;
|
||||
const guildConfig = (cfg as GuildSettings)?.automodConfig ? (cfg as GuildSettings).automodConfig : cfg;
|
||||
const config = { ...this.defaults, ...(guildConfig ?? {}) };
|
||||
const member = message.member;
|
||||
|
||||
if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) {
|
||||
@@ -50,23 +54,16 @@ export class AutoModService {
|
||||
}
|
||||
|
||||
if (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) {
|
||||
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
|
||||
message.delete().catch(() => undefined);
|
||||
message.channel
|
||||
.send({ content: `${message.author}, Links sind hier nicht erlaubt.` })
|
||||
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
|
||||
await this.deleteMessageWithReason(message, `${message.author}, Links sind hier nicht erlaubt.`);
|
||||
const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`;
|
||||
logger.info(`Deleted link from ${message.author.tag}`);
|
||||
await this.logAutomodAction(message, config, 'link_filter');
|
||||
await this.logAutomodAction(message, config, 'link_filter', reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
|
||||
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
|
||||
message.delete().catch(() => undefined);
|
||||
message.channel
|
||||
.send({ content: `${message.author}, bitte auf deine Wortwahl achten.` })
|
||||
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
|
||||
await this.logAutomodAction(message, config, 'badword', message.content);
|
||||
await this.deleteMessageWithReason(message, `${message.author}, bitte auf deine Wortwahl achten.`);
|
||||
await this.logAutomodAction(message, config, 'badword', 'Badword erkannt', message.content);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -74,11 +71,9 @@ export class AutoModService {
|
||||
const letters = message.content.replace(/[^a-zA-Z]/g, '');
|
||||
const upper = letters.replace(/[^A-Z]/g, '');
|
||||
if (letters.length >= 10 && upper.length / letters.length > 0.7) {
|
||||
message.delete().catch(() => undefined);
|
||||
message.channel
|
||||
.send({ content: `${message.author}, bitte weniger Capslock nutzen.` })
|
||||
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
|
||||
await this.logAutomodAction(message, config, 'capslock', message.content);
|
||||
await this.deleteMessageWithReason(message, `${message.author}, bitte weniger Capslock nutzen.`);
|
||||
const ratio = Math.round((upper.length / letters.length) * 100);
|
||||
await this.logAutomodAction(message, config, 'capslock', `Caps Anteil ${ratio}%`, message.content);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -98,12 +93,11 @@ export class AutoModService {
|
||||
if (tracker.count >= threshold) {
|
||||
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
|
||||
message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined);
|
||||
message.channel
|
||||
.send({ content: `${message.author}, bitte langsamer schreiben (Spam-Schutz).` })
|
||||
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
|
||||
await this.deleteMessageWithReason(message, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`);
|
||||
logger.warn(`Timed out ${message.author.tag} for spam`);
|
||||
this.spamTracker.delete(message.author.id);
|
||||
await this.logAutomodAction(message, config, 'spam', `Count ${tracker.count}`);
|
||||
const reason = `Spam erkannt (${tracker.count}/${threshold} Nachrichten innerhalb ${config.windowMs ?? this.windowMs}ms)`;
|
||||
await this.logAutomodAction(message, config, 'spam', reason);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -111,24 +105,52 @@ export class AutoModService {
|
||||
}
|
||||
|
||||
private containsBadword(content: string, custom: string[] = []) {
|
||||
const combined = [...this.defaultBadwords, ...(custom || [])].filter(Boolean).map((w) => w.toLowerCase());
|
||||
const combined = [...this.defaultBadwords, ...(custom || [])]
|
||||
.map((w) => w?.toString().trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (!combined.length) return false;
|
||||
const lower = content.toLowerCase();
|
||||
return combined.some((w) => lower.includes(w));
|
||||
return combined.some((w) => {
|
||||
// Try to match word boundaries first, fall back to substring to remain permissive
|
||||
const escaped = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`\\b${escaped}\\b`, 'i');
|
||||
return regex.test(lower) || lower.includes(w);
|
||||
});
|
||||
}
|
||||
|
||||
private containsLink(content: string, whitelist: string[] = []) {
|
||||
const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean);
|
||||
const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+)/i.exec(content);
|
||||
// Match common link formats, even without protocol
|
||||
const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+|[a-z0-9.-]+\.[a-z]{2,}\/?[^\s]*)/i.exec(content);
|
||||
if (!match) return false;
|
||||
const url = match[0].toLowerCase();
|
||||
return !normalized.some((w) => url.includes(w));
|
||||
}
|
||||
|
||||
private async logAutomodAction(message: Message, config: AutomodConfig, action: string, details?: string) {
|
||||
private async deleteMessageWithReason(message: Message, response: string) {
|
||||
await message.delete().catch(() => undefined);
|
||||
await message.channel
|
||||
.send({ content: response })
|
||||
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
private async logAutomodAction(message: Message, config: AutomodConfig, action: string, reason: string, content?: string) {
|
||||
try {
|
||||
const guild = message.guild;
|
||||
if (!guild) return;
|
||||
if (this.logging) {
|
||||
this.logging.logAutomodAction(guild, {
|
||||
userTag: message.author.tag,
|
||||
userId: message.author.id,
|
||||
action,
|
||||
reason,
|
||||
content,
|
||||
channel: guild.channels.cache.get(message.channelId) ?? null,
|
||||
messageUrl: message.url
|
||||
});
|
||||
return;
|
||||
}
|
||||
const loggingCfg = config.loggingConfig || {};
|
||||
const flags = loggingCfg.categories || {};
|
||||
if (flags.automodActions === false) return;
|
||||
@@ -136,8 +158,8 @@ export class AutoModService {
|
||||
if (!channelId) return;
|
||||
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel || !channel.isTextBased()) return;
|
||||
const content = `[Automod] ${action} by ${message.author.tag}${details ? ` | ${details}` : ''}`;
|
||||
await channel.send({ content });
|
||||
const body = `[Automod] ${action} by ${message.author.tag} | ${reason}${content ? ` | ${content.slice(0, 1800)}` : ''}`;
|
||||
await channel.send({ content: body });
|
||||
} catch (err) {
|
||||
logger.error('Automod log failed', err);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export class LoggingService {
|
||||
private resolve(guild: Guild) {
|
||||
const cfg = settingsStore.get(guild.id);
|
||||
const loggingCfg = cfg?.loggingConfig || cfg?.automodConfig?.loggingConfig || {};
|
||||
const logChannelId = loggingCfg.logChannelId || cfg?.logChannelId || this.fallbackLogChannelId;
|
||||
const logChannelId = loggingCfg.logChannelId || cfg?.automodConfig?.logChannelId || cfg?.logChannelId || this.fallbackLogChannelId;
|
||||
const flags = loggingCfg.categories || {};
|
||||
const channel = logChannelId ? guild.channels.cache.get(logChannelId) : null;
|
||||
return { channel: channel && channel.type === 0 ? (channel as TextChannel) : null, flags };
|
||||
@@ -128,11 +128,11 @@ export class LoggingService {
|
||||
});
|
||||
}
|
||||
|
||||
logAction(user: User, action: string, reason?: string) {
|
||||
const guild = user instanceof GuildMember ? user.guild : null;
|
||||
if (!guild) return;
|
||||
if (!this.shouldLog(guild, 'automodActions')) return;
|
||||
const { channel } = this.resolve(guild);
|
||||
logAction(user: User | GuildMember, action: string, reason?: string, guild?: Guild) {
|
||||
const resolvedGuild = guild ?? (user instanceof GuildMember ? user.guild : null);
|
||||
if (!resolvedGuild) return;
|
||||
if (!this.shouldLog(resolvedGuild, 'automodActions')) return;
|
||||
const { channel } = this.resolve(resolvedGuild);
|
||||
if (!channel) return;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Moderation')
|
||||
@@ -141,7 +141,7 @@ export class LoggingService {
|
||||
.setColor(0x7289da)
|
||||
.setTimestamp();
|
||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err));
|
||||
const guildId = (user as GuildMember)?.guild?.id;
|
||||
const guildId = resolvedGuild.id;
|
||||
if (guildId) {
|
||||
adminSink?.pushGuildLog({
|
||||
guildId,
|
||||
@@ -154,6 +154,36 @@ export class LoggingService {
|
||||
}
|
||||
}
|
||||
|
||||
logAutomodAction(guild: Guild, options: { userTag: string; userId: string; action: string; reason: string; content?: string; channel?: GuildChannel | null; messageUrl?: string }) {
|
||||
if (!this.shouldLog(guild, 'automodActions')) return;
|
||||
const { channel } = this.resolve(guild);
|
||||
if (!channel) return;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Automod')
|
||||
.setDescription(`${options.userTag} (${options.userId}) -> ${options.action}`)
|
||||
.addFields(
|
||||
{ name: 'Grund', value: this.safeField(options.reason) },
|
||||
{ name: 'Kanal', value: options.channel ? `<#${options.channel.id}>` : 'Unbekannt' }
|
||||
)
|
||||
.setColor(0xff006e)
|
||||
.setTimestamp();
|
||||
if (options.content) {
|
||||
embed.addFields({ name: 'Nachricht', value: this.safeField(options.content) });
|
||||
}
|
||||
if (options.messageUrl) {
|
||||
embed.addFields({ name: 'Link', value: options.messageUrl });
|
||||
}
|
||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log automod action', err));
|
||||
adminSink?.pushGuildLog({
|
||||
guildId: guild.id,
|
||||
level: 'INFO',
|
||||
message: `Automod: ${options.action} (${options.userTag})`,
|
||||
timestamp: Date.now(),
|
||||
category: 'automodActions'
|
||||
});
|
||||
adminSink?.trackGuildEvent(guild.id, 'automod');
|
||||
}
|
||||
|
||||
logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
|
||||
const guildId = member.guild.id;
|
||||
adminSink?.pushGuildLog({
|
||||
|
||||
@@ -11,7 +11,8 @@ export type ModuleKey =
|
||||
| 'birthdayEnabled'
|
||||
| 'reactionRolesEnabled'
|
||||
| 'eventsEnabled'
|
||||
| 'registerEnabled';
|
||||
| 'registerEnabled'
|
||||
| 'serverStatsEnabled';
|
||||
|
||||
export interface GuildModuleState {
|
||||
key: ModuleKey;
|
||||
@@ -31,7 +32,8 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
|
||||
birthdayEnabled: { name: 'Birthday', description: 'Geburtstage speichern und Glueckwuensche senden.' },
|
||||
reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' },
|
||||
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' },
|
||||
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' }
|
||||
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' },
|
||||
serverStatsEnabled: { name: 'Server Stats', description: 'Zeigt Member-/Channel-Zahlen als Voice-Statistiken an.' }
|
||||
};
|
||||
|
||||
export class BotModuleService {
|
||||
@@ -53,6 +55,7 @@ export class BotModuleService {
|
||||
if (key === 'birthdayEnabled') enabled = cfg.birthdayEnabled === true || cfg.birthdayConfig?.enabled === true;
|
||||
if (key === 'reactionRolesEnabled') enabled = cfg.reactionRolesEnabled === true || cfg.reactionRolesConfig?.enabled === true;
|
||||
if (key === 'eventsEnabled') enabled = (cfg as any).eventsEnabled !== false;
|
||||
if (key === 'serverStatsEnabled') enabled = (cfg as any).serverStatsEnabled === true || (cfg as any).serverStatsConfig?.enabled === true;
|
||||
return {
|
||||
key: key as ModuleKey,
|
||||
name: meta.name,
|
||||
|
||||
@@ -23,7 +23,7 @@ export class RegisterService {
|
||||
}
|
||||
|
||||
public async listForms(guildId: string) {
|
||||
return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { sortOrder: 'asc' } } }, orderBy: { createdAt: 'desc' } });
|
||||
return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { order: 'asc' } } }, orderBy: { createdAt: 'desc' } });
|
||||
}
|
||||
|
||||
public async saveForm(form: {
|
||||
@@ -55,10 +55,10 @@ export class RegisterService {
|
||||
label: f.label,
|
||||
type: f.type,
|
||||
required: f.required ?? false,
|
||||
sortOrder: f.order ?? idx
|
||||
order: f.order ?? idx
|
||||
}))
|
||||
});
|
||||
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { sortOrder: 'asc' } } } });
|
||||
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { order: 'asc' } } } });
|
||||
}
|
||||
const created = await prisma.registerForm.create({
|
||||
data: {
|
||||
@@ -73,11 +73,11 @@ export class RegisterService {
|
||||
label: f.label,
|
||||
type: f.type,
|
||||
required: f.required ?? false,
|
||||
sortOrder: f.order ?? idx
|
||||
order: f.order ?? idx
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { fields: { orderBy: { sortOrder: 'asc' } } }
|
||||
include: { fields: { orderBy: { order: 'asc' } } }
|
||||
});
|
||||
return created;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export class RegisterService {
|
||||
public async handleButton(interaction: ButtonInteraction) {
|
||||
if (interaction.customId.startsWith('register:form:')) {
|
||||
const formId = interaction.customId.split(':')[2];
|
||||
const form = await prisma.registerForm.findFirst({ where: { id: formId }, include: { fields: { orderBy: { sortOrder: 'asc' } } } });
|
||||
const form = await prisma.registerForm.findFirst({ where: { id: formId }, include: { fields: { orderBy: { order: 'asc' } } } });
|
||||
if (!form) return interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
|
||||
const modal = new ModalBuilder().setTitle(form.name).setCustomId(`register:submit:${form.id}`);
|
||||
const components: any[] = [];
|
||||
@@ -164,7 +164,7 @@ export class RegisterService {
|
||||
const formId = interaction.customId.split(':')[2];
|
||||
const form = await prisma.registerForm.findFirst({
|
||||
where: { id: formId },
|
||||
include: { fields: { orderBy: { sortOrder: 'asc' } } }
|
||||
include: { fields: { orderBy: { order: 'asc' } } }
|
||||
});
|
||||
if (!form) {
|
||||
await interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
|
||||
@@ -200,7 +200,7 @@ export class RegisterService {
|
||||
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel || !channel.isTextBased()) return;
|
||||
const member = await guild.members.fetch(userId).catch(() => null);
|
||||
const fields = await prisma.registerFormField.findMany({ where: { formId: form.id }, orderBy: { sortOrder: 'asc' } });
|
||||
const fields = await prisma.registerFormField.findMany({ where: { formId: form.id }, orderBy: { order: 'asc' } });
|
||||
const answers = await prisma.registerApplicationAnswer.findMany({ where: { applicationId: app.id } });
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`Registrierung: ${form.name}`)
|
||||
@@ -254,5 +254,3 @@ export class RegisterService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
271
src/services/statsService.ts
Normal file
271
src/services/statsService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,8 @@ router.get('/guild/info', requireAuth, async (req, res) => {
|
||||
dynamicVoiceEnabled: modules.dynamicVoiceEnabled !== false,
|
||||
statuspageEnabled: (modules as any).statuspageEnabled !== false,
|
||||
birthdayEnabled: (modules as any).birthdayEnabled !== false,
|
||||
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false
|
||||
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false,
|
||||
serverStatsEnabled: (modules as any).serverStatsEnabled === true || (modules as any).serverStatsConfig?.enabled === true
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -754,6 +755,27 @@ router.delete('/statuspage/service/:id', requireAuth, async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/server-stats', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const cfg = await context.stats.getConfig(guildId);
|
||||
res.json({ config: cfg });
|
||||
});
|
||||
|
||||
router.post('/server-stats', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const cfg = await context.stats.saveConfig(guildId, req.body.config || {});
|
||||
res.json({ config: cfg });
|
||||
});
|
||||
|
||||
router.post('/server-stats/refresh', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
await context.stats.refreshGuild(guildId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/settings', requireAuth, async (req, res) => {
|
||||
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
|
||||
const {
|
||||
@@ -779,7 +801,9 @@ router.post('/settings', requireAuth, async (req, res) => {
|
||||
reactionRolesEnabled,
|
||||
reactionRolesConfig,
|
||||
registerEnabled,
|
||||
registerConfig
|
||||
registerConfig,
|
||||
serverStatsEnabled,
|
||||
serverStatsConfig
|
||||
} = req.body;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const normalizeArray = (val: any) =>
|
||||
@@ -913,7 +937,9 @@ router.post('/settings', requireAuth, async (req, res) => {
|
||||
reactionRolesEnabled: parsedReactionRoles.enabled,
|
||||
reactionRolesConfig: parsedReactionRoles,
|
||||
registerEnabled: parsedRegister.enabled,
|
||||
registerConfig: parsedRegister
|
||||
registerConfig: parsedRegister,
|
||||
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
|
||||
serverStatsConfig: serverStatsConfig
|
||||
});
|
||||
// Live update logging target
|
||||
context.logging = new LoggingService(updated.logChannelId);
|
||||
|
||||
@@ -46,17 +46,18 @@ router.get('/', (req, res) => {
|
||||
<a class="active" href="#overview" data-target="overview"><span class="icon">🏠</span> Uebersicht</a>
|
||||
<a href="#tickets" data-target="tickets"><span class="icon">🎫</span> Ticketsystem</a>
|
||||
<a href="#automod" data-target="automod" class="automod-link"><span class="icon">🛡️</span> Automod</a>
|
||||
<a href="#welcome" data-target="welcome" class="welcome-link"><span class="icon">✨</span> Willkommen</a>
|
||||
<a href="#dynamicvoice" data-target="dynamicvoice" class="dynamicvoice-link"><span class="icon">🎧</span> Dynamic Voice</a>
|
||||
<a href="#welcome" data-target="welcome" class="welcome-link"><span class="icon">👋</span> Willkommen</a>
|
||||
<a href="#dynamicvoice" data-target="dynamicvoice" class="dynamicvoice-link"><span class="icon">🎙️</span> Dynamic Voice</a>
|
||||
<a href="#birthday" data-target="birthday" class="birthday-link"><span class="icon">🎂</span> Birthday</a>
|
||||
<a href="#reactionroles" data-target="reactionroles" class="reactionroles-link"><span class="icon">😎</span> Reaction Roles</a>
|
||||
<a href="#statuspage" data-target="statuspage" class="statuspage-link"><span class="icon">🖥️</span> Statuspage</a>
|
||||
<a href="#reactionroles" data-target="reactionroles" class="reactionroles-link"><span class="icon">🎭</span> Reaction Roles</a>
|
||||
<a href="#statuspage" data-target="statuspage" class="statuspage-link"><span class="icon">📡</span> Statuspage</a>
|
||||
<a href="#serverstats" data-target="serverstats" class="serverstats-link"><span class="icon">📈</span> Server Stats</a>
|
||||
<a href="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</a>
|
||||
<a href="#modules" data-target="modules"><span class="icon">🧩</span> Module</a>
|
||||
<a href="#events" data-target="events" class="events-link"><span class="icon">📅</span> Events</a>
|
||||
<a href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛡</span> Admin</a>
|
||||
<a href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛠️</span> Admin</a>
|
||||
</div>
|
||||
<div class="muted">Angemeldet als <span id="userInfo"></span></div>
|
||||
<div class="muted">Angemeldet als <span id="userInfo"></span></div>
|
||||
<button id="logoutBtn" class="logout">Logout</button>
|
||||
</aside>
|
||||
`;
|
||||
@@ -330,10 +331,10 @@ router.get('/', (req, res) => {
|
||||
<div class="card" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;">
|
||||
<div>
|
||||
<p class="section-title">Tickets</p>
|
||||
<p class="section-sub"><EFBFBD>bersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
|
||||
<p class="section-sub">bersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
|
||||
</div>
|
||||
<div class="row" style="gap:8px; flex-wrap:wrap;">
|
||||
<button class="secondary-btn ticket-tab-btn active" data-tab="overview"><EFBFBD>bersicht</button>
|
||||
<button class="secondary-btn ticket-tab-btn active" data-tab="overview">bersicht</button>
|
||||
<button class="secondary-btn ticket-tab-btn" data-tab="pipeline">Pipeline</button>
|
||||
<button class="secondary-btn ticket-tab-btn" data-tab="sla">SLA</button>
|
||||
<button class="secondary-btn ticket-tab-btn" data-tab="automations">Automationen</button>
|
||||
@@ -346,7 +347,7 @@ router.get('/', (req, res) => {
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||
<div>
|
||||
<p class="section-title">Ticketliste</p>
|
||||
<p class="section-sub">Links ausw<EFBFBD>hlen, Details im Modal. Plus <EFBFBD>ffnet Panel-Erstellung.</p>
|
||||
<p class="section-sub">Links auswhlen, Details im Modal. Plus ffnet Panel-Erstellung.</p>
|
||||
</div>
|
||||
<div class="row" style="gap:10px;">
|
||||
<button class="secondary-btn" id="openSupportLogin" type="button">Support-Login Einstellungen</button>
|
||||
@@ -381,7 +382,7 @@ router.get('/', (req, res) => {
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
|
||||
<div>
|
||||
<p class="section-title">Status-Pipeline</p>
|
||||
<p class="section-sub">Tickets nach Phase. Status per Dropdown <EFBFBD>ndern.</p>
|
||||
<p class="section-sub">Tickets nach Phase. Status per Dropdown ndern.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;" id="pipelineGrid">
|
||||
@@ -430,7 +431,7 @@ router.get('/', (req, res) => {
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<p class="section-title">Automationen</p>
|
||||
<p class="section-sub">Regeln f<EFBFBD>r Ticket-Aktionen.</p>
|
||||
<p class="section-sub">Regeln fr Ticket-Aktionen.</p>
|
||||
</div>
|
||||
<button class="secondary-btn" id="addAutomation">Neue Regel</button>
|
||||
</div>
|
||||
@@ -467,7 +468,7 @@ router.get('/', (req, res) => {
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<p class="section-title">Knowledge-Base</p>
|
||||
<p class="section-sub">Artikel f<EFBFBD>r Self-Service.</p>
|
||||
<p class="section-sub">Artikel fr Self-Service.</p>
|
||||
</div>
|
||||
<button class="secondary-btn" id="addKb">Neuer Artikel</button>
|
||||
</div>
|
||||
@@ -782,6 +783,33 @@ router.get('/', (req, res) => {
|
||||
<div id="statuspageServices" class="module-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="section" data-section="serverstats">
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<p class="section-title">Server Stats</p>
|
||||
<p class="section-sub">Kategorie und Counter verwalten.</p>
|
||||
</div>
|
||||
<div class="row" style="gap:8px; align-items:center;">
|
||||
<label class="form-label">Kategorie-Name</label>
|
||||
<input id="statsCategoryName" placeholder="Server Stats" />
|
||||
<label class="form-label">Refresh (Min)</label>
|
||||
<input id="statsRefresh" type="number" min="1" step="1" placeholder="10" />
|
||||
<div id="statsToggle" class="switch"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<div>
|
||||
<p class="section-title">Statistiken</p>
|
||||
<p class="section-sub">Counter und Format anpassen.</p>
|
||||
</div>
|
||||
<button class="icon-button" id="statsAddItem">+</button>
|
||||
</div>
|
||||
<div id="statsItems" class="module-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="section" data-section="events">
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
@@ -1203,6 +1231,7 @@ router.get('/', (req, res) => {
|
||||
let activeModal = null;
|
||||
let automodConfigCache = {};
|
||||
let modulesCache = {};
|
||||
let serverStatsCache = { items: [] };
|
||||
let dynamicVoiceCache = {};
|
||||
let isAdmin = false;
|
||||
let statuspageCache = { services: [] };
|
||||
@@ -1240,6 +1269,7 @@ router.get('/', (req, res) => {
|
||||
const welcomeEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'welcomeEnabled') ? modulesCache['welcomeEnabled'] : true;
|
||||
const dynamicVoiceEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'dynamicVoiceEnabled') ? modulesCache['dynamicVoiceEnabled'] : true;
|
||||
const statuspageEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'statuspageEnabled') ? modulesCache['statuspageEnabled'] : true;
|
||||
const serverStatsEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'serverStatsEnabled') ? modulesCache['serverStatsEnabled'] : false;
|
||||
const birthdayEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'birthdayEnabled') ? modulesCache['birthdayEnabled'] : true;
|
||||
const reactionRolesEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'reactionRolesEnabled') ? modulesCache['reactionRolesEnabled'] : true;
|
||||
const eventsEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'eventsEnabled') ? modulesCache['eventsEnabled'] : true;
|
||||
@@ -1248,6 +1278,8 @@ router.get('/', (req, res) => {
|
||||
if (dynamicVoiceNav) dynamicVoiceNav.classList.toggle('hidden', !dynamicVoiceEnabled);
|
||||
const statuspageNav = document.querySelector('.nav .statuspage-link');
|
||||
if (statuspageNav) statuspageNav.classList.toggle('hidden', !statuspageEnabled);
|
||||
const serverstatsNav = document.querySelector('.nav .serverstats-link');
|
||||
if (serverstatsNav) serverstatsNav.classList.toggle('hidden', !serverStatsEnabled);
|
||||
const birthdayNav = document.querySelector('.nav .birthday-link');
|
||||
if (birthdayNav) birthdayNav.classList.toggle('hidden', !birthdayEnabled);
|
||||
const reactionRolesNav = document.querySelector('.nav .reactionroles-link');
|
||||
@@ -1262,6 +1294,7 @@ router.get('/', (req, res) => {
|
||||
(current === 'welcome' && !welcomeEnabled) ||
|
||||
(current === 'dynamicvoice' && !dynamicVoiceEnabled) ||
|
||||
(current === 'statuspage' && !statuspageEnabled) ||
|
||||
(current === 'serverstats' && !serverStatsEnabled) ||
|
||||
(current === 'birthday' && !birthdayEnabled) ||
|
||||
(current === 'reactionroles' && !reactionRolesEnabled) ||
|
||||
(current === 'events' && !eventsEnabled) ||
|
||||
@@ -1372,6 +1405,103 @@ router.get('/', (req, res) => {
|
||||
if (!logs.length) guildLogs.innerHTML = '<li class="muted">Keine Logs</li>';
|
||||
}
|
||||
|
||||
const STAT_LABELS = {
|
||||
members_total: 'Mitglieder (gesamt)',
|
||||
members_humans: 'Mitglieder (ohne Bots)',
|
||||
members_bots: 'Bots',
|
||||
boosts: 'Server Boosts',
|
||||
text_channels: 'Text Channels',
|
||||
voice_channels: 'Voice Channels',
|
||||
roles: 'Rollen'
|
||||
};
|
||||
|
||||
async function loadServerStats() {
|
||||
if (!currentGuild) return;
|
||||
const res = await fetch('/api/server-stats?guildId=' + encodeURIComponent(currentGuild));
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
serverStatsCache = data.config || { items: [] };
|
||||
setSwitch(statsToggle, serverStatsCache.enabled !== false);
|
||||
if (statsCategoryName) statsCategoryName.value = serverStatsCache.categoryName || 'Server Stats';
|
||||
if (statsRefresh) statsRefresh.value = serverStatsCache.refreshMinutes ?? 10;
|
||||
renderServerStats();
|
||||
}
|
||||
|
||||
function renderServerStats() {
|
||||
if (!statsItems) return;
|
||||
statsItems.innerHTML = '';
|
||||
(serverStatsCache.items || []).forEach((item) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'module-item';
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'module-meta';
|
||||
const label = STAT_LABELS[item.type] || item.type;
|
||||
meta.innerHTML =
|
||||
'<div class="module-title">' + label + '</div><div class="module-desc">' + (item.label || label) + ' - ' + (item.format || '{label}: {value}') + '</div>';
|
||||
const buttons = document.createElement('div');
|
||||
buttons.className = 'row';
|
||||
const edit = document.createElement('button');
|
||||
edit.className = 'secondary-btn';
|
||||
edit.textContent = 'Bearbeiten';
|
||||
edit.addEventListener('click', () => editServerStat(item));
|
||||
const del = document.createElement('button');
|
||||
del.className = 'danger-btn';
|
||||
del.textContent = 'Loeschen';
|
||||
del.addEventListener('click', () => {
|
||||
serverStatsCache.items = (serverStatsCache.items || []).filter((x) => x !== item);
|
||||
renderServerStats();
|
||||
saveServerStats();
|
||||
});
|
||||
buttons.appendChild(edit);
|
||||
buttons.appendChild(del);
|
||||
row.appendChild(meta);
|
||||
row.appendChild(buttons);
|
||||
statsItems.appendChild(row);
|
||||
});
|
||||
if (!(serverStatsCache.items || []).length) statsItems.innerHTML = '<div class="muted">Keine Statistiken</div>';
|
||||
}
|
||||
|
||||
function editServerStat(item) {
|
||||
const typeKeys = Object.keys(STAT_LABELS);
|
||||
const nextType = prompt('Typ (' + typeKeys.join(', ') + ')', item?.type || 'members_total');
|
||||
if (!nextType || !STAT_LABELS[nextType]) return;
|
||||
const nextLabel = prompt('Label', item?.label || STAT_LABELS[nextType]) || STAT_LABELS[nextType];
|
||||
const nextFormat = prompt('Format ({label} / {value})', item?.format || '{label}: {value}') || '{label}: {value}';
|
||||
if (item) {
|
||||
item.type = nextType;
|
||||
item.label = nextLabel;
|
||||
item.format = nextFormat;
|
||||
} else {
|
||||
(serverStatsCache.items = serverStatsCache.items || []).push({
|
||||
id: (crypto.randomUUID && crypto.randomUUID()) || String(Date.now()),
|
||||
type: nextType,
|
||||
label: nextLabel,
|
||||
format: nextFormat
|
||||
});
|
||||
}
|
||||
renderServerStats();
|
||||
saveServerStats();
|
||||
}
|
||||
|
||||
async function saveServerStats() {
|
||||
if (!currentGuild) return;
|
||||
const payload = {
|
||||
guildId: currentGuild,
|
||||
config: {
|
||||
enabled: getSwitch(statsToggle),
|
||||
categoryName: statsCategoryName?.value || undefined,
|
||||
refreshMinutes: statsRefresh?.value ? Number(statsRefresh.value) : undefined,
|
||||
items: serverStatsCache.items || []
|
||||
}
|
||||
};
|
||||
const res = await fetch('/api/server-stats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) showToast('Server Stats speichern fehlgeschlagen', true);
|
||||
}
|
||||
|
||||
async function loadStatuspage() {
|
||||
if (!currentGuild) return;
|
||||
const res = await fetch('/api/statuspage?guildId=' + encodeURIComponent(currentGuild));
|
||||
@@ -1755,7 +1885,7 @@ router.get('/', (req, res) => {
|
||||
'</div>' +
|
||||
'<div class=\"ticket-meta\">User: ' +
|
||||
(t.userId || '-') +
|
||||
(t.claimedBy ? ' <EFBFBD> Supporter: ' + t.claimedBy : '') +
|
||||
(t.claimedBy ? ' Supporter: ' + t.claimedBy : '') +
|
||||
'</div>';
|
||||
const select = document.createElement('select');
|
||||
select.innerHTML =
|
||||
@@ -1869,14 +1999,14 @@ router.get('/', (req, res) => {
|
||||
edit.addEventListener('click', () => fillAutomationForm(r));
|
||||
const del = document.createElement('button');
|
||||
del.className = 'danger-btn';
|
||||
del.textContent = 'L<EFBFBD>schen';
|
||||
del.textContent = 'Lschen';
|
||||
del.addEventListener('click', async () => {
|
||||
const res = await fetch('/api/automations/' + r.id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ guildId: currentGuild })
|
||||
});
|
||||
showToast(res.ok ? 'Regel gel<EFBFBD>scht' : 'L<EFBFBD>schen fehlgeschlagen', !res.ok);
|
||||
showToast(res.ok ? 'Regel gelscht' : 'Lschen fehlgeschlagen', !res.ok);
|
||||
if (res.ok) loadAutomations();
|
||||
});
|
||||
actions.appendChild(edit);
|
||||
@@ -1936,14 +2066,14 @@ router.get('/', (req, res) => {
|
||||
edit.addEventListener('click', () => fillKbForm(a));
|
||||
const del = document.createElement('button');
|
||||
del.className = 'danger-btn';
|
||||
del.textContent = 'L<EFBFBD>schen';
|
||||
del.textContent = 'Lschen';
|
||||
del.addEventListener('click', async () => {
|
||||
const res = await fetch('/api/kb/' + a.id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ guildId: currentGuild })
|
||||
});
|
||||
showToast(res.ok ? 'Artikel gel<EFBFBD>scht' : 'L<EFBFBD>schen fehlgeschlagen', !res.ok);
|
||||
showToast(res.ok ? 'Artikel gelscht' : 'Lschen fehlgeschlagen', !res.ok);
|
||||
if (res.ok) loadKb();
|
||||
});
|
||||
actions.appendChild(edit);
|
||||
@@ -1991,6 +2121,7 @@ router.get('/', (req, res) => {
|
||||
modulesCache['welcomeEnabled'] = (cfg.welcomeConfig?.enabled ?? cfg.automodConfig?.welcomeConfig?.enabled ?? true) !== false;
|
||||
modulesCache['dynamicVoiceEnabled'] = cfg.dynamicVoiceEnabled !== false;
|
||||
modulesCache['statuspageEnabled'] = cfg.statuspageEnabled !== false && cfg.automodConfig?.statuspageEnabled !== false;
|
||||
modulesCache['serverStatsEnabled'] = cfg.serverStatsEnabled === true || cfg.serverStatsConfig?.enabled === true;
|
||||
modulesCache['birthdayEnabled'] = cfg.birthdayEnabled !== false && cfg.birthdayConfig?.enabled !== false;
|
||||
modulesCache['reactionRolesEnabled'] = cfg.reactionRolesEnabled !== false && cfg.reactionRolesConfig?.enabled !== false;
|
||||
modulesCache['eventsEnabled'] = cfg.eventsEnabled !== false;
|
||||
@@ -2457,6 +2588,7 @@ router.get('/', (req, res) => {
|
||||
list.innerHTML = '';
|
||||
let ticketsActive = false;
|
||||
let statuspageActive = false;
|
||||
let serverStatsActive = false;
|
||||
let birthdayActive = false;
|
||||
let reactionRolesActive = false;
|
||||
let eventsActive = false;
|
||||
@@ -2478,17 +2610,18 @@ router.get('/', (req, res) => {
|
||||
showToast(willEnable ? m.name + ' aktiviert' : m.name + ' deaktiviert');
|
||||
modulesCache[m.key] = willEnable;
|
||||
if (m.key === 'ticketsEnabled') applyTicketsVisibility(willEnable);
|
||||
if (m.key === 'automodEnabled') modulesCache['automodEnabled'] = willEnable;
|
||||
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = willEnable;
|
||||
if (m.key === 'statuspageEnabled') modulesCache['statuspageEnabled'] = willEnable;
|
||||
if (m.key === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable;
|
||||
if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable;
|
||||
if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable;
|
||||
applyNavVisibility();
|
||||
} else {
|
||||
showToast('Speichern fehlgeschlagen', true);
|
||||
}
|
||||
});
|
||||
if (m.key === 'automodEnabled') modulesCache['automodEnabled'] = willEnable;
|
||||
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = willEnable;
|
||||
if (m.key === 'statuspageEnabled') modulesCache['statuspageEnabled'] = willEnable;
|
||||
if (m.key === 'serverStatsEnabled') modulesCache['serverStatsEnabled'] = willEnable;
|
||||
if (m.key === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable;
|
||||
if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable;
|
||||
if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable;
|
||||
applyNavVisibility();
|
||||
} else {
|
||||
showToast('Speichern fehlgeschlagen', true);
|
||||
}
|
||||
});
|
||||
row.appendChild(meta);
|
||||
row.appendChild(toggle);
|
||||
list.appendChild(row);
|
||||
@@ -2497,6 +2630,7 @@ router.get('/', (req, res) => {
|
||||
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = !!m.enabled;
|
||||
if (m.key === 'dynamicVoiceEnabled') modulesCache['dynamicVoiceEnabled'] = !!m.enabled;
|
||||
if (m.key === 'statuspageEnabled') statuspageActive = !!m.enabled;
|
||||
if (m.key === 'serverStatsEnabled') serverStatsActive = !!m.enabled;
|
||||
if (m.key === 'birthdayEnabled') birthdayActive = !!m.enabled;
|
||||
if (m.key === 'reactionRolesEnabled') reactionRolesActive = !!m.enabled;
|
||||
if (m.key === 'eventsEnabled') eventsActive = !!m.enabled;
|
||||
@@ -2504,6 +2638,7 @@ router.get('/', (req, res) => {
|
||||
applyNavVisibility();
|
||||
applyTicketsVisibility(ticketsActive);
|
||||
if (statuspageActive) loadStatuspage();
|
||||
if (serverStatsActive) loadServerStats();
|
||||
if (birthdayActive) loadBirthday();
|
||||
if (reactionRolesActive) loadReactionRoles();
|
||||
if (eventsActive) loadEvents();
|
||||
@@ -2512,7 +2647,7 @@ router.get('/', (req, res) => {
|
||||
async function saveModuleToggle(key, enabled) {
|
||||
if (!currentGuild) return false;
|
||||
const payload = { guildId: currentGuild };
|
||||
['ticketsEnabled', 'automodEnabled', 'welcomeEnabled', 'musicEnabled', 'levelingEnabled', 'statuspageEnabled', 'birthdayEnabled', 'reactionRolesEnabled', 'eventsEnabled'].forEach((k) => {
|
||||
['ticketsEnabled', 'automodEnabled', 'welcomeEnabled', 'musicEnabled', 'levelingEnabled', 'statuspageEnabled', 'serverStatsEnabled', 'birthdayEnabled', 'reactionRolesEnabled', 'eventsEnabled'].forEach((k) => {
|
||||
if (modulesCache[k] !== undefined) payload[k] = modulesCache[k];
|
||||
});
|
||||
payload['dynamicVoiceEnabled'] = modulesCache['dynamicVoiceEnabled'];
|
||||
@@ -2716,6 +2851,10 @@ router.get('/', (req, res) => {
|
||||
if (statuspageInterval) statuspageInterval.addEventListener('change', saveStatuspageConfig);
|
||||
if (statuspageChannel) statuspageChannel.addEventListener('change', saveStatuspageConfig);
|
||||
if (statuspageAddService) statuspageAddService.addEventListener('click', addServicePrompt);
|
||||
if (statsToggle) statsToggle.addEventListener('click', async () => { statsToggle.classList.toggle('on'); await saveServerStats(); });
|
||||
if (statsCategoryName) statsCategoryName.addEventListener('change', saveServerStats);
|
||||
if (statsRefresh) statsRefresh.addEventListener('change', saveServerStats);
|
||||
if (statsAddItem) statsAddItem.addEventListener('click', () => editServerStat(null));
|
||||
[welcomeTitle, welcomeDescription, welcomeFooter, welcomeColor].forEach((el) => {
|
||||
if (el) el.addEventListener('input', updateWelcomePreview);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user