koishi-plugin-chat-analyse 0.1.1 → 0.2.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.
@@ -0,0 +1,34 @@
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
+ renderer: Renderer;
20
+ ctx: Context;
21
+ constructor(context: Context);
22
+ /**
23
+ * @method registerCommands
24
+ * @description 注册命令,并通过选项支持不同维度的查询。
25
+ */
26
+ registerCommands(analyse: Command): void;
27
+ /**
28
+ * @private
29
+ * @method getCommandStats
30
+ * @description 从数据库获取并处理命令统计数据,兼容全局、群组和个人查询。
31
+ * @returns 返回一个包含二维数组列表和总数的对象,或错误字符串。
32
+ */
33
+ private getCommandStats;
34
+ }
@@ -0,0 +1,61 @@
1
+ import { Context } from 'koishi';
2
+ declare module 'koishi' {
3
+ interface Tables {
4
+ analyse_msg: {
5
+ channelId: string;
6
+ userId: string;
7
+ type: string;
8
+ content: string;
9
+ timestamp: Date;
10
+ };
11
+ analyse_name: {
12
+ channelId: string;
13
+ channelName: string;
14
+ userId: string;
15
+ userName: string;
16
+ };
17
+ }
18
+ }
19
+ /**
20
+ * @class Collector
21
+ * @description
22
+ * 负责初始化数据库表、监听消息,并将处理后的数据高效存入数据库。
23
+ */
24
+ export declare class Collector {
25
+ private ctx;
26
+ private static readonly FLUSH_INTERVAL;
27
+ private static readonly BUFFER_THRESHOLD;
28
+ private msgBuffer;
29
+ private flushInterval;
30
+ private nameCache;
31
+ /**
32
+ * @constructor
33
+ * @param ctx {Context} Koishi 的上下文对象
34
+ */
35
+ constructor(ctx: Context);
36
+ /**
37
+ * 核心消息处理函数。
38
+ * @param session {Session} 消息会话对象
39
+ */
40
+ private handleMessage;
41
+ /**
42
+ * 从消息元素中提取并汇总类型。
43
+ * @example
44
+ * // returns "[text][img]"
45
+ * summarizeElementTypes([{type: 'text'}, {type: 'img'}])
46
+ */
47
+ private summarizeElementTypes;
48
+ /**
49
+ * 从消息元素中提取文本化、安全的内容。
50
+ */
51
+ private sanitizeContent;
52
+ /**
53
+ * 将内存缓冲区的消息数据批量写入数据库。
54
+ */
55
+ private flushBuffer;
56
+ /**
57
+ * 检查并更新用户和群组的名称信息。
58
+ * 如果名称不在缓存或缓存已过期 (超过24小时),则从 API 获取并更新到数据库。
59
+ */
60
+ private updateNameIfNeeded;
61
+ }
@@ -0,0 +1,49 @@
1
+ import { Context } from 'koishi';
2
+ /**
3
+ * 渲染列表中的一行数据。每个元素代表一列。
4
+ * 这是一个灵活的元组类型,可以包含字符串、数字或日期。
5
+ * @example ['ping', 150, new Date()]
6
+ */
7
+ export type RenderListItem = (string | number | Date)[];
8
+ /**
9
+ * 渲染列表图片所需的数据结构。
10
+ */
11
+ export interface ListRenderData {
12
+ title: string;
13
+ time: Date;
14
+ total?: string | number;
15
+ list: RenderListItem[];
16
+ }
17
+ /**
18
+ * @class Renderer
19
+ * @description 使用 Puppeteer 服务将格式化的数据渲染成图片。
20
+ * 这是一个通用的列表渲染器,能够处理任意列数的数据,并根据数据类型智能应用样式。
21
+ */
22
+ export declare class Renderer {
23
+ private ctx;
24
+ /**
25
+ * @constructor
26
+ * @param ctx {Context} Koishi 的上下文对象
27
+ */
28
+ constructor(ctx: Context);
29
+ /**
30
+ * 将列表数据渲染为图片。
31
+ * @param data {ListRenderData} 待渲染的列表数据。
32
+ * @param headers {string[]} (可选) 表头数组。如果不提供或为空,则不渲染表头部分。
33
+ * @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,失败或无数据时返回提示文本。
34
+ */
35
+ renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer>;
36
+ /**
37
+ * 格式化日期,提供相对时间和绝对时间显示。
38
+ * @param date {Date} 日期对象
39
+ * @returns {string} 格式化后的日期字符串
40
+ */
41
+ private formatDate;
42
+ /**
43
+ * 根据列表数据动态生成 HTML 字符串。
44
+ * @param data {ListRenderData} 列表数据
45
+ * @param headers {string[]} (可选) 表头数组
46
+ * @returns {string | null} 生成的 HTML 字符串,如果无数据则返回 null。
47
+ */
48
+ private generateListHtml;
49
+ }
package/lib/index.d.ts CHANGED
@@ -5,4 +5,8 @@ export interface Config {
5
5
  }
6
6
  export declare const Config: Schema<Config>;
7
7
  export declare const using: string[];
8
+ /**
9
+ * Koishi 插件的入口函数。
10
+ * @param ctx {Context} Koishi 的上下文对象,用于访问框架核心功能。
11
+ */
8
12
  export declare function apply(ctx: Context): void;
package/lib/index.js CHANGED
@@ -27,112 +27,396 @@ __export(src_exports, {
27
27
  using: () => using
28
28
  });
29
29
  module.exports = __toCommonJS(src_exports);
30
- var import_koishi = require("koishi");
30
+ var import_koishi3 = require("koishi");
31
31
 
32
- // src/collector.ts
33
- var Collector = class {
32
+ // src/Collector.ts
33
+ var Collector = class _Collector {
34
+ /**
35
+ * @constructor
36
+ * @param ctx {Context} Koishi 的上下文对象
37
+ */
34
38
  constructor(ctx) {
35
39
  this.ctx = ctx;
36
- ctx.model.extend("analyse_origin_msg", {
37
- id: "unsigned",
38
- channelId: "string",
39
- userId: "string",
40
- content: "text",
41
- timestamp: "timestamp"
42
- }, {
43
- autoInc: true,
44
- indexes: ["channelId", "userId", "timestamp"]
45
- });
46
- ctx.model.extend("analyse_origin_cmd", {
47
- id: "unsigned",
40
+ this.ctx.model.extend("analyse_msg", {
48
41
  channelId: "string",
49
42
  userId: "string",
50
- command: "string",
43
+ type: "string",
51
44
  content: "text",
52
45
  timestamp: "timestamp"
53
46
  }, {
54
- autoInc: true,
55
- indexes: ["channelId", "userId", "command", "timestamp"]
47
+ indexes: ["channelId", "userId", "type", "timestamp"]
56
48
  });
57
- ctx.model.extend("analyse_name_map", {
58
- id: "unsigned",
49
+ this.ctx.model.extend("analyse_name", {
59
50
  channelId: "string",
60
51
  channelName: "string",
61
52
  userId: "string",
62
- userName: "string",
63
- timestamp: "timestamp"
53
+ userName: "string"
64
54
  }, {
65
- autoInc: true,
66
- unique: [["channelId", "userId"]]
55
+ primary: ["channelId", "userId"]
67
56
  });
68
- ctx.on("command/before-execute", async (argv) => {
69
- const effectiveId = argv.session.channelId || argv.session.guildId;
70
- if (!effectiveId) return;
71
- await ctx.database.create("analyse_origin_cmd", {
72
- channelId: effectiveId,
73
- userId: argv.session.userId,
74
- command: argv.command.name,
75
- content: argv.session.content,
76
- timestamp: new Date(argv.session.timestamp)
77
- });
57
+ ctx.on("message", (session) => {
58
+ this.handleMessage(session);
78
59
  });
79
- ctx.on("message", async (session) => {
80
- const { userId, author } = session;
81
- const effectiveId = session.channelId || session.guildId;
82
- const { channelName, guildName } = session;
83
- const effectiveName = channelName || guildName;
84
- const currentName = author?.name || author?.nick;
85
- if (currentName && effectiveName && effectiveId && userId) {
86
- const cacheKey = `${effectiveId}:${userId}`;
87
- const cachedName = this.nameCache.get(cacheKey);
88
- if (cachedName && currentName !== cachedName) await this.updateName(effectiveId, effectiveName, userId, currentName);
89
- else if (!cachedName) {
90
- const dbRecord = await ctx.database.get("analyse_name_map", {
91
- channelId: effectiveId,
92
- userId
93
- });
94
- const dbName = dbRecord[0]?.userName;
95
- if (!dbName || currentName !== dbName) await this.updateName(effectiveId, effectiveName, userId, currentName);
96
- else this.nameCache.set(cacheKey, currentName);
97
- }
98
- }
99
- if (session.argv?.command) return;
100
- if (!effectiveId) return;
101
- const content = session.elements.map((element) => {
102
- switch (element.type) {
103
- case "text":
104
- return element.attrs.content;
105
- case "img":
106
- return element.attrs.summary === "[动画表情]" ? "[gif]" : `[${element.type}]`;
107
- case "at":
108
- return `[at:${element.attrs.id}]`;
109
- default:
110
- return `[${element.type}]`;
111
- }
112
- }).join("");
113
- const sanitizedContent = content.trim();
114
- if (!sanitizedContent) return;
115
- await ctx.database.create("analyse_origin_msg", {
116
- channelId: effectiveId,
117
- userId: session.userId,
118
- content: sanitizedContent,
119
- timestamp: new Date(session.timestamp)
120
- });
60
+ this.flushInterval = setInterval(() => this.flushBuffer(), _Collector.FLUSH_INTERVAL);
61
+ ctx.on("dispose", () => {
62
+ clearInterval(this.flushInterval);
63
+ this.flushBuffer();
121
64
  });
122
65
  }
123
66
  static {
124
67
  __name(this, "Collector");
125
68
  }
69
+ // 每隔 1 分钟将缓冲区数据写入数据库
70
+ static FLUSH_INTERVAL = 60 * 1e3;
71
+ // 当缓冲区数据达到 100 条时,立即写入数据库
72
+ static BUFFER_THRESHOLD = 100;
73
+ // 消息数据写入缓冲区
74
+ msgBuffer = [];
75
+ // 定时器,用于周期性地清空缓冲区
76
+ flushInterval;
77
+ // 名称缓存,减少不必要的 API 请求 (key: 'channelId:userId')
126
78
  nameCache = /* @__PURE__ */ new Map();
127
- async updateName(channelId, channelName, userId, userName) {
128
- await this.ctx.database.upsert("analyse_name_map", [{
129
- channelId,
130
- channelName,
79
+ /**
80
+ * 核心消息处理函数。
81
+ * @param session {Session} 消息会话对象
82
+ */
83
+ async handleMessage(session) {
84
+ const { userId, channelId, guildId, content, timestamp, argv, elements } = session;
85
+ const effectiveId = channelId || guildId;
86
+ if (!effectiveId || !userId) return;
87
+ this.updateNameIfNeeded(session, effectiveId);
88
+ const isCommand = !!argv?.command;
89
+ const type = isCommand ? argv.command.name : this.summarizeElementTypes(elements);
90
+ const finalContent = isCommand ? content : this.sanitizeContent(elements);
91
+ if (!finalContent?.trim()) return;
92
+ this.msgBuffer.push({
93
+ channelId: effectiveId,
131
94
  userId,
132
- userName,
133
- timestamp: /* @__PURE__ */ new Date()
134
- }]);
135
- this.nameCache.set(`${channelId}:${userId}`, userName);
95
+ type,
96
+ content: finalContent,
97
+ timestamp: new Date(timestamp)
98
+ });
99
+ if (this.msgBuffer.length >= _Collector.BUFFER_THRESHOLD) {
100
+ this.flushBuffer();
101
+ }
102
+ }
103
+ /**
104
+ * 从消息元素中提取并汇总类型。
105
+ * @example
106
+ * // returns "[text][img]"
107
+ * summarizeElementTypes([{type: 'text'}, {type: 'img'}])
108
+ */
109
+ summarizeElementTypes(elements) {
110
+ return [...new Set(elements.map((e) => `[${e.type}]`))].join("");
111
+ }
112
+ /**
113
+ * 从消息元素中提取文本化、安全的内容。
114
+ */
115
+ sanitizeContent(elements) {
116
+ return elements.map((element) => {
117
+ switch (element.type) {
118
+ case "text":
119
+ return element.attrs.content;
120
+ case "img":
121
+ return element.attrs.summary === "[动画表情]" ? "[gif]" : `[img]`;
122
+ case "at":
123
+ return `[at:${element.attrs.id}]`;
124
+ default:
125
+ return `[${element.type}]`;
126
+ }
127
+ }).join("");
128
+ }
129
+ /**
130
+ * 将内存缓冲区的消息数据批量写入数据库。
131
+ */
132
+ async flushBuffer() {
133
+ if (this.msgBuffer.length === 0) return;
134
+ const bufferToFlush = this.msgBuffer;
135
+ this.msgBuffer = [];
136
+ try {
137
+ await this.ctx.database.create("analyse_msg", bufferToFlush);
138
+ } catch (error) {
139
+ this.ctx.logger.error("数据库写入失败:", error);
140
+ this.msgBuffer.unshift(...bufferToFlush);
141
+ }
142
+ }
143
+ /**
144
+ * 检查并更新用户和群组的名称信息。
145
+ * 如果名称不在缓存或缓存已过期 (超过24小时),则从 API 获取并更新到数据库。
146
+ */
147
+ async updateNameIfNeeded(session, effectiveId) {
148
+ const { userId, guildId, bot } = session;
149
+ const cacheKey = `${effectiveId}:${userId}`;
150
+ const cached = this.nameCache.get(cacheKey);
151
+ const CACHE_EXPIRATION = 24 * 60 * 60 * 1e3;
152
+ if (cached && Date.now() - cached.timestamp < CACHE_EXPIRATION) return;
153
+ try {
154
+ const [guild, member] = await Promise.all([
155
+ bot.getGuild(guildId),
156
+ bot.getGuildMember(guildId, userId)
157
+ ]);
158
+ const channelName = guild?.name;
159
+ const userName = member?.nick || member?.name;
160
+ if (!channelName || !userName) return;
161
+ await this.ctx.database.upsert("analyse_name", [{
162
+ channelId: effectiveId,
163
+ channelName,
164
+ userId,
165
+ userName
166
+ }]);
167
+ this.nameCache.set(cacheKey, { name: userName, timestamp: Date.now() });
168
+ } catch (error) {
169
+ this.ctx.logger.warn("更新用户/群组名称失败:", error);
170
+ }
171
+ }
172
+ };
173
+
174
+ // src/CmdStat.ts
175
+ var import_koishi2 = require("koishi");
176
+
177
+ // src/Renderer.ts
178
+ var import_koishi = require("koishi");
179
+ var Renderer = class {
180
+ /**
181
+ * @constructor
182
+ * @param ctx {Context} Koishi 的上下文对象
183
+ */
184
+ constructor(ctx) {
185
+ this.ctx = ctx;
186
+ }
187
+ static {
188
+ __name(this, "Renderer");
189
+ }
190
+ /**
191
+ * 将列表数据渲染为图片。
192
+ * @param data {ListRenderData} 待渲染的列表数据。
193
+ * @param headers {string[]} (可选) 表头数组。如果不提供或为空,则不渲染表头部分。
194
+ * @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,失败或无数据时返回提示文本。
195
+ */
196
+ async renderList(data, headers) {
197
+ const htmlContent = this.generateListHtml(data, headers);
198
+ return this.ctx.puppeteer.render(htmlContent);
199
+ }
200
+ /**
201
+ * 格式化日期,提供相对时间和绝对时间显示。
202
+ * @param date {Date} 日期对象
203
+ * @returns {string} 格式化后的日期字符串
204
+ */
205
+ formatDate(date) {
206
+ if (!date) return "未知";
207
+ const now = Date.now();
208
+ const diff = now - date.getTime();
209
+ if (diff < import_koishi.Time.minute) return "刚刚";
210
+ if (diff < import_koishi.Time.hour) return `${Math.floor(diff / import_koishi.Time.minute)} 分钟前`;
211
+ if (diff < import_koishi.Time.day) return `${Math.floor(diff / import_koishi.Time.hour)} 小时前`;
212
+ const pad = /* @__PURE__ */ __name((n) => n.toString().padStart(2, "0"), "pad");
213
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
214
+ }
215
+ /**
216
+ * 根据列表数据动态生成 HTML 字符串。
217
+ * @param data {ListRenderData} 列表数据
218
+ * @param headers {string[]} (可选) 表头数组
219
+ * @returns {string | null} 生成的 HTML 字符串,如果无数据则返回 null。
220
+ */
221
+ generateListHtml(data, headers) {
222
+ const { title, time, total, list } = data;
223
+ if (!list || list.length === 0) return null;
224
+ let tableHeadHtml = "";
225
+ if (headers && headers.length > 0) {
226
+ const numDataColumns = list[0].length;
227
+ const headerCells = [];
228
+ for (let i = 0; i < numDataColumns; i++) {
229
+ const headerText = headers[i] || "";
230
+ headerCells.push(`<th>${headerText}</th>`);
231
+ }
232
+ const allHeaders = `<th class="rank-cell">排名</th>${headerCells.join("")}`;
233
+ tableHeadHtml = `<thead><tr>${allHeaders}</tr></thead>`;
234
+ }
235
+ const tableRows = list.map((rowItems, index) => {
236
+ const rank = index + 1;
237
+ let rankClass = "";
238
+ if (rank === 1) rankClass = "rank-gold";
239
+ if (rank === 2) rankClass = "rank-silver";
240
+ if (rank === 3) rankClass = "rank-bronze";
241
+ const rankCell = `<td class="rank-cell"><span class="rank-badge ${rankClass}">${rank}</span></td>`;
242
+ const dataCells = rowItems.map((cellData) => {
243
+ let cellClass = "data-cell";
244
+ let content;
245
+ if (cellData instanceof Date) {
246
+ cellClass += " date-cell";
247
+ content = this.formatDate(cellData);
248
+ } else if (typeof cellData === "number") {
249
+ cellClass += " count-cell";
250
+ content = cellData;
251
+ } else {
252
+ cellClass += " name-cell";
253
+ content = String(cellData);
254
+ }
255
+ return `<td class="${cellClass}">${content}</td>`;
256
+ }).join("");
257
+ return `<tr>${rankCell}${dataCells}</tr>`;
258
+ }).join("");
259
+ const metaInfo = total !== void 0 ? `<div class="total-count">总计: ${total}</div>` : "";
260
+ const timeLabel = `生成于 ${time.getFullYear()}-${String(time.getMonth() + 1).padStart(2, "0")}-${String(time.getDate()).padStart(2, "0")} ${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
261
+ const styles = `
262
+ :root {
263
+ --bg-color: #f7f8fa; --card-bg: #ffffff; --text-color: #333;
264
+ --header-color: #1f2329; --sub-text-color: #646a73;
265
+ --border-color: #e4e6eb; --accent-color: #4a6ee0;
266
+ --gold: #ffc327; --silver: #a8b5c1; --bronze: #d69864;
267
+ }
268
+ body {
269
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
270
+ background: var(--bg-color); margin: 0; padding: 20px;
271
+ width: 700px; box-sizing: border-box;
272
+ -webkit-font-smoothing: antialiased;
273
+ }
274
+ .container { background: var(--card-bg); border-radius: 12px; box-shadow: 0 6px 16px rgba(0,0,0,0.08); padding: 24px; }
275
+ .header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; margin-bottom: 16px; }
276
+ .title-group h1 { font-size: 24px; font-weight: 700; color: var(--header-color); margin: 0; }
277
+ .meta-group { text-align: right; }
278
+ .meta-group .total-count { font-size: 22px; font-weight: 700; color: var(--accent-color); }
279
+ .meta-group .time-label { font-size: 13px; color: var(--sub-text-color); margin-top: 4px; }
280
+ table { width: 100%; border-collapse: collapse; color: var(--text-color); }
281
+ th, td { padding: 12px 8px; text-align: left; border-bottom: 1px solid var(--border-color); }
282
+ th { font-size: 13px; font-weight: 600; color: var(--sub-text-color); }
283
+ td { font-size: 15px; vertical-align: middle; }
284
+ tr:last-child td { border-bottom: none; }
285
+ .rank-cell { width: 50px; text-align: center; }
286
+ .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; }
287
+ .rank-gold { background-color: var(--gold); color: #fff; }
288
+ .rank-silver { background-color: var(--silver); color: #fff; }
289
+ .rank-bronze { background-color: var(--bronze); color: #fff; }
290
+ .data-cell { word-break: break-all; }
291
+ .name-cell { font-weight: 600; color: var(--header-color); }
292
+ .count-cell { text-align: right; font-weight: 600; color: var(--accent-color); }
293
+ .date-cell { text-align: right; font-size: 13px; color: var(--sub-text-color); }
294
+ `;
295
+ return `
296
+ <!DOCTYPE html><html lang="zh-CN">
297
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${title}</title><style>${styles}</style></head>
298
+ <body>
299
+ <div class="container">
300
+ <div class="header">
301
+ <div class="title-group"><h1>${title}</h1></div>
302
+ <div class="meta-group">${metaInfo}<div class="time-label">${timeLabel}</div></div>
303
+ </div>
304
+ <table>
305
+ ${tableHeadHtml}
306
+ <tbody>${tableRows}</tbody>
307
+ </table>
308
+ </div>
309
+ </body></html>
310
+ `;
311
+ }
312
+ };
313
+
314
+ // src/CmdStat.ts
315
+ var CmdStat = class {
316
+ static {
317
+ __name(this, "CmdStat");
318
+ }
319
+ renderer;
320
+ ctx;
321
+ constructor(context) {
322
+ this.ctx = context;
323
+ this.renderer = new Renderer(this.ctx);
324
+ this.ctx.model.extend("analyse_cmd", {
325
+ channelId: "string",
326
+ userId: "string",
327
+ command: "string",
328
+ count: "unsigned",
329
+ timestamp: "timestamp"
330
+ }, { primary: ["channelId", "userId", "command"] });
331
+ this.ctx.on("command/before-execute", async ({ command, session }) => {
332
+ const { userId, guildId } = session;
333
+ if (!guildId || !userId || command.name === "analyse" || command.parent?.name === "analyse") return;
334
+ const commandName = command.name;
335
+ const query = { channelId: guildId, userId, command: commandName };
336
+ await this.ctx.database.upsert("analyse_cmd", (row) => [{
337
+ ...query,
338
+ count: import_koishi2.$.add(import_koishi2.$.ifNull(row.count, 0), 1),
339
+ timestamp: /* @__PURE__ */ new Date()
340
+ }]);
341
+ });
342
+ }
343
+ /**
344
+ * @method registerCommands
345
+ * @description 注册命令,并通过选项支持不同维度的查询。
346
+ */
347
+ registerCommands(analyse) {
348
+ analyse.subcommand(".command", "命令使用统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").usage(`查询命令使用统计。默认查询全局统计,可通过选项指定用户和群组。`).action(async ({ session, options }) => {
349
+ const userId = options.user ? import_koishi2.h.select(options.user, "user")[0]?.attrs.id : void 0;
350
+ let guildId = options.guild;
351
+ if (options.guild === "" && !options.user) {
352
+ if (!session.guildId) return "请指定群组 ID";
353
+ guildId = session.guildId;
354
+ }
355
+ try {
356
+ const stats = await this.getCommandStats(guildId, userId);
357
+ if (typeof stats === "string") return stats;
358
+ let title;
359
+ const titleParts = [];
360
+ if (userId) {
361
+ const user = await session.bot.getUser(userId).catch(() => null);
362
+ titleParts.push(`用户 ${user?.name || userId}`);
363
+ }
364
+ if (guildId) {
365
+ const guild = await session.bot.getGuild(guildId).catch(() => null);
366
+ titleParts.push(`群组 ${guild?.name || guildId}`);
367
+ }
368
+ if (userId && !guildId) {
369
+ title = `${titleParts[0]}的全局命令统计`;
370
+ } else if (titleParts.length > 0) {
371
+ title = `${titleParts.join("、")}的命令统计`;
372
+ } else {
373
+ title = "全局命令统计";
374
+ }
375
+ const renderData = {
376
+ title,
377
+ time: /* @__PURE__ */ new Date(),
378
+ total: stats.total,
379
+ list: stats.list
380
+ };
381
+ const headers = ["命令", "次数", "上次使用"];
382
+ const result = await this.renderer.renderList(renderData, headers);
383
+ return Buffer.isBuffer(result) ? import_koishi2.Element.image(result, "image/png") : result;
384
+ } catch (error) {
385
+ this.ctx.logger.error("渲染统计图片失败:", error);
386
+ return "渲染统计图片失败";
387
+ }
388
+ });
389
+ }
390
+ /**
391
+ * @private
392
+ * @method getCommandStats
393
+ * @description 从数据库获取并处理命令统计数据,兼容全局、群组和个人查询。
394
+ * @returns 返回一个包含二维数组列表和总数的对象,或错误字符串。
395
+ */
396
+ async getCommandStats(guildId, userId) {
397
+ const query = {};
398
+ if (guildId) query.channelId = guildId;
399
+ if (userId) query.userId = userId;
400
+ const records = await this.ctx.database.get("analyse_cmd", query);
401
+ if (records.length === 0) return "暂无统计数据";
402
+ const totalCount = records.reduce((sum, record) => sum + record.count, 0);
403
+ let sortedList;
404
+ if (userId) {
405
+ sortedList = records.map((r) => ({ name: r.command, count: r.count, lastUsed: r.timestamp })).sort((a, b) => b.count - a.count);
406
+ } else {
407
+ const commandMap = /* @__PURE__ */ new Map();
408
+ for (const record of records) {
409
+ const existing = commandMap.get(record.command) || { count: 0, lastUsed: /* @__PURE__ */ new Date(0) };
410
+ existing.count += record.count;
411
+ if (record.timestamp > existing.lastUsed) {
412
+ existing.lastUsed = record.timestamp;
413
+ }
414
+ commandMap.set(record.command, existing);
415
+ }
416
+ sortedList = Array.from(commandMap.entries()).map(([name2, data]) => ({ name: name2, ...data })).sort((a, b) => b.count - a.count);
417
+ }
418
+ const list = sortedList.map((item) => [item.name, item.count, item.lastUsed]);
419
+ return { list, total: totalCount };
136
420
  }
137
421
  };
138
422
 
@@ -150,10 +434,13 @@ var usage = `
150
434
  </div>
151
435
  `;
152
436
  var name = "chat-analyse";
153
- var Config = import_koishi.Schema.object({});
154
- var using = ["database"];
437
+ var Config = import_koishi3.Schema.object({});
438
+ var using = ["database", "puppeteer"];
155
439
  function apply(ctx) {
156
440
  new Collector(ctx);
441
+ const cmd = new CmdStat(ctx);
442
+ const analyse = ctx.command("analyse", "聊天记录分析");
443
+ cmd.registerCommands(analyse);
157
444
  }
158
445
  __name(apply, "apply");
159
446
  // 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.1.1",
4
+ "version": "0.2.0",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
@@ -16,14 +16,25 @@
16
16
  "lib",
17
17
  "dist"
18
18
  ],
19
- "license": "MIT",
19
+ "license": "AGPL-3.0-only",
20
20
  "scripts": {},
21
21
  "keywords": [
22
22
  "chatbot",
23
23
  "koishi",
24
- "plugin"
24
+ "plugin",
25
+ "chat",
26
+ "analyse",
27
+ "statistics",
28
+ "command",
29
+ "message",
30
+ "group",
31
+ "user",
32
+ "platform"
25
33
  ],
26
- "devDependencies": {},
34
+ "devDependencies": {
35
+ "koishi-plugin-puppeteer": "^3.5.2",
36
+ "koishi-plugin-cron": "^3.0.0"
37
+ },
27
38
  "peerDependencies": {
28
39
  "koishi": "4.18.8"
29
40
  }
@@ -1,42 +0,0 @@
1
- import { Context } from 'koishi';
2
- /**
3
- * @file collector.ts
4
- * @description 通过独立的事件监听器,分别持久化存储收到的普通消息和已确认的命令,并高效地维护ID与名称的映射。
5
- */
6
- declare module 'koishi' {
7
- interface Tables {
8
- analyse_origin_msg: {
9
- id: number;
10
- channelId: string;
11
- userId: string;
12
- content: string;
13
- timestamp: Date;
14
- };
15
- analyse_origin_cmd: {
16
- id: number;
17
- channelId: string;
18
- userId: string;
19
- command: string;
20
- content: string;
21
- timestamp: Date;
22
- };
23
- analyse_name_map: {
24
- id: number;
25
- channelId: string;
26
- channelName: string;
27
- userId: string;
28
- userName: string;
29
- timestamp: Date;
30
- };
31
- }
32
- }
33
- /**
34
- * @class Collector
35
- * @description 使用双监听器,精确捕获并存储消息和命令,并维护一个ID到名称的映射表。
36
- */
37
- export declare class Collector {
38
- private ctx;
39
- private nameCache;
40
- constructor(ctx: Context);
41
- private updateName;
42
- }