koishi-plugin-chat-analyse 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/Collector.d.ts +12 -13
- package/lib/Stat.d.ts +5 -2
- package/lib/index.d.ts +2 -1
- package/lib/index.js +107 -67
- package/package.json +1 -1
package/lib/Collector.d.ts
CHANGED
|
@@ -41,12 +41,11 @@ export declare class Collector {
|
|
|
41
41
|
private static readonly FLUSH_INTERVAL;
|
|
42
42
|
/** @const {number} BUFFER_THRESHOLD - 内存缓存区触发自动刷新的消息数量阈值。 */
|
|
43
43
|
private static readonly BUFFER_THRESHOLD;
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
private pendingUidRequests;
|
|
44
|
+
private msgStatBuffer;
|
|
45
|
+
private cmdStatBuffer;
|
|
46
|
+
private oriCacheBuffer;
|
|
47
|
+
private userCache;
|
|
48
|
+
private pendingUserRequests;
|
|
50
49
|
private flushInterval;
|
|
51
50
|
/**
|
|
52
51
|
* @constructor
|
|
@@ -71,13 +70,13 @@ export declare class Collector {
|
|
|
71
70
|
/**
|
|
72
71
|
* @private
|
|
73
72
|
* @async
|
|
74
|
-
* @method
|
|
75
|
-
* @description
|
|
73
|
+
* @method getOrCreateCachedUser
|
|
74
|
+
* @description 高效地获取或创建用户的中央记录,并全面利用缓存。
|
|
76
75
|
* @param {Session} session - Koishi 会话对象,用于获取用户信息和 Bot 实例。
|
|
77
76
|
* @param {string} channelId - 消息所在的频道或群组 ID。
|
|
78
|
-
* @returns {Promise<
|
|
77
|
+
* @returns {Promise<UserCache | null>} 返回用户的缓存对象,如果操作失败则返回 `null`。
|
|
79
78
|
*/
|
|
80
|
-
private
|
|
79
|
+
private getOrCreateCachedUser;
|
|
81
80
|
/**
|
|
82
81
|
* @private
|
|
83
82
|
* @method sanitizeContent
|
|
@@ -89,8 +88,8 @@ export declare class Collector {
|
|
|
89
88
|
/**
|
|
90
89
|
* @private
|
|
91
90
|
* @async
|
|
92
|
-
* @method
|
|
93
|
-
* @description
|
|
91
|
+
* @method flushBuffers
|
|
92
|
+
* @description 将所有内存中的缓冲区数据批量写入数据库。
|
|
94
93
|
*/
|
|
95
|
-
private
|
|
94
|
+
private flushBuffers;
|
|
96
95
|
}
|
package/lib/Stat.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export declare class Stat {
|
|
|
17
17
|
constructor(ctx: Context, config: Config);
|
|
18
18
|
/**
|
|
19
19
|
* @method registerCommands
|
|
20
|
-
* @description 根据插件配置,动态地将 `.
|
|
20
|
+
* @description 根据插件配置,动态地将 `.cmd`, `.msg`, `.rank` 子命令注册到主 `analyse` 命令下。
|
|
21
21
|
* @param {Command} analyse - 主 `analyse` 命令实例。
|
|
22
22
|
*/
|
|
23
23
|
registerCommands(analyse: Command): void;
|
|
@@ -28,7 +28,10 @@ export declare class Stat {
|
|
|
28
28
|
* @description 通用的标题生成器。根据查询参数和类型选项动态生成易于理解的图片标题。
|
|
29
29
|
* @param {string} [guildId] - (可选) 查询的群组 ID。
|
|
30
30
|
* @param {string} [userId] - (可选) 查询的用户 ID。
|
|
31
|
-
* @param {
|
|
31
|
+
* @param {object} options - 标题的配置选项。
|
|
32
|
+
* @param {'命令' | '消息' | '排行'} options.main - 标题主类型。
|
|
33
|
+
* @param {string} [options.subtype] - (可选) 消息类型的子类型。
|
|
34
|
+
* @param {number} [options.timeRange] - (可选) 排行的时间范围(小时)。
|
|
32
35
|
* @returns {Promise<string>} 生成的标题字符串。
|
|
33
36
|
*/
|
|
34
37
|
private generateTitle;
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -42,13 +42,11 @@ var Collector = class _Collector {
|
|
|
42
42
|
this.config = config;
|
|
43
43
|
this.defineModels();
|
|
44
44
|
ctx.on("message", (session) => this.handleMessage(session));
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
});
|
|
51
|
-
}
|
|
45
|
+
this.flushInterval = setInterval(() => this.flushBuffers(), _Collector.FLUSH_INTERVAL);
|
|
46
|
+
ctx.on("dispose", () => {
|
|
47
|
+
clearInterval(this.flushInterval);
|
|
48
|
+
this.flushBuffers();
|
|
49
|
+
});
|
|
52
50
|
}
|
|
53
51
|
static {
|
|
54
52
|
__name(this, "Collector");
|
|
@@ -57,12 +55,13 @@ var Collector = class _Collector {
|
|
|
57
55
|
static FLUSH_INTERVAL = 60 * 1e3;
|
|
58
56
|
/** @const {number} BUFFER_THRESHOLD - 内存缓存区触发自动刷新的消息数量阈值。 */
|
|
59
57
|
static BUFFER_THRESHOLD = 100;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
// 统一的缓冲区
|
|
59
|
+
msgStatBuffer = /* @__PURE__ */ new Map();
|
|
60
|
+
cmdStatBuffer = /* @__PURE__ */ new Map();
|
|
61
|
+
oriCacheBuffer = [];
|
|
62
|
+
// 用户缓存
|
|
63
|
+
userCache = /* @__PURE__ */ new Map();
|
|
64
|
+
pendingUserRequests = /* @__PURE__ */ new Map();
|
|
66
65
|
flushInterval;
|
|
67
66
|
/**
|
|
68
67
|
* @private
|
|
@@ -90,7 +89,7 @@ var Collector = class _Collector {
|
|
|
90
89
|
count: "unsigned",
|
|
91
90
|
timestamp: "timestamp"
|
|
92
91
|
}, { primary: ["uid", "type", "hour"] });
|
|
93
|
-
if (this.config.
|
|
92
|
+
if (this.config.enableOriRecord) {
|
|
94
93
|
this.ctx.model.extend("analyse_cache", {
|
|
95
94
|
id: "unsigned",
|
|
96
95
|
uid: "unsigned",
|
|
@@ -111,35 +110,39 @@ var Collector = class _Collector {
|
|
|
111
110
|
const { userId, channelId, guildId, content, timestamp, argv, elements } = session;
|
|
112
111
|
const effectiveId = channelId || guildId;
|
|
113
112
|
if (!effectiveId || !userId || !timestamp || !content?.trim()) return;
|
|
114
|
-
const
|
|
115
|
-
if (!
|
|
113
|
+
const user = await this.getOrCreateCachedUser(session, effectiveId);
|
|
114
|
+
if (!user) return;
|
|
115
|
+
const { uid } = user;
|
|
116
|
+
const messageTime = new Date(timestamp);
|
|
116
117
|
if (argv?.command) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
timestamp
|
|
122
|
-
}
|
|
118
|
+
const key = `${uid}:${argv.command.name}`;
|
|
119
|
+
const existing = this.cmdStatBuffer.get(key);
|
|
120
|
+
if (existing) {
|
|
121
|
+
existing.count++;
|
|
122
|
+
existing.timestamp = messageTime;
|
|
123
|
+
} else {
|
|
124
|
+
this.cmdStatBuffer.set(key, { uid, command: argv.command.name, count: 1, timestamp: messageTime });
|
|
125
|
+
}
|
|
123
126
|
}
|
|
124
|
-
const messageTime = new Date(timestamp);
|
|
125
127
|
const hourStart = new Date(messageTime.getFullYear(), messageTime.getMonth(), messageTime.getDate(), messageTime.getHours());
|
|
126
128
|
const uniqueElementTypes = new Set(elements.map((e) => e.type));
|
|
127
129
|
for (const type of uniqueElementTypes) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
const key = `${uid}:${type}:${hourStart.toISOString()}`;
|
|
131
|
+
const existing = this.msgStatBuffer.get(key);
|
|
132
|
+
if (existing) {
|
|
133
|
+
existing.count++;
|
|
134
|
+
existing.timestamp = messageTime;
|
|
135
|
+
} else {
|
|
136
|
+
this.msgStatBuffer.set(key, { uid, type, hour: hourStart, count: 1, timestamp: messageTime });
|
|
137
|
+
}
|
|
135
138
|
}
|
|
136
|
-
if (this.config.
|
|
137
|
-
this.
|
|
139
|
+
if (this.config.enableOriRecord) {
|
|
140
|
+
this.oriCacheBuffer.push({
|
|
138
141
|
uid,
|
|
139
142
|
content: this.sanitizeContent(elements),
|
|
140
|
-
timestamp:
|
|
143
|
+
timestamp: messageTime
|
|
141
144
|
});
|
|
142
|
-
if (this.
|
|
145
|
+
if (this.oriCacheBuffer.length >= _Collector.BUFFER_THRESHOLD) await this.flushBuffers();
|
|
143
146
|
}
|
|
144
147
|
} catch (error) {
|
|
145
148
|
this.ctx.logger.warn("消息处理出错:", error);
|
|
@@ -148,45 +151,50 @@ var Collector = class _Collector {
|
|
|
148
151
|
/**
|
|
149
152
|
* @private
|
|
150
153
|
* @async
|
|
151
|
-
* @method
|
|
152
|
-
* @description
|
|
154
|
+
* @method getOrCreateCachedUser
|
|
155
|
+
* @description 高效地获取或创建用户的中央记录,并全面利用缓存。
|
|
153
156
|
* @param {Session} session - Koishi 会话对象,用于获取用户信息和 Bot 实例。
|
|
154
157
|
* @param {string} channelId - 消息所在的频道或群组 ID。
|
|
155
|
-
* @returns {Promise<
|
|
158
|
+
* @returns {Promise<UserCache | null>} 返回用户的缓存对象,如果操作失败则返回 `null`。
|
|
156
159
|
*/
|
|
157
|
-
async
|
|
160
|
+
async getOrCreateCachedUser(session, channelId) {
|
|
158
161
|
const { userId, bot, guildId } = session;
|
|
159
162
|
const cacheKey = `${channelId}:${userId}`;
|
|
160
|
-
if (this.
|
|
161
|
-
if (this.
|
|
163
|
+
if (this.userCache.has(cacheKey)) return this.userCache.get(cacheKey);
|
|
164
|
+
if (this.pendingUserRequests.has(cacheKey)) return this.pendingUserRequests.get(cacheKey);
|
|
162
165
|
const promise = (async () => {
|
|
163
166
|
try {
|
|
164
|
-
const existing = await this.ctx.database.get("analyse_user", { channelId, userId }
|
|
167
|
+
const existing = await this.ctx.database.get("analyse_user", { channelId, userId });
|
|
165
168
|
if (existing.length > 0) {
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
const { uid: uid2, userName: userName2, channelName: channelName2 } = existing[0];
|
|
170
|
+
const cachedUser2 = { uid: uid2, userName: userName2, channelName: channelName2 };
|
|
171
|
+
this.userCache.set(cacheKey, cachedUser2);
|
|
172
|
+
return cachedUser2;
|
|
168
173
|
}
|
|
169
174
|
const [guild, member] = await Promise.all([
|
|
170
175
|
guildId ? bot.getGuild(guildId).catch(() => null) : Promise.resolve(null),
|
|
171
176
|
guildId ? bot.getGuildMember(guildId, userId).catch(() => null) : Promise.resolve(null)
|
|
172
177
|
]);
|
|
173
178
|
const user = !member ? await bot.getUser(userId).catch(() => null) : null;
|
|
174
|
-
const
|
|
179
|
+
const newUserRecord = {
|
|
175
180
|
channelId,
|
|
176
181
|
userId,
|
|
177
182
|
channelName: guild?.name || channelId,
|
|
178
183
|
userName: member?.nick || member?.name || user?.name || userId
|
|
179
|
-
}
|
|
180
|
-
this.
|
|
181
|
-
|
|
184
|
+
};
|
|
185
|
+
const createdUser = await this.ctx.database.create("analyse_user", newUserRecord);
|
|
186
|
+
const { uid, userName, channelName } = createdUser;
|
|
187
|
+
const cachedUser = { uid, userName, channelName };
|
|
188
|
+
this.userCache.set(cacheKey, cachedUser);
|
|
189
|
+
return cachedUser;
|
|
182
190
|
} catch (error) {
|
|
183
|
-
this.ctx.logger.error(`创建或获取用户(${cacheKey})
|
|
191
|
+
this.ctx.logger.error(`创建或获取用户(${cacheKey})失败:`, error);
|
|
184
192
|
return null;
|
|
185
193
|
} finally {
|
|
186
|
-
this.
|
|
194
|
+
this.pendingUserRequests.delete(cacheKey);
|
|
187
195
|
}
|
|
188
196
|
})();
|
|
189
|
-
this.
|
|
197
|
+
this.pendingUserRequests.set(cacheKey, promise);
|
|
190
198
|
return promise;
|
|
191
199
|
}
|
|
192
200
|
/**
|
|
@@ -211,17 +219,43 @@ var Collector = class _Collector {
|
|
|
211
219
|
/**
|
|
212
220
|
* @private
|
|
213
221
|
* @async
|
|
214
|
-
* @method
|
|
215
|
-
* @description
|
|
222
|
+
* @method flushBuffers
|
|
223
|
+
* @description 将所有内存中的缓冲区数据批量写入数据库。
|
|
216
224
|
*/
|
|
217
|
-
async
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
225
|
+
async flushBuffers() {
|
|
226
|
+
const cmdBufferToFlush = Array.from(this.cmdStatBuffer.values());
|
|
227
|
+
const msgBufferToFlush = Array.from(this.msgStatBuffer.values());
|
|
228
|
+
const advancedBufferToFlush = this.oriCacheBuffer;
|
|
229
|
+
this.cmdStatBuffer.clear();
|
|
230
|
+
this.msgStatBuffer.clear();
|
|
231
|
+
this.oriCacheBuffer = [];
|
|
221
232
|
try {
|
|
222
|
-
|
|
233
|
+
if (cmdBufferToFlush.length > 0) {
|
|
234
|
+
await this.ctx.database.upsert(
|
|
235
|
+
"analyse_cmd",
|
|
236
|
+
(row) => cmdBufferToFlush.map((item) => ({
|
|
237
|
+
uid: item.uid,
|
|
238
|
+
command: item.command,
|
|
239
|
+
count: import_koishi.$.add(import_koishi.$.ifNull(row.count, 0), item.count),
|
|
240
|
+
timestamp: item.timestamp
|
|
241
|
+
}))
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (msgBufferToFlush.length > 0) {
|
|
245
|
+
await this.ctx.database.upsert(
|
|
246
|
+
"analyse_msg",
|
|
247
|
+
(row) => msgBufferToFlush.map((item) => ({
|
|
248
|
+
uid: item.uid,
|
|
249
|
+
type: item.type,
|
|
250
|
+
hour: item.hour,
|
|
251
|
+
count: import_koishi.$.add(import_koishi.$.ifNull(row.count, 0), item.count),
|
|
252
|
+
timestamp: item.timestamp
|
|
253
|
+
}))
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (advancedBufferToFlush.length > 0) await this.ctx.database.create("analyse_cache", advancedBufferToFlush);
|
|
223
257
|
} catch (error) {
|
|
224
|
-
this.ctx.logger.error("
|
|
258
|
+
this.ctx.logger.error("写入数据出错:", error);
|
|
225
259
|
}
|
|
226
260
|
}
|
|
227
261
|
};
|
|
@@ -360,12 +394,12 @@ var Stat = class {
|
|
|
360
394
|
renderer;
|
|
361
395
|
/**
|
|
362
396
|
* @method registerCommands
|
|
363
|
-
* @description 根据插件配置,动态地将 `.
|
|
397
|
+
* @description 根据插件配置,动态地将 `.cmd`, `.msg`, `.rank` 子命令注册到主 `analyse` 命令下。
|
|
364
398
|
* @param {Command} analyse - 主 `analyse` 命令实例。
|
|
365
399
|
*/
|
|
366
400
|
registerCommands(analyse) {
|
|
367
401
|
if (this.config.enableCmdStat) {
|
|
368
|
-
analyse.subcommand(".
|
|
402
|
+
analyse.subcommand(".cmd", "命令使用统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").usage("查询用户或群组的命令使用统计,默认展示全局统计。").action(async ({ session, options }) => {
|
|
369
403
|
const userId = options.user ? import_koishi3.h.select(options.user, "user")[0]?.attrs.id : void 0;
|
|
370
404
|
let guildId = options.guild;
|
|
371
405
|
if (!userId && !guildId && session.guildId) {
|
|
@@ -393,7 +427,7 @@ var Stat = class {
|
|
|
393
427
|
});
|
|
394
428
|
}
|
|
395
429
|
if (this.config.enableMsgStat) {
|
|
396
|
-
analyse.subcommand(".
|
|
430
|
+
analyse.subcommand(".msg", "消息发送统计").option("user", "-u [user:user] 指定用户").option("guild", "-g [guildId:string] 指定群组").option("type", "-t <type:string> 指定类型").usage("查询用户或群组的消息发送统计,默认展示全局统计。").action(async ({ session, options }) => {
|
|
397
431
|
const userId = options.user ? import_koishi3.h.select(options.user, "user")[0]?.attrs.id : void 0;
|
|
398
432
|
let guildId = options.guild;
|
|
399
433
|
if (!userId && !guildId && !options.type && session.guildId) {
|
|
@@ -434,7 +468,9 @@ var Stat = class {
|
|
|
434
468
|
return "渲染消息统计图片失败";
|
|
435
469
|
}
|
|
436
470
|
});
|
|
437
|
-
|
|
471
|
+
}
|
|
472
|
+
if (this.config.enableRankStat) {
|
|
473
|
+
analyse.subcommand(".rank", "用户发言排行").option("guild", "-g [guildId:string] 指定群组").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).usage("查询用户或群组的用户发言排行,默认展示全局统计。").action(async ({ session, options }) => {
|
|
438
474
|
let guildId = options.guild;
|
|
439
475
|
if (!guildId && session.guildId) guildId = session.guildId;
|
|
440
476
|
if (!guildId) return "请指定查询范围";
|
|
@@ -470,7 +506,10 @@ var Stat = class {
|
|
|
470
506
|
* @description 通用的标题生成器。根据查询参数和类型选项动态生成易于理解的图片标题。
|
|
471
507
|
* @param {string} [guildId] - (可选) 查询的群组 ID。
|
|
472
508
|
* @param {string} [userId] - (可选) 查询的用户 ID。
|
|
473
|
-
* @param {
|
|
509
|
+
* @param {object} options - 标题的配置选项。
|
|
510
|
+
* @param {'命令' | '消息' | '排行'} options.main - 标题主类型。
|
|
511
|
+
* @param {string} [options.subtype] - (可选) 消息类型的子类型。
|
|
512
|
+
* @param {number} [options.timeRange] - (可选) 排行的时间范围(小时)。
|
|
474
513
|
* @returns {Promise<string>} 生成的标题字符串。
|
|
475
514
|
*/
|
|
476
515
|
async generateTitle(guildId, userId, options) {
|
|
@@ -635,13 +674,14 @@ var name = "chat-analyse";
|
|
|
635
674
|
var using = ["database", "puppeteer"];
|
|
636
675
|
var Config = import_koishi4.Schema.intersect([
|
|
637
676
|
import_koishi4.Schema.object({
|
|
638
|
-
enableListener: import_koishi4.Schema.boolean().default(true).description("
|
|
639
|
-
|
|
677
|
+
enableListener: import_koishi4.Schema.boolean().default(true).description("启用消息监听"),
|
|
678
|
+
enableOriRecord: import_koishi4.Schema.boolean().default(true).description("启用原始记录")
|
|
679
|
+
}).description("监听配置"),
|
|
640
680
|
import_koishi4.Schema.object({
|
|
641
681
|
enableCmdStat: import_koishi4.Schema.boolean().default(true).description("启用命令统计"),
|
|
642
682
|
enableMsgStat: import_koishi4.Schema.boolean().default(true).description("启用消息统计"),
|
|
643
|
-
|
|
644
|
-
}).description("
|
|
683
|
+
enableRankStat: import_koishi4.Schema.boolean().default(true).description("启用发言排行")
|
|
684
|
+
}).description("命令配置")
|
|
645
685
|
]);
|
|
646
686
|
function apply(ctx, config) {
|
|
647
687
|
if (config.enableListener) new Collector(ctx, config);
|