koishi-plugin-chat-analyse 0.1.2 → 0.2.1
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 +34 -0
- package/lib/Collector.d.ts +61 -0
- package/lib/Renderer.d.ts +49 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +354 -71
- package/package.json +15 -4
- package/lib/collector.d.ts +0 -63
package/lib/CmdStat.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Context, Command } from 'koishi';
|
|
2
|
+
import { Renderer } from './Renderer';
|
|
3
|
+
declare module 'koishi' {
|
|
4
|
+
interface Tables {
|
|
5
|
+
analyse_cmd: {
|
|
6
|
+
channelId: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
command: string;
|
|
9
|
+
count: number;
|
|
10
|
+
timestamp: Date;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* @class CmdStat
|
|
16
|
+
* @description 负责提供命令执行所需的服务,并管理插件的核心逻辑。
|
|
17
|
+
*/
|
|
18
|
+
export declare class CmdStat {
|
|
19
|
+
renderer: Renderer;
|
|
20
|
+
ctx: Context;
|
|
21
|
+
constructor(context: Context);
|
|
22
|
+
/**
|
|
23
|
+
* @method registerCommands
|
|
24
|
+
* @description 注册命令,并通过选项支持不同维度的查询。
|
|
25
|
+
*/
|
|
26
|
+
registerCommands(analyse: Command): void;
|
|
27
|
+
/**
|
|
28
|
+
* @private
|
|
29
|
+
* @method getCommandStats
|
|
30
|
+
* @description 从数据库获取并处理命令统计数据,兼容全局、群组和个人查询。
|
|
31
|
+
* @returns 返回一个包含二维数组列表和总数的对象,或错误字符串。
|
|
32
|
+
*/
|
|
33
|
+
private getCommandStats;
|
|
34
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
declare module 'koishi' {
|
|
3
|
+
interface Tables {
|
|
4
|
+
analyse_msg: {
|
|
5
|
+
channelId: string;
|
|
6
|
+
userId: string;
|
|
7
|
+
type: string;
|
|
8
|
+
content: string;
|
|
9
|
+
timestamp: Date;
|
|
10
|
+
};
|
|
11
|
+
analyse_name: {
|
|
12
|
+
channelId: string;
|
|
13
|
+
channelName: string;
|
|
14
|
+
userId: string;
|
|
15
|
+
userName: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* @class Collector
|
|
21
|
+
* @description
|
|
22
|
+
* 负责初始化数据库表、监听消息,并将处理后的数据高效存入数据库。
|
|
23
|
+
*/
|
|
24
|
+
export declare class Collector {
|
|
25
|
+
private ctx;
|
|
26
|
+
private static readonly FLUSH_INTERVAL;
|
|
27
|
+
private static readonly BUFFER_THRESHOLD;
|
|
28
|
+
private msgBuffer;
|
|
29
|
+
private flushInterval;
|
|
30
|
+
private nameCache;
|
|
31
|
+
/**
|
|
32
|
+
* @constructor
|
|
33
|
+
* @param ctx {Context} Koishi 的上下文对象
|
|
34
|
+
*/
|
|
35
|
+
constructor(ctx: Context);
|
|
36
|
+
/**
|
|
37
|
+
* 核心消息处理函数。
|
|
38
|
+
* @param session {Session} 消息会话对象
|
|
39
|
+
*/
|
|
40
|
+
private handleMessage;
|
|
41
|
+
/**
|
|
42
|
+
* 从消息元素中提取并汇总类型。
|
|
43
|
+
* @example
|
|
44
|
+
* // returns "[text][img]"
|
|
45
|
+
* summarizeElementTypes([{type: 'text'}, {type: 'img'}])
|
|
46
|
+
*/
|
|
47
|
+
private summarizeElementTypes;
|
|
48
|
+
/**
|
|
49
|
+
* 从消息元素中提取文本化、安全的内容。
|
|
50
|
+
*/
|
|
51
|
+
private sanitizeContent;
|
|
52
|
+
/**
|
|
53
|
+
* 将内存缓冲区的消息数据批量写入数据库。
|
|
54
|
+
*/
|
|
55
|
+
private flushBuffer;
|
|
56
|
+
/**
|
|
57
|
+
* 检查并更新用户和群组的名称信息。
|
|
58
|
+
* 如果名称不在缓存或缓存已过期 (超过24小时),则从 API 获取并更新到数据库。
|
|
59
|
+
*/
|
|
60
|
+
private updateNameIfNeeded;
|
|
61
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
/**
|
|
3
|
+
* 渲染列表中的一行数据。每个元素代表一列。
|
|
4
|
+
* 这是一个灵活的元组类型,可以包含字符串、数字或日期。
|
|
5
|
+
* @example ['ping', 150, new Date()]
|
|
6
|
+
*/
|
|
7
|
+
export type RenderListItem = (string | number | Date)[];
|
|
8
|
+
/**
|
|
9
|
+
* 渲染列表图片所需的数据结构。
|
|
10
|
+
*/
|
|
11
|
+
export interface ListRenderData {
|
|
12
|
+
title: string;
|
|
13
|
+
time: Date;
|
|
14
|
+
total?: string | number;
|
|
15
|
+
list: RenderListItem[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* @class Renderer
|
|
19
|
+
* @description 使用 Puppeteer 服务将格式化的数据渲染成图片。
|
|
20
|
+
* 这是一个通用的列表渲染器,能够处理任意列数的数据,并根据数据类型智能应用样式。
|
|
21
|
+
*/
|
|
22
|
+
export declare class Renderer {
|
|
23
|
+
private ctx;
|
|
24
|
+
/**
|
|
25
|
+
* @constructor
|
|
26
|
+
* @param ctx {Context} Koishi 的上下文对象
|
|
27
|
+
*/
|
|
28
|
+
constructor(ctx: Context);
|
|
29
|
+
/**
|
|
30
|
+
* 将列表数据渲染为图片。
|
|
31
|
+
* @param data {ListRenderData} 待渲染的列表数据。
|
|
32
|
+
* @param headers {string[]} (可选) 表头数组。如果不提供或为空,则不渲染表头部分。
|
|
33
|
+
* @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,失败或无数据时返回提示文本。
|
|
34
|
+
*/
|
|
35
|
+
renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer>;
|
|
36
|
+
/**
|
|
37
|
+
* 格式化日期,提供相对时间和绝对时间显示。
|
|
38
|
+
* @param date {Date} 日期对象
|
|
39
|
+
* @returns {string} 格式化后的日期字符串
|
|
40
|
+
*/
|
|
41
|
+
private formatDate;
|
|
42
|
+
/**
|
|
43
|
+
* 根据列表数据动态生成 HTML 字符串。
|
|
44
|
+
* @param data {ListRenderData} 列表数据
|
|
45
|
+
* @param headers {string[]} (可选) 表头数组
|
|
46
|
+
* @returns {string | null} 生成的 HTML 字符串,如果无数据则返回 null。
|
|
47
|
+
*/
|
|
48
|
+
private generateListHtml;
|
|
49
|
+
}
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -27,71 +27,91 @@ __export(src_exports, {
|
|
|
27
27
|
using: () => using
|
|
28
28
|
});
|
|
29
29
|
module.exports = __toCommonJS(src_exports);
|
|
30
|
-
var
|
|
30
|
+
var import_koishi3 = require("koishi");
|
|
31
31
|
|
|
32
|
-
// src/
|
|
33
|
-
var Collector = class {
|
|
32
|
+
// src/Collector.ts
|
|
33
|
+
var Collector = class _Collector {
|
|
34
34
|
/**
|
|
35
35
|
* @constructor
|
|
36
|
-
* @param {Context}
|
|
36
|
+
* @param ctx {Context} Koishi 的上下文对象
|
|
37
37
|
*/
|
|
38
38
|
constructor(ctx) {
|
|
39
39
|
this.ctx = ctx;
|
|
40
|
-
this.defineModels();
|
|
41
|
-
ctx.on("message", async (session) => {
|
|
42
|
-
const { userId, channelId, guildId, content, timestamp, argv, elements } = session;
|
|
43
|
-
const effectiveId = channelId || guildId;
|
|
44
|
-
if (!effectiveId || !userId) return;
|
|
45
|
-
this.updateNameIfNeeded(session, effectiveId);
|
|
46
|
-
const isCommand = !!argv?.command;
|
|
47
|
-
const type = isCommand ? argv.command.name : [...new Set(elements.map((e) => `[${e.type}]`))].join("");
|
|
48
|
-
const finalContent = isCommand ? content : this.sanitizeContent(elements);
|
|
49
|
-
if (!finalContent?.trim()) return;
|
|
50
|
-
await ctx.database.create("analyse_msg", {
|
|
51
|
-
channelId: effectiveId,
|
|
52
|
-
userId,
|
|
53
|
-
type,
|
|
54
|
-
content: finalContent,
|
|
55
|
-
timestamp: new Date(timestamp)
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
static {
|
|
60
|
-
__name(this, "Collector");
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* @property {Map<string, { name: string, timestamp: number }>} nameCache
|
|
64
|
-
* @description 用户名缓存,键为“频道ID:用户ID”,值为名称和时间戳。
|
|
65
|
-
*/
|
|
66
|
-
nameCache = /* @__PURE__ */ new Map();
|
|
67
|
-
/**
|
|
68
|
-
* @private
|
|
69
|
-
* @method defineModels
|
|
70
|
-
* @description 初始化插件所需的两个数据表模型。
|
|
71
|
-
*/
|
|
72
|
-
defineModels() {
|
|
73
40
|
this.ctx.model.extend("analyse_msg", {
|
|
74
|
-
id: "unsigned",
|
|
75
41
|
channelId: "string",
|
|
76
42
|
userId: "string",
|
|
77
43
|
type: "string",
|
|
78
44
|
content: "text",
|
|
79
45
|
timestamp: "timestamp"
|
|
80
|
-
}, {
|
|
46
|
+
}, {
|
|
47
|
+
primary: ["channelId", "userId", "timestamp"],
|
|
48
|
+
indexes: ["type"]
|
|
49
|
+
});
|
|
81
50
|
this.ctx.model.extend("analyse_name", {
|
|
82
|
-
id: "unsigned",
|
|
83
51
|
channelId: "string",
|
|
84
52
|
channelName: "string",
|
|
85
53
|
userId: "string",
|
|
86
54
|
userName: "string"
|
|
87
|
-
}, {
|
|
55
|
+
}, {
|
|
56
|
+
primary: ["channelId", "userId"]
|
|
57
|
+
});
|
|
58
|
+
ctx.on("message", (session) => {
|
|
59
|
+
this.handleMessage(session);
|
|
60
|
+
});
|
|
61
|
+
this.flushInterval = setInterval(() => this.flushBuffer(), _Collector.FLUSH_INTERVAL);
|
|
62
|
+
ctx.on("dispose", () => {
|
|
63
|
+
clearInterval(this.flushInterval);
|
|
64
|
+
this.flushBuffer();
|
|
65
|
+
});
|
|
88
66
|
}
|
|
67
|
+
static {
|
|
68
|
+
__name(this, "Collector");
|
|
69
|
+
}
|
|
70
|
+
// 每隔 1 分钟将缓冲区数据写入数据库
|
|
71
|
+
static FLUSH_INTERVAL = 60 * 1e3;
|
|
72
|
+
// 当缓冲区数据达到 100 条时,立即写入数据库
|
|
73
|
+
static BUFFER_THRESHOLD = 100;
|
|
74
|
+
// 消息数据写入缓冲区
|
|
75
|
+
msgBuffer = [];
|
|
76
|
+
// 定时器,用于周期性地清空缓冲区
|
|
77
|
+
flushInterval;
|
|
78
|
+
// 名称缓存,减少不必要的 API 请求 (key: 'channelId:userId')
|
|
79
|
+
nameCache = /* @__PURE__ */ new Map();
|
|
89
80
|
/**
|
|
90
|
-
*
|
|
91
|
-
* @
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
81
|
+
* 核心消息处理函数。
|
|
82
|
+
* @param session {Session} 消息会话对象
|
|
83
|
+
*/
|
|
84
|
+
async handleMessage(session) {
|
|
85
|
+
const { userId, channelId, guildId, content, timestamp, argv, elements } = session;
|
|
86
|
+
const effectiveId = channelId || guildId;
|
|
87
|
+
if (!effectiveId || !userId) return;
|
|
88
|
+
this.updateNameIfNeeded(session, effectiveId);
|
|
89
|
+
const isCommand = !!argv?.command;
|
|
90
|
+
const type = isCommand ? argv.command.name : this.summarizeElementTypes(elements);
|
|
91
|
+
const finalContent = isCommand ? content : this.sanitizeContent(elements);
|
|
92
|
+
if (!finalContent?.trim()) return;
|
|
93
|
+
this.msgBuffer.push({
|
|
94
|
+
channelId: effectiveId,
|
|
95
|
+
userId,
|
|
96
|
+
type,
|
|
97
|
+
content: finalContent,
|
|
98
|
+
timestamp: new Date(timestamp)
|
|
99
|
+
});
|
|
100
|
+
if (this.msgBuffer.length >= _Collector.BUFFER_THRESHOLD) {
|
|
101
|
+
this.flushBuffer();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 从消息元素中提取并汇总类型。
|
|
106
|
+
* @example
|
|
107
|
+
* // returns "[text][img]"
|
|
108
|
+
* summarizeElementTypes([{type: 'text'}, {type: 'img'}])
|
|
109
|
+
*/
|
|
110
|
+
summarizeElementTypes(elements) {
|
|
111
|
+
return [...new Set(elements.map((e) => `[${e.type}]`))].join("");
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 从消息元素中提取文本化、安全的内容。
|
|
95
115
|
*/
|
|
96
116
|
sanitizeContent(elements) {
|
|
97
117
|
return elements.map((element) => {
|
|
@@ -108,36 +128,296 @@ var Collector = class {
|
|
|
108
128
|
}).join("");
|
|
109
129
|
}
|
|
110
130
|
/**
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
* 将内存缓冲区的消息数据批量写入数据库。
|
|
132
|
+
*/
|
|
133
|
+
async flushBuffer() {
|
|
134
|
+
if (this.msgBuffer.length === 0) return;
|
|
135
|
+
const bufferToFlush = this.msgBuffer;
|
|
136
|
+
this.msgBuffer = [];
|
|
137
|
+
try {
|
|
138
|
+
await this.ctx.database.create("analyse_msg", bufferToFlush);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
this.ctx.logger.error("数据库写入失败:", error);
|
|
141
|
+
this.msgBuffer.unshift(...bufferToFlush);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 检查并更新用户和群组的名称信息。
|
|
146
|
+
* 如果名称不在缓存或缓存已过期 (超过24小时),则从 API 获取并更新到数据库。
|
|
116
147
|
*/
|
|
117
148
|
async updateNameIfNeeded(session, effectiveId) {
|
|
118
149
|
const { userId, guildId, bot } = session;
|
|
119
150
|
const cacheKey = `${effectiveId}:${userId}`;
|
|
120
151
|
const cached = this.nameCache.get(cacheKey);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
const CACHE_EXPIRATION = 24 * 60 * 60 * 1e3;
|
|
153
|
+
if (cached && Date.now() - cached.timestamp < CACHE_EXPIRATION) return;
|
|
154
|
+
try {
|
|
155
|
+
const [guild, member] = await Promise.all([
|
|
156
|
+
bot.getGuild(guildId),
|
|
157
|
+
bot.getGuildMember(guildId, userId)
|
|
158
|
+
]);
|
|
159
|
+
const channelName = guild?.name;
|
|
160
|
+
const userName = member?.nick || member?.name;
|
|
161
|
+
if (!channelName || !userName) return;
|
|
162
|
+
await this.ctx.database.upsert("analyse_name", [{
|
|
163
|
+
channelId: effectiveId,
|
|
164
|
+
channelName,
|
|
165
|
+
userId,
|
|
166
|
+
userName
|
|
167
|
+
}]);
|
|
131
168
|
this.nameCache.set(cacheKey, { name: userName, timestamp: Date.now() });
|
|
132
|
-
|
|
169
|
+
} catch (error) {
|
|
170
|
+
this.ctx.logger.warn("更新用户/群组名称失败:", error);
|
|
133
171
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// src/CmdStat.ts
|
|
176
|
+
var import_koishi2 = require("koishi");
|
|
177
|
+
|
|
178
|
+
// src/Renderer.ts
|
|
179
|
+
var import_koishi = require("koishi");
|
|
180
|
+
var Renderer = class {
|
|
181
|
+
/**
|
|
182
|
+
* @constructor
|
|
183
|
+
* @param ctx {Context} Koishi 的上下文对象
|
|
184
|
+
*/
|
|
185
|
+
constructor(ctx) {
|
|
186
|
+
this.ctx = ctx;
|
|
187
|
+
}
|
|
188
|
+
static {
|
|
189
|
+
__name(this, "Renderer");
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 将列表数据渲染为图片。
|
|
193
|
+
* @param data {ListRenderData} 待渲染的列表数据。
|
|
194
|
+
* @param headers {string[]} (可选) 表头数组。如果不提供或为空,则不渲染表头部分。
|
|
195
|
+
* @returns {Promise<string | Buffer>} 成功时返回图片 Buffer,失败或无数据时返回提示文本。
|
|
196
|
+
*/
|
|
197
|
+
async renderList(data, headers) {
|
|
198
|
+
const htmlContent = this.generateListHtml(data, headers);
|
|
199
|
+
return this.ctx.puppeteer.render(htmlContent);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* 格式化日期,提供相对时间和绝对时间显示。
|
|
203
|
+
* @param date {Date} 日期对象
|
|
204
|
+
* @returns {string} 格式化后的日期字符串
|
|
205
|
+
*/
|
|
206
|
+
formatDate(date) {
|
|
207
|
+
if (!date) return "未知";
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
const diff = now - date.getTime();
|
|
210
|
+
if (diff < import_koishi.Time.minute) return "刚刚";
|
|
211
|
+
if (diff < import_koishi.Time.hour) return `${Math.floor(diff / import_koishi.Time.minute)} 分钟前`;
|
|
212
|
+
if (diff < import_koishi.Time.day) return `${Math.floor(diff / import_koishi.Time.hour)} 小时前`;
|
|
213
|
+
const pad = /* @__PURE__ */ __name((n) => n.toString().padStart(2, "0"), "pad");
|
|
214
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* 根据列表数据动态生成 HTML 字符串。
|
|
218
|
+
* @param data {ListRenderData} 列表数据
|
|
219
|
+
* @param headers {string[]} (可选) 表头数组
|
|
220
|
+
* @returns {string | null} 生成的 HTML 字符串,如果无数据则返回 null。
|
|
221
|
+
*/
|
|
222
|
+
generateListHtml(data, headers) {
|
|
223
|
+
const { title, time, total, list } = data;
|
|
224
|
+
if (!list || list.length === 0) return null;
|
|
225
|
+
let tableHeadHtml = "";
|
|
226
|
+
if (headers && headers.length > 0) {
|
|
227
|
+
const numDataColumns = list[0].length;
|
|
228
|
+
const headerCells = [];
|
|
229
|
+
for (let i = 0; i < numDataColumns; i++) {
|
|
230
|
+
const headerText = headers[i] || "";
|
|
231
|
+
headerCells.push(`<th>${headerText}</th>`);
|
|
232
|
+
}
|
|
233
|
+
const allHeaders = `<th class="rank-cell">排名</th>${headerCells.join("")}`;
|
|
234
|
+
tableHeadHtml = `<thead><tr>${allHeaders}</tr></thead>`;
|
|
235
|
+
}
|
|
236
|
+
const tableRows = list.map((rowItems, index) => {
|
|
237
|
+
const rank = index + 1;
|
|
238
|
+
let rankClass = "";
|
|
239
|
+
if (rank === 1) rankClass = "rank-gold";
|
|
240
|
+
if (rank === 2) rankClass = "rank-silver";
|
|
241
|
+
if (rank === 3) rankClass = "rank-bronze";
|
|
242
|
+
const rankCell = `<td class="rank-cell"><span class="rank-badge ${rankClass}">${rank}</span></td>`;
|
|
243
|
+
const dataCells = rowItems.map((cellData) => {
|
|
244
|
+
let cellClass = "data-cell";
|
|
245
|
+
let content;
|
|
246
|
+
if (cellData instanceof Date) {
|
|
247
|
+
cellClass += " date-cell";
|
|
248
|
+
content = this.formatDate(cellData);
|
|
249
|
+
} else if (typeof cellData === "number") {
|
|
250
|
+
cellClass += " count-cell";
|
|
251
|
+
content = cellData;
|
|
252
|
+
} else {
|
|
253
|
+
cellClass += " name-cell";
|
|
254
|
+
content = String(cellData);
|
|
255
|
+
}
|
|
256
|
+
return `<td class="${cellClass}">${content}</td>`;
|
|
257
|
+
}).join("");
|
|
258
|
+
return `<tr>${rankCell}${dataCells}</tr>`;
|
|
259
|
+
}).join("");
|
|
260
|
+
const metaInfo = total !== void 0 ? `<div class="total-count">总计: ${total}</div>` : "";
|
|
261
|
+
const timeLabel = `生成于 ${time.getFullYear()}-${String(time.getMonth() + 1).padStart(2, "0")}-${String(time.getDate()).padStart(2, "0")} ${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
|
|
262
|
+
const styles = `
|
|
263
|
+
:root {
|
|
264
|
+
--bg-color: #f7f8fa; --card-bg: #ffffff; --text-color: #333;
|
|
265
|
+
--header-color: #1f2329; --sub-text-color: #646a73;
|
|
266
|
+
--border-color: #e4e6eb; --accent-color: #4a6ee0;
|
|
267
|
+
--gold: #ffc327; --silver: #a8b5c1; --bronze: #d69864;
|
|
268
|
+
}
|
|
269
|
+
body {
|
|
270
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
271
|
+
background: var(--bg-color); margin: 0; padding: 20px;
|
|
272
|
+
width: 700px; box-sizing: border-box;
|
|
273
|
+
-webkit-font-smoothing: antialiased;
|
|
274
|
+
}
|
|
275
|
+
.container { background: var(--card-bg); border-radius: 12px; box-shadow: 0 6px 16px rgba(0,0,0,0.08); padding: 24px; }
|
|
276
|
+
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; margin-bottom: 16px; }
|
|
277
|
+
.title-group h1 { font-size: 24px; font-weight: 700; color: var(--header-color); margin: 0; }
|
|
278
|
+
.meta-group { text-align: right; }
|
|
279
|
+
.meta-group .total-count { font-size: 22px; font-weight: 700; color: var(--accent-color); }
|
|
280
|
+
.meta-group .time-label { font-size: 13px; color: var(--sub-text-color); margin-top: 4px; }
|
|
281
|
+
table { width: 100%; border-collapse: collapse; color: var(--text-color); }
|
|
282
|
+
th, td { padding: 12px 8px; text-align: left; border-bottom: 1px solid var(--border-color); }
|
|
283
|
+
th { font-size: 13px; font-weight: 600; color: var(--sub-text-color); }
|
|
284
|
+
td { font-size: 15px; vertical-align: middle; }
|
|
285
|
+
tr:last-child td { border-bottom: none; }
|
|
286
|
+
.rank-cell { width: 50px; text-align: center; }
|
|
287
|
+
.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; }
|
|
288
|
+
.rank-gold { background-color: var(--gold); color: #fff; }
|
|
289
|
+
.rank-silver { background-color: var(--silver); color: #fff; }
|
|
290
|
+
.rank-bronze { background-color: var(--bronze); color: #fff; }
|
|
291
|
+
.data-cell { word-break: break-all; }
|
|
292
|
+
.name-cell { font-weight: 600; color: var(--header-color); }
|
|
293
|
+
.count-cell { text-align: right; font-weight: 600; color: var(--accent-color); }
|
|
294
|
+
.date-cell { text-align: right; font-size: 13px; color: var(--sub-text-color); }
|
|
295
|
+
`;
|
|
296
|
+
return `
|
|
297
|
+
<!DOCTYPE html><html lang="zh-CN">
|
|
298
|
+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${title}</title><style>${styles}</style></head>
|
|
299
|
+
<body>
|
|
300
|
+
<div class="container">
|
|
301
|
+
<div class="header">
|
|
302
|
+
<div class="title-group"><h1>${title}</h1></div>
|
|
303
|
+
<div class="meta-group">${metaInfo}<div class="time-label">${timeLabel}</div></div>
|
|
304
|
+
</div>
|
|
305
|
+
<table>
|
|
306
|
+
${tableHeadHtml}
|
|
307
|
+
<tbody>${tableRows}</tbody>
|
|
308
|
+
</table>
|
|
309
|
+
</div>
|
|
310
|
+
</body></html>
|
|
311
|
+
`;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// src/CmdStat.ts
|
|
316
|
+
var CmdStat = class {
|
|
317
|
+
static {
|
|
318
|
+
__name(this, "CmdStat");
|
|
319
|
+
}
|
|
320
|
+
renderer;
|
|
321
|
+
ctx;
|
|
322
|
+
constructor(context) {
|
|
323
|
+
this.ctx = context;
|
|
324
|
+
this.renderer = new Renderer(this.ctx);
|
|
325
|
+
this.ctx.model.extend("analyse_cmd", {
|
|
326
|
+
channelId: "string",
|
|
327
|
+
userId: "string",
|
|
328
|
+
command: "string",
|
|
329
|
+
count: "unsigned",
|
|
330
|
+
timestamp: "timestamp"
|
|
331
|
+
}, { primary: ["channelId", "userId", "command"] });
|
|
332
|
+
this.ctx.on("command/before-execute", async ({ command, session }) => {
|
|
333
|
+
const { userId, guildId } = session;
|
|
334
|
+
if (!guildId || !userId || command.name === "analyse" || command.parent?.name === "analyse") return;
|
|
335
|
+
const commandName = command.name;
|
|
336
|
+
const query = { channelId: guildId, userId, command: commandName };
|
|
337
|
+
await this.ctx.database.upsert("analyse_cmd", (row) => [{
|
|
338
|
+
...query,
|
|
339
|
+
count: import_koishi2.$.add(import_koishi2.$.ifNull(row.count, 0), 1),
|
|
340
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
341
|
+
}]);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* @method registerCommands
|
|
346
|
+
* @description 注册命令,并通过选项支持不同维度的查询。
|
|
347
|
+
*/
|
|
348
|
+
registerCommands(analyse) {
|
|
349
|
+
analyse.subcommand(".command", "命令使用统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").usage(`查询命令使用统计。默认查询全局统计,可通过选项指定用户和群组。`).action(async ({ session, options }) => {
|
|
350
|
+
const userId = options.user ? import_koishi2.h.select(options.user, "user")[0]?.attrs.id : void 0;
|
|
351
|
+
let guildId = options.guild;
|
|
352
|
+
if (options.guild === "" && !options.user) {
|
|
353
|
+
if (!session.guildId) return "请指定群组 ID";
|
|
354
|
+
guildId = session.guildId;
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
const stats = await this.getCommandStats(guildId, userId);
|
|
358
|
+
if (typeof stats === "string") return stats;
|
|
359
|
+
let title;
|
|
360
|
+
const titleParts = [];
|
|
361
|
+
if (userId) {
|
|
362
|
+
const user = await session.bot.getUser(userId).catch(() => null);
|
|
363
|
+
titleParts.push(`用户 ${user?.name || userId}`);
|
|
364
|
+
}
|
|
365
|
+
if (guildId) {
|
|
366
|
+
const guild = await session.bot.getGuild(guildId).catch(() => null);
|
|
367
|
+
titleParts.push(`群组 ${guild?.name || guildId}`);
|
|
368
|
+
}
|
|
369
|
+
if (userId && !guildId) {
|
|
370
|
+
title = `${titleParts[0]}的全局命令统计`;
|
|
371
|
+
} else if (titleParts.length > 0) {
|
|
372
|
+
title = `${titleParts.join("、")}的命令统计`;
|
|
373
|
+
} else {
|
|
374
|
+
title = "全局命令统计";
|
|
375
|
+
}
|
|
376
|
+
const renderData = {
|
|
377
|
+
title,
|
|
378
|
+
time: /* @__PURE__ */ new Date(),
|
|
379
|
+
total: stats.total,
|
|
380
|
+
list: stats.list
|
|
381
|
+
};
|
|
382
|
+
const headers = ["命令", "次数", "上次使用"];
|
|
383
|
+
const result = await this.renderer.renderList(renderData, headers);
|
|
384
|
+
return Buffer.isBuffer(result) ? import_koishi2.Element.image(result, "image/png") : result;
|
|
385
|
+
} catch (error) {
|
|
386
|
+
this.ctx.logger.error("渲染统计图片失败:", error);
|
|
387
|
+
return "渲染统计图片失败";
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* @private
|
|
393
|
+
* @method getCommandStats
|
|
394
|
+
* @description 从数据库获取并处理命令统计数据,兼容全局、群组和个人查询。
|
|
395
|
+
* @returns 返回一个包含二维数组列表和总数的对象,或错误字符串。
|
|
396
|
+
*/
|
|
397
|
+
async getCommandStats(guildId, userId) {
|
|
398
|
+
const query = {};
|
|
399
|
+
if (guildId) query.channelId = guildId;
|
|
400
|
+
if (userId) query.userId = userId;
|
|
401
|
+
const records = await this.ctx.database.get("analyse_cmd", query);
|
|
402
|
+
if (records.length === 0) return "暂无统计数据";
|
|
403
|
+
const totalCount = records.reduce((sum, record) => sum + record.count, 0);
|
|
404
|
+
let sortedList;
|
|
405
|
+
if (userId) {
|
|
406
|
+
sortedList = records.map((r) => ({ name: r.command, count: r.count, lastUsed: r.timestamp })).sort((a, b) => b.count - a.count);
|
|
407
|
+
} else {
|
|
408
|
+
const commandMap = /* @__PURE__ */ new Map();
|
|
409
|
+
for (const record of records) {
|
|
410
|
+
const existing = commandMap.get(record.command) || { count: 0, lastUsed: /* @__PURE__ */ new Date(0) };
|
|
411
|
+
existing.count += record.count;
|
|
412
|
+
if (record.timestamp > existing.lastUsed) {
|
|
413
|
+
existing.lastUsed = record.timestamp;
|
|
414
|
+
}
|
|
415
|
+
commandMap.set(record.command, existing);
|
|
416
|
+
}
|
|
417
|
+
sortedList = Array.from(commandMap.entries()).map(([name2, data]) => ({ name: name2, ...data })).sort((a, b) => b.count - a.count);
|
|
418
|
+
}
|
|
419
|
+
const list = sortedList.map((item) => [item.name, item.count, item.lastUsed]);
|
|
420
|
+
return { list, total: totalCount };
|
|
141
421
|
}
|
|
142
422
|
};
|
|
143
423
|
|
|
@@ -155,10 +435,13 @@ var usage = `
|
|
|
155
435
|
</div>
|
|
156
436
|
`;
|
|
157
437
|
var name = "chat-analyse";
|
|
158
|
-
var Config =
|
|
159
|
-
var using = ["database"];
|
|
438
|
+
var Config = import_koishi3.Schema.object({});
|
|
439
|
+
var using = ["database", "puppeteer"];
|
|
160
440
|
function apply(ctx) {
|
|
161
441
|
new Collector(ctx);
|
|
442
|
+
const cmd = new CmdStat(ctx);
|
|
443
|
+
const analyse = ctx.command("analyse", "聊天记录分析");
|
|
444
|
+
cmd.registerCommands(analyse);
|
|
162
445
|
}
|
|
163
446
|
__name(apply, "apply");
|
|
164
447
|
// Annotate the CommonJS export names for ESM import in node:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-chat-analyse",
|
|
3
3
|
"description": "聊天记录分析",
|
|
4
|
-
"version": "0.1
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"contributors": [
|
|
6
6
|
"Yis_Rime <yis_rime@outlook.com>"
|
|
7
7
|
],
|
|
@@ -16,14 +16,25 @@
|
|
|
16
16
|
"lib",
|
|
17
17
|
"dist"
|
|
18
18
|
],
|
|
19
|
-
"license": "
|
|
19
|
+
"license": "AGPL-3.0-only",
|
|
20
20
|
"scripts": {},
|
|
21
21
|
"keywords": [
|
|
22
22
|
"chatbot",
|
|
23
23
|
"koishi",
|
|
24
|
-
"plugin"
|
|
24
|
+
"plugin",
|
|
25
|
+
"chat",
|
|
26
|
+
"analyse",
|
|
27
|
+
"statistics",
|
|
28
|
+
"command",
|
|
29
|
+
"message",
|
|
30
|
+
"group",
|
|
31
|
+
"user",
|
|
32
|
+
"platform"
|
|
25
33
|
],
|
|
26
|
-
"devDependencies": {
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"koishi-plugin-puppeteer": "^3.5.2",
|
|
36
|
+
"koishi-plugin-cron": "^3.0.0"
|
|
37
|
+
},
|
|
27
38
|
"peerDependencies": {
|
|
28
39
|
"koishi": "4.18.8"
|
|
29
40
|
}
|
package/lib/collector.d.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { Context } from 'koishi';
|
|
2
|
-
/**
|
|
3
|
-
* @file collector.ts
|
|
4
|
-
* @description 通过统一的事件监听器,持久化存储所有收到的消息和命令,并高效地维护用户/群组ID与名称的映射。
|
|
5
|
-
*/
|
|
6
|
-
declare module 'koishi' {
|
|
7
|
-
interface Tables {
|
|
8
|
-
analyse_msg: {
|
|
9
|
-
id: number;
|
|
10
|
-
channelId: string;
|
|
11
|
-
userId: string;
|
|
12
|
-
type: string;
|
|
13
|
-
content: string;
|
|
14
|
-
timestamp: Date;
|
|
15
|
-
};
|
|
16
|
-
analyse_name: {
|
|
17
|
-
id: number;
|
|
18
|
-
channelId: string;
|
|
19
|
-
channelName: string;
|
|
20
|
-
userId: string;
|
|
21
|
-
userName: string;
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* @class Collector
|
|
27
|
-
* @description 核心收集器类。负责初始化数据库、监听消息、分类处理并持久化数据。
|
|
28
|
-
*/
|
|
29
|
-
export declare class Collector {
|
|
30
|
-
private ctx;
|
|
31
|
-
/**
|
|
32
|
-
* @property {Map<string, { name: string, timestamp: number }>} nameCache
|
|
33
|
-
* @description 用户名缓存,键为“频道ID:用户ID”,值为名称和时间戳。
|
|
34
|
-
*/
|
|
35
|
-
private nameCache;
|
|
36
|
-
/**
|
|
37
|
-
* @constructor
|
|
38
|
-
* @param {Context} ctx Koishi 的上下文对象
|
|
39
|
-
*/
|
|
40
|
-
constructor(ctx: Context);
|
|
41
|
-
/**
|
|
42
|
-
* @private
|
|
43
|
-
* @method defineModels
|
|
44
|
-
* @description 初始化插件所需的两个数据表模型。
|
|
45
|
-
*/
|
|
46
|
-
private defineModels;
|
|
47
|
-
/**
|
|
48
|
-
* @private
|
|
49
|
-
* @method sanitizeContent
|
|
50
|
-
* @description 将消息元素数组转换并清理为用于存储的字符串。
|
|
51
|
-
* @param {Element[]} elements 消息元素数组
|
|
52
|
-
* @returns {string} 清理后的消息内容
|
|
53
|
-
*/
|
|
54
|
-
private sanitizeContent;
|
|
55
|
-
/**
|
|
56
|
-
* @private
|
|
57
|
-
* @method updateNameIfNeeded
|
|
58
|
-
* @description 检查并按需更新用户和频道的名称。
|
|
59
|
-
* @param {Session} session 当前会话对象
|
|
60
|
-
* @param {string} effectiveId 当前生效的频道/群组ID
|
|
61
|
-
*/
|
|
62
|
-
private updateNameIfNeeded;
|
|
63
|
-
}
|