koishi-plugin-chat-analyse 0.3.3 → 0.3.4
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/lib/Renderer.d.ts +2 -15
- package/lib/Stat.d.ts +25 -5
- package/lib/index.js +181 -207
- package/package.json +1 -1
package/lib/Renderer.d.ts
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
|
-
/**
|
|
3
|
-
* @typedef {Array<string | number | Date>} RenderListItem
|
|
4
|
-
* @description 定义了统计列表中单行数据的格式,它是一个由字符串、数字或日期组成的元组。
|
|
5
|
-
*/
|
|
6
2
|
export type RenderListItem = (string | number | Date)[];
|
|
7
|
-
/**
|
|
8
|
-
* @interface ListRenderData
|
|
9
|
-
* @description 定义了调用渲染器生成列表图片时所需的完整数据结构。
|
|
10
|
-
*/
|
|
11
3
|
export interface ListRenderData {
|
|
12
4
|
title: string;
|
|
13
5
|
time: Date;
|
|
@@ -16,15 +8,10 @@ export interface ListRenderData {
|
|
|
16
8
|
}
|
|
17
9
|
/**
|
|
18
10
|
* @class Renderer
|
|
19
|
-
* @description
|
|
20
|
-
* 渲染为一张包含精美表格的图片。
|
|
11
|
+
* @description 通用列表渲染器,使用 Puppeteer 将结构化数据渲染为精美的、类似卡片的表格图片。
|
|
21
12
|
*/
|
|
22
13
|
export declare class Renderer {
|
|
23
14
|
private ctx;
|
|
24
|
-
/**
|
|
25
|
-
* @constructor
|
|
26
|
-
* @param {Context} ctx - Koishi 的插件上下文,用于访问 puppeteer 服务。
|
|
27
|
-
*/
|
|
28
15
|
constructor(ctx: Context);
|
|
29
16
|
/**
|
|
30
17
|
* @public
|
|
@@ -39,7 +26,7 @@ export declare class Renderer {
|
|
|
39
26
|
/**
|
|
40
27
|
* @private
|
|
41
28
|
* @method formatDate
|
|
42
|
-
* @description
|
|
29
|
+
* @description 将日期格式化为包含两个单位的相对时间字符串(如“21天14时前”),如果超过一年则显示绝对日期。
|
|
43
30
|
* @param {Date} date - 待格式化的 Date 对象。
|
|
44
31
|
* @returns {string} 格式化后的日期字符串。
|
|
45
32
|
*/
|
package/lib/Stat.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Renderer } from './Renderer';
|
|
|
3
3
|
import { Config } from './index';
|
|
4
4
|
/**
|
|
5
5
|
* @class Stat
|
|
6
|
-
* @description
|
|
6
|
+
* @description 提供统一的统计查询服务。负责注册查询命令,从数据库获取数据,并调用渲染器生成图表。
|
|
7
7
|
*/
|
|
8
8
|
export declare class Stat {
|
|
9
9
|
private ctx;
|
|
@@ -16,11 +16,31 @@ export declare class Stat {
|
|
|
16
16
|
*/
|
|
17
17
|
constructor(ctx: Context, config: Config);
|
|
18
18
|
/**
|
|
19
|
+
* @public
|
|
19
20
|
* @method registerCommands
|
|
20
21
|
* @description 根据插件配置,动态地将 `.cmd`, `.msg`, `.rank` 子命令注册到主 `analyse` 命令下。
|
|
21
22
|
* @param {Command} analyse - 主 `analyse` 命令实例。
|
|
22
23
|
*/
|
|
23
24
|
registerCommands(analyse: Command): void;
|
|
25
|
+
/**
|
|
26
|
+
* @private
|
|
27
|
+
* @method parseQueryScope
|
|
28
|
+
* @description 解析命令的选项,将其转换为统一的查询范围对象(userId 和 guildId)。
|
|
29
|
+
* @param {Session} session - 当前会话对象。
|
|
30
|
+
* @param {QueryScopeOptions} options - 命令传入的选项。
|
|
31
|
+
* @returns {QueryScopeResult} 包含 userId、guildId 或 error 信息的查询范围对象。
|
|
32
|
+
*/
|
|
33
|
+
private parseQueryScope;
|
|
34
|
+
/**
|
|
35
|
+
* @private
|
|
36
|
+
* @async
|
|
37
|
+
* @method getUidsInScope
|
|
38
|
+
* @description 根据查询范围(guildId, userId)获取匹配用户的 UID 列表。
|
|
39
|
+
* @param {string} [guildId] - (可选) 群组 ID。
|
|
40
|
+
* @param {string} [userId] - (可选) 用户 ID。
|
|
41
|
+
* @returns {Promise<{ uids?: number[], error?: string }>} 包含 UID 数组或错误信息的对象。
|
|
42
|
+
*/
|
|
43
|
+
private getUidsInScope;
|
|
24
44
|
/**
|
|
25
45
|
* @private
|
|
26
46
|
* @async
|
|
@@ -63,17 +83,17 @@ export declare class Stat {
|
|
|
63
83
|
* @param {string} type - 要查询的消息类型。
|
|
64
84
|
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
65
85
|
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
66
|
-
* @returns {Promise<{ list: RenderListItem[], total: number } | string>}
|
|
86
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
67
87
|
*/
|
|
68
88
|
private getMessageStatsByType;
|
|
69
89
|
/**
|
|
70
90
|
* @private
|
|
71
91
|
* @async
|
|
72
92
|
* @method getActiveUserStats
|
|
73
|
-
* @description
|
|
74
|
-
* @param {string} guildId - 要查询的群组 ID。
|
|
93
|
+
* @description 从数据库中获取并聚合指定时间范围内的活跃用户排行数据。
|
|
75
94
|
* @param {number} hours - 查询过去的小时数。
|
|
76
|
-
* @
|
|
95
|
+
* @param {string} [guildId] - (可选) 要查询的群组 ID。若不提供,则进行全局排行。
|
|
96
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
77
97
|
*/
|
|
78
98
|
private getActiveUserStats;
|
|
79
99
|
}
|
package/lib/index.js
CHANGED
|
@@ -266,10 +266,6 @@ var import_koishi3 = require("koishi");
|
|
|
266
266
|
// src/Renderer.ts
|
|
267
267
|
var import_koishi2 = require("koishi");
|
|
268
268
|
var Renderer = class {
|
|
269
|
-
/**
|
|
270
|
-
* @constructor
|
|
271
|
-
* @param {Context} ctx - Koishi 的插件上下文,用于访问 puppeteer 服务。
|
|
272
|
-
*/
|
|
273
269
|
constructor(ctx) {
|
|
274
270
|
this.ctx = ctx;
|
|
275
271
|
}
|
|
@@ -293,7 +289,7 @@ var Renderer = class {
|
|
|
293
289
|
/**
|
|
294
290
|
* @private
|
|
295
291
|
* @method formatDate
|
|
296
|
-
* @description
|
|
292
|
+
* @description 将日期格式化为包含两个单位的相对时间字符串(如“21天14时前”),如果超过一年则显示绝对日期。
|
|
297
293
|
* @param {Date} date - 待格式化的 Date 对象。
|
|
298
294
|
* @returns {string} 格式化后的日期字符串。
|
|
299
295
|
*/
|
|
@@ -301,9 +297,26 @@ var Renderer = class {
|
|
|
301
297
|
if (!date) return "未知";
|
|
302
298
|
const diff = Date.now() - date.getTime();
|
|
303
299
|
if (diff < import_koishi2.Time.minute) return "刚刚";
|
|
304
|
-
if (diff
|
|
305
|
-
|
|
306
|
-
|
|
300
|
+
if (diff > 365 * import_koishi2.Time.day) {
|
|
301
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
302
|
+
}
|
|
303
|
+
const timeUnits = [
|
|
304
|
+
{ unit: "月", ms: 30 * import_koishi2.Time.day },
|
|
305
|
+
{ unit: "天", ms: import_koishi2.Time.day },
|
|
306
|
+
{ unit: "时", ms: import_koishi2.Time.hour },
|
|
307
|
+
{ unit: "分", ms: import_koishi2.Time.minute }
|
|
308
|
+
];
|
|
309
|
+
let remainingDiff = diff;
|
|
310
|
+
const parts = [];
|
|
311
|
+
for (const { unit, ms } of timeUnits) {
|
|
312
|
+
if (remainingDiff >= ms) {
|
|
313
|
+
const value = Math.floor(remainingDiff / ms);
|
|
314
|
+
parts.push(`${value}${unit}`);
|
|
315
|
+
remainingDiff %= ms;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const result = parts.slice(0, 2).join("");
|
|
319
|
+
return result ? `${result}前` : "刚刚";
|
|
307
320
|
}
|
|
308
321
|
/**
|
|
309
322
|
* @private
|
|
@@ -316,63 +329,45 @@ var Renderer = class {
|
|
|
316
329
|
generateListHtml(data, headers) {
|
|
317
330
|
const { title, time, total, list } = data;
|
|
318
331
|
if (!list?.length) return null;
|
|
319
|
-
const tableHeadHtml = headers?.length > 0 ? `<thead><tr><th class="rank-cell">排名</th>${headers.map((h2) =>
|
|
332
|
+
const tableHeadHtml = headers?.length > 0 ? `<thead><tr><th class="rank-cell">排名</th>${headers.map((h2, i) => {
|
|
333
|
+
const firstCell = list[0]?.[i];
|
|
334
|
+
let headerClass = "";
|
|
335
|
+
if (i === 0) headerClass = "column-main-label";
|
|
336
|
+
if (typeof firstCell === "number" || firstCell instanceof Date) {
|
|
337
|
+
headerClass += " header-right-align";
|
|
338
|
+
}
|
|
339
|
+
return `<th class="${headerClass.trim()}">${h2}</th>`;
|
|
340
|
+
}).join("")}</tr></thead>` : "";
|
|
320
341
|
const tableRowsHtml = list.map((row, index) => {
|
|
321
342
|
const rank = index + 1;
|
|
322
343
|
const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
|
|
323
|
-
const rankCell = `<td class="rank-cell
|
|
324
|
-
const dataCells = row.map((cell) => {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
344
|
+
const rankCell = `<td class="rank-cell ${rankClass}">${rank}</td>`;
|
|
345
|
+
const dataCells = row.map((cell, cellIndex) => {
|
|
346
|
+
let className = "data-cell";
|
|
347
|
+
let content;
|
|
348
|
+
if (cell instanceof Date) {
|
|
349
|
+
className += " date-cell";
|
|
350
|
+
content = this.formatDate(cell);
|
|
351
|
+
} else if (typeof cell === "number") {
|
|
352
|
+
className += " count-cell";
|
|
353
|
+
content = cell.toLocaleString();
|
|
354
|
+
} else {
|
|
355
|
+
className += " name-cell";
|
|
356
|
+
content = String(cell);
|
|
357
|
+
}
|
|
358
|
+
if (cellIndex === 0) className += " column-main-label";
|
|
359
|
+
return `<td class="${className}">${content}</td>`;
|
|
328
360
|
}).join("");
|
|
329
361
|
return `<tr>${rankCell}${dataCells}</tr>`;
|
|
330
362
|
}).join("");
|
|
331
363
|
const metaInfoHtml = `
|
|
332
364
|
<div class="meta-group">
|
|
333
|
-
${total !== void 0 ? `<div class="total-count">总计: ${total}</div>` : ""}
|
|
365
|
+
${total !== void 0 ? `<div class="total-count">总计: ${typeof total === "number" ? total.toLocaleString() : total}</div>` : ""}
|
|
334
366
|
<div class="time-label">生成于 ${time.toLocaleString("zh-CN", { hour12: false })}</div>
|
|
335
367
|
</div>
|
|
336
368
|
`;
|
|
337
|
-
const styles =
|
|
338
|
-
|
|
339
|
-
--bg-color: #f7f8fa; --card-bg: #ffffff; --text-color: #333; --header-color: #1f2329;
|
|
340
|
-
--sub-text-color: #646a73; --border-color: #e4e6eb; --accent-color: #4a6ee0;
|
|
341
|
-
--gold: #ffc327; --silver: #a8b5c1; --bronze: #d69864;
|
|
342
|
-
}
|
|
343
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: var(--bg-color); margin: 0; padding: 20px; width: 700px; box-sizing: border-box; -webkit-font-smoothing: antialiased; }
|
|
344
|
-
.container { background: var(--card-bg); border-radius: 12px; box-shadow: 0 6px 16px rgba(0,0,0,0.08); padding: 24px; }
|
|
345
|
-
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; margin-bottom: 16px; }
|
|
346
|
-
.title-group h1 { font-size: 24px; font-weight: 700; color: var(--header-color); margin: 0; }
|
|
347
|
-
.meta-group { text-align: right; }
|
|
348
|
-
.meta-group .total-count { font-size: 22px; font-weight: 700; color: var(--accent-color); }
|
|
349
|
-
.meta-group .time-label { font-size: 13px; color: var(--sub-text-color); margin-top: 4px; }
|
|
350
|
-
table { width: 100%; border-collapse: collapse; color: var(--text-color); }
|
|
351
|
-
th, td { padding: 12px 8px; text-align: left; border-bottom: 1px solid var(--border-color); vertical-align: middle; }
|
|
352
|
-
th { font-size: 13px; font-weight: 600; color: var(--sub-text-color); }
|
|
353
|
-
td { font-size: 15px; }
|
|
354
|
-
tr:last-child td { border-bottom: none; }
|
|
355
|
-
.rank-cell { width: 50px; text-align: center; }
|
|
356
|
-
.rank-badge { display: inline-block; width: 24px; height: 24px; line-height: 24px; border-radius: 50%; font-weight: 600; font-size: 14px; color: var(--header-color); background-color: #eef0f3; }
|
|
357
|
-
.rank-gold, .rank-silver, .rank-bronze { color: #fff; }
|
|
358
|
-
.rank-gold { background-color: var(--gold); } .rank-silver { background-color: var(--silver); } .rank-bronze { background-color: var(--bronze); }
|
|
359
|
-
.data-cell { word-break: break-all; }
|
|
360
|
-
.name-cell { font-weight: 600; color: var(--header-color); }
|
|
361
|
-
.count-cell { text-align: right; font-weight: 600; color: var(--accent-color); }
|
|
362
|
-
.date-cell { text-align: right; font-size: 13px; color: var(--sub-text-color); }
|
|
363
|
-
`;
|
|
364
|
-
return `
|
|
365
|
-
<!DOCTYPE html><html lang="zh-CN">
|
|
366
|
-
<head><meta charset="UTF-8"><title>${title}</title><style>${styles}</style></head>
|
|
367
|
-
<body>
|
|
368
|
-
<div class="container">
|
|
369
|
-
<div class="header">
|
|
370
|
-
<div class="title-group"><h1>${title}</h1></div>
|
|
371
|
-
${metaInfoHtml}
|
|
372
|
-
</div>
|
|
373
|
-
<table>${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table>
|
|
374
|
-
</div>
|
|
375
|
-
</body></html>`;
|
|
369
|
+
const styles = `:root{--bg-color:#f7f8fa;--card-bg:#fff;--text-color:#333;--header-color:#1f2329;--sub-text-color:#646a73;--border-color:#f0f0f0;--accent-color:#4a6ee0;--gold:#ffac33;--silver:#a8b5c1;--bronze:#d69864}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:var(--bg-color);margin:0;padding:20px;width:800px;box-sizing:border-box;-webkit-font-smoothing:antialiased}.container{background:var(--card-bg);border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.08);padding:20px}.header{display:flex;justify-content:space-between;align-items:flex-start;padding-bottom:12px;margin-bottom:8px}.title-group h1{font-size:22px;font-weight:600;color:var(--header-color);margin:0}.meta-group{text-align:right;white-space:nowrap}.meta-group .total-count{font-size:18px;font-weight:600;color:var(--accent-color)}.meta-group .time-label{font-size:13px;color:var(--sub-text-color);margin-top:4px}table{width:100%;border-collapse:collapse;color:var(--text-color)}th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--border-color);vertical-align:middle}thead tr:first-child th{border-top:1px solid var(--border-color)}th{font-size:13px;font-weight:500;color:var(--sub-text-color)}td{font-size:15px}tr:last-child td{border-bottom:none}.header-right-align{text-align:right}.rank-cell{width:45px;text-align:center;font-weight:600;color:var(--sub-text-color);padding-left:0;padding-right:0}.rank-gold{color:var(--gold) !important}.rank-silver{color:var(--silver) !important}.rank-bronze{color:var(--bronze) !important}.column-main-label{width:45%}.data-cell{word-break:break-all}.name-cell{font-weight:500;color:var(--header-color)}.count-cell,.date-cell{text-align:right}.count-cell{font-weight:600;color:var(--accent-color)}.date-cell{font-size:14px;color:var(--sub-text-color)}`;
|
|
370
|
+
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${title}</title><style>${styles}</style></head><body><div class="container"><div class="header"><div class="title-group"><h1>${title}</h1></div>${metaInfoHtml}</div><table>${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table></div></body></html>`;
|
|
376
371
|
}
|
|
377
372
|
};
|
|
378
373
|
|
|
@@ -393,32 +388,22 @@ var Stat = class {
|
|
|
393
388
|
}
|
|
394
389
|
renderer;
|
|
395
390
|
/**
|
|
391
|
+
* @public
|
|
396
392
|
* @method registerCommands
|
|
397
393
|
* @description 根据插件配置,动态地将 `.cmd`, `.msg`, `.rank` 子命令注册到主 `analyse` 命令下。
|
|
398
394
|
* @param {Command} analyse - 主 `analyse` 命令实例。
|
|
399
395
|
*/
|
|
400
396
|
registerCommands(analyse) {
|
|
401
397
|
if (this.config.enableCmdStat) {
|
|
402
|
-
analyse.subcommand(".cmd", "命令使用统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
if (!userId && !guildId && session.guildId) {
|
|
406
|
-
guildId = session.guildId;
|
|
407
|
-
} else if (!userId && !guildId && !session.guildId) {
|
|
408
|
-
return "请指定查询范围";
|
|
409
|
-
}
|
|
398
|
+
analyse.subcommand(".cmd", "命令使用统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").option("all", "-a 展示全局统计").action(async ({ session, options }) => {
|
|
399
|
+
const scope = this.parseQueryScope(session, options);
|
|
400
|
+
if (scope.error) return scope.error;
|
|
410
401
|
try {
|
|
411
|
-
const stats = await this.getCommandStats(guildId, userId);
|
|
402
|
+
const stats = await this.getCommandStats(scope.guildId, scope.userId);
|
|
412
403
|
if (typeof stats === "string") return stats;
|
|
413
|
-
const title = await this.generateTitle(guildId, userId, { main: "命令" });
|
|
414
|
-
const renderData = {
|
|
415
|
-
|
|
416
|
-
time: /* @__PURE__ */ new Date(),
|
|
417
|
-
total: stats.total,
|
|
418
|
-
list: stats.list
|
|
419
|
-
};
|
|
420
|
-
const headers = ["命令", "次数", "最后使用"];
|
|
421
|
-
const result = await this.renderer.renderList(renderData, headers);
|
|
404
|
+
const title = await this.generateTitle(scope.guildId, scope.userId, { main: "命令" });
|
|
405
|
+
const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: stats.list };
|
|
406
|
+
const result = await this.renderer.renderList(renderData, ["命令", "次数", "最后使用"]);
|
|
422
407
|
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
423
408
|
} catch (error) {
|
|
424
409
|
this.ctx.logger.error("渲染命令统计图片失败:", error);
|
|
@@ -427,40 +412,23 @@ var Stat = class {
|
|
|
427
412
|
});
|
|
428
413
|
}
|
|
429
414
|
if (this.config.enableMsgStat) {
|
|
430
|
-
analyse.subcommand(".msg", "消息发送统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").option("type", "-t <type:string> 指定类型").
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
if (!userId && !guildId && !options.type && session.guildId) {
|
|
434
|
-
guildId = session.guildId;
|
|
435
|
-
} else if (!userId && !guildId && !session.guildId) {
|
|
436
|
-
return "请指定查询范围";
|
|
437
|
-
}
|
|
415
|
+
analyse.subcommand(".msg", "消息发送统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a 展示全局统计").action(async ({ session, options }) => {
|
|
416
|
+
const scope = this.parseQueryScope(session, options);
|
|
417
|
+
if (scope.error) return scope.error;
|
|
438
418
|
try {
|
|
439
419
|
if (options.type) {
|
|
440
|
-
const stats = await this.getMessageStatsByType(options.type, guildId, userId);
|
|
420
|
+
const stats = await this.getMessageStatsByType(options.type, scope.guildId, scope.userId);
|
|
441
421
|
if (typeof stats === "string") return stats;
|
|
442
|
-
const title = await this.generateTitle(guildId,
|
|
443
|
-
const renderData = {
|
|
444
|
-
|
|
445
|
-
time: /* @__PURE__ */ new Date(),
|
|
446
|
-
total: stats.total,
|
|
447
|
-
list: stats.list
|
|
448
|
-
};
|
|
449
|
-
const headers = ["用户", "条数", "最后发言"];
|
|
450
|
-
const result = await this.renderer.renderList(renderData, headers);
|
|
422
|
+
const title = await this.generateTitle(scope.guildId, scope.userId, { main: "消息", subtype: options.type });
|
|
423
|
+
const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: stats.list };
|
|
424
|
+
const result = await this.renderer.renderList(renderData, ["用户", "条数", "最后发言"]);
|
|
451
425
|
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
452
426
|
} else {
|
|
453
|
-
const stats = await this.getMessageStats(guildId, userId);
|
|
427
|
+
const stats = await this.getMessageStats(scope.guildId, scope.userId);
|
|
454
428
|
if (typeof stats === "string") return stats;
|
|
455
|
-
const title = await this.generateTitle(guildId, userId, { main: "消息" });
|
|
456
|
-
const renderData = {
|
|
457
|
-
|
|
458
|
-
time: /* @__PURE__ */ new Date(),
|
|
459
|
-
total: stats.total,
|
|
460
|
-
list: stats.list
|
|
461
|
-
};
|
|
462
|
-
const headers = ["类型", "条数", "最后发言"];
|
|
463
|
-
const result = await this.renderer.renderList(renderData, headers);
|
|
429
|
+
const title = await this.generateTitle(scope.guildId, scope.userId, { main: "消息" });
|
|
430
|
+
const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: stats.list };
|
|
431
|
+
const result = await this.renderer.renderList(renderData, ["类型", "条数", "最后发言"]);
|
|
464
432
|
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
465
433
|
}
|
|
466
434
|
} catch (error) {
|
|
@@ -470,27 +438,20 @@ var Stat = class {
|
|
|
470
438
|
});
|
|
471
439
|
}
|
|
472
440
|
if (this.config.enableRankStat) {
|
|
473
|
-
analyse.subcommand(".rank", "用户发言排行").option("guild", "-g [guildId:string] 指定群组").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).
|
|
474
|
-
let guildId = options.guild;
|
|
475
|
-
if (!
|
|
476
|
-
if (!guildId) return "
|
|
441
|
+
analyse.subcommand(".rank", "用户发言排行").option("guild", "-g [guildId:string] 指定群组").option("all", "-a 展示全局统计").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).action(async ({ session, options }) => {
|
|
442
|
+
let guildId = options.all ? void 0 : typeof options.guild === "string" ? options.guild : session.guildId;
|
|
443
|
+
if (!session.guildId) return "请指定群组 ID";
|
|
444
|
+
if (!guildId && !options.all) return "请提供查询范围";
|
|
477
445
|
try {
|
|
478
|
-
const stats = await this.getActiveUserStats(
|
|
446
|
+
const stats = await this.getActiveUserStats(options.hours, guildId);
|
|
479
447
|
if (typeof stats === "string") return stats;
|
|
480
|
-
const listWithPercentage = stats.list.map((row) =>
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
});
|
|
448
|
+
const listWithPercentage = stats.list.map((row) => [
|
|
449
|
+
...row,
|
|
450
|
+
stats.total > 0 ? `${(row[1] / stats.total * 100).toFixed(2)}%` : "0.00%"
|
|
451
|
+
]);
|
|
485
452
|
const title = await this.generateTitle(guildId, void 0, { main: "排行", timeRange: options.hours });
|
|
486
|
-
const renderData = {
|
|
487
|
-
|
|
488
|
-
time: /* @__PURE__ */ new Date(),
|
|
489
|
-
total: stats.total,
|
|
490
|
-
list: listWithPercentage
|
|
491
|
-
};
|
|
492
|
-
const headers = ["用户", "总计发言", "占比"];
|
|
493
|
-
const result = await this.renderer.renderList(renderData, headers);
|
|
453
|
+
const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: listWithPercentage };
|
|
454
|
+
const result = await this.renderer.renderList(renderData, ["用户", "总计发言", "占比"]);
|
|
494
455
|
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
495
456
|
} catch (error) {
|
|
496
457
|
this.ctx.logger.error("渲染发言排行图片失败:", error);
|
|
@@ -499,6 +460,44 @@ var Stat = class {
|
|
|
499
460
|
});
|
|
500
461
|
}
|
|
501
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* @private
|
|
465
|
+
* @method parseQueryScope
|
|
466
|
+
* @description 解析命令的选项,将其转换为统一的查询范围对象(userId 和 guildId)。
|
|
467
|
+
* @param {Session} session - 当前会话对象。
|
|
468
|
+
* @param {QueryScopeOptions} options - 命令传入的选项。
|
|
469
|
+
* @returns {QueryScopeResult} 包含 userId、guildId 或 error 信息的查询范围对象。
|
|
470
|
+
*/
|
|
471
|
+
parseQueryScope(session, options) {
|
|
472
|
+
let userId, guildId;
|
|
473
|
+
if (typeof options.user === "string") userId = import_koishi3.h.select(options.user, "user")[0]?.attrs.id;
|
|
474
|
+
else if (options.user) userId = session.userId;
|
|
475
|
+
if (typeof options.guild === "string") guildId = options.guild;
|
|
476
|
+
else if (options.guild) {
|
|
477
|
+
if (!session.guildId) return { error: "请指定群组 ID" };
|
|
478
|
+
guildId = session.guildId;
|
|
479
|
+
}
|
|
480
|
+
if (options.all) return { userId, guildId: void 0 };
|
|
481
|
+
if (!guildId && !userId) return session.guildId ? { guildId: session.guildId } : { error: "请提供查询范围" };
|
|
482
|
+
return { userId, guildId };
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* @private
|
|
486
|
+
* @async
|
|
487
|
+
* @method getUidsInScope
|
|
488
|
+
* @description 根据查询范围(guildId, userId)获取匹配用户的 UID 列表。
|
|
489
|
+
* @param {string} [guildId] - (可选) 群组 ID。
|
|
490
|
+
* @param {string} [userId] - (可选) 用户 ID。
|
|
491
|
+
* @returns {Promise<{ uids?: number[], error?: string }>} 包含 UID 数组或错误信息的对象。
|
|
492
|
+
*/
|
|
493
|
+
async getUidsInScope(guildId, userId) {
|
|
494
|
+
const query = {};
|
|
495
|
+
if (guildId) query.channelId = guildId;
|
|
496
|
+
if (userId) query.userId = userId;
|
|
497
|
+
const users = await this.ctx.database.get("analyse_user", query, ["uid"]);
|
|
498
|
+
if (users.length === 0) return { error: "暂无统计数据" };
|
|
499
|
+
return { uids: users.map((u) => u.uid) };
|
|
500
|
+
}
|
|
502
501
|
/**
|
|
503
502
|
* @private
|
|
504
503
|
* @async
|
|
@@ -513,34 +512,21 @@ var Stat = class {
|
|
|
513
512
|
* @returns {Promise<string>} 生成的标题字符串。
|
|
514
513
|
*/
|
|
515
514
|
async generateTitle(guildId, userId, options) {
|
|
516
|
-
let scopeText;
|
|
515
|
+
let scopeText = "全局";
|
|
517
516
|
if (userId && guildId) {
|
|
518
517
|
const user = await this.ctx.database.get("analyse_user", { channelId: guildId, userId }, ["userName"]);
|
|
519
518
|
const guild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["channelName"]);
|
|
520
|
-
|
|
521
|
-
const guildName = guild[0]?.channelName || guildId;
|
|
522
|
-
scopeText = `${userName} 在 ${guildName}`;
|
|
519
|
+
scopeText = `${user[0]?.userName || userId} 在 ${guild[0]?.channelName || guildId}`;
|
|
523
520
|
} else if (userId) {
|
|
524
521
|
const user = await this.ctx.database.get("analyse_user", { userId }, ["userName"]);
|
|
525
|
-
|
|
526
|
-
scopeText = `${userName}的全局`;
|
|
522
|
+
scopeText = `${user[0]?.userName || userId}的全局`;
|
|
527
523
|
} else if (guildId) {
|
|
528
524
|
const guild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["channelName"]);
|
|
529
525
|
scopeText = guild[0]?.channelName || guildId;
|
|
530
|
-
} else {
|
|
531
|
-
scopeText = "全局";
|
|
532
|
-
}
|
|
533
|
-
switch (options.main) {
|
|
534
|
-
case "命令":
|
|
535
|
-
return `${scopeText}的命令统计`;
|
|
536
|
-
case "消息":
|
|
537
|
-
if (options.subtype) return `${scopeText}的"${options.subtype}"消息统计`;
|
|
538
|
-
return `${scopeText}的消息统计`;
|
|
539
|
-
case "排行":
|
|
540
|
-
return `${scopeText}的${options.timeRange}小时消息排行`;
|
|
541
|
-
default:
|
|
542
|
-
return scopeText;
|
|
543
526
|
}
|
|
527
|
+
if (options.main === "排行") return `${scopeText}的${options.timeRange}小时消息排行`;
|
|
528
|
+
if (options.main === "消息" && options.subtype) return `${scopeText}的"${options.subtype}"消息统计`;
|
|
529
|
+
return `${scopeText}的${options.main}统计`;
|
|
544
530
|
}
|
|
545
531
|
/**
|
|
546
532
|
* @private
|
|
@@ -552,20 +538,13 @@ var Stat = class {
|
|
|
552
538
|
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
553
539
|
*/
|
|
554
540
|
async getCommandStats(guildId, userId) {
|
|
555
|
-
const
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
|
|
563
|
-
lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed")
|
|
564
|
-
}).orderBy("count", "desc").execute();
|
|
565
|
-
if (aggregatedStats.length === 0) return "暂无统计数据";
|
|
566
|
-
const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
|
|
567
|
-
const list = aggregatedStats.map((item) => [item.command, item.count, item.lastUsed]);
|
|
568
|
-
return { list, total: totalCount };
|
|
541
|
+
const { uids, error } = await this.getUidsInScope(guildId, userId);
|
|
542
|
+
if (error) return error;
|
|
543
|
+
const stats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: uids } }).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
|
|
544
|
+
if (stats.length === 0) return "暂无统计数据";
|
|
545
|
+
const total = stats.reduce((sum, record) => sum + record.count, 0);
|
|
546
|
+
const list = stats.map((item) => [item.command, item.count, item.lastUsed]);
|
|
547
|
+
return { list, total };
|
|
569
548
|
}
|
|
570
549
|
/**
|
|
571
550
|
* @private
|
|
@@ -577,20 +556,13 @@ var Stat = class {
|
|
|
577
556
|
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
578
557
|
*/
|
|
579
558
|
async getMessageStats(guildId, userId) {
|
|
580
|
-
const
|
|
581
|
-
if (
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
|
|
588
|
-
lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed")
|
|
589
|
-
}).orderBy("count", "desc").execute();
|
|
590
|
-
if (aggregatedStats.length === 0) return "暂无统计数据";
|
|
591
|
-
const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
|
|
592
|
-
const list = aggregatedStats.map((item) => [item.type, item.count, item.lastUsed]);
|
|
593
|
-
return { list, total: totalCount };
|
|
559
|
+
const { uids, error } = await this.getUidsInScope(guildId, userId);
|
|
560
|
+
if (error) return error;
|
|
561
|
+
const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids } }).groupBy("type", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
|
|
562
|
+
if (stats.length === 0) return "暂无统计数据";
|
|
563
|
+
const total = stats.reduce((sum, record) => sum + record.count, 0);
|
|
564
|
+
const list = stats.map((item) => [item.type, item.count, item.lastUsed]);
|
|
565
|
+
return { list, total };
|
|
594
566
|
}
|
|
595
567
|
/**
|
|
596
568
|
* @private
|
|
@@ -600,60 +572,62 @@ var Stat = class {
|
|
|
600
572
|
* @param {string} type - 要查询的消息类型。
|
|
601
573
|
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
602
574
|
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
603
|
-
* @returns {Promise<{ list: RenderListItem[], total: number } | string>}
|
|
575
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
604
576
|
*/
|
|
605
577
|
async getMessageStatsByType(type, guildId, userId) {
|
|
606
|
-
const
|
|
607
|
-
if (guildId)
|
|
608
|
-
if (userId)
|
|
609
|
-
const users = await this.ctx.database.get("analyse_user",
|
|
610
|
-
if (users.length === 0) return "
|
|
578
|
+
const query = {};
|
|
579
|
+
if (guildId) query.channelId = guildId;
|
|
580
|
+
if (userId) query.userId = userId;
|
|
581
|
+
const users = await this.ctx.database.get("analyse_user", query, ["uid", "userName"]);
|
|
582
|
+
if (users.length === 0) return "暂无统计数据";
|
|
611
583
|
const uids = users.map((u) => u.uid);
|
|
612
584
|
const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed")
|
|
619
|
-
}).orderBy("count", "desc").execute();
|
|
620
|
-
if (aggregatedStats.length === 0) return `暂无统计数据`;
|
|
621
|
-
const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
|
|
622
|
-
const list = aggregatedStats.map((item) => [
|
|
623
|
-
userNameMap.get(item.uid) || `UID ${item.uid}`,
|
|
624
|
-
item.count,
|
|
625
|
-
item.lastUsed
|
|
626
|
-
]);
|
|
627
|
-
return { list, total: totalCount };
|
|
585
|
+
const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids }, type }).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
|
|
586
|
+
if (stats.length === 0) return `暂无统计数据`;
|
|
587
|
+
const total = stats.reduce((sum, record) => sum + record.count, 0);
|
|
588
|
+
const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
|
|
589
|
+
return { list, total };
|
|
628
590
|
}
|
|
629
591
|
/**
|
|
630
592
|
* @private
|
|
631
593
|
* @async
|
|
632
594
|
* @method getActiveUserStats
|
|
633
|
-
* @description
|
|
634
|
-
* @param {string} guildId - 要查询的群组 ID。
|
|
595
|
+
* @description 从数据库中获取并聚合指定时间范围内的活跃用户排行数据。
|
|
635
596
|
* @param {number} hours - 查询过去的小时数。
|
|
636
|
-
* @
|
|
597
|
+
* @param {string} [guildId] - (可选) 要查询的群组 ID。若不提供,则进行全局排行。
|
|
598
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
637
599
|
*/
|
|
638
|
-
async getActiveUserStats(
|
|
600
|
+
async getActiveUserStats(hours, guildId) {
|
|
639
601
|
const since = new Date(Date.now() - hours * 3600 * 1e3);
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
uid: { $in: uids },
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
602
|
+
if (guildId) {
|
|
603
|
+
const usersInGuild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["uid", "userName"]);
|
|
604
|
+
if (usersInGuild.length === 0) return "暂无统计数据";
|
|
605
|
+
const uids = usersInGuild.map((u) => u.uid);
|
|
606
|
+
const userNameMap = new Map(usersInGuild.map((u) => [u.uid, u.userName]));
|
|
607
|
+
const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids }, hour: { $gte: since } }).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").limit(100).execute();
|
|
608
|
+
if (stats.length === 0) return "暂无统计数据";
|
|
609
|
+
const total = stats.reduce((sum, record) => sum + record.count, 0);
|
|
610
|
+
const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count]);
|
|
611
|
+
return { list, total };
|
|
612
|
+
} else {
|
|
613
|
+
const msgStats = await this.ctx.database.select("analyse_msg").where({ hour: { $gte: since } }).project(["uid", "count"]).execute();
|
|
614
|
+
if (msgStats.length === 0) return "暂无统计数据";
|
|
615
|
+
const allUsers = await this.ctx.database.get("analyse_user", {}, ["uid", "userId", "userName"]);
|
|
616
|
+
const uidToUserMap = new Map(allUsers.map((u) => [u.uid, { userId: u.userId, userName: u.userName }]));
|
|
617
|
+
const userCounts = /* @__PURE__ */ new Map();
|
|
618
|
+
for (const msg of msgStats) {
|
|
619
|
+
const userInfo = uidToUserMap.get(msg.uid);
|
|
620
|
+
if (userInfo) {
|
|
621
|
+
const existing = userCounts.get(userInfo.userId);
|
|
622
|
+
userCounts.set(userInfo.userId, { count: (existing?.count || 0) + msg.count, name: userInfo.userName });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (userCounts.size === 0) return "暂无统计数据";
|
|
626
|
+
const grandTotal = Array.from(userCounts.values()).reduce((sum, data) => sum + data.count, 0);
|
|
627
|
+
const sortedUsers = Array.from(userCounts.entries()).sort(([, a], [, b]) => b.count - a.count).slice(0, 100);
|
|
628
|
+
const list = sortedUsers.map(([userId, data]) => [data.name || userId, data.count]);
|
|
629
|
+
return { list, total: grandTotal };
|
|
630
|
+
}
|
|
657
631
|
}
|
|
658
632
|
};
|
|
659
633
|
|