koishi-plugin-chat-analyse 0.4.9 → 0.5.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/index.js CHANGED
@@ -43,15 +43,19 @@ var import_koishi6 = require("koishi");
43
43
  var import_koishi = require("koishi");
44
44
  var Collector = class _Collector {
45
45
  /**
46
- * @constructor
47
- * @param {Context} ctx - Koishi 的插件上下文。
48
- * @param {Config} config - 插件的配置对象。
46
+ * @param ctx - Koishi 的插件上下文。
47
+ * @param config - 插件的配置对象。
49
48
  */
50
49
  constructor(ctx, config) {
51
50
  this.ctx = ctx;
52
51
  this.config = config;
53
- this.defineModels();
54
- ctx.on("message", (session) => this.handleMessage(session));
52
+ this.ctx.model.extend("analyse_user", { uid: "unsigned", channelId: "string", userId: "string", channelName: "string", userName: "string" }, { primary: "uid", autoInc: true, indexes: ["channelId", "userId"] });
53
+ this.ctx.model.extend("analyse_cmd", { uid: "unsigned", command: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "command"] });
54
+ this.ctx.model.extend("analyse_msg", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "type"] });
55
+ this.ctx.model.extend("analyse_rank", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "timestamp", "type"] });
56
+ if (this.config.enableOriRecord) this.ctx.model.extend("analyse_cache", { id: "unsigned", uid: "unsigned", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["uid", "timestamp"] });
57
+ if (this.config.enableWhoAt) this.ctx.model.extend("analyse_at", { id: "unsigned", uid: "unsigned", target: "string", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["target", "uid"] });
58
+ ctx.on("message", (session) => this.onMessage(session));
55
59
  this.flushInterval = setInterval(() => this.flushBuffers(), _Collector.FLUSH_INTERVAL);
56
60
  ctx.on("dispose", () => {
57
61
  clearInterval(this.flushInterval);
@@ -61,268 +65,134 @@ var Collector = class _Collector {
61
65
  static {
62
66
  __name(this, "Collector");
63
67
  }
64
- /** @const {number} FLUSH_INTERVAL - 内存缓存区自动刷新到数据库的时间间隔。 */
65
- static FLUSH_INTERVAL = 60 * 1e3;
66
- /** @const {number} BUFFER_THRESHOLD - 内存缓存区触发自动刷新的消息数量阈值。 */
68
+ /** @property FLUSH_INTERVAL - 内存缓存区定时刷入数据库的间隔(毫秒)。 */
69
+ static FLUSH_INTERVAL = import_koishi.Time.minute;
70
+ /** @property BUFFER_THRESHOLD - 内存缓存区触发刷新的消息数量阈值。 */
67
71
  static BUFFER_THRESHOLD = 100;
68
- // 统一的缓冲区
72
+ // 统一的数据缓冲区
69
73
  msgStatBuffer = /* @__PURE__ */ new Map();
70
74
  rankStatBuffer = /* @__PURE__ */ new Map();
71
75
  cmdStatBuffer = /* @__PURE__ */ new Map();
72
76
  oriCacheBuffer = [];
73
77
  whoAtBuffer = [];
74
- // 用户缓存
75
78
  userCache = /* @__PURE__ */ new Map();
76
79
  pendingUserRequests = /* @__PURE__ */ new Map();
77
80
  flushInterval;
78
81
  /**
79
- * @private
80
- * @method defineModels
81
- * @description 定义插件所需的所有数据表模型。
82
- */
83
- defineModels() {
84
- this.ctx.model.extend("analyse_user", {
85
- uid: "unsigned",
86
- channelId: "string",
87
- userId: "string",
88
- channelName: "string",
89
- userName: "string"
90
- }, { primary: "uid", autoInc: true, indexes: ["channelId", "userId"] });
91
- this.ctx.model.extend("analyse_cmd", {
92
- uid: "unsigned",
93
- command: "string",
94
- count: "unsigned",
95
- timestamp: "timestamp"
96
- }, { primary: ["uid", "command"] });
97
- this.ctx.model.extend("analyse_msg", {
98
- uid: "unsigned",
99
- type: "string",
100
- count: "unsigned",
101
- timestamp: "timestamp"
102
- }, { primary: ["uid", "type"] });
103
- this.ctx.model.extend("analyse_rank", {
104
- uid: "unsigned",
105
- type: "string",
106
- count: "unsigned",
107
- timestamp: "timestamp"
108
- }, { primary: ["uid", "timestamp", "type"] });
109
- if (this.config.enableOriRecord) {
110
- this.ctx.model.extend("analyse_cache", {
111
- id: "unsigned",
112
- uid: "unsigned",
113
- content: "text",
114
- timestamp: "timestamp"
115
- }, { primary: "id", autoInc: true, indexes: ["uid", "timestamp"] });
116
- }
117
- if (this.config.enableWhoAt) {
118
- this.ctx.model.extend("analyse_at", {
119
- id: "unsigned",
120
- uid: "unsigned",
121
- target: "string",
122
- content: "text",
123
- timestamp: "timestamp"
124
- }, { primary: "id", autoInc: true, indexes: ["target", "uid"] });
125
- }
126
- }
127
- /**
128
- * @private
129
- * @async
130
- * @method handleMessage
131
- * @description 统一的消息和命令处理器。它会解析收到的消息,提取关键信息并更新相应的统计数据。
132
- * @param {Session} session - Koishi 的会话对象,包含消息的全部信息。
82
+ * @private @method onMessage
83
+ * @description 统一的消息事件处理器,解析消息并更新各类统计数据的缓冲区。
84
+ * @param session - Koishi 的会话对象。
133
85
  */
134
- async handleMessage(session) {
135
- try {
136
- const { userId, channelId, guildId, content, timestamp, argv, elements } = session;
137
- const effectiveId = channelId || guildId;
138
- if (!effectiveId || !userId || !timestamp || !content?.trim()) return;
139
- const user = await this.getOrCreateCachedUser(session, effectiveId);
140
- if (!user) return;
141
- const { uid } = user;
142
- const messageTime = new Date(timestamp);
143
- if (argv?.command) {
144
- const key = `${uid}:${argv.command.name}`;
145
- const existing = this.cmdStatBuffer.get(key);
146
- if (existing) {
147
- existing.count++;
148
- existing.timestamp = messageTime;
149
- } else {
150
- this.cmdStatBuffer.set(key, { uid, command: argv.command.name, count: 1, timestamp: messageTime });
151
- }
152
- }
153
- const hourStart = new Date(messageTime.getFullYear(), messageTime.getMonth(), messageTime.getDate(), messageTime.getHours());
154
- const uniqueElementTypes = new Set(elements.map((e) => e.type));
155
- for (const type of uniqueElementTypes) {
156
- const msgKey = `${uid}:${type}`;
157
- const existingMsg = this.msgStatBuffer.get(msgKey);
158
- if (existingMsg) {
159
- existingMsg.count++;
160
- existingMsg.timestamp = messageTime;
161
- } else {
162
- this.msgStatBuffer.set(msgKey, { uid, type, count: 1, timestamp: messageTime });
163
- }
164
- const rankKey = `${uid}:${hourStart.toISOString()}:${type}`;
165
- const existingRank = this.rankStatBuffer.get(rankKey);
166
- if (existingRank) {
167
- existingRank.count++;
168
- } else {
169
- this.rankStatBuffer.set(rankKey, { uid, timestamp: hourStart, type, count: 1 });
170
- }
171
- }
172
- if (this.config.enableWhoAt) {
173
- const atElements = elements.filter((e) => e.type === "at");
174
- if (atElements.length > 0) {
175
- const contentElements = elements.filter((e) => e.type !== "at");
176
- const sanitizedContent = this.sanitizeContent(contentElements);
177
- for (const atElement of atElements) {
178
- const targetId = atElement.attrs.id;
179
- if (targetId && targetId !== userId) this.whoAtBuffer.push({ uid, target: targetId, content: sanitizedContent, timestamp: messageTime });
86
+ async onMessage(session) {
87
+ const { userId, channelId, content, timestamp, argv, elements, bot } = session;
88
+ if (!channelId || !userId || !content?.trim()) return;
89
+ const cacheKey = `${channelId}:${userId}`;
90
+ let user;
91
+ if (this.userCache.has(cacheKey)) {
92
+ user = this.userCache.get(cacheKey);
93
+ } else if (this.pendingUserRequests.has(cacheKey)) {
94
+ user = await this.pendingUserRequests.get(cacheKey);
95
+ } else {
96
+ const promise = (async () => {
97
+ try {
98
+ const [dbUser] = await this.ctx.database.get("analyse_user", { channelId, userId });
99
+ const currentUserName = session.username ?? "";
100
+ const guild = await bot.getGuild(channelId).catch(() => null);
101
+ const currentChannelName = guild?.name ?? "";
102
+ if (dbUser) {
103
+ if (currentUserName && dbUser.userName !== currentUserName || currentChannelName && dbUser.channelName !== currentChannelName) {
104
+ await this.ctx.database.set("analyse_user", { uid: dbUser.uid }, { userName: currentUserName, channelName: currentChannelName });
105
+ dbUser.userName = currentUserName;
106
+ dbUser.channelName = currentChannelName;
107
+ }
108
+ this.userCache.set(cacheKey, dbUser);
109
+ return dbUser;
180
110
  }
111
+ const createdUser = await this.ctx.database.create("analyse_user", { channelId, userId, userName: currentUserName, channelName: currentChannelName });
112
+ this.userCache.set(cacheKey, createdUser);
113
+ return createdUser;
114
+ } catch (error) {
115
+ this.ctx.logger.error(`创建或获取用户(${cacheKey})失败:`, error);
116
+ return null;
117
+ } finally {
118
+ this.pendingUserRequests.delete(cacheKey);
181
119
  }
182
- }
183
- if (this.config.enableOriRecord) {
184
- this.oriCacheBuffer.push({
185
- uid,
186
- content: this.sanitizeContent(elements),
187
- timestamp: messageTime
188
- });
189
- if (this.oriCacheBuffer.length >= _Collector.BUFFER_THRESHOLD) await this.flushBuffers();
190
- }
191
- } catch (error) {
192
- this.ctx.logger.warn("消息处理出错:", error);
120
+ })();
121
+ this.pendingUserRequests.set(cacheKey, promise);
122
+ user = await promise;
193
123
  }
194
- }
195
- /**
196
- * @private
197
- * @async
198
- * @method getOrCreateCachedUser
199
- * @description 高效地获取或创建用户的中央记录,并全面利用缓存。
200
- * @param {Session} session - Koishi 会话对象,用于获取用户信息和 Bot 实例。
201
- * @param {string} channelId - 消息所在的频道或群组 ID。
202
- * @returns {Promise<UserCache | null>} 返回用户的缓存对象,如果操作失败则返回 `null`。
203
- */
204
- async getOrCreateCachedUser(session, channelId) {
205
- const { userId, guildId, bot } = session;
206
- const effectiveId = guildId || channelId;
207
- const cacheKey = `${effectiveId}:${userId}`;
208
- if (this.userCache.has(cacheKey)) return this.userCache.get(cacheKey);
209
- if (this.pendingUserRequests.has(cacheKey)) return this.pendingUserRequests.get(cacheKey);
210
- const promise = (async () => {
211
- try {
212
- const dbUsers = await this.ctx.database.get("analyse_user", { channelId: effectiveId, userId });
213
- const dbUser = dbUsers[0];
214
- const currentUserName = session.username || "";
215
- let currentChannelName = "";
216
- if (effectiveId && bot.getGuild) {
217
- const guild = await bot.getGuild(effectiveId);
218
- currentChannelName = guild?.name || "";
219
- }
220
- if (dbUser) {
221
- const nameChanged = currentUserName && dbUser.userName !== currentUserName;
222
- const channelNameChanged = currentChannelName && dbUser.channelName !== currentChannelName;
223
- if (nameChanged || channelNameChanged) {
224
- dbUser.userName = nameChanged ? currentUserName : dbUser.userName;
225
- dbUser.channelName = channelNameChanged ? currentChannelName : dbUser.channelName;
226
- await this.ctx.database.set("analyse_user", { uid: dbUser.uid }, {
227
- userName: dbUser.userName,
228
- channelName: dbUser.channelName
229
- });
230
- }
231
- this.userCache.set(cacheKey, dbUser);
232
- return dbUser;
124
+ if (!user) return;
125
+ const { uid } = user;
126
+ const messageTime = new Date(timestamp);
127
+ if (argv?.command) {
128
+ const key = `${uid}:${argv.command.name}`;
129
+ const entry = this.cmdStatBuffer.get(key) ?? { uid, command: argv.command.name, count: 0, timestamp: messageTime };
130
+ entry.count++;
131
+ entry.timestamp = messageTime;
132
+ this.cmdStatBuffer.set(key, entry);
133
+ }
134
+ const hourStart = new Date(messageTime.getFullYear(), messageTime.getMonth(), messageTime.getDate(), messageTime.getHours());
135
+ for (const type of new Set(elements.map((e) => e.type))) {
136
+ const msgKey = `${uid}:${type}`;
137
+ const msgEntry = this.msgStatBuffer.get(msgKey) ?? { uid, type, count: 0, timestamp: messageTime };
138
+ msgEntry.count++;
139
+ msgEntry.timestamp = messageTime;
140
+ this.msgStatBuffer.set(msgKey, msgEntry);
141
+ const rankKey = `${uid}:${hourStart.toISOString()}:${type}`;
142
+ const rankEntry = this.rankStatBuffer.get(rankKey) ?? { uid, timestamp: hourStart, type, count: 0 };
143
+ rankEntry.count++;
144
+ this.rankStatBuffer.set(rankKey, rankEntry);
145
+ }
146
+ if (this.config.enableWhoAt) {
147
+ const sanitizedContent = this.sanitizeContent(elements.filter((e) => e.type !== "at"));
148
+ for (const atElement of elements.filter((e) => e.type === "at")) {
149
+ const targetId = atElement.attrs.id;
150
+ if (targetId && targetId !== userId) {
151
+ this.whoAtBuffer.push({ uid, target: targetId, content: sanitizedContent, timestamp: messageTime });
233
152
  }
234
- const createdUser = await this.ctx.database.create("analyse_user", {
235
- channelId: effectiveId,
236
- userId,
237
- userName: currentUserName,
238
- channelName: currentChannelName
239
- });
240
- this.userCache.set(cacheKey, createdUser);
241
- return createdUser;
242
- } catch (error) {
243
- this.ctx.logger.error(`创建或获取用户(${cacheKey})失败:`, error);
244
- return null;
245
- } finally {
246
- this.pendingUserRequests.delete(cacheKey);
247
153
  }
248
- })();
249
- this.pendingUserRequests.set(cacheKey, promise);
250
- return promise;
154
+ }
155
+ if (this.config.enableOriRecord) {
156
+ this.oriCacheBuffer.push({ uid, content: this.sanitizeContent(elements), timestamp: messageTime });
157
+ if (this.oriCacheBuffer.length >= _Collector.BUFFER_THRESHOLD) await this.flushBuffers();
158
+ }
251
159
  }
252
160
  /**
253
- * @private
254
- * @method sanitizeContent
255
- * @description Koishi 的消息元素 (Element) 数组净化为纯文本字符串,以便存储和分析。
256
- * @param {Element[]} elements - 消息元素的数组。
257
- * @returns {string} 净化后的纯文本字符串。
161
+ * @private @method sanitizeContent
162
+ * @description 将 Koishi 消息元素数组净化为纯文本字符串。
163
+ * @param elements - 消息元素数组。
164
+ * @returns 净化后的纯文本。
258
165
  */
259
- sanitizeContent = /* @__PURE__ */ __name((elements) => elements.map((e) => {
260
- switch (e.type) {
261
- case "text":
262
- return e.attrs.content;
263
- case "img":
264
- return e.attrs.summary === "[动画表情]" ? "[gif]" : "[img]";
265
- case "at":
266
- return `[at:${e.attrs.id}]`;
267
- default:
268
- return `[${e.type}]`;
269
- }
270
- }).join(""), "sanitizeContent");
166
+ sanitizeContent = /* @__PURE__ */ __name((elements) => import_koishi.h.transform(elements, {
167
+ text: /* @__PURE__ */ __name((attrs) => attrs.content, "text"),
168
+ img: /* @__PURE__ */ __name((attrs) => attrs.summary === "[动画表情]" ? "[gif]" : "[img]", "img"),
169
+ at: /* @__PURE__ */ __name((attrs) => `[at:${attrs.id}]`, "at")
170
+ }, "").join(""), "sanitizeContent");
271
171
  /**
272
- * @private
273
- * @async
274
- * @method flushBuffers
275
- * @description 将所有内存中的缓冲区数据批量写入数据库。
172
+ * @private @method flushBuffers
173
+ * @description 将所有内存中的数据缓冲区批量写入数据库,并清空缓冲区。
276
174
  */
277
175
  async flushBuffers() {
278
- const cmdBufferToFlush = Array.from(this.cmdStatBuffer.values());
279
- const msgBufferToFlush = Array.from(this.msgStatBuffer.values());
280
- const rankBufferToFlush = Array.from(this.rankStatBuffer.values());
281
- const oriCacheBufferToFlush = this.oriCacheBuffer;
282
- const whoAtBufferToFlush = this.whoAtBuffer;
176
+ const buffers = {
177
+ cmd: Array.from(this.cmdStatBuffer.values()),
178
+ msg: Array.from(this.msgStatBuffer.values()),
179
+ rank: Array.from(this.rankStatBuffer.values()),
180
+ at: this.whoAtBuffer,
181
+ cache: this.oriCacheBuffer
182
+ };
283
183
  this.cmdStatBuffer.clear();
284
184
  this.msgStatBuffer.clear();
285
185
  this.rankStatBuffer.clear();
286
- this.oriCacheBuffer = [];
287
186
  this.whoAtBuffer = [];
187
+ this.oriCacheBuffer = [];
288
188
  try {
289
- if (cmdBufferToFlush.length > 0) {
290
- await this.ctx.database.upsert(
291
- "analyse_cmd",
292
- (row) => cmdBufferToFlush.map((item) => ({
293
- uid: item.uid,
294
- command: item.command,
295
- count: import_koishi.$.add(import_koishi.$.ifNull(row.count, 0), item.count),
296
- timestamp: item.timestamp
297
- }))
298
- );
299
- }
300
- if (msgBufferToFlush.length > 0) {
301
- await this.ctx.database.upsert(
302
- "analyse_msg",
303
- (row) => msgBufferToFlush.map((item) => ({
304
- uid: item.uid,
305
- type: item.type,
306
- count: import_koishi.$.add(import_koishi.$.ifNull(row.count, 0), item.count),
307
- timestamp: item.timestamp
308
- }))
309
- );
310
- }
311
- if (rankBufferToFlush.length > 0) {
312
- await this.ctx.database.upsert(
313
- "analyse_rank",
314
- (row) => rankBufferToFlush.map((item) => ({
315
- uid: item.uid,
316
- timestamp: item.timestamp,
317
- type: item.type,
318
- count: import_koishi.$.add(import_koishi.$.ifNull(row.count, 0), item.count)
319
- }))
320
- );
321
- }
322
- if (whoAtBufferToFlush.length > 0) await this.ctx.database.upsert("analyse_at", whoAtBufferToFlush);
323
- if (oriCacheBufferToFlush.length > 0) await this.ctx.database.upsert("analyse_cache", oriCacheBufferToFlush);
189
+ if (buffers.cmd.length > 0) await this.ctx.database.upsert("analyse_cmd", (row) => buffers.cmd.map((item) => ({ ...item, count: import_koishi.$.add(import_koishi.$.ifNull(row.count, 0), item.count) })));
190
+ if (buffers.msg.length > 0) await this.ctx.database.upsert("analyse_msg", (row) => buffers.msg.map((item) => ({ ...item, count: import_koishi.$.add(import_koishi.$.ifNull(row.count, 0), item.count) })));
191
+ if (buffers.rank.length > 0) await this.ctx.database.upsert("analyse_rank", (row) => buffers.rank.map((item) => ({ ...item, count: import_koishi.$.add(import_koishi.$.ifNull(row.count, 0), item.count) })));
192
+ if (buffers.at.length > 0) await this.ctx.database.upsert("analyse_at", buffers.at);
193
+ if (buffers.cache.length > 0) await this.ctx.database.upsert("analyse_cache", buffers.cache);
324
194
  } catch (error) {
325
- this.ctx.logger.error("写入数据出错:", error);
195
+ this.ctx.logger.error("数据库刷写失败:", error);
326
196
  }
327
197
  }
328
198
  };
@@ -334,7 +204,7 @@ var import_koishi3 = require("koishi");
334
204
  var import_koishi2 = require("koishi");
335
205
  var Renderer = class {
336
206
  /**
337
- * @param {Context} ctx - Koishi 的插件上下文,用于访问核心服务如 `puppeteer` 和 `logger`。
207
+ * @param ctx - Koishi 的插件上下文,用于访问 `puppeteer` 等核心服务。
338
208
  */
339
209
  constructor(ctx) {
340
210
  this.ctx = ctx;
@@ -345,210 +215,76 @@ var Renderer = class {
345
215
  /**
346
216
  * @private
347
217
  * @method htmlToImage
348
- * @description
349
- * 负责将任意HTML字符串转换为PNG图片Buffer。
350
- * @param {string} html - 要渲染的HTML主体内容(不包含 `<html>` 和 `<body>` 标签)。
351
- * @returns {Promise<Buffer>} 返回一个包含PNG图片数据的 Buffer 对象。
352
- * @throws {Error} 如果 Puppeteer 截图过程中发生错误,将抛出异常。
218
+ * @description 将 HTML 字符串转换为 PNG 图片 Buffer。
219
+ * @param html - 要渲染的 HTML 主体内容。
220
+ * @returns 返回一个包含 PNG 图片数据的 Buffer。
353
221
  */
354
222
  async htmlToImage(html) {
355
- let page = null;
223
+ const page = await this.ctx.puppeteer.page();
356
224
  try {
357
- page = await this.ctx.puppeteer.page();
358
225
  await page.setViewport({ width: 720, height: 1080, deviceScaleFactor: 2 });
359
- await page.setContent(`
360
- <!DOCTYPE html>
361
- <html>
362
- <head>
363
- <meta charset="UTF-8">
364
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
365
- <style>
366
- :root {
367
- --card-bg: #ffffff; --text-color: #111827; --header-color: #111827;
368
- --sub-text-color: #6b7280; --border-color: #e5e7eb; --accent-color: #4a6ee0;
369
- --chip-bg: #f3f4f6; --stripe-bg: #f9fafb; --gold: #f59e0b; --silver: #9ca3af; --bronze: #a16207;
370
- }
371
- body {
372
- display: inline-block; /* Crucial for shrink-wrapping */
373
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
374
- background: transparent; margin: 0; padding: 8px;
375
- -webkit-font-smoothing: antialiased;
376
- }
377
- .container {
378
- display: inline-block; background: var(--card-bg);
379
- border-radius: 12px; padding: 0; overflow: hidden;
380
- box-shadow: 0 2px 4px rgba(0,0,0,0.05);
381
- }
382
- .header { padding: 10px 14px; }
383
- .header-table { border-collapse: collapse; width: 100%; }
384
- .header-table-left, .header-table-right { width: 1%; white-space: nowrap; }
385
- .header-table-left { text-align: left; }
386
- .header-table-center { text-align: center; }
387
- .header-table-right { text-align: right; }
388
- .title-text { font-size: 18px; font-weight: 600; color: var(--header-color); margin: 0; }
389
- .stat-chip, .time-label {
390
- display: inline-flex; align-items: baseline; padding: 4px 8px; border-radius: 8px;
391
- background: var(--chip-bg); font-size: 13px; color: var(--sub-text-color);
392
- }
393
- .stat-chip span { font-weight: 600; color: var(--text-color); margin-left: 4px; }
394
- .table-container { border-top: 1px solid var(--border-color); }
395
- .main-table { border-collapse: collapse; width: 100%; }
396
- .main-table th, .main-table td {
397
- padding: 8px 14px;
398
- vertical-align: middle;
399
- }
400
- .main-table th {
401
- font-size: 12px; font-weight: 500; color: var(--sub-text-color);
402
- text-transform: uppercase; letter-spacing: 0.05em; background: var(--stripe-bg);
403
- }
404
- .main-table td { font-size: 14px; color: var(--text-color); }
405
- .main-table tbody tr:nth-child(even) { background-color: var(--stripe-bg); }
406
- .main-table .name-cell, .main-table .name-header { text-align: left; }
407
- .main-table .rank-cell, .main-table .count-cell, .main-table .date-cell, .main-table .percent-cell, .main-table .header-right-align {
408
- text-align: right;
409
- white-space: nowrap;
410
- width: 1%;
411
- font-variant-numeric: tabular-nums;
412
- }
413
- .name-cell { font-weight: 500; }
414
- .rank-cell { font-weight: 500; color: var(--sub-text-color); }
415
- .count-cell { font-weight: 600; color: var(--accent-color); }
416
- .date-cell { color: var(--sub-text-color); }
417
- .rank-gold, .rank-silver, .rank-bronze { font-weight: 600; }
418
- .rank-gold { color: var(--gold) !important; }
419
- .rank-silver { color: var(--silver) !important; }
420
- .rank-bronze { color: var(--bronze) !important; }
421
- .percent-cell { position: relative; }
422
- .percent-bar { position: absolute; top: 0; right: 0; height: 100%; background-color: var(--accent-color); opacity: 0.15; }
423
- .percent-text { position: relative; z-index: 1; }
424
- </style>
425
- </head>
426
- <body>${html}</body>
427
- </html>
428
- `, { waitUntil: "networkidle0" });
429
- const dimensions = await page.evaluate(() => {
430
- const el = document.body;
431
- return {
432
- width: el.scrollWidth,
433
- height: el.scrollHeight
434
- };
435
- });
226
+ await page.setContent(`<!DOCTYPE html><html><head><meta charset="UTF-8"><style>:root{--card-bg:#fff;--text-color:#111827;--header-color:#111827;--sub-text-color:#6b7280;--border-color:#e5e7eb;--accent-color:#4a6ee0;--chip-bg:#f3f4f6;--stripe-bg:#f9fafb;--gold:#f59e0b;--silver:#9ca3af;--bronze:#a16207}body{display:inline-block;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:0 0;margin:0;padding:8px;-webkit-font-smoothing:antialiased}.container{display:inline-block;background:var(--card-bg);border-radius:12px;padding:0;overflow:hidden;box-shadow:0 2px 4px rgba(0,0,0,.05)}.header{padding:10px 14px}.header-table{border-collapse:collapse;width:100%}.header-table-left,.header-table-right{width:1%;white-space:nowrap}.header-table-left{text-align:left}.header-table-center{text-align:center}.header-table-right{text-align:right}.title-text{font-size:18px;font-weight:600;color:var(--header-color);margin:0}.stat-chip,.time-label{display:inline-flex;align-items:baseline;padding:4px 8px;border-radius:8px;background:var(--chip-bg);font-size:13px;color:var(--sub-text-color)}.stat-chip span{font-weight:600;color:var(--text-color);margin-left:4px}.table-container{border-top:1px solid var(--border-color)}.main-table{border-collapse:collapse;width:100%}.main-table th,.main-table td{padding:8px 14px;vertical-align:middle}.main-table th{font-size:12px;font-weight:500;color:var(--sub-text-color);text-transform:uppercase;letter-spacing:.05em;background:var(--stripe-bg)}.main-table td{font-size:14px;color:var(--text-color)}.main-table tbody tr:nth-child(even){background-color:var(--stripe-bg)}.main-table .name-cell,.main-table .name-header{text-align:left}.main-table .rank-cell,.main-table .count-cell,.main-table .date-cell,.main-table .percent-cell,.main-table .header-right-align{text-align:right;white-space:nowrap;width:1%;font-variant-numeric:tabular-nums}.name-cell{font-weight:500}.rank-cell{font-weight:500;color:var(--sub-text-color)}.count-cell{font-weight:600;color:var(--accent-color)}.date-cell{color:var(--sub-text-color)}.rank-gold,.rank-silver,.rank-bronze{font-weight:600}.rank-gold{color:var(--gold)!important}.rank-silver{color:var(--silver)!important}.rank-bronze{color:var(--bronze)!important}.percent-cell{position:relative}.percent-bar{position:absolute;top:0;right:0;height:100%;background-color:var(--accent-color);opacity:.15}.percent-text{position:relative;z-index:1}</style></head><body>${html}</body></html>`, { waitUntil: "networkidle0" });
227
+ const dimensions = await page.evaluate(() => ({ width: document.body.scrollWidth, height: document.body.scrollHeight }));
436
228
  await page.setViewport({ ...dimensions, deviceScaleFactor: 2 });
437
229
  return await page.screenshot({ type: "png", fullPage: true, omitBackground: true });
438
230
  } catch (error) {
439
- this.ctx.logger.error("图片渲染出错:", error);
231
+ this.ctx.logger.error("图片渲染失败:", error);
232
+ return null;
440
233
  } finally {
441
- if (page) await page.close().catch(() => {
442
- });
234
+ if (page) await page.close().catch((e) => this.ctx.logger.error("关闭页面失败:", e));
443
235
  }
444
236
  }
445
237
  /**
446
238
  * @private
447
239
  * @method formatDate
448
- * @description
449
- * `Date` 对象格式化为人类友好的相对时间字符串。
450
- * @param {Date} date - 需要格式化的日期对象。
451
- * @returns {string} - 格式化后的时间字符串。
240
+ * @description 将 `Date` 对象格式化为易于理解的相对时间或绝对日期字符串。
241
+ * @param date - 需要格式化的日期对象。
242
+ * @returns 格式化后的时间字符串。
452
243
  */
453
244
  formatDate(date) {
454
245
  if (!date) return "未知";
455
246
  const diff = Date.now() - date.getTime();
456
247
  if (diff < import_koishi2.Time.minute) return "刚刚";
457
- if (diff > 365 * import_koishi2.Time.day) {
458
- return date.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).replace(/\//g, "-");
459
- }
460
- const timeUnits = [
461
- ["月", 30 * import_koishi2.Time.day],
462
- ["天", import_koishi2.Time.day],
463
- ["时", import_koishi2.Time.hour],
464
- ["分", import_koishi2.Time.minute]
465
- ];
466
- let remainingDiff = diff;
467
- const parts = [];
248
+ if (diff > 365 * import_koishi2.Time.day) return date.toLocaleDateString("zh-CN").replace(/\//g, "-");
249
+ const timeUnits = [["", 30 * import_koishi2.Time.day], ["", import_koishi2.Time.day], ["", import_koishi2.Time.hour], ["", import_koishi2.Time.minute]];
468
250
  for (const [unit, ms] of timeUnits) {
469
- if (remainingDiff >= ms) {
470
- const value = Math.floor(remainingDiff / ms);
471
- parts.push(`${value}${unit}`);
472
- remainingDiff %= ms;
251
+ if (diff >= ms) {
252
+ return `${Math.floor(diff / ms)}${unit}前`;
473
253
  }
474
254
  }
475
- const result = parts.slice(0, 2).join("");
476
- return `${result}前`;
255
+ return "刚刚";
477
256
  }
478
257
  /**
479
258
  * @public
480
259
  * @method renderList
481
- * @description
482
- * 接收一个标准化的 `ListRenderData` 对象和可选的表头数组,
483
- * 然后构建一个包含标题、统计信息和数据表格的完整HTML卡片。
484
- * @param {ListRenderData} data - 包含渲染所需全部信息的对象。
485
- * @param {string[]} [headers] - (可选) 表格的表头字符串数组。如果提供,将渲染表头。
486
- * @returns {Promise<string | Buffer>}
487
- * 如果成功,返回包含PNG图片的 Buffer。如果输入的数据列表为空,则返回一个提示性字符串。
260
+ * @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML 卡片为图片。
261
+ * @param data - 包含渲染所需全部信息的对象。
262
+ * @param headers - (可选) 表格的表头字符串数组。
263
+ * @returns 成功时返回包含 PNG 图片的 Buffer,若列表为空则返回提示字符串。
488
264
  */
489
265
  async renderList(data, headers) {
490
266
  const { title, time, list } = data;
491
267
  if (!list?.length) return "暂无数据可供渲染";
492
- let totalValueForPercent = 0;
493
- const countHeaderIndex = headers?.findIndex((h3) => ["总计发言", "条数", "次数", "数量"].includes(h3));
494
- if (countHeaderIndex > -1) {
495
- totalValueForPercent = list.reduce((sum, row) => sum + (Number(row[countHeaderIndex]) || 0), 0);
496
- }
497
- const totalCount = data.total || totalValueForPercent;
498
- const tableHeadHtml = headers?.length > 0 ? `<thead><tr><th class="rank-cell">#</th>${headers.map((h3, i) => {
499
- const firstCell = list[0]?.[i];
500
- const isRightAlign = typeof firstCell === "number" || firstCell instanceof Date || h3.includes("占比");
501
- const alignClass = isRightAlign ? "header-right-align" : "name-header";
502
- return `<th class="${alignClass}">${h3}</th>`;
503
- }).join("")}</tr></thead>` : "";
268
+ const countHeaderIndex = headers?.findIndex((h4) => ["总计发言", "条数", "次数", "数量"].includes(h4)) ?? -1;
269
+ const totalValue = countHeaderIndex > -1 ? list.reduce((sum, row) => sum + (Number(row[countHeaderIndex]) || 0), 0) : 0;
270
+ const totalCount = data.total || totalValue;
271
+ const renderCell = /* @__PURE__ */ __name((cell, i) => {
272
+ const headerText = headers?.[i] || "";
273
+ if (headerText.includes("占比")) {
274
+ const percentValue = parseFloat(String(cell).replace("%", ""));
275
+ return `<td class="percent-cell"><div class="percent-bar" style="width: ${percentValue}%;"></div><span class="percent-text">${cell}</span></td>`;
276
+ }
277
+ if (cell instanceof Date) return `<td class="date-cell">${this.formatDate(cell)}</td>`;
278
+ if (typeof cell === "number") return `<td class="count-cell">${cell.toLocaleString()}</td>`;
279
+ return `<td class="name-cell">${String(cell)}</td>`;
280
+ }, "renderCell");
281
+ const tableHeadHtml = headers?.length ? `<thead><tr><th class="rank-cell">#</th>${headers.map((h4) => `<th class="${typeof list[0]?.[headers.indexOf(h4)] === "string" ? "name-header" : "header-right-align"}">${h4}</th>`).join("")}</tr></thead>` : "";
504
282
  const tableRowsHtml = list.map((row, index) => {
505
283
  const rank = index + 1;
506
284
  const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
507
- const rankCell = `<td class="rank-cell ${rankClass}">${rank}</td>`;
508
- const dataCells = row.map((cell, i) => {
509
- let className = "";
510
- let content;
511
- const headerText = headers?.[i] || "";
512
- if (headerText.includes("占比")) {
513
- className = "percent-cell";
514
- const percentValue = parseFloat(String(cell).replace("%", ""));
515
- content = `<div class="percent-bar" style="width: ${percentValue}%;"></div><span class="percent-text">${cell}</span>`;
516
- } else if (cell instanceof Date) {
517
- className = "date-cell";
518
- content = this.formatDate(cell);
519
- } else if (typeof cell === "number") {
520
- className = "count-cell";
521
- content = cell.toLocaleString();
522
- } else {
523
- className = "name-cell";
524
- content = String(cell);
525
- }
526
- return `<td class="${className}">${content}</td>`;
527
- }).join("");
528
- return `<tr>${rankCell}${dataCells}</tr>`;
285
+ return `<tr><td class="rank-cell ${rankClass}">${rank}</td>${row.map(renderCell).join("")}</tr>`;
529
286
  }).join("");
530
- const cardHtml = `
531
- <div class="container">
532
- <div class="header">
533
- <table class="header-table">
534
- <tr>
535
- <td class="header-table-left">
536
- <div class="stat-chip">总计: <span>${typeof totalCount === "number" ? totalCount.toLocaleString() : totalCount}</span></div>
537
- </td>
538
- <td class="header-table-center">
539
- <h1 class="title-text">${title}</h1>
540
- </td>
541
- <td class="header-table-right">
542
- <div class="time-label">${time.toLocaleString("zh-CN", { hour12: false }).replace(/\//g, "-")}</div>
543
- </td>
544
- </tr>
545
- </table>
546
- </div>
547
- <div class="table-container">
548
- <table class="main-table">${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table>
549
- </div>
550
- </div>
551
- `;
287
+ const cardHtml = `<div class="container"><div class="header"><table class="header-table"><tr><td class="header-table-left"><div class="stat-chip">总计: <span>${typeof totalCount === "number" ? totalCount.toLocaleString() : totalCount}</span></div></td><td class="header-table-center"><h1 class="title-text">${title}</h1></td><td class="header-table-right"><div class="time-label">${time.toLocaleString("zh-CN", { hour12: false }).replace(/\//g, "-")}</div></td></tr></table></div><div class="table-container"><table class="main-table">${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table></div></div>`;
552
288
  return this.htmlToImage(cardHtml);
553
289
  }
554
290
  };
@@ -556,298 +292,139 @@ var Renderer = class {
556
292
  // src/Stat.ts
557
293
  var Stat = class {
558
294
  /**
559
- * @constructor
560
- * @param {Context} ctx - Koishi 的插件上下文。
561
- * @param {Config} config - 插件的配置对象。
295
+ * @param ctx - Koishi 的插件上下文。
296
+ * @param config - 插件的配置对象。
562
297
  */
563
298
  constructor(ctx, config) {
564
299
  this.ctx = ctx;
565
300
  this.config = config;
566
301
  this.renderer = new Renderer(ctx);
567
- this.setupCleanupTask();
568
- }
569
- static {
570
- __name(this, "Stat");
571
- }
572
- renderer;
573
- /**
574
- * @private
575
- * @method setupCleanupTask
576
- * @description 设置一个定时清理任务。
577
- * 此任务会根据配置中的 `rankRetentionDays` 定期删除过期的发言排行数据,以防止数据库膨胀。
578
- */
579
- setupCleanupTask() {
580
302
  if (this.config.rankRetentionDays > 0) {
581
303
  this.ctx.cron("0 0 * * *", async () => {
582
- try {
583
- const cutoffDate = new Date(Date.now() - this.config.rankRetentionDays * import_koishi3.Time.day);
584
- await this.ctx.database.remove("analyse_rank", { timestamp: { $lt: cutoffDate } });
585
- } catch (error) {
586
- this.ctx.logger.error("清理发言排行历史记录出错:", error);
587
- }
304
+ const cutoffDate = new Date(Date.now() - this.config.rankRetentionDays * import_koishi3.Time.day);
305
+ await this.ctx.database.remove("analyse_rank", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理发言排行历史记录失败:", e));
588
306
  });
589
307
  }
590
308
  }
309
+ static {
310
+ __name(this, "Stat");
311
+ }
312
+ renderer;
591
313
  /**
592
- * @public
593
- * @method registerCommands
594
- * @description 根据插件配置,动态地将 `.cmd`, `.msg`, `.rank` 子命令注册到主 `analyse` 命令下。
595
- * @param {Command} analyse - 主 `analyse` 命令实例。
314
+ * @public @method registerCommands
315
+ * @description 根据配置,动态地将 `.cmd`, `.msg`, `.rank` 子命令注册到主 `analyse` 命令下。
316
+ * @param analyse - `analyse` 命令实例。
596
317
  */
597
318
  registerCommands(analyse) {
598
- if (this.config.enableCmdStat) {
599
- analyse.subcommand(".cmd", "命令使用统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 展示全局统计").action(async ({ session, options }) => {
600
- const scope = this.parseQueryScope(session, options);
601
- if (scope.error) return scope.error;
602
- try {
603
- const stats = await this.getCommandStats(scope.guildId, scope.userId);
604
- if (typeof stats === "string") return stats;
605
- const title = await this.generateTitle(scope.guildId, scope.userId, { main: "命令" });
606
- const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: stats.list };
607
- const result = await this.renderer.renderList(renderData, ["命令", "次数", "最后使用"]);
608
- return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
609
- } catch (error) {
610
- this.ctx.logger.error("渲染命令统计图片失败:", error);
611
- return "渲染命令统计图片失败";
612
- }
613
- });
614
- }
615
- if (this.config.enableMsgStat) {
616
- analyse.subcommand(".msg", "消息发送统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a 展示全局统计").action(async ({ session, options }) => {
617
- const scope = this.parseQueryScope(session, options);
319
+ const createHandler = /* @__PURE__ */ __name((handler) => {
320
+ return async ({ session, options }) => {
321
+ const scope = await this.parseQueryScope(session, options);
618
322
  if (scope.error) return scope.error;
619
323
  try {
620
- if (options.type) {
621
- const stats = await this.getMessageStatsByType(options.type, scope.guildId, scope.userId);
622
- if (typeof stats === "string") return stats;
623
- const title = await this.generateTitle(scope.guildId, scope.userId, { main: "消息", subtype: options.type });
624
- const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: stats.list };
625
- const result = await this.renderer.renderList(renderData, ["用户", "条数", "最后发言"]);
626
- return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
627
- } else {
628
- const stats = await this.getUserMessageStats(scope.guildId, scope.userId);
629
- if (typeof stats === "string") return stats;
630
- const title = await this.generateTitle(scope.guildId, scope.userId, { main: "消息" });
631
- const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: stats.list };
632
- const result = await this.renderer.renderList(renderData, ["用户", "总计发言", "最后发言"]);
633
- return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
634
- }
635
- } catch (error) {
636
- this.ctx.logger.error("渲染消息统计图片失败:", error);
637
- return "渲染消息统计图片失败";
638
- }
639
- });
640
- }
641
- if (this.config.enableRankStat) {
642
- analyse.subcommand(".rank", "用户发言排行").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).option("all", "-a 展示全局统计").action(async ({ session, options }) => {
643
- const guildId = options.all ? void 0 : options.guild || session.guildId;
644
- if (!guildId && !options.all) return "请提供查询范围";
645
- try {
646
- const stats = await this.getActiveUserStats(options.hours, guildId, options.type);
647
- if (typeof stats === "string") return stats;
648
- const listWithPercentage = stats.list.map((row) => [
649
- ...row,
650
- stats.total > 0 ? `${(row[1] / stats.total * 100).toFixed(2)}%` : "0.00%"
651
- ]);
652
- const title = await this.generateTitle(guildId, void 0, { main: "排行", timeRange: options.hours, subtype: options.type });
653
- const renderData = { title, time: /* @__PURE__ */ new Date(), total: stats.total, list: listWithPercentage };
654
- const result = await this.renderer.renderList(renderData, ["用户", "总计发言", "占比"]);
655
- return Buffer.isBuffer(result) ? import_koishi3.Element.image(result, "image/png") : result;
324
+ const result = await handler(scope, options);
325
+ return Buffer.isBuffer(result) ? import_koishi3.h.image(result, "image/png") : result;
656
326
  } catch (error) {
657
- this.ctx.logger.error("渲染发言排行图片失败:", error);
658
- return "渲染发言排行图片失败";
327
+ this.ctx.logger.error("渲染统计图片失败:", error);
328
+ return "渲染统计图片失败";
659
329
  }
660
- });
661
- }
662
- }
663
- /**
664
- * @private
665
- * @method parseQueryScope
666
- * @description 解析命令的选项,将其转换为统一的查询范围对象(userId guildId)。
667
- * @param {Session} session - 当前会话对象。
668
- * @param {QueryScopeOptions} options - 命令传入的选项。
669
- * @returns {QueryScopeResult} 包含 userId、guildId 或 error 信息的查询范围对象。
670
- */
671
- parseQueryScope(session, options) {
672
- let userId, guildId;
673
- if (options.user) {
674
- const atElements = import_koishi3.h.select(options.user, "at");
675
- if (atElements.length > 0) {
676
- userId = atElements[0].attrs.id;
330
+ };
331
+ }, "createHandler");
332
+ if (this.config.enableCmdStat) analyse.subcommand(".cmd", "命令使用统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(createHandler(async (scope) => {
333
+ const stats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: scope.uids } }).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
334
+ if (stats.length === 0) return "暂无匹配指令统计数据";
335
+ const total = stats.reduce((sum, record) => sum + record.count, 0);
336
+ const list = stats.map((item) => [item.command, item.count, item.lastUsed]);
337
+ const title = await this.generateTitle(scope.scopeDesc, { main: "命令使用" });
338
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["命令", "次数", "最后使用"]);
339
+ }));
340
+ if (this.config.enableMsgStat) analyse.subcommand(".msg", "消息发送统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a 全局").action(createHandler(async (scope, options) => {
341
+ const type = options.type;
342
+ if (type) {
343
+ const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
344
+ const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
345
+ const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: scope.uids }, type }).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
346
+ if (stats.length === 0) return `暂无“${type}”类型消息数据`;
347
+ const total = stats.reduce((sum, r) => sum + r.count, 0);
348
+ const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
349
+ const title = await this.generateTitle(scope.scopeDesc, { main: "消息", subtype: type });
350
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["用户", "条数", "最后发言"]);
677
351
  } else {
678
- userId = options.user.trim();
352
+ const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
353
+ const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
354
+ const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: scope.uids } }).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
355
+ if (stats.length === 0) return "暂无消息数据";
356
+ const total = stats.reduce((sum, r) => sum + r.count, 0);
357
+ const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
358
+ const title = await this.generateTitle(scope.scopeDesc, { main: "消息发送" });
359
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["用户", "总计发言", "最后发言"]);
679
360
  }
680
- }
681
- if (options.guild) guildId = options.guild;
682
- if (options.all) return { userId, guildId: void 0 };
683
- if (!userId && !guildId) {
684
- if (session.guildId) return { guildId: session.guildId };
685
- return { error: "请提供查询范围" };
686
- }
687
- return { userId, guildId };
361
+ }));
362
+ if (this.config.enableRankStat) analyse.subcommand(".rank", "用户发言排行").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).option("all", "-a 全局").action(async ({ session, options }) => {
363
+ const guildId = options.all ? void 0 : options.guild || session.guildId;
364
+ if (!guildId && !options.all) return "请指定群组或查询全局";
365
+ try {
366
+ const { hours, type } = options;
367
+ const since = new Date(Date.now() - hours * import_koishi3.Time.hour);
368
+ const baseQuery = { timestamp: { $gte: since } };
369
+ if (type) baseQuery.type = type;
370
+ const uidsInScope = guildId ? (await this.ctx.database.get("analyse_user", { channelId: guildId }, ["uid"])).map((u) => u.uid) : void 0;
371
+ if (guildId && uidsInScope.length === 0) return "暂无指定时段内发言记录";
372
+ if (uidsInScope) baseQuery.uid = { $in: uidsInScope };
373
+ const rankStats = await this.ctx.database.select("analyse_rank").where(baseQuery).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").limit(100).execute();
374
+ if (rankStats.length === 0) return "暂无指定时段内发言记录";
375
+ const uids = rankStats.map((s) => s.uid);
376
+ const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName"]);
377
+ const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
378
+ const total = rankStats.reduce((sum, record) => sum + record.count, 0);
379
+ const list = rankStats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count]);
380
+ const listWithPercentage = list.map((row) => [...row, total > 0 ? `${(row[1] / total * 100).toFixed(2)}%` : "0.00%"]);
381
+ const title = await this.generateTitle({ guildId }, { main: "发言排行", timeRange: hours, subtype: type });
382
+ const result = await this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
383
+ return Buffer.isBuffer(result) ? import_koishi3.h.image(result, "image/png") : result;
384
+ } catch (error) {
385
+ this.ctx.logger.error("渲染发言排行图片失败:", error);
386
+ return "渲染发言排行图片失败";
387
+ }
388
+ });
688
389
  }
689
390
  /**
690
- * @private
691
- * @async
692
- * @method getUidsInScope
693
- * @description 根据查询范围(guildId, userId)获取匹配用户的 UID 列表。
694
- * @param {string} [guildId] - (可选) 群组 ID。
695
- * @param {string} [userId] - (可选) 用户 ID。
696
- * @returns {Promise<{ uids?: number[], error?: string }>} 包含 UID 数组或错误信息的对象。
391
+ * @private @method parseQueryScope
392
+ * @description 解析命令选项,转换为包含 UIDs 和描述性信息的统一查询范围对象。
393
+ * @param session - 当前会话对象。
394
+ * @param options - 命令选项。
395
+ * @returns 包含 uids、错误或范围描述的查询范围对象。
697
396
  */
698
- async getUidsInScope(guildId, userId) {
397
+ async parseQueryScope(session, options) {
398
+ const scopeDesc = { guildId: options.guild, userId: void 0 };
399
+ if (options.user) scopeDesc.userId = import_koishi3.h.select(options.user, "at")[0]?.attrs.id ?? options.user.trim();
400
+ if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId;
401
+ if (!options.all && !scopeDesc.guildId) return { error: "请指定群组或查询全局", scopeDesc };
699
402
  const query = {};
700
- if (guildId) query.channelId = guildId;
701
- if (userId) query.userId = userId;
403
+ if (scopeDesc.guildId) query.channelId = scopeDesc.guildId;
404
+ if (scopeDesc.userId) query.userId = scopeDesc.userId;
702
405
  const users = await this.ctx.database.get("analyse_user", query, ["uid"]);
703
- if (users.length === 0) return { error: "暂无统计数据" };
704
- return { uids: users.map((u) => u.uid) };
406
+ if (users.length === 0) return { error: "在指定范围内未找到任何记录", scopeDesc };
407
+ return { uids: users.map((u) => u.uid), scopeDesc };
705
408
  }
706
409
  /**
707
- * @private
708
- * @async
709
- * @method generateTitle
710
- * @description 通用的标题生成器。根据查询参数和类型选项动态生成易于理解的图片标题。
711
- * @param {string} [guildId] - (可选) 查询的群组 ID。
712
- * @param {string} [userId] - (可选) 查询的用户 ID。
713
- * @param {object} options - 标题的配置选项。
714
- * @param {'命令' | '消息' | '排行'} options.main - 标题主类型。
715
- * @param {string} [options.subtype] - (可选) 消息类型的子类型。
716
- * @param {number} [options.timeRange] - (可选) 排行的时间范围(小时)。
717
- * @returns {Promise<string>} 生成的标题字符串。
410
+ * @private @method generateTitle
411
+ * @description 根据查询范围和类型动态生成易于理解的图片标题。
412
+ * @returns 生成的标题字符串。
718
413
  */
719
- async generateTitle(guildId, userId, options) {
414
+ async generateTitle(scopeDesc, options) {
720
415
  let scopeText = "全局";
721
- if (userId && guildId) {
722
- const user = await this.ctx.database.get("analyse_user", { channelId: guildId, userId }, ["userName"]);
723
- const guild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["channelName"]);
724
- scopeText = `${user[0]?.userName || userId} 在 ${guild[0]?.channelName || guildId}`;
725
- } else if (userId) {
726
- const user = await this.ctx.database.get("analyse_user", { userId }, ["userName"]);
727
- scopeText = `${user[0]?.userName || userId}的全局`;
728
- } else if (guildId) {
729
- const guild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["channelName"]);
730
- scopeText = guild[0]?.channelName || guildId;
416
+ if (scopeDesc.guildId) {
417
+ const [guild] = await this.ctx.database.get("analyse_user", { channelId: scopeDesc.guildId }, ["channelName"]);
418
+ scopeText = guild?.channelName || scopeDesc.guildId;
731
419
  }
732
- if (options.main === "排行") {
733
- const typeText = options.subtype ? `“${options.subtype}”` : "";
734
- return `${scopeText}${options.timeRange}小时${typeText}消息排行`;
735
- }
736
- if (options.main === "消息" && options.subtype) return `${scopeText}“${options.subtype}”消息统计`;
737
- return `${scopeText}${options.main}统计`;
738
- }
739
- /**
740
- * @private
741
- * @async
742
- * @method getCommandStats
743
- * @description 从数据库中获取并聚合命令使用统计数据。
744
- * @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
745
- * @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
746
- * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
747
- */
748
- async getCommandStats(guildId, userId) {
749
- const { uids, error } = await this.getUidsInScope(guildId, userId);
750
- if (error) return error;
751
- const stats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: uids } }).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
752
- if (stats.length === 0) return "暂无统计数据";
753
- const total = stats.reduce((sum, record) => sum + record.count, 0);
754
- const list = stats.map((item) => [item.command, item.count, item.lastUsed]);
755
- return { list, total };
756
- }
757
- /**
758
- * @private
759
- * @async
760
- * @method getUserMessageStats
761
- * @description 从数据库中获取并聚合每个用户的消息统计数据。
762
- * @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
763
- * @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
764
- * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
765
- */
766
- async getUserMessageStats(guildId, userId) {
767
- const query = {};
768
- if (guildId) query.channelId = guildId;
769
- if (userId) query.userId = userId;
770
- const users = await this.ctx.database.get("analyse_user", query, ["uid", "userName"]);
771
- if (users.length === 0) return "暂无统计数据";
772
- const uids = users.map((u) => u.uid);
773
- const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
774
- const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids } }).groupBy("uid", {
775
- count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"),
776
- lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed")
777
- }).orderBy("count", "desc").execute();
778
- if (stats.length === 0) return "暂无统计数据";
779
- const total = stats.reduce((sum, record) => sum + record.count, 0);
780
- const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
781
- return { list, total };
782
- }
783
- /**
784
- * @private
785
- * @async
786
- * @method getMessageStatsByType
787
- * @description 按指定消息类型,从数据库中获取并聚合用户排行数据。
788
- * @param {string} type - 要查询的消息类型。
789
- * @param {string} [guildId] - (可选) 若提供,则将范围限制在此群组。
790
- * @param {string} [userId] - (可选) 若提供,则将范围限制在此用户。
791
- * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
792
- */
793
- async getMessageStatsByType(type, guildId, userId) {
794
- const query = {};
795
- if (guildId) query.channelId = guildId;
796
- if (userId) query.userId = userId;
797
- const users = await this.ctx.database.get("analyse_user", query, ["uid", "userName"]);
798
- if (users.length === 0) return "暂无统计数据";
799
- const uids = users.map((u) => u.uid);
800
- const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
801
- const stats = await this.ctx.database.select("analyse_msg").where({ uid: { $in: uids }, type }).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
802
- if (stats.length === 0) return `暂无统计数据`;
803
- const total = stats.reduce((sum, record) => sum + record.count, 0);
804
- const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
805
- return { list, total };
806
- }
807
- /**
808
- * @private
809
- * @async
810
- * @method getActiveUserStats
811
- * @description 从数据库中获取并聚合指定时间范围内的活跃用户排行数据。
812
- * @param {number} hours - 查询过去的小时数。
813
- * @param {string} [guildId] - (可选) 要查询的群组 ID。若不提供,则进行全局排行。
814
- * @param {string} [type] - (可选) 要筛选的消息类型。
815
- * @returns {Promise<{ list: RenderListItem[], total: number } | string>} 返回一个包含列表和总数的对象,或在无数据时返回提示字符串。
816
- */
817
- async getActiveUserStats(hours, guildId, type) {
818
- const since = new Date(Date.now() - hours * 3600 * 1e3);
819
- const baseQuery = { timestamp: { $gte: since } };
820
- if (type) baseQuery.type = type;
821
- if (guildId) {
822
- const usersInGuild = await this.ctx.database.get("analyse_user", { channelId: guildId }, ["uid", "userName"]);
823
- if (usersInGuild.length === 0) return "暂无统计数据";
824
- const uids = usersInGuild.map((u) => u.uid);
825
- const userNameMap = new Map(usersInGuild.map((u) => [u.uid, u.userName]));
826
- const query = { ...baseQuery, uid: { $in: uids } };
827
- const stats = await this.ctx.database.select("analyse_rank").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").limit(100).execute();
828
- if (stats.length === 0) return "暂无统计数据";
829
- const total = stats.reduce((sum, record) => sum + record.count, 0);
830
- const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count]);
831
- return { list, total };
832
- } else {
833
- const msgStats = await this.ctx.database.select("analyse_rank").where(baseQuery).project(["uid", "count"]).execute();
834
- if (msgStats.length === 0) return "暂无统计数据";
835
- const allUsers = await this.ctx.database.get("analyse_user", {}, ["uid", "userId", "userName"]);
836
- const uidToUserMap = new Map(allUsers.map((u) => [u.uid, { userId: u.userId, userName: u.userName }]));
837
- const userCounts = /* @__PURE__ */ new Map();
838
- for (const msg of msgStats) {
839
- const userInfo = uidToUserMap.get(msg.uid);
840
- if (userInfo) {
841
- const existing = userCounts.get(userInfo.userId);
842
- userCounts.set(userInfo.userId, { count: (existing?.count || 0) + msg.count, name: userInfo.userName });
843
- }
844
- }
845
- if (userCounts.size === 0) return "暂无统计数据";
846
- const grandTotal = Array.from(userCounts.values()).reduce((sum, data) => sum + data.count, 0);
847
- const sortedUsers = Array.from(userCounts.entries()).sort(([, a], [, b]) => b.count - a.count).slice(0, 100);
848
- const list = sortedUsers.map(([userId, data]) => [data.name || userId, data.count]);
849
- return { list, total: grandTotal };
420
+ if (scopeDesc.userId) {
421
+ const [user] = await this.ctx.database.get("analyse_user", { userId: scopeDesc.userId }, ["userName"]);
422
+ const userName = user?.userName || scopeDesc.userId;
423
+ scopeText = scopeDesc.guildId ? `${userName} 在 ${scopeText}` : `${userName} 的全局`;
850
424
  }
425
+ const typeText = options.subtype ? `“${options.subtype}”` : "";
426
+ if (options.main.includes("排行")) return `${scopeText}${options.timeRange}小时${typeText}消息排行`;
427
+ return `${scopeText}${typeText}${options.main}统计`;
851
428
  }
852
429
  };
853
430
 
@@ -855,65 +432,50 @@ var Stat = class {
855
432
  var import_koishi4 = require("koishi");
856
433
  var WhoAt = class {
857
434
  /**
858
- * WhoAt 类的构造函数。
859
- * @param {Context} ctx - Koishi 的插件上下文,用于访问框架核心功能和数据库等服务。
860
- * @param {Config} config - 插件的配置对象,包含如记录保留天数等设置。
435
+ * @param ctx - Koishi 的插件上下文。
436
+ * @param config - 插件的配置对象。
861
437
  */
862
438
  constructor(ctx, config) {
863
439
  this.ctx = ctx;
864
440
  this.config = config;
865
- this.setupCleanupTask();
866
- }
867
- static {
868
- __name(this, "WhoAt");
869
- }
870
- /**
871
- * @private
872
- * @method setupCleanupTask
873
- * @description 设置一个定时清理任务。
874
- * 此任务会根据配置中的 `atRetentionDays` 定期删除过期的@记录,以防止数据库膨胀。
875
- */
876
- setupCleanupTask() {
877
441
  if (this.config.atRetentionDays > 0) {
878
442
  this.ctx.cron("0 0 * * *", async () => {
879
- try {
880
- const cutoffDate = new Date(Date.now() - this.config.atRetentionDays * import_koishi4.Time.day);
881
- await this.ctx.database.remove("analyse_at", { timestamp: { $lt: cutoffDate } });
882
- } catch (error) {
883
- this.ctx.logger.error("清理 @ 历史记录出错:", error);
884
- }
443
+ const cutoffDate = new Date(Date.now() - this.config.atRetentionDays * import_koishi4.Time.day);
444
+ await this.ctx.database.remove("analyse_at", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理 @ 历史记录失败:", e));
885
445
  });
886
446
  }
887
447
  }
448
+ static {
449
+ __name(this, "WhoAt");
450
+ }
888
451
  /**
889
- * @public
890
- * @method registerCommand
452
+ * @public @method registerCommand
891
453
  * @description 在主 `analyse` 命令下注册 `whoatme` 子命令。
892
- * @param {Command} analyse - 用户传入的主 `analyse` 命令实例,`whoatme` 将作为其子命令。
454
+ * @param analyse - `analyse` 命令实例。
893
455
  */
894
456
  registerCommand(analyse) {
895
- analyse.subcommand("whoatme", "谁 @ 我").action(async ({ session }) => {
457
+ analyse.subcommand(".whoatme", "谁@我").action(async ({ session }) => {
896
458
  if (!session.userId) return "无法获取用户信息";
897
459
  try {
898
- const records = await this.ctx.database.select("analyse_at").where({ target: session.userId }).orderBy("timestamp", "asc").limit(100).execute();
899
- if (records.length === 0) return "暂无 @ 记录";
460
+ const records = await this.ctx.database.get("analyse_at", { target: session.userId }, {
461
+ sort: { timestamp: "asc" },
462
+ limit: 100
463
+ });
464
+ if (records.length === 0) return "最近没有人提及您";
900
465
  const uids = [...new Set(records.map((r) => r.uid))];
901
- const users = await this.ctx.database.select("analyse_user", { uid: { $in: uids } }).project(["uid", "userName", "userId"]).execute();
466
+ const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName", "userId"]);
902
467
  const userInfoMap = new Map(users.map((u) => [u.uid, { name: u.userName, id: u.userId }]));
903
468
  const messageElements = records.map((record) => {
904
- const senderInfo = userInfoMap.get(record.uid);
905
- const userId = senderInfo.id;
906
- const userName = senderInfo.name || userId;
907
- const authorElement = (0, import_koishi4.h)("author", { userId, name: userName });
908
- const contentElement = import_koishi4.h.text(record.content);
909
- return (0, import_koishi4.h)("message", {}, [authorElement, contentElement]);
469
+ const senderInfo = userInfoMap.get(record.uid) ?? { name: "未知用户", id: "0" };
470
+ return (0, import_koishi4.h)("message", {}, [
471
+ (0, import_koishi4.h)("author", { userId: senderInfo.id, nickname: senderInfo.name }),
472
+ import_koishi4.h.text(record.content)
473
+ ]);
910
474
  });
911
- if (messageElements.length === 0) return "暂无有效 @ 记录";
912
- const forwardMessage = (0, import_koishi4.h)("message", { forward: true }, messageElements);
913
- await session.send(forwardMessage);
475
+ return (0, import_koishi4.h)("message", { forward: true }, messageElements);
914
476
  } catch (error) {
915
- this.ctx.logger.error("查询 @ 记录时失败:", error);
916
- return "查询失败,请稍后再试";
477
+ this.ctx.logger.error("查询 @ 记录失败:", error);
478
+ return "查询失败,请稍后重试";
917
479
  }
918
480
  });
919
481
  }
@@ -923,14 +485,8 @@ var WhoAt = class {
923
485
  var import_koishi5 = require("koishi");
924
486
  var fs = __toESM(require("fs/promises"));
925
487
  var path = __toESM(require("path"));
926
- var ALL_TABLES = [
927
- "analyse_user",
928
- "analyse_cmd",
929
- "analyse_msg",
930
- "analyse_rank",
931
- "analyse_at",
932
- "analyse_cache"
933
- ];
488
+ var ALL_TABLES = ["analyse_user", "analyse_cmd", "analyse_msg", "analyse_rank", "analyse_at", "analyse_cache"];
489
+ var BATCH_SIZE = 100;
934
490
  var Data = class {
935
491
  constructor(ctx) {
936
492
  this.ctx = ctx;
@@ -943,8 +499,8 @@ var Data = class {
943
499
  /**
944
500
  * @public
945
501
  * @method registerCommands
946
- * @description 在 'analyse.admin' 命令下注册所有数据管理相关的子命令。
947
- * @param {Command} analyse - '.admin' 命令实例。
502
+ * @description 在 `analyse` 命令下注册所有数据管理相关的子命令 (`.backup`, `.restore`, `.clear`, `.list`)。
503
+ * @param analyse - `analyse` 命令实例。
948
504
  */
949
505
  registerCommands(analyse) {
950
506
  analyse.subcommand(".backup", "备份统计数据", { authority: 4 }).action(async () => {
@@ -963,125 +519,89 @@ var Data = class {
963
519
  const userInfo = uidToUserInfoMap.get(record.uid);
964
520
  if (!userInfo) return null;
965
521
  const { uid, ...restOfRecord } = record;
966
- return {
967
- userId: userInfo.userId,
968
- channelId: userInfo.channelId,
969
- ...restOfRecord
970
- };
522
+ return { userId: userInfo.userId, channelId: userInfo.channelId, ...restOfRecord };
971
523
  }).filter(Boolean);
972
524
  }
973
525
  await fs.writeFile(filepath, JSON.stringify(dataToExport, null, 2));
974
526
  }
975
- return `数据备份成功`;
527
+ return "数据备份成功";
976
528
  } catch (error) {
977
529
  this.ctx.logger.error("数据备份失败:", error);
978
530
  return "数据备份失败";
979
531
  }
980
532
  });
981
533
  analyse.subcommand(".restore", "恢复统计数据", { authority: 4 }).action(async () => {
982
- const BATCH_SIZE = 100;
983
534
  try {
984
535
  const userTablePath = path.join(this.dataDir, "analyse_user.json");
985
- try {
986
- const usersToImport = JSON.parse(await fs.readFile(userTablePath, "utf-8"));
987
- if (Array.isArray(usersToImport) && usersToImport.length > 0) {
988
- for (let i = 0; i < usersToImport.length; i += BATCH_SIZE) {
989
- const batch = usersToImport.slice(i, i + BATCH_SIZE);
990
- await this.ctx.database.upsert("analyse_user", batch);
991
- }
992
- }
993
- } catch (e) {
994
- if (e.code !== "ENOENT") throw e;
995
- this.ctx.logger.warn("无用户数据可恢复");
996
- }
536
+ const usersToImport = JSON.parse(await fs.readFile(userTablePath, "utf-8").catch(() => "[]"));
537
+ if (usersToImport.length) for (let i = 0; i < usersToImport.length; i += BATCH_SIZE) await this.ctx.database.upsert("analyse_user", usersToImport.slice(i, i + BATCH_SIZE));
997
538
  const allUsers = await this.ctx.database.get("analyse_user", {});
998
539
  const userToUidMap = new Map(allUsers.map((u) => [`${u.channelId}:${u.userId}`, u.uid]));
999
540
  for (const tableName of ALL_TABLES.filter((t) => t !== "analyse_user")) {
1000
541
  const filepath = path.join(this.dataDir, `${tableName}.json`);
1001
- try {
1002
- const recordsToImport = JSON.parse(await fs.readFile(filepath, "utf-8"));
1003
- if (Array.isArray(recordsToImport) && recordsToImport.length > 0) {
1004
- const recordsWithUid = recordsToImport.map((r) => {
1005
- const uid = userToUidMap.get(`${r.channelId}:${r.userId}`);
1006
- if (!uid) return null;
1007
- const { userId, channelId, ...rest } = r;
1008
- return { uid, ...rest };
1009
- }).filter(Boolean);
1010
- if (recordsWithUid.length > 0) {
1011
- for (let i = 0; i < recordsWithUid.length; i += BATCH_SIZE) {
1012
- const batch = recordsWithUid.slice(i, i + BATCH_SIZE);
1013
- await this.ctx.database.upsert(tableName, batch);
1014
- }
1015
- }
1016
- }
1017
- } catch (e) {
1018
- if (e.code !== "ENOENT") throw e;
1019
- }
542
+ const recordsToImport = JSON.parse(await fs.readFile(filepath, "utf-8").catch(() => "[]"));
543
+ if (!recordsToImport.length) continue;
544
+ const recordsWithUid = recordsToImport.map((r) => {
545
+ const uid = userToUidMap.get(`${r.channelId}:${r.userId}`);
546
+ if (!uid) return null;
547
+ const { userId, channelId, ...rest } = r;
548
+ return { uid, ...rest };
549
+ }).filter(Boolean);
550
+ if (recordsWithUid.length > 0) for (let i = 0; i < recordsWithUid.length; i += BATCH_SIZE) await this.ctx.database.upsert(tableName, recordsWithUid.slice(i, i + BATCH_SIZE));
1020
551
  }
1021
- return `数据恢复成功`;
552
+ return "数据恢复成功";
1022
553
  } catch (error) {
1023
554
  this.ctx.logger.error("数据恢复失败:", error);
1024
555
  return "数据恢复失败";
1025
556
  }
1026
557
  });
1027
- analyse.subcommand(".clear", "清理统计数据", { authority: 4 }).option("table", "-t <table:string> 指定表").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("days", "-d <days:number> 指定天数").option("all", "-a 清理全部数据").action(async ({ options }) => {
1028
- if (!options.table && !options.guild && !options.user && !options.all && !options.days) return "请提供清理条件";
558
+ analyse.subcommand(".clear", "清理统计数据", { authority: 4 }).option("table", "-t <table:string> 指定表名").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("days", "-d <days:number> 指定天数").option("all", "-a 清理全部数据").action(async ({ options }) => {
559
+ if (Object.keys(options).length === 0) return "请提供清理条件";
1029
560
  try {
1030
561
  if (options.all) {
1031
- for (const tableName of ALL_TABLES) await this.ctx.database.remove(tableName, {});
562
+ await Promise.all(ALL_TABLES.map((tableName) => this.ctx.database.remove(tableName, {})));
1032
563
  return "已清除所有聊天分析数据";
1033
564
  }
1034
- let tablesToClear;
1035
- if (options.table) {
1036
- if (!ALL_TABLES.includes(options.table)) return `无效表名: ${options.table}`;
1037
- tablesToClear = [options.table];
1038
- } else {
1039
- tablesToClear = ALL_TABLES.filter((t) => t !== "analyse_user");
1040
- }
1041
- const queryParts = { query: {}, desc: "" };
565
+ const tablesToClear = options.table ? [options.table] : ALL_TABLES.filter((t) => t !== "analyse_user");
566
+ if (options.table && !ALL_TABLES.includes(options.table)) return `无效表名: ${options.table}。`;
567
+ const query = {};
1042
568
  const descParts = [];
1043
569
  if (options.guild || options.user) {
1044
- const uidsToClear = [];
1045
- const scopeDesc = [];
570
+ const userQuery = {};
1046
571
  if (options.guild) {
1047
- uidsToClear.push(...(await this.ctx.database.get("analyse_user", { channelId: options.guild })).map((u) => u.uid));
1048
- scopeDesc.push(`群组 ${options.guild} `);
572
+ userQuery.channelId = options.guild;
573
+ descParts.push(`群组 ${options.guild}`);
1049
574
  }
1050
575
  if (options.user) {
1051
- const userId = import_koishi5.Element.select(options.user, "at")[0]?.attrs.id || options.user;
1052
- uidsToClear.push(...(await this.ctx.database.get("analyse_user", { userId })).map((u) => u.uid));
1053
- scopeDesc.push(`用户 ${userId} `);
576
+ userQuery.userId = import_koishi5.Element.select(options.user, "at")[0]?.attrs.id ?? options.user;
577
+ descParts.push(`用户 ${userQuery.userId}`);
1054
578
  }
1055
- const uniqueUids = [...new Set(uidsToClear)];
1056
- if (uniqueUids.length === 0) return "未找到该用户";
1057
- queryParts.query.uid = { $in: uniqueUids };
1058
- descParts.push(scopeDesc.join("、"));
579
+ const uidsToClear = (await this.ctx.database.get("analyse_user", userQuery)).map((u) => u.uid);
580
+ if (uidsToClear.length === 0) return "未找到匹配记录";
581
+ query.uid = { $in: [...new Set(uidsToClear)] };
1059
582
  }
1060
- if (options.days && options.days > 0) {
1061
- queryParts.query.timestamp = { $lt: new Date(Date.now() - options.days * import_koishi5.Time.day) };
583
+ if (options.days > 0) {
584
+ query.timestamp = { $lt: new Date(Date.now() - options.days * import_koishi5.Time.day) };
1062
585
  descParts.push(`超过 ${options.days} 天`);
1063
586
  }
1064
- for (const tableName of tablesToClear) {
1065
- const finalQuery = { ...queryParts.query };
1066
- if (tableName === "analyse_user" && finalQuery.timestamp) delete finalQuery.timestamp;
1067
- await this.ctx.database.remove(tableName, finalQuery);
1068
- }
1069
- const targetStr = options.table ? `表 ${options.table} ` : "所有表";
1070
- const conditionStr = descParts.join(" 且 ");
1071
- const finalDescription = conditionStr ? `${targetStr} 中${conditionStr}的数据` : `${targetStr}的全部统计数据`;
1072
- return `已成功清理${finalDescription}`;
587
+ for (const tableName of tablesToClear) await this.ctx.database.remove(tableName, query);
588
+ const targetStr = options.table ? `表 ${options.table}` : "所有相关表";
589
+ return `已成功清理${targetStr}中${descParts.join("")}的数据`;
1073
590
  } catch (error) {
1074
591
  this.ctx.logger.error("数据清理失败:", error);
1075
592
  return "数据清理失败";
1076
593
  }
1077
594
  });
1078
- analyse.subcommand(".list", "列出频道及命令", { authority: 4 }).action(async () => {
1079
- const allChannelInfo = await this.ctx.database.get("analyse_user", {}, ["channelId", "channelName"]);
595
+ analyse.subcommand(".list", "列出频道和命令", { authority: 4 }).action(async () => {
596
+ const [allChannelInfo, commands] = await Promise.all([
597
+ this.ctx.database.get("analyse_user", {}, ["channelId", "channelName"]),
598
+ this.ctx.database.select("analyse_cmd").distinct("command").execute()
599
+ ]);
1080
600
  const uniqueChannels = [...new Map(allChannelInfo.map((item) => [item.channelId, item])).values()];
1081
- const channelOutput = uniqueChannels.length > 0 ? "频道列表:\n" + uniqueChannels.map((c) => `[${c.channelId}] ${c.channelName}`).join("\n") : "暂无频道记录";
1082
- const commands = await this.ctx.database.select("analyse_cmd").distinct("command").execute();
1083
- const commandOutput = commands.length > 0 ? "命令列表:\n" + commands.map((c) => c.command).join(", ") : "暂无命令记录";
601
+ const channelOutput = uniqueChannels.length ? "已记录频道列表:\n" + uniqueChannels.map((c) => `[${c.channelId}] ${c.channelName}`).join("\n") : "暂无频道记录";
602
+ const commandOutput = commands.length ? "已记录命令列表:\n" + commands.join(", ") : "暂无命令记录";
1084
603
  return `${channelOutput}
604
+
1085
605
  ${commandOutput}`;
1086
606
  });
1087
607
  }
@@ -1106,7 +626,7 @@ var Config = import_koishi6.Schema.intersect([
1106
626
  import_koishi6.Schema.object({
1107
627
  enableListener: import_koishi6.Schema.boolean().default(true).description("启用消息监听"),
1108
628
  enableData: import_koishi6.Schema.boolean().default(false).description("启用数据管理")
1109
- }).description("监听配置"),
629
+ }).description("基础配置"),
1110
630
  import_koishi6.Schema.object({
1111
631
  enableCmdStat: import_koishi6.Schema.boolean().default(true).description("启用命令统计"),
1112
632
  enableMsgStat: import_koishi6.Schema.boolean().default(true).description("启用消息统计"),
@@ -1117,9 +637,9 @@ var Config = import_koishi6.Schema.intersect([
1117
637
  rankRetentionDays: import_koishi6.Schema.number().min(0).default(31).description("记录保留天数")
1118
638
  }).description("发言排行配置"),
1119
639
  import_koishi6.Schema.object({
1120
- enableWhoAt: import_koishi6.Schema.boolean().default(true).description("启用 @ 记录"),
640
+ enableWhoAt: import_koishi6.Schema.boolean().default(true).description("启用@记录"),
1121
641
  atRetentionDays: import_koishi6.Schema.number().min(0).default(7).description("记录保留天数")
1122
- }).description("@ 记录配置")
642
+ }).description("@记录配置")
1123
643
  ]);
1124
644
  function apply(ctx, config) {
1125
645
  if (config.enableListener) new Collector(ctx, config);