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 +322 -802
- package/package.json +1 -1
- package/lib/Collector.d.ts +0 -109
- package/lib/Data.d.ts +0 -19
- package/lib/Debug.d.ts +0 -22
- package/lib/Manager.d.ts +0 -56
- package/lib/Renderer.d.ts +0 -60
- package/lib/Stat.d.ts +0 -107
- package/lib/WhoAt.d.ts +0 -34
- package/lib/index.d.ts +0 -35
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
|
-
* @
|
|
47
|
-
* @param
|
|
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.
|
|
54
|
-
ctx.
|
|
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
|
-
/** @
|
|
65
|
-
static FLUSH_INTERVAL =
|
|
66
|
-
/** @
|
|
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
|
-
* @
|
|
81
|
-
* @
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
|
|
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.
|
|
250
|
-
|
|
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
|
-
* @
|
|
255
|
-
* @
|
|
256
|
-
* @
|
|
257
|
-
* @returns {string} 净化后的纯文本字符串。
|
|
161
|
+
* @private @method sanitizeContent
|
|
162
|
+
* @description 将 Koishi 消息元素数组净化为纯文本字符串。
|
|
163
|
+
* @param elements - 消息元素数组。
|
|
164
|
+
* @returns 净化后的纯文本。
|
|
258
165
|
*/
|
|
259
|
-
sanitizeContent = /* @__PURE__ */ __name((elements) =>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
* @
|
|
274
|
-
* @method flushBuffers
|
|
275
|
-
* @description 将所有内存中的缓冲区数据批量写入数据库。
|
|
172
|
+
* @private @method flushBuffers
|
|
173
|
+
* @description 将所有内存中的数据缓冲区批量写入数据库,并清空缓冲区。
|
|
276
174
|
*/
|
|
277
175
|
async flushBuffers() {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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 (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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("
|
|
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
|
|
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
|
-
*
|
|
350
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
*
|
|
450
|
-
* @
|
|
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
|
-
|
|
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 (
|
|
470
|
-
|
|
471
|
-
parts.push(`${value}${unit}`);
|
|
472
|
-
remainingDiff %= ms;
|
|
251
|
+
if (diff >= ms) {
|
|
252
|
+
return `${Math.floor(diff / ms)}${unit}前`;
|
|
473
253
|
}
|
|
474
254
|
}
|
|
475
|
-
|
|
476
|
-
return `${result}前`;
|
|
255
|
+
return "刚刚";
|
|
477
256
|
}
|
|
478
257
|
/**
|
|
479
258
|
* @public
|
|
480
259
|
* @method renderList
|
|
481
|
-
* @description
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
* @
|
|
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
|
-
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
return `<
|
|
503
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
560
|
-
* @param
|
|
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
|
-
|
|
583
|
-
|
|
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
|
-
* @
|
|
594
|
-
* @
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
|
|
621
|
-
|
|
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("
|
|
658
|
-
return "
|
|
327
|
+
this.ctx.logger.error("渲染统计图片失败:", error);
|
|
328
|
+
return "渲染统计图片失败";
|
|
659
329
|
}
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
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 (
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
* @
|
|
692
|
-
* @
|
|
693
|
-
* @
|
|
694
|
-
* @
|
|
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
|
|
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
|
-
* @
|
|
709
|
-
* @
|
|
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(
|
|
414
|
+
async generateTitle(scopeDesc, options) {
|
|
720
415
|
let scopeText = "全局";
|
|
721
|
-
if (
|
|
722
|
-
const
|
|
723
|
-
|
|
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 (
|
|
733
|
-
const
|
|
734
|
-
|
|
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
|
-
*
|
|
859
|
-
* @param
|
|
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
|
-
|
|
880
|
-
|
|
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
|
|
454
|
+
* @param analyse - 主 `analyse` 命令实例。
|
|
893
455
|
*/
|
|
894
456
|
registerCommand(analyse) {
|
|
895
|
-
analyse.subcommand("whoatme", "
|
|
457
|
+
analyse.subcommand(".whoatme", "谁@我").action(async ({ session }) => {
|
|
896
458
|
if (!session.userId) return "无法获取用户信息";
|
|
897
459
|
try {
|
|
898
|
-
const records = await this.ctx.database.
|
|
899
|
-
|
|
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.
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
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("查询 @
|
|
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
|
-
|
|
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 在
|
|
947
|
-
* @param
|
|
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
|
-
|
|
986
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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>
|
|
1028
|
-
if (
|
|
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
|
-
|
|
562
|
+
await Promise.all(ALL_TABLES.map((tableName) => this.ctx.database.remove(tableName, {})));
|
|
1032
563
|
return "已清除所有聊天分析数据";
|
|
1033
564
|
}
|
|
1034
|
-
|
|
1035
|
-
if (options.table) {
|
|
1036
|
-
|
|
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
|
|
1045
|
-
const scopeDesc = [];
|
|
570
|
+
const userQuery = {};
|
|
1046
571
|
if (options.guild) {
|
|
1047
|
-
|
|
1048
|
-
|
|
572
|
+
userQuery.channelId = options.guild;
|
|
573
|
+
descParts.push(`群组 ${options.guild}`);
|
|
1049
574
|
}
|
|
1050
575
|
if (options.user) {
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
|
1056
|
-
if (
|
|
1057
|
-
|
|
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
|
|
1061
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
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", "
|
|
1079
|
-
const allChannelInfo = await
|
|
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
|
|
1082
|
-
const
|
|
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);
|