koishi-plugin-chat-analyse 1.3.2 → 1.3.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.
@@ -0,0 +1,90 @@
1
+ import { Context } from 'koishi';
2
+ import { WordCloudData } from './Analyse';
3
+ /**
4
+ * @interface ListRenderData
5
+ * @description 定义了调用 `renderList` 方法所需的数据结构。
6
+ */
7
+ export interface ListRenderData {
8
+ title: string;
9
+ time: Date;
10
+ total?: string | number;
11
+ list: (string | number | Date)[][];
12
+ }
13
+ /**
14
+ * @interface LineChartData
15
+ * @description 定义了调用 `renderLineChart` 方法所需的数据结构,支持多组数据系列。
16
+ */
17
+ export interface LineChartData {
18
+ title: string;
19
+ time: Date;
20
+ series: {
21
+ name: string;
22
+ data: number[];
23
+ }[];
24
+ labels: string[];
25
+ }
26
+ /**
27
+ * @class Renderer
28
+ * @description 负责将结构化的数据渲染为设计精美的 PNG 图片。
29
+ */
30
+ export declare class Renderer {
31
+ private ctx;
32
+ private readonly COLOR_PALETTES;
33
+ private readonly COMMON_STYLE;
34
+ /**
35
+ * @constructor
36
+ * @description Renderer 类的构造函数。
37
+ * @param {Context} ctx - Koishi 的插件上下文,用于访问 logger 和 puppeteer 服务。
38
+ */
39
+ constructor(ctx: Context);
40
+ /**
41
+ * @private
42
+ * @method generateFullHtml
43
+ * @description 将卡片内容和特定样式组合成一个完整的 HTML 文档,以便进行渲染。
44
+ * @param {string} cardContent - 卡片主体部分的 HTML 字符串。
45
+ * @param {string} specificStyles - 针对该卡片类型的特定 CSS 样式字符串。
46
+ * @returns {string} - 一个完整的、可被浏览器渲染的 HTML 字符串。
47
+ */
48
+ private generateFullHtml;
49
+ /**
50
+ * @private
51
+ * @method htmlToImage
52
+ * @description 使用 puppeteer 将给定的 HTML 字符串内容渲染成 PNG 图片的 Buffer。
53
+ * @param {string} fullHtmlContent - 完整的 HTML 内容字符串。
54
+ * @returns {Promise<Buffer | null>} - 成功时返回包含 PNG 图片数据的 Buffer,失败则返回 null。
55
+ */
56
+ private htmlToImage;
57
+ /**
58
+ * @private
59
+ * @method formatDate
60
+ * @description 将 Date 对象格式化为易于理解的相对时间字符串(如“刚刚”,“5分钟前”)。
61
+ * @param {Date} date - 需要格式化的日期对象。
62
+ * @returns {string} - 格式化后的时间字符串。
63
+ */
64
+ private formatDate;
65
+ /**
66
+ * @public
67
+ * @method renderList
68
+ * @description 将表格型数据渲染成列表形式的图片。如果数据过多,会通过异步生成器逐个产出图片。
69
+ * @param {ListRenderData} data - 包含标题、时间、总计和列表数据的对象。
70
+ * @param {string[]} [headers] - (可选)列表的表头数组。
71
+ * @returns {AsyncGenerator<Buffer>} - 一个异步生成器,每次迭代产出一张图片的 Buffer。
72
+ */
73
+ renderList(data: ListRenderData, headers?: string[]): AsyncGenerator<Buffer>;
74
+ /**
75
+ * @public
76
+ * @method renderLineChart
77
+ * @description 将时间序列数据(如活跃度)渲染成一张基于 SVG 的折线图。支持单组或多组数据进行对比。
78
+ * @param {LineChartData} data - 包含标题、时间、数据系列和标签的对象。
79
+ * @returns {AsyncGenerator<Buffer>} - 一个异步生成器,产出渲染后的图片 Buffer。
80
+ */
81
+ renderLineChart(data: LineChartData): AsyncGenerator<Buffer>;
82
+ /**
83
+ * @public
84
+ * @method renderWordCloud
85
+ * @description 将词频数据渲染成一张词云图片,使用 Puppeteer 和 wordcloud2.js。
86
+ * @param {WordCloudData} data - 包含标题、时间和词汇列表的对象。
87
+ * @returns {AsyncGenerator<Buffer>} - 一个异步生成器,产出渲染后的图片 Buffer。
88
+ */
89
+ renderWordCloud(data: WordCloudData): AsyncGenerator<Buffer>;
90
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { Context, Schema, Session } 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
+ enableWordCloud: boolean;
22
+ cacheRetentionDays: number;
23
+ enableSimilarActivity: boolean;
24
+ }
25
+ /** @description 插件的配置项定义 */
26
+ export declare const Config: Schema<Config>;
27
+ /**
28
+ * @private @method parseQueryScope
29
+ * @description 解析命令选项,转换为包含 UIDs 和描述性信息的统一查询范围对象。
30
+ * @param session - 当前会话对象。
31
+ * @param options - 命令选项。
32
+ * @returns 包含 uids、错误或范围描述的查询范围对象。
33
+ */
34
+ export declare function parseQueryScope(ctx: Context, session: Session, options: {
35
+ user?: string;
36
+ guild?: string;
37
+ all?: boolean;
38
+ }): Promise<{
39
+ uids?: number[];
40
+ error?: string;
41
+ scopeDesc: {
42
+ guildId?: string;
43
+ userId?: string;
44
+ };
45
+ }>;
46
+ /**
47
+ * @private @method generateTitle
48
+ * @description 根据查询范围和类型动态生成易于理解的图片标题。
49
+ * @returns 生成的标题字符串。
50
+ */
51
+ export declare function generateTitle(ctx: Context, scopeDesc: {
52
+ guildId?: string;
53
+ userId?: string;
54
+ }, options: {
55
+ main: string;
56
+ subtype?: string;
57
+ timeRange?: number;
58
+ timeUnit?: '小时' | '天';
59
+ }): Promise<string>;
60
+ /**
61
+ * @function apply
62
+ * @description Koishi 插件的主入口函数,负责初始化和注册所有功能模块。
63
+ * @param ctx - Koishi 的插件上下文。
64
+ * @param config - 用户配置对象。
65
+ */
66
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -1518,6 +1518,7 @@ var Renderer = class {
1518
1518
  .container {
1519
1519
  display: inline-block; background: var(--card-bg); border-radius: 12px;
1520
1520
  padding: 0; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,.05);
1521
+ width: 600px;
1521
1522
  }
1522
1523
  .header {
1523
1524
  padding: 12px 16px;
@@ -1683,40 +1684,78 @@ var Renderer = class {
1683
1684
  }
1684
1685
  /**
1685
1686
  * @public
1686
- * @method renderCircadianChart
1687
- * @description 24 小时制的活跃度数据渲染成一张柱状图图片。通过异步生成器产出图片。
1688
- * @param {CircadianChartData} data - 包含标题、时间、总计和 24 小时数据数组的对象。
1687
+ * @method renderLineChart
1688
+ * @description 将时间序列数据(如活跃度)渲染成一张基于 SVG 的折线图。支持单组或多组数据进行对比。
1689
+ * @param {LineChartData} data - 包含标题、时间、数据系列和标签的对象。
1689
1690
  * @returns {AsyncGenerator<Buffer>} - 一个异步生成器,产出渲染后的图片 Buffer。
1690
1691
  */
1691
- async *renderCircadianChart(data) {
1692
- const { title, time, total, data: hourlyCounts, labels } = data;
1693
- const maxCount = Math.max(...hourlyCounts, 1);
1694
- const chartStyles = `
1695
- .chart-container { display: flex; align-items: flex-end; gap: 4px; height: 180px; padding: 30px 15px 10px; }
1696
- .bar-wrapper { flex: 1; text-align: center; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
1697
- .bar-value { font-size: 11px; color: var(--sub-text-color); height: 16px; line-height: 16px; font-weight: 500; }
1698
- .bar-container { flex-grow: 1; display: flex; align-items: flex-end; width: 100%; }
1699
- .bar { width: 100%; background-color: var(--accent-color); opacity: .7; border-radius: 3px 3px 0 0; transition: height .3s ease-out; }
1700
- .bar-label { font-size: 10px; color: var(--sub-text-color); margin-top: 4px; height: 12px; }
1701
- `;
1692
+ async *renderLineChart(data) {
1693
+ const { title, time, series, labels } = data;
1694
+ const seriesColors = series.map(() => {
1695
+ const randomPalette = this.COLOR_PALETTES[Math.floor(Math.random() * this.COLOR_PALETTES.length)];
1696
+ return randomPalette[Math.floor(Math.random() * randomPalette.length)];
1697
+ });
1698
+ const width = 600, height = 320;
1699
+ const padding = { top: 20, right: 30, bottom: 80, left: 40 };
1700
+ const chartWidth = width - padding.left - padding.right;
1701
+ const chartHeight = height - padding.top - padding.bottom;
1702
+ const maxVal = Math.max(1, ...series.flatMap((s) => s.data));
1703
+ const yTickCount = 5;
1704
+ const yTickValue = Math.ceil(maxVal / yTickCount);
1705
+ const yAxisMax = yTickValue * yTickCount;
1706
+ const getX = /* @__PURE__ */ __name((index) => {
1707
+ if (labels.length <= 1) return padding.left + chartWidth / 2;
1708
+ return padding.left + index / (labels.length - 1) * chartWidth;
1709
+ }, "getX");
1710
+ const getY = /* @__PURE__ */ __name((value) => padding.top + chartHeight - value / yAxisMax * chartHeight, "getY");
1711
+ let svgElements = "";
1712
+ for (let i = 0; i <= yTickCount; i++) {
1713
+ const y = getY(i * yTickValue);
1714
+ const value = i * yTickValue;
1715
+ svgElements += `<line x1="${padding.left}" y1="${y}" x2="${width - padding.right}" y2="${y}" stroke="var(--border-color)" stroke-width="1"/>`;
1716
+ svgElements += `<text x="${padding.left - 8}" y="${y + 4}" font-size="10" fill="var(--sub-text-color)" text-anchor="end">${value}</text>`;
1717
+ }
1718
+ labels.forEach((label, index) => {
1719
+ if (index % Math.ceil(labels.length / 12) === 0) {
1720
+ const x = getX(index);
1721
+ svgElements += `<text x="${x}" y="${height - padding.bottom + 20}" font-size="10" fill="var(--sub-text-color)" text-anchor="middle">${label}</text>`;
1722
+ }
1723
+ });
1724
+ series.forEach((s, seriesIndex) => {
1725
+ const color = seriesColors[seriesIndex];
1726
+ const points = s.data.map((value, index) => `${getX(index)},${getY(value)}`).join(" ");
1727
+ svgElements += `<polyline points="${points}" fill="none" stroke="${color}" stroke-width="2"/>`;
1728
+ });
1729
+ if (series.length > 1) {
1730
+ const ITEMS_PER_ROW = 3;
1731
+ const ROW_HEIGHT = 20;
1732
+ const LEGEND_START_Y = height - padding.bottom + 45;
1733
+ const columnWidth = chartWidth / ITEMS_PER_ROW;
1734
+ series.forEach((s, seriesIndex) => {
1735
+ const rowIndex = Math.floor(seriesIndex / ITEMS_PER_ROW);
1736
+ const colIndex = seriesIndex % ITEMS_PER_ROW;
1737
+ const legendX = padding.left + colIndex * columnWidth;
1738
+ const legendY = LEGEND_START_Y + rowIndex * ROW_HEIGHT;
1739
+ const color = seriesColors[seriesIndex];
1740
+ svgElements += `<rect x="${legendX}" y="${legendY - 8}" width="12" height="8" fill="${color}" rx="2"/>`;
1741
+ svgElements += `<text x="${legendX + 18}" y="${legendY}" font-size="12" fill="var(--text-color)">${s.name}</text>`;
1742
+ });
1743
+ }
1744
+ const totalMessages = series.reduce((sum, s) => sum + s.data.reduce((a, b) => a + b, 0), 0);
1702
1745
  const cardHtml = `
1703
1746
  <div class="container">
1704
1747
  <div class="header">
1705
- <div class="stat-chip">总计: <span>${typeof total === "number" ? total.toLocaleString() : total}</span></div>
1748
+ <div class="stat-chip">总计: <span>${totalMessages.toLocaleString()}</span></div>
1706
1749
  <h1 class="title-text">${title}</h1>
1707
1750
  <div class="time-label">${time.toLocaleString("zh-CN", { hour12: false })}</div>
1708
1751
  </div>
1709
- <div class="chart-container">
1710
- ${hourlyCounts.map((count, hour) => `
1711
- <div class="bar-wrapper">
1712
- <div class="bar-value">${count > 0 ? count : ""}</div>
1713
- <div class="bar-container">
1714
- <div class="bar" style="height: ${count / maxCount * 100}%;"></div>
1715
- </div>
1716
- <div class="bar-label">${labels ? labels[hour] : hour}</div>
1717
- </div>`).join("")}
1752
+ <div class="chart-wrapper">
1753
+ <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
1754
+ ${svgElements}
1755
+ </svg>
1718
1756
  </div>
1719
1757
  </div>`;
1758
+ const chartStyles = ` .chart-wrapper { padding: 10px; } `;
1720
1759
  const fullHtml = this.generateFullHtml(cardHtml, chartStyles);
1721
1760
  const imageBuffer = await this.htmlToImage(fullHtml);
1722
1761
  if (imageBuffer) yield imageBuffer;
@@ -1925,7 +1964,7 @@ var Stat = class {
1925
1964
  })()));
1926
1965
  }
1927
1966
  if (this.config.enableRankStat) {
1928
- cmd.subcommand("rankstat", "发言排行").usage("查询发言排行,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("duration", "-n <hours:number> 指定时长", { fallback: 24 }).option("offset", "-o <hours:number> 指定偏移").option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
1967
+ cmd.subcommand("rankstat", "发言排行").usage("查询发言排行,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("duration", "-n <hours:number> 指定时长", { fallback: 24 }).option("offset", "-o <hours:number> 指定偏移", { fallback: 0 }).option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
1929
1968
  const scope = await this.parseScope(session, options);
1930
1969
  if (scope.error) return scope.error;
1931
1970
  const until = new Date(Date.now() - options.offset * import_koishi3.Time.hour);
@@ -1968,7 +2007,7 @@ var Stat = class {
1968
2007
  })()));
1969
2008
  }
1970
2009
  if (this.config.enableActivity) {
1971
- cmd.subcommand("activity", "活跃统计").usage("查询活跃统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("duration", "-n <units:number> 指定时长", { fallback: 24 }).option("offset", "-o <units:number> 指定偏移").option("days", "-d 以天为粒度").option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
2010
+ cmd.subcommand("activity", "活跃统计").usage("查询活跃统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("duration", "-n <units:number> 指定时长", { fallback: 24 }).option("offset", "-o <units:number> 指定偏移", { fallback: 0 }).option("days", "-d 以天为粒度").option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
1972
2011
  const scope = await this.parseScope(session, options);
1973
2012
  if (scope.error) return scope.error;
1974
2013
  const timeUnit = options.days ? import_koishi3.Time.day : import_koishi3.Time.hour;
@@ -1995,9 +2034,9 @@ var Stat = class {
1995
2034
  counts[index] += stat.count;
1996
2035
  }
1997
2036
  });
1998
- const total = counts.reduce((a, b) => a + b, 0);
1999
2037
  const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "活跃", timeRange: options.duration, timeUnit: timeUnitName });
2000
- return this.renderer.renderCircadianChart({ title, time: /* @__PURE__ */ new Date(), total, data: counts, labels });
2038
+ const series = [{ name: "活跃度", data: counts }];
2039
+ return this.renderer.renderLineChart({ title, time: /* @__PURE__ */ new Date(), series, labels });
2001
2040
  })()));
2002
2041
  }
2003
2042
  }
@@ -2205,6 +2244,21 @@ ${commandOutput}`;
2205
2244
  var import_koishi6 = require("koishi");
2206
2245
  var import_jieba = require("@node-rs/jieba");
2207
2246
  var import_dict = require("@node-rs/jieba/dict");
2247
+ function cosineSimilarity(vecA, vecB) {
2248
+ let dotProduct = 0;
2249
+ let magA = 0;
2250
+ let magB = 0;
2251
+ for (let i = 0; i < vecA.length; i++) {
2252
+ dotProduct += (vecA[i] || 0) * (vecB[i] || 0);
2253
+ magA += (vecA[i] || 0) * (vecA[i] || 0);
2254
+ magB += (vecB[i] || 0) * (vecB[i] || 0);
2255
+ }
2256
+ magA = Math.sqrt(magA);
2257
+ magB = Math.sqrt(magB);
2258
+ if (magA === 0 || magB === 0) return 0;
2259
+ return dotProduct / (magA * magB);
2260
+ }
2261
+ __name(cosineSimilarity, "cosineSimilarity");
2208
2262
  var Analyse = class {
2209
2263
  constructor(ctx, config) {
2210
2264
  this.ctx = ctx;
@@ -2261,6 +2315,49 @@ var Analyse = class {
2261
2315
  }
2262
2316
  });
2263
2317
  }
2318
+ if (this.config.enableSimilarActivity) {
2319
+ cmd.subcommand("simiactive", "相似活跃分析").usage("分析你和群友的活跃度,找出谁和你的活跃度最相似。").option("hours", "-n <hours:number> 指定分析时长(小时)", { fallback: 24 }).action(async ({ session, options }) => {
2320
+ if (!session.guildId) return "请在群组中使用此命令";
2321
+ try {
2322
+ const until = /* @__PURE__ */ new Date();
2323
+ const since = new Date(until.getTime() - options.hours * import_koishi6.Time.hour);
2324
+ const points = options.hours;
2325
+ const guildUsers = await this.ctx.database.get("analyse_user", { channelId: session.guildId });
2326
+ if (guildUsers.length < 2) return "暂无用户数据";
2327
+ const selfUser = guildUsers.find((u) => u.userId === session.userId);
2328
+ const guildUserUids = guildUsers.map((u) => u.uid);
2329
+ const uidToNameMap = new Map(guildUsers.map((u) => [u.uid, u.userName]));
2330
+ const records = await this.ctx.database.get("analyse_rank", { uid: { $in: guildUserUids }, timestamp: { $gte: since } });
2331
+ if (records.length === 0) return "暂无统计数据";
2332
+ const activityVectors = /* @__PURE__ */ new Map();
2333
+ guildUserUids.forEach((uid) => activityVectors.set(uid, Array(points).fill(0)));
2334
+ records.forEach((stat) => {
2335
+ const diff = until.getTime() - stat.timestamp.getTime();
2336
+ const index = points - 1 - Math.floor(diff / import_koishi6.Time.hour);
2337
+ if (index >= 0 && index < points) {
2338
+ activityVectors.get(stat.uid)[index] += stat.count;
2339
+ }
2340
+ });
2341
+ const selfVector = activityVectors.get(selfUser.uid);
2342
+ if (!selfVector || selfVector.every((v) => v === 0)) return "暂无统计数据";
2343
+ const similarities = guildUserUids.filter((uid) => uid !== selfUser.uid && !activityVectors.get(uid).every((v) => v === 0)).map((uid) => ({ uid, score: cosineSimilarity(selfVector, activityVectors.get(uid)) })).sort((a, b) => b.score - a.score);
2344
+ if (similarities.length === 0) return "暂无相似用户";
2345
+ const top5 = similarities.slice(0, 5);
2346
+ const series = [{ name: uidToNameMap.get(selfUser.uid) || "您", data: selfVector }];
2347
+ top5.forEach((sim) => {
2348
+ const name2 = uidToNameMap.get(sim.uid) || `UID ${sim.uid}`;
2349
+ series.push({ name: `${name2} (${(sim.score * 100).toFixed(1)}%)`, data: activityVectors.get(sim.uid) });
2350
+ });
2351
+ const labels = Array.from({ length: points }, (_, i) => String(new Date(until.getTime() - (points - 1 - i) * import_koishi6.Time.hour).getHours()));
2352
+ const title = `${options.hours}小时相似活跃分析`;
2353
+ const imageGenerator = this.renderer.renderLineChart({ title, time: /* @__PURE__ */ new Date(), series, labels });
2354
+ for await (const buffer of imageGenerator) await session.send(import_koishi6.h.image(buffer, "image/png"));
2355
+ } catch (error) {
2356
+ this.ctx.logger.error("生成作息分析图片失败:", error);
2357
+ return "图片渲染失败";
2358
+ }
2359
+ });
2360
+ }
2264
2361
  }
2265
2362
  };
2266
2363
 
@@ -2296,7 +2393,8 @@ var Config3 = import_koishi7.Schema.intersect([
2296
2393
  import_koishi7.Schema.object({
2297
2394
  enableOriRecord: import_koishi7.Schema.boolean().default(true).description("启用原始记录"),
2298
2395
  cacheRetentionDays: import_koishi7.Schema.number().min(0).default(30).description("记录保留天数"),
2299
- enableWordCloud: import_koishi7.Schema.boolean().default(true).description("启用词云生成")
2396
+ enableWordCloud: import_koishi7.Schema.boolean().default(true).description("启用词云生成"),
2397
+ enableSimilarActivity: import_koishi7.Schema.boolean().default(true).description("启用相似活跃分析")
2300
2398
  }).description("高级分析配置")
2301
2399
  ]);
2302
2400
  async function parseQueryScope(ctx, session, options) {
@@ -2343,7 +2441,7 @@ function apply(ctx, config) {
2343
2441
  new Stat(ctx, config).registerCommands(analyse);
2344
2442
  if (config.enableWhoAt) new WhoAt(ctx, config).registerCommand(analyse);
2345
2443
  if (config.enableDataIO) new Data(ctx).registerCommands(analyse);
2346
- if (config.enableOriRecord && config.enableWordCloud) new Analyse(ctx, config).registerCommands(analyse);
2444
+ if (config.enableWordCloud || config.enableSimilarActivity) new Analyse(ctx, config).registerCommands(analyse);
2347
2445
  }
2348
2446
  __name(apply, "apply");
2349
2447
  // 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": "1.3.2",
4
+ "version": "1.3.4",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
package/readme.md CHANGED
@@ -14,6 +14,7 @@
14
14
  - **活跃度分析** (`activity`):以小时或天为单位,生成直观的周期性活跃度图表,洞察社群活跃时段。
15
15
  - **高级文本分析**:
16
16
  - **词云生成** (`wordcloud`):基于聊天记录,利用 Jieba 分词生成热门话题词云图,快速了解近期热点。
17
+ - **相似活跃分析** (`simiactive`): 分析指定时间内,找出与您作息模式最相似的群友,并通过对比图表直观展示。
17
18
  - **提及追踪** (`whoatme`):轻松查询谁在什么时候因为什么内容提及了您,不再错过重要信息。
18
19
  - **强大的数据管理**:
19
20
  - **备份与恢复** (`.backup`/`.restore`):一键备份所有统计数据至本地,并可随时恢复,保障数据安全。
@@ -39,9 +40,10 @@
39
40
  | :--- | :--- | :--- | :--- |
40
41
  | `cmdstat` | 命令统计 | 查询命令使用情况,展示方式根据选项变化 | `-u`, `-g`, `-a`, `-p`, `-s` |
41
42
  | `msgstat` | 发言统计 | 查询用户发言统计,展示方式根据选项变化 | `-u`, `-g`, `-a`, `-t`, `-s` |
42
- | `rankstat` | 发言排行 | 查询指定时间内的发言排行,展示方式根据选项变化 | `-u`, `-g`, `-a`, `-t`, `-n`, `-o` |
43
+ | `rankstat` | 发言排行 | 查询指定时间内的发言排行 | `-u`, `-g`, `-a`, `-t`, `-n`, `-o` |
43
44
  | `activity` | 活跃统计 | 查询周期性活跃度图表 | `-u`, `-g`, `-a`, `-d`, `-n`, `-o` |
44
45
  | `wordcloud` | 生成词云 | 基于聊天记录生成词云图 | `-u`, `-g`, `-t` |
46
+ | `simiactive`| 作息分析 | 分析并找出与您作息相似的群友 | `-n` |
45
47
  | `whoatme` | 谁提及我 | 查看最近谁提及了您 | (无) |
46
48
  | `analyse.list` | 列出数据 | (管理) 列出已记录的频道和命令 | (无) |
47
49
  | `analyse.backup` | 备份数据 | (管理) 将所有数据备份为本地 JSON 文件 | (无) |
@@ -83,7 +85,7 @@
83
85
  - **`rankstat -u @用户 -g <群组ID>`**: 查询**指定用户**在**指定群组**的发言排行 (按**消息类型**排名)。
84
86
  - **`rankstat -a`**: 查询**全局**发言排行 (按**用户**排名)。
85
87
  - **选项 `-n, --duration <小时数>`**: 指定查询范围的时长,默认为 `24`。
86
- - **选项 `-o, --offset <小时数>`"**: 指定查询结束时间的偏移量(从现在往前推的小时数),默认为 `0`。
88
+ - **选项 `-o, --offset <小时数>`**: 指定查询结束时间的偏移量(从现在往前推的小时数),默认为 `0`。
87
89
  - **选项 `-t, --type <类型>`**: 筛选指定消息类型。
88
90
 
89
91
  #### `activity` (活跃统计)
@@ -95,6 +97,12 @@
95
97
  - **选项 `-o, --offset <数值>`**: 指定查询结束时间的偏移量,默认为 `0`。
96
98
  - **选项 `-d, --days`**: 将 `-n` 和 `-o` 的单位从**小时**切换为**天**。
97
99
 
100
+ #### `simiactive` (相似活跃分析)
101
+
102
+ - **`simiactive`**: 在**当前群组**中,分析最近 **24 小时**内与您作息最相似的群友。
103
+ - **`simiactive -n 48`**: 指定分析最近 **48 小时**的数据。
104
+ - **选项 `-n, --hours <小时数>`**: 指定查询范围的时长,默认为 `24`。
105
+
98
106
  ## 🔧 配置项
99
107
 
100
108
  您可以在插件的配置页面中调整以下选项:
@@ -117,8 +125,9 @@
117
125
  ### 高级分析配置
118
126
 
119
127
  - `enableOriRecord`: **启用原始记录**。是否记录原始消息内容以供词云等功能使用。 (默认: `true`)
120
- - `enableWordCloud`: **启用词云生成**。 (默认: `true`)
121
128
  - `cacheRetentionDays`: **原始记录保留天数**。原始消息记录的保留时长(天),`0` 为永久保留。 (默认: `30`)
129
+ - `enableWordCloud`: **启用词云生成** (依赖`原始记录`)。 (默认: `true`)
130
+ - `enablesimiactive`: **启用相似活跃分析** (依赖`发言排行`或`活跃统计`)。 (默认: `true`)
122
131
 
123
132
  ## 📌 注意事项
124
133