272 lines
9.9 KiB
TypeScript
272 lines
9.9 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|