koishi-plugin-chat-analyse 0.2.2 → 0.2.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/CmdStat.d.ts +13 -9
- package/lib/Collector.d.ts +23 -14
- package/lib/Renderer.d.ts +13 -15
- package/lib/index.d.ts +3 -3
- package/lib/index.js +142 -170
- 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
|
@@ -19,44 +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;
|
|
30
|
+
private pendingNameRequests;
|
|
31
|
+
private flushInterval;
|
|
32
32
|
/**
|
|
33
33
|
* @constructor
|
|
34
|
-
* @param ctx {Context} Koishi
|
|
34
|
+
* @param ctx {Context} Koishi 上下文,用于访问框架核心功能。
|
|
35
35
|
*/
|
|
36
36
|
constructor(ctx: Context);
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
* @param session {Session}
|
|
38
|
+
* 核心消息处理器,对消息进行格式化并存入缓冲区。
|
|
39
|
+
* @param session {Session} 消息会话对象。
|
|
40
40
|
*/
|
|
41
41
|
private handleMessage;
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
* @
|
|
45
|
-
*
|
|
46
|
-
* summarizeElementTypes([{type: 'text'}, {type: 'img'}])
|
|
43
|
+
* 汇总消息元素的类型,生成紧凑的类型字符串。
|
|
44
|
+
* @param elements {Element[]} 消息元素数组。
|
|
45
|
+
* @returns {string} 类型汇总字符串,如 `[text][img]`。
|
|
47
46
|
*/
|
|
48
47
|
private summarizeElementTypes;
|
|
49
48
|
/**
|
|
50
|
-
*
|
|
49
|
+
* 清理并格式化消息内容,提取关键信息。
|
|
50
|
+
* @param elements {Element[]} 消息元素数组。
|
|
51
|
+
* @returns {string} 处理后的内容字符串。
|
|
51
52
|
*/
|
|
52
53
|
private sanitizeContent;
|
|
53
54
|
/**
|
|
54
|
-
*
|
|
55
|
+
* 将内存缓冲区的消息批量写入数据库,并处理写入失败的情况。
|
|
55
56
|
*/
|
|
56
57
|
private flushBuffer;
|
|
57
58
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
59
|
+
* 检查用户和群组名称是否需要更新,利用缓存和请求锁机制避免重复调用。
|
|
60
|
+
* @param session {Session} 消息会话对象。
|
|
61
|
+
* @param effectiveId {string} 有效的频道/群组ID。
|
|
60
62
|
*/
|
|
61
63
|
private updateNameIfNeeded;
|
|
64
|
+
/**
|
|
65
|
+
* 异步获取用户和群组的最新名称,并更新到数据库和内存缓存。
|
|
66
|
+
* @param session {Session} 消息会话对象。
|
|
67
|
+
* @param effectiveId {string} 频道/群组ID。
|
|
68
|
+
* @param cacheKey {string} 用于缓存的键。
|
|
69
|
+
*/
|
|
70
|
+
private fetchAndUpdateNames;
|
|
62
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_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,24 +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();
|
|
72
|
+
pendingNameRequests = /* @__PURE__ */ new Map();
|
|
73
|
+
flushInterval;
|
|
77
74
|
/**
|
|
78
|
-
*
|
|
79
|
-
* @param session {Session}
|
|
75
|
+
* 核心消息处理器,对消息进行格式化并存入缓冲区。
|
|
76
|
+
* @param session {Session} 消息会话对象。
|
|
80
77
|
*/
|
|
81
78
|
async handleMessage(session) {
|
|
82
79
|
const { userId, channelId, guildId, content, timestamp, argv, elements } = session;
|
|
83
80
|
const effectiveId = channelId || guildId;
|
|
84
|
-
if (!effectiveId || !userId || !timestamp) return;
|
|
81
|
+
if (!effectiveId || !userId || !timestamp || !content?.trim()) return;
|
|
85
82
|
this.updateNameIfNeeded(session, effectiveId);
|
|
86
83
|
const isCommand = !!argv?.command;
|
|
87
84
|
const type = isCommand ? argv.command.name : this.summarizeElementTypes(elements);
|
|
88
85
|
const finalContent = isCommand ? content : this.sanitizeContent(elements);
|
|
89
|
-
if (!finalContent?.trim()) return;
|
|
90
86
|
this.msgBuffer.push({
|
|
91
87
|
channelId: effectiveId,
|
|
92
88
|
userId,
|
|
@@ -94,38 +90,38 @@ var Collector = class _Collector {
|
|
|
94
90
|
content: finalContent,
|
|
95
91
|
timestamp: new Date(timestamp)
|
|
96
92
|
});
|
|
97
|
-
if (this.msgBuffer.length >= _Collector.BUFFER_THRESHOLD)
|
|
98
|
-
this.flushBuffer();
|
|
99
|
-
}
|
|
93
|
+
if (this.msgBuffer.length >= _Collector.BUFFER_THRESHOLD) await this.flushBuffer();
|
|
100
94
|
}
|
|
101
95
|
/**
|
|
102
|
-
*
|
|
103
|
-
* @
|
|
104
|
-
*
|
|
105
|
-
* summarizeElementTypes([{type: 'text'}, {type: 'img'}])
|
|
96
|
+
* 汇总消息元素的类型,生成紧凑的类型字符串。
|
|
97
|
+
* @param elements {Element[]} 消息元素数组。
|
|
98
|
+
* @returns {string} 类型汇总字符串,如 `[text][img]`。
|
|
106
99
|
*/
|
|
107
100
|
summarizeElementTypes(elements) {
|
|
108
|
-
|
|
101
|
+
const types = new Set(elements.map((e) => `[${e.type}]`));
|
|
102
|
+
return Array.from(types).join("");
|
|
109
103
|
}
|
|
110
104
|
/**
|
|
111
|
-
*
|
|
105
|
+
* 清理并格式化消息内容,提取关键信息。
|
|
106
|
+
* @param elements {Element[]} 消息元素数组。
|
|
107
|
+
* @returns {string} 处理后的内容字符串。
|
|
112
108
|
*/
|
|
113
109
|
sanitizeContent(elements) {
|
|
114
|
-
return elements.map((
|
|
115
|
-
switch (
|
|
110
|
+
return elements.map((e) => {
|
|
111
|
+
switch (e.type) {
|
|
116
112
|
case "text":
|
|
117
|
-
return
|
|
113
|
+
return e.attrs.content;
|
|
118
114
|
case "img":
|
|
119
|
-
return
|
|
115
|
+
return e.attrs.summary === "[动画表情]" ? "[gif]" : "[img]";
|
|
120
116
|
case "at":
|
|
121
|
-
return `[at:${
|
|
117
|
+
return `[at:${e.attrs.id}]`;
|
|
122
118
|
default:
|
|
123
|
-
return `[${
|
|
119
|
+
return `[${e.type}]`;
|
|
124
120
|
}
|
|
125
121
|
}).join("");
|
|
126
122
|
}
|
|
127
123
|
/**
|
|
128
|
-
*
|
|
124
|
+
* 将内存缓冲区的消息批量写入数据库,并处理写入失败的情况。
|
|
129
125
|
*/
|
|
130
126
|
async flushBuffer() {
|
|
131
127
|
if (this.msgBuffer.length === 0) return;
|
|
@@ -139,32 +135,50 @@ var Collector = class _Collector {
|
|
|
139
135
|
}
|
|
140
136
|
}
|
|
141
137
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
138
|
+
* 检查用户和群组名称是否需要更新,利用缓存和请求锁机制避免重复调用。
|
|
139
|
+
* @param session {Session} 消息会话对象。
|
|
140
|
+
* @param effectiveId {string} 有效的频道/群组ID。
|
|
144
141
|
*/
|
|
145
142
|
async updateNameIfNeeded(session, effectiveId) {
|
|
146
|
-
const { userId
|
|
143
|
+
const { userId } = session;
|
|
144
|
+
if (!userId) return;
|
|
147
145
|
const cacheKey = `${effectiveId}:${userId}`;
|
|
148
|
-
const cached = this.nameCache.get(cacheKey);
|
|
149
146
|
const CACHE_EXPIRATION = 24 * 60 * 60 * 1e3;
|
|
147
|
+
if (this.pendingNameRequests.has(cacheKey)) return this.pendingNameRequests.get(cacheKey);
|
|
148
|
+
const cached = this.nameCache.get(cacheKey);
|
|
150
149
|
if (cached && Date.now() - cached.timestamp < CACHE_EXPIRATION) return;
|
|
150
|
+
const promise = this.fetchAndUpdateNames(session, effectiveId, cacheKey);
|
|
151
|
+
this.pendingNameRequests.set(cacheKey, promise);
|
|
152
|
+
promise.finally(() => this.pendingNameRequests.delete(cacheKey));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 异步获取用户和群组的最新名称,并更新到数据库和内存缓存。
|
|
156
|
+
* @param session {Session} 消息会话对象。
|
|
157
|
+
* @param effectiveId {string} 频道/群组ID。
|
|
158
|
+
* @param cacheKey {string} 用于缓存的键。
|
|
159
|
+
*/
|
|
160
|
+
async fetchAndUpdateNames(session, effectiveId, cacheKey) {
|
|
151
161
|
try {
|
|
162
|
+
const { userId, guildId, bot } = session;
|
|
152
163
|
const [guild, member] = await Promise.all([
|
|
153
|
-
bot.getGuild(guildId),
|
|
154
|
-
bot.getGuildMember(guildId, userId)
|
|
164
|
+
guildId ? bot.getGuild(guildId).catch(() => null) : Promise.resolve(null),
|
|
165
|
+
guildId && userId ? bot.getGuildMember(guildId, userId).catch(() => null) : Promise.resolve(null)
|
|
155
166
|
]);
|
|
156
167
|
const channelName = guild?.name;
|
|
157
168
|
const userName = member?.nick || member?.name;
|
|
158
|
-
if (!channelName || !userName)
|
|
159
|
-
|
|
169
|
+
if (!channelName || !userName) {
|
|
170
|
+
this.nameCache.set(cacheKey, { name: null, timestamp: Date.now() });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
await this.ctx.database.upsert("analyse_name", [{
|
|
160
174
|
channelId: effectiveId,
|
|
161
|
-
channelName,
|
|
162
175
|
userId,
|
|
176
|
+
channelName,
|
|
163
177
|
userName
|
|
164
178
|
}]);
|
|
165
179
|
this.nameCache.set(cacheKey, { name: userName, timestamp: Date.now() });
|
|
166
180
|
} catch (error) {
|
|
167
|
-
this.
|
|
181
|
+
this.nameCache.set(cacheKey, { name: null, timestamp: Date.now() });
|
|
168
182
|
}
|
|
169
183
|
}
|
|
170
184
|
};
|
|
@@ -177,7 +191,7 @@ var import_koishi = require("koishi");
|
|
|
177
191
|
var Renderer = class {
|
|
178
192
|
/**
|
|
179
193
|
* @constructor
|
|
180
|
-
* @param ctx {Context} Koishi
|
|
194
|
+
* @param ctx {Context} Koishi 上下文,用于访问 puppeteer 服务。
|
|
181
195
|
*/
|
|
182
196
|
constructor(ctx) {
|
|
183
197
|
this.ctx = ctx;
|
|
@@ -188,87 +202,61 @@ var Renderer = class {
|
|
|
188
202
|
/**
|
|
189
203
|
* 将列表数据渲染为图片。
|
|
190
204
|
* @param data {ListRenderData} 待渲染的列表数据。
|
|
191
|
-
* @param headers {string[]} (可选)
|
|
192
|
-
* @returns {Promise<string | Buffer>} 成功时返回图片 Buffer
|
|
205
|
+
* @param headers {string[]} (可选) 表头文案数组,若不提供则不渲染表头。
|
|
206
|
+
* @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,无数据时返回提示文本。
|
|
193
207
|
*/
|
|
194
208
|
async renderList(data, headers) {
|
|
195
209
|
const htmlContent = this.generateListHtml(data, headers);
|
|
210
|
+
if (!htmlContent) return "暂无数据可供渲染";
|
|
196
211
|
return this.ctx.puppeteer.render(htmlContent);
|
|
197
212
|
}
|
|
198
213
|
/**
|
|
199
|
-
*
|
|
200
|
-
* @param date {Date}
|
|
201
|
-
* @returns {string}
|
|
214
|
+
* 智能格式化日期,提供相对时间(如“刚刚”,“x分钟前”)和绝对日期。
|
|
215
|
+
* @param date {Date} 待格式化的日期对象。
|
|
216
|
+
* @returns {string} 格式化后的日期字符串。
|
|
202
217
|
*/
|
|
203
218
|
formatDate(date) {
|
|
204
219
|
if (!date) return "未知";
|
|
205
|
-
const
|
|
206
|
-
const diff = now - date.getTime();
|
|
220
|
+
const diff = Date.now() - date.getTime();
|
|
207
221
|
if (diff < import_koishi.Time.minute) return "刚刚";
|
|
208
222
|
if (diff < import_koishi.Time.hour) return `${Math.floor(diff / import_koishi.Time.minute)} 分钟前`;
|
|
209
223
|
if (diff < import_koishi.Time.day) return `${Math.floor(diff / import_koishi.Time.hour)} 小时前`;
|
|
210
|
-
|
|
211
|
-
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")}`;
|
|
212
225
|
}
|
|
213
226
|
/**
|
|
214
|
-
*
|
|
215
|
-
* @param data {ListRenderData}
|
|
216
|
-
* @param headers {string[]} (可选)
|
|
217
|
-
* @returns {string | null} 生成的 HTML
|
|
227
|
+
* 根据数据动态生成渲染图片所需的完整 HTML 字符串。
|
|
228
|
+
* @param data {ListRenderData} 列表数据。
|
|
229
|
+
* @param headers {string[]} (可选) 表头数组。
|
|
230
|
+
* @returns {string | null} 生成的 HTML 字符串,若无数据则返回 null。
|
|
218
231
|
*/
|
|
219
232
|
generateListHtml(data, headers) {
|
|
220
233
|
const { title, time, total, list } = data;
|
|
221
|
-
if (!list
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const numDataColumns = list[0].length;
|
|
225
|
-
const headerCells = [];
|
|
226
|
-
for (let i = 0; i < numDataColumns; i++) {
|
|
227
|
-
const headerText = headers[i] || "";
|
|
228
|
-
headerCells.push(`<th>${headerText}</th>`);
|
|
229
|
-
}
|
|
230
|
-
const allHeaders = `<th class="rank-cell">排名</th>${headerCells.join("")}`;
|
|
231
|
-
tableHeadHtml = `<thead><tr>${allHeaders}</tr></thead>`;
|
|
232
|
-
}
|
|
233
|
-
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) => {
|
|
234
237
|
const rank = index + 1;
|
|
235
|
-
|
|
236
|
-
if (rank === 1) rankClass = "rank-gold";
|
|
237
|
-
if (rank === 2) rankClass = "rank-silver";
|
|
238
|
-
if (rank === 3) rankClass = "rank-bronze";
|
|
238
|
+
const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
|
|
239
239
|
const rankCell = `<td class="rank-cell"><span class="rank-badge ${rankClass}">${rank}</span></td>`;
|
|
240
|
-
const dataCells =
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
cellClass += " date-cell";
|
|
245
|
-
content = this.formatDate(cellData);
|
|
246
|
-
} else if (typeof cellData === "number") {
|
|
247
|
-
cellClass += " count-cell";
|
|
248
|
-
content = cellData;
|
|
249
|
-
} else {
|
|
250
|
-
cellClass += " name-cell";
|
|
251
|
-
content = String(cellData);
|
|
252
|
-
}
|
|
253
|
-
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>`;
|
|
254
244
|
}).join("");
|
|
255
245
|
return `<tr>${rankCell}${dataCells}</tr>`;
|
|
256
246
|
}).join("");
|
|
257
|
-
const
|
|
258
|
-
|
|
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
|
+
`;
|
|
259
253
|
const styles = `
|
|
260
254
|
:root {
|
|
261
|
-
--bg-color: #f7f8fa; --card-bg: #ffffff; --text-color: #333;
|
|
262
|
-
--
|
|
263
|
-
--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;
|
|
264
257
|
--gold: #ffc327; --silver: #a8b5c1; --bronze: #d69864;
|
|
265
258
|
}
|
|
266
|
-
body {
|
|
267
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
268
|
-
background: var(--bg-color); margin: 0; padding: 20px;
|
|
269
|
-
width: 700px; box-sizing: border-box;
|
|
270
|
-
-webkit-font-smoothing: antialiased;
|
|
271
|
-
}
|
|
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; }
|
|
272
260
|
.container { background: var(--card-bg); border-radius: 12px; box-shadow: 0 6px 16px rgba(0,0,0,0.08); padding: 24px; }
|
|
273
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; }
|
|
274
262
|
.title-group h1 { font-size: 24px; font-weight: 700; color: var(--header-color); margin: 0; }
|
|
@@ -276,15 +264,14 @@ var Renderer = class {
|
|
|
276
264
|
.meta-group .total-count { font-size: 22px; font-weight: 700; color: var(--accent-color); }
|
|
277
265
|
.meta-group .time-label { font-size: 13px; color: var(--sub-text-color); margin-top: 4px; }
|
|
278
266
|
table { width: 100%; border-collapse: collapse; color: var(--text-color); }
|
|
279
|
-
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; }
|
|
280
268
|
th { font-size: 13px; font-weight: 600; color: var(--sub-text-color); }
|
|
281
|
-
td { font-size: 15px;
|
|
269
|
+
td { font-size: 15px; }
|
|
282
270
|
tr:last-child td { border-bottom: none; }
|
|
283
271
|
.rank-cell { width: 50px; text-align: center; }
|
|
284
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; }
|
|
285
|
-
.rank-gold
|
|
286
|
-
.rank-silver { background-color: var(--silver); color:
|
|
287
|
-
.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); }
|
|
288
275
|
.data-cell { word-break: break-all; }
|
|
289
276
|
.name-cell { font-weight: 600; color: var(--header-color); }
|
|
290
277
|
.count-cell { text-align: right; font-weight: 600; color: var(--accent-color); }
|
|
@@ -292,33 +279,24 @@ var Renderer = class {
|
|
|
292
279
|
`;
|
|
293
280
|
return `
|
|
294
281
|
<!DOCTYPE html><html lang="zh-CN">
|
|
295
|
-
<head><meta charset="UTF-8"><
|
|
282
|
+
<head><meta charset="UTF-8"><title>${title}</title><style>${styles}</style></head>
|
|
296
283
|
<body>
|
|
297
284
|
<div class="container">
|
|
298
285
|
<div class="header">
|
|
299
286
|
<div class="title-group"><h1>${title}</h1></div>
|
|
300
|
-
|
|
287
|
+
${metaInfoHtml}
|
|
301
288
|
</div>
|
|
302
|
-
<table>
|
|
303
|
-
${tableHeadHtml}
|
|
304
|
-
<tbody>${tableRows}</tbody>
|
|
305
|
-
</table>
|
|
289
|
+
<table>${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table>
|
|
306
290
|
</div>
|
|
307
|
-
</body></html
|
|
308
|
-
`;
|
|
291
|
+
</body></html>`;
|
|
309
292
|
}
|
|
310
293
|
};
|
|
311
294
|
|
|
312
295
|
// src/CmdStat.ts
|
|
313
296
|
var CmdStat = class {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
renderer;
|
|
318
|
-
ctx;
|
|
319
|
-
constructor(context) {
|
|
320
|
-
this.ctx = context;
|
|
321
|
-
this.renderer = new Renderer(this.ctx);
|
|
297
|
+
constructor(ctx) {
|
|
298
|
+
this.ctx = ctx;
|
|
299
|
+
this.renderer = new Renderer(ctx);
|
|
322
300
|
this.ctx.model.extend("analyse_cmd", {
|
|
323
301
|
channelId: "string",
|
|
324
302
|
userId: "string",
|
|
@@ -328,9 +306,8 @@ var CmdStat = class {
|
|
|
328
306
|
}, { primary: ["channelId", "userId", "command"] });
|
|
329
307
|
this.ctx.on("command/before-execute", async ({ command, session }) => {
|
|
330
308
|
const { userId, guildId } = session;
|
|
331
|
-
if (!guildId || !userId
|
|
332
|
-
const
|
|
333
|
-
const query = { channelId: guildId, userId, command: commandName };
|
|
309
|
+
if (!guildId || !userId) return;
|
|
310
|
+
const query = { channelId: guildId, userId, command: command.name };
|
|
334
311
|
await this.ctx.database.upsert("analyse_cmd", (row) => [{
|
|
335
312
|
...query,
|
|
336
313
|
count: import_koishi2.$.add(import_koishi2.$.ifNull(row.count, 0), 1),
|
|
@@ -338,38 +315,26 @@ var CmdStat = class {
|
|
|
338
315
|
}]);
|
|
339
316
|
});
|
|
340
317
|
}
|
|
318
|
+
static {
|
|
319
|
+
__name(this, "CmdStat");
|
|
320
|
+
}
|
|
321
|
+
renderer;
|
|
341
322
|
/**
|
|
342
|
-
*
|
|
343
|
-
* @
|
|
323
|
+
* 注册所有相关的子命令到主 `analyse` 命令下。
|
|
324
|
+
* @param analyse {Command} 主 `analyse` 命令实例。
|
|
344
325
|
*/
|
|
345
326
|
registerCommands(analyse) {
|
|
346
|
-
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 }) => {
|
|
347
328
|
const userId = options.user ? import_koishi2.h.select(options.user, "user")[0]?.attrs.id : void 0;
|
|
348
329
|
let guildId = options.guild;
|
|
349
330
|
if (options.guild === "" && !options.user) {
|
|
350
|
-
if (!session.guildId) return "
|
|
331
|
+
if (!session.guildId) return "私聊中请使用 -g <群组ID> 指定群组。";
|
|
351
332
|
guildId = session.guildId;
|
|
352
333
|
}
|
|
353
334
|
try {
|
|
354
335
|
const stats = await this.getCommandStats(guildId, userId);
|
|
355
336
|
if (typeof stats === "string") return stats;
|
|
356
|
-
|
|
357
|
-
const titleParts = [];
|
|
358
|
-
if (userId) {
|
|
359
|
-
const user = await session.bot.getUser(userId).catch(() => null);
|
|
360
|
-
titleParts.push(`用户 ${user?.name || userId}`);
|
|
361
|
-
}
|
|
362
|
-
if (guildId) {
|
|
363
|
-
const guild = await session.bot.getGuild(guildId).catch(() => null);
|
|
364
|
-
titleParts.push(`群组 ${guild?.name || guildId}`);
|
|
365
|
-
}
|
|
366
|
-
if (userId && !guildId) {
|
|
367
|
-
title = `${titleParts[0]}的全局命令统计`;
|
|
368
|
-
} else if (titleParts.length > 0) {
|
|
369
|
-
title = `${titleParts.join("、")}的命令统计`;
|
|
370
|
-
} else {
|
|
371
|
-
title = "全局命令统计";
|
|
372
|
-
}
|
|
337
|
+
const title = await this.generateTitle(session, guildId, userId);
|
|
373
338
|
const renderData = {
|
|
374
339
|
title,
|
|
375
340
|
time: /* @__PURE__ */ new Date(),
|
|
@@ -386,34 +351,41 @@ var CmdStat = class {
|
|
|
386
351
|
});
|
|
387
352
|
}
|
|
388
353
|
/**
|
|
389
|
-
*
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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>} 包含结果列表和总数的对象,或错误/提示信息。
|
|
393
377
|
*/
|
|
394
378
|
async getCommandStats(guildId, userId) {
|
|
395
379
|
const query = {};
|
|
396
380
|
if (guildId) query.channelId = guildId;
|
|
397
381
|
if (userId) query.userId = userId;
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const commandMap = /* @__PURE__ */ new Map();
|
|
406
|
-
for (const record of records) {
|
|
407
|
-
const existing = commandMap.get(record.command) || { count: 0, lastUsed: /* @__PURE__ */ new Date(0) };
|
|
408
|
-
existing.count += record.count;
|
|
409
|
-
if (record.timestamp > existing.lastUsed) {
|
|
410
|
-
existing.lastUsed = record.timestamp;
|
|
411
|
-
}
|
|
412
|
-
commandMap.set(record.command, existing);
|
|
413
|
-
}
|
|
414
|
-
sortedList = Array.from(commandMap.entries()).map(([name2, data]) => ({ name: name2, ...data })).sort((a, b) => b.count - a.count);
|
|
415
|
-
}
|
|
416
|
-
const list = sortedList.map((item) => [item.name, item.count, item.lastUsed]);
|
|
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();
|
|
386
|
+
if (aggregatedStats.length === 0) return "暂无统计数据";
|
|
387
|
+
const totalCount = aggregatedStats.reduce((sum, record) => sum + record.count, 0);
|
|
388
|
+
const list = aggregatedStats.map((item) => [item.command, item.count, item.lastUsed]);
|
|
417
389
|
return { list, total: totalCount };
|
|
418
390
|
}
|
|
419
391
|
};
|
|
@@ -432,8 +404,8 @@ var usage = `
|
|
|
432
404
|
</div>
|
|
433
405
|
`;
|
|
434
406
|
var name = "chat-analyse";
|
|
435
|
-
var Config = import_koishi3.Schema.object({});
|
|
436
407
|
var using = ["database", "puppeteer"];
|
|
408
|
+
var Config = import_koishi3.Schema.object({});
|
|
437
409
|
function apply(ctx) {
|
|
438
410
|
new Collector(ctx);
|
|
439
411
|
const cmd = new CmdStat(ctx);
|