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.
- package/lib/Collector.d.ts +0 -2
- package/lib/Data.d.ts +17 -0
- package/lib/Renderer.d.ts +44 -16
- package/lib/Stat.d.ts +37 -0
- package/lib/WhoAt.d.ts +21 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.js +330 -172
- package/package.json +1 -1
package/lib/Collector.d.ts
CHANGED
|
@@ -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:
|
|
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
|
|
24
|
+
* @description 负责将结构化的数据渲染为设计精美的 PNG 图片。
|
|
17
25
|
*/
|
|
18
26
|
export declare class Renderer {
|
|
19
27
|
private ctx;
|
|
28
|
+
private readonly COMMON_STYLE;
|
|
20
29
|
/**
|
|
21
|
-
* @
|
|
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
|
|
28
|
-
* @param fullHtmlContent -
|
|
29
|
-
* @returns
|
|
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 将
|
|
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
|
|
44
|
-
* @param data -
|
|
45
|
-
* @param headers -
|
|
46
|
-
* @returns
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
* @
|
|
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
|
|
226
|
-
* @param fullHtmlContent -
|
|
227
|
-
* @returns
|
|
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:
|
|
304
|
+
await page.setViewport({ width: 720, height: 10, deviceScaleFactor: 2 });
|
|
233
305
|
await page.setContent(fullHtmlContent, { waitUntil: "networkidle0" });
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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 将
|
|
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")
|
|
256
|
-
const
|
|
257
|
-
for (const [unit, ms] of
|
|
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
|
|
268
|
-
* @param data -
|
|
269
|
-
* @param headers -
|
|
270
|
-
* @returns
|
|
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
|
-
|
|
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
|
|
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 =
|
|
295
|
-
const
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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("
|
|
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
|
|
345
|
-
* @param
|
|
485
|
+
* @description 根据配置,动态地将子命令注册到主命令下。
|
|
486
|
+
* @param cmd - 主命令实例。
|
|
346
487
|
*/
|
|
347
|
-
registerCommands(
|
|
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)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
const
|
|
381
|
-
if (
|
|
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(
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
404
|
-
if (type)
|
|
405
|
-
const
|
|
406
|
-
if (
|
|
407
|
-
|
|
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(
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
scopeText = scopeDesc.guildId ? `${userName} 在 ${scopeText}` : `${userName} 的全局`;
|
|
597
|
+
userName = user?.userName || scopeDesc.userId;
|
|
464
598
|
}
|
|
465
599
|
const typeText = options.subtype ? `“${options.subtype}”` : "";
|
|
466
|
-
|
|
467
|
-
|
|
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("
|
|
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
|
|
494
|
-
* @param
|
|
634
|
+
* @description 在主命令下注册子命令。
|
|
635
|
+
* @param cmd - 主命令实例。
|
|
495
636
|
*/
|
|
496
|
-
registerCommand(
|
|
497
|
-
|
|
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)
|
|
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("
|
|
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
|
|
543
|
-
* @param
|
|
683
|
+
* @description 在主命令下注册所有数据管理相关的子命令。
|
|
684
|
+
* @param cmd - 主命令实例。
|
|
544
685
|
*/
|
|
545
|
-
registerCommands(
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
617
|
-
|
|
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(
|
|
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
|
|
629
|
-
|
|
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
|
-
|
|
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").
|
|
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 ? "
|
|
642
|
-
const commandOutput = commands.length ? "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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.
|
|
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:
|