koishi-plugin-chat-analyse 0.2.5 → 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.
- package/lib/Collector.d.ts +63 -38
- package/lib/Renderer.d.ts +24 -15
- package/lib/Stat.d.ts +76 -0
- package/lib/index.d.ts +21 -3
- package/lib/index.js +449 -213
- package/package.json +1 -1
- package/lib/CmdStat.d.ts +0 -38
package/lib/Collector.d.ts
CHANGED
|
@@ -1,71 +1,96 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
|
+
import { Config } from './index';
|
|
2
3
|
declare module 'koishi' {
|
|
3
4
|
interface Tables {
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
analyse_user: {
|
|
6
|
+
uid: number;
|
|
6
7
|
channelId: string;
|
|
7
8
|
userId: string;
|
|
9
|
+
channelName: string;
|
|
10
|
+
userName: string;
|
|
11
|
+
};
|
|
12
|
+
analyse_cmd: {
|
|
13
|
+
uid: number;
|
|
14
|
+
command: string;
|
|
15
|
+
count: number;
|
|
16
|
+
timestamp: Date;
|
|
17
|
+
};
|
|
18
|
+
analyse_msg: {
|
|
19
|
+
uid: number;
|
|
8
20
|
type: string;
|
|
9
|
-
|
|
21
|
+
hour: Date;
|
|
22
|
+
count: number;
|
|
10
23
|
timestamp: Date;
|
|
11
24
|
};
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
analyse_cache: {
|
|
26
|
+
id: number;
|
|
27
|
+
uid: number;
|
|
28
|
+
content: string;
|
|
29
|
+
timestamp: Date;
|
|
17
30
|
};
|
|
18
31
|
}
|
|
19
32
|
}
|
|
20
33
|
/**
|
|
21
34
|
* @class Collector
|
|
22
|
-
* @description
|
|
35
|
+
* @description 核心数据收集器。根据插件配置,高效地监听、收集、缓冲并持久化聊天数据。
|
|
23
36
|
*/
|
|
24
37
|
export declare class Collector {
|
|
25
38
|
private ctx;
|
|
39
|
+
private config;
|
|
40
|
+
/** @const {number} FLUSH_INTERVAL - 内存缓存区自动刷新到数据库的时间间隔。 */
|
|
26
41
|
private static readonly FLUSH_INTERVAL;
|
|
42
|
+
/** @const {number} BUFFER_THRESHOLD - 内存缓存区触发自动刷新的消息数量阈值。 */
|
|
27
43
|
private static readonly BUFFER_THRESHOLD;
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
44
|
+
/** @member {Omit<Tables['analyse_cache'], 'id'>[]} cacheBuffer - 用于暂存原始消息的内存缓冲区,以减少数据库写入频率。 */
|
|
45
|
+
private cacheBuffer;
|
|
46
|
+
/** @member {Map<string, number>} uidCache - 用户 uid 的内存缓存,避免重复查询数据库。*/
|
|
47
|
+
private uidCache;
|
|
48
|
+
/** @member {Map<string, Promise<number>>} pendingUidRequests - 用于处理并发获取 uid 的请求锁。*/
|
|
49
|
+
private pendingUidRequests;
|
|
31
50
|
private flushInterval;
|
|
32
51
|
/**
|
|
33
52
|
* @constructor
|
|
34
|
-
* @param
|
|
35
|
-
|
|
36
|
-
constructor(ctx: Context);
|
|
37
|
-
/**
|
|
38
|
-
* 核心消息处理器,对消息进行格式化并存入缓冲区。
|
|
39
|
-
* @param session {Session} 消息会话对象。
|
|
53
|
+
* @param {Context} ctx - Koishi 的插件上下文。
|
|
54
|
+
* @param {Config} config - 插件的配置对象。
|
|
40
55
|
*/
|
|
41
|
-
|
|
56
|
+
constructor(ctx: Context, config: Config);
|
|
42
57
|
/**
|
|
43
|
-
*
|
|
44
|
-
* @
|
|
45
|
-
* @
|
|
58
|
+
* @private
|
|
59
|
+
* @method defineModels
|
|
60
|
+
* @description 定义插件所需的所有数据表模型。
|
|
46
61
|
*/
|
|
47
|
-
private
|
|
62
|
+
private defineModels;
|
|
48
63
|
/**
|
|
49
|
-
*
|
|
50
|
-
* @
|
|
51
|
-
* @
|
|
64
|
+
* @private
|
|
65
|
+
* @async
|
|
66
|
+
* @method handleMessage
|
|
67
|
+
* @description 统一的消息和命令处理器。它会解析收到的消息,提取关键信息并更新相应的统计数据。
|
|
68
|
+
* @param {Session} session - Koishi 的会话对象,包含消息的全部信息。
|
|
52
69
|
*/
|
|
53
|
-
private
|
|
70
|
+
private handleMessage;
|
|
54
71
|
/**
|
|
55
|
-
*
|
|
72
|
+
* @private
|
|
73
|
+
* @async
|
|
74
|
+
* @method getOrCreateUser
|
|
75
|
+
* @description 高效地获取或创建用户的中央记录 (`analyse_user`)。
|
|
76
|
+
* @param {Session} session - Koishi 会话对象,用于获取用户信息和 Bot 实例。
|
|
77
|
+
* @param {string} channelId - 消息所在的频道或群组 ID。
|
|
78
|
+
* @returns {Promise<number | null>} 返回用户的唯一 `uid`,如果操作失败则返回 `null`。
|
|
56
79
|
*/
|
|
57
|
-
private
|
|
80
|
+
private getOrCreateUser;
|
|
58
81
|
/**
|
|
59
|
-
*
|
|
60
|
-
* @
|
|
61
|
-
* @
|
|
82
|
+
* @private
|
|
83
|
+
* @method sanitizeContent
|
|
84
|
+
* @description 将 Koishi 的消息元素 (Element) 数组净化为纯文本字符串,以便存储和分析。
|
|
85
|
+
* @param {Element[]} elements - 消息元素的数组。
|
|
86
|
+
* @returns {string} 净化后的纯文本字符串。
|
|
62
87
|
*/
|
|
63
|
-
private
|
|
88
|
+
private sanitizeContent;
|
|
64
89
|
/**
|
|
65
|
-
*
|
|
66
|
-
* @
|
|
67
|
-
* @
|
|
68
|
-
* @
|
|
90
|
+
* @private
|
|
91
|
+
* @async
|
|
92
|
+
* @method flushCacheBuffer
|
|
93
|
+
* @description 将内存中的消息缓存 (`cacheBuffer`) 批量写入数据库。
|
|
69
94
|
*/
|
|
70
|
-
private
|
|
95
|
+
private flushCacheBuffer;
|
|
71
96
|
}
|
package/lib/Renderer.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* @
|
|
3
|
+
* @typedef {Array<string | number | Date>} RenderListItem
|
|
4
|
+
* @description 定义了统计列表中单行数据的格式,它是一个由字符串、数字或日期组成的元组。
|
|
5
5
|
*/
|
|
6
6
|
export type RenderListItem = (string | number | Date)[];
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* @interface ListRenderData
|
|
9
|
+
* @description 定义了调用渲染器生成列表图片时所需的完整数据结构。
|
|
9
10
|
*/
|
|
10
11
|
export interface ListRenderData {
|
|
11
12
|
title: string;
|
|
@@ -15,33 +16,41 @@ export interface ListRenderData {
|
|
|
15
16
|
}
|
|
16
17
|
/**
|
|
17
18
|
* @class Renderer
|
|
18
|
-
* @description
|
|
19
|
+
* @description 一个通用的列表渲染器。它使用 Koishi 的 Puppeteer 服务将结构化的 `ListRenderData` 数据
|
|
20
|
+
* 渲染为一张包含精美表格的图片。
|
|
19
21
|
*/
|
|
20
22
|
export declare class Renderer {
|
|
21
23
|
private ctx;
|
|
22
24
|
/**
|
|
23
25
|
* @constructor
|
|
24
|
-
* @param
|
|
26
|
+
* @param {Context} ctx - Koishi 的插件上下文,用于访问 puppeteer 服务。
|
|
25
27
|
*/
|
|
26
28
|
constructor(ctx: Context);
|
|
27
29
|
/**
|
|
28
|
-
*
|
|
29
|
-
* @
|
|
30
|
-
* @
|
|
31
|
-
* @
|
|
30
|
+
* @public
|
|
31
|
+
* @async
|
|
32
|
+
* @method renderList
|
|
33
|
+
* @description 将列表数据渲染为图片。
|
|
34
|
+
* @param {ListRenderData} data - 待渲染的完整列表数据。
|
|
35
|
+
* @param {string[]} [headers] - (可选) 表头文案数组。如果提供,将会在表格顶部渲染表头。
|
|
36
|
+
* @returns {Promise<string | Buffer>} 渲染成功时返回图片的 Buffer 数据;如果输入数据为空,则返回提示文本。
|
|
32
37
|
*/
|
|
33
38
|
renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer>;
|
|
34
39
|
/**
|
|
35
|
-
*
|
|
36
|
-
* @
|
|
40
|
+
* @private
|
|
41
|
+
* @method formatDate
|
|
42
|
+
* @description 智能格式化日期。对于近期的时间,提供更人性化的相对时间描述(如“刚刚”,“x 分钟前”);对于较早的时间,则显示标准的“年-月-日”格式。
|
|
43
|
+
* @param {Date} date - 待格式化的 Date 对象。
|
|
37
44
|
* @returns {string} 格式化后的日期字符串。
|
|
38
45
|
*/
|
|
39
46
|
private formatDate;
|
|
40
47
|
/**
|
|
41
|
-
*
|
|
42
|
-
* @
|
|
43
|
-
* @
|
|
44
|
-
* @
|
|
48
|
+
* @private
|
|
49
|
+
* @method generateListHtml
|
|
50
|
+
* @description 根据传入的结构化数据和表头,动态生成用于 Puppeteer 渲染的完整 HTML 字符串。
|
|
51
|
+
* @param {ListRenderData} data - 列表数据对象。
|
|
52
|
+
* @param {string[]} [headers] - (可选) 表头数组。
|
|
53
|
+
* @returns {string | null} 返回生成的 HTML 字符串。如果列表数据为空,则返回 `null`。
|
|
45
54
|
*/
|
|
46
55
|
private generateListHtml;
|
|
47
56
|
}
|
package/lib/Stat.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Context, Command } from 'koishi';
|
|
2
|
+
import { Renderer } from './Renderer';
|
|
3
|
+
import { Config } from './index';
|
|
4
|
+
/**
|
|
5
|
+
* @class Stat
|
|
6
|
+
* @description 提供统一的统计查询服务。它负责注册查询命令,根据用户输入从数据库中获取数据,并调用渲染器生成统计图表。
|
|
7
|
+
*/
|
|
8
|
+
export declare class Stat {
|
|
9
|
+
private ctx;
|
|
10
|
+
private config;
|
|
11
|
+
renderer: Renderer;
|
|
12
|
+
/**
|
|
13
|
+
* @constructor
|
|
14
|
+
* @param {Context} ctx - Koishi 的插件上下文。
|
|
15
|
+
* @param {Config} config - 插件的配置对象。
|
|
16
|
+
*/
|
|
17
|
+
constructor(ctx: Context, config: Config);
|
|
18
|
+
/**
|
|
19
|
+
* @method registerCommands
|
|
20
|
+
* @description 根据插件配置,动态地将 `.command`, `.message`, `.rank` 子命令注册到主 `analyse` 命令下。
|
|
21
|
+
* @param {Command} analyse - 主 `analyse` 命令实例。
|
|
22
|
+
*/
|
|
23
|
+
registerCommands(analyse: Command): void;
|
|
24
|
+
/**
|
|
25
|
+
* @private
|
|
26
|
+
* @async
|
|
27
|
+
* @method generateTitle
|
|
28
|
+
* @description 通用的标题生成器。根据查询参数和类型选项动态生成易于理解的图片标题。
|
|
29
|
+
* @param {string} [guildId] - (可选) 查询的群组 ID。
|
|
30
|
+
* @param {string} [userId] - (可选) 查询的用户 ID。
|
|
31
|
+
* @param {TitleOptions} options - 标题的配置选项。
|
|
32
|
+
* @returns {Promise<string>} 生成的标题字符串。
|
|
33
|
+
*/
|
|
34
|
+
private generateTitle;
|
|
35
|
+
/**
|
|
36
|
+
* @private
|
|
37
|
+
* @async
|
|
38
|
+
* @method getCommandStats
|
|
39
|
+
* @description 从数据库中获取并聚合命令使用统计数据。
|
|
40
|
+
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
41
|
+
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
42
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
43
|
+
*/
|
|
44
|
+
private getCommandStats;
|
|
45
|
+
/**
|
|
46
|
+
* @private
|
|
47
|
+
* @async
|
|
48
|
+
* @method getMessageStats
|
|
49
|
+
* @description 从数据库中获取并聚合所有消息类型的统计数据。
|
|
50
|
+
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
51
|
+
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
52
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
53
|
+
*/
|
|
54
|
+
private getMessageStats;
|
|
55
|
+
/**
|
|
56
|
+
* @private
|
|
57
|
+
* @async
|
|
58
|
+
* @method getMessageStatsByType
|
|
59
|
+
* @description 按指定消息类型,从数据库中获取并聚合用户排行数据。
|
|
60
|
+
* @param {string} type - 要查询的消息类型。
|
|
61
|
+
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
62
|
+
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
63
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>}
|
|
64
|
+
*/
|
|
65
|
+
private getMessageStatsByType;
|
|
66
|
+
/**
|
|
67
|
+
* @private
|
|
68
|
+
* @async
|
|
69
|
+
* @method getActiveUserStats
|
|
70
|
+
* @description 从数据库中获取并聚合活跃用户排行数据。
|
|
71
|
+
* @param {string} guildId - 要查询的群组 ID。
|
|
72
|
+
* @param {number} hours - 查询过去的小时数。
|
|
73
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>}
|
|
74
|
+
*/
|
|
75
|
+
private getActiveUserStats;
|
|
76
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import { Context, Schema } from 'koishi';
|
|
2
|
+
/**
|
|
3
|
+
* @name 插件使用说明
|
|
4
|
+
* @description 在 Koishi 控制台中显示的插件介绍和帮助信息。
|
|
5
|
+
*/
|
|
2
6
|
export declare const usage = "\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #4a6ee0;\">\uD83D\uDCCC \u63D2\u4EF6\u8BF4\u660E</h2>\n <p>\uD83D\uDCD6 <strong>\u4F7F\u7528\u6587\u6863</strong>\uFF1A\u8BF7\u70B9\u51FB\u5DE6\u4E0A\u89D2\u7684 <strong>\u63D2\u4EF6\u4E3B\u9875</strong> \u67E5\u770B\u63D2\u4EF6\u4F7F\u7528\u6587\u6863</p>\n <p>\uD83D\uDD0D <strong>\u66F4\u591A\u63D2\u4EF6</strong>\uFF1A\u53EF\u8BBF\u95EE <a href=\"https://github.com/YisRime\" style=\"color:#4a6ee0;text-decoration:none;\">\u82E1\u6DDE\u7684 GitHub</a> \u67E5\u770B\u672C\u4EBA\u7684\u6240\u6709\u63D2\u4EF6</p>\n</div>\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #e0574a;\">\u2764\uFE0F \u652F\u6301\u4E0E\u53CD\u9988</h2>\n <p>\uD83C\uDF1F \u559C\u6B22\u8FD9\u4E2A\u63D2\u4EF6\uFF1F\u8BF7\u5728 <a href=\"https://github.com/YisRime\" style=\"color:#e0574a;text-decoration:none;\">GitHub</a> \u4E0A\u7ED9\u6211\u4E00\u4E2A Star\uFF01</p>\n <p>\uD83D\uDC1B \u9047\u5230\u95EE\u9898\uFF1F\u8BF7\u901A\u8FC7 <strong>Issues</strong> \u63D0\u4EA4\u53CD\u9988\uFF0C\u6216\u52A0\u5165 QQ \u7FA4 <a href=\"https://qm.qq.com/q/PdLMx9Jowq\" style=\"color:#e0574a;text-decoration:none;\"><strong>855571375</strong></a> \u8FDB\u884C\u4EA4\u6D41</p>\n</div>\n";
|
|
3
7
|
export declare const name = "chat-analyse";
|
|
4
8
|
export declare const using: string[];
|
|
9
|
+
/**
|
|
10
|
+
* @interface Config
|
|
11
|
+
* @description 定义插件的配置项结构。
|
|
12
|
+
*/
|
|
5
13
|
export interface Config {
|
|
14
|
+
enableListener: boolean;
|
|
15
|
+
enableCmdStat: boolean;
|
|
16
|
+
enableMsgStat: boolean;
|
|
17
|
+
enableAdvanced: boolean;
|
|
6
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* @const {Schema<Config>} Config
|
|
21
|
+
* @description 使用 Koishi 的 `Schema` 来定义配置项的类型、默认值和在控制台中的交互界面。
|
|
22
|
+
*/
|
|
7
23
|
export declare const Config: Schema<Config>;
|
|
8
24
|
/**
|
|
9
|
-
*
|
|
10
|
-
* @
|
|
25
|
+
* @function apply
|
|
26
|
+
* @description Koishi 插件的主入口函数。
|
|
27
|
+
* @param {Context} ctx - Koishi 的插件上下文,提供了访问核心 API 的能力。
|
|
28
|
+
* @param {Config} config - 用户在 `koishi.config.js` 或控制台中配置的对象。
|
|
11
29
|
*/
|
|
12
|
-
export declare function apply(ctx: Context): void;
|
|
30
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
CHANGED
|
@@ -27,171 +27,214 @@ __export(src_exports, {
|
|
|
27
27
|
using: () => using
|
|
28
28
|
});
|
|
29
29
|
module.exports = __toCommonJS(src_exports);
|
|
30
|
-
var
|
|
30
|
+
var import_koishi4 = require("koishi");
|
|
31
31
|
|
|
32
32
|
// src/Collector.ts
|
|
33
|
+
var import_koishi = require("koishi");
|
|
33
34
|
var Collector = class _Collector {
|
|
34
35
|
/**
|
|
35
36
|
* @constructor
|
|
36
|
-
* @param
|
|
37
|
+
* @param {Context} ctx - Koishi 的插件上下文。
|
|
38
|
+
* @param {Config} config - 插件的配置对象。
|
|
37
39
|
*/
|
|
38
|
-
constructor(ctx) {
|
|
40
|
+
constructor(ctx, config) {
|
|
39
41
|
this.ctx = ctx;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
channelId: "string",
|
|
43
|
-
userId: "string",
|
|
44
|
-
type: "string",
|
|
45
|
-
content: "text",
|
|
46
|
-
timestamp: "timestamp"
|
|
47
|
-
}, { primary: "id", autoInc: true, indexes: ["timestamp", "channelId", "userId", "type"] });
|
|
48
|
-
ctx.model.extend("analyse_name", {
|
|
49
|
-
channelId: "string",
|
|
50
|
-
channelName: "string",
|
|
51
|
-
userId: "string",
|
|
52
|
-
userName: "string"
|
|
53
|
-
}, { primary: ["channelId", "userId"] });
|
|
42
|
+
this.config = config;
|
|
43
|
+
this.defineModels();
|
|
54
44
|
ctx.on("message", (session) => this.handleMessage(session));
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
45
|
+
if (this.config.enableAdvanced) {
|
|
46
|
+
this.flushInterval = setInterval(() => this.flushCacheBuffer(), _Collector.FLUSH_INTERVAL);
|
|
47
|
+
ctx.on("dispose", () => {
|
|
48
|
+
clearInterval(this.flushInterval);
|
|
49
|
+
this.flushCacheBuffer();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
60
52
|
}
|
|
61
53
|
static {
|
|
62
54
|
__name(this, "Collector");
|
|
63
55
|
}
|
|
64
|
-
|
|
56
|
+
/** @const {number} FLUSH_INTERVAL - 内存缓存区自动刷新到数据库的时间间隔。 */
|
|
65
57
|
static FLUSH_INTERVAL = 60 * 1e3;
|
|
66
|
-
|
|
58
|
+
/** @const {number} BUFFER_THRESHOLD - 内存缓存区触发自动刷新的消息数量阈值。 */
|
|
67
59
|
static BUFFER_THRESHOLD = 100;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
60
|
+
/** @member {Omit<Tables['analyse_cache'], 'id'>[]} cacheBuffer - 用于暂存原始消息的内存缓冲区,以减少数据库写入频率。 */
|
|
61
|
+
cacheBuffer = [];
|
|
62
|
+
/** @member {Map<string, number>} uidCache - 用户 uid 的内存缓存,避免重复查询数据库。*/
|
|
63
|
+
uidCache = /* @__PURE__ */ new Map();
|
|
64
|
+
/** @member {Map<string, Promise<number>>} pendingUidRequests - 用于处理并发获取 uid 的请求锁。*/
|
|
65
|
+
pendingUidRequests = /* @__PURE__ */ new Map();
|
|
73
66
|
flushInterval;
|
|
74
67
|
/**
|
|
75
|
-
*
|
|
76
|
-
* @
|
|
68
|
+
* @private
|
|
69
|
+
* @method defineModels
|
|
70
|
+
* @description 定义插件所需的所有数据表模型。
|
|
77
71
|
*/
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
this.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
72
|
+
defineModels() {
|
|
73
|
+
this.ctx.model.extend("analyse_user", {
|
|
74
|
+
uid: "unsigned",
|
|
75
|
+
channelId: "string",
|
|
76
|
+
userId: "string",
|
|
77
|
+
channelName: "string",
|
|
78
|
+
userName: "string"
|
|
79
|
+
}, { primary: "uid", autoInc: true, indexes: ["channelId", "userId"] });
|
|
80
|
+
this.ctx.model.extend("analyse_cmd", {
|
|
81
|
+
uid: "unsigned",
|
|
82
|
+
command: "string",
|
|
83
|
+
count: "unsigned",
|
|
84
|
+
timestamp: "timestamp"
|
|
85
|
+
}, { primary: ["uid", "command"] });
|
|
86
|
+
this.ctx.model.extend("analyse_msg", {
|
|
87
|
+
uid: "unsigned",
|
|
88
|
+
type: "string",
|
|
89
|
+
hour: "timestamp",
|
|
90
|
+
count: "unsigned",
|
|
91
|
+
timestamp: "timestamp"
|
|
92
|
+
}, { primary: ["uid", "type", "hour"] });
|
|
93
|
+
if (this.config.enableAdvanced) {
|
|
94
|
+
this.ctx.model.extend("analyse_cache", {
|
|
95
|
+
id: "unsigned",
|
|
96
|
+
uid: "unsigned",
|
|
97
|
+
content: "text",
|
|
98
|
+
timestamp: "timestamp"
|
|
99
|
+
}, { primary: "id", autoInc: true, indexes: ["uid", "timestamp"] });
|
|
100
|
+
}
|
|
94
101
|
}
|
|
95
102
|
/**
|
|
96
|
-
*
|
|
97
|
-
* @
|
|
98
|
-
* @
|
|
103
|
+
* @private
|
|
104
|
+
* @async
|
|
105
|
+
* @method handleMessage
|
|
106
|
+
* @description 统一的消息和命令处理器。它会解析收到的消息,提取关键信息并更新相应的统计数据。
|
|
107
|
+
* @param {Session} session - Koishi 的会话对象,包含消息的全部信息。
|
|
99
108
|
*/
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
async handleMessage(session) {
|
|
110
|
+
try {
|
|
111
|
+
const { userId, channelId, guildId, content, timestamp, argv, elements } = session;
|
|
112
|
+
const effectiveId = channelId || guildId;
|
|
113
|
+
if (!effectiveId || !userId || !timestamp || !content?.trim()) return;
|
|
114
|
+
const uid = await this.getOrCreateUser(session, effectiveId);
|
|
115
|
+
if (!uid) return;
|
|
116
|
+
if (argv?.command) {
|
|
117
|
+
await this.ctx.database.upsert("analyse_cmd", (row) => [{
|
|
118
|
+
uid,
|
|
119
|
+
command: argv.command.name,
|
|
120
|
+
count: import_koishi.$.add(import_koishi.$.ifNull(row.count, import_koishi.$.literal(0)), 1),
|
|
121
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
122
|
+
}]);
|
|
123
|
+
}
|
|
124
|
+
const messageTime = new Date(timestamp);
|
|
125
|
+
const hourStart = new Date(messageTime.getFullYear(), messageTime.getMonth(), messageTime.getDate(), messageTime.getHours());
|
|
126
|
+
const uniqueElementTypes = new Set(elements.map((e) => e.type));
|
|
127
|
+
for (const type of uniqueElementTypes) {
|
|
128
|
+
await this.ctx.database.upsert("analyse_msg", (row) => [{
|
|
129
|
+
uid,
|
|
130
|
+
type,
|
|
131
|
+
hour: hourStart,
|
|
132
|
+
count: import_koishi.$.add(import_koishi.$.ifNull(row.count, 0), 1),
|
|
133
|
+
timestamp: messageTime
|
|
134
|
+
}]);
|
|
135
|
+
}
|
|
136
|
+
if (this.config.enableAdvanced) {
|
|
137
|
+
this.cacheBuffer.push({
|
|
138
|
+
uid,
|
|
139
|
+
content: this.sanitizeContent(elements),
|
|
140
|
+
timestamp: new Date(timestamp)
|
|
141
|
+
});
|
|
142
|
+
if (this.cacheBuffer.length >= _Collector.BUFFER_THRESHOLD) await this.flushCacheBuffer();
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.ctx.logger.warn("消息处理出错:", error);
|
|
146
|
+
}
|
|
103
147
|
}
|
|
104
148
|
/**
|
|
105
|
-
*
|
|
106
|
-
* @
|
|
107
|
-
* @
|
|
149
|
+
* @private
|
|
150
|
+
* @async
|
|
151
|
+
* @method getOrCreateUser
|
|
152
|
+
* @description 高效地获取或创建用户的中央记录 (`analyse_user`)。
|
|
153
|
+
* @param {Session} session - Koishi 会话对象,用于获取用户信息和 Bot 实例。
|
|
154
|
+
* @param {string} channelId - 消息所在的频道或群组 ID。
|
|
155
|
+
* @returns {Promise<number | null>} 返回用户的唯一 `uid`,如果操作失败则返回 `null`。
|
|
108
156
|
*/
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return
|
|
157
|
+
async getOrCreateUser(session, channelId) {
|
|
158
|
+
const { userId, bot, guildId } = session;
|
|
159
|
+
const cacheKey = `${channelId}:${userId}`;
|
|
160
|
+
if (this.uidCache.has(cacheKey)) return this.uidCache.get(cacheKey);
|
|
161
|
+
if (this.pendingUidRequests.has(cacheKey)) return this.pendingUidRequests.get(cacheKey);
|
|
162
|
+
const promise = (async () => {
|
|
163
|
+
try {
|
|
164
|
+
const existing = await this.ctx.database.get("analyse_user", { channelId, userId }, ["uid"]);
|
|
165
|
+
if (existing.length > 0) {
|
|
166
|
+
this.uidCache.set(cacheKey, existing[0].uid);
|
|
167
|
+
return existing[0].uid;
|
|
168
|
+
}
|
|
169
|
+
const [guild, member] = await Promise.all([
|
|
170
|
+
guildId ? bot.getGuild(guildId).catch(() => null) : Promise.resolve(null),
|
|
171
|
+
guildId ? bot.getGuildMember(guildId, userId).catch(() => null) : Promise.resolve(null)
|
|
172
|
+
]);
|
|
173
|
+
const user = !member ? await bot.getUser(userId).catch(() => null) : null;
|
|
174
|
+
const newUser = await this.ctx.database.create("analyse_user", {
|
|
175
|
+
channelId,
|
|
176
|
+
userId,
|
|
177
|
+
channelName: guild?.name || channelId,
|
|
178
|
+
userName: member?.nick || member?.name || user?.name || userId
|
|
179
|
+
});
|
|
180
|
+
this.uidCache.set(cacheKey, newUser.uid);
|
|
181
|
+
return newUser.uid;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
this.ctx.logger.error(`创建或获取用户(${cacheKey}) UID 失败:`, error);
|
|
184
|
+
return null;
|
|
185
|
+
} finally {
|
|
186
|
+
this.pendingUidRequests.delete(cacheKey);
|
|
120
187
|
}
|
|
121
|
-
})
|
|
188
|
+
})();
|
|
189
|
+
this.pendingUidRequests.set(cacheKey, promise);
|
|
190
|
+
return promise;
|
|
122
191
|
}
|
|
123
192
|
/**
|
|
124
|
-
*
|
|
193
|
+
* @private
|
|
194
|
+
* @method sanitizeContent
|
|
195
|
+
* @description 将 Koishi 的消息元素 (Element) 数组净化为纯文本字符串,以便存储和分析。
|
|
196
|
+
* @param {Element[]} elements - 消息元素的数组。
|
|
197
|
+
* @returns {string} 净化后的纯文本字符串。
|
|
125
198
|
*/
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
199
|
+
sanitizeContent = /* @__PURE__ */ __name((elements) => elements.map((e) => {
|
|
200
|
+
switch (e.type) {
|
|
201
|
+
case "text":
|
|
202
|
+
return e.attrs.content;
|
|
203
|
+
case "img":
|
|
204
|
+
return e.attrs.summary === "[动画表情]" ? "[gif]" : "[img]";
|
|
205
|
+
case "at":
|
|
206
|
+
return `[at:${e.attrs.id}]`;
|
|
207
|
+
default:
|
|
208
|
+
return `[${e.type}]`;
|
|
135
209
|
}
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* 检查用户和群组名称是否需要更新,利用缓存和请求锁机制避免重复调用。
|
|
139
|
-
* @param session {Session} 消息会话对象。
|
|
140
|
-
* @param effectiveId {string} 有效的频道/群组ID。
|
|
141
|
-
*/
|
|
142
|
-
async updateNameIfNeeded(session, effectiveId) {
|
|
143
|
-
const { userId } = session;
|
|
144
|
-
if (!userId) return;
|
|
145
|
-
const cacheKey = `${effectiveId}:${userId}`;
|
|
146
|
-
const CACHE_EXPIRATION = 24 * 60 * 60 * 1e3;
|
|
147
|
-
if (this.pendingNameRequests.has(cacheKey)) return this.pendingNameRequests.get(cacheKey);
|
|
148
|
-
const cached = this.nameCache.get(cacheKey);
|
|
149
|
-
if (cached && Date.now() - cached.timestamp < CACHE_EXPIRATION) return;
|
|
150
|
-
const promise = this.fetchAndUpdateNames(session, effectiveId, cacheKey);
|
|
151
|
-
this.pendingNameRequests.set(cacheKey, promise);
|
|
152
|
-
promise.finally(() => this.pendingNameRequests.delete(cacheKey));
|
|
153
|
-
}
|
|
210
|
+
}).join(""), "sanitizeContent");
|
|
154
211
|
/**
|
|
155
|
-
*
|
|
156
|
-
* @
|
|
157
|
-
* @
|
|
158
|
-
* @
|
|
212
|
+
* @private
|
|
213
|
+
* @async
|
|
214
|
+
* @method flushCacheBuffer
|
|
215
|
+
* @description 将内存中的消息缓存 (`cacheBuffer`) 批量写入数据库。
|
|
159
216
|
*/
|
|
160
|
-
async
|
|
217
|
+
async flushCacheBuffer() {
|
|
218
|
+
if (this.cacheBuffer.length === 0) return;
|
|
219
|
+
const bufferToFlush = this.cacheBuffer;
|
|
220
|
+
this.cacheBuffer = [];
|
|
161
221
|
try {
|
|
162
|
-
|
|
163
|
-
const [guild, member] = await Promise.all([
|
|
164
|
-
guildId ? bot.getGuild(guildId).catch(() => null) : Promise.resolve(null),
|
|
165
|
-
guildId && userId ? bot.getGuildMember(guildId, userId).catch(() => null) : Promise.resolve(null)
|
|
166
|
-
]);
|
|
167
|
-
const channelName = guild?.name;
|
|
168
|
-
const userName = member?.nick || member?.name;
|
|
169
|
-
if (!channelName || !userName) {
|
|
170
|
-
this.nameCache.set(cacheKey, { name: null, timestamp: Date.now() });
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
await this.ctx.database.upsert("analyse_name", [{
|
|
174
|
-
channelId: effectiveId,
|
|
175
|
-
userId,
|
|
176
|
-
channelName,
|
|
177
|
-
userName
|
|
178
|
-
}]);
|
|
179
|
-
this.nameCache.set(cacheKey, { name: userName, timestamp: Date.now() });
|
|
222
|
+
await this.ctx.database.upsert("analyse_cache", bufferToFlush);
|
|
180
223
|
} catch (error) {
|
|
181
|
-
this.
|
|
224
|
+
this.ctx.logger.error("写入缓存出错:", error);
|
|
182
225
|
}
|
|
183
226
|
}
|
|
184
227
|
};
|
|
185
228
|
|
|
186
|
-
// src/
|
|
187
|
-
var
|
|
229
|
+
// src/Stat.ts
|
|
230
|
+
var import_koishi3 = require("koishi");
|
|
188
231
|
|
|
189
232
|
// src/Renderer.ts
|
|
190
|
-
var
|
|
233
|
+
var import_koishi2 = require("koishi");
|
|
191
234
|
var Renderer = class {
|
|
192
235
|
/**
|
|
193
236
|
* @constructor
|
|
194
|
-
* @param
|
|
237
|
+
* @param {Context} ctx - Koishi 的插件上下文,用于访问 puppeteer 服务。
|
|
195
238
|
*/
|
|
196
239
|
constructor(ctx) {
|
|
197
240
|
this.ctx = ctx;
|
|
@@ -200,10 +243,13 @@ var Renderer = class {
|
|
|
200
243
|
__name(this, "Renderer");
|
|
201
244
|
}
|
|
202
245
|
/**
|
|
203
|
-
*
|
|
204
|
-
* @
|
|
205
|
-
* @
|
|
206
|
-
* @
|
|
246
|
+
* @public
|
|
247
|
+
* @async
|
|
248
|
+
* @method renderList
|
|
249
|
+
* @description 将列表数据渲染为图片。
|
|
250
|
+
* @param {ListRenderData} data - 待渲染的完整列表数据。
|
|
251
|
+
* @param {string[]} [headers] - (可选) 表头文案数组。如果提供,将会在表格顶部渲染表头。
|
|
252
|
+
* @returns {Promise<string | Buffer>} 渲染成功时返回图片的 Buffer 数据;如果输入数据为空,则返回提示文本。
|
|
207
253
|
*/
|
|
208
254
|
async renderList(data, headers) {
|
|
209
255
|
const htmlContent = this.generateListHtml(data, headers);
|
|
@@ -211,23 +257,27 @@ var Renderer = class {
|
|
|
211
257
|
return this.ctx.puppeteer.render(htmlContent);
|
|
212
258
|
}
|
|
213
259
|
/**
|
|
214
|
-
*
|
|
215
|
-
* @
|
|
260
|
+
* @private
|
|
261
|
+
* @method formatDate
|
|
262
|
+
* @description 智能格式化日期。对于近期的时间,提供更人性化的相对时间描述(如“刚刚”,“x 分钟前”);对于较早的时间,则显示标准的“年-月-日”格式。
|
|
263
|
+
* @param {Date} date - 待格式化的 Date 对象。
|
|
216
264
|
* @returns {string} 格式化后的日期字符串。
|
|
217
265
|
*/
|
|
218
266
|
formatDate(date) {
|
|
219
267
|
if (!date) return "未知";
|
|
220
268
|
const diff = Date.now() - date.getTime();
|
|
221
|
-
if (diff <
|
|
222
|
-
if (diff <
|
|
223
|
-
if (diff <
|
|
269
|
+
if (diff < import_koishi2.Time.minute) return "刚刚";
|
|
270
|
+
if (diff < import_koishi2.Time.hour) return `${Math.floor(diff / import_koishi2.Time.minute)} 分钟前`;
|
|
271
|
+
if (diff < import_koishi2.Time.day) return `${Math.floor(diff / import_koishi2.Time.hour)} 小时前`;
|
|
224
272
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
225
273
|
}
|
|
226
274
|
/**
|
|
227
|
-
*
|
|
228
|
-
* @
|
|
229
|
-
* @
|
|
230
|
-
* @
|
|
275
|
+
* @private
|
|
276
|
+
* @method generateListHtml
|
|
277
|
+
* @description 根据传入的结构化数据和表头,动态生成用于 Puppeteer 渲染的完整 HTML 字符串。
|
|
278
|
+
* @param {ListRenderData} data - 列表数据对象。
|
|
279
|
+
* @param {string[]} [headers] - (可选) 表头数组。
|
|
280
|
+
* @returns {string | null} 返回生成的 HTML 字符串。如果列表数据为空,则返回 `null`。
|
|
231
281
|
*/
|
|
232
282
|
generateListHtml(data, headers) {
|
|
233
283
|
const { title, time, total, list } = data;
|
|
@@ -292,102 +342,280 @@ var Renderer = class {
|
|
|
292
342
|
}
|
|
293
343
|
};
|
|
294
344
|
|
|
295
|
-
// src/
|
|
296
|
-
var
|
|
297
|
-
|
|
345
|
+
// src/Stat.ts
|
|
346
|
+
var Stat = class {
|
|
347
|
+
/**
|
|
348
|
+
* @constructor
|
|
349
|
+
* @param {Context} ctx - Koishi 的插件上下文。
|
|
350
|
+
* @param {Config} config - 插件的配置对象。
|
|
351
|
+
*/
|
|
352
|
+
constructor(ctx, config) {
|
|
298
353
|
this.ctx = ctx;
|
|
354
|
+
this.config = config;
|
|
299
355
|
this.renderer = new Renderer(ctx);
|
|
300
|
-
this.ctx.model.extend("analyse_cmd", {
|
|
301
|
-
channelId: "string",
|
|
302
|
-
userId: "string",
|
|
303
|
-
command: "string",
|
|
304
|
-
count: "unsigned",
|
|
305
|
-
timestamp: "timestamp"
|
|
306
|
-
}, { primary: ["channelId", "userId", "command"] });
|
|
307
|
-
this.ctx.on("command/before-execute", async ({ command, session }) => {
|
|
308
|
-
const { userId, guildId } = session;
|
|
309
|
-
if (!guildId || !userId) return;
|
|
310
|
-
const query = { channelId: guildId, userId, command: command.name };
|
|
311
|
-
await this.ctx.database.upsert("analyse_cmd", (row) => [{
|
|
312
|
-
...query,
|
|
313
|
-
count: import_koishi2.$.add(import_koishi2.$.ifNull(row.count, 0), 1),
|
|
314
|
-
timestamp: /* @__PURE__ */ new Date()
|
|
315
|
-
}]);
|
|
316
|
-
});
|
|
317
356
|
}
|
|
318
357
|
static {
|
|
319
|
-
__name(this, "
|
|
358
|
+
__name(this, "Stat");
|
|
320
359
|
}
|
|
321
360
|
renderer;
|
|
322
361
|
/**
|
|
323
|
-
*
|
|
324
|
-
* @
|
|
362
|
+
* @method registerCommands
|
|
363
|
+
* @description 根据插件配置,动态地将 `.command`, `.message`, `.rank` 子命令注册到主 `analyse` 命令下。
|
|
364
|
+
* @param {Command} analyse - 主 `analyse` 命令实例。
|
|
325
365
|
*/
|
|
326
366
|
registerCommands(analyse) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (!session.guildId)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
title,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
367
|
+
if (this.config.enableCmdStat) {
|
|
368
|
+
analyse.subcommand(".command", "命令使用统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").usage("查询用户或群组的命令使用统计,默认展示全局统计。").action(async ({ session, options }) => {
|
|
369
|
+
const userId = options.user ? import_koishi3.h.select(options.user, "user")[0]?.attrs.id : void 0;
|
|
370
|
+
let guildId = options.guild;
|
|
371
|
+
if (!userId && !guildId && session.guildId) {
|
|
372
|
+
guildId = session.guildId;
|
|
373
|
+
} else if (!userId && !guildId && !session.guildId) {
|
|
374
|
+
return "请指定查询范围";
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const stats = await this.getCommandStats(guildId, userId);
|
|
378
|
+
if (typeof stats === "string") return stats;
|
|
379
|
+
const title = await this.generateTitle(guildId, userId, { main: "命令" });
|
|
380
|
+
const renderData = {
|
|
381
|
+
title,
|
|
382
|
+
time: /* @__PURE__ */ new Date(),
|
|
383
|
+
total: stats.total,
|
|
384
|
+
list: stats.list
|
|
385
|
+
};
|
|
386
|
+
const headers = ["命令", "次数", "最后使用"];
|
|
387
|
+
const result = await this.renderer.renderList(renderData, headers);
|
|
388
|
+
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
389
|
+
} catch (error) {
|
|
390
|
+
this.ctx.logger.error("渲染命令统计图片失败:", error);
|
|
391
|
+
return "渲染命令统计图片失败";
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
if (this.config.enableMsgStat) {
|
|
396
|
+
analyse.subcommand(".message", "消息发送统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").option("type", "-t <type:string> 指定类型").usage("查询用户或群组的消息发送统计,默认展示全局统计。").action(async ({ session, options }) => {
|
|
397
|
+
const userId = options.user ? import_koishi3.h.select(options.user, "user")[0]?.attrs.id : void 0;
|
|
398
|
+
let guildId = options.guild;
|
|
399
|
+
if (!userId && !guildId && !options.type && session.guildId) {
|
|
400
|
+
guildId = session.guildId;
|
|
401
|
+
} else if (!userId && !guildId && !session.guildId) {
|
|
402
|
+
return "请指定查询范围";
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
if (options.type) {
|
|
406
|
+
const stats = await this.getMessageStatsByType(options.type, guildId, userId);
|
|
407
|
+
if (typeof stats === "string") return stats;
|
|
408
|
+
const title = await this.generateTitle(guildId, void 0, { main: "消息", subtype: options.type });
|
|
409
|
+
const renderData = {
|
|
410
|
+
title,
|
|
411
|
+
time: /* @__PURE__ */ new Date(),
|
|
412
|
+
total: stats.total,
|
|
413
|
+
list: stats.list
|
|
414
|
+
};
|
|
415
|
+
const headers = ["用户", "条数", "最后发言"];
|
|
416
|
+
const result = await this.renderer.renderList(renderData, headers);
|
|
417
|
+
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
418
|
+
} else {
|
|
419
|
+
const stats = await this.getMessageStats(guildId, userId);
|
|
420
|
+
if (typeof stats === "string") return stats;
|
|
421
|
+
const title = await this.generateTitle(guildId, userId, { main: "消息" });
|
|
422
|
+
const renderData = {
|
|
423
|
+
title,
|
|
424
|
+
time: /* @__PURE__ */ new Date(),
|
|
425
|
+
total: stats.total,
|
|
426
|
+
list: stats.list
|
|
427
|
+
};
|
|
428
|
+
const headers = ["类型", "条数", "最后发言"];
|
|
429
|
+
const result = await this.renderer.renderList(renderData, headers);
|
|
430
|
+
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
431
|
+
}
|
|
432
|
+
} catch (error) {
|
|
433
|
+
this.ctx.logger.error("渲染消息统计图片失败:", error);
|
|
434
|
+
return "渲染消息统计图片失败";
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
analyse.subcommand(".rank", "用户发言排行").option("guild", "-g [guildId:string] 指定群组").option("hours", "-h <hours:number> 查看过去 N 小时的排行", { fallback: 24 }).usage("查询用户或群组的用户发言排行,默认展示全局统计。").action(async ({ session, options }) => {
|
|
438
|
+
let guildId = options.guild;
|
|
439
|
+
if (!guildId && session.guildId) guildId = session.guildId;
|
|
440
|
+
if (!guildId) return "请指定查询范围";
|
|
441
|
+
try {
|
|
442
|
+
const stats = await this.getActiveUserStats(guildId, options.hours);
|
|
443
|
+
if (typeof stats === "string") return stats;
|
|
444
|
+
const listWithPercentage = stats.list.map((row) => {
|
|
445
|
+
const count = row[1];
|
|
446
|
+
const percentage = stats.total > 0 ? `${(count / stats.total * 100).toFixed(2)}%` : "0.00%";
|
|
447
|
+
return [...row, percentage];
|
|
448
|
+
});
|
|
449
|
+
const title = await this.generateTitle(guildId, void 0, { main: "排行", timeRange: options.hours });
|
|
450
|
+
const renderData = {
|
|
451
|
+
title,
|
|
452
|
+
time: /* @__PURE__ */ new Date(),
|
|
453
|
+
total: stats.total,
|
|
454
|
+
list: listWithPercentage
|
|
455
|
+
};
|
|
456
|
+
const headers = ["用户", "总计发言", "占比"];
|
|
457
|
+
const result = await this.renderer.renderList(renderData, headers);
|
|
458
|
+
return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
|
|
459
|
+
} catch (error) {
|
|
460
|
+
this.ctx.logger.error("渲染发言排行图片失败:", error);
|
|
461
|
+
return "渲染发言排行图片失败";
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
}
|
|
352
465
|
}
|
|
353
466
|
/**
|
|
354
|
-
*
|
|
467
|
+
* @private
|
|
468
|
+
* @async
|
|
469
|
+
* @method generateTitle
|
|
470
|
+
* @description 通用的标题生成器。根据查询参数和类型选项动态生成易于理解的图片标题。
|
|
471
|
+
* @param {string} [guildId] - (可选) 查询的群组 ID。
|
|
472
|
+
* @param {string} [userId] - (可选) 查询的用户 ID。
|
|
473
|
+
* @param {TitleOptions} options - 标题的配置选项。
|
|
474
|
+
* @returns {Promise<string>} 生成的标题字符串。
|
|
355
475
|
*/
|
|
356
|
-
async generateTitle(
|
|
476
|
+
async generateTitle(guildId, userId, options) {
|
|
477
|
+
let scopeText;
|
|
357
478
|
if (userId && guildId) {
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
-
|
|
479
|
+
const user = await this.ctx.database.get("analyse_user", { channelId: guildId, userId }, ["userName"]);
|
|
480
|
+
const guild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["channelName"]);
|
|
481
|
+
const userName = user[0]?.userName || userId;
|
|
482
|
+
const guildName = guild[0]?.channelName || guildId;
|
|
483
|
+
scopeText = `${userName} 在 ${guildName}`;
|
|
484
|
+
} else if (userId) {
|
|
485
|
+
const user = await this.ctx.database.get("analyse_user", { userId }, ["userName"]);
|
|
486
|
+
const userName = user[0]?.userName || userId;
|
|
487
|
+
scopeText = `${userName}的全局`;
|
|
488
|
+
} else if (guildId) {
|
|
489
|
+
const guild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["channelName"]);
|
|
490
|
+
scopeText = guild[0]?.channelName || guildId;
|
|
491
|
+
} else {
|
|
492
|
+
scopeText = "全局";
|
|
361
493
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
494
|
+
switch (options.main) {
|
|
495
|
+
case "命令":
|
|
496
|
+
return `${scopeText}的命令统计`;
|
|
497
|
+
case "消息":
|
|
498
|
+
if (options.subtype) return `${scopeText}的"${options.subtype}"消息统计`;
|
|
499
|
+
return `${scopeText}的消息统计`;
|
|
500
|
+
case "排行":
|
|
501
|
+
return `${scopeText}的${options.timeRange}小时消息排行`;
|
|
502
|
+
default:
|
|
503
|
+
return scopeText;
|
|
365
504
|
}
|
|
366
|
-
if (guildId) {
|
|
367
|
-
const guildName = (await session.bot.getGuild(guildId).catch(() => null))?.name || guildId;
|
|
368
|
-
return `${guildName} 的命令统计`;
|
|
369
|
-
}
|
|
370
|
-
return "全局命令统计";
|
|
371
505
|
}
|
|
372
506
|
/**
|
|
373
|
-
*
|
|
374
|
-
* @
|
|
375
|
-
* @
|
|
376
|
-
* @
|
|
507
|
+
* @private
|
|
508
|
+
* @async
|
|
509
|
+
* @method getCommandStats
|
|
510
|
+
* @description 从数据库中获取并聚合命令使用统计数据。
|
|
511
|
+
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
512
|
+
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
513
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
377
514
|
*/
|
|
378
515
|
async getCommandStats(guildId, userId) {
|
|
379
|
-
const
|
|
380
|
-
if (guildId)
|
|
381
|
-
if (userId)
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
516
|
+
const userQuery = {};
|
|
517
|
+
if (guildId) userQuery.channelId = guildId;
|
|
518
|
+
if (userId) userQuery.userId = userId;
|
|
519
|
+
const users = await this.ctx.database.get("analyse_user", userQuery, ["uid"]);
|
|
520
|
+
if (users.length === 0) return "暂无目标用户统计数据";
|
|
521
|
+
const uids = users.map((u) => u.uid);
|
|
522
|
+
const aggregatedStats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: uids } }).groupBy(["command"], {
|
|
523
|
+
count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
|
|
524
|
+
lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed")
|
|
385
525
|
}).orderBy("count", "desc").execute();
|
|
386
526
|
if (aggregatedStats.length === 0) return "暂无统计数据";
|
|
387
527
|
const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
|
|
388
528
|
const list = aggregatedStats.map((item) => [item.command, item.count, item.lastUsed]);
|
|
389
529
|
return { list, total: totalCount };
|
|
390
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* @private
|
|
533
|
+
* @async
|
|
534
|
+
* @method getMessageStats
|
|
535
|
+
* @description 从数据库中获取并聚合所有消息类型的统计数据。
|
|
536
|
+
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
537
|
+
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
538
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
|
|
539
|
+
*/
|
|
540
|
+
async getMessageStats(guildId, userId) {
|
|
541
|
+
const userQuery = {};
|
|
542
|
+
if (guildId) userQuery.channelId = guildId;
|
|
543
|
+
if (userId) userQuery.userId = userId;
|
|
544
|
+
const users = await this.ctx.database.get("analyse_user", userQuery, ["uid"]);
|
|
545
|
+
if (users.length === 0) return "暂无目标用户统计数据";
|
|
546
|
+
const uids = users.map((u) => u.uid);
|
|
547
|
+
const aggregatedStats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids } }).groupBy(["type"], {
|
|
548
|
+
count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
|
|
549
|
+
lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed")
|
|
550
|
+
}).orderBy("count", "desc").execute();
|
|
551
|
+
if (aggregatedStats.length === 0) return "暂无统计数据";
|
|
552
|
+
const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
|
|
553
|
+
const list = aggregatedStats.map((item) => [item.type, item.count, item.lastUsed]);
|
|
554
|
+
return { list, total: totalCount };
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* @private
|
|
558
|
+
* @async
|
|
559
|
+
* @method getMessageStatsByType
|
|
560
|
+
* @description 按指定消息类型,从数据库中获取并聚合用户排行数据。
|
|
561
|
+
* @param {string} type - 要查询的消息类型。
|
|
562
|
+
* @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
|
|
563
|
+
* @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
|
|
564
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>}
|
|
565
|
+
*/
|
|
566
|
+
async getMessageStatsByType(type, guildId, userId) {
|
|
567
|
+
const userQuery = {};
|
|
568
|
+
if (guildId) userQuery.channelId = guildId;
|
|
569
|
+
if (userId) userQuery.userId = userId;
|
|
570
|
+
const users = await this.ctx.database.get("analyse_user", userQuery, ["uid", "userName"]);
|
|
571
|
+
if (users.length === 0) return "暂无目标用户统计数据";
|
|
572
|
+
const uids = users.map((u) => u.uid);
|
|
573
|
+
const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
|
|
574
|
+
const aggregatedStats = await this.ctx.database.select("analyse_msg").where({
|
|
575
|
+
uid: { $in: uids },
|
|
576
|
+
type
|
|
577
|
+
}).groupBy(["uid"], {
|
|
578
|
+
count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
|
|
579
|
+
lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed")
|
|
580
|
+
}).orderBy("count", "desc").execute();
|
|
581
|
+
if (aggregatedStats.length === 0) return `暂无统计数据`;
|
|
582
|
+
const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
|
|
583
|
+
const list = aggregatedStats.map((item) => [
|
|
584
|
+
userNameMap.get(item.uid) || `UID ${item.uid}`,
|
|
585
|
+
item.count,
|
|
586
|
+
item.lastUsed
|
|
587
|
+
]);
|
|
588
|
+
return { list, total: totalCount };
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* @private
|
|
592
|
+
* @async
|
|
593
|
+
* @method getActiveUserStats
|
|
594
|
+
* @description 从数据库中获取并聚合活跃用户排行数据。
|
|
595
|
+
* @param {string} guildId - 要查询的群组 ID。
|
|
596
|
+
* @param {number} hours - 查询过去的小时数。
|
|
597
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>}
|
|
598
|
+
*/
|
|
599
|
+
async getActiveUserStats(guildId, hours) {
|
|
600
|
+
const since = new Date(Date.now() - hours * 3600 * 1e3);
|
|
601
|
+
const usersInGuild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["uid", "userId", "userName"]);
|
|
602
|
+
if (usersInGuild.length === 0) return "暂无用户统计数据";
|
|
603
|
+
const uids = usersInGuild.map((u) => u.uid);
|
|
604
|
+
const userNameMap = new Map(usersInGuild.map((u) => [u.uid, u.userName]));
|
|
605
|
+
const aggregatedStats = await this.ctx.database.select("analyse_msg").where({
|
|
606
|
+
uid: { $in: uids },
|
|
607
|
+
hour: { $gte: since }
|
|
608
|
+
}).groupBy(["uid"], {
|
|
609
|
+
count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count")
|
|
610
|
+
}).orderBy("count", "desc").limit(100).execute();
|
|
611
|
+
if (aggregatedStats.length === 0) return "暂无统计数据";
|
|
612
|
+
const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
|
|
613
|
+
const list = aggregatedStats.map((item) => [
|
|
614
|
+
userNameMap.get(item.uid) || `UID ${item.uid}`,
|
|
615
|
+
item.count
|
|
616
|
+
]);
|
|
617
|
+
return { list, total: totalCount };
|
|
618
|
+
}
|
|
391
619
|
};
|
|
392
620
|
|
|
393
621
|
// src/index.ts
|
|
@@ -405,12 +633,20 @@ var usage = `
|
|
|
405
633
|
`;
|
|
406
634
|
var name = "chat-analyse";
|
|
407
635
|
var using = ["database", "puppeteer"];
|
|
408
|
-
var Config =
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
636
|
+
var Config = import_koishi4.Schema.intersect([
|
|
637
|
+
import_koishi4.Schema.object({
|
|
638
|
+
enableListener: import_koishi4.Schema.boolean().default(true).description("开启监听")
|
|
639
|
+
}).description("基本设置"),
|
|
640
|
+
import_koishi4.Schema.object({
|
|
641
|
+
enableCmdStat: import_koishi4.Schema.boolean().default(true).description("启用命令统计"),
|
|
642
|
+
enableMsgStat: import_koishi4.Schema.boolean().default(true).description("启用消息统计"),
|
|
643
|
+
enableAdvanced: import_koishi4.Schema.boolean().default(true).description("启用原始记录")
|
|
644
|
+
}).description("功能开关")
|
|
645
|
+
]);
|
|
646
|
+
function apply(ctx, config) {
|
|
647
|
+
if (config.enableListener) new Collector(ctx, config);
|
|
412
648
|
const analyse = ctx.command("analyse", "聊天记录分析");
|
|
413
|
-
|
|
649
|
+
new Stat(ctx, config).registerCommands(analyse);
|
|
414
650
|
}
|
|
415
651
|
__name(apply, "apply");
|
|
416
652
|
// Annotate the CommonJS export names for ESM import in node:
|
package/package.json
CHANGED
package/lib/CmdStat.d.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { Context, Command } from 'koishi';
|
|
2
|
-
import { Renderer } from './Renderer';
|
|
3
|
-
declare module 'koishi' {
|
|
4
|
-
interface Tables {
|
|
5
|
-
analyse_cmd: {
|
|
6
|
-
channelId: string;
|
|
7
|
-
userId: string;
|
|
8
|
-
command: string;
|
|
9
|
-
count: number;
|
|
10
|
-
timestamp: Date;
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* @class CmdStat
|
|
16
|
-
* @description 提供命令统计服务,处理用户查询并渲染结果。
|
|
17
|
-
*/
|
|
18
|
-
export declare class CmdStat {
|
|
19
|
-
private ctx;
|
|
20
|
-
renderer: Renderer;
|
|
21
|
-
constructor(ctx: Context);
|
|
22
|
-
/**
|
|
23
|
-
* 注册所有相关的子命令到主 `analyse` 命令下。
|
|
24
|
-
* @param analyse {Command} 主 `analyse` 命令实例。
|
|
25
|
-
*/
|
|
26
|
-
registerCommands(analyse: Command): void;
|
|
27
|
-
/**
|
|
28
|
-
* 根据查询参数动态生成图片标题。
|
|
29
|
-
*/
|
|
30
|
-
private generateTitle;
|
|
31
|
-
/**
|
|
32
|
-
* 从数据库获取并聚合命令统计数据。
|
|
33
|
-
* @param guildId {string} (可选) 群组ID。
|
|
34
|
-
* @param userId {string} (可选) 用户ID。
|
|
35
|
-
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 包含结果列表和总数的对象,或错误/提示信息。
|
|
36
|
-
*/
|
|
37
|
-
private getCommandStats;
|
|
38
|
-
}
|