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',
|
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
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',
|
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'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 {
|
model GuildSettings {
|
||||||
guildId String @id
|
guildId String @id
|
||||||
welcomeChannelId String?
|
welcomeChannelId String?
|
||||||
logChannelId String?
|
logChannelId String?
|
||||||
automodEnabled Boolean?
|
automodEnabled Boolean?
|
||||||
automodConfig Json?
|
automodConfig Json?
|
||||||
levelingEnabled Boolean?
|
levelingEnabled Boolean?
|
||||||
ticketsEnabled Boolean?
|
ticketsEnabled Boolean?
|
||||||
musicEnabled Boolean?
|
musicEnabled Boolean?
|
||||||
statuspageEnabled Boolean?
|
statuspageEnabled Boolean?
|
||||||
statuspageConfig Json?
|
statuspageConfig Json?
|
||||||
dynamicVoiceEnabled Boolean?
|
dynamicVoiceEnabled Boolean?
|
||||||
dynamicVoiceConfig Json?
|
dynamicVoiceConfig Json?
|
||||||
supportLoginConfig Json?
|
supportLoginConfig Json?
|
||||||
birthdayEnabled Boolean?
|
birthdayEnabled Boolean?
|
||||||
birthdayConfig Json?
|
birthdayConfig Json?
|
||||||
reactionRolesEnabled Boolean?
|
reactionRolesEnabled Boolean?
|
||||||
reactionRolesConfig Json?
|
reactionRolesConfig Json?
|
||||||
eventsEnabled Boolean?\n registerEnabled Boolean?\n registerConfig Json?\n supportRoleId String?
|
eventsEnabled Boolean?
|
||||||
updatedAt DateTime @updatedAt
|
registerEnabled Boolean?
|
||||||
createdAt DateTime @default(now())
|
registerConfig Json?
|
||||||
|
serverStatsEnabled Boolean?
|
||||||
|
serverStatsConfig Json?
|
||||||
|
supportRoleId String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
model Ticket {
|
model Ticket {
|
||||||
@@ -182,7 +187,7 @@ model RegisterFormField {
|
|||||||
label String
|
label String
|
||||||
type String
|
type String
|
||||||
required Boolean @default(false)
|
required Boolean @default(false)
|
||||||
"order" Int @default(0)
|
order Int @default(0)
|
||||||
|
|
||||||
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|||||||
75
readme.md
75
readme.md
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ 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 { 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(),
|
||||||
@@ -33,7 +36,8 @@ export const context = {
|
|||||||
events: new EventService(),
|
events: new EventService(),
|
||||||
ticketAutomation: new TicketAutomationService(),
|
ticketAutomation: new TicketAutomationService(),
|
||||||
knowledgeBase: new KnowledgeBaseService(),
|
knowledgeBase: new KnowledgeBaseService(),
|
||||||
register: new RegisterService()
|
register: new RegisterService(),
|
||||||
|
stats: new StatsService()
|
||||||
};
|
};
|
||||||
|
|
||||||
context.modules.setHooks({
|
context.modules.setHooks({
|
||||||
@@ -63,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)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ export interface GuildSettings {
|
|||||||
reviewChannelId?: string;
|
reviewChannelId?: string;
|
||||||
notifyRoleIds?: 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;
|
||||||
}
|
}
|
||||||
@@ -74,23 +83,23 @@ 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',
|
'levelingEnabled',
|
||||||
'levelingEnabled',
|
'musicEnabled',
|
||||||
'musicEnabled',
|
'dynamicVoiceEnabled',
|
||||||
'dynamicVoiceEnabled',
|
'statuspageEnabled',
|
||||||
'statuspageEnabled',
|
'birthdayEnabled',
|
||||||
'birthdayEnabled',
|
'reactionRolesEnabled',
|
||||||
'reactionRolesEnabled',
|
'eventsEnabled',
|
||||||
'eventsEnabled',
|
'registerEnabled'
|
||||||
'registerEnabled'
|
] as const;
|
||||||
] as const
|
defaultOn.forEach((key) => {
|
||||||
).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 };
|
||||||
@@ -124,6 +133,8 @@ class SettingsStore {
|
|||||||
reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined,
|
reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined,
|
||||||
registerEnabled: (row as any).registerEnabled ?? undefined,
|
registerEnabled: (row as any).registerEnabled ?? undefined,
|
||||||
registerConfig: (row as any).registerConfig ?? 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));
|
||||||
@@ -206,6 +217,8 @@ class SettingsStore {
|
|||||||
reactionRolesConfig: merged.reactionRolesConfig ?? null,
|
reactionRolesConfig: merged.reactionRolesConfig ?? null,
|
||||||
registerEnabled: merged.registerEnabled ?? null,
|
registerEnabled: merged.registerEnabled ?? null,
|
||||||
registerConfig: merged.registerConfig ?? 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: {
|
||||||
@@ -228,6 +241,8 @@ class SettingsStore {
|
|||||||
reactionRolesConfig: merged.reactionRolesConfig ?? null,
|
reactionRolesConfig: merged.reactionRolesConfig ?? null,
|
||||||
registerEnabled: merged.registerEnabled ?? null,
|
registerEnabled: merged.registerEnabled ?? null,
|
||||||
registerConfig: merged.registerConfig ?? 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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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?
|
eventsEnabled Boolean?
|
||||||
registerEnabled Boolean?
|
registerEnabled Boolean?
|
||||||
registerConfig Json?
|
registerConfig Json?
|
||||||
|
serverStatsEnabled Boolean?
|
||||||
|
serverStatsConfig Json?
|
||||||
supportRoleId String?
|
supportRoleId String?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -174,6 +176,7 @@ model RegisterForm {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
fields RegisterFormField[]
|
fields RegisterFormField[]
|
||||||
|
applications RegisterApplication[]
|
||||||
|
|
||||||
@@index([guildId, isActive])
|
@@index([guildId, isActive])
|
||||||
}
|
}
|
||||||
@@ -184,7 +187,7 @@ model RegisterFormField {
|
|||||||
label String
|
label String
|
||||||
type String
|
type String
|
||||||
required Boolean @default(false)
|
required Boolean @default(false)
|
||||||
"order" Int @default(0)
|
order Int @default(0)
|
||||||
|
|
||||||
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ async function bootstrap() {
|
|||||||
context.events.setClient(client);
|
context.events.setClient(client);
|
||||||
context.events.startScheduler();
|
context.events.startScheduler();
|
||||||
context.register.setClient(client);
|
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()) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export type ModuleKey =
|
|||||||
| 'birthdayEnabled'
|
| 'birthdayEnabled'
|
||||||
| 'reactionRolesEnabled'
|
| 'reactionRolesEnabled'
|
||||||
| 'eventsEnabled'
|
| 'eventsEnabled'
|
||||||
| 'registerEnabled';
|
| 'registerEnabled'
|
||||||
|
| 'serverStatsEnabled';
|
||||||
|
|
||||||
export interface GuildModuleState {
|
export interface GuildModuleState {
|
||||||
key: ModuleKey;
|
key: ModuleKey;
|
||||||
@@ -31,7 +32,8 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
|
|||||||
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.' }
|
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 {
|
||||||
@@ -53,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,
|
||||||
|
|||||||
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,
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -754,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 {
|
||||||
@@ -779,7 +801,9 @@ router.post('/settings', requireAuth, async (req, res) => {
|
|||||||
reactionRolesEnabled,
|
reactionRolesEnabled,
|
||||||
reactionRolesConfig,
|
reactionRolesConfig,
|
||||||
registerEnabled,
|
registerEnabled,
|
||||||
registerConfig
|
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) =>
|
||||||
@@ -913,7 +937,9 @@ router.post('/settings', requireAuth, async (req, res) => {
|
|||||||
reactionRolesEnabled: parsedReactionRoles.enabled,
|
reactionRolesEnabled: parsedReactionRoles.enabled,
|
||||||
reactionRolesConfig: parsedReactionRoles,
|
reactionRolesConfig: parsedReactionRoles,
|
||||||
registerEnabled: parsedRegister.enabled,
|
registerEnabled: parsedRegister.enabled,
|
||||||
registerConfig: parsedRegister
|
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);
|
||||||
|
|||||||
@@ -46,17 +46,18 @@ 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>
|
||||||
</aside>
|
</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 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;
|
||||||
@@ -2478,17 +2610,18 @@ router.get('/', (req, res) => {
|
|||||||
showToast(willEnable ? m.name + ' aktiviert' : m.name + ' deaktiviert');
|
showToast(willEnable ? m.name + ' aktiviert' : m.name + ' deaktiviert');
|
||||||
modulesCache[m.key] = willEnable;
|
modulesCache[m.key] = willEnable;
|
||||||
if (m.key === 'ticketsEnabled') applyTicketsVisibility(willEnable);
|
if (m.key === 'ticketsEnabled') applyTicketsVisibility(willEnable);
|
||||||
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 === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable;
|
if (m.key === 'serverStatsEnabled') modulesCache['serverStatsEnabled'] = willEnable;
|
||||||
if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable;
|
if (m.key === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable;
|
||||||
if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable;
|
if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable;
|
||||||
applyNavVisibility();
|
if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable;
|
||||||
} else {
|
applyNavVisibility();
|
||||||
showToast('Speichern fehlgeschlagen', true);
|
} else {
|
||||||
}
|
showToast('Speichern fehlgeschlagen', true);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
row.appendChild(meta);
|
row.appendChild(meta);
|
||||||
row.appendChild(toggle);
|
row.appendChild(toggle);
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user