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.
@@ -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
- analyse_ori_msg: {
5
- id: number;
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
- content: string;
21
+ hour: Date;
22
+ count: number;
10
23
  timestamp: Date;
11
24
  };
12
- analyse_name: {
13
- channelId: string;
14
- channelName: string;
15
- userId: string;
16
- userName: string;
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
- private msgBuffer;
29
- private nameCache;
30
- private pendingNameRequests;
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 ctx {Context} Koishi 上下文,用于访问框架核心功能。
35
- */
36
- constructor(ctx: Context);
37
- /**
38
- * 核心消息处理器,对消息进行格式化并存入缓冲区。
39
- * @param session {Session} 消息会话对象。
53
+ * @param {Context} ctx - Koishi 的插件上下文。
54
+ * @param {Config} config - 插件的配置对象。
40
55
  */
41
- private handleMessage;
56
+ constructor(ctx: Context, config: Config);
42
57
  /**
43
- * 汇总消息元素的类型,生成紧凑的类型字符串。
44
- * @param elements {Element[]} 消息元素数组。
45
- * @returns {string} 类型汇总字符串,如 `[text][img]`。
58
+ * @private
59
+ * @method defineModels
60
+ * @description 定义插件所需的所有数据表模型。
46
61
  */
47
- private summarizeElementTypes;
62
+ private defineModels;
48
63
  /**
49
- * 清理并格式化消息内容,提取关键信息。
50
- * @param elements {Element[]} 消息元素数组。
51
- * @returns {string} 处理后的内容字符串。
64
+ * @private
65
+ * @async
66
+ * @method handleMessage
67
+ * @description 统一的消息和命令处理器。它会解析收到的消息,提取关键信息并更新相应的统计数据。
68
+ * @param {Session} session - Koishi 的会话对象,包含消息的全部信息。
52
69
  */
53
- private sanitizeContent;
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 flushBuffer;
80
+ private getOrCreateUser;
58
81
  /**
59
- * 检查用户和群组名称是否需要更新,利用缓存和请求锁机制避免重复调用。
60
- * @param session {Session} 消息会话对象。
61
- * @param effectiveId {string} 有效的频道/群组ID。
82
+ * @private
83
+ * @method sanitizeContent
84
+ * @description Koishi 的消息元素 (Element) 数组净化为纯文本字符串,以便存储和分析。
85
+ * @param {Element[]} elements - 消息元素的数组。
86
+ * @returns {string} 净化后的纯文本字符串。
62
87
  */
63
- private updateNameIfNeeded;
88
+ private sanitizeContent;
64
89
  /**
65
- * 异步获取用户和群组的最新名称,并更新到数据库和内存缓存。
66
- * @param session {Session} 消息会话对象。
67
- * @param effectiveId {string} 频道/群组ID。
68
- * @param cacheKey {string} 用于缓存的键。
90
+ * @private
91
+ * @async
92
+ * @method flushCacheBuffer
93
+ * @description 将内存中的消息缓存 (`cacheBuffer`) 批量写入数据库。
69
94
  */
70
- private fetchAndUpdateNames;
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
- * @example ['ping', 150, new Date()]
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 通用列表渲染器,通过 Puppeteer 将数据渲染为包含精美表格的图片。
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 ctx {Context} Koishi 上下文,用于访问 puppeteer 服务。
26
+ * @param {Context} ctx - Koishi 的插件上下文,用于访问 puppeteer 服务。
25
27
  */
26
28
  constructor(ctx: Context);
27
29
  /**
28
- * 将列表数据渲染为图片。
29
- * @param data {ListRenderData} 待渲染的列表数据。
30
- * @param headers {string[]} (可选) 表头文案数组,若不提供则不渲染表头。
31
- * @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,无数据时返回提示文本。
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
- * 智能格式化日期,提供相对时间(如“刚刚”,“x分钟前”)和绝对日期。
36
- * @param date {Date} 待格式化的日期对象。
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
- * 根据数据动态生成渲染图片所需的完整 HTML 字符串。
42
- * @param data {ListRenderData} 列表数据。
43
- * @param headers {string[]} (可选) 表头数组。
44
- * @returns {string | null} 生成的 HTML 字符串,若无数据则返回 null。
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
- * Koishi 插件主入口函数。
10
- * @param ctx {Context} Koishi 上下文,用于访问和扩展框架功能。
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 import_koishi3 = require("koishi");
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 ctx {Context} Koishi 上下文,用于访问框架核心功能。
37
+ * @param {Context} ctx - Koishi 的插件上下文。
38
+ * @param {Config} config - 插件的配置对象。
37
39
  */
38
- constructor(ctx) {
40
+ constructor(ctx, config) {
39
41
  this.ctx = ctx;
40
- ctx.model.extend("analyse_ori_msg", {
41
- id: "unsigned",
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
- this.flushInterval = setInterval(() => this.flushBuffer(), _Collector.FLUSH_INTERVAL);
56
- ctx.on("dispose", () => {
57
- clearInterval(this.flushInterval);
58
- this.flushBuffer();
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
- // 缓冲区达到100条消息时刷新
69
- // 消息和名称缓存
70
- msgBuffer = [];
71
- nameCache = /* @__PURE__ */ new Map();
72
- pendingNameRequests = /* @__PURE__ */ new Map();
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
- * @param session {Session} 消息会话对象。
68
+ * @private
69
+ * @method defineModels
70
+ * @description 定义插件所需的所有数据表模型。
77
71
  */
78
- async handleMessage(session) {
79
- const { userId, channelId, guildId, content, timestamp, argv, elements } = session;
80
- const effectiveId = channelId || guildId;
81
- if (!effectiveId || !userId || !timestamp || !content?.trim()) return;
82
- this.updateNameIfNeeded(session, effectiveId);
83
- const isCommand = !!argv?.command;
84
- const type = isCommand ? argv.command.name : this.summarizeElementTypes(elements);
85
- const finalContent = isCommand ? content : this.sanitizeContent(elements);
86
- this.msgBuffer.push({
87
- channelId: effectiveId,
88
- userId,
89
- type,
90
- content: finalContent,
91
- timestamp: new Date(timestamp)
92
- });
93
- if (this.msgBuffer.length >= _Collector.BUFFER_THRESHOLD) await this.flushBuffer();
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
- * @param elements {Element[]} 消息元素数组。
98
- * @returns {string} 类型汇总字符串,如 `[text][img]`。
103
+ * @private
104
+ * @async
105
+ * @method handleMessage
106
+ * @description 统一的消息和命令处理器。它会解析收到的消息,提取关键信息并更新相应的统计数据。
107
+ * @param {Session} session - Koishi 的会话对象,包含消息的全部信息。
99
108
  */
100
- summarizeElementTypes(elements) {
101
- const types = new Set(elements.map((e) => `[${e.type}]`));
102
- return Array.from(types).join("");
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
- * @param elements {Element[]} 消息元素数组。
107
- * @returns {string} 处理后的内容字符串。
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
- sanitizeContent(elements) {
110
- return elements.map((e) => {
111
- switch (e.type) {
112
- case "text":
113
- return e.attrs.content;
114
- case "img":
115
- return e.attrs.summary === "[动画表情]" ? "[gif]" : "[img]";
116
- case "at":
117
- return `[at:${e.attrs.id}]`;
118
- default:
119
- return `[${e.type}]`;
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
- }).join("");
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
- async flushBuffer() {
127
- if (this.msgBuffer.length === 0) return;
128
- const bufferToFlush = this.msgBuffer;
129
- this.msgBuffer = [];
130
- try {
131
- await this.ctx.database.upsert("analyse_ori_msg", bufferToFlush);
132
- } catch (error) {
133
- this.ctx.logger.error("数据写入失败:", error);
134
- this.msgBuffer.unshift(...bufferToFlush);
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
- * @param session {Session} 消息会话对象。
157
- * @param effectiveId {string} 频道/群组ID。
158
- * @param cacheKey {string} 用于缓存的键。
212
+ * @private
213
+ * @async
214
+ * @method flushCacheBuffer
215
+ * @description 将内存中的消息缓存 (`cacheBuffer`) 批量写入数据库。
159
216
  */
160
- async fetchAndUpdateNames(session, effectiveId, cacheKey) {
217
+ async flushCacheBuffer() {
218
+ if (this.cacheBuffer.length === 0) return;
219
+ const bufferToFlush = this.cacheBuffer;
220
+ this.cacheBuffer = [];
161
221
  try {
162
- const { userId, guildId, bot } = session;
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.nameCache.set(cacheKey, { name: null, timestamp: Date.now() });
224
+ this.ctx.logger.error("写入缓存出错:", error);
182
225
  }
183
226
  }
184
227
  };
185
228
 
186
- // src/CmdStat.ts
187
- var import_koishi2 = require("koishi");
229
+ // src/Stat.ts
230
+ var import_koishi3 = require("koishi");
188
231
 
189
232
  // src/Renderer.ts
190
- var import_koishi = require("koishi");
233
+ var import_koishi2 = require("koishi");
191
234
  var Renderer = class {
192
235
  /**
193
236
  * @constructor
194
- * @param ctx {Context} Koishi 上下文,用于访问 puppeteer 服务。
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
- * @param data {ListRenderData} 待渲染的列表数据。
205
- * @param headers {string[]} (可选) 表头文案数组,若不提供则不渲染表头。
206
- * @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,无数据时返回提示文本。
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
- * 智能格式化日期,提供相对时间(如“刚刚”,“x分钟前”)和绝对日期。
215
- * @param date {Date} 待格式化的日期对象。
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 < import_koishi.Time.minute) return "刚刚";
222
- if (diff < import_koishi.Time.hour) return `${Math.floor(diff / import_koishi.Time.minute)} 分钟前`;
223
- if (diff < import_koishi.Time.day) return `${Math.floor(diff / import_koishi.Time.hour)} 小时前`;
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
- * 根据数据动态生成渲染图片所需的完整 HTML 字符串。
228
- * @param data {ListRenderData} 列表数据。
229
- * @param headers {string[]} (可选) 表头数组。
230
- * @returns {string | null} 生成的 HTML 字符串,若无数据则返回 null。
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/CmdStat.ts
296
- var CmdStat = class {
297
- constructor(ctx) {
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, "CmdStat");
358
+ __name(this, "Stat");
320
359
  }
321
360
  renderer;
322
361
  /**
323
- * 注册所有相关的子命令到主 `analyse` 命令下。
324
- * @param analyse {Command} `analyse` 命令实例。
362
+ * @method registerCommands
363
+ * @description 根据插件配置,动态地将 `.command`, `.message`, `.rank` 子命令注册到主 `analyse` 命令下。
364
+ * @param {Command} analyse - 主 `analyse` 命令实例。
325
365
  */
326
366
  registerCommands(analyse) {
327
- analyse.subcommand(".command", "命令使用统计").option("user", "-u [user:user] 查看指定用户的统计").option("guild", "-g [guildId:string] 查看指定群组的统计 (默认当前群)").usage("查询命令使用统计。支持按用户、按群组或组合查询。").action(async ({ session, options }) => {
328
- const userId = options.user ? import_koishi2.h.select(options.user, "user")[0]?.attrs.id : void 0;
329
- let guildId = options.guild;
330
- if (options.guild === "" && !options.user) {
331
- if (!session.guildId) return "私聊中请使用 -g <群组ID> 指定群组。";
332
- guildId = session.guildId;
333
- }
334
- try {
335
- const stats = await this.getCommandStats(guildId, userId);
336
- if (typeof stats === "string") return stats;
337
- const title = await this.generateTitle(session, guildId, userId);
338
- const renderData = {
339
- title,
340
- time: /* @__PURE__ */ new Date(),
341
- total: stats.total,
342
- list: stats.list
343
- };
344
- const headers = ["命令", "次数", "上次使用"];
345
- const result = await this.renderer.renderList(renderData, headers);
346
- return Buffer.isBuffer(result) ? import_koishi2.Element.image(result, "image/png") : result;
347
- } catch (error) {
348
- this.ctx.logger.error("渲染统计图片失败:", error);
349
- return "渲染统计图片失败";
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(session, guildId, userId) {
476
+ async generateTitle(guildId, userId, options) {
477
+ let scopeText;
357
478
  if (userId && guildId) {
358
- const userName = (await session.bot.getUser(userId).catch(() => null))?.name || userId;
359
- const guildName = (await session.bot.getGuild(guildId).catch(() => null))?.name || guildId;
360
- return `${userName} ${guildName} 的命令统计`;
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
- if (userId) {
363
- const userName = (await session.bot.getUser(userId).catch(() => null))?.name || userId;
364
- return `${userName} 的全局命令统计`;
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
- * @param guildId {string} (可选) 群组ID。
375
- * @param userId {string} (可选) 用户ID。
376
- * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 包含结果列表和总数的对象,或错误/提示信息。
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 query = {};
380
- if (guildId) query.channelId = guildId;
381
- if (userId) query.userId = userId;
382
- const aggregatedStats = await this.ctx.database.select("analyse_cmd", query).groupBy(["command"], {
383
- count: /* @__PURE__ */ __name((row) => import_koishi2.$.sum(row.count), "count"),
384
- lastUsed: /* @__PURE__ */ __name((row) => import_koishi2.$.max(row.timestamp), "lastUsed")
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 = import_koishi3.Schema.object({});
409
- function apply(ctx) {
410
- new Collector(ctx);
411
- const cmd = new CmdStat(ctx);
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
- cmd.registerCommands(analyse);
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-analyse",
3
3
  "description": "聊天记录分析",
4
- "version": "0.2.5",
4
+ "version": "0.3.0",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
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
- }