[deploy] Add server stats module with dashboard controls
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s

This commit is contained in:
Pascal Prießnitz
2025-12-04 11:37:49 +01:00
parent c66da87207
commit 5aef575f41
15 changed files with 559 additions and 81 deletions

View File

@@ -26,7 +26,12 @@ model GuildSettings {
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?
registerEnabled Boolean?
registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
@@ -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)
} }

View File

@@ -15,6 +15,7 @@ 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';
export const context = { export const context = {
client: null as Client | null, client: null as Client | null,
@@ -33,7 +34,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 +65,10 @@ context.modules.setHooks({
}, },
eventsEnabled: { eventsEnabled: {
onEnable: async (guildId: string) => context.events.tick().catch(() => undefined) onEnable: async (guildId: string) => context.events.tick().catch(() => undefined)
},
serverStatsEnabled: {
onEnable: async (guildId: string) => context.stats.refreshGuild(guildId).catch(() => undefined),
onDisable: async (guildId: string) => context.stats.disableGuild(guildId).catch(() => undefined)
} }
}); });

View File

@@ -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,8 +83,7 @@ class SettingsStore {
private applyModuleDefaults(cfg: GuildSettings): GuildSettings { private applyModuleDefaults(cfg: GuildSettings): GuildSettings {
const normalized: GuildSettings = { ...cfg }; const normalized: GuildSettings = { ...cfg };
( const defaultOn = [
[
'ticketsEnabled', 'ticketsEnabled',
'automodEnabled', 'automodEnabled',
'welcomeEnabled', 'welcomeEnabled',
@@ -87,10 +95,11 @@ class SettingsStore {
'reactionRolesEnabled', 'reactionRolesEnabled',
'eventsEnabled', 'eventsEnabled',
'registerEnabled' 'registerEnabled'
] as const ] as const;
).forEach((key) => { defaultOn.forEach((key) => {
if (normalized[key] === undefined) normalized[key] = true; if (normalized[key] === undefined) normalized[key] = true;
}); });
if (normalized.serverStatsEnabled === undefined) normalized.serverStatsEnabled = false;
// keep welcomeConfig flag in sync when present // keep welcomeConfig flag in sync when present
if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) { if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) {
normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled }; normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled };
@@ -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
} }
}); });

View File

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

View File

@@ -28,6 +28,8 @@ model GuildSettings {
eventsEnabled Boolean? 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())

View File

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

View File

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

View File

@@ -47,6 +47,7 @@ const event: EventHandler = {
} }
} }
context.logging.logMemberJoin(member); context.logging.logMemberJoin(member);
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
} }
}; };

View File

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

View File

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

View File

@@ -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()) {

View File

@@ -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,

View File

@@ -0,0 +1,251 @@
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>();
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>) {
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) {
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);
}
}
}

View File

@@ -85,7 +85,8 @@ router.get('/guild/info', requireAuth, async (req, res) => {
dynamicVoiceEnabled: modules.dynamicVoiceEnabled !== false, dynamicVoiceEnabled: modules.dynamicVoiceEnabled !== false,
statuspageEnabled: (modules as any).statuspageEnabled !== false, statuspageEnabled: (modules as any).statuspageEnabled !== false,
birthdayEnabled: (modules as any).birthdayEnabled !== false, birthdayEnabled: (modules as any).birthdayEnabled !== false,
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false,
serverStatsEnabled: (modules as any).serverStatsEnabled === true || (modules as any).serverStatsConfig?.enabled === true
} }
} }
}); });
@@ -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);

View File

@@ -1,4 +1,4 @@
import { Router } from 'express'; import { Router } from 'express';
const router = Router(); const router = Router();
@@ -43,18 +43,19 @@ router.get('/', (req, res) => {
<aside class="sidebar"> <aside class="sidebar">
<div class="brand">Papo Control</div> <div class="brand">Papo Control</div>
<div class="nav"> <div class="nav">
<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="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</a> <a href="#serverstats" data-target="serverstats" class="serverstats-link"><span class="icon">??</span> Server Stats</a>
<a href="#modules" data-target="modules"><span class="icon">🧩</span> Module</a> <a href="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</a>
<a href="#events" data-target="events" class="events-link"><span class="icon">📅</span> Events</a> <a href="#modules" data-target="modules"><span class="icon">🧩</span> Module</a>
<a href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛡</span> Admin</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>
</div> </div>
<div class="muted">Angemeldet als <span id="userInfo"></span></div> <div class="muted">Angemeldet als <span id="userInfo"></span></div>
<button id="logoutBtn" class="logout">Logout</button> <button id="logoutBtn" class="logout">Logout</button>
@@ -330,10 +331,10 @@ router.get('/', (req, res) => {
<div class="card" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;"> <div class="card" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;">
<div> <div>
<p class="section-title">Tickets</p> <p class="section-title">Tickets</p>
<p class="section-sub"><EFBFBD>bersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p> <p class="section-sub">Übersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
</div> </div>
<div class="row" style="gap:8px; flex-wrap:wrap;"> <div class="row" style="gap:8px; flex-wrap:wrap;">
<button class="secondary-btn ticket-tab-btn active" data-tab="overview"><EFBFBD>bersicht</button> <button class="secondary-btn ticket-tab-btn active" data-tab="overview">Übersicht</button>
<button class="secondary-btn ticket-tab-btn" data-tab="pipeline">Pipeline</button> <button class="secondary-btn ticket-tab-btn" data-tab="pipeline">Pipeline</button>
<button class="secondary-btn ticket-tab-btn" data-tab="sla">SLA</button> <button class="secondary-btn ticket-tab-btn" data-tab="sla">SLA</button>
<button class="secondary-btn ticket-tab-btn" data-tab="automations">Automationen</button> <button class="secondary-btn ticket-tab-btn" data-tab="automations">Automationen</button>
@@ -346,7 +347,7 @@ router.get('/', (req, res) => {
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;"> <div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div> <div>
<p class="section-title">Ticketliste</p> <p class="section-title">Ticketliste</p>
<p class="section-sub">Links ausw<EFBFBD>hlen, Details im Modal. Plus <EFBFBD>ffnet Panel-Erstellung.</p> <p class="section-sub">Links auswählen, 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 für 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 für 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>
@@ -586,7 +587,7 @@ router.get('/', (req, res) => {
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="form-label">Embed Footer</label> <label class="form-label">Embed Footer</label>
<input id="welcomeFooter" placeholder="Schön, dass du da bist!" /> <input id="welcomeFooter" placeholder="Schön, dass du da bist!" />
</div> </div>
</div> </div>
<div class="form-field"> <div class="form-field">
@@ -609,7 +610,7 @@ router.get('/', (req, res) => {
<div class="embed-color" id="welcomePreviewColor"></div> <div class="embed-color" id="welcomePreviewColor"></div>
<div class="embed-body"> <div class="embed-body">
<div class="embed-title" id="welcomePreviewTitle">Willkommen!</div> <div class="embed-title" id="welcomePreviewTitle">Willkommen!</div>
<div class="embed-desc" id="welcomePreviewDesc">Schön, dass du da bist.</div> <div class="embed-desc" id="welcomePreviewDesc">Schön, dass du da bist.</div>
<div class="embed-footer" id="welcomePreviewFooter">Footer</div> <div class="embed-footer" id="welcomePreviewFooter">Footer</div>
<img id="welcomePreviewImage" class="embed-image" style="display:none;" /> <img id="welcomePreviewImage" class="embed-image" style="display:none;" />
</div> </div>
@@ -736,7 +737,7 @@ router.get('/', (req, res) => {
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="form-label">Eintraege (Emoji | Role ID | Label | Beschreibung)</label> <label class="form-label">Eintraege (Emoji | Role ID | Label | Beschreibung)</label>
<textarea id="reactionRoleEntries" rows="4" placeholder="😀 | 123456789 | Freunde | Erhaelt Freunde Rolle"></textarea> <textarea id="reactionRoleEntries" rows="4" placeholder="😀 | 123456789 | Freunde | Erhaelt Freunde Rolle"></textarea>
<p class="muted">Eine Zeile pro Zuordnung. Label/Beschreibung optional.</p> <p class="muted">Eine Zeile pro Zuordnung. Label/Beschreibung optional.</p>
</div> </div>
<div style="display:flex; justify-content:flex-end; gap:10px;"> <div style="display:flex; justify-content:flex-end; gap:10px;">
@@ -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;">
<div>
<p class="section-title">Server Stats</p>
<p class="section-sub">Erstellt eine Kategorie mit Voice-Statistiken.</p>
</div>
<div class="row">
<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">Waehle Counter und Format.</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;">
@@ -810,7 +838,7 @@ router.get('/', (req, res) => {
<section class="card"> <section class="card">
<div class="row" style="justify-content:space-between;"> <div class="row" style="justify-content:space-between;">
<div> <div>
<p class="section-title">Aktivität (letzte 24h)</p> <p class="section-title">Aktivität (letzte 24h)</p>
<p class="section-sub">Events/Commands pro Stunde</p> <p class="section-sub">Events/Commands pro Stunde</p>
</div> </div>
</div> </div>
@@ -820,7 +848,7 @@ router.get('/', (req, res) => {
<div class="row" style="justify-content:space-between;"> <div class="row" style="justify-content:space-between;">
<div> <div>
<p class="section-title">Logs</p> <p class="section-title">Logs</p>
<p class="section-sub">Neueste Einträge</p> <p class="section-sub">Neueste Einträge</p>
</div> </div>
</div> </div>
<ul class="log-list" id="adminLogs"></ul> <ul class="log-list" id="adminLogs"></ul>
@@ -847,13 +875,13 @@ router.get('/', (req, res) => {
</div> </div>
</div> </div>
<div class="option-grid"> <div class="option-grid">
<div class="option-card"><span>👋 User Join / Leave</span><div id="logJoinLeave" class="switch on"></div></div> <div class="option-card"><span>👋 User Join / Leave</span><div id="logJoinLeave" class="switch on"></div></div>
<div class="option-card"><span>✏️ Message Edit</span><div id="logMsgEdit" class="switch on"></div></div> <div class="option-card"><span>✏️ Message Edit</span><div id="logMsgEdit" class="switch on"></div></div>
<div class="option-card"><span>🗑️ Message Delete</span><div id="logMsgDelete" class="switch on"></div></div> <div class="option-card"><span>🗑️ Message Delete</span><div id="logMsgDelete" class="switch on"></div></div>
<div class="option-card"><span>🛡️ Automod Actions</span><div id="logAutomod" class="switch on"></div></div> <div class="option-card"><span>🛡️ Automod Actions</span><div id="logAutomod" class="switch on"></div></div>
<div class="option-card"><span>🎫 Ticket Actions</span><div id="logTickets" class="switch on"></div></div> <div class="option-card"><span>🎫 Ticket Actions</span><div id="logTickets" class="switch on"></div></div>
<div class="option-card"><span>🎵 Musik-Events</span><div id="logMusic" class="switch on"></div></div> <div class="option-card"><span>🎵 Musik-Events</span><div id="logMusic" class="switch on"></div></div>
<div class="option-card"><span>⚙️ System / Channels</span><div id="logSystem" class="switch on"></div></div> <div class="option-card"><span>⚙️ System / Channels</span><div id="logSystem" class="switch on"></div></div>
</div> </div>
<div style="display:flex; justify-content:flex-end; gap:10px; margin-top:12px;"> <div style="display:flex; justify-content:flex-end; gap:10px; margin-top:12px;">
<button id="loggingSave" type="button">Logging speichern</button> <button id="loggingSave" type="button">Logging speichern</button>
@@ -1166,6 +1194,11 @@ router.get('/', (req, res) => {
const statuspageChannel = document.getElementById('statuspageChannel'); const statuspageChannel = document.getElementById('statuspageChannel');
const statuspageServices = document.getElementById('statuspageServices'); const statuspageServices = document.getElementById('statuspageServices');
const statuspageAddService = document.getElementById('statuspageAddService'); const statuspageAddService = document.getElementById('statuspageAddService');
const statsToggle = document.getElementById('statsToggle');
const statsCategoryName = document.getElementById('statsCategoryName');
const statsRefresh = document.getElementById('statsRefresh');
const statsItems = document.getElementById('statsItems');
const statsAddItem = document.getElementById('statsAddItem');
const adminGuilds = document.getElementById('adminGuilds'); const adminGuilds = document.getElementById('adminGuilds');
const adminActiveGuilds = document.getElementById('adminActiveGuilds'); const adminActiveGuilds = document.getElementById('adminActiveGuilds');
const adminUptime = document.getElementById('adminUptime'); const adminUptime = document.getElementById('adminUptime');
@@ -1206,6 +1239,7 @@ router.get('/', (req, res) => {
let dynamicVoiceCache = {}; let dynamicVoiceCache = {};
let isAdmin = false; let isAdmin = false;
let statuspageCache = { services: [] }; let statuspageCache = { services: [] };
let serverStatsCache = { items: [] };
let birthdayCache = { config: {}, birthdays: [] }; let birthdayCache = { config: {}, birthdays: [] };
let reactionRoleCache = []; let reactionRoleCache = [];
let editingReactionRole = null; let editingReactionRole = null;
@@ -1240,6 +1274,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 +1283,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 +1299,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) ||
@@ -1384,6 +1422,115 @@ router.get('/', (req, res) => {
renderStatuspageServices(); renderStatuspageServices();
} }
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 = 'Entfernen';
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);
return;
}
const data = await res.json();
serverStatsCache = data.config || serverStatsCache;
renderServerStats();
}
function renderStatuspageServices() { function renderStatuspageServices() {
if (!statuspageServices) return; if (!statuspageServices) return;
statuspageServices.innerHTML = ''; statuspageServices.innerHTML = '';
@@ -1419,7 +1566,7 @@ router.get('/', (req, res) => {
actions.className = 'row'; actions.className = 'row';
const del = document.createElement('button'); const del = document.createElement('button');
del.className = 'danger-btn'; del.className = 'danger-btn';
del.textContent = 'Löschen'; del.textContent = 'Löschen';
del.addEventListener('click', async () => { del.addEventListener('click', async () => {
await deleteService(svc.id); await deleteService(svc.id);
}); });
@@ -1477,7 +1624,7 @@ router.get('/', (req, res) => {
if (res.ok) { if (res.ok) {
await loadStatuspage(); await loadStatuspage();
} else { } else {
showToast('Service löschen fehlgeschlagen', true); showToast('Service löschen fehlgeschlagen', true);
} }
} }
@@ -1755,7 +1902,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 +2016,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 = 'Löschen';
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 gelöscht' : 'Löschen fehlgeschlagen', !res.ok);
if (res.ok) loadAutomations(); if (res.ok) loadAutomations();
}); });
actions.appendChild(edit); actions.appendChild(edit);
@@ -1936,14 +2083,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 = 'Löschen';
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 gelöscht' : 'Löschen fehlgeschlagen', !res.ok);
if (res.ok) loadKb(); if (res.ok) loadKb();
}); });
actions.appendChild(edit); actions.appendChild(edit);
@@ -2116,7 +2263,7 @@ router.get('/', (req, res) => {
(s.userId || '-') + (s.userId || '-') +
'</strong></div><div class="muted">Ende: ' + '</strong></div><div class="muted">Ende: ' +
formatDate(s.endedAt || Date.now()) + formatDate(s.endedAt || Date.now()) +
' · Dauer: ' + ' · Dauer: ' +
dur + dur +
'</div>'; '</div>';
supportRecentList.appendChild(div); supportRecentList.appendChild(div);
@@ -2174,9 +2321,9 @@ router.get('/', (req, res) => {
(ev.repeatType || 'none') + (ev.repeatType || 'none') +
'</span></div><div class="ticket-meta">Start: ' + '</span></div><div class="ticket-meta">Start: ' +
formatDate(ev.startTime) + formatDate(ev.startTime) +
' · Channel: ' + ' · Channel: ' +
(ev.channelId || '-') + (ev.channelId || '-') +
' · Anmeldungen: ' + ' · Anmeldungen: ' +
(ev._count?.signups ?? 0) + (ev._count?.signups ?? 0) +
'</div>'; '</div>';
const actions = document.createElement('div'); const actions = document.createElement('div');
@@ -2389,7 +2536,7 @@ router.get('/', (req, res) => {
meta.className = 'module-meta'; meta.className = 'module-meta';
const descParts = ['Channel: ' + (set.channelId || '-')]; const descParts = ['Channel: ' + (set.channelId || '-')];
if (set.messageId) descParts.push('Message: ' + set.messageId); if (set.messageId) descParts.push('Message: ' + set.messageId);
meta.innerHTML = '<div class="module-title">' + (set.title || 'Reaction Role') + '</div><div class="module-desc">' + descParts.join(' ') + '</div>'; meta.innerHTML = '<div class="module-title">' + (set.title || 'Reaction Role') + '</div><div class="module-desc">' + descParts.join(' • ') + '</div>';
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'row'; actions.className = 'row';
const editBtn = document.createElement('button'); const editBtn = document.createElement('button');
@@ -2457,6 +2604,7 @@ router.get('/', (req, res) => {
list.innerHTML = ''; list.innerHTML = '';
let ticketsActive = false; let ticketsActive = false;
let statuspageActive = false; let statuspageActive = false;
let serverStatsActive = false;
let birthdayActive = false; let birthdayActive = false;
let reactionRolesActive = false; let reactionRolesActive = false;
let eventsActive = false; let eventsActive = false;
@@ -2481,10 +2629,12 @@ router.get('/', (req, res) => {
if (m.key === 'automodEnabled') modulesCache['automodEnabled'] = willEnable; if (m.key === 'automodEnabled') modulesCache['automodEnabled'] = willEnable;
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = willEnable; if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = willEnable;
if (m.key === 'statuspageEnabled') modulesCache['statuspageEnabled'] = willEnable; if (m.key === 'statuspageEnabled') modulesCache['statuspageEnabled'] = willEnable;
if (m.key === 'serverStatsEnabled') modulesCache['serverStatsEnabled'] = willEnable;
if (m.key === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable; if (m.key === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable;
if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable; if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable;
if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable; if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable;
applyNavVisibility(); applyNavVisibility();
if (m.key === 'serverStatsEnabled' && willEnable) loadServerStats();
} else { } else {
showToast('Speichern fehlgeschlagen', true); showToast('Speichern fehlgeschlagen', true);
} }
@@ -2497,6 +2647,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 +2655,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 +2664,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 +2868,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);
}); });
@@ -2883,3 +3039,4 @@ router.get('/settings', (_req, res) => {
export default router; export default router;