koishi-plugin-chat-analyse 0.2.3 → 0.2.5
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/CmdStat.d.ts +13 -9
- package/lib/Collector.d.ts +20 -13
- package/lib/Renderer.d.ts +13 -15
- package/lib/index.d.ts +3 -3
- package/lib/index.js +121 -165
- package/package.json +1 -1
package/lib/CmdStat.d.ts
CHANGED
|
@@ -13,22 +13,26 @@ declare module 'koishi' {
|
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* @class CmdStat
|
|
16
|
-
* @description
|
|
16
|
+
* @description 提供命令统计服务,处理用户查询并渲染结果。
|
|
17
17
|
*/
|
|
18
18
|
export declare class CmdStat {
|
|
19
|
+
private ctx;
|
|
19
20
|
renderer: Renderer;
|
|
20
|
-
ctx: Context;
|
|
21
|
-
constructor(context: Context);
|
|
21
|
+
constructor(ctx: Context);
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
* @
|
|
23
|
+
* 注册所有相关的子命令到主 `analyse` 命令下。
|
|
24
|
+
* @param analyse {Command} 主 `analyse` 命令实例。
|
|
25
25
|
*/
|
|
26
26
|
registerCommands(analyse: Command): void;
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
* 根据查询参数动态生成图片标题。
|
|
29
|
+
*/
|
|
30
|
+
private generateTitle;
|
|
31
|
+
/**
|
|
32
|
+
* 从数据库获取并聚合命令统计数据。
|
|
33
|
+
* @param guildId {string} (可选) 群组ID。
|
|
34
|
+
* @param userId {string} (可选) 用户ID。
|
|
35
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 包含结果列表和总数的对象,或错误/提示信息。
|
|
32
36
|
*/
|
|
33
37
|
private getCommandStats;
|
|
34
38
|
}
|
package/lib/Collector.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
2
|
declare module 'koishi' {
|
|
3
3
|
interface Tables {
|
|
4
|
-
|
|
4
|
+
analyse_ori_msg: {
|
|
5
5
|
id: number;
|
|
6
6
|
channelId: string;
|
|
7
7
|
userId: string;
|
|
@@ -19,46 +19,53 @@ declare module 'koishi' {
|
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* @class Collector
|
|
22
|
-
* @description
|
|
23
|
-
* 负责初始化数据库表、监听消息,并将处理后的数据高效存入数据库。
|
|
22
|
+
* @description 负责收集、缓冲并持久化消息数据,同时高效缓存用户与群组的名称信息。
|
|
24
23
|
*/
|
|
25
24
|
export declare class Collector {
|
|
26
25
|
private ctx;
|
|
27
26
|
private static readonly FLUSH_INTERVAL;
|
|
28
27
|
private static readonly BUFFER_THRESHOLD;
|
|
29
28
|
private msgBuffer;
|
|
30
|
-
private flushInterval;
|
|
31
29
|
private nameCache;
|
|
32
30
|
private pendingNameRequests;
|
|
31
|
+
private flushInterval;
|
|
33
32
|
/**
|
|
34
33
|
* @constructor
|
|
35
|
-
* @param ctx {Context} Koishi
|
|
34
|
+
* @param ctx {Context} Koishi 上下文,用于访问框架核心功能。
|
|
36
35
|
*/
|
|
37
36
|
constructor(ctx: Context);
|
|
38
37
|
/**
|
|
39
|
-
*
|
|
40
|
-
* @param session {Session}
|
|
38
|
+
* 核心消息处理器,对消息进行格式化并存入缓冲区。
|
|
39
|
+
* @param session {Session} 消息会话对象。
|
|
41
40
|
*/
|
|
42
41
|
private handleMessage;
|
|
43
42
|
/**
|
|
44
|
-
*
|
|
43
|
+
* 汇总消息元素的类型,生成紧凑的类型字符串。
|
|
44
|
+
* @param elements {Element[]} 消息元素数组。
|
|
45
|
+
* @returns {string} 类型汇总字符串,如 `[text][img]`。
|
|
45
46
|
*/
|
|
46
47
|
private summarizeElementTypes;
|
|
47
48
|
/**
|
|
48
|
-
*
|
|
49
|
+
* 清理并格式化消息内容,提取关键信息。
|
|
50
|
+
* @param elements {Element[]} 消息元素数组。
|
|
51
|
+
* @returns {string} 处理后的内容字符串。
|
|
49
52
|
*/
|
|
50
53
|
private sanitizeContent;
|
|
51
54
|
/**
|
|
52
|
-
*
|
|
55
|
+
* 将内存缓冲区的消息批量写入数据库,并处理写入失败的情况。
|
|
53
56
|
*/
|
|
54
57
|
private flushBuffer;
|
|
55
58
|
/**
|
|
56
|
-
*
|
|
59
|
+
* 检查用户和群组名称是否需要更新,利用缓存和请求锁机制避免重复调用。
|
|
60
|
+
* @param session {Session} 消息会话对象。
|
|
61
|
+
* @param effectiveId {string} 有效的频道/群组ID。
|
|
57
62
|
*/
|
|
58
63
|
private updateNameIfNeeded;
|
|
59
64
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
65
|
+
* 异步获取用户和群组的最新名称,并更新到数据库和内存缓存。
|
|
66
|
+
* @param session {Session} 消息会话对象。
|
|
67
|
+
* @param effectiveId {string} 频道/群组ID。
|
|
68
|
+
* @param cacheKey {string} 用于缓存的键。
|
|
62
69
|
*/
|
|
63
70
|
private fetchAndUpdateNames;
|
|
64
71
|
}
|
package/lib/Renderer.d.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* 这是一个灵活的元组类型,可以包含字符串、数字或日期。
|
|
3
|
+
* 定义渲染列表中的单行数据格式。
|
|
5
4
|
* @example ['ping', 150, new Date()]
|
|
6
5
|
*/
|
|
7
6
|
export type RenderListItem = (string | number | Date)[];
|
|
8
7
|
/**
|
|
9
|
-
*
|
|
8
|
+
* 定义渲染图片所需的数据结构。
|
|
10
9
|
*/
|
|
11
10
|
export interface ListRenderData {
|
|
12
11
|
title: string;
|
|
@@ -16,34 +15,33 @@ export interface ListRenderData {
|
|
|
16
15
|
}
|
|
17
16
|
/**
|
|
18
17
|
* @class Renderer
|
|
19
|
-
* @description
|
|
20
|
-
* 这是一个通用的列表渲染器,能够处理任意列数的数据,并根据数据类型智能应用样式。
|
|
18
|
+
* @description 通用列表渲染器,通过 Puppeteer 将数据渲染为包含精美表格的图片。
|
|
21
19
|
*/
|
|
22
20
|
export declare class Renderer {
|
|
23
21
|
private ctx;
|
|
24
22
|
/**
|
|
25
23
|
* @constructor
|
|
26
|
-
* @param ctx {Context} Koishi
|
|
24
|
+
* @param ctx {Context} Koishi 上下文,用于访问 puppeteer 服务。
|
|
27
25
|
*/
|
|
28
26
|
constructor(ctx: Context);
|
|
29
27
|
/**
|
|
30
28
|
* 将列表数据渲染为图片。
|
|
31
29
|
* @param data {ListRenderData} 待渲染的列表数据。
|
|
32
|
-
* @param headers {string[]} (可选)
|
|
33
|
-
* @returns {Promise<string | Buffer>} 成功时返回图片 Buffer
|
|
30
|
+
* @param headers {string[]} (可选) 表头文案数组,若不提供则不渲染表头。
|
|
31
|
+
* @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,无数据时返回提示文本。
|
|
34
32
|
*/
|
|
35
33
|
renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer>;
|
|
36
34
|
/**
|
|
37
|
-
*
|
|
38
|
-
* @param date {Date}
|
|
39
|
-
* @returns {string}
|
|
35
|
+
* 智能格式化日期,提供相对时间(如“刚刚”,“x分钟前”)和绝对日期。
|
|
36
|
+
* @param date {Date} 待格式化的日期对象。
|
|
37
|
+
* @returns {string} 格式化后的日期字符串。
|
|
40
38
|
*/
|
|
41
39
|
private formatDate;
|
|
42
40
|
/**
|
|
43
|
-
*
|
|
44
|
-
* @param data {ListRenderData}
|
|
45
|
-
* @param headers {string[]} (可选)
|
|
46
|
-
* @returns {string | null} 生成的 HTML
|
|
41
|
+
* 根据数据动态生成渲染图片所需的完整 HTML 字符串。
|
|
42
|
+
* @param data {ListRenderData} 列表数据。
|
|
43
|
+
* @param headers {string[]} (可选) 表头数组。
|
|
44
|
+
* @returns {string | null} 生成的 HTML 字符串,若无数据则返回 null。
|
|
47
45
|
*/
|
|
48
46
|
private generateListHtml;
|
|
49
47
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Context, Schema } from 'koishi';
|
|
2
2
|
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";
|
|
3
3
|
export declare const name = "chat-analyse";
|
|
4
|
+
export declare const using: string[];
|
|
4
5
|
export interface Config {
|
|
5
6
|
}
|
|
6
7
|
export declare const Config: Schema<Config>;
|
|
7
|
-
export declare const using: string[];
|
|
8
8
|
/**
|
|
9
|
-
* Koishi
|
|
10
|
-
* @param ctx {Context} Koishi
|
|
9
|
+
* Koishi 插件主入口函数。
|
|
10
|
+
* @param ctx {Context} Koishi 上下文,用于访问和扩展框架功能。
|
|
11
11
|
*/
|
|
12
12
|
export declare function apply(ctx: Context): void;
|
package/lib/index.js
CHANGED
|
@@ -33,33 +33,25 @@ var import_koishi3 = require("koishi");
|
|
|
33
33
|
var Collector = class _Collector {
|
|
34
34
|
/**
|
|
35
35
|
* @constructor
|
|
36
|
-
* @param ctx {Context} Koishi
|
|
36
|
+
* @param ctx {Context} Koishi 上下文,用于访问框架核心功能。
|
|
37
37
|
*/
|
|
38
38
|
constructor(ctx) {
|
|
39
39
|
this.ctx = ctx;
|
|
40
|
-
|
|
40
|
+
ctx.model.extend("analyse_ori_msg", {
|
|
41
41
|
id: "unsigned",
|
|
42
42
|
channelId: "string",
|
|
43
43
|
userId: "string",
|
|
44
44
|
type: "string",
|
|
45
45
|
content: "text",
|
|
46
46
|
timestamp: "timestamp"
|
|
47
|
-
}, {
|
|
48
|
-
|
|
49
|
-
autoInc: true,
|
|
50
|
-
indexes: ["timestamp", "channelId", "userId", "type"]
|
|
51
|
-
});
|
|
52
|
-
this.ctx.model.extend("analyse_name", {
|
|
47
|
+
}, { primary: "id", autoInc: true, indexes: ["timestamp", "channelId", "userId", "type"] });
|
|
48
|
+
ctx.model.extend("analyse_name", {
|
|
53
49
|
channelId: "string",
|
|
54
50
|
channelName: "string",
|
|
55
51
|
userId: "string",
|
|
56
52
|
userName: "string"
|
|
57
|
-
}, {
|
|
58
|
-
|
|
59
|
-
});
|
|
60
|
-
ctx.on("message", (session) => {
|
|
61
|
-
this.handleMessage(session);
|
|
62
|
-
});
|
|
53
|
+
}, { primary: ["channelId", "userId"] });
|
|
54
|
+
ctx.on("message", (session) => this.handleMessage(session));
|
|
63
55
|
this.flushInterval = setInterval(() => this.flushBuffer(), _Collector.FLUSH_INTERVAL);
|
|
64
56
|
ctx.on("dispose", () => {
|
|
65
57
|
clearInterval(this.flushInterval);
|
|
@@ -69,25 +61,28 @@ var Collector = class _Collector {
|
|
|
69
61
|
static {
|
|
70
62
|
__name(this, "Collector");
|
|
71
63
|
}
|
|
64
|
+
// 数据刷新配置
|
|
72
65
|
static FLUSH_INTERVAL = 60 * 1e3;
|
|
66
|
+
// 每分钟刷新一次
|
|
73
67
|
static BUFFER_THRESHOLD = 100;
|
|
68
|
+
// 缓冲区达到100条消息时刷新
|
|
69
|
+
// 消息和名称缓存
|
|
74
70
|
msgBuffer = [];
|
|
75
|
-
flushInterval;
|
|
76
71
|
nameCache = /* @__PURE__ */ new Map();
|
|
77
72
|
pendingNameRequests = /* @__PURE__ */ new Map();
|
|
73
|
+
flushInterval;
|
|
78
74
|
/**
|
|
79
|
-
*
|
|
80
|
-
* @param session {Session}
|
|
75
|
+
* 核心消息处理器,对消息进行格式化并存入缓冲区。
|
|
76
|
+
* @param session {Session} 消息会话对象。
|
|
81
77
|
*/
|
|
82
78
|
async handleMessage(session) {
|
|
83
79
|
const { userId, channelId, guildId, content, timestamp, argv, elements } = session;
|
|
84
80
|
const effectiveId = channelId || guildId;
|
|
85
|
-
if (!effectiveId || !userId || !timestamp) return;
|
|
86
|
-
|
|
81
|
+
if (!effectiveId || !userId || !timestamp || !content?.trim()) return;
|
|
82
|
+
this.updateNameIfNeeded(session, effectiveId);
|
|
87
83
|
const isCommand = !!argv?.command;
|
|
88
84
|
const type = isCommand ? argv.command.name : this.summarizeElementTypes(elements);
|
|
89
85
|
const finalContent = isCommand ? content : this.sanitizeContent(elements);
|
|
90
|
-
if (!finalContent?.trim()) return;
|
|
91
86
|
this.msgBuffer.push({
|
|
92
87
|
channelId: effectiveId,
|
|
93
88
|
userId,
|
|
@@ -95,69 +90,72 @@ var Collector = class _Collector {
|
|
|
95
90
|
content: finalContent,
|
|
96
91
|
timestamp: new Date(timestamp)
|
|
97
92
|
});
|
|
98
|
-
if (this.msgBuffer.length >= _Collector.BUFFER_THRESHOLD)
|
|
99
|
-
this.flushBuffer();
|
|
100
|
-
}
|
|
93
|
+
if (this.msgBuffer.length >= _Collector.BUFFER_THRESHOLD) await this.flushBuffer();
|
|
101
94
|
}
|
|
102
95
|
/**
|
|
103
|
-
*
|
|
96
|
+
* 汇总消息元素的类型,生成紧凑的类型字符串。
|
|
97
|
+
* @param elements {Element[]} 消息元素数组。
|
|
98
|
+
* @returns {string} 类型汇总字符串,如 `[text][img]`。
|
|
104
99
|
*/
|
|
105
100
|
summarizeElementTypes(elements) {
|
|
106
|
-
|
|
101
|
+
const types = new Set(elements.map((e) => `[${e.type}]`));
|
|
102
|
+
return Array.from(types).join("");
|
|
107
103
|
}
|
|
108
104
|
/**
|
|
109
|
-
*
|
|
105
|
+
* 清理并格式化消息内容,提取关键信息。
|
|
106
|
+
* @param elements {Element[]} 消息元素数组。
|
|
107
|
+
* @returns {string} 处理后的内容字符串。
|
|
110
108
|
*/
|
|
111
109
|
sanitizeContent(elements) {
|
|
112
|
-
return elements.map((
|
|
113
|
-
switch (
|
|
110
|
+
return elements.map((e) => {
|
|
111
|
+
switch (e.type) {
|
|
114
112
|
case "text":
|
|
115
|
-
return
|
|
113
|
+
return e.attrs.content;
|
|
116
114
|
case "img":
|
|
117
|
-
return
|
|
115
|
+
return e.attrs.summary === "[动画表情]" ? "[gif]" : "[img]";
|
|
118
116
|
case "at":
|
|
119
|
-
return `[at:${
|
|
117
|
+
return `[at:${e.attrs.id}]`;
|
|
120
118
|
default:
|
|
121
|
-
return `[${
|
|
119
|
+
return `[${e.type}]`;
|
|
122
120
|
}
|
|
123
121
|
}).join("");
|
|
124
122
|
}
|
|
125
123
|
/**
|
|
126
|
-
*
|
|
124
|
+
* 将内存缓冲区的消息批量写入数据库,并处理写入失败的情况。
|
|
127
125
|
*/
|
|
128
126
|
async flushBuffer() {
|
|
129
127
|
if (this.msgBuffer.length === 0) return;
|
|
130
128
|
const bufferToFlush = this.msgBuffer;
|
|
131
129
|
this.msgBuffer = [];
|
|
132
130
|
try {
|
|
133
|
-
await this.ctx.database.upsert("
|
|
131
|
+
await this.ctx.database.upsert("analyse_ori_msg", bufferToFlush);
|
|
134
132
|
} catch (error) {
|
|
135
133
|
this.ctx.logger.error("数据写入失败:", error);
|
|
136
134
|
this.msgBuffer.unshift(...bufferToFlush);
|
|
137
135
|
}
|
|
138
136
|
}
|
|
139
137
|
/**
|
|
140
|
-
*
|
|
138
|
+
* 检查用户和群组名称是否需要更新,利用缓存和请求锁机制避免重复调用。
|
|
139
|
+
* @param session {Session} 消息会话对象。
|
|
140
|
+
* @param effectiveId {string} 有效的频道/群组ID。
|
|
141
141
|
*/
|
|
142
142
|
async updateNameIfNeeded(session, effectiveId) {
|
|
143
143
|
const { userId } = session;
|
|
144
144
|
if (!userId) return;
|
|
145
145
|
const cacheKey = `${effectiveId}:${userId}`;
|
|
146
|
+
const CACHE_EXPIRATION = 24 * 60 * 60 * 1e3;
|
|
146
147
|
if (this.pendingNameRequests.has(cacheKey)) return this.pendingNameRequests.get(cacheKey);
|
|
147
148
|
const cached = this.nameCache.get(cacheKey);
|
|
148
|
-
const CACHE_EXPIRATION = 24 * 60 * 60 * 1e3;
|
|
149
149
|
if (cached && Date.now() - cached.timestamp < CACHE_EXPIRATION) return;
|
|
150
150
|
const promise = this.fetchAndUpdateNames(session, effectiveId, cacheKey);
|
|
151
151
|
this.pendingNameRequests.set(cacheKey, promise);
|
|
152
|
-
|
|
153
|
-
await promise;
|
|
154
|
-
} finally {
|
|
155
|
-
this.pendingNameRequests.delete(cacheKey);
|
|
156
|
-
}
|
|
152
|
+
promise.finally(() => this.pendingNameRequests.delete(cacheKey));
|
|
157
153
|
}
|
|
158
154
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
155
|
+
* 异步获取用户和群组的最新名称,并更新到数据库和内存缓存。
|
|
156
|
+
* @param session {Session} 消息会话对象。
|
|
157
|
+
* @param effectiveId {string} 频道/群组ID。
|
|
158
|
+
* @param cacheKey {string} 用于缓存的键。
|
|
161
159
|
*/
|
|
162
160
|
async fetchAndUpdateNames(session, effectiveId, cacheKey) {
|
|
163
161
|
try {
|
|
@@ -193,7 +191,7 @@ var import_koishi = require("koishi");
|
|
|
193
191
|
var Renderer = class {
|
|
194
192
|
/**
|
|
195
193
|
* @constructor
|
|
196
|
-
* @param ctx {Context} Koishi
|
|
194
|
+
* @param ctx {Context} Koishi 上下文,用于访问 puppeteer 服务。
|
|
197
195
|
*/
|
|
198
196
|
constructor(ctx) {
|
|
199
197
|
this.ctx = ctx;
|
|
@@ -204,87 +202,61 @@ var Renderer = class {
|
|
|
204
202
|
/**
|
|
205
203
|
* 将列表数据渲染为图片。
|
|
206
204
|
* @param data {ListRenderData} 待渲染的列表数据。
|
|
207
|
-
* @param headers {string[]} (可选)
|
|
208
|
-
* @returns {Promise<string | Buffer>} 成功时返回图片 Buffer
|
|
205
|
+
* @param headers {string[]} (可选) 表头文案数组,若不提供则不渲染表头。
|
|
206
|
+
* @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,无数据时返回提示文本。
|
|
209
207
|
*/
|
|
210
208
|
async renderList(data, headers) {
|
|
211
209
|
const htmlContent = this.generateListHtml(data, headers);
|
|
210
|
+
if (!htmlContent) return "暂无数据可供渲染";
|
|
212
211
|
return this.ctx.puppeteer.render(htmlContent);
|
|
213
212
|
}
|
|
214
213
|
/**
|
|
215
|
-
*
|
|
216
|
-
* @param date {Date}
|
|
217
|
-
* @returns {string}
|
|
214
|
+
* 智能格式化日期,提供相对时间(如“刚刚”,“x分钟前”)和绝对日期。
|
|
215
|
+
* @param date {Date} 待格式化的日期对象。
|
|
216
|
+
* @returns {string} 格式化后的日期字符串。
|
|
218
217
|
*/
|
|
219
218
|
formatDate(date) {
|
|
220
219
|
if (!date) return "未知";
|
|
221
|
-
const
|
|
222
|
-
const diff = now - date.getTime();
|
|
220
|
+
const diff = Date.now() - date.getTime();
|
|
223
221
|
if (diff < import_koishi.Time.minute) return "刚刚";
|
|
224
222
|
if (diff < import_koishi.Time.hour) return `${Math.floor(diff / import_koishi.Time.minute)} 分钟前`;
|
|
225
223
|
if (diff < import_koishi.Time.day) return `${Math.floor(diff / import_koishi.Time.hour)} 小时前`;
|
|
226
|
-
|
|
227
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
224
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
228
225
|
}
|
|
229
226
|
/**
|
|
230
|
-
*
|
|
231
|
-
* @param data {ListRenderData}
|
|
232
|
-
* @param headers {string[]} (可选)
|
|
233
|
-
* @returns {string | null} 生成的 HTML
|
|
227
|
+
* 根据数据动态生成渲染图片所需的完整 HTML 字符串。
|
|
228
|
+
* @param data {ListRenderData} 列表数据。
|
|
229
|
+
* @param headers {string[]} (可选) 表头数组。
|
|
230
|
+
* @returns {string | null} 生成的 HTML 字符串,若无数据则返回 null。
|
|
234
231
|
*/
|
|
235
232
|
generateListHtml(data, headers) {
|
|
236
233
|
const { title, time, total, list } = data;
|
|
237
|
-
if (!list
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const numDataColumns = list[0].length;
|
|
241
|
-
const headerCells = [];
|
|
242
|
-
for (let i = 0; i < numDataColumns; i++) {
|
|
243
|
-
const headerText = headers[i] || "";
|
|
244
|
-
headerCells.push(`<th>${headerText}</th>`);
|
|
245
|
-
}
|
|
246
|
-
const allHeaders = `<th class="rank-cell">排名</th>${headerCells.join("")}`;
|
|
247
|
-
tableHeadHtml = `<thead><tr>${allHeaders}</tr></thead>`;
|
|
248
|
-
}
|
|
249
|
-
const tableRows = list.map((rowItems, index) => {
|
|
234
|
+
if (!list?.length) return null;
|
|
235
|
+
const tableHeadHtml = headers?.length > 0 ? `<thead><tr><th class="rank-cell">排名</th>${headers.map((h2) => `<th>${h2}</th>`).join("")}</tr></thead>` : "";
|
|
236
|
+
const tableRowsHtml = list.map((row, index) => {
|
|
250
237
|
const rank = index + 1;
|
|
251
|
-
|
|
252
|
-
if (rank === 1) rankClass = "rank-gold";
|
|
253
|
-
if (rank === 2) rankClass = "rank-silver";
|
|
254
|
-
if (rank === 3) rankClass = "rank-bronze";
|
|
238
|
+
const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
|
|
255
239
|
const rankCell = `<td class="rank-cell"><span class="rank-badge ${rankClass}">${rank}</span></td>`;
|
|
256
|
-
const dataCells =
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
cellClass += " date-cell";
|
|
261
|
-
content = this.formatDate(cellData);
|
|
262
|
-
} else if (typeof cellData === "number") {
|
|
263
|
-
cellClass += " count-cell";
|
|
264
|
-
content = cellData;
|
|
265
|
-
} else {
|
|
266
|
-
cellClass += " name-cell";
|
|
267
|
-
content = String(cellData);
|
|
268
|
-
}
|
|
269
|
-
return `<td class="${cellClass}">${content}</td>`;
|
|
240
|
+
const dataCells = row.map((cell) => {
|
|
241
|
+
if (cell instanceof Date) return `<td class="data-cell date-cell">${this.formatDate(cell)}</td>`;
|
|
242
|
+
if (typeof cell === "number") return `<td class="data-cell count-cell">${cell}</td>`;
|
|
243
|
+
return `<td class="data-cell name-cell">${String(cell)}</td>`;
|
|
270
244
|
}).join("");
|
|
271
245
|
return `<tr>${rankCell}${dataCells}</tr>`;
|
|
272
246
|
}).join("");
|
|
273
|
-
const
|
|
274
|
-
|
|
247
|
+
const metaInfoHtml = `
|
|
248
|
+
<div class="meta-group">
|
|
249
|
+
${total !== void 0 ? `<div class="total-count">总计: ${total}</div>` : ""}
|
|
250
|
+
<div class="time-label">生成于 ${time.toLocaleString("zh-CN", { hour12: false })}</div>
|
|
251
|
+
</div>
|
|
252
|
+
`;
|
|
275
253
|
const styles = `
|
|
276
254
|
:root {
|
|
277
|
-
--bg-color: #f7f8fa; --card-bg: #ffffff; --text-color: #333;
|
|
278
|
-
--
|
|
279
|
-
--border-color: #e4e6eb; --accent-color: #4a6ee0;
|
|
255
|
+
--bg-color: #f7f8fa; --card-bg: #ffffff; --text-color: #333; --header-color: #1f2329;
|
|
256
|
+
--sub-text-color: #646a73; --border-color: #e4e6eb; --accent-color: #4a6ee0;
|
|
280
257
|
--gold: #ffc327; --silver: #a8b5c1; --bronze: #d69864;
|
|
281
258
|
}
|
|
282
|
-
body {
|
|
283
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
284
|
-
background: var(--bg-color); margin: 0; padding: 20px;
|
|
285
|
-
width: 700px; box-sizing: border-box;
|
|
286
|
-
-webkit-font-smoothing: antialiased;
|
|
287
|
-
}
|
|
259
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: var(--bg-color); margin: 0; padding: 20px; width: 700px; box-sizing: border-box; -webkit-font-smoothing: antialiased; }
|
|
288
260
|
.container { background: var(--card-bg); border-radius: 12px; box-shadow: 0 6px 16px rgba(0,0,0,0.08); padding: 24px; }
|
|
289
261
|
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; margin-bottom: 16px; }
|
|
290
262
|
.title-group h1 { font-size: 24px; font-weight: 700; color: var(--header-color); margin: 0; }
|
|
@@ -292,15 +264,14 @@ var Renderer = class {
|
|
|
292
264
|
.meta-group .total-count { font-size: 22px; font-weight: 700; color: var(--accent-color); }
|
|
293
265
|
.meta-group .time-label { font-size: 13px; color: var(--sub-text-color); margin-top: 4px; }
|
|
294
266
|
table { width: 100%; border-collapse: collapse; color: var(--text-color); }
|
|
295
|
-
th, td { padding: 12px 8px; text-align: left; border-bottom: 1px solid var(--border-color); }
|
|
267
|
+
th, td { padding: 12px 8px; text-align: left; border-bottom: 1px solid var(--border-color); vertical-align: middle; }
|
|
296
268
|
th { font-size: 13px; font-weight: 600; color: var(--sub-text-color); }
|
|
297
|
-
td { font-size: 15px;
|
|
269
|
+
td { font-size: 15px; }
|
|
298
270
|
tr:last-child td { border-bottom: none; }
|
|
299
271
|
.rank-cell { width: 50px; text-align: center; }
|
|
300
272
|
.rank-badge { display: inline-block; width: 24px; height: 24px; line-height: 24px; border-radius: 50%; font-weight: 600; font-size: 14px; color: var(--header-color); background-color: #eef0f3; }
|
|
301
|
-
.rank-gold
|
|
302
|
-
.rank-silver { background-color: var(--silver); color:
|
|
303
|
-
.rank-bronze { background-color: var(--bronze); color: #fff; }
|
|
273
|
+
.rank-gold, .rank-silver, .rank-bronze { color: #fff; }
|
|
274
|
+
.rank-gold { background-color: var(--gold); } .rank-silver { background-color: var(--silver); } .rank-bronze { background-color: var(--bronze); }
|
|
304
275
|
.data-cell { word-break: break-all; }
|
|
305
276
|
.name-cell { font-weight: 600; color: var(--header-color); }
|
|
306
277
|
.count-cell { text-align: right; font-weight: 600; color: var(--accent-color); }
|
|
@@ -308,33 +279,24 @@ var Renderer = class {
|
|
|
308
279
|
`;
|
|
309
280
|
return `
|
|
310
281
|
<!DOCTYPE html><html lang="zh-CN">
|
|
311
|
-
<head><meta charset="UTF-8"><
|
|
282
|
+
<head><meta charset="UTF-8"><title>${title}</title><style>${styles}</style></head>
|
|
312
283
|
<body>
|
|
313
284
|
<div class="container">
|
|
314
285
|
<div class="header">
|
|
315
286
|
<div class="title-group"><h1>${title}</h1></div>
|
|
316
|
-
|
|
287
|
+
${metaInfoHtml}
|
|
317
288
|
</div>
|
|
318
|
-
<table>
|
|
319
|
-
${tableHeadHtml}
|
|
320
|
-
<tbody>${tableRows}</tbody>
|
|
321
|
-
</table>
|
|
289
|
+
<table>${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table>
|
|
322
290
|
</div>
|
|
323
|
-
</body></html
|
|
324
|
-
`;
|
|
291
|
+
</body></html>`;
|
|
325
292
|
}
|
|
326
293
|
};
|
|
327
294
|
|
|
328
295
|
// src/CmdStat.ts
|
|
329
296
|
var CmdStat = class {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
renderer;
|
|
334
|
-
ctx;
|
|
335
|
-
constructor(context) {
|
|
336
|
-
this.ctx = context;
|
|
337
|
-
this.renderer = new Renderer(this.ctx);
|
|
297
|
+
constructor(ctx) {
|
|
298
|
+
this.ctx = ctx;
|
|
299
|
+
this.renderer = new Renderer(ctx);
|
|
338
300
|
this.ctx.model.extend("analyse_cmd", {
|
|
339
301
|
channelId: "string",
|
|
340
302
|
userId: "string",
|
|
@@ -345,8 +307,7 @@ var CmdStat = class {
|
|
|
345
307
|
this.ctx.on("command/before-execute", async ({ command, session }) => {
|
|
346
308
|
const { userId, guildId } = session;
|
|
347
309
|
if (!guildId || !userId) return;
|
|
348
|
-
const
|
|
349
|
-
const query = { channelId: guildId, userId, command: commandName };
|
|
310
|
+
const query = { channelId: guildId, userId, command: command.name };
|
|
350
311
|
await this.ctx.database.upsert("analyse_cmd", (row) => [{
|
|
351
312
|
...query,
|
|
352
313
|
count: import_koishi2.$.add(import_koishi2.$.ifNull(row.count, 0), 1),
|
|
@@ -354,38 +315,26 @@ var CmdStat = class {
|
|
|
354
315
|
}]);
|
|
355
316
|
});
|
|
356
317
|
}
|
|
318
|
+
static {
|
|
319
|
+
__name(this, "CmdStat");
|
|
320
|
+
}
|
|
321
|
+
renderer;
|
|
357
322
|
/**
|
|
358
|
-
*
|
|
359
|
-
* @
|
|
323
|
+
* 注册所有相关的子命令到主 `analyse` 命令下。
|
|
324
|
+
* @param analyse {Command} 主 `analyse` 命令实例。
|
|
360
325
|
*/
|
|
361
326
|
registerCommands(analyse) {
|
|
362
|
-
analyse.subcommand(".command", "命令使用统计").option("user", "-u [user:user]
|
|
327
|
+
analyse.subcommand(".command", "命令使用统计").option("user", "-u [user:user] 查看指定用户的统计").option("guild", "-g [guildId:string] 查看指定群组的统计 (默认当前群)").usage("查询命令使用统计。支持按用户、按群组或组合查询。").action(async ({ session, options }) => {
|
|
363
328
|
const userId = options.user ? import_koishi2.h.select(options.user, "user")[0]?.attrs.id : void 0;
|
|
364
329
|
let guildId = options.guild;
|
|
365
330
|
if (options.guild === "" && !options.user) {
|
|
366
|
-
if (!session.guildId) return "
|
|
331
|
+
if (!session.guildId) return "私聊中请使用 -g <群组ID> 指定群组。";
|
|
367
332
|
guildId = session.guildId;
|
|
368
333
|
}
|
|
369
334
|
try {
|
|
370
335
|
const stats = await this.getCommandStats(guildId, userId);
|
|
371
336
|
if (typeof stats === "string") return stats;
|
|
372
|
-
|
|
373
|
-
const titleParts = [];
|
|
374
|
-
if (userId) {
|
|
375
|
-
const user = await session.bot.getUser(userId).catch(() => null);
|
|
376
|
-
titleParts.push(`用户 ${user?.name || userId}`);
|
|
377
|
-
}
|
|
378
|
-
if (guildId) {
|
|
379
|
-
const guild = await session.bot.getGuild(guildId).catch(() => null);
|
|
380
|
-
titleParts.push(`群组 ${guild?.name || guildId}`);
|
|
381
|
-
}
|
|
382
|
-
if (userId && !guildId) {
|
|
383
|
-
title = `${titleParts[0]}的全局命令统计`;
|
|
384
|
-
} else if (titleParts.length > 0) {
|
|
385
|
-
title = `${titleParts.join("、")}的命令统计`;
|
|
386
|
-
} else {
|
|
387
|
-
title = "全局命令统计";
|
|
388
|
-
}
|
|
337
|
+
const title = await this.generateTitle(session, guildId, userId);
|
|
389
338
|
const renderData = {
|
|
390
339
|
title,
|
|
391
340
|
time: /* @__PURE__ */ new Date(),
|
|
@@ -402,34 +351,41 @@ var CmdStat = class {
|
|
|
402
351
|
});
|
|
403
352
|
}
|
|
404
353
|
/**
|
|
405
|
-
*
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
354
|
+
* 根据查询参数动态生成图片标题。
|
|
355
|
+
*/
|
|
356
|
+
async generateTitle(session, guildId, userId) {
|
|
357
|
+
if (userId && guildId) {
|
|
358
|
+
const userName = (await session.bot.getUser(userId).catch(() => null))?.name || userId;
|
|
359
|
+
const guildName = (await session.bot.getGuild(guildId).catch(() => null))?.name || guildId;
|
|
360
|
+
return `${userName} 在 ${guildName} 的命令统计`;
|
|
361
|
+
}
|
|
362
|
+
if (userId) {
|
|
363
|
+
const userName = (await session.bot.getUser(userId).catch(() => null))?.name || userId;
|
|
364
|
+
return `${userName} 的全局命令统计`;
|
|
365
|
+
}
|
|
366
|
+
if (guildId) {
|
|
367
|
+
const guildName = (await session.bot.getGuild(guildId).catch(() => null))?.name || guildId;
|
|
368
|
+
return `${guildName} 的命令统计`;
|
|
369
|
+
}
|
|
370
|
+
return "全局命令统计";
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* 从数据库获取并聚合命令统计数据。
|
|
374
|
+
* @param guildId {string} (可选) 群组ID。
|
|
375
|
+
* @param userId {string} (可选) 用户ID。
|
|
376
|
+
* @returns {Promise<{ list: RenderListItem[], total: number } | string>} 包含结果列表和总数的对象,或错误/提示信息。
|
|
409
377
|
*/
|
|
410
378
|
async getCommandStats(guildId, userId) {
|
|
411
379
|
const query = {};
|
|
412
380
|
if (guildId) query.channelId = guildId;
|
|
413
381
|
if (userId) query.userId = userId;
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const sortedList2 = records.map((r) => ({ name: r.command, count: r.count, lastUsed: r.timestamp })).sort((a, b) => b.count - a.count);
|
|
419
|
-
const list2 = sortedList2.map((item) => [item.name, item.count, item.lastUsed]);
|
|
420
|
-
return { list: list2, total: totalCount2 };
|
|
421
|
-
}
|
|
422
|
-
const aggregatedStats = await this.ctx.database.select("analyse_cmd", query).groupBy(
|
|
423
|
-
["command"],
|
|
424
|
-
{
|
|
425
|
-
count: /* @__PURE__ */ __name((row) => import_koishi2.$.sum(row.count), "count"),
|
|
426
|
-
lastUsed: /* @__PURE__ */ __name((row) => import_koishi2.$.max(row.timestamp), "lastUsed")
|
|
427
|
-
}
|
|
428
|
-
).execute();
|
|
382
|
+
const aggregatedStats = await this.ctx.database.select("analyse_cmd", query).groupBy(["command"], {
|
|
383
|
+
count: /* @__PURE__ */ __name((row) => import_koishi2.$.sum(row.count), "count"),
|
|
384
|
+
lastUsed: /* @__PURE__ */ __name((row) => import_koishi2.$.max(row.timestamp), "lastUsed")
|
|
385
|
+
}).orderBy("count", "desc").execute();
|
|
429
386
|
if (aggregatedStats.length === 0) return "暂无统计数据";
|
|
430
387
|
const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
|
|
431
|
-
const
|
|
432
|
-
const list = sortedList.map((item) => [item.command, item.count, item.lastUsed]);
|
|
388
|
+
const list = aggregatedStats.map((item) => [item.command, item.count, item.lastUsed]);
|
|
433
389
|
return { list, total: totalCount };
|
|
434
390
|
}
|
|
435
391
|
};
|
|
@@ -448,8 +404,8 @@ var usage = `
|
|
|
448
404
|
</div>
|
|
449
405
|
`;
|
|
450
406
|
var name = "chat-analyse";
|
|
451
|
-
var Config = import_koishi3.Schema.object({});
|
|
452
407
|
var using = ["database", "puppeteer"];
|
|
408
|
+
var Config = import_koishi3.Schema.object({});
|
|
453
409
|
function apply(ctx) {
|
|
454
410
|
new Collector(ctx);
|
|
455
411
|
const cmd = new CmdStat(ctx);
|