koishi-plugin-ll-mc 1.0.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 ADDED
@@ -0,0 +1,2659 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.Config = exports.inject = exports.name = void 0;
40
+ exports.apply = apply;
41
+ const koishi_1 = require("koishi");
42
+ const path_1 = __importDefault(require("path"));
43
+ const fs = __importStar(require("fs/promises"));
44
+ const fs_1 = require("fs");
45
+ const crypto = __importStar(require("crypto"));
46
+ const assetsDir = path_1.default.resolve(__dirname, "..", "assets");
47
+ let fallbackBase64 = [""];
48
+ exports.name = "mc";
49
+ exports.inject = {
50
+ required: ["database", "cron"],
51
+ optional: ["markdownToImage", "puppeteer", "canvas"],
52
+ };
53
+ const logger = new koishi_1.Logger("mc");
54
+ // --- 定义字体选项常量 ---
55
+ const FONT_OPTIONS = {
56
+ TITLE: "HarmonyOS_Sans_Medium",
57
+ NICKNAME: "HarmonyOS_Sans_Medium",
58
+ };
59
+ // pz*
60
+ // pz*
61
+ exports.Config = koishi_1.Schema.intersect([
62
+ // --- 核心功能 ---
63
+ koishi_1.Schema.object({
64
+ isBotMessageTrackingEnabled: koishi_1.Schema.boolean()
65
+ .default(false)
66
+ .description("是否统计 Bot 自己发送的消息。"),
67
+ }).description("核心功能"),
68
+ // --- 排行榜设置 ---
69
+ koishi_1.Schema.object({
70
+ defaultMaxDisplayCount: koishi_1.Schema.number()
71
+ .min(0)
72
+ .default(20)
73
+ .description("排行榜默认显示的人数。设置为 0 则显示所有。"),
74
+ isTimeInfoSupplementEnabled: koishi_1.Schema.boolean()
75
+ .default(true)
76
+ .description("是否在排行榜标题中显示生成时间。"),
77
+ isUserMessagePercentageVisible: koishi_1.Schema.boolean()
78
+ .default(true)
79
+ .description("是否在排行榜中显示用户的消息数占比。"),
80
+ hiddenUserIdsInLeaderboard: koishi_1.Schema.array(String)
81
+ .role("table")
82
+ .description("全局隐藏的用户 ID 列表,在所有用户排行榜中生效。"),
83
+ hiddenChannelIdsInLeaderboard: koishi_1.Schema.array(String)
84
+ .role("table")
85
+ .description("全局隐藏的频道 ID 列表,在群排行榜中生效。"),
86
+ excludedChannelIds: koishi_1.Schema.array(String)
87
+ .role("table")
88
+ .description("排除统计的频道 ID 列表,这些群的消息不会被计数。"),
89
+ }).description("排行榜设置"),
90
+ // --- 图片生成 ---
91
+ koishi_1.Schema.intersect([
92
+ koishi_1.Schema.object({
93
+ isTextToImageConversionEnabled: koishi_1.Schema.boolean()
94
+ .default(false)
95
+ .description("是否将文本排行榜转为 Markdown 图片(依赖 `markdownToImage` 服务)。"),
96
+ isLeaderboardToHorizontalBarChartConversionEnabled: koishi_1.Schema.boolean()
97
+ .default(false)
98
+ .description("是否将排行榜渲染为水平柱状图(依赖 `puppeteer` 服务)。"),
99
+ }).description("图片生成"),
100
+ // 仅在开启柱状图功能时显示以下详细选项
101
+ koishi_1.Schema.union([
102
+ koishi_1.Schema.intersect([
103
+ koishi_1.Schema.object({
104
+ isLeaderboardToHorizontalBarChartConversionEnabled: koishi_1.Schema.const(true).required(),
105
+ imageType: koishi_1.Schema.union(["png", "jpeg", "webp"])
106
+ .default("png")
107
+ .description(`生成的柱状图图片格式。`),
108
+ }).description("柱状图基础设置"),
109
+ koishi_1.Schema.object({
110
+ chartViewportWidth: koishi_1.Schema.number()
111
+ .min(1)
112
+ .default(1080)
113
+ .description("渲染页面的视口宽度(像素)。此值会影响图片清晰度及背景图的展示。"),
114
+ deviceScaleFactor: koishi_1.Schema.number()
115
+ .min(0.1)
116
+ .max(4)
117
+ .step(0.1)
118
+ .default(1)
119
+ .description("设备像素比 (DPR)。更高的值可生成更清晰的图片(如 2 倍图),但会增加文件体积。"),
120
+ waitUntil: koishi_1.Schema.union([
121
+ "load",
122
+ "domcontentloaded",
123
+ "networkidle0",
124
+ "networkidle2",
125
+ ])
126
+ .default("networkidle0")
127
+ .description("页面加载等待策略,影响图片生成速度和稳定性。`networkidle0` 最稳定。"),
128
+ }).description("渲染与性能"),
129
+ koishi_1.Schema.object({
130
+ avatarCacheTTL: koishi_1.Schema.number()
131
+ .default(86400) // 24 hours
132
+ .description("头像缓存有效期(秒)。设置为 0 则永不刷新。过短的有效期会增加网络请求。"),
133
+ avatarFailureCacheTTL: koishi_1.Schema.number()
134
+ .default(300) // 5 minutes
135
+ .description("头像获取失败后的重试间隔(秒)。期间将使用默认头像,避免频繁请求无效链接。"),
136
+ }).description("缓存设置"),
137
+ koishi_1.Schema.object({
138
+ shouldMoveIconToBarEndLeft: koishi_1.Schema.boolean()
139
+ .default(true)
140
+ .description("是否将自定义图标显示在进度条的末端。关闭则显示在用户名旁。"),
141
+ showStarInChart: koishi_1.Schema.boolean()
142
+ .default(true)
143
+ .description("是否在图表中对触发指令的用户/群聊名称前添加 ★ 以高亮显示。"),
144
+ horizontalBarBackgroundOpacity: koishi_1.Schema.number()
145
+ .min(0)
146
+ .max(1)
147
+ .step(0.05)
148
+ .default(0.6)
149
+ .description("自定义背景图在进度条区域的不透明度。"),
150
+ horizontalBarBackgroundFullOpacity: koishi_1.Schema.number()
151
+ .min(0)
152
+ .max(1)
153
+ .step(0.05)
154
+ .default(0)
155
+ .description("自定义背景图在整行背景区域的不透明度。"),
156
+ }).description("样式定制"),
157
+ koishi_1.Schema.object({
158
+ maxBarBgWidth: koishi_1.Schema.number()
159
+ .min(0)
160
+ .default(850)
161
+ .description("允许上传的背景图最大宽度(像素)。0为不限制。"),
162
+ maxBarBgHeight: koishi_1.Schema.number()
163
+ .min(0)
164
+ .default(50)
165
+ .description("允许上传的背景图最大高度(像素)。0为不限制。"),
166
+ maxBarBgSize: koishi_1.Schema.number()
167
+ .min(0)
168
+ .default(5)
169
+ .description("允许上传的背景图最大体积(MB)。0为不限制。"),
170
+ }).description("上传限制"),
171
+ // 背景设置(条件化)
172
+ koishi_1.Schema.intersect([
173
+ koishi_1.Schema.object({
174
+ backgroundType: koishi_1.Schema.union([
175
+ koishi_1.Schema.const("none").description("默认渐变"),
176
+ koishi_1.Schema.const("api").description("API 获取"),
177
+ koishi_1.Schema.const("css").description("自定义 CSS"),
178
+ ])
179
+ .default("none")
180
+ .description("图片整体背景类型。"),
181
+ }),
182
+ koishi_1.Schema.union([
183
+ koishi_1.Schema.object({
184
+ backgroundType: koishi_1.Schema.const("api").required(),
185
+ apiBackgroundConfig: koishi_1.Schema.object({
186
+ apiUrl: koishi_1.Schema.string()
187
+ .description("获取背景图的 API 地址。")
188
+ .required(),
189
+ apiKey: koishi_1.Schema.string()
190
+ .role("secret")
191
+ .description("API 的访问凭证(可选)。"),
192
+ responseType: koishi_1.Schema.union(["binary", "url", "base64"])
193
+ .default("binary")
194
+ .description("API 返回的数据类型。"),
195
+ })
196
+ .description("API 背景配置")
197
+ .collapse(),
198
+ }),
199
+ koishi_1.Schema.object({
200
+ backgroundType: koishi_1.Schema.const("css").required(),
201
+ backgroundValue: koishi_1.Schema.string()
202
+ .role("textarea", { rows: [2, 4] })
203
+ .default(`html {\n background: linear-gradient(135deg, #f6f8f9 0%, #e5ebee 100%);\n}`)
204
+ .description("自定义背景的 CSS 代码。建议使用 `html` 选择器来设置背景。"),
205
+ }),
206
+ koishi_1.Schema.object({}),
207
+ ]),
208
+ ]).description("背景设置"),
209
+ koishi_1.Schema.object({
210
+ chartTitleFont: koishi_1.Schema.string()
211
+ .default(FONT_OPTIONS.TITLE)
212
+ .description(`标题字体。填写 'data/mc/fonts' 目录中的字体文件名(不含后缀)。`),
213
+ chartNicknameFont: koishi_1.Schema.string()
214
+ .default(FONT_OPTIONS.NICKNAME)
215
+ .description(`昵称与计数字体。填写 'data/mc/fonts' 目录中的字体文件名(不含后缀),或使用通用字体名称。`),
216
+ }).description("字体设置"),
217
+ ]),
218
+ koishi_1.Schema.object({}),
219
+ ]),
220
+ ]),
221
+ // --- 自动推送 ---
222
+ koishi_1.Schema.intersect([
223
+ koishi_1.Schema.object({
224
+ autoPush: koishi_1.Schema.boolean()
225
+ .default(false)
226
+ .description("是否启用定时自动推送排行榜功能。"),
227
+ }).description("自动推送"),
228
+ koishi_1.Schema.union([
229
+ koishi_1.Schema.intersect([
230
+ koishi_1.Schema.object({
231
+ autoPush: koishi_1.Schema.const(true).required(),
232
+ shouldSendDailyLeaderboardAtMidnight: koishi_1.Schema.boolean()
233
+ .default(true)
234
+ .description("每日 0 点自动发送昨日排行榜。"),
235
+ shouldSendWeeklyLeaderboard: koishi_1.Schema.boolean()
236
+ .default(false)
237
+ .description("每周一 0 点自动发送上周排行榜。"),
238
+ shouldSendMonthlyLeaderboard: koishi_1.Schema.boolean()
239
+ .default(false)
240
+ .description("每月一日 0 点自动发送上月排行榜。"),
241
+ shouldSendYearlyLeaderboard: koishi_1.Schema.boolean()
242
+ .default(false)
243
+ .description("每年一日 0 点自动发送去年排行榜。"),
244
+ dailyScheduledTimers: koishi_1.Schema.array(String)
245
+ .role("table")
246
+ .description("其他定时发送今日排行榜的时间点(24小时制,如 `08:00`)。"),
247
+ }).description("推送时机"),
248
+ koishi_1.Schema.object({
249
+ pushChannelIds: koishi_1.Schema.array(String)
250
+ .role("table")
251
+ .description("需要接收自动推送的频道 ID 列表。"),
252
+ shouldSendLeaderboardNotificationsToAllChannels: koishi_1.Schema.boolean()
253
+ .default(false)
254
+ .description("是否向机器人所在的所有群聊推送(可能造成打扰,请谨慎开启)。"),
255
+ excludedLeaderboardChannels: koishi_1.Schema.array(String)
256
+ .role("table")
257
+ .description("当“向所有群聊推送”开启时,此处指定的频道将不会收到推送。"),
258
+ }).description("推送目标"),
259
+ koishi_1.Schema.object({
260
+ isGeneratingRankingListPromptVisible: koishi_1.Schema.boolean()
261
+ .default(true)
262
+ .description("发送排行榜前,是否先发送一条“正在生成”的提示消息。"),
263
+ leaderboardGenerationWaitTime: koishi_1.Schema.number()
264
+ .min(0)
265
+ .default(3)
266
+ .description("发送提示消息后,等待多少秒再发送排行榜图片。"),
267
+ delayBetweenGroupPushesInSeconds: koishi_1.Schema.number()
268
+ .min(0)
269
+ .default(5)
270
+ .description("批量推送时,每个群之间的基础发送延迟(秒),以防风控。"),
271
+ groupPushDelayRandomizationSeconds: koishi_1.Schema.number()
272
+ .min(0)
273
+ .default(10)
274
+ .description("在基础延迟之上,增加一个随机波动范围(秒),以模拟人工操作。"),
275
+ }).description("推送行为"),
276
+ ]),
277
+ koishi_1.Schema.object({}),
278
+ ]),
279
+ ]),
280
+ // --- 龙王禁言 ---
281
+ koishi_1.Schema.intersect([
282
+ koishi_1.Schema.object({
283
+ enableMostActiveUserMuting: koishi_1.Schema.boolean()
284
+ .default(false)
285
+ .description("是否在每日 0 点自动禁言昨日发言最多的人(“抓龙王”)。"),
286
+ }).description("龙王禁言"),
287
+ koishi_1.Schema.union([
288
+ koishi_1.Schema.object({
289
+ enableMostActiveUserMuting: koishi_1.Schema.const(true).required(),
290
+ dragonKingDetainmentTime: koishi_1.Schema.number()
291
+ .min(0)
292
+ .default(5)
293
+ .description("0 点后,等待多少秒再执行禁言操作。"),
294
+ detentionDuration: koishi_1.Schema.number()
295
+ .min(1)
296
+ .default(1)
297
+ .description("禁言时长(天)。"),
298
+ muteChannelIds: koishi_1.Schema.array(String)
299
+ .role("table")
300
+ .description("在哪些频道中执行“抓龙王”操作。"),
301
+ }),
302
+ koishi_1.Schema.object({}),
303
+ ]),
304
+ ]),
305
+ ]);
306
+ const periodMapping = {
307
+ today: { field: "todayPostCount", name: "今日" },
308
+ yesterday: { field: "yesterdayPostCount", name: "昨日" },
309
+ week: { field: "thisWeekPostCount", name: "本周" },
310
+ month: { field: "thisMonthPostCount", name: "本月" },
311
+ year: { field: "thisYearPostCount", name: "今年" },
312
+ total: { field: "totalPostCount", name: "总" },
313
+ };
314
+ async function apply(ctx, config) {
315
+ // cl*
316
+ // 定义一个唯一的 Symbol 作为处理标记,防止与其他插件冲突
317
+ const PROCESSED = Symbol("message-counter.processed");
318
+ // --- 资源路径和缓存初始化 ---
319
+ const dataRoot = path_1.default.join(ctx.baseDir, "data");
320
+ const messageCounterRoot = path_1.default.join(dataRoot, "mc"); // 统一资源根目录
321
+ const iconsPath = path_1.default.join(messageCounterRoot, "icons");
322
+ const barBgImgsPath = path_1.default.join(messageCounterRoot, "barBgImgs");
323
+ const fontsPath = path_1.default.join(messageCounterRoot, "fonts"); // 字体目录路径
324
+ const avatarsPath = path_1.default.join(messageCounterRoot, "avatars");
325
+ const emptyHtmlPath = path_1.default
326
+ .join(messageCounterRoot, "emptyHtml.html")
327
+ .replace(/\\/g, "/");
328
+ // 兼容旧版插件的资源路径
329
+ const oldIconsPath = path_1.default.join(dataRoot, "mcIcons");
330
+ const oldBarBgImgsPath = path_1.default.join(dataRoot, "mcBarBgImgs");
331
+ // 自动创建所有必要的目录
332
+ await fs.mkdir(fontsPath, { recursive: true });
333
+ await fs.mkdir(iconsPath, { recursive: true });
334
+ await fs.mkdir(barBgImgsPath, { recursive: true });
335
+ await fs.mkdir(avatarsPath, { recursive: true });
336
+ await migrateFolder(oldIconsPath, iconsPath);
337
+ await migrateFolder(oldBarBgImgsPath, barBgImgsPath);
338
+ // 确保 emptyHtml.html 存在,用于 puppeteer 渲染
339
+ try {
340
+ await fs.access(emptyHtmlPath, fs_1.constants.F_OK);
341
+ }
342
+ catch {
343
+ // 文件不存在,则创建一个空文件
344
+ await fs.writeFile(emptyHtmlPath, "");
345
+ logger.info(`已创建空的渲染模板文件: emptyHtml.html`);
346
+ }
347
+ // 加载内置回退头像 base64
348
+ try {
349
+ const fallbackJson = await fs.readFile(path_1.default.join(assetsDir, "fallbackBase64.json"), "utf-8");
350
+ fallbackBase64 = JSON.parse(fallbackJson);
351
+ }
352
+ catch (error) {
353
+ logger.warn("无法加载 fallbackBase64.json,将使用空的回退头像。", error);
354
+ }
355
+ // 拷贝内置字体
356
+ const fontFiles = ["HarmonyOS_Sans_Medium.ttf"];
357
+ for (const fontFile of fontFiles) {
358
+ await copyAssetIfNotExists(path_1.default.join(assetsDir, "fonts"), fontsPath, fontFile);
359
+ }
360
+ // 缓存
361
+ const avatarCache = new Map();
362
+ let iconCache = [];
363
+ let barBgImgCache = [];
364
+ let fontFilesCache = []; // 字体文件缓存
365
+ // --- 数据库表定义 ---
366
+ ctx.model.extend("message_counter_records", {
367
+ // id: "unsigned",
368
+ channelId: "string",
369
+ channelName: "string",
370
+ userId: "string",
371
+ username: "string",
372
+ userAvatar: "string",
373
+ todayPostCount: "unsigned",
374
+ thisWeekPostCount: "unsigned",
375
+ thisMonthPostCount: "unsigned",
376
+ thisYearPostCount: "unsigned",
377
+ totalPostCount: "unsigned",
378
+ yesterdayPostCount: "unsigned",
379
+ }, {
380
+ primary: ["channelId", "userId"],
381
+ });
382
+ ctx.model.extend("message_counter_state", {
383
+ key: "string",
384
+ value: "timestamp",
385
+ }, { primary: "key" });
386
+ // 限定在群组中
387
+ const channelCtx = ctx.channel();
388
+ // 在插件启动完成后设置定时任务
389
+ ctx.on("ready", async () => {
390
+ // 启动时加载缓存
391
+ await reloadIconCache();
392
+ await reloadBarBgImgCache();
393
+ await reloadFontCache();
394
+ // 执行非破坏性的状态初始化
395
+ await initializeResetStates();
396
+ // 安全地检查并弥补真正错过的重置任务
397
+ await checkForMissedResets();
398
+ // --- 设置所有定时任务 ---
399
+ // 1. 自动推送排行榜的定时任务
400
+ if (config.autoPush) {
401
+ if (config.shouldSendDailyLeaderboardAtMidnight) {
402
+ const task = ctx.cron("1 0 * * *", () => generateAndPushLeaderboard("yesterday"));
403
+ scheduledTasks.push(task);
404
+ logger.info("[自动推送] 已设置每日 00:01 推送昨日排行榜的任务。");
405
+ }
406
+ (config.dailyScheduledTimers || []).forEach((time) => {
407
+ const match = /^([0-1]?[0-9]|2[0-3]):([0-5]?[0-9])$/.exec(time);
408
+ if (match) {
409
+ const [_, hour, minute] = match;
410
+ const cron = `${minute} ${hour} * * *`;
411
+ const task = ctx.cron(cron, () => generateAndPushLeaderboard("today"));
412
+ scheduledTasks.push(task);
413
+ logger.info(`[自动推送] 已设置每日 ${time} 推送今日排行榜的任务。`);
414
+ }
415
+ else {
416
+ logger.warn(`[自动推送] 无效的时间格式: "${time}",已跳过。请使用 "HH:mm" 格式。`);
417
+ }
418
+ });
419
+ }
420
+ // 2. 抓龙王(禁言)的定时任务
421
+ if (config.enableMostActiveUserMuting) {
422
+ const task = ctx.cron("1 0 * * *", () => performDragonKingMuting());
423
+ scheduledTasks.push(task);
424
+ logger.info("[抓龙王] 已设置每日 00:01 执行的禁言任务。");
425
+ }
426
+ // 3. 统一的推送与数据库重置定时任务
427
+ // 此任务在每天 00:00 执行
428
+ const resetTask = ctx.cron("0 0 * * *", async () => {
429
+ const now = new Date();
430
+ const dayOfMonth = now.getDate();
431
+ const month = now.getMonth(); // 0-11
432
+ const dayOfWeek = now.getDay(); // 0 = Sunday, 1 = Monday
433
+ // --- 周期性推送 (在数据重置之前执行) ---
434
+ // 在每年1月1日 00:00,重置年度数据前,推送去年的排行榜
435
+ if (config.autoPush &&
436
+ config.shouldSendYearlyLeaderboard &&
437
+ dayOfMonth === 1 &&
438
+ month === 0) {
439
+ await generateAndPushLeaderboard("year");
440
+ }
441
+ // 在每月1日 00:00,重置月度数据前,推送上个月的排行榜
442
+ if (config.autoPush &&
443
+ config.shouldSendMonthlyLeaderboard &&
444
+ dayOfMonth === 1) {
445
+ await generateAndPushLeaderboard("month");
446
+ }
447
+ // 在每周一 00:00,重置周数据前,推送上一周的排行榜
448
+ if (config.autoPush &&
449
+ config.shouldSendWeeklyLeaderboard &&
450
+ dayOfWeek === 1) {
451
+ await generateAndPushLeaderboard("week");
452
+ }
453
+ // --- 数据重置 (在周期性推送之后执行) ---
454
+ // 每日重置 (总是执行), 它会先把 today 备份到 yesterday
455
+ await resetCounter("todayPostCount", "今日发言榜已成功置空!", "daily");
456
+ // 每周重置 (在周一 00:00 执行)
457
+ if (dayOfWeek === 1) {
458
+ await resetCounter("thisWeekPostCount", "本周发言榜已成功置空!", "weekly");
459
+ }
460
+ // 每月重置 (在每月1号 00:00 执行)
461
+ if (dayOfMonth === 1) {
462
+ await resetCounter("thisMonthPostCount", "本月发言榜已成功置空!", "monthly");
463
+ }
464
+ // 每年重置 (在1月1号 00:00 执行)
465
+ if (dayOfMonth === 1 && month === 0) {
466
+ await resetCounter("thisYearPostCount", "今年发言榜已成功置空!", "yearly");
467
+ }
468
+ });
469
+ // 将这一个统一的任务添加到待清理列表
470
+ scheduledTasks.push(resetTask);
471
+ logger.info("已设置统一的推送与数据重置任务(每日、周、月、年)。");
472
+ });
473
+ // --- 资源清理 ---
474
+ ctx.on("dispose", () => {
475
+ // 调用 disposer 函数来取消定时任务
476
+ scheduledTasks.forEach((task) => task());
477
+ avatarCache.clear();
478
+ iconCache = [];
479
+ barBgImgCache = [];
480
+ fontFilesCache = [];
481
+ logger.info("所有已安排的任务和缓存都已清除。");
482
+ });
483
+ // --- 核心消息监听器 ---
484
+ // jt*
485
+ channelCtx.middleware(async (session, next) => {
486
+ // 检查此消息是否已被本插件处理过,如果是,则直接跳过
487
+ if (session[PROCESSED])
488
+ return next();
489
+ // 忽略无效会话或机器人自身消息(除非配置允许)
490
+ if (!session.userId ||
491
+ !session.channelId ||
492
+ (session.author?.isBot && !config.isBotMessageTrackingEnabled)) {
493
+ return next();
494
+ }
495
+ // 排除指定群聊的统计
496
+ if (config.excludedChannelIds?.includes(session.channelId))
497
+ return next();
498
+ session[PROCESSED] = true;
499
+ const { userId, channelId, author } = session;
500
+ let sessionChannelName = session.event.channel.name;
501
+ const username = author?.nick || author?.name || userId;
502
+ const userAvatar = author?.avatar;
503
+ try {
504
+ const channelName = sessionChannelName ||
505
+ (channelId ? await getChannelName(session.bot, channelId) : channelId);
506
+ await ctx.database.upsert("message_counter_records", (row) => [
507
+ {
508
+ channelId,
509
+ userId,
510
+ username,
511
+ userAvatar: userAvatar || row.userAvatar,
512
+ channelName: channelName || row.channelName,
513
+ todayPostCount: koishi_1.$.add(row.todayPostCount, 1),
514
+ thisWeekPostCount: koishi_1.$.add(row.thisWeekPostCount, 1),
515
+ thisMonthPostCount: koishi_1.$.add(row.thisMonthPostCount, 1),
516
+ thisYearPostCount: koishi_1.$.add(row.thisYearPostCount, 1),
517
+ totalPostCount: koishi_1.$.add(row.totalPostCount, 1),
518
+ },
519
+ ], ["channelId", "userId"]);
520
+ }
521
+ catch (error) {
522
+ logger.error("Failed to update message count for user %s in channel %s:", userId, channelId, error);
523
+ }
524
+ // 继续消息处理链
525
+ return next();
526
+ }, true);
527
+ // 统计机器人自身消息
528
+ if (config.isBotMessageTrackingEnabled) {
529
+ ctx.before("send", async (session) => {
530
+ if (!session.channelId)
531
+ return;
532
+ const { channelId, bot } = session;
533
+ let sessionChannelName = session.event.channel.name;
534
+ const botUser = bot.user;
535
+ if (!botUser) {
536
+ logger.warn("Bot user is undefined, skipping bot message tracking.");
537
+ return;
538
+ }
539
+ try {
540
+ const channelName = sessionChannelName ||
541
+ (await getChannelName(bot, channelId)) ||
542
+ channelId;
543
+ await ctx.database.upsert("message_counter_records", (row) => [
544
+ {
545
+ channelId,
546
+ userId: botUser.id,
547
+ username: botUser.name,
548
+ userAvatar: botUser.avatar,
549
+ channelName: channelName || row.channelName,
550
+ todayPostCount: koishi_1.$.add(row.todayPostCount, 1),
551
+ thisWeekPostCount: koishi_1.$.add(row.thisWeekPostCount, 1),
552
+ thisMonthPostCount: koishi_1.$.add(row.thisMonthPostCount, 1),
553
+ thisYearPostCount: koishi_1.$.add(row.thisYearPostCount, 1),
554
+ totalPostCount: koishi_1.$.add(row.totalPostCount, 1),
555
+ },
556
+ ], ["channelId", "userId"]);
557
+ }
558
+ catch (error) {
559
+ logger.error("Failed to update bot message count in channel %s:", channelId, error);
560
+ }
561
+ });
562
+ }
563
+ // --- 指令定义 ---
564
+ // zl*
565
+ ctx
566
+ .command("mc", "查看消息统计帮助")
567
+ .action(({ session }) => session?.execute(`help mc`));
568
+ ctx
569
+ .command("mc.初始化", "初始化", { authority: 3 })
570
+ .action(async ({ session }) => {
571
+ if (!session)
572
+ return;
573
+ await session.send("正在清空所有发言记录,请稍候...");
574
+ await ctx.database.remove("message_counter_records", {});
575
+ await session.send("所有发言记录已清空!");
576
+ });
577
+ // 查询指令
578
+ ctx
579
+ .command("mc.查询 [targetUser:text]", "查询指定用户的发言次数信息")
580
+ .userFields(["id", "name"])
581
+ .option("yesterday", "--yd 昨日发言")
582
+ .option("day", "-d 今日发言")
583
+ .option("week", "-w 本周发言")
584
+ .option("month", "-m 本月发言")
585
+ .option("year", "-y 今年发言")
586
+ .option("total", "-t 总发言")
587
+ .option("ydag", "跨群昨日发言")
588
+ .option("dag", "跨群今日发言")
589
+ .option("wag", "跨群本周发言")
590
+ .option("mag", "跨群本月发言")
591
+ .option("yag", "跨群本年发言")
592
+ .option("across", "-a 跨群总发言")
593
+ .action(async ({ session, options }, targetUser) => {
594
+ // -- 1. 选项解析 --
595
+ const optionKeys = [
596
+ "day",
597
+ "week",
598
+ "month",
599
+ "year",
600
+ "total",
601
+ "yesterday",
602
+ "dag",
603
+ "wag",
604
+ "mag",
605
+ "yag",
606
+ "ydag",
607
+ "across",
608
+ ];
609
+ const selectedOptions = {};
610
+ let noOptionSelected = true;
611
+ for (const key of optionKeys) {
612
+ if (options[key]) {
613
+ selectedOptions[key] = true;
614
+ noOptionSelected = false;
615
+ }
616
+ }
617
+ if (noOptionSelected) {
618
+ for (const key of optionKeys) {
619
+ selectedOptions[key] = true;
620
+ }
621
+ }
622
+ // -- 2. 用户信息与数据获取 --
623
+ let channelId = session?.channelId;
624
+ let userId = session?.userId;
625
+ let targetUserRecord = [];
626
+ if (targetUser) {
627
+ if (session)
628
+ targetUser = await replaceAtTags(session, targetUser);
629
+ const match = targetUser.match(/<at id="([^"]+)"/);
630
+ if (match)
631
+ userId = match[1];
632
+ }
633
+ targetUserRecord = await ctx.database.get("message_counter_records", {
634
+ channelId,
635
+ userId,
636
+ });
637
+ if (targetUserRecord.length === 0)
638
+ return `被查询对象无任何发言记录。`;
639
+ const channelUsers = await ctx.database.get("message_counter_records", {
640
+ channelId,
641
+ });
642
+ const allUsers = await ctx.database.get("message_counter_records", {});
643
+ const channelStats = [];
644
+ const acrossStats = [];
645
+ // 累加总数
646
+ const accumulate = (records) => records.reduce((sums, user) => {
647
+ for (const key in periodMapping) {
648
+ sums[periodMapping[key].field] =
649
+ (sums[periodMapping[key].field] || 0) +
650
+ user[periodMapping[key].field];
651
+ }
652
+ return sums;
653
+ }, {});
654
+ const channelTotals = accumulate(channelUsers);
655
+ const acrossTotals = accumulate(allUsers);
656
+ // 获取排名
657
+ const getRank = (records, field, uid) => {
658
+ const sorted = [...records].sort((a, b) => b[field] - a[field]);
659
+ const index = sorted.findIndex((u) => u.userId === uid);
660
+ return index !== -1 ? index + 1 : null;
661
+ };
662
+ const getAcrossRank = (records, field, uid) => {
663
+ const userTotals = records.reduce((acc, cur) => {
664
+ acc[cur.userId] = (acc[cur.userId] || 0) + cur[field];
665
+ return acc;
666
+ }, {});
667
+ const sorted = Object.entries(userTotals).sort(([, a], [, b]) => b - a);
668
+ const index = sorted.findIndex(([id]) => id === uid);
669
+ return index !== -1 ? index + 1 : null;
670
+ };
671
+ const getAcrossCount = (records, field, uid) => {
672
+ return records
673
+ .filter((r) => r.userId === uid)
674
+ .reduce((sum, r) => sum + r[field], 0);
675
+ };
676
+ // 填充本群数据
677
+ channelStats.push({
678
+ label: "昨日",
679
+ count: targetUserRecord[0].yesterdayPostCount,
680
+ total: channelTotals.yesterdayPostCount,
681
+ rank: getRank(channelUsers, "yesterdayPostCount", userId),
682
+ enabled: selectedOptions.yesterday,
683
+ });
684
+ channelStats.push({
685
+ label: "今日",
686
+ count: targetUserRecord[0].todayPostCount,
687
+ total: channelTotals.todayPostCount,
688
+ rank: getRank(channelUsers, "todayPostCount", userId),
689
+ enabled: selectedOptions.day,
690
+ });
691
+ channelStats.push({
692
+ label: "本周",
693
+ count: targetUserRecord[0].thisWeekPostCount,
694
+ total: channelTotals.thisWeekPostCount,
695
+ rank: getRank(channelUsers, "thisWeekPostCount", userId),
696
+ enabled: selectedOptions.week,
697
+ });
698
+ channelStats.push({
699
+ label: "本月",
700
+ count: targetUserRecord[0].thisMonthPostCount,
701
+ total: channelTotals.thisMonthPostCount,
702
+ rank: getRank(channelUsers, "thisMonthPostCount", userId),
703
+ enabled: selectedOptions.month,
704
+ });
705
+ channelStats.push({
706
+ label: "全年",
707
+ count: targetUserRecord[0].thisYearPostCount,
708
+ total: channelTotals.thisYearPostCount,
709
+ rank: getRank(channelUsers, "thisYearPostCount", userId),
710
+ enabled: selectedOptions.year,
711
+ });
712
+ channelStats.push({
713
+ label: "总计",
714
+ count: targetUserRecord[0].totalPostCount,
715
+ total: channelTotals.totalPostCount,
716
+ rank: getRank(channelUsers, "totalPostCount", userId),
717
+ enabled: selectedOptions.total,
718
+ });
719
+ // 填充跨群数据
720
+ acrossStats.push({
721
+ label: "昨日",
722
+ count: getAcrossCount(allUsers, "yesterdayPostCount", userId),
723
+ total: acrossTotals.yesterdayPostCount,
724
+ rank: getAcrossRank(allUsers, "yesterdayPostCount", userId),
725
+ enabled: selectedOptions.ydag,
726
+ });
727
+ acrossStats.push({
728
+ label: "今日",
729
+ count: getAcrossCount(allUsers, "todayPostCount", userId),
730
+ total: acrossTotals.todayPostCount,
731
+ rank: getAcrossRank(allUsers, "todayPostCount", userId),
732
+ enabled: selectedOptions.dag,
733
+ });
734
+ acrossStats.push({
735
+ label: "本周",
736
+ count: getAcrossCount(allUsers, "thisWeekPostCount", userId),
737
+ total: acrossTotals.thisWeekPostCount,
738
+ rank: getAcrossRank(allUsers, "thisWeekPostCount", userId),
739
+ enabled: selectedOptions.wag,
740
+ });
741
+ acrossStats.push({
742
+ label: "本月",
743
+ count: getAcrossCount(allUsers, "thisMonthPostCount", userId),
744
+ total: acrossTotals.thisMonthPostCount,
745
+ rank: getAcrossRank(allUsers, "thisMonthPostCount", userId),
746
+ enabled: selectedOptions.mag,
747
+ });
748
+ acrossStats.push({
749
+ label: "全年",
750
+ count: getAcrossCount(allUsers, "thisYearPostCount", userId),
751
+ total: acrossTotals.thisYearPostCount,
752
+ rank: getAcrossRank(allUsers, "thisYearPostCount", userId),
753
+ enabled: selectedOptions.yag,
754
+ });
755
+ acrossStats.push({
756
+ label: "总计",
757
+ count: getAcrossCount(allUsers, "totalPostCount", userId),
758
+ total: acrossTotals.totalPostCount,
759
+ rank: getAcrossRank(allUsers, "totalPostCount", userId),
760
+ enabled: selectedOptions.across,
761
+ });
762
+ // -- 4. 格式化与输出 --
763
+ const formatPercentage = (count, total) => {
764
+ if (total === 0)
765
+ return "(0%)";
766
+ const percentage = (count / total) * 100;
767
+ const numStr = percentage % 1 === 0 ? String(percentage) : percentage.toFixed(2);
768
+ return `(${numStr}%)`;
769
+ };
770
+ const formatStatsTable = (title, stats) => {
771
+ const activeStats = stats.filter((s) => s.enabled && s.count > 0);
772
+ if (activeStats.length === 0)
773
+ return "";
774
+ const counts = activeStats.map((s) => String(s.count));
775
+ const percents = activeStats.map((s) => formatPercentage(s.count, s.total));
776
+ const maxCountWidth = Math.max(0, ...counts.map((s) => s.length));
777
+ const maxPercentWidth = Math.max(0, ...percents.map((s) => s.length));
778
+ let table = `${title}\n`;
779
+ for (const row of activeStats) {
780
+ const label = row.label.padEnd(2, " "); // 使用全角空格对齐中文
781
+ const countStr = String(row.count).padStart(maxCountWidth, " ");
782
+ const percentStr = formatPercentage(row.count, row.total).padEnd(maxPercentWidth, " ");
783
+ const rankStr = row.rank ? `#${row.rank}` : "#-";
784
+ table += `${label} ${countStr} ${percentStr} ${rankStr}\n`;
785
+ }
786
+ return table;
787
+ };
788
+ const channelTable = formatStatsTable("群发言", channelStats);
789
+ const acrossTable = formatStatsTable("跨群发言", acrossStats);
790
+ const body = [channelTable, acrossTable].filter(Boolean).join("\n");
791
+ if (!body)
792
+ return `被查询对象在指定时段内无发言记录。`;
793
+ // 使用 'sv-SE' locale 可以方便地得到 YYYY-MM-DD HH:MM:SS 格式
794
+ const timestamp = new Date().toLocaleString("sv-SE", {
795
+ timeZone: "Asia/Shanghai",
796
+ });
797
+ const header = `${timestamp}\n${targetUserRecord[0].username}\n\n`;
798
+ const message = header + body;
799
+ // -- 5. 图片转换 --
800
+ if (config.isTextToImageConversionEnabled && ctx.markdownToImage) {
801
+ try {
802
+ const imageBuffer = await ctx.markdownToImage.convertToImage(message);
803
+ return koishi_1.h.image(imageBuffer, `image/${config.imageType}`);
804
+ }
805
+ catch (error) {
806
+ logger.warn("生成图片失败,将回退到文本输出:", error);
807
+ }
808
+ }
809
+ // -- 6. 文本输出 (如果图片转换失败,或者未开启图片转换) --
810
+ return message;
811
+ });
812
+ // 排行榜指令
813
+ ctx
814
+ .command("mc.排行榜 [limit:number]", "用户发言排行榜")
815
+ .userFields(["id", "name"])
816
+ .option("whites", "<users:text> 白名单,用空格、逗号等分隔")
817
+ .option("blacks", "<users:text> 黑名单,用空格、逗号等分隔")
818
+ .option("yesterday", "--yd")
819
+ .option("day", "-d")
820
+ .option("week", "-w")
821
+ .option("month", "-m")
822
+ .option("year", "-y")
823
+ .option("total", "-t")
824
+ .option("ydag", "跨群昨日")
825
+ .option("dag", "跨群今日")
826
+ .option("wag", "跨群本周")
827
+ .option("mag", "跨群本月")
828
+ .option("yag", "跨群本年")
829
+ .option("dragon", "圣龙王榜 (跨群总榜)")
830
+ .action(async ({ session, options }, limit) => {
831
+ if (!session)
832
+ return;
833
+ const number = limit ?? config.defaultMaxDisplayCount;
834
+ if (typeof number !== "number" || isNaN(number) || number < 0) {
835
+ return "请输入大于等于 0 的数字作为排行榜显示人数。";
836
+ }
837
+ const whites = parseList(options?.whites);
838
+ const blacks = [
839
+ ...parseList(options?.blacks),
840
+ ...config.hiddenUserIdsInLeaderboard,
841
+ ];
842
+ const period = getPeriodFromOptions(options, "today");
843
+ const isAcross = isAcrossChannel(options);
844
+ const { field, name: periodName } = periodMapping[period];
845
+ const scopeName = isAcross ? "跨群" : "本群";
846
+ const rankTitle = `${scopeName}${periodName}发言排行榜`;
847
+ const rankTimeTitle = getCurrentBeijingTime();
848
+ let records;
849
+ if (isAcross) {
850
+ records = await ctx.database.get("message_counter_records", {});
851
+ }
852
+ else {
853
+ records = await ctx.database.get("message_counter_records", {
854
+ channelId: session.channelId,
855
+ });
856
+ }
857
+ const filteredRecords = filterRecordsByWhitesAndBlacks(records, "userId", whites, blacks);
858
+ if (filteredRecords.length === 0) {
859
+ return "当前范围内暂无发言记录。";
860
+ }
861
+ // 聚合数据
862
+ const userPostCounts = {};
863
+ const userInfo = {};
864
+ let totalCount = 0;
865
+ for (const record of filteredRecords) {
866
+ const count = record[field];
867
+ userPostCounts[record.userId] =
868
+ (userPostCounts[record.userId] || 0) + count;
869
+ if (!userInfo[record.userId]) {
870
+ userInfo[record.userId] = {
871
+ username: record.username,
872
+ avatar: record.userAvatar ||
873
+ `https://q1.qlogo.cn/g?b=qq&nk=${record.userId}&s=640`,
874
+ };
875
+ }
876
+ totalCount += count;
877
+ }
878
+ const sortedUsers = Object.entries(userPostCounts).sort(([, a], [, b]) => b - a);
879
+ const rankingData = prepareRankingData(sortedUsers, userInfo, totalCount, number, session.userId);
880
+ return renderLeaderboard({
881
+ rankTimeTitle,
882
+ rankTitle,
883
+ rankingData,
884
+ });
885
+ });
886
+ ctx
887
+ .command("mc.群排行榜 [limit:number]", "群发言排行榜")
888
+ .option("specificUser", "-s <user:text> 特定用户的群发言榜")
889
+ .option("whites", "<channels:text> 白名单群号")
890
+ .option("blacks", "<channels:text> 黑名单群号")
891
+ .option("yesterday", "--yd")
892
+ .option("day", "-d")
893
+ .option("week", "-w")
894
+ .option("month", "-m")
895
+ .option("year", "-y")
896
+ .option("total", "-t")
897
+ .action(async ({ session, options }, limit) => {
898
+ if (!session)
899
+ return;
900
+ const number = limit ?? config.defaultMaxDisplayCount;
901
+ if (typeof number !== "number" || isNaN(number) || number < 0) {
902
+ return "请输入大于等于 0 的数字作为排行榜显示人数。";
903
+ }
904
+ const whites = parseList(options?.whites);
905
+ const blacks = [
906
+ ...parseList(options?.blacks),
907
+ ...config.hiddenChannelIdsInLeaderboard,
908
+ ];
909
+ const period = getPeriodFromOptions(options, "today");
910
+ const { field, name: periodName } = periodMapping[period];
911
+ let records;
912
+ let rankTitle;
913
+ const rankTimeTitle = getCurrentBeijingTime();
914
+ if (options?.specificUser) {
915
+ const at = koishi_1.h.select(options.specificUser, "at");
916
+ const userId = at.length ? at[0].attrs.id : options.specificUser;
917
+ const userRecords = await ctx.database.get("message_counter_records", {
918
+ userId,
919
+ channelId: session.channelId,
920
+ });
921
+ const username = userRecords.length > 0
922
+ ? userRecords[0].username || `用户${userId}`
923
+ : `用户${userId}`;
924
+ rankTitle = `${username}的${periodName}群发言排行榜`;
925
+ records = await ctx.database.get("message_counter_records", { userId });
926
+ }
927
+ else {
928
+ rankTitle = `全群${periodName}发言排行榜`;
929
+ records = await ctx.database.get("message_counter_records", {});
930
+ }
931
+ const filteredRecords = filterRecordsByWhitesAndBlacks(records, "channelId", whites, blacks);
932
+ if (filteredRecords.length === 0) {
933
+ return `在当前条件下找不到任何群聊发言记录。`;
934
+ }
935
+ const { channelPostCounts, channelInfo, totalCount } = aggregateChannelData(filteredRecords, field);
936
+ const sortedChannels = Object.entries(channelPostCounts).sort(([, a], [, b]) => b - a);
937
+ const rankingData = prepareChannelRankingData(sortedChannels, channelInfo, totalCount, number, session.channelId);
938
+ return renderLeaderboard({
939
+ rankTimeTitle,
940
+ rankTitle,
941
+ rankingData,
942
+ });
943
+ });
944
+ // 上传柱状条背景
945
+ ctx
946
+ .command("mc.上传柱状条背景", "上传/更新自定义的水平柱状条背景图")
947
+ .action(async ({ session }) => {
948
+ if (!session || !session.userId) {
949
+ return "无法获取用户信息,请稍后再试。";
950
+ }
951
+ if (!session.content) {
952
+ return "请在发送指令的同时附带一张图片。新图片将会覆盖旧的背景。";
953
+ }
954
+ const imageElements = koishi_1.h.select(session.content, "img");
955
+ if (imageElements.length === 0) {
956
+ return "请在发送指令的同时附带一张图片。新图片将会覆盖旧的背景。";
957
+ }
958
+ const { userId } = session;
959
+ // 辅助函数:清理用户旧的背景图
960
+ const cleanupOldBackground = async () => {
961
+ try {
962
+ const allFiles = await fs.readdir(barBgImgsPath);
963
+ // 查找所有以 "用户ID." 开头的文件,以匹配不同后缀名
964
+ const userFiles = allFiles.filter((file) => file.startsWith(`${userId}.`));
965
+ if (userFiles.length > 0) {
966
+ await Promise.all(userFiles.map((file) => fs.unlink(path_1.default.join(barBgImgsPath, file))));
967
+ }
968
+ }
969
+ catch (error) {
970
+ // 如果目录不存在,则无需处理,这是正常情况
971
+ if (error.code !== "ENOENT") {
972
+ logger.warn(`清理用户 ${userId} 的旧背景图时出错:`, error);
973
+ }
974
+ }
975
+ };
976
+ try {
977
+ const imageUrl = imageElements[0].attrs.src;
978
+ if (!imageUrl) {
979
+ throw new Error("未能从消息中提取图片 URL。");
980
+ }
981
+ const buffer = Buffer.from(await ctx.http.get(imageUrl, { responseType: "arraybuffer" }));
982
+ // 检查文件大小
983
+ const imageSizeInMB = buffer.byteLength / 1024 / 1024;
984
+ if (config.maxBarBgSize > 0 && imageSizeInMB > config.maxBarBgSize) {
985
+ throw new Error(`图片文件过大(${imageSizeInMB.toFixed(2)}MB),请上传小于 ${config.maxBarBgSize}MB 的图片。`);
986
+ }
987
+ // 检查图片尺寸
988
+ if (ctx.canvas) {
989
+ try {
990
+ const image = await ctx.canvas.loadImage(buffer);
991
+ if ((config.maxBarBgWidth > 0 &&
992
+ image.naturalWidth > config.maxBarBgWidth) ||
993
+ (config.maxBarBgHeight > 0 &&
994
+ image.naturalHeight > config.maxBarBgHeight)) {
995
+ throw new Error(`图片尺寸(${image.naturalWidth}x${image.naturalHeight})超出限制(最大 ${config.maxBarBgWidth}x${config.maxBarBgHeight})。\n建议尺寸为 850x50 像素。`);
996
+ }
997
+ }
998
+ catch (error) {
999
+ logger.error("解析图片尺寸失败:", error);
1000
+ throw new Error("无法解析图片尺寸,请尝试使用其他标准图片格式(如 PNG, JPEG)。");
1001
+ }
1002
+ }
1003
+ else {
1004
+ logger.warn("Canvas 服务未启用,跳过背景图尺寸检查。");
1005
+ }
1006
+ // 所有检查通过,先清理旧图,再保存新图
1007
+ await cleanupOldBackground();
1008
+ // 统一保存为 png 格式,文件名为 用户ID.png
1009
+ const newFileName = `${userId}.png`;
1010
+ const newFilePath = path_1.default.join(barBgImgsPath, newFileName);
1011
+ await fs.writeFile(newFilePath, buffer);
1012
+ await reloadBarBgImgCache();
1013
+ return "您的自定义柱状条背景已成功更新!";
1014
+ }
1015
+ catch (error) {
1016
+ logger.error(`为用户 ${userId} 上传背景图失败:`, error);
1017
+ // 上传失败,清理旧的背景图
1018
+ await cleanupOldBackground();
1019
+ await reloadBarBgImgCache(); // 清理后同样需要重载缓存
1020
+ const userMessage = error instanceof Error
1021
+ ? error.message
1022
+ : "图片保存时发生未知错误,请联系管理员。";
1023
+ return `图片上传失败: ${userMessage}\n您之前的自定义背景(如有)已被移除。`;
1024
+ }
1025
+ });
1026
+ // 重载资源
1027
+ ctx
1028
+ .command("mc.重载资源", "重载图标、背景和字体资源", {
1029
+ authority: 2,
1030
+ })
1031
+ .action(async ({ session }) => {
1032
+ if (!session)
1033
+ return;
1034
+ await session.send("正在重新加载用户图标、背景图片和字体文件缓存...");
1035
+ await reloadIconCache();
1036
+ await reloadBarBgImgCache();
1037
+ await reloadFontCache(); // 调用字体缓存重载
1038
+ return `资源重载完毕!\n- 已加载 ${iconCache.length} 个用户图标。\n- 已加载 ${barBgImgCache.length} 个柱状条背景图片。\n- 已加载 ${fontFilesCache.length} 个字体文件。`;
1039
+ });
1040
+ // 清理缓存
1041
+ ctx
1042
+ .command("mc.清理缓存", "清理过期的头像缓存文件", {
1043
+ authority: 3,
1044
+ })
1045
+ .option("days", "-d <days:number> 清理超过指定天数未使用的缓存文件 (默认: 30)")
1046
+ .action(async ({ session, options }) => {
1047
+ if (!session)
1048
+ return;
1049
+ const days = options.days ?? 30;
1050
+ if (typeof days !== "number" || days < 0) {
1051
+ return "请输入一个有效的天数(大于等于0)。";
1052
+ }
1053
+ await session.send(`正在开始清理 ${days} 天前的头像缓存,请稍候...`);
1054
+ const cacheDir = avatarsPath; // 使用已定义的头像缓存路径
1055
+ let deletedCount = 0;
1056
+ let totalFreedSize = 0;
1057
+ const now = Date.now();
1058
+ const expirationTime = now - days * 24 * 60 * 60 * 1000;
1059
+ try {
1060
+ const files = await fs.readdir(cacheDir);
1061
+ for (const file of files) {
1062
+ if (!file.endsWith(".json"))
1063
+ continue; // 只处理 .json 缓存文件
1064
+ const filePath = path_1.default.join(cacheDir, file);
1065
+ try {
1066
+ const stats = await fs.stat(filePath);
1067
+ const content = await fs.readFile(filePath, "utf-8");
1068
+ const entry = JSON.parse(content);
1069
+ // 检查时间戳是否早于我们设定的过期时间点
1070
+ if (entry.timestamp < expirationTime) {
1071
+ await fs.unlink(filePath);
1072
+ deletedCount++;
1073
+ totalFreedSize += stats.size;
1074
+ }
1075
+ }
1076
+ catch (error) {
1077
+ logger.warn(`处理缓存文件 ${file} 时出错,已跳过:`, error);
1078
+ }
1079
+ }
1080
+ const freedSizeFormatted = formatBytes(totalFreedSize);
1081
+ return `缓存清理完成!\n- 共删除 ${deletedCount} 个过期缓存文件。\n- 释放磁盘空间约 ${freedSizeFormatted}。`;
1082
+ }
1083
+ catch (error) {
1084
+ if (error.code === "ENOENT") {
1085
+ return "头像缓存目录不存在,无需清理。";
1086
+ }
1087
+ logger.error("清理头像缓存时发生未知错误:", error);
1088
+ return "清理过程中发生错误,请查看控制台日志。";
1089
+ }
1090
+ });
1091
+ // --- 辅助函数 ---
1092
+ // hs*
1093
+ /**
1094
+ * 检查字体文件,如果存在不规范的 vhea 版本,则创建一个修复后的副本,并返回可用字体的路径。
1095
+ * @param filePath 原始字体的绝对路径
1096
+ * @returns 一个保证可用的字体文件的绝对路径(可能是原始路径或修复后的副本路径)
1097
+ */
1098
+ async function patchAndGetUsableFontPath(filePath) {
1099
+ let buffer;
1100
+ try {
1101
+ buffer = await fs.readFile(filePath);
1102
+ }
1103
+ catch (readError) {
1104
+ logger.warn(`读取字体文件 "${path_1.default.basename(filePath)}" 失败,已跳过。错误: ${readError.message}`);
1105
+ return filePath; // 返回原始路径,让后续流程处理错误
1106
+ }
1107
+ // --- 核心检查逻辑 (与之前相同) ---
1108
+ if (buffer.length < 12)
1109
+ return filePath;
1110
+ const numTables = buffer.readUInt16BE(4);
1111
+ for (let i = 0; i < numTables; i++) {
1112
+ const recordOffset = 12 + i * 16;
1113
+ if (recordOffset + 16 > buffer.length)
1114
+ break;
1115
+ if (buffer.toString("ascii", recordOffset, recordOffset + 4) === "vhea") {
1116
+ const tableOffset = buffer.readUInt32BE(recordOffset + 8);
1117
+ if (tableOffset + 4 > buffer.length)
1118
+ break;
1119
+ const version = buffer.readUInt32BE(tableOffset);
1120
+ const INCORRECT_VERSION = 0x00010001; // 65537
1121
+ if (version === INCORRECT_VERSION) {
1122
+ // --- 新的修复逻辑:创建副本 ---
1123
+ const parsedPath = path_1.default.parse(filePath);
1124
+ const patchedFilename = `${parsedPath.name}-patched${parsedPath.ext}`;
1125
+ const patchedFilePath = path_1.default.join(parsedPath.dir, patchedFilename);
1126
+ try {
1127
+ // 检查修复后的文件是否已存在。如果存在,就直接使用它,避免重复写入。
1128
+ await fs.access(patchedFilePath);
1129
+ return patchedFilePath;
1130
+ }
1131
+ catch (e) {
1132
+ // 修复后的文件不存在,现在创建它。
1133
+ logger.info(`检测到字体 "${path_1.default.basename(filePath)}" 不规范,正在创建修复版本 "${patchedFilename}"...`);
1134
+ const CORRECT_VERSION = 0x00010000; // 65536
1135
+ buffer.writeUInt32BE(CORRECT_VERSION, tableOffset);
1136
+ try {
1137
+ await fs.writeFile(patchedFilePath, buffer);
1138
+ logger.success(`已成功创建修复后的字体文件 "${patchedFilename}"。`);
1139
+ return patchedFilePath;
1140
+ }
1141
+ catch (writeError) {
1142
+ logger.warn(`创建修复字体副本失败: ${writeError.message}`);
1143
+ return filePath; // 创建失败,回退到使用原始文件
1144
+ }
1145
+ }
1146
+ }
1147
+ break; // 找到 vhea 表后即可退出
1148
+ }
1149
+ }
1150
+ // 如果字体本身没问题,返回原始路径
1151
+ return filePath;
1152
+ }
1153
+ /**
1154
+ * 将字节数格式化为易于阅读的字符串 (B, KB, MB, GB...)
1155
+ * @param bytes - 要格式化的字节数
1156
+ * @param decimals - 保留的小数位数
1157
+ * @returns 格式化后的字符串
1158
+ */
1159
+ function formatBytes(bytes, decimals = 2) {
1160
+ if (bytes === 0)
1161
+ return "0 Bytes";
1162
+ const k = 1024;
1163
+ const dm = decimals < 0 ? 0 : decimals;
1164
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
1165
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1166
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
1167
+ }
1168
+ /**
1169
+ * 为自动推送功能生成并发送排行榜。
1170
+ * @param period - 排行榜的周期 ('today' 或 'yesterday')。
1171
+ */
1172
+ async function generateAndPushLeaderboard(period) {
1173
+ const pushPeriodConfig = {
1174
+ today: { field: "todayPostCount", name: "今日" },
1175
+ yesterday: { field: "yesterdayPostCount", name: "昨日" },
1176
+ week: { field: "thisWeekPostCount", name: "上周" },
1177
+ month: { field: "thisMonthPostCount", name: "上月" },
1178
+ year: { field: "thisYearPostCount", name: "去年" },
1179
+ };
1180
+ const { field, name: periodName } = pushPeriodConfig[period];
1181
+ logger.info(`[自动推送] 开始执行 ${periodName} 发言排行榜推送任务。`);
1182
+ const scopeName = "本群"; // 自动推送总是基于单个群聊的视角
1183
+ const rankTimeTitle = getCurrentBeijingTime();
1184
+ // 1. 优先获取所有机器人能触及的群聊列表,并建立一个 ID -> 带平台前缀ID 的映射
1185
+ // guildChannelIdMap 仅包含真实的群聊(来自 bot.getGuildList),用于“推送到所有群”
1186
+ // lookupChannelIdMap 在 guild 基础上叠加数据库记录,仅用于解析配置中指定的频道 ID
1187
+ const guildChannelIdMap = new Map();
1188
+ const lookupChannelIdMap = new Map();
1189
+ // 判断是否为私聊频道(Koishi 约定的私聊 channelId 形如 "private:...")
1190
+ const isDirectChannelId = (id) => {
1191
+ const sepIdx = id.indexOf(":");
1192
+ const unprefixed = sepIdx !== -1 ? id.substring(sepIdx + 1) : id;
1193
+ return (id.startsWith("private:") ||
1194
+ unprefixed.startsWith("private:") ||
1195
+ unprefixed.startsWith("private_"));
1196
+ };
1197
+ // --- 增强获取逻辑,防止 Adapter 报错导致任务中断 ---
1198
+ try {
1199
+ const channelListPromises = ctx.bots.map(async (bot) => {
1200
+ if (!bot.online || !bot.getGuildList)
1201
+ return [];
1202
+ try {
1203
+ let next;
1204
+ do {
1205
+ const result = await bot.getGuildList(next);
1206
+ if (!result || !result.data) {
1207
+ break;
1208
+ }
1209
+ // 确保 result.data 是数组
1210
+ if (Array.isArray(result.data)) {
1211
+ result.data.forEach((channel) => {
1212
+ // 跳过私聊目标,避免误将私聊用户当作群聊推送
1213
+ if (isDirectChannelId(channel.id))
1214
+ return;
1215
+ // 避免因多个机器人同在一个群而覆盖
1216
+ if (!guildChannelIdMap.has(channel.id)) {
1217
+ const prefixed = `${bot.platform}:${channel.id}`;
1218
+ guildChannelIdMap.set(channel.id, prefixed);
1219
+ lookupChannelIdMap.set(channel.id, prefixed);
1220
+ }
1221
+ });
1222
+ }
1223
+ next = result.next;
1224
+ } while (next);
1225
+ }
1226
+ catch (error) {
1227
+ // 捕获单个 Bot 获取列表时的错误(如 OneBot 的 retcode 1400),避免炸毁整个任务
1228
+ logger.warn(`[自动推送] 机器人 ${bot.platform} 获取群聊列表失败,将尝试使用数据库记录回退: ${error.message}`);
1229
+ }
1230
+ });
1231
+ await Promise.all(channelListPromises);
1232
+ }
1233
+ catch (error) {
1234
+ logger.error("[自动推送] 获取群聊列表的主流程发生错误:", error);
1235
+ }
1236
+ // --- 数据库回退机制 ---
1237
+ // 仅用于解析配置中显式指定的 pushChannelIds,不会污染“推送到所有群”的目标列表
1238
+ try {
1239
+ const allRecords = await ctx.database.get("message_counter_records", {}, [
1240
+ "channelId",
1241
+ ]);
1242
+ const dbChannelIds = new Set(allRecords.map((r) => r.channelId));
1243
+ const activeBot = ctx.bots.find((b) => b.status === 1);
1244
+ for (const dbCid of dbChannelIds) {
1245
+ if (isDirectChannelId(dbCid))
1246
+ continue;
1247
+ if (!lookupChannelIdMap.has(dbCid) && activeBot) {
1248
+ lookupChannelIdMap.set(dbCid, `${activeBot.platform}:${dbCid}`);
1249
+ }
1250
+ }
1251
+ }
1252
+ catch (dbError) {
1253
+ logger.warn("[自动推送] 读取数据库记录进行回退时出错:", dbError);
1254
+ }
1255
+ // 2. 确定需要推送的频道列表(使用 Set 自动去重)
1256
+ const targetChannels = new Set();
1257
+ // 2.1 解析配置中的 pushChannelIds,利用映射表转换为带前缀的 ID
1258
+ for (const channelId of config.pushChannelIds || []) {
1259
+ if (channelId.includes(":")) {
1260
+ // 本身就是带前缀的 ID
1261
+ targetChannels.add(channelId);
1262
+ }
1263
+ else if (lookupChannelIdMap.has(channelId)) {
1264
+ // 在映射表中找到了对应的带前缀 ID
1265
+ targetChannels.add(lookupChannelIdMap.get(channelId));
1266
+ }
1267
+ else {
1268
+ // 如果映射表没找到(且之前 DB 回退也没找到),尝试用第一个在线 Bot 强行构建
1269
+ const activeBot = ctx.bots.find((b) => b.status === 1);
1270
+ if (activeBot) {
1271
+ targetChannels.add(`${activeBot.platform}:${channelId}`);
1272
+ }
1273
+ else {
1274
+ logger.warn(`[自动推送] 无法处理配置的频道 ID: ${channelId},未找到在线 Bot。`);
1275
+ }
1276
+ }
1277
+ }
1278
+ // 2.2 如果开启了“向所有群聊推送”,仅添加来自 getGuildList 的真实群聊,避免私聊误推
1279
+ if (config.shouldSendLeaderboardNotificationsToAllChannels) {
1280
+ guildChannelIdMap.forEach((prefixedId) => targetChannels.add(prefixedId));
1281
+ }
1282
+ // 2.3 应用排除列表
1283
+ const excluded = new Set(config.excludedLeaderboardChannels || []);
1284
+ if (excluded.size > 0) {
1285
+ for (const id of Array.from(targetChannels)) {
1286
+ // 兼容带前缀和不带前缀的排除项
1287
+ const separatorIdx = id.indexOf(":");
1288
+ const unprefixedId = separatorIdx !== -1 ? id.substring(separatorIdx + 1) : id;
1289
+ if (excluded.has(id) || excluded.has(unprefixedId)) {
1290
+ targetChannels.delete(id);
1291
+ }
1292
+ }
1293
+ }
1294
+ if (targetChannels.size === 0) {
1295
+ logger.info("[自动推送] 没有配置任何需要推送的频道,任务结束。");
1296
+ return;
1297
+ }
1298
+ logger.info(`[自动推送] 将向 ${targetChannels.size} 个频道进行推送。`);
1299
+ // 3. 遍历频道并推送 (修改点在于 field 和 periodName 已被通用化)
1300
+ for (const prefixedChannelId of targetChannels) {
1301
+ try {
1302
+ const platformSeparatorIndex = prefixedChannelId.indexOf(":");
1303
+ const channelId = platformSeparatorIndex === -1
1304
+ ? prefixedChannelId
1305
+ : prefixedChannelId.substring(platformSeparatorIndex + 1);
1306
+ const records = await ctx.database.get("message_counter_records", {
1307
+ channelId,
1308
+ });
1309
+ if (records.length === 0) {
1310
+ logger.info(`[自动推送] 频道 ${prefixedChannelId} 无发言记录,跳过。`);
1311
+ continue;
1312
+ }
1313
+ // 聚合数据时,使用我们动态选择的 `field`
1314
+ const userPostCounts = {};
1315
+ const userInfo = {};
1316
+ let totalCount = 0;
1317
+ for (const record of records) {
1318
+ const count = record[field] || 0; // 读取正确的周期数据
1319
+ userPostCounts[record.userId] =
1320
+ (userPostCounts[record.userId] || 0) + count;
1321
+ if (!userInfo[record.userId]) {
1322
+ userInfo[record.userId] = {
1323
+ username: record.username,
1324
+ avatar: record.userAvatar ||
1325
+ `https://q1.qlogo.cn/g?b=qq&nk=${record.userId}&s=640`,
1326
+ };
1327
+ }
1328
+ totalCount += count;
1329
+ }
1330
+ const sortedUsers = Object.entries(userPostCounts)
1331
+ .filter(([, count]) => count > 0)
1332
+ .sort(([, a], [, b]) => b - a);
1333
+ if (sortedUsers.length === 0) {
1334
+ logger.info(`[自动推送] 频道 ${prefixedChannelId} 在 ${periodName} 榜单上无有效数据,跳过。`);
1335
+ continue;
1336
+ }
1337
+ const rankingData = prepareRankingData(sortedUsers, userInfo, totalCount, config.defaultMaxDisplayCount);
1338
+ if (config.isGeneratingRankingListPromptVisible) {
1339
+ // 忽略发送提示消息的错误(例如禁言导致),不中断后续发送图片
1340
+ try {
1341
+ await ctx.broadcast([prefixedChannelId], `正在为本群生成${periodName}发言排行榜...`);
1342
+ }
1343
+ catch (e) { }
1344
+ await (0, koishi_1.sleep)(config.leaderboardGenerationWaitTime * 1000);
1345
+ }
1346
+ // 渲染时,使用我们动态选择的 `periodName`
1347
+ const rankTitle = `${scopeName}${periodName}发言排行榜`;
1348
+ const renderedMessage = await renderLeaderboard({
1349
+ rankTimeTitle,
1350
+ rankTitle,
1351
+ rankingData,
1352
+ });
1353
+ await ctx.broadcast([prefixedChannelId], renderedMessage);
1354
+ logger.success(`[自动推送] 已成功向频道 ${prefixedChannelId} 推送${periodName}排行榜。`);
1355
+ const randomDelay = Math.random() * config.groupPushDelayRandomizationSeconds;
1356
+ const delay = (config.delayBetweenGroupPushesInSeconds + randomDelay) * 1000;
1357
+ if (delay > 0) {
1358
+ await (0, koishi_1.sleep)(delay);
1359
+ }
1360
+ }
1361
+ catch (error) {
1362
+ logger.error(`[自动推送] 向频道 ${prefixedChannelId} 推送时发生错误:`, error);
1363
+ }
1364
+ }
1365
+ logger.info(`[自动推送] 所有推送任务执行完毕。`);
1366
+ }
1367
+ /**
1368
+ * 执行“抓龙王”禁言操作
1369
+ */
1370
+ async function performDragonKingMuting() {
1371
+ if (!config.enableMostActiveUserMuting ||
1372
+ !config.muteChannelIds ||
1373
+ config.muteChannelIds.length === 0) {
1374
+ return;
1375
+ }
1376
+ logger.info("[抓龙王] 开始执行禁言任务。");
1377
+ // 等待设定的延迟时间
1378
+ await (0, koishi_1.sleep)(config.dragonKingDetainmentTime * 1000);
1379
+ for (const channelId of config.muteChannelIds) {
1380
+ try {
1381
+ const records = await ctx.database.get("message_counter_records", {
1382
+ channelId,
1383
+ yesterdayPostCount: { $gt: 0 }, // 只查找昨日有发言的
1384
+ });
1385
+ if (records.length === 0) {
1386
+ logger.info(`[抓龙王] 频道 ${channelId} 昨日无人发言,跳过。`);
1387
+ continue;
1388
+ }
1389
+ // 找出昨日发言最多的人
1390
+ const topUser = records.sort((a, b) => b.yesterdayPostCount - a.yesterdayPostCount)[0];
1391
+ if (!topUser)
1392
+ continue;
1393
+ const durationInMs = config.detentionDuration * 24 * 60 * 60 * 1000;
1394
+ let isMuted = false;
1395
+ // 遍历所有在线的机器人,尝试使用标准 API 执行禁言
1396
+ for (const bot of ctx.bots) {
1397
+ // 只尝试在线的机器人
1398
+ if (bot.status !== 1)
1399
+ continue;
1400
+ try {
1401
+ // 使用标准的 bot.muteGuildMember API
1402
+ await bot.muteGuildMember(channelId, topUser.userId, durationInMs);
1403
+ // 只要有一个 bot 成功,就标记成功并停止尝试
1404
+ isMuted = true;
1405
+ logger.success(`[抓龙王] Bot ${bot.selfId} 已在频道 ${channelId} 将昨日龙王 ${topUser.username} (${topUser.userId}) 禁言 ${config.detentionDuration} 天。`);
1406
+ break; // 禁言成功,跳出循环
1407
+ }
1408
+ catch (e) {
1409
+ // 这个机器人可能不在该群或权限不足,这是正常现象,静默处理并尝试下一个
1410
+ }
1411
+ }
1412
+ if (isMuted) {
1413
+ // 禁言成功后,再向群内发送通知
1414
+ await ctx.broadcast([channelId], `根据统计,昨日发言最多的是 ${(0, koishi_1.h)("at", {
1415
+ id: topUser.userId,
1416
+ name: topUser.username,
1417
+ })},现执行禁言 ${config.detentionDuration} 天。`);
1418
+ }
1419
+ else {
1420
+ // 如果所有机器人都尝试失败了
1421
+ logger.warn(`[抓龙王] 在频道 ${channelId} 执行禁言失败。可能没有任何机器人拥有该群的管理员权限,或目标用户是管理员。`);
1422
+ }
1423
+ }
1424
+ catch (error) {
1425
+ logger.error(`[抓龙王] 在频道 ${channelId} 查找龙王时出错:`, error);
1426
+ }
1427
+ }
1428
+ }
1429
+ const scheduledTasks = [];
1430
+ /**
1431
+ * 初始化重置状态,防止首次启动时发生破坏性数据清除。
1432
+ * 此函数会在插件启动时运行,为每个周期检查并创建基准重置时间记录。
1433
+ */
1434
+ async function initializeResetStates() {
1435
+ logger.info("正在初始化并验证发言计数器的重置状态...");
1436
+ const now = new Date();
1437
+ const state = await ctx.database.get("message_counter_state", {});
1438
+ const stateMap = new Map(state.map((s) => [s.key, s.value]));
1439
+ const periods = [
1440
+ "daily",
1441
+ "weekly",
1442
+ "monthly",
1443
+ "yearly",
1444
+ ];
1445
+ for (const period of periods) {
1446
+ const key = `last_${period}_reset`;
1447
+ if (!stateMap.has(key)) {
1448
+ // 如果状态不存在,说明是首次运行或数据被清除。
1449
+ // 我们不执行重置,而是创建一个安全的基准时间点。
1450
+ let baselineDate;
1451
+ switch (period) {
1452
+ case "daily":
1453
+ baselineDate = new Date();
1454
+ baselineDate.setHours(0, 0, 0, 0);
1455
+ break;
1456
+ case "weekly":
1457
+ baselineDate = new Date(now);
1458
+ baselineDate.setDate(now.getDate() - ((now.getDay() + 6) % 7));
1459
+ baselineDate.setHours(0, 0, 0, 0);
1460
+ break;
1461
+ case "monthly":
1462
+ baselineDate = new Date(now.getFullYear(), now.getMonth(), 1);
1463
+ baselineDate.setHours(0, 0, 0, 0);
1464
+ break;
1465
+ case "yearly":
1466
+ baselineDate = new Date(now.getFullYear(), 0, 1);
1467
+ baselineDate.setHours(0, 0, 0, 0);
1468
+ break;
1469
+ }
1470
+ await ctx.database.upsert("message_counter_state", [
1471
+ { key, value: baselineDate },
1472
+ ]);
1473
+ logger.info(`已为 '${period}' 周期初始化重置状态,基准时间:${baselineDate.toISOString()}`);
1474
+ }
1475
+ }
1476
+ logger.info("所有周期的重置状态已验证完毕。");
1477
+ }
1478
+ /**
1479
+ * 检查指定周期的重置任务是否应该执行。
1480
+ * 通过查询数据库中的最后重置时间,并与当前周期的起始时间对比,来防止重复执行。
1481
+ * @param period 要检查的周期 ('daily', 'weekly', 'monthly', 'yearly')。
1482
+ * @returns 如果需要重置,则返回 true;否则返回 false。
1483
+ */
1484
+ async function isResetDue(period) {
1485
+ const now = new Date();
1486
+ const state = await ctx.database.get("message_counter_state", {
1487
+ key: `last_${period}_reset`,
1488
+ });
1489
+ // 如果数据库中没有记录,则认为它从未重置过,使用一个很早的时间点。
1490
+ const lastReset = state.length ? new Date(state[0].value) : new Date(0);
1491
+ let periodStart;
1492
+ switch (period) {
1493
+ case "daily":
1494
+ periodStart = new Date();
1495
+ periodStart.setHours(0, 0, 0, 0);
1496
+ break;
1497
+ case "weekly":
1498
+ // 将日期设置为本周的周一。 (day + 6) % 7 是从周一算起的天数。
1499
+ periodStart = new Date(now);
1500
+ periodStart.setDate(now.getDate() - ((now.getDay() + 6) % 7));
1501
+ periodStart.setHours(0, 0, 0, 0);
1502
+ break;
1503
+ case "monthly":
1504
+ periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
1505
+ periodStart.setHours(0, 0, 0, 0);
1506
+ break;
1507
+ case "yearly":
1508
+ periodStart = new Date(now.getFullYear(), 0, 1);
1509
+ periodStart.setHours(0, 0, 0, 0);
1510
+ break;
1511
+ }
1512
+ // 核心判断:如果上次重置时间早于当前周期的起始时间,那么就需要执行重置。
1513
+ return lastReset < periodStart;
1514
+ }
1515
+ /**
1516
+ * 检查并执行错过的重置任务
1517
+ * 现在将使用 isResetDue() 来判断是否需要补上任务。
1518
+ */
1519
+ async function checkForMissedResets() {
1520
+ logger.info("正在检查错过的计数器重置任务...");
1521
+ // 定义任务,以便循环处理
1522
+ const jobDefinitions = [
1523
+ {
1524
+ period: "daily",
1525
+ field: "todayPostCount",
1526
+ message: "已补上错过的每日发言榜重置!",
1527
+ },
1528
+ {
1529
+ period: "weekly",
1530
+ field: "thisWeekPostCount",
1531
+ message: "已补上错过的每周发言榜重置!",
1532
+ },
1533
+ {
1534
+ period: "monthly",
1535
+ field: "thisMonthPostCount",
1536
+ message: "已补上错过的每月发言榜重置!",
1537
+ },
1538
+ {
1539
+ period: "yearly",
1540
+ field: "thisYearPostCount",
1541
+ message: "已补上错过的每年发言榜重置!",
1542
+ },
1543
+ ];
1544
+ for (const job of jobDefinitions) {
1545
+ if (await isResetDue(job.period)) {
1546
+ logger.info(`检测到错过的 ${job.period} 重置任务,正在执行...`);
1547
+ await resetCounter(job.field, job.message, job.period);
1548
+ }
1549
+ }
1550
+ logger.info("错过的计数器重置任务检查完毕。");
1551
+ }
1552
+ /**
1553
+ * 重置计数器并更新状态
1554
+ * @param field 要重置的数据库字段
1555
+ * @param message 重置后发送的消息
1556
+ * @param period 周期标识符
1557
+ */
1558
+ async function resetCounter(field, message, period) {
1559
+ // 当重置“今日”发言时,首先把“今日”的数据备份到“昨日”
1560
+ if (field === "todayPostCount") {
1561
+ logger.info("正在更新昨日发言数...");
1562
+ await ctx.database.set("message_counter_records", {}, (row) => ({
1563
+ yesterdayPostCount: row.todayPostCount,
1564
+ }));
1565
+ logger.success("更新昨日发言数完成。");
1566
+ }
1567
+ // 然后将相应的字段置零
1568
+ await ctx.database.set("message_counter_records", {}, { [field]: 0 });
1569
+ logger.success(message);
1570
+ // 更新状态表,记录本次重置时间
1571
+ await ctx.database.upsert("message_counter_state", [
1572
+ {
1573
+ key: `last_${period}_reset`,
1574
+ value: new Date(),
1575
+ },
1576
+ ]);
1577
+ logger.success(`已更新 ${period} 周期的最后重置时间。`);
1578
+ }
1579
+ // 将数字格式化为保留两位小数的百分比字符串,例如 "12.34%"
1580
+ function formatPercentageForDisplay(count, total) {
1581
+ if (total === 0) {
1582
+ return "(0%)";
1583
+ }
1584
+ const percentage = (count / total) * 100;
1585
+ // 使用 toFixed(2) 保证最多两位小数,然后用 parseFloat 去掉末尾多余的 .0 和 0
1586
+ const formattedNumber = parseFloat(percentage.toFixed(2));
1587
+ return `(${formattedNumber}%)`;
1588
+ }
1589
+ /**
1590
+ * getAvatarAsBase64 函数
1591
+ * 实现了成功的长 TTL 缓存和失败的短 TTL 缓存策略。
1592
+ * @param url 头像的URL
1593
+ * @returns 处理后的头像 base64 字符串
1594
+ */
1595
+ async function getAvatarAsBase64(url) {
1596
+ if (!url) {
1597
+ return fallbackBase64[0];
1598
+ }
1599
+ const now = Date.now();
1600
+ // 从配置中获取成功和失败的缓存有效期(转换为毫秒)
1601
+ const successTtl = config.avatarCacheTTL * 1000;
1602
+ const failureTtl = config.avatarFailureCacheTTL * 1000;
1603
+ // 辅助函数,用于检查缓存条目是否过期
1604
+ const isEntryExpired = (entry) => {
1605
+ // 判断缓存的头像是真实头像还是备用头像
1606
+ const isFallback = entry.base64 === fallbackBase64[0];
1607
+ // 根据情况选择对应的 TTL
1608
+ const ttl = isFallback ? failureTtl : successTtl;
1609
+ // 如果 TTL 设置为 0 且不是失败缓存,则永不过期
1610
+ if (ttl === 0 && !isFallback)
1611
+ return false;
1612
+ // 检查当前时间是否已超过缓存的创建时间+有效期
1613
+ return now - entry.timestamp >= ttl;
1614
+ };
1615
+ // 1. 检查内存缓存 (Hot Cache),用于最快的响应
1616
+ if (avatarCache.has(url)) {
1617
+ const entry = avatarCache.get(url);
1618
+ if (!isEntryExpired(entry)) {
1619
+ // 内存缓存命中且未过期,直接返回
1620
+ return entry.base64;
1621
+ }
1622
+ }
1623
+ // 2. 检查磁盘缓存 (Persistent Cache),用于持久化
1624
+ // 使用 URL 的 MD5 哈希作为文件名,避免特殊字符和路径过长问题
1625
+ const hash = crypto.createHash("md5").update(url).digest("hex");
1626
+ const cacheFilePath = path_1.default.join(avatarsPath, `${hash}.json`);
1627
+ try {
1628
+ const cachedFile = await fs.readFile(cacheFilePath, "utf-8");
1629
+ const entry = JSON.parse(cachedFile);
1630
+ if (!isEntryExpired(entry)) {
1631
+ // 磁盘缓存命中且未过期,将其加载到内存并返回
1632
+ avatarCache.set(url, entry); // 更新内存缓存
1633
+ return entry.base64;
1634
+ }
1635
+ }
1636
+ catch (error) {
1637
+ // 捕获错误(如文件不存在、JSON解析失败),意味着磁盘缓存无效,继续执行网络请求
1638
+ }
1639
+ // 3. 从网络获取,并根据结果应用不同的缓存策略
1640
+ let finalBase64 = fallbackBase64[0];
1641
+ try {
1642
+ if (!ctx.canvas) {
1643
+ throw new Error("Canvas service is not available.");
1644
+ }
1645
+ // 设置5秒超时,防止请求卡死
1646
+ const buffer = await ctx.http.get(url, {
1647
+ responseType: "arraybuffer",
1648
+ timeout: 5000,
1649
+ });
1650
+ // 使用 canvas 将图片统一处理为 50x50 的 PNG
1651
+ const image = await ctx.canvas.loadImage(buffer);
1652
+ const canvas = await ctx.canvas.createCanvas(50, 50);
1653
+ const context = canvas.getContext("2d");
1654
+ context.drawImage(image, 0, 0, 50, 50);
1655
+ finalBase64 = (await canvas.toBuffer("image/png")).toString("base64");
1656
+ }
1657
+ catch (error) {
1658
+ logger.warn(`获取或处理头像失败 (URL: ${url}),将使用默认头像并缓存失败状态:`, error.message || error);
1659
+ // 如果获取或处理失败,finalBase64 保持为 fallbackBase64
1660
+ }
1661
+ // 4. 将获取结果(无论成功或失败)写入缓存
1662
+ const newEntry = {
1663
+ base64: finalBase64,
1664
+ timestamp: now,
1665
+ };
1666
+ try {
1667
+ // 同时写入磁盘和内存,确保数据同步
1668
+ await fs.writeFile(cacheFilePath, JSON.stringify(newEntry));
1669
+ avatarCache.set(url, newEntry);
1670
+ }
1671
+ catch (cacheError) {
1672
+ logger.error(`无法写入头像缓存文件 (Path: ${cacheFilePath}):`, cacheError);
1673
+ }
1674
+ return newEntry.base64;
1675
+ }
1676
+ async function reloadIconCache() {
1677
+ iconCache = await loadAssetsFromFolder(iconsPath);
1678
+ logger.info(`已加载 ${iconCache.length} 个用户图标。`);
1679
+ }
1680
+ async function reloadBarBgImgCache() {
1681
+ barBgImgCache = await loadAssetsFromFolder(barBgImgsPath);
1682
+ logger.info(`已加载 ${barBgImgCache.length} 个柱状图背景图片。`);
1683
+ }
1684
+ /**
1685
+ * 重新加载字体缓存,自动修复不规范的 TTF/OTF 文件,并包含 WOFF/WOFF2 等其他格式。
1686
+ */
1687
+ async function reloadFontCache() {
1688
+ try {
1689
+ await fs.access(fontsPath);
1690
+ const files = await fs.readdir(fontsPath);
1691
+ const usableFontBasenames = new Set(); // 使用 Set 自动处理重复项
1692
+ for (const file of files) {
1693
+ // 跳过我们自己创建的 "-patched" 文件,避免将其作为原始文件处理
1694
+ if (file.toLowerCase().includes("-patched.")) {
1695
+ continue;
1696
+ }
1697
+ const lowerCaseFile = file.toLowerCase();
1698
+ if (lowerCaseFile.endsWith(".ttf") || lowerCaseFile.endsWith(".otf")) {
1699
+ // 对 TTF/OTF 文件应用修复逻辑
1700
+ const filePath = path_1.default.join(fontsPath, file);
1701
+ const usablePath = await patchAndGetUsableFontPath(filePath);
1702
+ usableFontBasenames.add(path_1.default.basename(usablePath));
1703
+ }
1704
+ else if (lowerCaseFile.endsWith(".woff2") ||
1705
+ lowerCaseFile.endsWith(".woff")) {
1706
+ // 对于 WOFF/WOFF2 文件,直接视为可用并添加
1707
+ usableFontBasenames.add(file);
1708
+ }
1709
+ // 其他非字体文件将被忽略
1710
+ }
1711
+ // 将 Set 转换为数组,更新缓存
1712
+ fontFilesCache = [...usableFontBasenames];
1713
+ logger.info(`已加载 ${fontFilesCache.length} 个可用字体文件。`);
1714
+ }
1715
+ catch (error) {
1716
+ logger.warn(`无法读取或重载字体目录 ${fontsPath}:`, error);
1717
+ fontFilesCache = [];
1718
+ }
1719
+ }
1720
+ // 自动迁移旧资源文件到新目录结构
1721
+ async function migrateFolder(oldPath, newPath) {
1722
+ try {
1723
+ await fs.access(oldPath, fs_1.constants.F_OK); // 检查旧文件夹是否存在
1724
+ logger.info(`检测到旧资源文件夹: ${oldPath},将迁移至: ${newPath}`);
1725
+ const files = await fs.readdir(oldPath);
1726
+ for (const file of files) {
1727
+ const oldFile = path_1.default.join(oldPath, file);
1728
+ const newFile = path_1.default.join(newPath, file);
1729
+ try {
1730
+ // 尝试移动,如果目标文件已存在则跳过
1731
+ await fs.rename(oldFile, newFile);
1732
+ }
1733
+ catch (renameError) {
1734
+ if (renameError.code !== "EEXIST") {
1735
+ logger.warn(`迁移文件 ${file} 失败:`, renameError);
1736
+ }
1737
+ }
1738
+ }
1739
+ await (0, koishi_1.sleep)(100); // 短暂等待以确保文件系统同步
1740
+ await fs.rmdir(oldPath);
1741
+ logger.info(`旧资源文件夹 ${oldPath} 迁移成功并已删除。`);
1742
+ }
1743
+ catch (error) {
1744
+ if (error.code !== "ENOENT") {
1745
+ // ENOENT (Not Found) 是正常情况,说明无需迁移
1746
+ logger.warn(`处理旧文件夹 ${oldPath} 时出错:`, error);
1747
+ }
1748
+ }
1749
+ }
1750
+ // 拷贝渲染所需的核心文件 (HTML 和内置字体)
1751
+ async function copyAssetIfNotExists(sourceDir, destDir, filename) {
1752
+ const destPath = path_1.default.join(destDir, filename);
1753
+ try {
1754
+ // 仅当目标文件不存在时才拷贝
1755
+ await fs.access(destPath, fs_1.constants.F_OK);
1756
+ }
1757
+ catch {
1758
+ const sourcePath = path_1.default.join(sourceDir, filename);
1759
+ try {
1760
+ await fs.access(sourcePath, fs_1.constants.F_OK);
1761
+ }
1762
+ catch {
1763
+ logger.warn(`插件资源文件未找到,无法拷贝: ${filename}`);
1764
+ return;
1765
+ }
1766
+ await fs.copyFile(sourcePath, destPath);
1767
+ logger.info(`已拷贝资源文件 ${filename} 到 ${destDir}`);
1768
+ }
1769
+ }
1770
+ /**
1771
+ * 根据字体缓存动态生成 @font-face CSS 规则。
1772
+ * @param fontFiles - 缓存的字体文件名列表。
1773
+ * @returns 包含所有 @font-face 规则的 CSS 字符串。
1774
+ */
1775
+ function generateFontFacesCSS(fontFiles) {
1776
+ let css = "";
1777
+ for (const file of fontFiles) {
1778
+ // 原始文件名(不含后缀),用于生成安全的 CSS 名称
1779
+ const rawFontName = path_1.default.parse(file).name.replace(/-patched$/i, "");
1780
+ const ext = path_1.default.parse(file).ext.toLowerCase();
1781
+ let format;
1782
+ switch (ext) {
1783
+ case ".woff2":
1784
+ format = "woff2";
1785
+ break;
1786
+ case ".woff":
1787
+ format = "woff";
1788
+ break;
1789
+ case ".ttf":
1790
+ format = "truetype";
1791
+ break;
1792
+ case ".otf":
1793
+ format = "opentype";
1794
+ break;
1795
+ default:
1796
+ continue; // 跳过不支持或非字体的文件
1797
+ }
1798
+ const fontUrl = `fonts/${file}`;
1799
+ css += `
1800
+ @font-face {
1801
+ font-family: '${rawFontName}';
1802
+ src: url("${fontUrl}") format('${format}');
1803
+ }
1804
+ `;
1805
+ }
1806
+ return css;
1807
+ }
1808
+ /** 缓存加载函数 */
1809
+ async function loadAssetsFromFolder(folderPath) {
1810
+ const assetData = [];
1811
+ try {
1812
+ await fs.access(folderPath, fs_1.constants.R_OK); // 检查目录是否存在且可读
1813
+ const files = await fs.readdir(folderPath);
1814
+ for (const file of files) {
1815
+ const userId = path_1.default.parse(file).name.split("-")[0].trim();
1816
+ const filePath = path_1.default.join(folderPath, file);
1817
+ try {
1818
+ const fileData = await fs.readFile(filePath);
1819
+ assetData.push({ userId, base64: fileData.toString("base64") });
1820
+ }
1821
+ catch (readError) {
1822
+ logger.warn(`Failed to read asset file ${filePath}:`, readError);
1823
+ }
1824
+ }
1825
+ }
1826
+ catch (err) {
1827
+ logger.warn(`Error accessing asset folder ${folderPath}:`, err);
1828
+ }
1829
+ return assetData;
1830
+ }
1831
+ /** 聚合群组数据 */
1832
+ function aggregateChannelData(records, field) {
1833
+ const channelPostCounts = {};
1834
+ const channelInfo = {};
1835
+ let totalCount = 0;
1836
+ for (const record of records) {
1837
+ const count = record[field] || 0;
1838
+ channelPostCounts[record.channelId] =
1839
+ (channelPostCounts[record.channelId] || 0) + count;
1840
+ if (!channelInfo[record.channelId]) {
1841
+ channelInfo[record.channelId] = {
1842
+ channelName: record.channelName || `群聊${record.channelId}`,
1843
+ };
1844
+ }
1845
+ totalCount += count;
1846
+ }
1847
+ return { channelPostCounts, channelInfo, totalCount };
1848
+ }
1849
+ /** 为群组排行榜准备 RankingData,并确保当前群在榜单中 */
1850
+ function prepareChannelRankingData(sortedChannels, channelInfo, totalCount, limit, currentChannelId) {
1851
+ const topChannels = sortedChannels.slice(0, limit);
1852
+ const isCurrentInTop = currentChannelId &&
1853
+ topChannels.some(([channelId]) => channelId === currentChannelId);
1854
+ // 如果当前群聊不在榜单上,则找到它的数据并直接追加到末尾
1855
+ if (currentChannelId && !isCurrentInTop) {
1856
+ const currentChannelData = sortedChannels.find(([channelId]) => channelId === currentChannelId);
1857
+ if (currentChannelData) {
1858
+ topChannels.push(currentChannelData);
1859
+ }
1860
+ }
1861
+ return topChannels.map(([channelId, count]) => ({
1862
+ // 增加★高亮当前群聊
1863
+ name: (channelId === currentChannelId ? "★" : "") +
1864
+ (channelInfo[channelId]?.channelName || `群聊${channelId}`),
1865
+ // 使用 channelId 作为 RankingData 的 userId 和头像源
1866
+ userId: channelId,
1867
+ avatar: `https://p.qlogo.cn/gh/${channelId === "#" ? "426230045" : channelId}/${channelId === "#" ? "426230045" : channelId}/100`, // QQ群头像URL格式
1868
+ count,
1869
+ percentage: calculatePercentage(count, totalCount),
1870
+ }));
1871
+ }
1872
+ // --- 辅助函数:图表生成 ---
1873
+ /**
1874
+ * 生成图表的静态 CSS 样式。
1875
+ * @returns 包含基本元素样式的 CSS 字符串。
1876
+ */
1877
+ function _getChartBaseStyles() {
1878
+ return `
1879
+ html {
1880
+ min-height: 100%;
1881
+ }
1882
+
1883
+ body {
1884
+ font-family: sans-serif;
1885
+ margin: 0;
1886
+ padding: 20px;
1887
+ width: 100%;
1888
+ min-height: 100%;
1889
+ box-sizing: border-box;
1890
+ }
1891
+
1892
+ .ranking-title {
1893
+ text-align: center;
1894
+ margin-bottom: 20px;
1895
+ color: #333;
1896
+ font-style: normal;
1897
+ }
1898
+
1899
+ /* 预加载字体用,不显示 */
1900
+ .font-preload {
1901
+ display: none;
1902
+ }
1903
+ `;
1904
+ }
1905
+ /**
1906
+ * 准备图表的背景样式。
1907
+ * 此函数根据配置生成应用于整个 HTML 页面的背景 CSS。
1908
+ * 通过将样式应用于 `<html>` 标签,确保背景能完全覆盖 `fullPage` 截图的区域。
1909
+ * @param config 插件配置对象。
1910
+ * @returns 一个包含背景样式的 CSS 字符串。
1911
+ */
1912
+ async function _prepareBackgroundStyle(config) {
1913
+ if (config.backgroundType === "api" && config.apiBackgroundConfig?.apiUrl) {
1914
+ try {
1915
+ const { apiUrl, apiKey, responseType } = config.apiBackgroundConfig;
1916
+ const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
1917
+ let backgroundImage;
1918
+ switch (responseType) {
1919
+ case "url": {
1920
+ // API 返回一个包含图片 URL 的 JSON 或纯文本
1921
+ const response = await ctx.http.get(apiUrl, { headers });
1922
+ const imageUrl = typeof response === "string" ? response : response?.url;
1923
+ if (!imageUrl || typeof imageUrl !== "string") {
1924
+ throw new Error('API response for "url" type is not a valid string.');
1925
+ }
1926
+ backgroundImage = `url('${imageUrl}')`;
1927
+ break;
1928
+ }
1929
+ case "base64": {
1930
+ // API 返回一个包含 Base64 数据的 JSON 或纯文本
1931
+ const response = await ctx.http.get(apiUrl, { headers });
1932
+ const base64Data = typeof response === "string" ? response : response?.data;
1933
+ if (!base64Data || typeof base64Data !== "string") {
1934
+ throw new Error('API response for "base64" type is not a valid string.');
1935
+ }
1936
+ // 自动检测并添加 data URI scheme
1937
+ const prefix = base64Data.startsWith("data:image")
1938
+ ? ""
1939
+ : "data:image/png;base64,";
1940
+ backgroundImage = `url('${prefix}${base64Data}')`;
1941
+ break;
1942
+ }
1943
+ case "binary":
1944
+ default: {
1945
+ // API 返回原始图片数据(二进制)
1946
+ const responseBuffer = await ctx.http.get(apiUrl, {
1947
+ headers,
1948
+ responseType: "arraybuffer",
1949
+ });
1950
+ const base64 = Buffer.from(responseBuffer).toString("base64");
1951
+ backgroundImage = `url('data:image/png;base64,${base64}')`;
1952
+ break;
1953
+ }
1954
+ }
1955
+ return `html {
1956
+ background-image: ${backgroundImage};
1957
+ background-size: cover;
1958
+ background-position: center;
1959
+ background-repeat: no-repeat;
1960
+ }`;
1961
+ }
1962
+ catch (error) {
1963
+ logger.error("获取 API 背景图失败,将使用默认背景:", error);
1964
+ }
1965
+ }
1966
+ if (config.backgroundType === "css" && config.backgroundValue) {
1967
+ return config.backgroundValue;
1968
+ }
1969
+ return `html {
1970
+ background: linear-gradient(135deg, #f6f8f9 0%, #e5ebee 100%);
1971
+ }`;
1972
+ }
1973
+ /**
1974
+ * 获取在浏览器端执行的绘图脚本。
1975
+ * @returns 一个 IIFE (立即调用函数表达式) 字符串,用于在浏览器中绘制 Canvas。
1976
+ */
1977
+ function _getClientScript() {
1978
+ // 此函数返回一个字符串,该字符串是将在 Puppeteer 浏览器上下文中执行的完整脚本。
1979
+ // 使用 IIFE (async (...) => { ... }) 格式,以便在 HTML 中清晰地传递参数。
1980
+ return `
1981
+ async ({ rankingData, iconData, barBgImgs, config }) => {
1982
+ // --- 主绘制函数 ---
1983
+ async function drawRanking() {
1984
+ const maxCount = rankingData.reduce((max, item) => Math.max(max, item.count), 0) || 1;
1985
+ const userNum = rankingData.length;
1986
+ const userAvatarSize = 50;
1987
+ const tableWidth = 200 + 7 * 100; // 固定宽度
1988
+ const canvasHeight = 50 * userNum;
1989
+
1990
+ const canvas = document.getElementById('rankingCanvas');
1991
+ let context = canvas.getContext('2d');
1992
+
1993
+ // 根据最大计数的文本宽度动态调整画布宽度,以防数字溢出
1994
+ context.font = \`30px "\${config.chartNicknameFont}", HarmonyOS_Sans_Medium, "Microsoft YaHei", sans-serif\`;
1995
+ // 找到拥有最大发言数的条目,因为它的文本通常最长
1996
+ const maxCountData = rankingData.find(d => d.count === maxCount) || rankingData[0] || { count: 1, percentage: 0 };
1997
+ let maxCountText = maxCount.toString();
1998
+ if (config.isUserMessagePercentageVisible && maxCountData) {
1999
+ const percentage = maxCountData.percentage;
2000
+ let percentageStr = percentage < 0.01 && percentage > 0 ? '<0.01' : percentage.toFixed(percentage < 1 ? 2 : 0);
2001
+ maxCountText += \` ( \${percentageStr}%)\`;
2002
+ }
2003
+ const maxCountTextWidth = context.measureText(maxCountText).width;
2004
+
2005
+ // 最长进度条的宽度是固定的
2006
+ const maxBarWidth = 150 + 700; // 进度条区域总宽度
2007
+
2008
+ // 计算最终画布宽度:头像(50) + 进度条(850) + 文本与进度条间距(10) + 文本宽度 + 右侧留白(20)
2009
+ // 头像左侧的空白由页面 body 的 padding 提供
2010
+ canvas.width = 50 + maxBarWidth + 10 + maxCountTextWidth + 20;
2011
+ canvas.height = canvasHeight;
2012
+
2013
+ // 重新获取上下文,因为尺寸变化会重置状态
2014
+ context = canvas.getContext('2d');
2015
+
2016
+ // 按顺序绘制图层
2017
+ await drawRankingBars(context, maxCount, userAvatarSize, tableWidth); // 传递动态的 canvas.width
2018
+ await drawAvatars(context, userAvatarSize);
2019
+ drawVerticalLines(context, canvas.height, tableWidth); // 竖线仍然可以按旧的固定宽度绘制,不影响主体
2020
+ }
2021
+
2022
+ // --- 核心绘图逻辑 ---
2023
+
2024
+ async function drawRankingBars(context, maxCount, userAvatarSize, canvasWidth) { // 接收 canvasWidth
2025
+ for (const [index, data] of rankingData.entries()) {
2026
+ const countBarWidth = 150 + (700 * data.count) / maxCount;
2027
+ const countBarX = 50; // 头像宽度
2028
+ const countBarY = 50 * index;
2029
+
2030
+ let avgColor = await getAverageColor(data.avatarBase64);
2031
+ const colorWithOpacity = addOpacityToColor(avgColor, 0.5);
2032
+
2033
+ // 绘制底色进度条
2034
+ context.fillStyle = avgColor;
2035
+ context.fillRect(countBarX, countBarY, countBarWidth, userAvatarSize);
2036
+
2037
+ // 绘制自定义背景图
2038
+ const userBarBgImgs = findAssets(data.userId, barBgImgs, 'barBgImgBase64');
2039
+ if (userBarBgImgs.length > 0) {
2040
+ const randomBarBgImgBase64 = userBarBgImgs[Math.floor(Math.random() * userBarBgImgs.length)];
2041
+ avgColor = await drawCustomBarBackground(context, randomBarBgImgBase64, countBarX, countBarY, countBarWidth, userAvatarSize, canvasWidth); // 传递 canvasWidth
2042
+ }
2043
+
2044
+ // 绘制剩余部分灰色背景
2045
+ const remainingBarX = countBarX + countBarWidth;
2046
+ // 确保灰色背景能填满到画布最右侧,减去文本区域
2047
+ context.fillStyle = colorWithOpacity;
2048
+ context.fillRect(remainingBarX, countBarY, canvasWidth - remainingBarX, userAvatarSize);
2049
+
2050
+ // 绘制文本和图标
2051
+ await drawTextAndIcons(context, data, index, avgColor, countBarX, countBarY, countBarWidth, userAvatarSize);
2052
+ }
2053
+ }
2054
+
2055
+ async function drawCustomBarBackground(context, base64, x, y, barWidth, barHeight, canvasWidth) { // 接收 canvasWidth
2056
+ return new Promise(async (resolve) => {
2057
+ const barBgImg = new Image();
2058
+ barBgImg.src = "data:image/png;base64," + base64;
2059
+ barBgImg.onload = async () => {
2060
+ context.save();
2061
+ // 绘制整行背景(如果透明度 > 0)
2062
+ if (config.horizontalBarBackgroundFullOpacity > 0) {
2063
+ context.globalAlpha = config.horizontalBarBackgroundFullOpacity;
2064
+ context.drawImage(barBgImg, x, y, canvasWidth - x, barHeight); // 填充到画布右侧
2065
+ }
2066
+ // 绘制进度条区域背景
2067
+ context.globalAlpha = config.horizontalBarBackgroundOpacity;
2068
+ context.drawImage(barBgImg, 0, 0, barWidth, barHeight, x, y, barWidth, barHeight);
2069
+ context.restore();
2070
+ const newAvgColor = await getAverageColor(base64);
2071
+ resolve(newAvgColor);
2072
+ };
2073
+ barBgImg.onerror = async () => {
2074
+ const originalColor = await getAverageColor(base64);
2075
+ resolve(originalColor); // 发生错误则返回原始颜色
2076
+ }
2077
+ });
2078
+ }
2079
+
2080
+ async function drawTextAndIcons(context, data, index, avgColor, barX, barY, barWidth, barHeight) {
2081
+ // 字体栈包含了用户选择的字体、插件内置字体和通用字体,以确保兼容性。
2082
+ context.font = \`30px "\${config.chartNicknameFont}", HarmonyOS_Sans_Medium, "Microsoft YaHei", sans-serif\`;
2083
+ const textY = barY + barHeight / 2 + 10.5;
2084
+
2085
+
2086
+ // 绘制发言次数和百分比
2087
+ let countText = data.count.toString();
2088
+ if (config.isUserMessagePercentageVisible) {
2089
+ const percentage = data.percentage;
2090
+ let percentageStr = percentage < 0.01 && percentage > 0 ? '<0.01' : percentage.toFixed(percentage < 1 ? 2 : 0);
2091
+ countText += \` ( \${percentageStr}%)\`;
2092
+ }
2093
+
2094
+ const countTextWidth = context.measureText(countText).width;
2095
+ const countTextX = barX + barWidth + 10;
2096
+
2097
+ if (countTextX + countTextWidth > context.canvas.width - 5) {
2098
+ context.fillStyle = chooseColorAdjustmentMethod(avgColor);
2099
+ context.textAlign = "right";
2100
+ context.fillText(countText, barX + barWidth - 10, textY);
2101
+ } else {
2102
+ context.fillStyle = "rgba(0, 0, 0, 1)";
2103
+ context.textAlign = "left";
2104
+ context.fillText(countText, countTextX, textY);
2105
+ }
2106
+
2107
+ // 绘制用户名(带截断)
2108
+ context.fillStyle = chooseColorAdjustmentMethod(avgColor);
2109
+ context.textAlign = "left"; // 重置对齐方式,以防被上一部分修改
2110
+
2111
+ let nameText = data.name;
2112
+ const maxNameWidth = barWidth - 60;
2113
+ if (context.measureText(nameText).width > maxNameWidth) {
2114
+ const ellipsis = "...";
2115
+ while (context.measureText(nameText + ellipsis).width > maxNameWidth && nameText.length > 0) {
2116
+ nameText = nameText.slice(0, -1);
2117
+ }
2118
+ nameText += ellipsis;
2119
+ }
2120
+ const nameTextX = barX + 10;
2121
+ context.fillText(nameText, nameTextX, textY);
2122
+
2123
+ // 绘制用户自定义图标
2124
+ const userIcons = findAssets(data.userId, iconData, 'iconBase64');
2125
+ if (userIcons.length > 0) {
2126
+ await drawUserIcons(context, userIcons, {
2127
+ nameText: data.name, // 传递原始nameText用于计算位置
2128
+ nameTextX: context.measureText(nameText).width + nameTextX,
2129
+ barX: barX,
2130
+ barWidth: barWidth,
2131
+ textY: textY
2132
+ });
2133
+ }
2134
+ }
2135
+
2136
+ async function drawUserIcons(context, icons, positions) {
2137
+ const { nameTextX, barX, barWidth, textY } = positions;
2138
+
2139
+ // 使用 Promise.all 等待所有图片加载和绘制
2140
+ await Promise.all(icons.map((iconBase64, i) => {
2141
+ return new Promise((resolve, reject) => {
2142
+ const icon = new Image();
2143
+ icon.src = "data:image/png;base64," + iconBase64;
2144
+ icon.onload = () => {
2145
+ const iconSize = 40;
2146
+ const iconY = textY - 30;
2147
+ let iconX = config.shouldMoveIconToBarEndLeft
2148
+ ? barX + barWidth - (iconSize * (i + 1))
2149
+ : nameTextX + (iconSize * i) + 5;
2150
+ context.drawImage(icon, iconX, iconY, iconSize, iconSize);
2151
+ resolve(); // 图片绘制成功
2152
+ };
2153
+ icon.onerror = () => {
2154
+ resolve(); // 即使单个图标加载失败,也继续执行,不中断整个排行榜生成
2155
+ };
2156
+ });
2157
+ }));
2158
+ }
2159
+
2160
+ async function drawAvatars(context, userAvatarSize) {
2161
+ for (const [index, data] of rankingData.entries()) {
2162
+ const image = new Image();
2163
+ image.src = "data:image/png;base64," + data.avatarBase64;
2164
+ // onload不是必需的,因为图片已是base64,但为了健壮性可以保留
2165
+ await new Promise(resolve => {
2166
+ image.onload = () => {
2167
+ context.drawImage(image, 0, 50 * index, userAvatarSize, userAvatarSize);
2168
+ resolve();
2169
+ };
2170
+ image.onerror = resolve; // 即使加载失败也继续
2171
+ });
2172
+ }
2173
+ }
2174
+
2175
+ function drawVerticalLines(context, canvasHeight, tableWidth) {
2176
+ context.fillStyle = "rgba(0, 0, 0, 0.12)";
2177
+ const verticalLineWidth = 3;
2178
+ const firstLineX = 200;
2179
+ for (let i = 0; i < 8; i++) {
2180
+ context.fillRect(firstLineX + 100 * i, 0, verticalLineWidth, canvasHeight);
2181
+ }
2182
+ }
2183
+
2184
+
2185
+ // --- 辅助工具函数 ---
2186
+
2187
+ function findAssets(userId, assetList, key) {
2188
+ return assetList
2189
+ .filter(data => data.userId === userId)
2190
+ .map(data => data[key]);
2191
+ }
2192
+
2193
+ function addOpacityToColor(color, opacity) {
2194
+ const opacityHex = Math.round(opacity * 255).toString(16).padStart(2, "0");
2195
+ return \`\${color}\${opacityHex}\`;
2196
+ }
2197
+
2198
+ function chooseColorAdjustmentMethod(hexcolor) {
2199
+ const rgb = hexToRgb(hexcolor)
2200
+ const yiqBrightness = calculateYiqBrightness(rgb)
2201
+ if (yiqBrightness > 0.2 && yiqBrightness < 0.8) {
2202
+ return adjustColorHsl(hexcolor)
2203
+ } else {
2204
+ return adjustColorYiq(hexcolor)
2205
+ }
2206
+ }
2207
+
2208
+ function calculateYiqBrightness(rgb) {
2209
+ return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000 / 255
2210
+ }
2211
+
2212
+ function adjustColorYiq(hexcolor) {
2213
+ const rgb = hexToRgb(hexcolor)
2214
+ const yiqBrightness = calculateYiqBrightness(rgb)
2215
+ return yiqBrightness >= 0.8 ? "#000000" : "#FFFFFF"
2216
+ }
2217
+
2218
+ function adjustColorHsl(hexcolor) {
2219
+ const rgb = hexToRgb(hexcolor)
2220
+ let hsl = rgbToHsl(rgb.r, rgb.g, rgb.b)
2221
+ hsl.l = hsl.l < 0.5 ? hsl.l + 0.3 : hsl.l - 0.3
2222
+ hsl.s = hsl.s < 0.5 ? hsl.s + 0.3 : hsl.s - 0.3
2223
+ const contrastRgb = hslToRgb(hsl.h, hsl.s, hsl.l)
2224
+ return rgbToHex(contrastRgb.r, contrastRgb.g, contrastRgb.b)
2225
+ }
2226
+
2227
+ function hexToRgb(hex) {
2228
+ const sanitizedHex = String(hex).replace("#", "")
2229
+ const bigint = parseInt(sanitizedHex, 16)
2230
+ const r = (bigint >> 16) & 255
2231
+ const g = (bigint >> 8) & 255
2232
+ const b = bigint & 255
2233
+ return {r, g, b}
2234
+ }
2235
+
2236
+ function rgbToHsl(r, g, b) {
2237
+ r /= 255, g /= 255, b /= 255
2238
+ const max = Math.max(r, g, b), min = Math.min(r, g, b)
2239
+ let h, s, l = (max + min) / 2
2240
+ if (max === min) {
2241
+ h = s = 0
2242
+ } else {
2243
+ const d = max - min
2244
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
2245
+ switch (max) {
2246
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break
2247
+ case g: h = (b - r) / d + 2; break
2248
+ case b: h = (r - g) / d + 4; break
2249
+ }
2250
+ h /= 6
2251
+ }
2252
+ return {h, s, l}
2253
+ }
2254
+
2255
+ function hslToRgb(h, s, l) {
2256
+ let r, g, b
2257
+ if (s === 0) {
2258
+ r = g = b = l
2259
+ } else {
2260
+ const hue2rgb = (p, q, t) => {
2261
+ if (t < 0) t += 1
2262
+ if (t > 1) t -= 1
2263
+ if (t < 1 / 6) return p + (q - p) * 6 * t
2264
+ if (t < 1 / 2) return q
2265
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
2266
+ return p
2267
+ }
2268
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s
2269
+ const p = 2 * l - q
2270
+ r = hue2rgb(p, q, h + 1 / 3)
2271
+ g = hue2rgb(p, q, h)
2272
+ b = hue2rgb(p, q, h - 1 / 3)
2273
+ }
2274
+ return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
2275
+ }
2276
+
2277
+ function rgbToHex(r, g, b) {
2278
+ const toHex = c => {
2279
+ const hex = c.toString(16)
2280
+ return hex.length === 1 ? "0" + hex : hex
2281
+ }
2282
+ return \`#\${toHex(r)}\${toHex(g)}\${toHex(b)}\`
2283
+ }
2284
+
2285
+ async function getAverageColor(base64) {
2286
+ const image = new Image();
2287
+ image.src = "data:image/png;base64," + base64;
2288
+ await new Promise(r => image.onload = r);
2289
+
2290
+ const canvas = document.createElement('canvas');
2291
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
2292
+ canvas.width = image.width; canvas.height = image.height;
2293
+ ctx.drawImage(image, 0, 0);
2294
+
2295
+ const data = ctx.getImageData(0, 0, image.width, image.height).data;
2296
+ let r = 0, g = 0, b = 0;
2297
+
2298
+ for (let i = 0; i < data.length; i += 4) {
2299
+ r += data[i]; g += data[i+1]; b += data[i+2];
2300
+ }
2301
+ const count = data.length / 4;
2302
+ r = ~~(r / count); g = ~~(g / count); b = ~~(b / count);
2303
+
2304
+ return \`#\${r.toString(16).padStart(2, "0")}\${g.toString(16).padStart(2, "0")}\${b.toString(16).padStart(2, "0")}\`;
2305
+ }
2306
+
2307
+ // --- 启动绘制 ---
2308
+ await drawRanking();
2309
+ }
2310
+ `;
2311
+ }
2312
+ /**
2313
+ * 组装最终的 HTML 页面内容。
2314
+ * @param params 包含所有渲染所需数据的对象。
2315
+ * @returns 完整的 HTML 字符串。
2316
+ */
2317
+ function _getChartHtmlContent(params) {
2318
+ const { rankTimeTitle, rankTitle, data, iconCache, barBgImgCache, backgroundStyle, fontFacesCSS, chartConfig, } = params;
2319
+ // 准备注入到客户端脚本的数据
2320
+ const clientData = {
2321
+ rankingData: data,
2322
+ iconData: iconCache.map((d) => ({
2323
+ userId: d.userId,
2324
+ iconBase64: d.base64,
2325
+ })),
2326
+ barBgImgs: barBgImgCache.map((d) => ({
2327
+ userId: d.userId,
2328
+ barBgImgBase64: d.base64,
2329
+ })),
2330
+ config: chartConfig,
2331
+ };
2332
+ return `
2333
+ <!DOCTYPE html>
2334
+ <html lang="zh-CN">
2335
+ <head>
2336
+ <meta charset="UTF--8">
2337
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2338
+ <title>排行榜</title>
2339
+ <style>${_getChartBaseStyles()}</style>
2340
+ <style>${backgroundStyle}</style>
2341
+ <style>${fontFacesCSS}</style>
2342
+ <style>
2343
+ .ranking-title { font-family: "${chartConfig.chartTitleFont}", "Microsoft YaHei", sans-serif; }
2344
+ </style>
2345
+ </head>
2346
+ <body>
2347
+ <h1 class="ranking-title">${rankTimeTitle}</h1>
2348
+ <h1 class="ranking-title">${rankTitle}</h1>
2349
+ <div class="font-preload">
2350
+ <span style="font-family: '${chartConfig.chartNicknameFont}';">预加载</span>
2351
+ <span style="font-family: '${chartConfig.chartTitleFont}';">预加载</span>
2352
+ </div>
2353
+ <canvas id="rankingCanvas"></canvas>
2354
+ <script>
2355
+ // 立即执行的异步函数,用于绘制图表
2356
+ (async () => {
2357
+ const drawFunction = ${_getClientScript()};
2358
+ await drawFunction(${JSON.stringify(clientData)});
2359
+ })();
2360
+ </script>
2361
+ </body>
2362
+ </html>
2363
+ `;
2364
+ }
2365
+ /**
2366
+ * 生成排行榜图片。
2367
+ * 该函数通过组合多个辅助函数来创建 HTML 页面,并使用 Puppeteer 进行截图。
2368
+ * @param params 包含标题和数据的对象。
2369
+ * @returns 包含图表图片的 Buffer。
2370
+ */
2371
+ async function generateRankingChart({ rankTimeTitle, rankTitle, data, }, { iconCache, barBgImgCache, fontFilesCache, emptyHtmlPath, }) {
2372
+ if (!ctx.puppeteer) {
2373
+ throw new Error("Puppeteer 服务未启用,无法生成图表。");
2374
+ }
2375
+ const browser = ctx.puppeteer.browser;
2376
+ if (!browser) {
2377
+ throw new Error("Puppeteer 浏览器实例不可用。");
2378
+ }
2379
+ const page = await browser.newPage();
2380
+ try {
2381
+ const fontFaces = generateFontFacesCSS(fontFilesCache);
2382
+ const backgroundStyle = await _prepareBackgroundStyle(config);
2383
+ const chartConfigForClient = {
2384
+ shouldMoveIconToBarEndLeft: config.shouldMoveIconToBarEndLeft,
2385
+ horizontalBarBackgroundOpacity: config.horizontalBarBackgroundOpacity,
2386
+ horizontalBarBackgroundFullOpacity: config.horizontalBarBackgroundFullOpacity,
2387
+ isUserMessagePercentageVisible: config.isUserMessagePercentageVisible,
2388
+ chartTitleFont: config.chartTitleFont,
2389
+ chartNicknameFont: config.chartNicknameFont,
2390
+ };
2391
+ const htmlContent = _getChartHtmlContent({
2392
+ rankTimeTitle,
2393
+ rankTitle,
2394
+ data,
2395
+ iconCache,
2396
+ barBgImgCache,
2397
+ backgroundStyle,
2398
+ fontFacesCSS: fontFaces,
2399
+ chartConfig: chartConfigForClient,
2400
+ });
2401
+ // 加载本地空 HTML 文件作为起点
2402
+ const pageUrl = emptyHtmlPath.includes(":")
2403
+ ? `file:///${emptyHtmlPath}`
2404
+ : `file://${emptyHtmlPath}`;
2405
+ await page.goto(pageUrl, { waitUntil: "load" });
2406
+ // 调试:将 html 内容保存到文件中
2407
+ // const debugHtmlPath = path.join(
2408
+ // ctx.baseDir,
2409
+ // "data",
2410
+ // "mc",
2411
+ // "debug-ranking-chart.html"
2412
+ // );
2413
+ // await fs.writeFile(debugHtmlPath, htmlContent, "utf-8");
2414
+ // logger.info(`排行榜 HTML 已保存到 ${debugHtmlPath}`);
2415
+ await page.setContent(koishi_1.h.unescape(htmlContent), {
2416
+ waitUntil: config.waitUntil,
2417
+ });
2418
+ const calculatedWidth = await page.evaluate(() => {
2419
+ const canvas = document.getElementById("rankingCanvas");
2420
+ const bodyPadding = 40; // 对应 body 的左右 padding (20px + 20px)
2421
+ // 如果 canvas 存在,则返回其宽度加上页面的 padding;否则返回一个默认值。
2422
+ return canvas ? canvas.width + bodyPadding : 1080;
2423
+ });
2424
+ await page.setViewport({
2425
+ // 使用客户端计算出的宽度,但确保不小于用户在配置中设定的值
2426
+ width: Math.max(config.chartViewportWidth, Math.ceil(calculatedWidth)),
2427
+ // 高度在这里是次要的,因为 fullPage: true 会自动调整,但设置一个合理的值可以避免潜在问题
2428
+ height: 256,
2429
+ deviceScaleFactor: config.deviceScaleFactor,
2430
+ });
2431
+ const imageBuffer = await page.screenshot({
2432
+ type: config.imageType,
2433
+ fullPage: true,
2434
+ });
2435
+ return imageBuffer;
2436
+ }
2437
+ catch (error) {
2438
+ logger.error("生成排行榜图表时发生错误:", error);
2439
+ throw error; // 将错误向上抛出,以便调用者可以处理
2440
+ }
2441
+ finally {
2442
+ await page.close(); // 确保页面总是被关闭
2443
+ }
2444
+ }
2445
+ function getUserRankAndRecord(getDragons, userId, postCountType) {
2446
+ if (getDragons.length === 0) {
2447
+ return;
2448
+ }
2449
+ const aggregatedUserRecords = getDragons.reduce((acc, user) => {
2450
+ if (!acc[user.userId]) {
2451
+ acc[user.userId] = {
2452
+ userId: user.userId,
2453
+ postCountAll: 0,
2454
+ username: user.username,
2455
+ };
2456
+ }
2457
+ let postCount = 0;
2458
+ switch (postCountType) {
2459
+ case "todayPostCount":
2460
+ postCount = user.todayPostCount;
2461
+ break;
2462
+ case "thisWeekPostCount":
2463
+ postCount = user.thisWeekPostCount;
2464
+ break;
2465
+ case "thisMonthPostCount":
2466
+ postCount = user.thisMonthPostCount;
2467
+ break;
2468
+ case "thisYearPostCount":
2469
+ postCount = user.thisYearPostCount;
2470
+ break;
2471
+ case "totalPostCount":
2472
+ postCount = user.totalPostCount;
2473
+ break;
2474
+ case "yesterdayPostCount":
2475
+ postCount = user.yesterdayPostCount;
2476
+ break;
2477
+ default:
2478
+ postCount = user.todayPostCount;
2479
+ break;
2480
+ }
2481
+ acc[user.userId].postCountAll += postCount;
2482
+ return acc;
2483
+ }, {});
2484
+ const sortedUserRecords = Object.values(aggregatedUserRecords).sort((a, b) => b.postCountAll - a.postCountAll);
2485
+ const userIndex = sortedUserRecords.findIndex((user) => user.userId === userId);
2486
+ const userRecord = sortedUserRecords[userIndex];
2487
+ const acrossRank = userIndex + 1;
2488
+ return { acrossRank, userRecord };
2489
+ }
2490
+ function getSortedDragons(records) {
2491
+ const dragonsMap = {};
2492
+ for (const dragon of records) {
2493
+ const { userId, totalPostCount } = dragon;
2494
+ const key = `${userId}`;
2495
+ dragonsMap[key] = (dragonsMap[key] || 0) + totalPostCount;
2496
+ }
2497
+ return Object.entries(dragonsMap).sort((a, b) => b[1] - a[1]);
2498
+ }
2499
+ async function replaceAtTags(session, content) {
2500
+ const atRegex = /<at id="(\d+)"(?: name="([^"]*)")?\/>/g;
2501
+ let match;
2502
+ while ((match = atRegex.exec(content)) !== null) {
2503
+ const userId = match[1];
2504
+ const name = match[2];
2505
+ if (!name) {
2506
+ let userName = "未知用户";
2507
+ try {
2508
+ if (typeof session.bot?.getChannelMember === "function" &&
2509
+ session.channelId) {
2510
+ const channelMember = await session.bot.getChannelMember(session.channelId, userId);
2511
+ if (channelMember &&
2512
+ channelMember.user &&
2513
+ channelMember.user.name) {
2514
+ userName = channelMember.user.name;
2515
+ }
2516
+ }
2517
+ }
2518
+ catch (error) {
2519
+ logger.error(error);
2520
+ }
2521
+ const newAtTag = `<at id="${userId}" name="${userName}"/>`;
2522
+ content = content.replace(match[0], newAtTag);
2523
+ }
2524
+ }
2525
+ return content;
2526
+ }
2527
+ async function getChannelName(bot, channelId) {
2528
+ try {
2529
+ const channel = await bot.getChannel(channelId);
2530
+ return channel?.name;
2531
+ }
2532
+ catch (error) {
2533
+ logger.warn(`Failed to get channelId name for ${channelId}:`, error);
2534
+ return undefined;
2535
+ }
2536
+ }
2537
+ function parseList(str) {
2538
+ if (!str)
2539
+ return [];
2540
+ return str.split(/[\s,,、]+/).filter(Boolean);
2541
+ }
2542
+ function getPeriodFromOptions(options, fallback) {
2543
+ if (options?.yesterday || options?.ydag)
2544
+ return "yesterday";
2545
+ if (options?.day || options?.dag)
2546
+ return "today";
2547
+ if (options?.week || options?.wag)
2548
+ return "week";
2549
+ if (options?.month || options?.mag)
2550
+ return "month";
2551
+ if (options?.year || options?.yag)
2552
+ return "year";
2553
+ if (options?.total || options?.across || options?.dragon)
2554
+ return "total";
2555
+ return fallback;
2556
+ }
2557
+ function isAcrossChannel(options) {
2558
+ return ["ydag", "dag", "wag", "mag", "yag", "across", "dragon"].some((opt) => options?.[opt]);
2559
+ }
2560
+ function filterRecordsByWhitesAndBlacks(records, key, whites, blacks) {
2561
+ let result = records;
2562
+ if (whites.length > 0) {
2563
+ result = result.filter((r) => whites.includes(r[key]));
2564
+ }
2565
+ if (blacks.length > 0) {
2566
+ result = result.filter((r) => !blacks.includes(r[key]));
2567
+ }
2568
+ return result;
2569
+ }
2570
+ function prepareRankingData(sortedUsers, userInfo, totalCount, limit, requesterId) {
2571
+ const topUsers = sortedUsers.slice(0, limit);
2572
+ const isRequesterInTop = requesterId && topUsers.some(([userId]) => userId === requesterId);
2573
+ // 如果指令发送者不在榜单上,则找到他的数据并直接追加到末尾
2574
+ if (requesterId && !isRequesterInTop) {
2575
+ const requesterData = sortedUsers.find(([userId]) => userId === requesterId);
2576
+ if (requesterData) {
2577
+ topUsers.push(requesterData);
2578
+ }
2579
+ }
2580
+ return topUsers.map(([userId, count]) => ({
2581
+ // 增加★高亮指令发送者
2582
+ name: (userId === requesterId ? "★" : "") + userInfo[userId].username,
2583
+ userId: userId,
2584
+ avatar: userInfo[userId].avatar,
2585
+ count,
2586
+ percentage: calculatePercentage(count, totalCount),
2587
+ }));
2588
+ }
2589
+ function calculatePercentage(number, total) {
2590
+ if (total === 0)
2591
+ return 0;
2592
+ return (number / total) * 100;
2593
+ }
2594
+ function getCurrentBeijingTime() {
2595
+ return new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
2596
+ }
2597
+ async function renderLeaderboard({ rankTimeTitle, rankTitle, rankingData, }) {
2598
+ // 渲染为水平柱状图
2599
+ if (config.isLeaderboardToHorizontalBarChartConversionEnabled) {
2600
+ if (!ctx.puppeteer) {
2601
+ logger.warn("Puppeteer service is not enabled. Falling back to text.");
2602
+ }
2603
+ else {
2604
+ try {
2605
+ const chartReadyData = rankingData.map((item) => {
2606
+ const newItem = { ...item };
2607
+ if (!config.showStarInChart && newItem.name.startsWith("★")) {
2608
+ newItem.name = newItem.name.substring(1);
2609
+ }
2610
+ return newItem;
2611
+ });
2612
+ // 调用新的、带持久化缓存的函数来获取头像
2613
+ // 旧的逻辑是直接在这里操作内存缓存,现在封装到 getAvatarAsBase64 中
2614
+ await Promise.all(chartReadyData.map(async (item) => {
2615
+ item.avatarBase64 = await getAvatarAsBase64(item.avatar);
2616
+ }));
2617
+ const imageBuffer = await generateRankingChart({ rankTimeTitle, rankTitle, data: chartReadyData }, { iconCache, barBgImgCache, fontFilesCache, emptyHtmlPath });
2618
+ return koishi_1.h.image(imageBuffer, `image/${config.imageType}`);
2619
+ }
2620
+ catch (error) {
2621
+ logger.error("Failed to generate leaderboard chart:", error);
2622
+ }
2623
+ }
2624
+ }
2625
+ // 渲染为 Markdown 图片
2626
+ if (config.isTextToImageConversionEnabled) {
2627
+ if (!ctx.markdownToImage) {
2628
+ logger.warn("markdownToImage service is not enabled. Falling back to text.");
2629
+ }
2630
+ else {
2631
+ const markdown = formatLeaderboardAsMarkdown(rankTimeTitle, rankTitle, rankingData, config.isUserMessagePercentageVisible);
2632
+ const imageBuffer = await ctx.markdownToImage.convertToImage(markdown);
2633
+ return koishi_1.h.image(imageBuffer, `image/${config.imageType}`);
2634
+ }
2635
+ }
2636
+ // 默认渲染为纯文本
2637
+ return formatLeaderboardAsText(rankTimeTitle, rankTitle, rankingData, config.isUserMessagePercentageVisible);
2638
+ }
2639
+ function formatLeaderboardAsMarkdown(title, subtitle, data, showPercentage) {
2640
+ let result = `# ${title}\n## ${subtitle}\n\n`;
2641
+ data.forEach((item, index) => {
2642
+ const percentageStr = showPercentage
2643
+ ? ` (${Math.round(item.percentage)}%)`
2644
+ : "";
2645
+ result += `${index + 1}. **${item.name}**: ${item.count} 次${percentageStr}\n`;
2646
+ });
2647
+ return result;
2648
+ }
2649
+ function formatLeaderboardAsText(title, subtitle, data, showPercentage) {
2650
+ let result = `${title}\n${subtitle}\n\n`;
2651
+ data.forEach((item, index) => {
2652
+ const percentageStr = showPercentage
2653
+ ? ` (${Math.round(item.percentage)}%)`
2654
+ : "";
2655
+ result += `${index + 1}. ${item.name}:${item.count} 次${percentageStr}\n`;
2656
+ });
2657
+ return result.trim();
2658
+ }
2659
+ }