koishi-plugin-chat-analyse 0.5.3 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/Collector.d.ts +0 -2
- package/lib/Data.d.ts +17 -0
- package/lib/Renderer.d.ts +39 -11
- package/lib/Stat.d.ts +3 -3
- package/lib/WhoAt.d.ts +21 -0
- package/lib/index.d.ts +3 -3
- package/lib/index.js +324 -192
- package/package.json +1 -1
package/lib/Collector.d.ts
CHANGED
|
@@ -49,9 +49,7 @@ declare module 'koishi' {
|
|
|
49
49
|
export declare class Collector {
|
|
50
50
|
private ctx;
|
|
51
51
|
private config;
|
|
52
|
-
/** @property FLUSH_INTERVAL - 内存缓存区定时刷入数据库的间隔(毫秒)。 */
|
|
53
52
|
private static readonly FLUSH_INTERVAL;
|
|
54
|
-
/** @property BUFFER_THRESHOLD - 内存缓存区触发刷新的消息数量阈值。 */
|
|
55
53
|
private static readonly BUFFER_THRESHOLD;
|
|
56
54
|
private msgStatBuffer;
|
|
57
55
|
private rankStatBuffer;
|
package/lib/Data.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Context, Command } from 'koishi';
|
|
2
|
+
/**
|
|
3
|
+
* @class Data
|
|
4
|
+
* @description 提供数据备份、恢复和清理等高级管理功能。
|
|
5
|
+
*/
|
|
6
|
+
export declare class Data {
|
|
7
|
+
private ctx;
|
|
8
|
+
private dataDir;
|
|
9
|
+
constructor(ctx: Context);
|
|
10
|
+
/**
|
|
11
|
+
* @public
|
|
12
|
+
* @method registerCommands
|
|
13
|
+
* @description 在主命令下注册所有数据管理相关的子命令。
|
|
14
|
+
* @param cmd - 主命令实例。
|
|
15
|
+
*/
|
|
16
|
+
registerCommands(cmd: Command): void;
|
|
17
|
+
}
|
package/lib/Renderer.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
|
-
/** 定义了渲染列表中单行数据的格式,是一个由字符串、数字或 `Date` 对象构成的数组。 */
|
|
3
|
-
export type RenderListItem = (string | number | Date)[];
|
|
4
2
|
/**
|
|
5
3
|
* @interface ListRenderData
|
|
6
4
|
* @description 定义了调用 `renderList` 方法所需的数据结构。
|
|
@@ -9,7 +7,7 @@ export interface ListRenderData {
|
|
|
9
7
|
title: string;
|
|
10
8
|
time: Date;
|
|
11
9
|
total?: string | number;
|
|
12
|
-
list:
|
|
10
|
+
list: (string | number | Date)[][];
|
|
13
11
|
}
|
|
14
12
|
/**
|
|
15
13
|
* @interface CircadianChartData
|
|
@@ -23,27 +21,57 @@ export interface CircadianChartData {
|
|
|
23
21
|
}
|
|
24
22
|
/**
|
|
25
23
|
* @class Renderer
|
|
26
|
-
* @description
|
|
24
|
+
* @description 负责将结构化的数据渲染为设计精美的 PNG 图片。
|
|
27
25
|
*/
|
|
28
26
|
export declare class Renderer {
|
|
29
27
|
private ctx;
|
|
28
|
+
private readonly COMMON_STYLE;
|
|
30
29
|
/**
|
|
31
|
-
* @
|
|
32
|
-
* @
|
|
30
|
+
* @constructor
|
|
31
|
+
* @description Renderer 类的构造函数。
|
|
32
|
+
* @param {Context} ctx - Koishi 的插件上下文,用于访问 logger 和 puppeteer 服务。
|
|
33
33
|
*/
|
|
34
|
-
private readonly COMMON_STYLE;
|
|
35
34
|
constructor(ctx: Context);
|
|
36
35
|
/**
|
|
37
36
|
* @private
|
|
38
37
|
* @method generateFullHtml
|
|
39
|
-
* @description 将卡片内容和特定样式组合成一个完整的 HTML
|
|
40
|
-
* @param cardContent -
|
|
41
|
-
* @param specificStyles -
|
|
42
|
-
* @returns
|
|
38
|
+
* @description 将卡片内容和特定样式组合成一个完整的 HTML 文档,以便进行渲染。
|
|
39
|
+
* @param {string} cardContent - 卡片主体部分的 HTML 字符串。
|
|
40
|
+
* @param {string} specificStyles - 针对该卡片类型的特定 CSS 样式字符串。
|
|
41
|
+
* @returns {string} - 一个完整的、可被浏览器渲染的 HTML 字符串。
|
|
43
42
|
*/
|
|
44
43
|
private generateFullHtml;
|
|
44
|
+
/**
|
|
45
|
+
* @private
|
|
46
|
+
* @method htmlToImage
|
|
47
|
+
* @description 使用 puppeteer 将给定的 HTML 字符串内容渲染成 PNG 图片的 Buffer。
|
|
48
|
+
* @param {string} fullHtmlContent - 完整的 HTML 内容字符串。
|
|
49
|
+
* @returns {Promise<Buffer | null>} - 成功时返回包含 PNG 图片数据的 Buffer,失败则返回 null。
|
|
50
|
+
*/
|
|
45
51
|
private htmlToImage;
|
|
52
|
+
/**
|
|
53
|
+
* @private
|
|
54
|
+
* @method formatDate
|
|
55
|
+
* @description 将 Date 对象格式化为易于理解的相对时间字符串(如“刚刚”,“5分钟前”)。
|
|
56
|
+
* @param {Date} date - 需要格式化的日期对象。
|
|
57
|
+
* @returns {string} - 格式化后的时间字符串。
|
|
58
|
+
*/
|
|
46
59
|
private formatDate;
|
|
60
|
+
/**
|
|
61
|
+
* @public
|
|
62
|
+
* @method renderList
|
|
63
|
+
* @description 将表格型数据渲染成一个或多个列表形式的图片。如果数据过多,会自动进行分页渲染。
|
|
64
|
+
* @param {ListRenderData} data - 包含标题、时间、总计和列表数据的对象。
|
|
65
|
+
* @param {string[]} [headers] - (可选)列表的表头数组。
|
|
66
|
+
* @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
|
|
67
|
+
*/
|
|
47
68
|
renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer[]>;
|
|
69
|
+
/**
|
|
70
|
+
* @public
|
|
71
|
+
* @method renderCircadianChart
|
|
72
|
+
* @description 将 24 小时制的活跃度数据渲染成一张柱状图图片。
|
|
73
|
+
* @param {CircadianChartData} data - 包含标题、时间、总计和 24 小时数据数组的对象。
|
|
74
|
+
* @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
|
|
75
|
+
*/
|
|
48
76
|
renderCircadianChart(data: CircadianChartData): Promise<string | Buffer[]>;
|
|
49
77
|
}
|
package/lib/Stat.d.ts
CHANGED
|
@@ -16,10 +16,10 @@ export declare class Stat {
|
|
|
16
16
|
constructor(ctx: Context, config: Config);
|
|
17
17
|
/**
|
|
18
18
|
* @public @method registerCommands
|
|
19
|
-
* @description
|
|
20
|
-
* @param
|
|
19
|
+
* @description 根据配置,动态地将子命令注册到主命令下。
|
|
20
|
+
* @param cmd - 主命令实例。
|
|
21
21
|
*/
|
|
22
|
-
registerCommands(
|
|
22
|
+
registerCommands(cmd: Command): void;
|
|
23
23
|
/**
|
|
24
24
|
* @private @method parseQueryScope
|
|
25
25
|
* @description 解析命令选项,转换为包含 UIDs 和描述性信息的统一查询范围对象。
|
package/lib/WhoAt.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Context, Command } from 'koishi';
|
|
2
|
+
import { Config } from './index';
|
|
3
|
+
/**
|
|
4
|
+
* @class WhoAt
|
|
5
|
+
* @description 负责处理谁提及我相关功能,包括查询和定时清理。
|
|
6
|
+
*/
|
|
7
|
+
export declare class WhoAt {
|
|
8
|
+
private ctx;
|
|
9
|
+
private config;
|
|
10
|
+
/**
|
|
11
|
+
* @param ctx - Koishi 的插件上下文。
|
|
12
|
+
* @param config - 插件的配置对象。
|
|
13
|
+
*/
|
|
14
|
+
constructor(ctx: Context, config: Config);
|
|
15
|
+
/**
|
|
16
|
+
* @public @method registerCommand
|
|
17
|
+
* @description 在主命令下注册子命令。
|
|
18
|
+
* @param cmd - 主命令实例。
|
|
19
|
+
*/
|
|
20
|
+
registerCommand(cmd: Command): void;
|
|
21
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -12,14 +12,14 @@ export interface Config {
|
|
|
12
12
|
enableCmdStat: boolean;
|
|
13
13
|
enableMsgStat: boolean;
|
|
14
14
|
enableRankStat: boolean;
|
|
15
|
-
|
|
15
|
+
enableActivity: boolean;
|
|
16
16
|
enableOriRecord: boolean;
|
|
17
17
|
enableWhoAt: boolean;
|
|
18
|
-
|
|
18
|
+
enableDataIO: boolean;
|
|
19
19
|
atRetentionDays: number;
|
|
20
20
|
rankRetentionDays: number;
|
|
21
21
|
}
|
|
22
|
-
/** @description
|
|
22
|
+
/** @description 插件的配置项定义 */
|
|
23
23
|
export declare const Config: Schema<Config>;
|
|
24
24
|
/**
|
|
25
25
|
* @function apply
|
package/lib/index.js
CHANGED
|
@@ -50,11 +50,21 @@ var Collector = class _Collector {
|
|
|
50
50
|
this.ctx = ctx;
|
|
51
51
|
this.config = config;
|
|
52
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
53
|
+
if (config.enableCmdStat) {
|
|
54
|
+
this.ctx.model.extend("analyse_cmd", { uid: "unsigned", command: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "command"] });
|
|
55
|
+
}
|
|
56
|
+
if (config.enableMsgStat) {
|
|
57
|
+
this.ctx.model.extend("analyse_msg", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "type"] });
|
|
58
|
+
}
|
|
59
|
+
if (config.enableRankStat || config.enableActivity) {
|
|
60
|
+
this.ctx.model.extend("analyse_rank", { uid: "unsigned", type: "string", count: "unsigned", timestamp: "timestamp" }, { primary: ["uid", "timestamp", "type"] });
|
|
61
|
+
}
|
|
62
|
+
if (this.config.enableOriRecord) {
|
|
63
|
+
this.ctx.model.extend("analyse_cache", { id: "unsigned", uid: "unsigned", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["uid", "timestamp"] });
|
|
64
|
+
}
|
|
65
|
+
if (this.config.enableWhoAt) {
|
|
66
|
+
this.ctx.model.extend("analyse_at", { id: "unsigned", uid: "unsigned", target: "string", content: "text", timestamp: "timestamp" }, { primary: "id", autoInc: true, indexes: ["target", "uid"] });
|
|
67
|
+
}
|
|
58
68
|
ctx.on("message", (session) => this.onMessage(session));
|
|
59
69
|
this.flushInterval = setInterval(() => this.flushBuffers(), _Collector.FLUSH_INTERVAL);
|
|
60
70
|
ctx.on("dispose", () => {
|
|
@@ -65,11 +75,9 @@ var Collector = class _Collector {
|
|
|
65
75
|
static {
|
|
66
76
|
__name(this, "Collector");
|
|
67
77
|
}
|
|
68
|
-
/** @property FLUSH_INTERVAL - 内存缓存区定时刷入数据库的间隔(毫秒)。 */
|
|
69
78
|
static FLUSH_INTERVAL = import_koishi.Time.minute;
|
|
70
|
-
/** @property BUFFER_THRESHOLD - 内存缓存区触发刷新的消息数量阈值。 */
|
|
71
79
|
static BUFFER_THRESHOLD = 60;
|
|
72
|
-
//
|
|
80
|
+
// 数据缓冲区
|
|
73
81
|
msgStatBuffer = /* @__PURE__ */ new Map();
|
|
74
82
|
rankStatBuffer = /* @__PURE__ */ new Map();
|
|
75
83
|
cmdStatBuffer = /* @__PURE__ */ new Map();
|
|
@@ -131,7 +139,7 @@ var Collector = class _Collector {
|
|
|
131
139
|
if (!user) return;
|
|
132
140
|
const { uid } = user;
|
|
133
141
|
const messageTime = new Date(timestamp);
|
|
134
|
-
if (argv?.command) {
|
|
142
|
+
if (this.config.enableCmdStat && argv?.command) {
|
|
135
143
|
const key = `${uid}:${argv.command.name}`;
|
|
136
144
|
const entry = this.cmdStatBuffer.get(key) ?? { uid, command: argv.command.name, count: 0, timestamp: messageTime };
|
|
137
145
|
entry.count++;
|
|
@@ -140,15 +148,19 @@ var Collector = class _Collector {
|
|
|
140
148
|
}
|
|
141
149
|
const hourStart = new Date(messageTime.getFullYear(), messageTime.getMonth(), messageTime.getDate(), messageTime.getHours());
|
|
142
150
|
for (const type of new Set(elements.map((e) => e.type))) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
if (this.config.enableMsgStat) {
|
|
152
|
+
const msgKey = `${uid}:${type}`;
|
|
153
|
+
const msgEntry = this.msgStatBuffer.get(msgKey) ?? { uid, type, count: 0, timestamp: messageTime };
|
|
154
|
+
msgEntry.count++;
|
|
155
|
+
msgEntry.timestamp = messageTime;
|
|
156
|
+
this.msgStatBuffer.set(msgKey, msgEntry);
|
|
157
|
+
}
|
|
158
|
+
if (this.config.enableRankStat || this.config.enableActivity) {
|
|
159
|
+
const rankKey = `${uid}:${hourStart.toISOString()}:${type}`;
|
|
160
|
+
const rankEntry = this.rankStatBuffer.get(rankKey) ?? { uid, timestamp: hourStart, type, count: 0 };
|
|
161
|
+
rankEntry.count++;
|
|
162
|
+
this.rankStatBuffer.set(rankKey, rankEntry);
|
|
163
|
+
}
|
|
152
164
|
}
|
|
153
165
|
if (this.config.enableWhoAt) {
|
|
154
166
|
const sanitizedContent = this.sanitizeContent(elements.filter((e) => e.type !== "at"));
|
|
@@ -210,63 +222,126 @@ var import_koishi3 = require("koishi");
|
|
|
210
222
|
// src/Renderer.ts
|
|
211
223
|
var import_koishi2 = require("koishi");
|
|
212
224
|
var Renderer = class {
|
|
225
|
+
/**
|
|
226
|
+
* @constructor
|
|
227
|
+
* @description Renderer 类的构造函数。
|
|
228
|
+
* @param {Context} ctx - Koishi 的插件上下文,用于访问 logger 和 puppeteer 服务。
|
|
229
|
+
*/
|
|
213
230
|
constructor(ctx) {
|
|
214
231
|
this.ctx = ctx;
|
|
215
232
|
}
|
|
216
233
|
static {
|
|
217
234
|
__name(this, "Renderer");
|
|
218
235
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
236
|
+
COMMON_STYLE = `
|
|
237
|
+
:root {
|
|
238
|
+
--card-bg: #fff; --text-color: #111827; --header-color: #111827;
|
|
239
|
+
--sub-text-color: #6b7280; --border-color: #e5e7eb; --accent-color: #4a6ee0;
|
|
240
|
+
--chip-bg: #f3f4f6; --stripe-bg: #f9fafb; --gold: #f59e0b;
|
|
241
|
+
--silver: #9ca3af; --bronze: #a16207;
|
|
242
|
+
}
|
|
243
|
+
body {
|
|
244
|
+
display: inline-block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
|
245
|
+
Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
246
|
+
background: transparent; margin: 0; padding: 8px;
|
|
247
|
+
-webkit-font-smoothing: antialiased;
|
|
248
|
+
}
|
|
249
|
+
.container {
|
|
250
|
+
display: inline-block; background: var(--card-bg); border-radius: 12px;
|
|
251
|
+
padding: 0; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,.05);
|
|
252
|
+
}
|
|
253
|
+
.header {
|
|
254
|
+
padding: 12px 16px;
|
|
255
|
+
display: flex;
|
|
256
|
+
justify-content: space-between;
|
|
257
|
+
align-items: center;
|
|
258
|
+
border-bottom: 1px solid var(--border-color);
|
|
259
|
+
}
|
|
260
|
+
.title-text {
|
|
261
|
+
font-size: 18px; font-weight: 600; color: var(--header-color);
|
|
262
|
+
margin: 0; text-align: center;
|
|
263
|
+
}
|
|
264
|
+
.stat-chip, .time-label {
|
|
265
|
+
display: inline-flex; align-items: baseline; padding: 4px 8px;
|
|
266
|
+
border-radius: 8px; background: var(--chip-bg);
|
|
267
|
+
font-size: 13px; color: var(--sub-text-color);
|
|
268
|
+
white-space: nowrap;
|
|
269
|
+
}
|
|
270
|
+
.stat-chip span {
|
|
271
|
+
font-weight: 600; color: var(--text-color); margin-left: 4px;
|
|
272
|
+
}
|
|
273
|
+
`;
|
|
224
274
|
/**
|
|
225
275
|
* @private
|
|
226
276
|
* @method generateFullHtml
|
|
227
|
-
* @description 将卡片内容和特定样式组合成一个完整的 HTML
|
|
228
|
-
* @param cardContent -
|
|
229
|
-
* @param specificStyles -
|
|
230
|
-
* @returns
|
|
277
|
+
* @description 将卡片内容和特定样式组合成一个完整的 HTML 文档,以便进行渲染。
|
|
278
|
+
* @param {string} cardContent - 卡片主体部分的 HTML 字符串。
|
|
279
|
+
* @param {string} specificStyles - 针对该卡片类型的特定 CSS 样式字符串。
|
|
280
|
+
* @returns {string} - 一个完整的、可被浏览器渲染的 HTML 字符串。
|
|
231
281
|
*/
|
|
232
282
|
generateFullHtml(cardContent, specificStyles) {
|
|
233
283
|
return `<!DOCTYPE html>
|
|
234
284
|
<html>
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
285
|
+
<head>
|
|
286
|
+
<meta charset="UTF-8">
|
|
287
|
+
<style>${this.COMMON_STYLE}${specificStyles}</style>
|
|
288
|
+
</head>
|
|
289
|
+
<body>
|
|
290
|
+
${cardContent}
|
|
291
|
+
</body>
|
|
242
292
|
</html>`;
|
|
243
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* @private
|
|
296
|
+
* @method htmlToImage
|
|
297
|
+
* @description 使用 puppeteer 将给定的 HTML 字符串内容渲染成 PNG 图片的 Buffer。
|
|
298
|
+
* @param {string} fullHtmlContent - 完整的 HTML 内容字符串。
|
|
299
|
+
* @returns {Promise<Buffer | null>} - 成功时返回包含 PNG 图片数据的 Buffer,失败则返回 null。
|
|
300
|
+
*/
|
|
244
301
|
async htmlToImage(fullHtmlContent) {
|
|
245
302
|
const page = await this.ctx.puppeteer.page();
|
|
246
303
|
try {
|
|
247
|
-
await page.setViewport({ width: 720, height:
|
|
304
|
+
await page.setViewport({ width: 720, height: 10, deviceScaleFactor: 2 });
|
|
248
305
|
await page.setContent(fullHtmlContent, { waitUntil: "networkidle0" });
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
306
|
+
const { width, height } = await page.evaluate(() => ({
|
|
307
|
+
width: document.body.scrollWidth,
|
|
308
|
+
height: document.body.scrollHeight
|
|
309
|
+
}));
|
|
310
|
+
await page.setViewport({ width, height, deviceScaleFactor: 2 });
|
|
311
|
+
return await page.screenshot({ type: "png", omitBackground: true });
|
|
252
312
|
} catch (error) {
|
|
253
313
|
this.ctx.logger.error("图片渲染失败:", error);
|
|
254
314
|
return null;
|
|
255
315
|
} finally {
|
|
256
|
-
|
|
316
|
+
await page.close().catch((e) => this.ctx.logger.error("关闭页面失败:", e));
|
|
257
317
|
}
|
|
258
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* @private
|
|
321
|
+
* @method formatDate
|
|
322
|
+
* @description 将 Date 对象格式化为易于理解的相对时间字符串(如“刚刚”,“5分钟前”)。
|
|
323
|
+
* @param {Date} date - 需要格式化的日期对象。
|
|
324
|
+
* @returns {string} - 格式化后的时间字符串。
|
|
325
|
+
*/
|
|
259
326
|
formatDate(date) {
|
|
260
327
|
if (!date) return "未知";
|
|
261
328
|
const diff = Date.now() - date.getTime();
|
|
262
329
|
if (diff < import_koishi2.Time.minute) return "刚刚";
|
|
263
|
-
if (diff > 365 * import_koishi2.Time.day) return date.toLocaleDateString("zh-CN")
|
|
264
|
-
const
|
|
265
|
-
for (const [unit, ms] of
|
|
330
|
+
if (diff > 365 * import_koishi2.Time.day) return date.toLocaleDateString("zh-CN");
|
|
331
|
+
const units = [["月", 30 * import_koishi2.Time.day], ["天", import_koishi2.Time.day], ["小时", import_koishi2.Time.hour], ["分钟", import_koishi2.Time.minute]];
|
|
332
|
+
for (const [unit, ms] of units) {
|
|
266
333
|
if (diff >= ms) return `${Math.floor(diff / ms)}${unit}前`;
|
|
267
334
|
}
|
|
268
335
|
return "刚刚";
|
|
269
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* @public
|
|
339
|
+
* @method renderList
|
|
340
|
+
* @description 将表格型数据渲染成一个或多个列表形式的图片。如果数据过多,会自动进行分页渲染。
|
|
341
|
+
* @param {ListRenderData} data - 包含标题、时间、总计和列表数据的对象。
|
|
342
|
+
* @param {string[]} [headers] - (可选)列表的表头数组。
|
|
343
|
+
* @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
|
|
344
|
+
*/
|
|
270
345
|
async renderList(data, headers) {
|
|
271
346
|
const { title, time, list } = data;
|
|
272
347
|
if (!list?.length) return "暂无数据可供渲染";
|
|
@@ -278,43 +353,106 @@ var Renderer = class {
|
|
|
278
353
|
const renderCell = /* @__PURE__ */ __name((cell, i) => {
|
|
279
354
|
const headerText = headers?.[i] || "";
|
|
280
355
|
if (headerText.includes("占比")) {
|
|
281
|
-
|
|
282
|
-
return `<td class="percent-cell"><div class="percent-bar" style="width: ${percentValue}%;"></div><span class="percent-text">${cell}</span></td>`;
|
|
356
|
+
return `<td class="percent-cell"><div class="percent-bar" style="width: ${String(cell)};"></div><span class="percent-text">${cell}</span></td>`;
|
|
283
357
|
}
|
|
284
358
|
if (cell instanceof Date) return `<td class="date-cell">${this.formatDate(cell)}</td>`;
|
|
285
359
|
if (typeof cell === "number") return `<td class="count-cell">${cell.toLocaleString()}</td>`;
|
|
286
360
|
return `<td class="name-cell">${String(cell)}</td>`;
|
|
287
361
|
}, "renderCell");
|
|
288
|
-
const
|
|
289
|
-
|
|
362
|
+
const listStyles = `
|
|
363
|
+
.table-container { padding: 0; }
|
|
364
|
+
.main-table { border-collapse: collapse; width: 100%; }
|
|
365
|
+
.main-table th, .main-table td { padding: 9px 16px; vertical-align: middle; text-align: left; }
|
|
366
|
+
.main-table thead { border-bottom: 1px solid var(--border-color); }
|
|
367
|
+
.main-table th { font-size: 12px; font-weight: 500; color: var(--sub-text-color); text-transform: uppercase; }
|
|
368
|
+
.main-table td { font-size: 14px; color: var(--text-color); }
|
|
369
|
+
.main-table tbody tr:nth-child(even) { background-color: var(--stripe-bg); }
|
|
370
|
+
.rank-cell, .count-cell, .date-cell, .percent-cell { text-align: right; white-space: nowrap; width: 1%; font-variant-numeric: tabular-nums; }
|
|
371
|
+
.name-cell { font-weight: 500; }
|
|
372
|
+
.rank-cell { font-weight: 600; color: var(--sub-text-color); }
|
|
373
|
+
.count-cell { font-weight: 600; color: var(--accent-color); }
|
|
374
|
+
.rank-gold, .rank-silver, .rank-bronze { font-weight: 700; }
|
|
375
|
+
.rank-gold { color: var(--gold) !important; }
|
|
376
|
+
.rank-silver { color: var(--silver) !important; }
|
|
377
|
+
.rank-bronze { color: var(--bronze) !important; }
|
|
378
|
+
.percent-cell { position: relative; padding-right: 20px; }
|
|
379
|
+
.percent-bar { position: absolute; top: 50%; right: 0; transform: translateY(-50%); height: 6px; background-color: var(--accent-color); opacity: .2; border-radius: 3px; }
|
|
380
|
+
.percent-text { position: relative; z-index: 1; }
|
|
381
|
+
`;
|
|
290
382
|
for (let i = 0; i < totalItems; i += CHUNK_SIZE) {
|
|
291
383
|
const chunk = list.slice(i, i + CHUNK_SIZE);
|
|
292
384
|
const pageNum = Math.floor(i / CHUNK_SIZE) + 1;
|
|
293
|
-
const pageTitle =
|
|
294
|
-
const
|
|
385
|
+
const pageTitle = totalItems > CHUNK_SIZE ? `${title} (第 ${pageNum}/${Math.ceil(totalItems / CHUNK_SIZE)} 页)` : title;
|
|
386
|
+
const cardHtml = `
|
|
387
|
+
<div class="container">
|
|
388
|
+
<div class="header">
|
|
389
|
+
<div class="stat-chip">总计: <span>${typeof totalCount === "number" ? totalCount.toLocaleString() : totalCount}</span></div>
|
|
390
|
+
<h1 class="title-text">${pageTitle}</h1>
|
|
391
|
+
<div class="time-label">${time.toLocaleString("zh-CN", { hour12: false })}</div>
|
|
392
|
+
</div>
|
|
393
|
+
<div class="table-container">
|
|
394
|
+
<table class="main-table">
|
|
395
|
+
${headers?.length ? `
|
|
396
|
+
<thead>
|
|
397
|
+
<tr>
|
|
398
|
+
<th class="rank-cell">#</th>
|
|
399
|
+
${headers.map((h4) => `<th>${h4}</th>`).join("")}
|
|
400
|
+
</tr>
|
|
401
|
+
</thead>` : ""}
|
|
402
|
+
<tbody>
|
|
403
|
+
${chunk.map((row, index) => {
|
|
295
404
|
const rank = i + index + 1;
|
|
296
405
|
const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
|
|
297
406
|
return `<tr><td class="rank-cell ${rankClass}">${rank}</td>${row.map(renderCell).join("")}</tr>`;
|
|
298
|
-
}).join("")
|
|
299
|
-
|
|
300
|
-
|
|
407
|
+
}).join("")}
|
|
408
|
+
</tbody>
|
|
409
|
+
</table>
|
|
410
|
+
</div>
|
|
411
|
+
</div>`;
|
|
301
412
|
const fullHtml = this.generateFullHtml(cardHtml, listStyles);
|
|
302
413
|
const imageBuffer = await this.htmlToImage(fullHtml);
|
|
303
414
|
if (imageBuffer) imageBuffers.push(imageBuffer);
|
|
304
415
|
}
|
|
305
416
|
return imageBuffers.length > 0 ? imageBuffers : "图片渲染失败";
|
|
306
417
|
}
|
|
418
|
+
/**
|
|
419
|
+
* @public
|
|
420
|
+
* @method renderCircadianChart
|
|
421
|
+
* @description 将 24 小时制的活跃度数据渲染成一张柱状图图片。
|
|
422
|
+
* @param {CircadianChartData} data - 包含标题、时间、总计和 24 小时数据数组的对象。
|
|
423
|
+
* @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
|
|
424
|
+
*/
|
|
307
425
|
async renderCircadianChart(data) {
|
|
308
426
|
const { title, time, total, data: hourlyCounts } = data;
|
|
309
427
|
if (!hourlyCounts || hourlyCounts.every((c) => c === 0)) return "暂无数据可供渲染";
|
|
310
428
|
const maxCount = Math.max(...hourlyCounts, 1);
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
429
|
+
const chartStyles = `
|
|
430
|
+
.chart-container { display: flex; align-items: flex-end; gap: 4px; height: 180px; padding: 30px 15px 10px; }
|
|
431
|
+
.bar-wrapper { flex: 1; text-align: center; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
|
|
432
|
+
.bar-value { font-size: 11px; color: var(--sub-text-color); height: 16px; line-height: 16px; font-weight: 500; visibility: ${maxCount > 50 ? "hidden" : "visible"}; }
|
|
433
|
+
.bar-container { flex-grow: 1; display: flex; align-items: flex-end; width: 100%; }
|
|
434
|
+
.bar { width: 100%; background-color: var(--accent-color); opacity: .7; border-radius: 3px 3px 0 0; transition: height .3s ease-out; }
|
|
435
|
+
.bar.peak { opacity: 1; background-color: var(--gold); }
|
|
436
|
+
.bar-label { font-size: 10px; color: var(--sub-text-color); margin-top: 4px; height: 12px; }
|
|
437
|
+
`;
|
|
438
|
+
const cardHtml = `
|
|
439
|
+
<div class="container">
|
|
440
|
+
<div class="header">
|
|
441
|
+
<div class="stat-chip">总计: <span>${typeof total === "number" ? total.toLocaleString() : total}</span></div>
|
|
442
|
+
<h1 class="title-text">${title}</h1>
|
|
443
|
+
<div class="time-label">${time.toLocaleString("zh-CN", { hour12: false })}</div>
|
|
444
|
+
</div>
|
|
445
|
+
<div class="chart-container">
|
|
446
|
+
${hourlyCounts.map((count, hour) => `
|
|
447
|
+
<div class="bar-wrapper">
|
|
448
|
+
<div class="bar-value">${count > 0 ? count : ""}</div>
|
|
449
|
+
<div class="bar-container">
|
|
450
|
+
<div class="bar ${count === maxCount ? "peak" : ""}" style="height: ${count / maxCount * 100}%;"></div>
|
|
451
|
+
</div>
|
|
452
|
+
<div class="bar-label">${hour}</div>
|
|
453
|
+
</div>`).join("")}
|
|
454
|
+
</div>
|
|
455
|
+
</div>`;
|
|
318
456
|
const fullHtml = this.generateFullHtml(cardHtml, chartStyles);
|
|
319
457
|
const imageBuffer = await this.htmlToImage(fullHtml);
|
|
320
458
|
return imageBuffer ? [imageBuffer] : "图片渲染失败";
|
|
@@ -331,10 +469,10 @@ var Stat = class {
|
|
|
331
469
|
this.ctx = ctx;
|
|
332
470
|
this.config = config;
|
|
333
471
|
this.renderer = new Renderer(ctx);
|
|
334
|
-
if (this.config.rankRetentionDays > 0) {
|
|
472
|
+
if (this.config.enableRankStat && this.config.rankRetentionDays > 0) {
|
|
335
473
|
this.ctx.cron("0 0 * * *", async () => {
|
|
336
474
|
const cutoffDate = new Date(Date.now() - this.config.rankRetentionDays * import_koishi3.Time.day);
|
|
337
|
-
await this.ctx.database.remove("analyse_rank", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("
|
|
475
|
+
await this.ctx.database.remove("analyse_rank", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理发言排行记录失败:", e));
|
|
338
476
|
});
|
|
339
477
|
}
|
|
340
478
|
}
|
|
@@ -344,10 +482,10 @@ var Stat = class {
|
|
|
344
482
|
renderer;
|
|
345
483
|
/**
|
|
346
484
|
* @public @method registerCommands
|
|
347
|
-
* @description
|
|
348
|
-
* @param
|
|
485
|
+
* @description 根据配置,动态地将子命令注册到主命令下。
|
|
486
|
+
* @param cmd - 主命令实例。
|
|
349
487
|
*/
|
|
350
|
-
registerCommands(
|
|
488
|
+
registerCommands(cmd) {
|
|
351
489
|
const createHandler = /* @__PURE__ */ __name((handler) => {
|
|
352
490
|
return async ({ session, options }) => {
|
|
353
491
|
const scope = await this.parseQueryScope(session, options);
|
|
@@ -355,102 +493,73 @@ var Stat = class {
|
|
|
355
493
|
try {
|
|
356
494
|
const result = await handler(scope, options);
|
|
357
495
|
if (typeof result === "string") return result;
|
|
358
|
-
if (Array.isArray(result)) {
|
|
359
|
-
if (result.length === 0) return "图片渲染失败";
|
|
496
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
360
497
|
for (const buffer of result) await session.sendQueued(import_koishi3.h.image(buffer, "image/png"));
|
|
361
498
|
return;
|
|
362
499
|
}
|
|
363
|
-
if (Buffer.isBuffer(result)) return import_koishi3.h.image(result, "image/png");
|
|
364
500
|
} catch (error) {
|
|
365
501
|
this.ctx.logger.error("渲染统计图片失败:", error);
|
|
366
|
-
return "
|
|
502
|
+
return "图片渲染失败";
|
|
367
503
|
}
|
|
368
504
|
};
|
|
369
505
|
}, "createHandler");
|
|
370
|
-
if (this.config.enableCmdStat)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
if (
|
|
385
|
-
const total = stats.reduce((sum, r) => sum + r.count, 0);
|
|
386
|
-
const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
|
|
387
|
-
const title = await this.generateTitle(scope.scopeDesc, { main: "消息", subtype: type });
|
|
388
|
-
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["用户", "条数", "最后发言"]);
|
|
389
|
-
} else {
|
|
506
|
+
if (this.config.enableCmdStat) {
|
|
507
|
+
cmd.subcommand("cmdstat", "命令统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(createHandler(async (scope) => {
|
|
508
|
+
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();
|
|
509
|
+
if (stats.length === 0) return "暂无统计数据";
|
|
510
|
+
const total = stats.reduce((sum, record) => sum + record.count, 0);
|
|
511
|
+
const list = stats.map((item) => [item.command, item.count, item.lastUsed]);
|
|
512
|
+
const title = await this.generateTitle(scope.scopeDesc, { main: "命令" });
|
|
513
|
+
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["命令", "次数", "最后使用"]);
|
|
514
|
+
}));
|
|
515
|
+
}
|
|
516
|
+
if (this.config.enableMsgStat) {
|
|
517
|
+
cmd.subcommand("msgstat", "发言统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a 全局").action(createHandler(async (scope, options) => {
|
|
518
|
+
const { type } = options;
|
|
519
|
+
const query = { uid: { $in: scope.uids } };
|
|
520
|
+
if (type) query.type = type;
|
|
390
521
|
const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
|
|
391
522
|
const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
|
|
392
|
-
const stats = await this.ctx.database.select("analyse_msg").where(
|
|
393
|
-
if (stats.length === 0) return "
|
|
523
|
+
const stats = await this.ctx.database.select("analyse_msg").where(query).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();
|
|
524
|
+
if (stats.length === 0) return "暂无统计数据";
|
|
394
525
|
const total = stats.reduce((sum, r) => sum + r.count, 0);
|
|
395
526
|
const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
|
|
396
|
-
const title = await this.generateTitle(scope.scopeDesc, { main: "
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
try {
|
|
527
|
+
const title = await this.generateTitle(scope.scopeDesc, { main: "发言", subtype: type });
|
|
528
|
+
const headers = type ? ["用户", "条数", "最后发言"] : ["用户", "总计发言", "最后发言"];
|
|
529
|
+
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, headers);
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
if (this.config.enableRankStat) {
|
|
533
|
+
cmd.subcommand("rankstat", "发言排行").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("hours", "-h <hours:number> 指定时长", { fallback: 24 }).option("all", "-a 全局").action(createHandler(async (scope, options) => {
|
|
404
534
|
const { hours, type } = options;
|
|
405
535
|
const since = new Date(Date.now() - hours * import_koishi3.Time.hour);
|
|
406
|
-
const
|
|
407
|
-
if (type)
|
|
408
|
-
const
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
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").execute();
|
|
412
|
-
if (rankStats.length === 0) return "暂无指定时段内发言记录";
|
|
413
|
-
const uids = rankStats.map((s) => s.uid);
|
|
414
|
-
const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName"]);
|
|
536
|
+
const query = { uid: { $in: scope.uids }, timestamp: { $gte: since } };
|
|
537
|
+
if (type) query.type = type;
|
|
538
|
+
const rankStats = await this.ctx.database.select("analyse_rank").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
|
|
539
|
+
if (rankStats.length === 0) return "暂无统计数据";
|
|
540
|
+
const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
|
|
415
541
|
const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
|
|
416
542
|
const total = rankStats.reduce((sum, record) => sum + record.count, 0);
|
|
417
543
|
const list = rankStats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count]);
|
|
418
544
|
const listWithPercentage = list.map((row) => [...row, total > 0 ? `${(row[1] / total * 100).toFixed(2)}%` : "0.00%"]);
|
|
419
|
-
const title = await this.generateTitle(
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (hourlyStats.length === 0) return "暂无消息数据";
|
|
438
|
-
const hourlyCounts = Array(24).fill(0);
|
|
439
|
-
let totalMessages = 0;
|
|
440
|
-
hourlyStats.forEach((stat) => {
|
|
441
|
-
const hour = stat.timestamp.getHours();
|
|
442
|
-
hourlyCounts[hour] = stat.count;
|
|
443
|
-
totalMessages += stat.count;
|
|
444
|
-
});
|
|
445
|
-
const title = await this.generateTitle(scope.scopeDesc, { main: "活跃分析" });
|
|
446
|
-
const result = await this.renderer.renderCircadianChart({
|
|
447
|
-
title,
|
|
448
|
-
time: /* @__PURE__ */ new Date(),
|
|
449
|
-
total: totalMessages,
|
|
450
|
-
data: hourlyCounts
|
|
451
|
-
});
|
|
452
|
-
return result;
|
|
453
|
-
}));
|
|
545
|
+
const title = await this.generateTitle(scope.scopeDesc, { main: "发言排行", timeRange: hours, subtype: type });
|
|
546
|
+
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
if (this.config.enableActivity) {
|
|
550
|
+
cmd.subcommand("activity", "活跃统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(createHandler(async (scope) => {
|
|
551
|
+
const hourlyStats = await this.ctx.database.select("analyse_rank").where({ uid: { $in: scope.uids } }).groupBy(["timestamp"], { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).execute();
|
|
552
|
+
if (hourlyStats.length === 0) return "暂无统计数据";
|
|
553
|
+
const hourlyCounts = Array(24).fill(0);
|
|
554
|
+
let totalMessages = 0;
|
|
555
|
+
hourlyStats.forEach((stat) => {
|
|
556
|
+
hourlyCounts[stat.timestamp.getHours()] += stat.count;
|
|
557
|
+
totalMessages += stat.count;
|
|
558
|
+
});
|
|
559
|
+
const title = await this.generateTitle(scope.scopeDesc, { main: "活跃" });
|
|
560
|
+
return this.renderer.renderCircadianChart({ title, time: /* @__PURE__ */ new Date(), total: totalMessages, data: hourlyCounts });
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
454
563
|
}
|
|
455
564
|
/**
|
|
456
565
|
* @private @method parseQueryScope
|
|
@@ -463,12 +572,13 @@ var Stat = class {
|
|
|
463
572
|
const scopeDesc = { guildId: options.guild, userId: void 0 };
|
|
464
573
|
if (options.user) scopeDesc.userId = import_koishi3.h.select(options.user, "at")[0]?.attrs.id ?? options.user.trim();
|
|
465
574
|
if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId;
|
|
466
|
-
if (!options.all && !scopeDesc.guildId) return { error: "
|
|
575
|
+
if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) return { error: "请指定查询范围", scopeDesc };
|
|
467
576
|
const query = {};
|
|
468
577
|
if (scopeDesc.guildId) query.channelId = scopeDesc.guildId;
|
|
469
578
|
if (scopeDesc.userId) query.userId = scopeDesc.userId;
|
|
579
|
+
if (Object.keys(query).length === 0) return { uids: void 0, scopeDesc };
|
|
470
580
|
const users = await this.ctx.database.get("analyse_user", query, ["uid"]);
|
|
471
|
-
if (users.length === 0) return { error: "
|
|
581
|
+
if (users.length === 0) return { error: "暂无统计数据", scopeDesc };
|
|
472
582
|
return { uids: users.map((u) => u.uid), scopeDesc };
|
|
473
583
|
}
|
|
474
584
|
/**
|
|
@@ -477,19 +587,25 @@ var Stat = class {
|
|
|
477
587
|
* @returns 生成的标题字符串。
|
|
478
588
|
*/
|
|
479
589
|
async generateTitle(scopeDesc, options) {
|
|
480
|
-
let scopeText = "全局";
|
|
590
|
+
let guildName = "", userName = "", scopeText = "全局";
|
|
481
591
|
if (scopeDesc.guildId) {
|
|
482
592
|
const [guild] = await this.ctx.database.get("analyse_user", { channelId: scopeDesc.guildId }, ["channelName"]);
|
|
483
|
-
|
|
593
|
+
guildName = guild?.channelName || scopeDesc.guildId;
|
|
484
594
|
}
|
|
485
595
|
if (scopeDesc.userId) {
|
|
486
596
|
const [user] = await this.ctx.database.get("analyse_user", { userId: scopeDesc.userId }, ["userName"]);
|
|
487
|
-
|
|
488
|
-
scopeText = scopeDesc.guildId ? `${userName} 在 ${scopeText}` : `${userName} 的全局`;
|
|
597
|
+
userName = user?.userName || scopeDesc.userId;
|
|
489
598
|
}
|
|
490
599
|
const typeText = options.subtype ? `“${options.subtype}”` : "";
|
|
491
|
-
|
|
492
|
-
|
|
600
|
+
const mainText = options.main;
|
|
601
|
+
if (mainText.includes("排行")) {
|
|
602
|
+
scopeText = guildName || "全局";
|
|
603
|
+
return `${options.timeRange}小时${scopeText}${typeText}${mainText}`;
|
|
604
|
+
}
|
|
605
|
+
if (userName && guildName) scopeText = `${guildName} ${userName}`;
|
|
606
|
+
else if (userName) scopeText = userName;
|
|
607
|
+
else if (guildName) scopeText = guildName;
|
|
608
|
+
return `${scopeText}${typeText}${mainText}统计`;
|
|
493
609
|
}
|
|
494
610
|
};
|
|
495
611
|
|
|
@@ -503,10 +619,10 @@ var WhoAt = class {
|
|
|
503
619
|
constructor(ctx, config) {
|
|
504
620
|
this.ctx = ctx;
|
|
505
621
|
this.config = config;
|
|
506
|
-
if (this.config.atRetentionDays > 0) {
|
|
622
|
+
if (this.config.enableWhoAt && this.config.atRetentionDays > 0) {
|
|
507
623
|
this.ctx.cron("0 0 * * *", async () => {
|
|
508
624
|
const cutoffDate = new Date(Date.now() - this.config.atRetentionDays * import_koishi4.Time.day);
|
|
509
|
-
await this.ctx.database.remove("analyse_at", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("
|
|
625
|
+
await this.ctx.database.remove("analyse_at", { timestamp: { $lt: cutoffDate } }).catch((e) => this.ctx.logger.error("清理提及记录失败:", e));
|
|
510
626
|
});
|
|
511
627
|
}
|
|
512
628
|
}
|
|
@@ -515,11 +631,11 @@ var WhoAt = class {
|
|
|
515
631
|
}
|
|
516
632
|
/**
|
|
517
633
|
* @public @method registerCommand
|
|
518
|
-
* @description
|
|
519
|
-
* @param
|
|
634
|
+
* @description 在主命令下注册子命令。
|
|
635
|
+
* @param cmd - 主命令实例。
|
|
520
636
|
*/
|
|
521
|
-
registerCommand(
|
|
522
|
-
|
|
637
|
+
registerCommand(cmd) {
|
|
638
|
+
cmd.subcommand("whoatme", "谁提及我").usage("查看最近提及我的消息,不分群组。").action(async ({ session }) => {
|
|
523
639
|
if (!session.userId) return "无法获取用户信息";
|
|
524
640
|
try {
|
|
525
641
|
const records = await this.ctx.database.get("analyse_at", { target: session.userId }, {
|
|
@@ -531,7 +647,7 @@ var WhoAt = class {
|
|
|
531
647
|
const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName", "userId"]);
|
|
532
648
|
const userInfoMap = new Map(users.map((u) => [u.uid, { name: u.userName, id: u.userId }]));
|
|
533
649
|
const messageElements = records.map((record) => {
|
|
534
|
-
const senderInfo = userInfoMap.get(record.uid)
|
|
650
|
+
const senderInfo = userInfoMap.get(record.uid);
|
|
535
651
|
return (0, import_koishi4.h)("message", {}, [
|
|
536
652
|
(0, import_koishi4.h)("author", { userId: senderInfo.id, nickname: senderInfo.name }),
|
|
537
653
|
import_koishi4.h.text(record.content)
|
|
@@ -539,7 +655,7 @@ var WhoAt = class {
|
|
|
539
655
|
});
|
|
540
656
|
return (0, import_koishi4.h)("message", { forward: true }, messageElements);
|
|
541
657
|
} catch (error) {
|
|
542
|
-
this.ctx.logger.error("
|
|
658
|
+
this.ctx.logger.error("查询提及记录失败:", error);
|
|
543
659
|
return "查询失败,请稍后重试";
|
|
544
660
|
}
|
|
545
661
|
});
|
|
@@ -564,11 +680,11 @@ var Data = class {
|
|
|
564
680
|
/**
|
|
565
681
|
* @public
|
|
566
682
|
* @method registerCommands
|
|
567
|
-
* @description
|
|
568
|
-
* @param
|
|
683
|
+
* @description 在主命令下注册所有数据管理相关的子命令。
|
|
684
|
+
* @param cmd - 主命令实例。
|
|
569
685
|
*/
|
|
570
|
-
registerCommands(
|
|
571
|
-
|
|
686
|
+
registerCommands(cmd) {
|
|
687
|
+
cmd.subcommand(".backup", "备份数据", { authority: 4 }).usage("将所有统计数据导出为 JSON 文件并保存到本地。").action(async () => {
|
|
572
688
|
try {
|
|
573
689
|
await fs.mkdir(this.dataDir, { recursive: true });
|
|
574
690
|
const allUsers = await this.ctx.database.get("analyse_user", {});
|
|
@@ -595,7 +711,7 @@ var Data = class {
|
|
|
595
711
|
return "数据备份失败";
|
|
596
712
|
}
|
|
597
713
|
});
|
|
598
|
-
|
|
714
|
+
cmd.subcommand(".restore", "恢复数据", { authority: 4 }).usage(`从本地的 JSON 文件中恢复统计数据。`).action(async () => {
|
|
599
715
|
try {
|
|
600
716
|
const userTablePath = path.join(this.dataDir, "analyse_user.json");
|
|
601
717
|
const usersToImport = JSON.parse(await fs.readFile(userTablePath, "utf-8").catch(() => "[]"));
|
|
@@ -620,15 +736,14 @@ var Data = class {
|
|
|
620
736
|
return "数据恢复失败";
|
|
621
737
|
}
|
|
622
738
|
});
|
|
623
|
-
|
|
624
|
-
if (Object.keys(options).length === 0) return "
|
|
739
|
+
cmd.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("command", "-c <command:string> 指定命令").option("all", "-a 清除全部").usage(`根据指定条件清理统计数据,可以组合多个选项以精确控制清除范围。`).action(async ({ options }) => {
|
|
740
|
+
if (Object.keys(options).length === 0) return "请指定清除条件";
|
|
741
|
+
if (options.table && !ALL_TABLES.includes(options.table)) return `表名 ${options.table} 无效`;
|
|
625
742
|
try {
|
|
626
743
|
if (options.all) {
|
|
627
|
-
await Promise.all(ALL_TABLES.map((tableName) => this.ctx.database.
|
|
628
|
-
return "
|
|
744
|
+
await Promise.all(ALL_TABLES.map((tableName) => this.ctx.database.drop(tableName)));
|
|
745
|
+
return "已清除所有数据,请重新初始化插件";
|
|
629
746
|
}
|
|
630
|
-
const tablesToClear = options.table ? [options.table] : ALL_TABLES.filter((t) => t !== "analyse_user");
|
|
631
|
-
if (options.table && !ALL_TABLES.includes(options.table)) return `无效表名: ${options.table}。`;
|
|
632
747
|
const query = {};
|
|
633
748
|
const descParts = [];
|
|
634
749
|
if (options.guild || options.user) {
|
|
@@ -638,35 +753,54 @@ var Data = class {
|
|
|
638
753
|
descParts.push(`群组 ${options.guild}`);
|
|
639
754
|
}
|
|
640
755
|
if (options.user) {
|
|
641
|
-
|
|
642
|
-
|
|
756
|
+
const userId = import_koishi5.Element.select(options.user, "at")[0]?.attrs.id ?? options.user;
|
|
757
|
+
userQuery.userId = userId;
|
|
758
|
+
descParts.push(`用户 ${userId}`);
|
|
643
759
|
}
|
|
644
760
|
const uidsToClear = (await this.ctx.database.get("analyse_user", userQuery)).map((u) => u.uid);
|
|
645
|
-
if (uidsToClear.length === 0) return "
|
|
761
|
+
if (uidsToClear.length === 0) return "未找到相关数据";
|
|
646
762
|
query.uid = { $in: [...new Set(uidsToClear)] };
|
|
647
763
|
}
|
|
648
764
|
if (options.days > 0) {
|
|
649
765
|
query.timestamp = { $lt: new Date(Date.now() - options.days * import_koishi5.Time.day) };
|
|
650
|
-
descParts.push(
|
|
766
|
+
descParts.push(`${options.days} 天前`);
|
|
767
|
+
}
|
|
768
|
+
if (options.command) {
|
|
769
|
+
query.command = options.command;
|
|
770
|
+
descParts.push(`命令 ${options.command}`);
|
|
651
771
|
}
|
|
772
|
+
const tablesToClear = options.command ? ["analyse_cmd"] : options.table ? [options.table] : ALL_TABLES.filter((t) => t !== "analyse_user");
|
|
773
|
+
let foundData = false;
|
|
774
|
+
for (const tableName of tablesToClear) {
|
|
775
|
+
const records = await this.ctx.database.get(tableName, query, ["uid"]);
|
|
776
|
+
if (records.length > 0) {
|
|
777
|
+
foundData = true;
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (!foundData) return "未找到相关数据";
|
|
652
782
|
for (const tableName of tablesToClear) await this.ctx.database.remove(tableName, query);
|
|
653
|
-
const
|
|
654
|
-
|
|
783
|
+
const tableString = options.table ? `表 ${options.table}` : "所有表";
|
|
784
|
+
const descString = descParts.join("、");
|
|
785
|
+
if (descString) {
|
|
786
|
+
return `已清除${tableString}中 ${descString} 的数据`;
|
|
787
|
+
} else {
|
|
788
|
+
return `已清除${tableString}中的所有数据`;
|
|
789
|
+
}
|
|
655
790
|
} catch (error) {
|
|
656
791
|
this.ctx.logger.error("数据清理失败:", error);
|
|
657
792
|
return "数据清理失败";
|
|
658
793
|
}
|
|
659
794
|
});
|
|
660
|
-
|
|
795
|
+
cmd.subcommand(".list", "列出数据", { authority: 4 }).usage("列出数据库中的频道和命令列表。").action(async () => {
|
|
661
796
|
const [allChannelInfo, commands] = await Promise.all([
|
|
662
797
|
this.ctx.database.get("analyse_user", {}, ["channelId", "channelName"]),
|
|
663
|
-
this.ctx.database.select("analyse_cmd").
|
|
798
|
+
this.ctx.database.select("analyse_cmd").groupBy("command").execute()
|
|
664
799
|
]);
|
|
665
800
|
const uniqueChannels = [...new Map(allChannelInfo.map((item) => [item.channelId, item])).values()];
|
|
666
|
-
const channelOutput = uniqueChannels.length ? "
|
|
667
|
-
const commandOutput = commands.length ? "
|
|
801
|
+
const channelOutput = uniqueChannels.length ? "频道列表:\n" + uniqueChannels.map((c) => `[${c.channelId}] ${c.channelName}`).join("\n") : "暂无频道记录";
|
|
802
|
+
const commandOutput = commands.length ? "命令列表:\n" + commands.join(", ") : "暂无命令记录";
|
|
668
803
|
return `${channelOutput}
|
|
669
|
-
|
|
670
804
|
${commandOutput}`;
|
|
671
805
|
});
|
|
672
806
|
}
|
|
@@ -690,29 +824,27 @@ var using = ["database", "puppeteer", "cron"];
|
|
|
690
824
|
var Config = import_koishi6.Schema.intersect([
|
|
691
825
|
import_koishi6.Schema.object({
|
|
692
826
|
enableListener: import_koishi6.Schema.boolean().default(true).description("启用消息监听"),
|
|
693
|
-
|
|
694
|
-
}).description("
|
|
827
|
+
enableDataIO: import_koishi6.Schema.boolean().default(true).description("启用数据管理")
|
|
828
|
+
}).description("杂项配置"),
|
|
695
829
|
import_koishi6.Schema.object({
|
|
696
830
|
enableCmdStat: import_koishi6.Schema.boolean().default(true).description("启用命令统计"),
|
|
697
831
|
enableMsgStat: import_koishi6.Schema.boolean().default(true).description("启用消息统计"),
|
|
698
|
-
|
|
699
|
-
enableOriRecord: import_koishi6.Schema.boolean().default(true).description("启用原始记录")
|
|
700
|
-
}).description("功能配置"),
|
|
701
|
-
import_koishi6.Schema.object({
|
|
832
|
+
enableActivity: import_koishi6.Schema.boolean().default(true).description("启用活跃统计"),
|
|
702
833
|
enableRankStat: import_koishi6.Schema.boolean().default(true).description("启用发言排行"),
|
|
703
|
-
rankRetentionDays: import_koishi6.Schema.number().min(0).default(31).description("
|
|
704
|
-
|
|
834
|
+
rankRetentionDays: import_koishi6.Schema.number().min(0).default(31).description("排行保留天数"),
|
|
835
|
+
enableWhoAt: import_koishi6.Schema.boolean().default(true).description("启用提及记录"),
|
|
836
|
+
atRetentionDays: import_koishi6.Schema.number().min(0).default(7).description("提及保留天数")
|
|
837
|
+
}).description("基础分析配置"),
|
|
705
838
|
import_koishi6.Schema.object({
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}).description("@记录配置")
|
|
839
|
+
enableOriRecord: import_koishi6.Schema.boolean().default(true).description("启用原始记录")
|
|
840
|
+
}).description("高级分析配置")
|
|
709
841
|
]);
|
|
710
842
|
function apply(ctx, config) {
|
|
711
843
|
if (config.enableListener) new Collector(ctx, config);
|
|
712
|
-
const analyse = ctx.command("analyse", "
|
|
844
|
+
const analyse = ctx.command("analyse", "数据分析");
|
|
713
845
|
new Stat(ctx, config).registerCommands(analyse);
|
|
714
846
|
if (config.enableWhoAt) new WhoAt(ctx, config).registerCommand(analyse);
|
|
715
|
-
if (config.
|
|
847
|
+
if (config.enableDataIO) new Data(ctx).registerCommands(analyse);
|
|
716
848
|
}
|
|
717
849
|
__name(apply, "apply");
|
|
718
850
|
// Annotate the CommonJS export names for ESM import in node:
|