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(); private syncLocks = new Map>(); 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 { 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) { 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 { 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 { 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(); 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(guildId: string, task: () => Promise): Promise { 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); } } }