koishi-plugin-chat-analyse 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,9 +49,7 @@ declare module 'koishi' {
49
49
  export declare class Collector {
50
50
  private ctx;
51
51
  private config;
52
- /** @property FLUSH_INTERVAL - 内存缓存区定时刷入数据库的间隔(毫秒)。 */
53
52
  private static readonly FLUSH_INTERVAL;
54
- /** @property BUFFER_THRESHOLD - 内存缓存区触发刷新的消息数量阈值。 */
55
53
  private static readonly BUFFER_THRESHOLD;
56
54
  private msgStatBuffer;
57
55
  private rankStatBuffer;
package/lib/Data.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { Context, Command } from 'koishi';
2
+ /**
3
+ * @class Data
4
+ * @description 提供数据备份、恢复和清理等高级管理功能。
5
+ */
6
+ export declare class Data {
7
+ private ctx;
8
+ private dataDir;
9
+ constructor(ctx: Context);
10
+ /**
11
+ * @public
12
+ * @method registerCommands
13
+ * @description 在主命令下注册所有数据管理相关的子命令。
14
+ * @param cmd - 主命令实例。
15
+ */
16
+ registerCommands(cmd: Command): void;
17
+ }
package/lib/Renderer.d.ts CHANGED
@@ -1,49 +1,77 @@
1
1
  import { Context } from 'koishi';
2
- /** 定义了渲染列表中单行数据的格式,是一个由字符串、数字或 `Date` 对象构成的数组。 */
3
- export type RenderListItem = (string | number | Date)[];
4
2
  /**
5
3
  * @interface ListRenderData
6
- * @description 定义了调用 `renderList` 方法所需的数据结构,包含了渲染一张完整列表图片所必需的所有信息。
4
+ * @description 定义了调用 `renderList` 方法所需的数据结构。
7
5
  */
8
6
  export interface ListRenderData {
9
7
  title: string;
10
8
  time: Date;
11
9
  total?: string | number;
12
- list: RenderListItem[];
10
+ list: (string | number | Date)[][];
11
+ }
12
+ /**
13
+ * @interface CircadianChartData
14
+ * @description 定义了调用 `renderCircadianChart` 方法所需的数据结构。
15
+ */
16
+ export interface CircadianChartData {
17
+ title: string;
18
+ time: Date;
19
+ total: string | number;
20
+ data: number[];
13
21
  }
14
22
  /**
15
23
  * @class Renderer
16
- * @description 负责将结构化的列表数据渲染为设计精美的 PNG 图片。其核心特性是能够动态计算内容尺寸,生成布局紧凑、自适应的图片。
24
+ * @description 负责将结构化的数据渲染为设计精美的 PNG 图片。
17
25
  */
18
26
  export declare class Renderer {
19
27
  private ctx;
28
+ private readonly COMMON_STYLE;
20
29
  /**
21
- * @param ctx - Koishi 的插件上下文,用于访问 `puppeteer` 等核心服务。
30
+ * @constructor
31
+ * @description Renderer 类的构造函数。
32
+ * @param {Context} ctx - Koishi 的插件上下文,用于访问 logger 和 puppeteer 服务。
22
33
  */
23
34
  constructor(ctx: Context);
35
+ /**
36
+ * @private
37
+ * @method generateFullHtml
38
+ * @description 将卡片内容和特定样式组合成一个完整的 HTML 文档,以便进行渲染。
39
+ * @param {string} cardContent - 卡片主体部分的 HTML 字符串。
40
+ * @param {string} specificStyles - 针对该卡片类型的特定 CSS 样式字符串。
41
+ * @returns {string} - 一个完整的、可被浏览器渲染的 HTML 字符串。
42
+ */
43
+ private generateFullHtml;
24
44
  /**
25
45
  * @private
26
46
  * @method htmlToImage
27
- * @description 将一个完整的 HTML 文档字符串转换为 PNG 图片 Buffer。
28
- * @param fullHtmlContent - 要渲染的、包含 `<html>...</html>` 的完整HTML字符串。
29
- * @returns 返回一个包含 PNG 图片数据的 Buffer,失败则返回 null。
47
+ * @description 使用 puppeteer 将给定的 HTML 字符串内容渲染成 PNG 图片的 Buffer。
48
+ * @param {string} fullHtmlContent - 完整的 HTML 内容字符串。
49
+ * @returns {Promise<Buffer | null>} - 成功时返回包含 PNG 图片数据的 Buffer,失败则返回 null。
30
50
  */
31
51
  private htmlToImage;
32
52
  /**
33
53
  * @private
34
54
  * @method formatDate
35
- * @description 将 `Date` 对象格式化为易于理解的相对时间或绝对日期字符串。
36
- * @param date - 需要格式化的日期对象。
37
- * @returns 格式化后的时间字符串。
55
+ * @description 将 Date 对象格式化为易于理解的相对时间字符串(如“刚刚”,“5分钟前”)。
56
+ * @param {Date} date - 需要格式化的日期对象。
57
+ * @returns {string} - 格式化后的时间字符串。
38
58
  */
39
59
  private formatDate;
40
60
  /**
41
61
  * @public
42
62
  * @method renderList
43
- * @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML 卡片为图片。如果数据过多,则会分片渲染成多张图片。
44
- * @param data - 包含渲染所需全部信息的对象。
45
- * @param headers - (可选) 表格的表头字符串数组。
46
- * @returns 成功时返回包含 PNG 图片的 Buffer 数组,若列表为空则返回提示字符串。
63
+ * @description 将表格型数据渲染成一个或多个列表形式的图片。如果数据过多,会自动进行分页渲染。
64
+ * @param {ListRenderData} data - 包含标题、时间、总计和列表数据的对象。
65
+ * @param {string[]} [headers] - (可选)列表的表头数组。
66
+ * @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
47
67
  */
48
68
  renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer[]>;
69
+ /**
70
+ * @public
71
+ * @method renderCircadianChart
72
+ * @description 将 24 小时制的活跃度数据渲染成一张柱状图图片。
73
+ * @param {CircadianChartData} data - 包含标题、时间、总计和 24 小时数据数组的对象。
74
+ * @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
75
+ */
76
+ renderCircadianChart(data: CircadianChartData): Promise<string | Buffer[]>;
49
77
  }
package/lib/Stat.d.ts ADDED
@@ -0,0 +1,37 @@
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
+ * @param ctx - Koishi 的插件上下文。
14
+ * @param config - 插件的配置对象。
15
+ */
16
+ constructor(ctx: Context, config: Config);
17
+ /**
18
+ * @public @method registerCommands
19
+ * @description 根据配置,动态地将子命令注册到主命令下。
20
+ * @param cmd - 主命令实例。
21
+ */
22
+ registerCommands(cmd: Command): void;
23
+ /**
24
+ * @private @method parseQueryScope
25
+ * @description 解析命令选项,转换为包含 UIDs 和描述性信息的统一查询范围对象。
26
+ * @param session - 当前会话对象。
27
+ * @param options - 命令选项。
28
+ * @returns 包含 uids、错误或范围描述的查询范围对象。
29
+ */
30
+ private parseQueryScope;
31
+ /**
32
+ * @private @method generateTitle
33
+ * @description 根据查询范围和类型动态生成易于理解的图片标题。
34
+ * @returns 生成的标题字符串。
35
+ */
36
+ private generateTitle;
37
+ }
package/lib/WhoAt.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { Context, Command } from 'koishi';
2
+ import { Config } from './index';
3
+ /**
4
+ * @class WhoAt
5
+ * @description 负责处理谁提及我相关功能,包括查询和定时清理。
6
+ */
7
+ export declare class WhoAt {
8
+ private ctx;
9
+ private config;
10
+ /**
11
+ * @param ctx - Koishi 的插件上下文。
12
+ * @param config - 插件的配置对象。
13
+ */
14
+ constructor(ctx: Context, config: Config);
15
+ /**
16
+ * @public @method registerCommand
17
+ * @description 在主命令下注册子命令。
18
+ * @param cmd - 主命令实例。
19
+ */
20
+ registerCommand(cmd: Command): void;
21
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { Context, Schema } from 'koishi';
2
+ /** @name 插件使用说明 */
3
+ 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";
4
+ export declare const name = "chat-analyse";
5
+ export declare const using: string[];
6
+ /**
7
+ * @interface Config
8
+ * @description 定义插件的配置项结构。
9
+ */
10
+ export interface Config {
11
+ enableListener: boolean;
12
+ enableCmdStat: boolean;
13
+ enableMsgStat: boolean;
14
+ enableRankStat: boolean;
15
+ enableActivity: boolean;
16
+ enableOriRecord: boolean;
17
+ enableWhoAt: boolean;
18
+ enableDataIO: boolean;
19
+ atRetentionDays: number;
20
+ rankRetentionDays: number;
21
+ }
22
+ /** @description 插件的配置项定义 */
23
+ export declare const Config: Schema<Config>;
24
+ /**
25
+ * @function apply
26
+ * @description Koishi 插件的主入口函数,负责初始化和注册所有功能模块。
27
+ * @param ctx - Koishi 的插件上下文。
28
+ * @param config - 用户配置对象。
29
+ */
30
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -50,11 +50,21 @@ var Collector = class _Collector {
50
50
  this.ctx = ctx;
51
51
  this.config = config;
52
52
  this.ctx.model.extend("analyse_user", { uid: "unsigned", channelId: "string", userId: "string", channelName: "string", userName: "string" }, { primary: "uid", autoInc: true, indexes: ["channelId", "userId"] });
53
- this.ctx.model.extend("analyse_cmd", { uid: "unsigned", command: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "command"] });
54
- this.ctx.model.extend("analyse_msg", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "type"] });
55
- this.ctx.model.extend("analyse_rank", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "timestamp", "type"] });
56
- if (this.config.enableOriRecord) this.ctx.model.extend("analyse_cache", { id: "unsigned", uid: "unsigned", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["uid", "timestamp"] });
57
- if (this.config.enableWhoAt) this.ctx.model.extend("analyse_at", { id: "unsigned", uid: "unsigned", target: "string", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["target", "uid"] });
53
+ if (config.enableCmdStat) {
54
+ this.ctx.model.extend("analyse_cmd", { uid: "unsigned", command: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "command"] });
55
+ }
56
+ if (config.enableMsgStat) {
57
+ this.ctx.model.extend("analyse_msg", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "type"] });
58
+ }
59
+ if (config.enableRankStat || config.enableActivity) {
60
+ this.ctx.model.extend("analyse_rank", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "timestamp", "type"] });
61
+ }
62
+ if (this.config.enableOriRecord) {
63
+ this.ctx.model.extend("analyse_cache", { id: "unsigned", uid: "unsigned", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["uid", "timestamp"] });
64
+ }
65
+ if (this.config.enableWhoAt) {
66
+ this.ctx.model.extend("analyse_at", { id: "unsigned", uid: "unsigned", target: "string", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["target", "uid"] });
67
+ }
58
68
  ctx.on("message", (session) => this.onMessage(session));
59
69
  this.flushInterval = setInterval(() => this.flushBuffers(), _Collector.FLUSH_INTERVAL);
60
70
  ctx.on("dispose", () => {
@@ -65,11 +75,9 @@ var Collector = class _Collector {
65
75
  static {
66
76
  __name(this, "Collector");
67
77
  }
68
- /** @property FLUSH_INTERVAL - 内存缓存区定时刷入数据库的间隔(毫秒)。 */
69
78
  static FLUSH_INTERVAL = import_koishi.Time.minute;
70
- /** @property BUFFER_THRESHOLD - 内存缓存区触发刷新的消息数量阈值。 */
71
79
  static BUFFER_THRESHOLD = 60;
72
- // 统一的数据缓冲区
80
+ // 数据缓冲区
73
81
  msgStatBuffer = /* @__PURE__ */ new Map();
74
82
  rankStatBuffer = /* @__PURE__ */ new Map();
75
83
  cmdStatBuffer = /* @__PURE__ */ new Map();
@@ -131,7 +139,7 @@ var Collector = class _Collector {
131
139
  if (!user) return;
132
140
  const { uid } = user;
133
141
  const messageTime = new Date(timestamp);
134
- if (argv?.command) {
142
+ if (this.config.enableCmdStat && argv?.command) {
135
143
  const key = `${uid}:${argv.command.name}`;
136
144
  const entry = this.cmdStatBuffer.get(key) ?? { uid, command: argv.command.name, count: 0, timestamp: messageTime };
137
145
  entry.count++;
@@ -140,15 +148,19 @@ var Collector = class _Collector {
140
148
  }
141
149
  const hourStart = new Date(messageTime.getFullYear(), messageTime.getMonth(), messageTime.getDate(), messageTime.getHours());
142
150
  for (const type of new Set(elements.map((e) => e.type))) {
143
- const msgKey = `${uid}:${type}`;
144
- const msgEntry = this.msgStatBuffer.get(msgKey) ?? { uid, type, count: 0, timestamp: messageTime };
145
- msgEntry.count++;
146
- msgEntry.timestamp = messageTime;
147
- this.msgStatBuffer.set(msgKey, msgEntry);
148
- const rankKey = `${uid}:${hourStart.toISOString()}:${type}`;
149
- const rankEntry = this.rankStatBuffer.get(rankKey) ?? { uid, timestamp: hourStart, type, count: 0 };
150
- rankEntry.count++;
151
- this.rankStatBuffer.set(rankKey, rankEntry);
151
+ if (this.config.enableMsgStat) {
152
+ const msgKey = `${uid}:${type}`;
153
+ const msgEntry = this.msgStatBuffer.get(msgKey) ?? { uid, type, count: 0, timestamp: messageTime };
154
+ msgEntry.count++;
155
+ msgEntry.timestamp = messageTime;
156
+ this.msgStatBuffer.set(msgKey, msgEntry);
157
+ }
158
+ if (this.config.enableRankStat || this.config.enableActivity) {
159
+ const rankKey = `${uid}:${hourStart.toISOString()}:${type}`;
160
+ const rankEntry = this.rankStatBuffer.get(rankKey) ?? { uid, timestamp: hourStart, type, count: 0 };
161
+ rankEntry.count++;
162
+ this.rankStatBuffer.set(rankKey, rankEntry);
163
+ }
152
164
  }
153
165
  if (this.config.enableWhoAt) {
154
166
  const sanitizedContent = this.sanitizeContent(elements.filter((e) => e.type !== "at"));
@@ -211,7 +223,9 @@ var import_koishi3 = require("koishi");
211
223
  var import_koishi2 = require("koishi");
212
224
  var Renderer = class {
213
225
  /**
214
- * @param ctx - Koishi 的插件上下文,用于访问 `puppeteer` 等核心服务。
226
+ * @constructor
227
+ * @description Renderer 类的构造函数。
228
+ * @param {Context} ctx - Koishi 的插件上下文,用于访问 logger 和 puppeteer 服务。
215
229
  */
216
230
  constructor(ctx) {
217
231
  this.ctx = ctx;
@@ -219,55 +233,114 @@ var Renderer = class {
219
233
  static {
220
234
  __name(this, "Renderer");
221
235
  }
236
+ COMMON_STYLE = `
237
+ :root {
238
+ --card-bg: #fff; --text-color: #111827; --header-color: #111827;
239
+ --sub-text-color: #6b7280; --border-color: #e5e7eb; --accent-color: #4a6ee0;
240
+ --chip-bg: #f3f4f6; --stripe-bg: #f9fafb; --gold: #f59e0b;
241
+ --silver: #9ca3af; --bronze: #a16207;
242
+ }
243
+ body {
244
+ display: inline-block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
245
+ Roboto, 'Helvetica Neue', Arial, sans-serif;
246
+ background: transparent; margin: 0; padding: 8px;
247
+ -webkit-font-smoothing: antialiased;
248
+ }
249
+ .container {
250
+ display: inline-block; background: var(--card-bg); border-radius: 12px;
251
+ padding: 0; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,.05);
252
+ }
253
+ .header {
254
+ padding: 12px 16px;
255
+ display: flex;
256
+ justify-content: space-between;
257
+ align-items: center;
258
+ border-bottom: 1px solid var(--border-color);
259
+ }
260
+ .title-text {
261
+ font-size: 18px; font-weight: 600; color: var(--header-color);
262
+ margin: 0; text-align: center;
263
+ }
264
+ .stat-chip, .time-label {
265
+ display: inline-flex; align-items: baseline; padding: 4px 8px;
266
+ border-radius: 8px; background: var(--chip-bg);
267
+ font-size: 13px; color: var(--sub-text-color);
268
+ white-space: nowrap;
269
+ }
270
+ .stat-chip span {
271
+ font-weight: 600; color: var(--text-color); margin-left: 4px;
272
+ }
273
+ `;
274
+ /**
275
+ * @private
276
+ * @method generateFullHtml
277
+ * @description 将卡片内容和特定样式组合成一个完整的 HTML 文档,以便进行渲染。
278
+ * @param {string} cardContent - 卡片主体部分的 HTML 字符串。
279
+ * @param {string} specificStyles - 针对该卡片类型的特定 CSS 样式字符串。
280
+ * @returns {string} - 一个完整的、可被浏览器渲染的 HTML 字符串。
281
+ */
282
+ generateFullHtml(cardContent, specificStyles) {
283
+ return `<!DOCTYPE html>
284
+ <html>
285
+ <head>
286
+ <meta charset="UTF-8">
287
+ <style>${this.COMMON_STYLE}${specificStyles}</style>
288
+ </head>
289
+ <body>
290
+ ${cardContent}
291
+ </body>
292
+ </html>`;
293
+ }
222
294
  /**
223
295
  * @private
224
296
  * @method htmlToImage
225
- * @description 将一个完整的 HTML 文档字符串转换为 PNG 图片 Buffer。
226
- * @param fullHtmlContent - 要渲染的、包含 `<html>...</html>` 的完整HTML字符串。
227
- * @returns 返回一个包含 PNG 图片数据的 Buffer,失败则返回 null。
297
+ * @description 使用 puppeteer 将给定的 HTML 字符串内容渲染成 PNG 图片的 Buffer。
298
+ * @param {string} fullHtmlContent - 完整的 HTML 内容字符串。
299
+ * @returns {Promise<Buffer | null>} - 成功时返回包含 PNG 图片数据的 Buffer,失败则返回 null。
228
300
  */
229
301
  async htmlToImage(fullHtmlContent) {
230
302
  const page = await this.ctx.puppeteer.page();
231
303
  try {
232
- await page.setViewport({ width: 720, height: 1080, deviceScaleFactor: 2 });
304
+ await page.setViewport({ width: 720, height: 10, deviceScaleFactor: 2 });
233
305
  await page.setContent(fullHtmlContent, { waitUntil: "networkidle0" });
234
- const dimensions = await page.evaluate(() => ({ width: document.body.scrollWidth, height: document.body.scrollHeight }));
235
- await page.setViewport({ ...dimensions, deviceScaleFactor: 2 });
236
- return await page.screenshot({ type: "png", fullPage: true, omitBackground: true });
306
+ const { width, height } = await page.evaluate(() => ({
307
+ width: document.body.scrollWidth,
308
+ height: document.body.scrollHeight
309
+ }));
310
+ await page.setViewport({ width, height, deviceScaleFactor: 2 });
311
+ return await page.screenshot({ type: "png", omitBackground: true });
237
312
  } catch (error) {
238
313
  this.ctx.logger.error("图片渲染失败:", error);
239
314
  return null;
240
315
  } finally {
241
- if (page) await page.close().catch((e) => this.ctx.logger.error("关闭页面失败:", e));
316
+ await page.close().catch((e) => this.ctx.logger.error("关闭页面失败:", e));
242
317
  }
243
318
  }
244
319
  /**
245
320
  * @private
246
321
  * @method formatDate
247
- * @description 将 `Date` 对象格式化为易于理解的相对时间或绝对日期字符串。
248
- * @param date - 需要格式化的日期对象。
249
- * @returns 格式化后的时间字符串。
322
+ * @description 将 Date 对象格式化为易于理解的相对时间字符串(如“刚刚”,“5分钟前”)。
323
+ * @param {Date} date - 需要格式化的日期对象。
324
+ * @returns {string} - 格式化后的时间字符串。
250
325
  */
251
326
  formatDate(date) {
252
327
  if (!date) return "未知";
253
328
  const diff = Date.now() - date.getTime();
254
329
  if (diff < import_koishi2.Time.minute) return "刚刚";
255
- if (diff > 365 * import_koishi2.Time.day) return date.toLocaleDateString("zh-CN").replace(/\//g, "-");
256
- const timeUnits = [["月", 30 * import_koishi2.Time.day], ["天", import_koishi2.Time.day], ["", import_koishi2.Time.hour], ["", import_koishi2.Time.minute]];
257
- for (const [unit, ms] of timeUnits) {
258
- if (diff >= ms) {
259
- return `${Math.floor(diff / ms)}${unit}前`;
260
- }
330
+ if (diff > 365 * import_koishi2.Time.day) return date.toLocaleDateString("zh-CN");
331
+ const units = [["月", 30 * import_koishi2.Time.day], ["天", import_koishi2.Time.day], ["小时", import_koishi2.Time.hour], ["分钟", import_koishi2.Time.minute]];
332
+ for (const [unit, ms] of units) {
333
+ if (diff >= ms) return `${Math.floor(diff / ms)}${unit}前`;
261
334
  }
262
335
  return "刚刚";
263
336
  }
264
337
  /**
265
338
  * @public
266
339
  * @method renderList
267
- * @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML 卡片为图片。如果数据过多,则会分片渲染成多张图片。
268
- * @param data - 包含渲染所需全部信息的对象。
269
- * @param headers - (可选) 表格的表头字符串数组。
270
- * @returns 成功时返回包含 PNG 图片的 Buffer 数组,若列表为空则返回提示字符串。
340
+ * @description 将表格型数据渲染成一个或多个列表形式的图片。如果数据过多,会自动进行分页渲染。
341
+ * @param {ListRenderData} data - 包含标题、时间、总计和列表数据的对象。
342
+ * @param {string[]} [headers] - (可选)列表的表头数组。
343
+ * @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
271
344
  */
272
345
  async renderList(data, headers) {
273
346
  const { title, time, list } = data;
@@ -280,42 +353,110 @@ var Renderer = class {
280
353
  const renderCell = /* @__PURE__ */ __name((cell, i) => {
281
354
  const headerText = headers?.[i] || "";
282
355
  if (headerText.includes("占比")) {
283
- const percentValue = parseFloat(String(cell).replace("%", ""));
284
- return `<td class="percent-cell"><div class="percent-bar" style="width: ${percentValue}%;"></div><span class="percent-text">${cell}</span></td>`;
356
+ return `<td class="percent-cell"><div class="percent-bar" style="width: ${String(cell)};"></div><span class="percent-text">${cell}</span></td>`;
285
357
  }
286
358
  if (cell instanceof Date) return `<td class="date-cell">${this.formatDate(cell)}</td>`;
287
359
  if (typeof cell === "number") return `<td class="count-cell">${cell.toLocaleString()}</td>`;
288
360
  return `<td class="name-cell">${String(cell)}</td>`;
289
361
  }, "renderCell");
290
- const totalPages = Math.ceil(totalItems / CHUNK_SIZE);
362
+ const listStyles = `
363
+ .table-container { padding: 0; }
364
+ .main-table { border-collapse: collapse; width: 100%; }
365
+ .main-table th, .main-table td { padding: 9px 16px; vertical-align: middle; text-align: left; }
366
+ .main-table thead { border-bottom: 1px solid var(--border-color); }
367
+ .main-table th { font-size: 12px; font-weight: 500; color: var(--sub-text-color); text-transform: uppercase; }
368
+ .main-table td { font-size: 14px; color: var(--text-color); }
369
+ .main-table tbody tr:nth-child(even) { background-color: var(--stripe-bg); }
370
+ .rank-cell, .count-cell, .date-cell, .percent-cell { text-align: right; white-space: nowrap; width: 1%; font-variant-numeric: tabular-nums; }
371
+ .name-cell { font-weight: 500; }
372
+ .rank-cell { font-weight: 600; color: var(--sub-text-color); }
373
+ .count-cell { font-weight: 600; color: var(--accent-color); }
374
+ .rank-gold, .rank-silver, .rank-bronze { font-weight: 700; }
375
+ .rank-gold { color: var(--gold) !important; }
376
+ .rank-silver { color: var(--silver) !important; }
377
+ .rank-bronze { color: var(--bronze) !important; }
378
+ .percent-cell { position: relative; padding-right: 20px; }
379
+ .percent-bar { position: absolute; top: 50%; right: 0; transform: translateY(-50%); height: 6px; background-color: var(--accent-color); opacity: .2; border-radius: 3px; }
380
+ .percent-text { position: relative; z-index: 1; }
381
+ `;
291
382
  for (let i = 0; i < totalItems; i += CHUNK_SIZE) {
292
383
  const chunk = list.slice(i, i + CHUNK_SIZE);
293
384
  const pageNum = Math.floor(i / CHUNK_SIZE) + 1;
294
- const pageTitle = totalPages > 1 ? `${title} (第 ${pageNum}/${totalPages} 页)` : title;
295
- const tableRowsHtml = chunk.map((row, index) => {
385
+ const pageTitle = totalItems > CHUNK_SIZE ? `${title} (第 ${pageNum}/${Math.ceil(totalItems / CHUNK_SIZE)} 页)` : title;
386
+ const cardHtml = `
387
+ <div class="container">
388
+ <div class="header">
389
+ <div class="stat-chip">总计: <span>${typeof totalCount === "number" ? totalCount.toLocaleString() : totalCount}</span></div>
390
+ <h1 class="title-text">${pageTitle}</h1>
391
+ <div class="time-label">${time.toLocaleString("zh-CN", { hour12: false })}</div>
392
+ </div>
393
+ <div class="table-container">
394
+ <table class="main-table">
395
+ ${headers?.length ? `
396
+ <thead>
397
+ <tr>
398
+ <th class="rank-cell">#</th>
399
+ ${headers.map((h4) => `<th>${h4}</th>`).join("")}
400
+ </tr>
401
+ </thead>` : ""}
402
+ <tbody>
403
+ ${chunk.map((row, index) => {
296
404
  const rank = i + index + 1;
297
405
  const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
298
406
  return `<tr><td class="rank-cell ${rankClass}">${rank}</td>${row.map(renderCell).join("")}</tr>`;
299
- }).join("");
300
- const tableHeadHtml = headers?.length ? `<thead><tr><th class="rank-cell">#</th>${headers.map((h4) => `<th class="${typeof list[0]?.[headers.indexOf(h4)] === "string" ? "name-header" : "header-right-align"}">${h4}</th>`).join("")}</tr></thead>` : "";
301
- const cardHtml = `<div class="container"><div class="header"><table class="header-table"><tr><td class="header-table-left"><div class="stat-chip">总计: <span>${typeof totalCount === "number" ? totalCount.toLocaleString() : totalCount}</span></div></td><td class="header-table-center"><h1 class="title-text">${pageTitle}</h1></td><td class="header-table-right"><div class="time-label">${time.toLocaleString("zh-CN", { hour12: false }).replace(/\//g, "-")}</div></td></tr></table></div><div class="table-container"><table class="main-table">${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table></div></div>`;
302
- const fullHtml = `<!DOCTYPE html>
303
- <html>
304
- <head>
305
- <meta charset="UTF-8">
306
- <style>:root{--card-bg:#fff;--text-color:#111827;--header-color:#111827;--sub-text-color:#6b7280;--border-color:#e5e7eb;--accent-color:#4a6ee0;--chip-bg:#f3f4f6;--stripe-bg:#f9fafb;--gold:#f59e0b;--silver:#9ca3af;--bronze:#a16207}body{display:inline-block;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:0 0;margin:0;padding:8px;-webkit-font-smoothing:antialiased}.container{display:inline-block;background:var(--card-bg);border-radius:12px;padding:0;overflow:hidden;box-shadow:0 2px 4px rgba(0,0,0,.05)}.header{padding:10px 14px}.header-table{border-collapse:collapse;width:100%}.header-table-left,.header-table-right{width:1%;white-space:nowrap}.header-table-left{text-align:left}.header-table-center{text-align:center}.header-table-right{text-align:right}.title-text{font-size:18px;font-weight:600;color:var(--header-color);margin:0}.stat-chip,.time-label{display:inline-flex;align-items:baseline;padding:4px 8px;border-radius:8px;background:var(--chip-bg);font-size:13px;color:var(--sub-text-color)}.stat-chip span{font-weight:600;color:var(--text-color);margin-left:4px}.table-container{border-top:1px solid var(--border-color)}.main-table{border-collapse:collapse;width:100%}.main-table th,.main-table td{padding:8px 14px;vertical-align:middle}.main-table th{font-size:12px;font-weight:500;color:var(--sub-text-color);text-transform:uppercase;letter-spacing:.05em;background:var(--stripe-bg)}.main-table td{font-size:14px;color:var(--text-color)}.main-table tbody tr:nth-child(even){background-color:var(--stripe-bg)}.main-table .name-cell,.main-table .name-header{text-align:left}.main-table .rank-cell,.main-table .count-cell,.main-table .date-cell,.main-table .percent-cell,.main-table .header-right-align{text-align:right;white-space:nowrap;width:1%;font-variant-numeric:tabular-nums}.name-cell{font-weight:500}.rank-cell{font-weight:500;color:var(--sub-text-color)}.count-cell{font-weight:600;color:var(--accent-color)}.date-cell{color:var(--sub-text-color)}.rank-gold,.rank-silver,.rank-bronze{font-weight:600}.rank-gold{color:var(--gold)!important}.rank-silver{color:var(--silver)!important}.rank-bronze{color:var(--bronze)!important}.percent-cell{position:relative}.percent-bar{position:absolute;top:0;right:0;height:100%;background-color:var(--accent-color);opacity:.15}.percent-text{position:relative;z-index:1}</style>
307
- </head>
308
- <body>
309
- ${cardHtml}
310
- </body>
311
- </html>`;
407
+ }).join("")}
408
+ </tbody>
409
+ </table>
410
+ </div>
411
+ </div>`;
412
+ const fullHtml = this.generateFullHtml(cardHtml, listStyles);
312
413
  const imageBuffer = await this.htmlToImage(fullHtml);
313
- if (imageBuffer) {
314
- imageBuffers.push(imageBuffer);
315
- }
414
+ if (imageBuffer) imageBuffers.push(imageBuffer);
316
415
  }
317
416
  return imageBuffers.length > 0 ? imageBuffers : "图片渲染失败";
318
417
  }
418
+ /**
419
+ * @public
420
+ * @method renderCircadianChart
421
+ * @description 将 24 小时制的活跃度数据渲染成一张柱状图图片。
422
+ * @param {CircadianChartData} data - 包含标题、时间、总计和 24 小时数据数组的对象。
423
+ * @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
424
+ */
425
+ async renderCircadianChart(data) {
426
+ const { title, time, total, data: hourlyCounts } = data;
427
+ if (!hourlyCounts || hourlyCounts.every((c) => c === 0)) return "暂无数据可供渲染";
428
+ const maxCount = Math.max(...hourlyCounts, 1);
429
+ const chartStyles = `
430
+ .chart-container { display: flex; align-items: flex-end; gap: 4px; height: 180px; padding: 30px 15px 10px; }
431
+ .bar-wrapper { flex: 1; text-align: center; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
432
+ .bar-value { font-size: 11px; color: var(--sub-text-color); height: 16px; line-height: 16px; font-weight: 500; visibility: ${maxCount > 50 ? "hidden" : "visible"}; }
433
+ .bar-container { flex-grow: 1; display: flex; align-items: flex-end; width: 100%; }
434
+ .bar { width: 100%; background-color: var(--accent-color); opacity: .7; border-radius: 3px 3px 0 0; transition: height .3s ease-out; }
435
+ .bar.peak { opacity: 1; background-color: var(--gold); }
436
+ .bar-label { font-size: 10px; color: var(--sub-text-color); margin-top: 4px; height: 12px; }
437
+ `;
438
+ const cardHtml = `
439
+ <div class="container">
440
+ <div class="header">
441
+ <div class="stat-chip">总计: <span>${typeof total === "number" ? total.toLocaleString() : total}</span></div>
442
+ <h1 class="title-text">${title}</h1>
443
+ <div class="time-label">${time.toLocaleString("zh-CN", { hour12: false })}</div>
444
+ </div>
445
+ <div class="chart-container">
446
+ ${hourlyCounts.map((count, hour) => `
447
+ <div class="bar-wrapper">
448
+ <div class="bar-value">${count > 0 ? count : ""}</div>
449
+ <div class="bar-container">
450
+ <div class="bar ${count === maxCount ? "peak" : ""}" style="height: ${count / maxCount * 100}%;"></div>
451
+ </div>
452
+ <div class="bar-label">${hour}</div>
453
+ </div>`).join("")}
454
+ </div>
455
+ </div>`;
456
+ const fullHtml = this.generateFullHtml(cardHtml, chartStyles);
457
+ const imageBuffer = await this.htmlToImage(fullHtml);
458
+ return imageBuffer ? [imageBuffer] : "图片渲染失败";
459
+ }
319
460
  };
320
461
 
321
462
  // src/Stat.ts
@@ -328,10 +469,10 @@ var Stat = class {
328
469
  this.ctx = ctx;
329
470
  this.config = config;
330
471
  this.renderer = new Renderer(ctx);
331
- if (this.config.rankRetentionDays > 0) {
472
+ if (this.config.enableRankStat && this.config.rankRetentionDays > 0) {
332
473
  this.ctx.cron("0 0 * * *", async () => {
333
474
  const cutoffDate = new Date(Date.now() - this.config.rankRetentionDays * import_koishi3.Time.day);
334
- await this.ctx.database.remove("analyse_rank", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理发言排行历史记录失败:", e));
475
+ await this.ctx.database.remove("analyse_rank", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理发言排行记录失败:", e));
335
476
  });
336
477
  }
337
478
  }
@@ -341,10 +482,10 @@ var Stat = class {
341
482
  renderer;
342
483
  /**
343
484
  * @public @method registerCommands
344
- * @description 根据配置,动态地将 `.cmd`, `.msg`, `.rank` 子命令注册到主 `analyse` 命令下。
345
- * @param analyse - 主 `analyse` 命令实例。
485
+ * @description 根据配置,动态地将子命令注册到主命令下。
486
+ * @param cmd - 主命令实例。
346
487
  */
347
- registerCommands(analyse) {
488
+ registerCommands(cmd) {
348
489
  const createHandler = /* @__PURE__ */ __name((handler) => {
349
490
  return async ({ session, options }) => {
350
491
  const scope = await this.parseQueryScope(session, options);
@@ -352,80 +493,73 @@ var Stat = class {
352
493
  try {
353
494
  const result = await handler(scope, options);
354
495
  if (typeof result === "string") return result;
355
- if (Array.isArray(result)) {
356
- if (result.length === 0) return "图片渲染失败";
496
+ if (Array.isArray(result) && result.length > 0) {
357
497
  for (const buffer of result) await session.sendQueued(import_koishi3.h.image(buffer, "image/png"));
358
498
  return;
359
499
  }
360
- if (Buffer.isBuffer(result)) return import_koishi3.h.image(result, "image/png");
361
500
  } catch (error) {
362
501
  this.ctx.logger.error("渲染统计图片失败:", error);
363
- return "渲染统计图片失败";
502
+ return "图片渲染失败";
364
503
  }
365
504
  };
366
505
  }, "createHandler");
367
- if (this.config.enableCmdStat) analyse.subcommand(".cmd", "命令使用统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(createHandler(async (scope) => {
368
- const stats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: scope.uids } }).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
369
- if (stats.length === 0) return "暂无匹配指令统计数据";
370
- const total = stats.reduce((sum, record) => sum + record.count, 0);
371
- const list = stats.map((item) => [item.command, item.count, item.lastUsed]);
372
- const title = await this.generateTitle(scope.scopeDesc, { main: "命令使用" });
373
- return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["命令", "次数", "最后使用"]);
374
- }));
375
- if (this.config.enableMsgStat) analyse.subcommand(".msg", "消息发送统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a 全局").action(createHandler(async (scope, options) => {
376
- const type = options.type;
377
- if (type) {
378
- const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
379
- const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
380
- const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: scope.uids }, type }).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
381
- if (stats.length === 0) return `暂无“${type}”类型消息数据`;
382
- const total = stats.reduce((sum, r) => sum + r.count, 0);
383
- const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
384
- const title = await this.generateTitle(scope.scopeDesc, { main: "消息", subtype: type });
385
- return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["用户", "条数", "最后发言"]);
386
- } else {
506
+ if (this.config.enableCmdStat) {
507
+ cmd.subcommand("cmdstat", "命令统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(createHandler(async (scope) => {
508
+ const stats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: scope.uids } }).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
509
+ if (stats.length === 0) return "暂无统计数据";
510
+ const total = stats.reduce((sum, record) => sum + record.count, 0);
511
+ const list = stats.map((item) => [item.command, item.count, item.lastUsed]);
512
+ const title = await this.generateTitle(scope.scopeDesc, { main: "命令" });
513
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["命令", "次数", "最后使用"]);
514
+ }));
515
+ }
516
+ if (this.config.enableMsgStat) {
517
+ cmd.subcommand("msgstat", "发言统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a 全局").action(createHandler(async (scope, options) => {
518
+ const { type } = options;
519
+ const query = { uid: { $in: scope.uids } };
520
+ if (type) query.type = type;
387
521
  const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
388
522
  const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
389
- const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: scope.uids } }).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
390
- if (stats.length === 0) return "暂无消息数据";
523
+ const stats = await this.ctx.database.select("analyse_msg").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
524
+ if (stats.length === 0) return "暂无统计数据";
391
525
  const total = stats.reduce((sum, r) => sum + r.count, 0);
392
526
  const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
393
- const title = await this.generateTitle(scope.scopeDesc, { main: "消息发送" });
394
- return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["用户", "总计发言", "最后发言"]);
395
- }
396
- }));
397
- if (this.config.enableRankStat) analyse.subcommand(".rank", "用户发言排行").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).option("all", "-a 全局").action(async ({ session, options }) => {
398
- const guildId = options.all ? void 0 : options.guild || session.guildId;
399
- if (!guildId && !options.all) return "请指定群组或查询全局";
400
- try {
527
+ const title = await this.generateTitle(scope.scopeDesc, { main: "发言", subtype: type });
528
+ const headers = type ? ["用户", "条数", "最后发言"] : ["用户", "总计发言", "最后发言"];
529
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, headers);
530
+ }));
531
+ }
532
+ if (this.config.enableRankStat) {
533
+ cmd.subcommand("rankstat", "发言排行").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).option("all", "-a 全局").action(createHandler(async (scope, options) => {
401
534
  const { hours, type } = options;
402
535
  const since = new Date(Date.now() - hours * import_koishi3.Time.hour);
403
- const baseQuery = { timestamp: { $gte: since } };
404
- if (type) baseQuery.type = type;
405
- const uidsInScope = guildId ? (await this.ctx.database.get("analyse_user", { channelId: guildId }, ["uid"])).map((u) => u.uid) : void 0;
406
- if (guildId && uidsInScope.length === 0) return "暂无指定时段内发言记录";
407
- if (uidsInScope) baseQuery.uid = { $in: uidsInScope };
408
- const rankStats = await this.ctx.database.select("analyse_rank").where(baseQuery).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
409
- if (rankStats.length === 0) return "暂无指定时段内发言记录";
410
- const uids = rankStats.map((s) => s.uid);
411
- const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName"]);
536
+ const query = { uid: { $in: scope.uids }, timestamp: { $gte: since } };
537
+ if (type) query.type = type;
538
+ const rankStats = await this.ctx.database.select("analyse_rank").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
539
+ if (rankStats.length === 0) return "暂无统计数据";
540
+ const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
412
541
  const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
413
542
  const total = rankStats.reduce((sum, record) => sum + record.count, 0);
414
543
  const list = rankStats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count]);
415
544
  const listWithPercentage = list.map((row) => [...row, total > 0 ? `${(row[1] / total * 100).toFixed(2)}%` : "0.00%"]);
416
- const title = await this.generateTitle({ guildId }, { main: "发言排行", timeRange: hours, subtype: type });
417
- const result = await this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
418
- if (typeof result === "string") return result;
419
- if (Array.isArray(result)) {
420
- if (result.length === 0) return "图片渲染失败";
421
- for (const buffer of result) await session.sendQueued(import_koishi3.h.image(buffer, "image/png"));
422
- return;
423
- }
424
- } catch (error) {
425
- this.ctx.logger.error("渲染发言排行图片失败:", error);
426
- return "渲染发言排行图片失败";
427
- }
428
- });
545
+ const title = await this.generateTitle(scope.scopeDesc, { main: "发言排行", timeRange: hours, subtype: type });
546
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
547
+ }));
548
+ }
549
+ if (this.config.enableActivity) {
550
+ cmd.subcommand("activity", "活跃统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(createHandler(async (scope) => {
551
+ const hourlyStats = await this.ctx.database.select("analyse_rank").where({ uid: { $in: scope.uids } }).groupBy(["timestamp"], { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).execute();
552
+ if (hourlyStats.length === 0) return "暂无统计数据";
553
+ const hourlyCounts = Array(24).fill(0);
554
+ let totalMessages = 0;
555
+ hourlyStats.forEach((stat) => {
556
+ hourlyCounts[stat.timestamp.getHours()] += stat.count;
557
+ totalMessages += stat.count;
558
+ });
559
+ const title = await this.generateTitle(scope.scopeDesc, { main: "活跃" });
560
+ return this.renderer.renderCircadianChart({ title, time: /* @__PURE__ */ new Date(), total: totalMessages, data: hourlyCounts });
561
+ }));
562
+ }
429
563
  }
430
564
  /**
431
565
  * @private @method parseQueryScope
@@ -438,12 +572,13 @@ var Stat = class {
438
572
  const scopeDesc = { guildId: options.guild, userId: void 0 };
439
573
  if (options.user) scopeDesc.userId = import_koishi3.h.select(options.user, "at")[0]?.attrs.id ?? options.user.trim();
440
574
  if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId;
441
- if (!options.all && !scopeDesc.guildId) return { error: "请指定群组或查询全局", scopeDesc };
575
+ if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) return { error: "请指定查询范围", scopeDesc };
442
576
  const query = {};
443
577
  if (scopeDesc.guildId) query.channelId = scopeDesc.guildId;
444
578
  if (scopeDesc.userId) query.userId = scopeDesc.userId;
579
+ if (Object.keys(query).length === 0) return { uids: void 0, scopeDesc };
445
580
  const users = await this.ctx.database.get("analyse_user", query, ["uid"]);
446
- if (users.length === 0) return { error: "在指定范围内未找到任何记录", scopeDesc };
581
+ if (users.length === 0) return { error: "暂无统计数据", scopeDesc };
447
582
  return { uids: users.map((u) => u.uid), scopeDesc };
448
583
  }
449
584
  /**
@@ -452,19 +587,25 @@ var Stat = class {
452
587
  * @returns 生成的标题字符串。
453
588
  */
454
589
  async generateTitle(scopeDesc, options) {
455
- let scopeText = "全局";
590
+ let guildName = "", userName = "", scopeText = "全局";
456
591
  if (scopeDesc.guildId) {
457
592
  const [guild] = await this.ctx.database.get("analyse_user", { channelId: scopeDesc.guildId }, ["channelName"]);
458
- scopeText = guild?.channelName || scopeDesc.guildId;
593
+ guildName = guild?.channelName || scopeDesc.guildId;
459
594
  }
460
595
  if (scopeDesc.userId) {
461
596
  const [user] = await this.ctx.database.get("analyse_user", { userId: scopeDesc.userId }, ["userName"]);
462
- const userName = user?.userName || scopeDesc.userId;
463
- scopeText = scopeDesc.guildId ? `${userName} 在 ${scopeText}` : `${userName} 的全局`;
597
+ userName = user?.userName || scopeDesc.userId;
464
598
  }
465
599
  const typeText = options.subtype ? `“${options.subtype}”` : "";
466
- if (options.main.includes("排行")) return `${scopeText}${options.timeRange}小时${typeText}消息排行`;
467
- return `${scopeText}${typeText}${options.main}统计`;
600
+ const mainText = options.main;
601
+ if (mainText.includes("排行")) {
602
+ scopeText = guildName || "全局";
603
+ return `${options.timeRange}小时${scopeText}${typeText}${mainText}`;
604
+ }
605
+ if (userName && guildName) scopeText = `${guildName} ${userName}`;
606
+ else if (userName) scopeText = userName;
607
+ else if (guildName) scopeText = guildName;
608
+ return `${scopeText}${typeText}${mainText}统计`;
468
609
  }
469
610
  };
470
611
 
@@ -478,10 +619,10 @@ var WhoAt = class {
478
619
  constructor(ctx, config) {
479
620
  this.ctx = ctx;
480
621
  this.config = config;
481
- if (this.config.atRetentionDays > 0) {
622
+ if (this.config.enableWhoAt && this.config.atRetentionDays > 0) {
482
623
  this.ctx.cron("0 0 * * *", async () => {
483
624
  const cutoffDate = new Date(Date.now() - this.config.atRetentionDays * import_koishi4.Time.day);
484
- await this.ctx.database.remove("analyse_at", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理 @ 历史记录失败:", e));
625
+ await this.ctx.database.remove("analyse_at", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理提及记录失败:", e));
485
626
  });
486
627
  }
487
628
  }
@@ -490,11 +631,11 @@ var WhoAt = class {
490
631
  }
491
632
  /**
492
633
  * @public @method registerCommand
493
- * @description 在主 `analyse` 命令下注册 `whoatme` 子命令。
494
- * @param analyse - 主 `analyse` 命令实例。
634
+ * @description 在主命令下注册子命令。
635
+ * @param cmd - 主命令实例。
495
636
  */
496
- registerCommand(analyse) {
497
- analyse.subcommand(".whoatme", "谁@我").action(async ({ session }) => {
637
+ registerCommand(cmd) {
638
+ cmd.subcommand("whoatme", "谁提及我").usage("查看最近提及我的消息,不分群组。").action(async ({ session }) => {
498
639
  if (!session.userId) return "无法获取用户信息";
499
640
  try {
500
641
  const records = await this.ctx.database.get("analyse_at", { target: session.userId }, {
@@ -506,7 +647,7 @@ var WhoAt = class {
506
647
  const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName", "userId"]);
507
648
  const userInfoMap = new Map(users.map((u) => [u.uid, { name: u.userName, id: u.userId }]));
508
649
  const messageElements = records.map((record) => {
509
- const senderInfo = userInfoMap.get(record.uid) ?? { name: "未知用户", id: "0" };
650
+ const senderInfo = userInfoMap.get(record.uid);
510
651
  return (0, import_koishi4.h)("message", {}, [
511
652
  (0, import_koishi4.h)("author", { userId: senderInfo.id, nickname: senderInfo.name }),
512
653
  import_koishi4.h.text(record.content)
@@ -514,7 +655,7 @@ var WhoAt = class {
514
655
  });
515
656
  return (0, import_koishi4.h)("message", { forward: true }, messageElements);
516
657
  } catch (error) {
517
- this.ctx.logger.error("查询 @ 记录失败:", error);
658
+ this.ctx.logger.error("查询提及记录失败:", error);
518
659
  return "查询失败,请稍后重试";
519
660
  }
520
661
  });
@@ -539,11 +680,11 @@ var Data = class {
539
680
  /**
540
681
  * @public
541
682
  * @method registerCommands
542
- * @description 在 `analyse` 命令下注册所有数据管理相关的子命令 (`.backup`, `.restore`, `.clear`, `.list`)。
543
- * @param analyse - 主 `analyse` 命令实例。
683
+ * @description 在主命令下注册所有数据管理相关的子命令。
684
+ * @param cmd - 主命令实例。
544
685
  */
545
- registerCommands(analyse) {
546
- analyse.subcommand(".backup", "备份统计数据", { authority: 4 }).action(async () => {
686
+ registerCommands(cmd) {
687
+ cmd.subcommand(".backup", "备份数据", { authority: 4 }).usage("将所有统计数据导出为 JSON 文件并保存到本地。").action(async () => {
547
688
  try {
548
689
  await fs.mkdir(this.dataDir, { recursive: true });
549
690
  const allUsers = await this.ctx.database.get("analyse_user", {});
@@ -570,7 +711,7 @@ var Data = class {
570
711
  return "数据备份失败";
571
712
  }
572
713
  });
573
- analyse.subcommand(".restore", "恢复统计数据", { authority: 4 }).action(async () => {
714
+ cmd.subcommand(".restore", "恢复数据", { authority: 4 }).usage(`从本地的 JSON 文件中恢复统计数据。`).action(async () => {
574
715
  try {
575
716
  const userTablePath = path.join(this.dataDir, "analyse_user.json");
576
717
  const usersToImport = JSON.parse(await fs.readFile(userTablePath, "utf-8").catch(() => "[]"));
@@ -595,15 +736,14 @@ var Data = class {
595
736
  return "数据恢复失败";
596
737
  }
597
738
  });
598
- analyse.subcommand(".clear", "清理统计数据", { authority: 4 }).option("table", "-t <table:string> 指定表名").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("days", "-d <days:number> 指定天数").option("all", "-a 清理全部数据").action(async ({ options }) => {
599
- if (Object.keys(options).length === 0) return "请提供清理条件";
739
+ cmd.subcommand(".clear", "清除数据", { authority: 4 }).option("table", "-t <table:string> 指定表名").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("days", "-d <days:number> 指定天数").option("command", "-c <command:string> 指定命令").option("all", "-a 清除全部").usage(`根据指定条件清理统计数据,可以组合多个选项以精确控制清除范围。`).action(async ({ options }) => {
740
+ if (Object.keys(options).length === 0) return "请指定清除条件";
741
+ if (options.table && !ALL_TABLES.includes(options.table)) return `表名 ${options.table} 无效`;
600
742
  try {
601
743
  if (options.all) {
602
- await Promise.all(ALL_TABLES.map((tableName) => this.ctx.database.remove(tableName, {})));
603
- return "已清除所有聊天分析数据";
744
+ await Promise.all(ALL_TABLES.map((tableName) => this.ctx.database.drop(tableName)));
745
+ return "已清除所有数据,请重新初始化插件";
604
746
  }
605
- const tablesToClear = options.table ? [options.table] : ALL_TABLES.filter((t) => t !== "analyse_user");
606
- if (options.table && !ALL_TABLES.includes(options.table)) return `无效表名: ${options.table}。`;
607
747
  const query = {};
608
748
  const descParts = [];
609
749
  if (options.guild || options.user) {
@@ -613,35 +753,54 @@ var Data = class {
613
753
  descParts.push(`群组 ${options.guild}`);
614
754
  }
615
755
  if (options.user) {
616
- userQuery.userId = import_koishi5.Element.select(options.user, "at")[0]?.attrs.id ?? options.user;
617
- descParts.push(`用户 ${userQuery.userId}`);
756
+ const userId = import_koishi5.Element.select(options.user, "at")[0]?.attrs.id ?? options.user;
757
+ userQuery.userId = userId;
758
+ descParts.push(`用户 ${userId}`);
618
759
  }
619
760
  const uidsToClear = (await this.ctx.database.get("analyse_user", userQuery)).map((u) => u.uid);
620
- if (uidsToClear.length === 0) return "未找到匹配记录";
761
+ if (uidsToClear.length === 0) return "未找到相关数据";
621
762
  query.uid = { $in: [...new Set(uidsToClear)] };
622
763
  }
623
764
  if (options.days > 0) {
624
765
  query.timestamp = { $lt: new Date(Date.now() - options.days * import_koishi5.Time.day) };
625
- descParts.push(`超过 ${options.days} 天`);
766
+ descParts.push(`${options.days} 天前`);
767
+ }
768
+ if (options.command) {
769
+ query.command = options.command;
770
+ descParts.push(`命令 ${options.command}`);
626
771
  }
772
+ const tablesToClear = options.command ? ["analyse_cmd"] : options.table ? [options.table] : ALL_TABLES.filter((t) => t !== "analyse_user");
773
+ let foundData = false;
774
+ for (const tableName of tablesToClear) {
775
+ const records = await this.ctx.database.get(tableName, query, ["uid"]);
776
+ if (records.length > 0) {
777
+ foundData = true;
778
+ break;
779
+ }
780
+ }
781
+ if (!foundData) return "未找到相关数据";
627
782
  for (const tableName of tablesToClear) await this.ctx.database.remove(tableName, query);
628
- const targetStr = options.table ? `表 ${options.table}` : "所有相关表";
629
- return `已成功清理${targetStr}中${descParts.join("")}的数据`;
783
+ const tableString = options.table ? `表 ${options.table}` : "所有表";
784
+ const descString = descParts.join("");
785
+ if (descString) {
786
+ return `已清除${tableString}中 ${descString} 的数据`;
787
+ } else {
788
+ return `已清除${tableString}中的所有数据`;
789
+ }
630
790
  } catch (error) {
631
791
  this.ctx.logger.error("数据清理失败:", error);
632
792
  return "数据清理失败";
633
793
  }
634
794
  });
635
- analyse.subcommand(".list", "列出频道和命令", { authority: 4 }).action(async () => {
795
+ cmd.subcommand(".list", "列出数据", { authority: 4 }).usage("列出数据库中的频道和命令列表。").action(async () => {
636
796
  const [allChannelInfo, commands] = await Promise.all([
637
797
  this.ctx.database.get("analyse_user", {}, ["channelId", "channelName"]),
638
- this.ctx.database.select("analyse_cmd").distinct("command").execute()
798
+ this.ctx.database.select("analyse_cmd").groupBy("command").execute()
639
799
  ]);
640
800
  const uniqueChannels = [...new Map(allChannelInfo.map((item) => [item.channelId, item])).values()];
641
- const channelOutput = uniqueChannels.length ? "已记录频道列表:\n" + uniqueChannels.map((c) => `[${c.channelId}] ${c.channelName}`).join("\n") : "暂无频道记录";
642
- const commandOutput = commands.length ? "已记录命令列表:\n" + commands.join(", ") : "暂无命令记录";
801
+ const channelOutput = uniqueChannels.length ? "频道列表:\n" + uniqueChannels.map((c) => `[${c.channelId}] ${c.channelName}`).join("\n") : "暂无频道记录";
802
+ const commandOutput = commands.length ? "命令列表:\n" + commands.join(", ") : "暂无命令记录";
643
803
  return `${channelOutput}
644
-
645
804
  ${commandOutput}`;
646
805
  });
647
806
  }
@@ -665,28 +824,27 @@ var using = ["database", "puppeteer", "cron"];
665
824
  var Config = import_koishi6.Schema.intersect([
666
825
  import_koishi6.Schema.object({
667
826
  enableListener: import_koishi6.Schema.boolean().default(true).description("启用消息监听"),
668
- enableData: import_koishi6.Schema.boolean().default(false).description("启用数据管理")
669
- }).description("基础配置"),
827
+ enableDataIO: import_koishi6.Schema.boolean().default(true).description("启用数据管理")
828
+ }).description("杂项配置"),
670
829
  import_koishi6.Schema.object({
671
830
  enableCmdStat: import_koishi6.Schema.boolean().default(true).description("启用命令统计"),
672
831
  enableMsgStat: import_koishi6.Schema.boolean().default(true).description("启用消息统计"),
673
- enableOriRecord: import_koishi6.Schema.boolean().default(true).description("启用原始记录")
674
- }).description("功能配置"),
675
- import_koishi6.Schema.object({
832
+ enableActivity: import_koishi6.Schema.boolean().default(true).description("启用活跃统计"),
676
833
  enableRankStat: import_koishi6.Schema.boolean().default(true).description("启用发言排行"),
677
- rankRetentionDays: import_koishi6.Schema.number().min(0).default(31).description("记录保留天数")
678
- }).description("发言排行配置"),
834
+ rankRetentionDays: import_koishi6.Schema.number().min(0).default(31).description("排行保留天数"),
835
+ enableWhoAt: import_koishi6.Schema.boolean().default(true).description("启用提及记录"),
836
+ atRetentionDays: import_koishi6.Schema.number().min(0).default(7).description("提及保留天数")
837
+ }).description("基础分析配置"),
679
838
  import_koishi6.Schema.object({
680
- enableWhoAt: import_koishi6.Schema.boolean().default(true).description("启用@记录"),
681
- atRetentionDays: import_koishi6.Schema.number().min(0).default(7).description("记录保留天数")
682
- }).description("@记录配置")
839
+ enableOriRecord: import_koishi6.Schema.boolean().default(true).description("启用原始记录")
840
+ }).description("高级分析配置")
683
841
  ]);
684
842
  function apply(ctx, config) {
685
843
  if (config.enableListener) new Collector(ctx, config);
686
- const analyse = ctx.command("analyse", "聊天记录分析");
844
+ const analyse = ctx.command("analyse", "数据分析");
687
845
  new Stat(ctx, config).registerCommands(analyse);
688
846
  if (config.enableWhoAt) new WhoAt(ctx, config).registerCommand(analyse);
689
- if (config.enableData) new Data(ctx).registerCommands(analyse);
847
+ if (config.enableDataIO) new Data(ctx).registerCommands(analyse);
690
848
  }
691
849
  __name(apply, "apply");
692
850
  // 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.5.2",
4
+ "version": "0.5.4",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],