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