koishi-plugin-chat-analyse 0.2.6 → 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.
@@ -18,13 +18,13 @@ declare module 'koishi' {
18
18
  analyse_msg: {
19
19
  uid: number;
20
20
  type: string;
21
+ hour: Date;
21
22
  count: number;
22
23
  timestamp: Date;
23
24
  };
24
25
  analyse_cache: {
25
26
  id: number;
26
- channelId: string;
27
- userId: string;
27
+ uid: number;
28
28
  content: string;
29
29
  timestamp: Date;
30
30
  };
package/lib/Stat.d.ts CHANGED
@@ -17,22 +17,21 @@ export declare class Stat {
17
17
  constructor(ctx: Context, config: Config);
18
18
  /**
19
19
  * @method registerCommands
20
- * @description 根据插件配置,动态地将 `.command` `.message` 子命令注册到主 `analyse` 命令下。
20
+ * @description 根据插件配置,动态地将 `.command`, `.message`, `.rank` 子命令注册到主 `analyse` 命令下。
21
21
  * @param {Command} analyse - 主 `analyse` 命令实例。
22
22
  */
23
23
  registerCommands(analyse: Command): void;
24
24
  /**
25
25
  * @private
26
26
  * @async
27
- * @method _generateTitle
28
- * @description 通用的标题生成器。根据查询参数 (guildId, userId) 和统计类型动态生成易于理解的图片标题。
29
- * @param {Session} session - 当前会话,备用。
27
+ * @method generateTitle
28
+ * @description 通用的标题生成器。根据查询参数和类型选项动态生成易于理解的图片标题。
30
29
  * @param {string} [guildId] - (可选) 查询的群组 ID。
31
30
  * @param {string} [userId] - (可选) 查询的用户 ID。
32
- * @param {'命令' | '消息'} type - 统计类型,用于嵌入标题文本中。
31
+ * @param {TitleOptions} options - 标题的配置选项。
33
32
  * @returns {Promise<string>} 生成的标题字符串。
34
33
  */
35
- private _generateTitle;
34
+ private generateTitle;
36
35
  /**
37
36
  * @private
38
37
  * @async
@@ -47,10 +46,31 @@ export declare class Stat {
47
46
  * @private
48
47
  * @async
49
48
  * @method getMessageStats
50
- * @description 从数据库中获取并聚合消息类型统计数据。
49
+ * @description 从数据库中获取并聚合所有消息类型的统计数据。
51
50
  * @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
52
51
  * @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
53
52
  * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
54
53
  */
55
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;
56
76
  }
package/lib/index.js CHANGED
@@ -86,17 +86,17 @@ var Collector = class _Collector {
86
86
  this.ctx.model.extend("analyse_msg", {
87
87
  uid: "unsigned",
88
88
  type: "string",
89
+ hour: "timestamp",
89
90
  count: "unsigned",
90
91
  timestamp: "timestamp"
91
- }, { primary: ["uid", "type"] });
92
+ }, { primary: ["uid", "type", "hour"] });
92
93
  if (this.config.enableAdvanced) {
93
94
  this.ctx.model.extend("analyse_cache", {
94
95
  id: "unsigned",
95
- channelId: "string",
96
- userId: "string",
96
+ uid: "unsigned",
97
97
  content: "text",
98
98
  timestamp: "timestamp"
99
- }, { primary: "id", autoInc: true });
99
+ }, { primary: "id", autoInc: true, indexes: ["uid", "timestamp"] });
100
100
  }
101
101
  }
102
102
  /**
@@ -113,28 +113,29 @@ var Collector = class _Collector {
113
113
  if (!effectiveId || !userId || !timestamp || !content?.trim()) return;
114
114
  const uid = await this.getOrCreateUser(session, effectiveId);
115
115
  if (!uid) return;
116
- const now = /* @__PURE__ */ new Date();
117
116
  if (argv?.command) {
118
117
  await this.ctx.database.upsert("analyse_cmd", (row) => [{
119
118
  uid,
120
119
  command: argv.command.name,
121
120
  count: import_koishi.$.add(import_koishi.$.ifNull(row.count, import_koishi.$.literal(0)), 1),
122
- timestamp: now
121
+ timestamp: /* @__PURE__ */ new Date()
123
122
  }]);
124
123
  }
124
+ const messageTime = new Date(timestamp);
125
+ const hourStart = new Date(messageTime.getFullYear(), messageTime.getMonth(), messageTime.getDate(), messageTime.getHours());
125
126
  const uniqueElementTypes = new Set(elements.map((e) => e.type));
126
127
  for (const type of uniqueElementTypes) {
127
128
  await this.ctx.database.upsert("analyse_msg", (row) => [{
128
129
  uid,
129
130
  type,
130
- count: import_koishi.$.add(import_koishi.$.ifNull(row.count, import_koishi.$.literal(0)), 1),
131
- timestamp: now
131
+ hour: hourStart,
132
+ count: import_koishi.$.add(import_koishi.$.ifNull(row.count, 0), 1),
133
+ timestamp: messageTime
132
134
  }]);
133
135
  }
134
136
  if (this.config.enableAdvanced) {
135
137
  this.cacheBuffer.push({
136
- channelId: effectiveId,
137
- userId,
138
+ uid,
138
139
  content: this.sanitizeContent(elements),
139
140
  timestamp: new Date(timestamp)
140
141
  });
@@ -359,29 +360,30 @@ var Stat = class {
359
360
  renderer;
360
361
  /**
361
362
  * @method registerCommands
362
- * @description 根据插件配置,动态地将 `.command` `.message` 子命令注册到主 `analyse` 命令下。
363
+ * @description 根据插件配置,动态地将 `.command`, `.message`, `.rank` 子命令注册到主 `analyse` 命令下。
363
364
  * @param {Command} analyse - 主 `analyse` 命令实例。
364
365
  */
365
366
  registerCommands(analyse) {
366
367
  if (this.config.enableCmdStat) {
367
- analyse.subcommand(".command", "命令使用统计").option("user", "-u [user:user] 查看指定用户的统计").option("guild", "-g [guildId:string] 查看指定群组的统计 (默认当前群)").usage("查询命令使用统计。支持按用户、按群组或组合查询。").action(async ({ session, options }) => {
368
+ analyse.subcommand(".command", "命令使用统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").usage("查询用户或群组的命令使用统计,默认展示全局统计。").action(async ({ session, options }) => {
368
369
  const userId = options.user ? import_koishi3.h.select(options.user, "user")[0]?.attrs.id : void 0;
369
370
  let guildId = options.guild;
370
- if (options.guild === "" && !options.user) {
371
- if (!session.guildId) return "私聊中请使用 -g <群组ID> 指定群组。";
371
+ if (!userId && !guildId && session.guildId) {
372
372
  guildId = session.guildId;
373
+ } else if (!userId && !guildId && !session.guildId) {
374
+ return "请指定查询范围";
373
375
  }
374
376
  try {
375
377
  const stats = await this.getCommandStats(guildId, userId);
376
378
  if (typeof stats === "string") return stats;
377
- const title = await this._generateTitle(session, guildId, userId, "命令");
379
+ const title = await this.generateTitle(guildId, userId, { main: "命令" });
378
380
  const renderData = {
379
381
  title,
380
382
  time: /* @__PURE__ */ new Date(),
381
383
  total: stats.total,
382
384
  list: stats.list
383
385
  };
384
- const headers = ["命令", "次数", "上次使用"];
386
+ const headers = ["命令", "次数", "最后使用"];
385
387
  const result = await this.renderer.renderList(renderData, headers);
386
388
  return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
387
389
  } catch (error) {
@@ -391,29 +393,72 @@ var Stat = class {
391
393
  });
392
394
  }
393
395
  if (this.config.enableMsgStat) {
394
- analyse.subcommand(".message", "消息类型统计").option("user", "-u [user:user] 查看指定用户的统计").option("guild", "-g [guildId:string] 查看指定群组的统计 (默认当前群)").usage("查询消息类型统计。支持按用户、按群组或组合查询。").action(async ({ session, options }) => {
396
+ analyse.subcommand(".message", "消息发送统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").option("type", "-t <type:string> 指定类型").usage("查询用户或群组的消息发送统计,默认展示全局统计。").action(async ({ session, options }) => {
395
397
  const userId = options.user ? import_koishi3.h.select(options.user, "user")[0]?.attrs.id : void 0;
396
398
  let guildId = options.guild;
397
- if (options.guild === "" && !options.user) {
398
- if (!session.guildId) return "私聊中请使用 -g <群组ID> 指定群组。";
399
+ if (!userId && !guildId && !options.type && session.guildId) {
399
400
  guildId = session.guildId;
401
+ } else if (!userId && !guildId && !session.guildId) {
402
+ return "请指定查询范围";
400
403
  }
401
404
  try {
402
- const stats = await this.getMessageStats(guildId, userId);
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);
403
443
  if (typeof stats === "string") return stats;
404
- const title = await this._generateTitle(session, guildId, userId, "消息");
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 });
405
450
  const renderData = {
406
451
  title,
407
452
  time: /* @__PURE__ */ new Date(),
408
453
  total: stats.total,
409
- list: stats.list
454
+ list: listWithPercentage
410
455
  };
411
- const headers = ["消息类型", "条数", "上次发送"];
456
+ const headers = ["用户", "总计发言", "占比"];
412
457
  const result = await this.renderer.renderList(renderData, headers);
413
458
  return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
414
459
  } catch (error) {
415
- this.ctx.logger.error("渲染消息统计图片失败:", error);
416
- return "渲染消息统计图片失败";
460
+ this.ctx.logger.error("渲染发言排行图片失败:", error);
461
+ return "渲染发言排行图片失败";
417
462
  }
418
463
  });
419
464
  }
@@ -421,33 +466,42 @@ var Stat = class {
421
466
  /**
422
467
  * @private
423
468
  * @async
424
- * @method _generateTitle
425
- * @description 通用的标题生成器。根据查询参数 (guildId, userId) 和统计类型动态生成易于理解的图片标题。
426
- * @param {Session} session - 当前会话,备用。
469
+ * @method generateTitle
470
+ * @description 通用的标题生成器。根据查询参数和类型选项动态生成易于理解的图片标题。
427
471
  * @param {string} [guildId] - (可选) 查询的群组 ID。
428
472
  * @param {string} [userId] - (可选) 查询的用户 ID。
429
- * @param {'命令' | '消息'} type - 统计类型,用于嵌入标题文本中。
473
+ * @param {TitleOptions} options - 标题的配置选项。
430
474
  * @returns {Promise<string>} 生成的标题字符串。
431
475
  */
432
- async _generateTitle(session, guildId, userId, type) {
476
+ async generateTitle(guildId, userId, options) {
477
+ let scopeText;
433
478
  if (userId && guildId) {
434
479
  const user = await this.ctx.database.get("analyse_user", { channelId: guildId, userId }, ["userName"]);
435
480
  const guild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["channelName"]);
436
481
  const userName = user[0]?.userName || userId;
437
482
  const guildName = guild[0]?.channelName || guildId;
438
- return `${userName} 在 ${guildName} 的${type}统计`;
439
- }
440
- if (userId) {
483
+ scopeText = `${userName} 在 ${guildName}`;
484
+ } else if (userId) {
441
485
  const user = await this.ctx.database.get("analyse_user", { userId }, ["userName"]);
442
486
  const userName = user[0]?.userName || userId;
443
- return `${userName} 的全局${type}统计`;
444
- }
445
- if (guildId) {
487
+ scopeText = `${userName}的全局`;
488
+ } else if (guildId) {
446
489
  const guild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["channelName"]);
447
- const guildName = guild[0]?.channelName || guildId;
448
- return `${guildName} 的${type}统计`;
490
+ scopeText = guild[0]?.channelName || guildId;
491
+ } else {
492
+ scopeText = "全局";
493
+ }
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;
449
504
  }
450
- return `全局${type}统计`;
451
505
  }
452
506
  /**
453
507
  * @private
@@ -463,7 +517,7 @@ var Stat = class {
463
517
  if (guildId) userQuery.channelId = guildId;
464
518
  if (userId) userQuery.userId = userId;
465
519
  const users = await this.ctx.database.get("analyse_user", userQuery, ["uid"]);
466
- if (users.length === 0) return "暂无目标用户的统计数据";
520
+ if (users.length === 0) return "暂无目标用户统计数据";
467
521
  const uids = users.map((u) => u.uid);
468
522
  const aggregatedStats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: uids } }).groupBy(["command"], {
469
523
  count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
@@ -478,7 +532,7 @@ var Stat = class {
478
532
  * @private
479
533
  * @async
480
534
  * @method getMessageStats
481
- * @description 从数据库中获取并聚合消息类型统计数据。
535
+ * @description 从数据库中获取并聚合所有消息类型的统计数据。
482
536
  * @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
483
537
  * @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
484
538
  * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
@@ -488,7 +542,7 @@ var Stat = class {
488
542
  if (guildId) userQuery.channelId = guildId;
489
543
  if (userId) userQuery.userId = userId;
490
544
  const users = await this.ctx.database.get("analyse_user", userQuery, ["uid"]);
491
- if (users.length === 0) return "暂无目标用户的统计数据";
545
+ if (users.length === 0) return "暂无目标用户统计数据";
492
546
  const uids = users.map((u) => u.uid);
493
547
  const aggregatedStats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids } }).groupBy(["type"], {
494
548
  count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
@@ -499,6 +553,69 @@ var Stat = class {
499
553
  const list = aggregatedStats.map((item) => [item.type, item.count, item.lastUsed]);
500
554
  return { list, total: totalCount };
501
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
+ }
502
619
  };
503
620
 
504
621
  // src/index.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-analyse",
3
3
  "description": "聊天记录分析",
4
- "version": "0.2.6",
4
+ "version": "0.3.0",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],