@zumito-team/analytics-module 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,101 +0,0 @@
1
- import { Route, RouteMethod, ServiceContainer } from 'zumito-framework';
2
- import path, { dirname } from 'path';
3
- import { fileURLToPath } from 'url';
4
- import ejs from 'ejs';
5
- import { Client, PermissionFlagsBits } from 'zumito-framework/discord';
6
- import { AnalyticsCollector } from '../services/AnalyticsCollector.js';
7
-
8
- const __dirname = dirname(fileURLToPath(import.meta.url));
9
-
10
- export class UserPanelAnalytics extends Route {
11
- method = RouteMethod.get;
12
- path = '/panel/:guildId/analytics';
13
-
14
- constructor(
15
- private collector: AnalyticsCollector = ServiceContainer.getService(AnalyticsCollector) as AnalyticsCollector,
16
- private client: Client = ServiceContainer.getService(Client) as Client,
17
- ) {
18
- super();
19
- }
20
-
21
- async execute(req: any, res: any): Promise<void> {
22
- const { UserPanelAuthService } = await import('@zumito-team/user-panel-module/services/UserPanelAuthService');
23
- const { UserPanelViewService } = await import('@zumito-team/user-panel-module/services/UserPanelViewService');
24
- const { UserPanelLanguageManager } = await import('@zumito-team/user-panel-module/services/UserPanelLanguageManager');
25
-
26
- const authService = ServiceContainer.getService(UserPanelAuthService);
27
- const authData = await authService.isLoginValid(req);
28
- if (!authData.isValid) return res.redirect('/panel/login');
29
-
30
- const userId = authData.data.discordUserData.id;
31
- const guildId = req.params.guildId as string;
32
- const guild = this.client.guilds.cache.get(guildId);
33
- if (!guild) return res.status(404).send('Server not found');
34
-
35
- let member = guild.members.cache.get(userId);
36
- if (!member) member = await guild.members.fetch(userId).catch(() => null);
37
- if (!member || !(
38
- member.permissions.has(PermissionFlagsBits.Administrator) ||
39
- member.permissions.has(PermissionFlagsBits.ManageGuild) ||
40
- guild.ownerId === userId
41
- )) {
42
- return res.status(403).send('No tienes permisos en este servidor');
43
- }
44
-
45
- const daysBack = parseInt(req.query.days as string) || 7;
46
- const langMgr = ServiceContainer.getService(UserPanelLanguageManager);
47
- const { t } = langMgr.getLanguageVariables(req, res);
48
-
49
- const stats = await this.collector.getGuildStats(guildId, daysBack);
50
-
51
- let guildSummary = {
52
- totalMessages: 0, totalJoins: 0, totalLeaves: 0,
53
- totalVoiceMinutes: 0, totalCommands: 0,
54
- };
55
- const joinsPerDay: { date: string; count: number }[] = [];
56
- const leavesPerDay: { date: string; count: number }[] = [];
57
- const messagesPerDay: { date: string; count: number }[] = [];
58
- const voicePerDay: { date: string; count: number }[] = [];
59
-
60
- for (const s of stats) {
61
- guildSummary.totalMessages += s.message_count;
62
- guildSummary.totalJoins += s.join_count;
63
- guildSummary.totalLeaves += s.leave_count;
64
- guildSummary.totalVoiceMinutes += s.voice_minutes;
65
- guildSummary.totalCommands += s.command_count;
66
-
67
- messagesPerDay.push({ date: s.date, count: s.message_count });
68
- joinsPerDay.push({ date: s.date, count: s.join_count });
69
- leavesPerDay.push({ date: s.date, count: s.leave_count });
70
- voicePerDay.push({ date: s.date, count: s.voice_minutes });
71
- }
72
-
73
- const commandsPerDay = await this.collector.getCommandsPerDay(guildId, daysBack);
74
- const topCommands = await this.collector.getTopCommands(guildId, daysBack);
75
- const slowestCommands = await this.collector.getSlowestCommands(guildId, daysBack);
76
- const channelVoice = await this.collector.getVoiceChannelStats(guildId, daysBack);
77
-
78
- const chartData = {
79
- messagesPerDay, joinsPerDay, leavesPerDay, voicePerDay,
80
- commandsPerDay, topCommands, slowestCommands, channelVoice,
81
- };
82
-
83
- const config = await this.collector.getConfig(guildId);
84
-
85
- const content = await ejs.renderFile(
86
- path.resolve(__dirname, '../views/user-analytics.ejs'),
87
- {
88
- guildSummary,
89
- chartData,
90
- slowestCommands: (config.track_command_performance && slowestCommands.length > 0) ? slowestCommands : undefined,
91
- channelVoice: (config.track_per_channel_voice && channelVoice.length > 0) ? channelVoice : undefined,
92
- t,
93
- daysBack,
94
- },
95
- );
96
-
97
- const view = ServiceContainer.getService(UserPanelViewService);
98
- const html = await view.render({ content, reqPath: req.path, req, res });
99
- res.send(html);
100
- }
101
- }
@@ -1,537 +0,0 @@
1
- import { DatabaseManager, ServiceContainer } from 'zumito-framework';
2
- import { GuildDailyStats } from '../models/GuildDailyStats.js';
3
- import { CommandDailyStats } from '../models/CommandDailyStats.js';
4
- import { VoiceChannelDailyStats } from '../models/VoiceChannelDailyStats.js';
5
- import { GuildAnalyticsConfig } from '../models/GuildAnalyticsConfig.js';
6
- import { AnalyticsModuleConfig } from '../config.js';
7
-
8
- interface VoiceSession {
9
- joinTime: number;
10
- channelId: string;
11
- }
12
-
13
- export interface CommandExecutedPayload {
14
- guildId: string;
15
- commandName: string;
16
- type: 'prefix' | 'slash';
17
- executionTimeMs: number;
18
- success: boolean;
19
- }
20
-
21
- function today(): string {
22
- return new Date().toISOString().slice(0, 10);
23
- }
24
-
25
- export class AnalyticsCollector {
26
-
27
- private voiceSessions: Map<string, VoiceSession> = new Map();
28
- private dailyVoiceUsers: Map<string, Set<string>> = new Map();
29
- private cleanupTimer: ReturnType<typeof setInterval> | null = null;
30
-
31
- constructor(
32
- private db: any = ServiceContainer.getService(DatabaseManager),
33
- ) {}
34
-
35
- private repo(name: any): any {
36
- return this.repo(name);
37
- }
38
-
39
- // ── Event recording ──────────────────────────────────────────
40
-
41
- private async ensuredConfig(guildId: string): Promise<GuildAnalyticsConfig> {
42
- const repo = this.repo(GuildAnalyticsConfig);
43
- const existing = await repo.findOne({ guild_id: guildId });
44
- if (existing) return existing;
45
-
46
- const defaults: GuildAnalyticsConfig = {
47
- guild_id: guildId,
48
- enabled: AnalyticsModuleConfig.defaultTrackMessages || AnalyticsModuleConfig.defaultTrackVoice || AnalyticsModuleConfig.defaultTrackMembers || AnalyticsModuleConfig.defaultTrackCommands,
49
- track_messages: AnalyticsModuleConfig.defaultTrackMessages,
50
- track_voice: AnalyticsModuleConfig.defaultTrackVoice,
51
- track_members: AnalyticsModuleConfig.defaultTrackMembers,
52
- track_commands: AnalyticsModuleConfig.defaultTrackCommands,
53
- track_command_performance: AnalyticsModuleConfig.defaultTrackCommandPerformance,
54
- track_per_channel_voice: AnalyticsModuleConfig.defaultTrackPerChannelVoice,
55
- retention_days: AnalyticsModuleConfig.defaultRetentionDays,
56
- public_stats_page: false,
57
- };
58
- await repo.insert(defaults);
59
- return defaults;
60
- }
61
-
62
- async recordMessage(guildId: string): Promise<void> {
63
- const config = await this.ensuredConfig(guildId);
64
- if (!config.enabled || !config.track_messages) return;
65
- await this.upsertGuildStats(guildId, { message_count: 1 });
66
- }
67
-
68
- async recordMemberJoin(guildId: string): Promise<void> {
69
- const config = await this.ensuredConfig(guildId);
70
- if (!config.enabled || !config.track_members) return;
71
- await this.upsertGuildStats(guildId, { join_count: 1 });
72
- }
73
-
74
- async recordMemberLeave(guildId: string): Promise<void> {
75
- const config = await this.ensuredConfig(guildId);
76
- if (!config.enabled || !config.track_members) return;
77
- await this.upsertGuildStats(guildId, { leave_count: 1 });
78
- }
79
-
80
- async recordVoiceJoin(guildId: string, channelId: string, userId: string): Promise<void> {
81
- const key = `${guildId}_${userId}`;
82
-
83
- const existing = this.voiceSessions.get(key);
84
- if (existing && existing.channelId !== channelId) {
85
- const minutes = (Date.now() - existing.joinTime) / 60000;
86
- if (minutes > 0) {
87
- await this.recordVoiceMinutes(guildId, existing.channelId, minutes, new Set([userId]));
88
- }
89
- } else if (existing) {
90
- return;
91
- }
92
-
93
- this.voiceSessions.set(key, { joinTime: Date.now(), channelId });
94
-
95
- const dateKey = `${guildId}_${channelId}_${today()}`;
96
- if (!this.dailyVoiceUsers.has(dateKey)) {
97
- this.dailyVoiceUsers.set(dateKey, new Set());
98
- }
99
- this.dailyVoiceUsers.get(dateKey)!.add(userId);
100
- }
101
-
102
- async recordVoiceLeave(guildId: string, channelId: string, userId: string): Promise<void> {
103
- const key = `${guildId}_${userId}`;
104
- const session = this.voiceSessions.get(key);
105
- if (!session) return;
106
-
107
- const minutes = (Date.now() - session.joinTime) / 60000;
108
- this.voiceSessions.delete(key);
109
-
110
- if (minutes > 0) {
111
- await this.recordVoiceMinutes(guildId, channelId, minutes, new Set([userId]));
112
- }
113
- }
114
-
115
- async recordVoiceMinutes(guildId: string, channelId: string, minutes: number, userIds: Set<string>): Promise<void> {
116
- const config = await this.ensuredConfig(guildId);
117
- if (!config.enabled || !config.track_voice) return;
118
-
119
- const rounded = Math.round(minutes);
120
- if (rounded <= 0) return;
121
-
122
- await this.upsertGuildStats(guildId, { voice_minutes: rounded });
123
-
124
- if (config.track_per_channel_voice) {
125
- const date = today();
126
- const id = `${guildId}_${channelId}_${date}`;
127
- const repo = this.repo(VoiceChannelDailyStats);
128
- const existing = await repo.findOne({ id });
129
-
130
- if (existing) {
131
- await repo.update({ id }, {
132
- total_minutes: existing.total_minutes + rounded,
133
- unique_users: existing.unique_users + userIds.size,
134
- });
135
- } else {
136
- await repo.insert({
137
- id,
138
- guild_id: guildId,
139
- channel_id: channelId,
140
- date,
141
- total_minutes: rounded,
142
- unique_users: userIds.size,
143
- });
144
- }
145
- }
146
- }
147
-
148
- async recordCommand(payload: CommandExecutedPayload): Promise<void> {
149
- const config = await this.ensuredConfig(payload.guildId);
150
- if (!config.enabled || !config.track_commands) return;
151
-
152
- await this.upsertGuildStats(payload.guildId, { command_count: 1 });
153
-
154
- const date = today();
155
- const id = `${payload.guildId}_${payload.commandName}_${date}`;
156
- const repo = this.repo(CommandDailyStats);
157
- const existing = await repo.findOne({ id });
158
-
159
- const executionTime = config.track_command_performance ? payload.executionTimeMs : 0;
160
- const errorAdd = payload.success ? 0 : 1;
161
-
162
- if (existing) {
163
- await repo.update({ id }, {
164
- usage_count: existing.usage_count + 1,
165
- total_execution_time_ms: existing.total_execution_time_ms + executionTime,
166
- error_count: existing.error_count + errorAdd,
167
- });
168
- } else {
169
- await repo.insert({
170
- id,
171
- guild_id: payload.guildId,
172
- command_name: payload.commandName,
173
- date,
174
- usage_count: 1,
175
- total_execution_time_ms: executionTime,
176
- error_count: errorAdd,
177
- });
178
- }
179
- }
180
-
181
- async recordMemberCount(guildId: string, memberCount: number): Promise<void> {
182
- const config = await this.ensuredConfig(guildId);
183
- if (!config.enabled || !config.track_members) return;
184
-
185
- const date = today();
186
- const id = `${guildId}_${date}`;
187
- const repo = this.repo(GuildDailyStats);
188
- const existing = await repo.findOne({ id });
189
-
190
- if (existing) {
191
- await repo.update({ id }, { member_count: memberCount });
192
- } else {
193
- await repo.insert({
194
- id,
195
- guild_id: guildId,
196
- date,
197
- message_count: 0,
198
- join_count: 0,
199
- leave_count: 0,
200
- voice_minutes: 0,
201
- command_count: 0,
202
- member_count: memberCount,
203
- });
204
- }
205
- }
206
-
207
- // ── Upsert helper ────────────────────────────────────────────
208
-
209
- private async upsertGuildStats(guildId: string, increments: Partial<GuildDailyStats>): Promise<void> {
210
- const date = today();
211
- const id = `${guildId}_${date}`;
212
- const repo = this.repo(GuildDailyStats);
213
- const existing = await repo.findOne({ id });
214
-
215
- if (existing) {
216
- const updates: any = {};
217
- for (const [key, val] of Object.entries(increments)) {
218
- updates[key] = (existing[key as keyof GuildDailyStats] as number) + (val as number);
219
- }
220
- await repo.update({ id }, updates);
221
- } else {
222
- await repo.insert({
223
- id,
224
- guild_id: guildId,
225
- date,
226
- message_count: increments.message_count || 0,
227
- join_count: increments.join_count || 0,
228
- leave_count: increments.leave_count || 0,
229
- voice_minutes: increments.voice_minutes || 0,
230
- command_count: increments.command_count || 0,
231
- member_count: increments.member_count || 0,
232
- });
233
- }
234
- }
235
-
236
- // ── Stats retrieval (public API) ─────────────────────────────
237
-
238
- async getGuildStats(guildId: string, daysBack: number): Promise<GuildDailyStats[]> {
239
- const repo = this.repo(GuildDailyStats);
240
- const dateMin = this.dateDaysAgo(daysBack);
241
- return repo.query()
242
- .where('guild_id', 'eq', guildId)
243
- .where('date', 'gte', dateMin)
244
- .sort('date', 'asc')
245
- .exec() as Promise<GuildDailyStats[]>;
246
- }
247
-
248
- async getGlobalStatsSummary(daysBack: number): Promise<{
249
- totalGuilds: number;
250
- totalMessages: number;
251
- totalCommands: number;
252
- totalVoiceMinutes: number;
253
- totalJoins: number;
254
- totalLeaves: number;
255
- }> {
256
- const repo = this.repo(GuildDailyStats);
257
- const dateMin = this.dateDaysAgo(daysBack);
258
- const rows = await repo.query()
259
- .where('date', 'gte', dateMin)
260
- .exec() as GuildDailyStats[];
261
-
262
- const guildIds = new Set<string>();
263
- let messages = 0, commands = 0, voice = 0, joins = 0, leaves = 0;
264
- for (const row of rows) {
265
- guildIds.add(row.guild_id);
266
- messages += row.message_count;
267
- commands += row.command_count;
268
- voice += row.voice_minutes;
269
- joins += row.join_count;
270
- leaves += row.leave_count;
271
- }
272
- return {
273
- totalGuilds: guildIds.size,
274
- totalMessages: messages,
275
- totalCommands: commands,
276
- totalVoiceMinutes: voice,
277
- totalJoins: joins,
278
- totalLeaves: leaves,
279
- };
280
- }
281
-
282
- async getGuildGrowth(daysBack: number): Promise<{ date: string; guildCount: number }[]> {
283
- const repo = this.repo(GuildDailyStats);
284
- const dateMin = this.dateDaysAgo(daysBack);
285
- const rows = await repo.query()
286
- .where('date', 'gte', dateMin)
287
- .sort('date', 'asc')
288
- .exec() as GuildDailyStats[];
289
-
290
- const byDate = new Map<string, Set<string>>();
291
- for (const row of rows) {
292
- if (!byDate.has(row.date)) byDate.set(row.date, new Set());
293
- byDate.get(row.date)!.add(row.guild_id);
294
- }
295
- return Array.from(byDate.entries())
296
- .map(([date, guilds]) => ({ date, guildCount: guilds.size }))
297
- .sort((a, b) => a.date.localeCompare(b.date));
298
- }
299
-
300
- async getMessagesPerDay(daysBack: number): Promise<{ date: string; count: number }[]> {
301
- const repo = this.repo(GuildDailyStats);
302
- const dateMin = this.dateDaysAgo(daysBack);
303
- const rows = await repo.query()
304
- .where('date', 'gte', dateMin)
305
- .sort('date', 'asc')
306
- .exec() as GuildDailyStats[];
307
-
308
- const byDate = new Map<string, number>();
309
- for (const row of rows) {
310
- byDate.set(row.date, (byDate.get(row.date) || 0) + row.message_count);
311
- }
312
- return Array.from(byDate.entries())
313
- .map(([date, count]) => ({ date, count }))
314
- .sort((a, b) => a.date.localeCompare(b.date));
315
- }
316
-
317
- async getCommandsPerDay(guildId: string | null, daysBack: number): Promise<{ date: string; count: number }[]> {
318
- const repo = this.repo(GuildDailyStats);
319
- const dateMin = this.dateDaysAgo(daysBack);
320
- let rows: GuildDailyStats[];
321
- if (guildId) {
322
- rows = await repo.query()
323
- .where('guild_id', 'eq', guildId)
324
- .where('date', 'gte', dateMin)
325
- .sort('date', 'asc')
326
- .exec() as GuildDailyStats[];
327
- } else {
328
- rows = await repo.query()
329
- .where('date', 'gte', dateMin)
330
- .sort('date', 'asc')
331
- .exec() as GuildDailyStats[];
332
- }
333
-
334
- const byDate = new Map<string, number>();
335
- for (const row of rows) {
336
- byDate.set(row.date, (byDate.get(row.date) || 0) + row.command_count);
337
- }
338
- return Array.from(byDate.entries())
339
- .map(([date, count]) => ({ date, count }))
340
- .sort((a, b) => a.date.localeCompare(b.date));
341
- }
342
-
343
- async getTopCommands(guildId: string | null, daysBack: number, limit: number = 10): Promise<CommandDailyStats[]> {
344
- const repo = this.repo(CommandDailyStats);
345
- const dateMin = this.dateDaysAgo(daysBack);
346
- let rows: CommandDailyStats[];
347
- if (guildId) {
348
- rows = await repo.query()
349
- .where('guild_id', 'eq', guildId)
350
- .where('date', 'gte', dateMin)
351
- .exec() as CommandDailyStats[];
352
- } else {
353
- rows = await repo.query()
354
- .where('date', 'gte', dateMin)
355
- .exec() as CommandDailyStats[];
356
- }
357
-
358
- const aggregated = new Map<string, CommandDailyStats>();
359
- for (const row of rows) {
360
- const key = row.command_name;
361
- if (aggregated.has(key)) {
362
- const existing = aggregated.get(key)!;
363
- existing.usage_count += row.usage_count;
364
- existing.total_execution_time_ms += row.total_execution_time_ms;
365
- existing.error_count += row.error_count;
366
- } else {
367
- aggregated.set(key, {
368
- id: key,
369
- guild_id: guildId || 'global',
370
- command_name: row.command_name,
371
- date: '',
372
- usage_count: row.usage_count,
373
- total_execution_time_ms: row.total_execution_time_ms,
374
- error_count: row.error_count,
375
- });
376
- }
377
- }
378
- return Array.from(aggregated.values())
379
- .sort((a, b) => b.usage_count - a.usage_count)
380
- .slice(0, limit);
381
- }
382
-
383
- async getSlowestCommands(guildId: string | null, daysBack: number, limit: number = 10): Promise<(CommandDailyStats & { avgExecutionTimeMs: number })[]> {
384
- const repo = this.repo(CommandDailyStats);
385
- const dateMin = this.dateDaysAgo(daysBack);
386
- let rows: CommandDailyStats[];
387
- if (guildId) {
388
- rows = await repo.query()
389
- .where('guild_id', 'eq', guildId)
390
- .where('date', 'gte', dateMin)
391
- .exec() as CommandDailyStats[];
392
- } else {
393
- rows = await repo.query()
394
- .where('date', 'gte', dateMin)
395
- .exec() as CommandDailyStats[];
396
- }
397
-
398
- const aggregated = new Map<string, { usage_count: number; totalTime: number; errors: number }>();
399
- for (const row of rows) {
400
- const key = row.command_name;
401
- if (aggregated.has(key)) {
402
- const e = aggregated.get(key)!;
403
- e.usage_count += row.usage_count;
404
- e.totalTime += row.total_execution_time_ms;
405
- e.errors += row.error_count;
406
- } else {
407
- aggregated.set(key, {
408
- usage_count: row.usage_count,
409
- totalTime: row.total_execution_time_ms,
410
- errors: row.error_count,
411
- });
412
- }
413
- }
414
- return Array.from(aggregated.entries())
415
- .map(([name, data]) => ({
416
- id: name,
417
- guild_id: guildId || 'global',
418
- command_name: name,
419
- date: '',
420
- usage_count: data.usage_count,
421
- total_execution_time_ms: data.totalTime,
422
- error_count: data.errors,
423
- avgExecutionTimeMs: data.usage_count > 0 ? Math.round(data.totalTime / data.usage_count) : 0,
424
- }))
425
- .filter(c => c.total_execution_time_ms > 0)
426
- .sort((a, b) => b.avgExecutionTimeMs - a.avgExecutionTimeMs)
427
- .slice(0, limit);
428
- }
429
-
430
- async getVoiceChannelStats(guildId: string, daysBack: number): Promise<VoiceChannelDailyStats[]> {
431
- const repo = this.repo(VoiceChannelDailyStats);
432
- const dateMin = this.dateDaysAgo(daysBack);
433
- return repo.query()
434
- .where('guild_id', 'eq', guildId)
435
- .where('date', 'gte', dateMin)
436
- .sort('date', 'asc')
437
- .exec() as Promise<VoiceChannelDailyStats[]>;
438
- }
439
-
440
- // ── Config ───────────────────────────────────────────────────
441
-
442
- async getConfig(guildId: string): Promise<GuildAnalyticsConfig> {
443
- return this.ensuredConfig(guildId);
444
- }
445
-
446
- async updateConfig(guildId: string, partial: Partial<GuildAnalyticsConfig>): Promise<void> {
447
- const repo = this.repo(GuildAnalyticsConfig);
448
- const existing = await repo.findOne({ guild_id: guildId });
449
- if (existing) {
450
- await repo.update({ guild_id: guildId }, partial);
451
- } else {
452
- await this.ensuredConfig(guildId);
453
- await repo.update({ guild_id: guildId }, partial);
454
- }
455
- }
456
-
457
- // ── Cleanup ──────────────────────────────────────────────────
458
-
459
- async runCleanup(): Promise<void> {
460
- const configRepo = this.repo(GuildAnalyticsConfig);
461
-
462
- const allConfigs = await configRepo.find();
463
- const guildRetention = new Map<string, number>();
464
-
465
- for (const c of allConfigs) {
466
- guildRetention.set(c.guild_id, c.retention_days || AnalyticsModuleConfig.defaultRetentionDays);
467
- }
468
-
469
- const statsRepo = this.repo(GuildDailyStats);
470
- const allStats = await statsRepo.find();
471
- const seenGuilds = new Set<string>();
472
- for (const s of allStats) {
473
- seenGuilds.add(s.guild_id);
474
- }
475
-
476
- for (const guildId of seenGuilds) {
477
- const retentionDays = guildRetention.get(guildId) || AnalyticsModuleConfig.defaultRetentionDays;
478
- const cutoffDate = this.dateDaysAgo(retentionDays);
479
-
480
- const oldStats = await statsRepo.query()
481
- .where('guild_id', 'eq', guildId)
482
- .where('date', 'lt', cutoffDate)
483
- .exec() as GuildDailyStats[];
484
-
485
- for (const stat of oldStats) {
486
- await statsRepo.delete({ id: stat.id });
487
- }
488
-
489
- const cmdRepo = this.repo(CommandDailyStats);
490
- const oldCmds = await cmdRepo.query()
491
- .where('guild_id', 'eq', guildId)
492
- .where('date', 'lt', cutoffDate)
493
- .exec() as CommandDailyStats[];
494
-
495
- for (const cmd of oldCmds) {
496
- await cmdRepo.delete({ id: cmd.id });
497
- }
498
-
499
- const voiceRepo = this.repo(VoiceChannelDailyStats);
500
- const oldVoice = await voiceRepo.query()
501
- .where('guild_id', 'eq', guildId)
502
- .where('date', 'lt', cutoffDate)
503
- .exec() as VoiceChannelDailyStats[];
504
-
505
- for (const vc of oldVoice) {
506
- await voiceRepo.delete({ id: vc.id });
507
- }
508
- }
509
- }
510
-
511
- startCleanupScheduler(): void {
512
- if (this.cleanupTimer) return;
513
- const intervalMs = AnalyticsModuleConfig.cleanupIntervalHours * 60 * 60 * 1000;
514
- this.cleanupTimer = setInterval(() => {
515
- this.runCleanup().catch(err => console.error('[Analytics] Cleanup error:', err));
516
- }, intervalMs);
517
- }
518
-
519
- stopCleanupScheduler(): void {
520
- if (this.cleanupTimer) {
521
- clearInterval(this.cleanupTimer);
522
- this.cleanupTimer = null;
523
- }
524
- }
525
-
526
- clearVoiceSessions(): void {
527
- this.voiceSessions.clear();
528
- }
529
-
530
- // ── Helpers ──────────────────────────────────────────────────
531
-
532
- private dateDaysAgo(days: number): string {
533
- const d = new Date();
534
- d.setDate(d.getDate() - days);
535
- return d.toISOString().slice(0, 10);
536
- }
537
- }