@zumito-team/analytics-module 0.1.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.
- package/README.md +237 -0
- package/commands/AnalyticsConfig.d.ts +12 -0
- package/commands/AnalyticsConfig.js +93 -0
- package/commands/AnalyticsConfig.ts +118 -0
- package/commands/Stats.d.ts +11 -0
- package/commands/Stats.js +52 -0
- package/commands/Stats.ts +66 -0
- package/config.d.ts +11 -0
- package/config.js +13 -0
- package/config.ts +14 -0
- package/events/discord/GuildMemberAdd.d.ts +7 -0
- package/events/discord/GuildMemberAdd.js +24 -0
- package/events/discord/GuildMemberAdd.ts +23 -0
- package/events/discord/GuildMemberRemove.d.ts +7 -0
- package/events/discord/GuildMemberRemove.js +24 -0
- package/events/discord/GuildMemberRemove.ts +23 -0
- package/events/discord/MessageCreate.d.ts +7 -0
- package/events/discord/MessageCreate.js +18 -0
- package/events/discord/MessageCreate.ts +16 -0
- package/events/discord/VoiceStateUpdate.d.ts +7 -0
- package/events/discord/VoiceStateUpdate.js +29 -0
- package/events/discord/VoiceStateUpdate.ts +31 -0
- package/events/framework/CommandExecuted.d.ts +7 -0
- package/events/framework/CommandExecuted.js +17 -0
- package/events/framework/CommandExecuted.ts +16 -0
- package/index.d.ts +15 -0
- package/index.js +61 -0
- package/index.ts +69 -0
- package/models/CommandDailyStats.d.ts +9 -0
- package/models/CommandDailyStats.js +34 -0
- package/models/CommandDailyStats.ts +25 -0
- package/models/GuildAnalyticsConfig.d.ts +12 -0
- package/models/GuildAnalyticsConfig.js +43 -0
- package/models/GuildAnalyticsConfig.ts +34 -0
- package/models/GuildDailyStats.d.ts +11 -0
- package/models/GuildDailyStats.js +40 -0
- package/models/GuildDailyStats.ts +31 -0
- package/models/VoiceChannelDailyStats.d.ts +8 -0
- package/models/VoiceChannelDailyStats.js +31 -0
- package/models/VoiceChannelDailyStats.ts +22 -0
- package/package.json +21 -0
- package/routes/AdminAnalytics.d.ts +11 -0
- package/routes/AdminAnalytics.js +55 -0
- package/routes/AdminAnalytics.ts +69 -0
- package/routes/UserPanelAnalytics.d.ts +11 -0
- package/routes/UserPanelAnalytics.js +81 -0
- package/routes/UserPanelAnalytics.ts +101 -0
- package/services/AnalyticsCollector.d.ts +62 -0
- package/services/AnalyticsCollector.js +470 -0
- package/services/AnalyticsCollector.ts +537 -0
- package/translations/en.json +63 -0
- package/translations/es.json +63 -0
- package/tsconfig.json +18 -0
- package/views/admin-analytics.ejs +170 -0
- package/views/user-analytics.ejs +209 -0
|
@@ -0,0 +1,537 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"command.stats.title": "Server Stats",
|
|
3
|
+
"command.stats.description": "Last {days} days summary",
|
|
4
|
+
"command.stats.messages": "Messages",
|
|
5
|
+
"command.stats.commands": "Commands",
|
|
6
|
+
"command.stats.joins": "Joins",
|
|
7
|
+
"command.stats.leaves": "Leaves",
|
|
8
|
+
"command.stats.voice": "Voice Activity",
|
|
9
|
+
"command.stats.noGuild": "This command can only be used in a server.",
|
|
10
|
+
"command.analytics-config.title": "Analytics Configuration",
|
|
11
|
+
"command.analytics-config.configTitle": "Analytics Configuration",
|
|
12
|
+
"command.analytics-config.configDescription": "Current analytics tracking settings for this server.",
|
|
13
|
+
"command.analytics-config.configUpdated": "Configuration Updated",
|
|
14
|
+
"command.analytics-config.enabled": "Enabled",
|
|
15
|
+
"command.analytics-config.trackMessages": "Track Messages",
|
|
16
|
+
"command.analytics-config.trackVoice": "Track Voice",
|
|
17
|
+
"command.analytics-config.trackMembers": "Track Members",
|
|
18
|
+
"command.analytics-config.trackCommands": "Track Commands",
|
|
19
|
+
"command.analytics-config.trackPerformance": "Track Command Performance",
|
|
20
|
+
"command.analytics-config.trackPerChannelVoice": "Track Per-Channel Voice",
|
|
21
|
+
"command.analytics-config.retentionDays": "Retention (days)",
|
|
22
|
+
"command.analytics-config.publicStats": "Public Stats Page",
|
|
23
|
+
"command.analytics-config.noGuild": "This command can only be used in a server.",
|
|
24
|
+
"command.analytics-config.noPermission": "You need administrator permissions to manage analytics.",
|
|
25
|
+
"analytics.sidebarTitle": "Analytics",
|
|
26
|
+
"analytics.title": "Analytics",
|
|
27
|
+
"analytics.botStats": "Bot Statistics",
|
|
28
|
+
"analytics.serverStats": "Server Statistics",
|
|
29
|
+
"analytics.messages": "Messages",
|
|
30
|
+
"analytics.commands": "Commands",
|
|
31
|
+
"analytics.joins": "Joins",
|
|
32
|
+
"analytics.leaves": "Leaves",
|
|
33
|
+
"analytics.voice": "Voice Activity",
|
|
34
|
+
"analytics.errors": "Errors",
|
|
35
|
+
"analytics.totalGuilds": "Total Servers",
|
|
36
|
+
"analytics.activeGuilds": "Active Servers",
|
|
37
|
+
"analytics.totalMembers": "Members Reach",
|
|
38
|
+
"analytics.messagesPerDay": "Messages per Day",
|
|
39
|
+
"analytics.commandsPerDay": "Commands per Day",
|
|
40
|
+
"analytics.joinsVsLeaves": "Joins vs Leaves",
|
|
41
|
+
"analytics.voiceActivity": "Voice Activity",
|
|
42
|
+
"analytics.topCommands": "Top Commands",
|
|
43
|
+
"analytics.slowestCommands": "Slowest Commands",
|
|
44
|
+
"analytics.guildGrowth": "Server Growth",
|
|
45
|
+
"analytics.last7days": "Last 7 Days",
|
|
46
|
+
"analytics.last30days": "Last 30 Days",
|
|
47
|
+
"analytics.last90days": "Last 90 Days",
|
|
48
|
+
"analytics.noData": "No data available",
|
|
49
|
+
"analytics.commandName": "Command",
|
|
50
|
+
"analytics.usageCount": "Uses",
|
|
51
|
+
"analytics.avgExecutionTime": "Avg. Time",
|
|
52
|
+
"analytics.executionTime": "Execution Time",
|
|
53
|
+
"analytics.overview": "Overview",
|
|
54
|
+
"analytics.detailedStats": "Detailed Stats",
|
|
55
|
+
"analytics.minutes": "minutes",
|
|
56
|
+
"analytics.globalStats": "Global",
|
|
57
|
+
"analytics.perChannelVoice": "Voice per Channel",
|
|
58
|
+
"analytics.channelName": "Channel",
|
|
59
|
+
"analytics.totalMinutes": "Total Minutes",
|
|
60
|
+
"analytics.uniqueUsers": "Unique Users",
|
|
61
|
+
"analytics.date": "Date",
|
|
62
|
+
"analytics.count": "Count"
|
|
63
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"command.stats.title": "Estadisticas del Servidor",
|
|
3
|
+
"command.stats.description": "Resumen de los ultimos {days} dias",
|
|
4
|
+
"command.stats.messages": "Mensajes",
|
|
5
|
+
"command.stats.commands": "Comandos",
|
|
6
|
+
"command.stats.joins": "Entradas",
|
|
7
|
+
"command.stats.leaves": "Salidas",
|
|
8
|
+
"command.stats.voice": "Actividad de Voz",
|
|
9
|
+
"command.stats.noGuild": "Este comando solo puede usarse en un servidor.",
|
|
10
|
+
"command.analytics-config.title": "Configuracion de Analiticas",
|
|
11
|
+
"command.analytics-config.configTitle": "Configuracion de Analiticas",
|
|
12
|
+
"command.analytics-config.configDescription": "Ajustes actuales de seguimiento de analiticas para este servidor.",
|
|
13
|
+
"command.analytics-config.configUpdated": "Configuracion Actualizada",
|
|
14
|
+
"command.analytics-config.enabled": "Activado",
|
|
15
|
+
"command.analytics-config.trackMessages": "Seguimiento de Mensajes",
|
|
16
|
+
"command.analytics-config.trackVoice": "Seguimiento de Voz",
|
|
17
|
+
"command.analytics-config.trackMembers": "Seguimiento de Miembros",
|
|
18
|
+
"command.analytics-config.trackCommands": "Seguimiento de Comandos",
|
|
19
|
+
"command.analytics-config.trackPerformance": "Seguimiento de Rendimiento",
|
|
20
|
+
"command.analytics-config.trackPerChannelVoice": "Voz por Canal",
|
|
21
|
+
"command.analytics-config.retentionDays": "Retencion (dias)",
|
|
22
|
+
"command.analytics-config.publicStats": "Pagina de Estadisticas Publica",
|
|
23
|
+
"command.analytics-config.noGuild": "Este comando solo puede usarse en un servidor.",
|
|
24
|
+
"command.analytics-config.noPermission": "Necesitas permisos de administrador para gestionar las analiticas.",
|
|
25
|
+
"analytics.sidebarTitle": "Analiticas",
|
|
26
|
+
"analytics.title": "Analiticas",
|
|
27
|
+
"analytics.botStats": "Estadisticas del Bot",
|
|
28
|
+
"analytics.serverStats": "Estadisticas del Servidor",
|
|
29
|
+
"analytics.messages": "Mensajes",
|
|
30
|
+
"analytics.commands": "Comandos",
|
|
31
|
+
"analytics.joins": "Entradas",
|
|
32
|
+
"analytics.leaves": "Salidas",
|
|
33
|
+
"analytics.voice": "Actividad de Voz",
|
|
34
|
+
"analytics.errors": "Errores",
|
|
35
|
+
"analytics.totalGuilds": "Servidores Totales",
|
|
36
|
+
"analytics.activeGuilds": "Servidores Activos",
|
|
37
|
+
"analytics.totalMembers": "Alcance de Miembros",
|
|
38
|
+
"analytics.messagesPerDay": "Mensajes por Dia",
|
|
39
|
+
"analytics.commandsPerDay": "Comandos por Dia",
|
|
40
|
+
"analytics.joinsVsLeaves": "Entradas vs Salidas",
|
|
41
|
+
"analytics.voiceActivity": "Actividad de Voz",
|
|
42
|
+
"analytics.topCommands": "Comandos Mas Usados",
|
|
43
|
+
"analytics.slowestCommands": "Comandos Mas Lentos",
|
|
44
|
+
"analytics.guildGrowth": "Crecimiento de Servidores",
|
|
45
|
+
"analytics.last7days": "Ultimos 7 Dias",
|
|
46
|
+
"analytics.last30days": "Ultimos 30 Dias",
|
|
47
|
+
"analytics.last90days": "Ultimos 90 Dias",
|
|
48
|
+
"analytics.noData": "Sin datos disponibles",
|
|
49
|
+
"analytics.commandName": "Comando",
|
|
50
|
+
"analytics.usageCount": "Usos",
|
|
51
|
+
"analytics.avgExecutionTime": "Tiempo Medio",
|
|
52
|
+
"analytics.executionTime": "Tiempo de Ejecucion",
|
|
53
|
+
"analytics.overview": "Resumen",
|
|
54
|
+
"analytics.detailedStats": "Estadisticas Detalladas",
|
|
55
|
+
"analytics.minutes": "minutos",
|
|
56
|
+
"analytics.globalStats": "Global",
|
|
57
|
+
"analytics.perChannelVoice": "Voz por Canal",
|
|
58
|
+
"analytics.channelName": "Canal",
|
|
59
|
+
"analytics.totalMinutes": "Minutos Totales",
|
|
60
|
+
"analytics.uniqueUsers": "Usuarios Unicos",
|
|
61
|
+
"analytics.date": "Fecha",
|
|
62
|
+
"analytics.count": "Cantidad"
|
|
63
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ES2020",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"outDir": ".",
|
|
7
|
+
"rootDir": ".",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"strict": false,
|
|
14
|
+
"experimentalDecorators": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["**/*.ts"],
|
|
17
|
+
"exclude": ["dist", "node_modules"]
|
|
18
|
+
}
|