UNPKG

koishi-plugin-message-counter

Version:

Koishi 的消息数量统计插件。生成各种发言排行榜。

1,322 lines (1,289 loc) 110 kB
var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __export = (target, all) => { for (var name2 in all) __defProp(target, name2, { get: all[name2], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { Config: () => Config, apply: () => apply, inject: () => inject, name: () => name, usage: () => usage }); module.exports = __toCommonJS(src_exports); var import_koishi = require("koishi"); var import_path = __toESM(require("path")); var fs = __toESM(require("fs/promises")); var import_fs = require("fs"); var crypto = __toESM(require("crypto")); var name = "message-counter"; var inject = { required: ["database", "cron"], optional: ["markdownToImage", "puppeteer", "canvas"] }; var usage = `## 📝 注意事项 - 仅记录群聊消息 - 初始化需要权限等级 3 级 ## 🔍 关键指令 ### \`messageCounter.查询 [指定用户]\` 查询指定用户的发言次数信息(次数[排名])。若不带任何选项,则显示所有时段的数据。 **选项:** | 参数 | 说明 | |------|------| | \`-d, --day\` | 今日发言次数[排名] | | \`--yd, --yesterday\` | 昨日发言次数[排名] | | \`-w, --week\` | 本周发言次数[排名] | | \`-m, --month\` | 本月发言次数[排名] | | \`-y, --year\` | 今年发言次数[排名] | | \`-t, --total\` | 总发言次数[排名] | | \`--dag\` | 跨群今日发言次数[排名] | | \`--ydag\` | 跨群昨日发言次数[排名] | | \`--wag\` | 跨群本周发言次数[排名] | | \`--mag\` | 跨群本月发言次数[排名] | | \`--yag\` | 跨群本年发言次数[排名] | | \`-a, --across\` | 跨群总发言次数[排名] | ### \`messageCounter.排行榜 [显示的人数]\` 发言排行榜。默认为今日发言榜。 **选项:** | 参数 | 说明 | |------|------| | \`--yd, --yesterday\` | 昨日发言排行榜 | | \`-w\` | 本周发言排行榜 | | \`-m\` | 本月发言排行榜 | | \`-y\` | 今年发言排行榜 | | \`-t\` | 总发言排行榜 | | \`--dag\` | 跨群今日发言排行榜 | | \`--ydag\` | 跨群昨日发言排行榜 | | \`--wag\` | 跨群本周发言排行榜 | | \`--mag\` | 跨群本月发言排行榜 | | \`--yag\` | 跨群今年发言排行榜 | | \`--dragon\` | 跨群总发言排行榜(圣龙王榜) | | \`--whites\` | 白名单,只显示白名单用户 | | \`--blacks\` | 黑名单,不显示黑名单用户 | ### \`messageCounter.群排行榜 [number:number]\` 各群聊的发言排行榜。默认为今日发言榜。 **选项:** | 参数 | 说明 | |------|------| | \`--yd, --yesterday\` | 昨日发言排行榜 | | \`-w, -m, -y, -t\` | 本周/本月/今年/总发言排行榜 | | \`-s\` | 指定用户的群发言排行榜 | | \`--whites\` | 白名单,只显示白名单群 | | \`--blacks\` | 黑名单,不显示黑名单群 | ### \`messageCounter.上传柱状条背景\` - 为自己上传一张自定义的水平柱状条背景图片 - 新图片会覆盖旧的图片。若上传失败,旧图片也会被删除 - 使用此指令时需附带图片 ### \`messageCounter.重载资源\` - 实时重载用户图标、柱状条背景和字体文件,使其更改即时生效(需要权限等级 2) ### \`messageCounter.清理缓存\` - 清理过期的头像缓存文件,以释放磁盘空间(需要权限等级 3) - 用户更换头像后,旧的头像缓存会变成“孤儿缓存”。此指令可以安全地移除它们。 ## 🎨 自定义水平柱状图样式 - 重载插件或使用 \`messageCounter.重载资源\` 指令可使新增的文件立即生效。 ### 1. 用户图标 - 在 \`data/messageCounter/icons\` 文件夹下添加用户图标 - 文件名格式为 \`用户ID.png\`(例:\`1234567890.png\`) - 支持多图标,文件名格式为 \`用户ID-1.png\`, \`用户ID-2.png\` ### 2. 柱状条背景 - **推荐方式**:使用 \`messageCounter.上传柱状条背景\` 指令 - **手动方式**:在 \`data/messageCounter/barBgImgs\` 文件夹下添加背景图片 - 支持多背景(随机选用),文件名格式为 \`用户ID-1.png\` 等 - 建议尺寸 850x50 像素,文件名 \`用户ID.png\` ### 3. 自定义字体 - 插件启动时,会自动将内置字体 \`HarmonyOS_Sans_Medium.ttf\` 拷贝到 \`data/messageCounter/fonts/\` 目录下。 - 您可以将自己喜爱的字体文件放入此文件夹,并在配置项的“字体设置”中填入该字体的文件名称(不带后缀)。 --- ## 💬 QQ 群 - 956758505`; var logger = new import_koishi.Logger("messageCounter"); var FONT_OPTIONS = { TITLE: "HarmonyOS_Sans_Medium", NICKNAME: "HarmonyOS_Sans_Medium" }; var Config = import_koishi.Schema.intersect([ // 核心功能设置 import_koishi.Schema.object({ isBotMessageTrackingEnabled: import_koishi.Schema.boolean().default(false).description("是否统计 Bot 自己发送的消息。") }).description("核心功能"), // 排行榜基础设置 import_koishi.Schema.object({ defaultMaxDisplayCount: import_koishi.Schema.number().min(0).default(20).description("排行榜默认显示的人数。"), isTimeInfoSupplementEnabled: import_koishi.Schema.boolean().default(true).description("是否在显示排行榜时补充时间信息。"), isUserMessagePercentageVisible: import_koishi.Schema.boolean().default(true).description("是否在排行榜中显示用户消息占比。"), hiddenUserIdsInLeaderboard: import_koishi.Schema.array(String).role("table").description("在排行榜中全局隐藏的用户列表。"), hiddenChannelIdsInLeaderboard: import_koishi.Schema.array(String).role("table").description("在群排行榜中全局隐藏的频道列表。") }).description("排行榜设置"), // 图片生成设置 import_koishi.Schema.intersect([ import_koishi.Schema.object({ isTextToImageConversionEnabled: import_koishi.Schema.boolean().default(false).description( "是否将文本排行榜转为图片(基于 `markdownToImage` 服务)。" ), isLeaderboardToHorizontalBarChartConversionEnabled: import_koishi.Schema.boolean().default(false).description( "是否将排行榜渲染为更美观的水平柱状图(基于 `puppeteer` 服务)。" ) }).description("图片生成"), // 仅在开启柱状图功能时显示以下详细选项 import_koishi.Schema.union([ import_koishi.Schema.object({ isLeaderboardToHorizontalBarChartConversionEnabled: import_koishi.Schema.const(true).required(), imageType: import_koishi.Schema.union(["png", "jpeg", "webp"]).default("png").description(`生成的柱状图图片类型。`), // 头像缓存 TTL 设置 avatarCacheTTL: import_koishi.Schema.number().default(86400).description( "头像缓存的有效期(秒)。设置为 0 则永不刷新。过短的有效期会增加网络请求。" ), // 失败缓存 TTL 设置 avatarFailureCacheTTL: import_koishi.Schema.number().default(300).description( "头像获取失败后的缓存时间(秒)。在此期间将使用默认头像且不再尝试获取,以避免频繁请求无效链接。" ), // --- 柱状图专属设置 --- waitUntil: import_koishi.Schema.union([ "load", "domcontentloaded", "networkidle0", "networkidle2" ]).default("networkidle0").description("页面加载等待事件,影响图片生成速度和稳定性。"), chartViewportWidth: import_koishi.Schema.number().min(1).default(1080).description( "渲染页面的视口宽度,单位为像素。此参数主要影响背景图片可显示的最大宽度。默认值为 1080px。" ), deviceScaleFactor: import_koishi.Schema.number().min(0.1).max(4).step(0.1).default(1).description( "渲染时的设备像素比 (DPR)。更高的值可以生成更清晰的图片(例如 2 倍图),但也会增加图片文件体积。默认值为 1。" ), shouldMoveIconToBarEndLeft: import_koishi.Schema.boolean().default(true).description( "是否将自定义图标显示在柱状条的末端。关闭则显示在用户名旁。" ), showStarInChart: import_koishi.Schema.boolean().default(true).description( "是否在生成水平柱状图时,在当前用户/群聊名称前显示★以高亮。" ), horizontalBarBackgroundOpacity: import_koishi.Schema.number().min(0).max(1).default(0.6).description("自定义背景图在进度条区域的不透明度。"), horizontalBarBackgroundFullOpacity: import_koishi.Schema.number().min(0).max(1).default(0).description("自定义背景图在整行背景的不透明度。"), maxBarBgWidth: import_koishi.Schema.number().min(0).default(850).description("允许上传的背景图最大宽度(像素),0为不限制。"), maxBarBgHeight: import_koishi.Schema.number().min(0).default(50).description("允许上传的背景图最大高度(像素),0为不限制。"), maxBarBgSize: import_koishi.Schema.number().min(0).default(5).description("允许上传的背景图最大体积(MB),0为不限制。"), // --- 柱状图背景设置 --- backgroundType: import_koishi.Schema.union(["none", "api", "css"]).default("none").description("图片整体背景的类型。"), apiBackgroundConfig: import_koishi.Schema.object({ apiUrl: import_koishi.Schema.string().description("获取背景图的 API 地址。"), apiKey: import_koishi.Schema.string().role("secret").description("API 的访问凭证(可选)。"), responseType: import_koishi.Schema.union(["binary", "url", "base64"]).default("binary").description("API 返回的数据类型。") }).role("collapse").description("API 背景配置(仅当类型为 API 时生效)。"), backgroundValue: import_koishi.Schema.string().role("textarea", { rows: [2, 4] }).default( `html { background: linear-gradient(135deg, #f6f8f9 0%, #e5ebee 100%); }` ).description( "自定义背景的 CSS 代码(仅当类型为 CSS 时生效)。建议使用 `html` 选择器来设置背景,以确保其能填充整个截图区域。" ), // --- 柱状图字体设置 --- chartTitleFont: import_koishi.Schema.string().default(FONT_OPTIONS.TITLE).description( `标题使用的字体。请填写 'data/messageCounter/fonts' 目录中的字体文件名(不含后缀)。` ), chartNicknameFont: import_koishi.Schema.string().default(FONT_OPTIONS.NICKNAME).description( `成员昵称和发言次数使用的字体。请填写 'data/messageCounter/fonts' 目录中的字体文件名(不含后缀),或使用通用字体(如 sans-serif, Microsoft YaHei 等)。` ) }), import_koishi.Schema.object({}) ]) ]), // 自动推送设置 import_koishi.Schema.intersect([ import_koishi.Schema.object({ autoPush: import_koishi.Schema.boolean().default(false).description("是否启用定时自动推送排行榜功能。") }).description("自动推送"), import_koishi.Schema.union([ import_koishi.Schema.object({ autoPush: import_koishi.Schema.const(true).required(), shouldSendDailyLeaderboardAtMidnight: import_koishi.Schema.boolean().default(true).description("是否在每日 0 点自动发送昨日排行榜。"), /** 每周排行榜推送配置 */ shouldSendWeeklyLeaderboard: import_koishi.Schema.boolean().default(false).description("是否在每周一 0 点自动发送上周排行榜。"), /** 每月排行榜推送配置 */ shouldSendMonthlyLeaderboard: import_koishi.Schema.boolean().default(false).description("是否在每月第一天 0 点自动发送上月排行榜。"), /** 每年排行榜推送配置 */ shouldSendYearlyLeaderboard: import_koishi.Schema.boolean().default(false).description("是否在每年第一天 0 点自动发送去年排行榜。"), dailyScheduledTimers: import_koishi.Schema.array(String).role("table").description( "除 0 点外,其他定时发送今日排行榜的时间点(24小时制,如 `08:00`)。" ), isGeneratingRankingListPromptVisible: import_koishi.Schema.boolean().default(true).description("在生成并发送排行榜前,是否先发送一条提示消息。"), leaderboardGenerationWaitTime: import_koishi.Schema.number().min(0).default(3).description("发送提示消息后,等待多少秒再发送排行榜图片。"), pushChannelIds: import_koishi.Schema.array(String).role("table").description( "需要接收自动推送的频道列表(留空则不推送到任何特定频道)。" ), shouldSendLeaderboardNotificationsToAllChannels: import_koishi.Schema.boolean().default(false).description( "是否向机器人所在的所有群聊推送(注意:这可能造成打扰)。" ), excludedLeaderboardChannels: import_koishi.Schema.array(String).role("table").description( "当“向所有群聊推送”开启时,此处指定的频道将不会收到推送。" ), delayBetweenGroupPushesInSeconds: import_koishi.Schema.number().min(0).default(5).description("批量推送时,每个群之间的发送延迟(秒),以防风控。"), groupPushDelayRandomizationSeconds: import_koishi.Schema.number().min(0).default(10).description("延迟时间的随机波动范围(秒),以进一步模拟人工操作。") }), import_koishi.Schema.object({}) ]) ]), // 龙王禁言设置 import_koishi.Schema.intersect([ import_koishi.Schema.object({ enableMostActiveUserMuting: import_koishi.Schema.boolean().default(false).description("是否在每日 0 点自动禁言昨日发言最多的人(“抓龙王”)。") }).description("龙王禁言"), import_koishi.Schema.union([ import_koishi.Schema.object({ enableMostActiveUserMuting: import_koishi.Schema.const(true).required(), dragonKingDetainmentTime: import_koishi.Schema.number().min(0).default(5).description("0 点后,等待多少秒再执行禁言操作。"), detentionDuration: import_koishi.Schema.number().default(1).description("禁言时长,单位为天。"), muteChannelIds: import_koishi.Schema.array(String).role("table").description("在哪些频道中执行“抓龙王”操作。") }), import_koishi.Schema.object({}) ]) ]) ]); var periodMapping = { today: { field: "todayPostCount", name: "今日" }, yesterday: { field: "yesterdayPostCount", name: "昨日" }, week: { field: "thisWeekPostCount", name: "本周" }, month: { field: "thisMonthPostCount", name: "本月" }, year: { field: "thisYearPostCount", name: "今年" }, total: { field: "totalPostCount", name: "总" } }; async function apply(ctx, config) { const dataRoot = import_path.default.join(ctx.baseDir, "data"); const messageCounterRoot = import_path.default.join(dataRoot, "messageCounter"); const iconsPath = import_path.default.join(messageCounterRoot, "icons"); const barBgImgsPath = import_path.default.join(messageCounterRoot, "barBgImgs"); const fontsPath = import_path.default.join(messageCounterRoot, "fonts"); const avatarsPath = import_path.default.join(messageCounterRoot, "avatars"); const emptyHtmlPath = import_path.default.join(messageCounterRoot, "emptyHtml.html").replace(/\\/g, "/"); const oldIconsPath = import_path.default.join(dataRoot, "messageCounterIcons"); const oldBarBgImgsPath = import_path.default.join(dataRoot, "messageCounterBarBgImgs"); await fs.mkdir(fontsPath, { recursive: true }); await fs.mkdir(iconsPath, { recursive: true }); await fs.mkdir(barBgImgsPath, { recursive: true }); await fs.mkdir(avatarsPath, { recursive: true }); await migrateFolder(oldIconsPath, iconsPath); await migrateFolder(oldBarBgImgsPath, barBgImgsPath); try { await fs.access(emptyHtmlPath, import_fs.constants.F_OK); } catch { await fs.writeFile(emptyHtmlPath, ""); logger.info(`已创建空的渲染模板文件: emptyHtml.html`); } const fontFiles = ["HarmonyOS_Sans_Medium.ttf"]; for (const fontFile of fontFiles) { await copyAssetIfNotExists(__dirname, fontsPath, fontFile, "assets/fonts"); } const avatarCache = /* @__PURE__ */ new Map(); let iconCache = []; let barBgImgCache = []; let fontFilesCache = []; ctx.model.extend( "message_counter_records", { id: "unsigned", channelId: "string", channelName: "string", userId: "string", username: "string", userAvatar: "string", todayPostCount: "unsigned", thisWeekPostCount: "unsigned", thisMonthPostCount: "unsigned", thisYearPostCount: "unsigned", totalPostCount: "unsigned", yesterdayPostCount: "unsigned" }, { primary: "id", autoInc: true } ); ctx.model.extend( "message_counter_state", { key: "string", value: "timestamp" }, { primary: "key" } ); const guildCtx = ctx.guild(); ctx.on("ready", async () => { await reloadIconCache(); await reloadBarBgImgCache(); await reloadFontCache(); await initializeResetStates(); await checkForMissedResets(); if (config.autoPush) { if (config.shouldSendDailyLeaderboardAtMidnight) { const task = ctx.cron( "1 0 * * *", () => generateAndPushLeaderboard("yesterday") ); scheduledTasks.push(task); logger.info("[自动推送] 已设置每日 00:01 推送昨日排行榜的任务。"); } (config.dailyScheduledTimers || []).forEach((time) => { const match = /^([0-1]?[0-9]|2[0-3]):([0-5]?[0-9])$/.exec(time); if (match) { const [_, hour, minute] = match; const cron = `${minute} ${hour} * * *`; const task = ctx.cron( cron, () => generateAndPushLeaderboard("today") ); scheduledTasks.push(task); logger.info(`[自动推送] 已设置每日 ${time} 推送今日排行榜的任务。`); } else { logger.warn( `[自动推送] 无效的时间格式: "${time}",已跳过。请使用 "HH:mm" 格式。` ); } }); } if (config.enableMostActiveUserMuting) { const task = ctx.cron("1 0 * * *", () => performDragonKingMuting()); scheduledTasks.push(task); logger.info("[抓龙王] 已设置每日 00:01 执行的禁言任务。"); } const resetTask = ctx.cron("0 0 * * *", async () => { const now = /* @__PURE__ */ new Date(); const dayOfMonth = now.getDate(); const month = now.getMonth(); const dayOfWeek = now.getDay(); if (config.autoPush && config.shouldSendYearlyLeaderboard && dayOfMonth === 1 && month === 0) { await generateAndPushLeaderboard("year"); } if (config.autoPush && config.shouldSendMonthlyLeaderboard && dayOfMonth === 1) { await generateAndPushLeaderboard("month"); } if (config.autoPush && config.shouldSendWeeklyLeaderboard && dayOfWeek === 1) { await generateAndPushLeaderboard("week"); } await resetCounter("todayPostCount", "今日发言榜已成功置空!", "daily"); if (dayOfWeek === 1) { await resetCounter( "thisWeekPostCount", "本周发言榜已成功置空!", "weekly" ); } if (dayOfMonth === 1) { await resetCounter( "thisMonthPostCount", "本月发言榜已成功置空!", "monthly" ); } if (dayOfMonth === 1 && month === 0) { await resetCounter( "thisYearPostCount", "今年发言榜已成功置空!", "yearly" ); } }); scheduledTasks.push(resetTask); logger.info("已设置统一的推送与数据重置任务(每日、周、月、年)。"); }); ctx.on("dispose", () => { scheduledTasks.forEach((task) => task()); avatarCache.clear(); iconCache = []; barBgImgCache = []; fontFilesCache = []; logger.info("All scheduled jobs and caches have been cleared."); }); guildCtx.on("message", async (session) => { if (!session.userId || !session.channelId || session.author?.isBot) return; const { userId, channelId, author, guildId } = session; let sessionChannelName = session.event.channel.name; const username = author?.nick || author?.name || userId; const userAvatar = author?.avatar; try { const channelName = sessionChannelName || (guildId ? await getGuildName(session.bot, guildId) : channelId); await ctx.database.upsert( "message_counter_records", (row) => [ { channelId, userId, username, userAvatar: userAvatar || row.userAvatar, channelName: channelName || row.channelName, todayPostCount: import_koishi.$.add(row.todayPostCount, 1), thisWeekPostCount: import_koishi.$.add(row.thisWeekPostCount, 1), thisMonthPostCount: import_koishi.$.add(row.thisMonthPostCount, 1), thisYearPostCount: import_koishi.$.add(row.thisYearPostCount, 1), totalPostCount: import_koishi.$.add(row.totalPostCount, 1) } ], ["channelId", "userId"] ); } catch (error) { logger.error( "Failed to update message count for user %s in channel %s:", userId, channelId, error ); } }); if (config.isBotMessageTrackingEnabled) { ctx.before("send", async (session) => { if (!session.channelId || !session.guildId) return; const { channelId, bot, guildId } = session; let sessionChannelName = session.event.channel.name; const botUser = bot.user; if (!botUser) { logger.warn("Bot user is undefined, skipping bot message tracking."); return; } try { const channelName = sessionChannelName || await getGuildName(bot, guildId) || channelId; await ctx.database.upsert( "message_counter_records", (row) => [ { channelId, userId: botUser.id, username: botUser.name, userAvatar: botUser.avatar, channelName: channelName || row.channelName, todayPostCount: import_koishi.$.add(row.todayPostCount, 1), thisWeekPostCount: import_koishi.$.add(row.thisWeekPostCount, 1), thisMonthPostCount: import_koishi.$.add(row.thisMonthPostCount, 1), thisYearPostCount: import_koishi.$.add(row.thisYearPostCount, 1), totalPostCount: import_koishi.$.add(row.totalPostCount, 1) } ], ["channelId", "userId"] ); } catch (error) { logger.error( "Failed to update bot message count in channel %s:", channelId, error ); } }); } ctx.command("messageCounter", "查看messageCounter帮助").action(({ session }) => session?.execute(`help messageCounter`)); ctx.command("messageCounter.初始化", "初始化", { authority: 3 }).action(async ({ session }) => { if (!session) return; await session.send("正在清空所有发言记录,请稍候..."); await ctx.database.remove("message_counter_records", {}); await session.send("所有发言记录已清空!"); }); ctx.command( "messageCounter.查询 [targetUser:text]", "查询指定用户的发言次数信息" ).userFields(["id", "name"]).option("yesterday", "--yd 昨日发言").option("day", "-d 今日发言").option("week", "-w 本周发言").option("month", "-m 本月发言").option("year", "-y 今年发言").option("total", "-t 总发言").option("ydag", "跨群昨日发言").option("dag", "跨群今日发言").option("wag", "跨群本周发言").option("mag", "跨群本月发言").option("yag", "跨群本年发言").option("across", "-a 跨群总发言").action(async ({ session, options }, targetUser) => { const optionKeys = [ "day", "week", "month", "year", "total", "yesterday", "dag", "wag", "mag", "yag", "ydag", "across" ]; const selectedOptions = {}; let noOptionSelected = true; for (const key of optionKeys) { if (options[key]) { selectedOptions[key] = true; noOptionSelected = false; } } if (noOptionSelected) { for (const key of optionKeys) { selectedOptions[key] = true; } } let channelId = session?.channelId; let userId = session?.userId; let targetUserRecord = []; if (targetUser) { if (session) targetUser = await replaceAtTags(session, targetUser); const match = targetUser.match(/<at id="([^"]+)"/); if (match) userId = match[1]; } targetUserRecord = await ctx.database.get("message_counter_records", { channelId, userId }); if (targetUserRecord.length === 0) return `被查询对象无任何发言记录。`; const guildUsers = await ctx.database.get("message_counter_records", { channelId }); const allUsers = await ctx.database.get("message_counter_records", {}); const guildStats = []; const acrossStats = []; const accumulate = /* @__PURE__ */ __name((records) => records.reduce((sums, user) => { for (const key in periodMapping) { sums[periodMapping[key].field] = (sums[periodMapping[key].field] || 0) + user[periodMapping[key].field]; } return sums; }, {}), "accumulate"); const guildTotals = accumulate(guildUsers); const acrossTotals = accumulate(allUsers); const getRank = /* @__PURE__ */ __name((records, field, uid) => { const sorted = [...records].sort((a, b) => b[field] - a[field]); const index = sorted.findIndex((u) => u.userId === uid); return index !== -1 ? index + 1 : null; }, "getRank"); const getAcrossRank = /* @__PURE__ */ __name((records, field, uid) => { const userTotals = records.reduce((acc, cur) => { acc[cur.userId] = (acc[cur.userId] || 0) + cur[field]; return acc; }, {}); const sorted = Object.entries(userTotals).sort(([, a], [, b]) => b - a); const index = sorted.findIndex(([id]) => id === uid); return index !== -1 ? index + 1 : null; }, "getAcrossRank"); const getAcrossCount = /* @__PURE__ */ __name((records, field, uid) => { return records.filter((r) => r.userId === uid).reduce((sum, r) => sum + r[field], 0); }, "getAcrossCount"); guildStats.push({ label: "昨日", count: targetUserRecord[0].yesterdayPostCount, total: guildTotals.yesterdayPostCount, rank: getRank(guildUsers, "yesterdayPostCount", userId), enabled: selectedOptions.yesterday }); guildStats.push({ label: "今日", count: targetUserRecord[0].todayPostCount, total: guildTotals.todayPostCount, rank: getRank(guildUsers, "todayPostCount", userId), enabled: selectedOptions.day }); guildStats.push({ label: "本周", count: targetUserRecord[0].thisWeekPostCount, total: guildTotals.thisWeekPostCount, rank: getRank(guildUsers, "thisWeekPostCount", userId), enabled: selectedOptions.week }); guildStats.push({ label: "本月", count: targetUserRecord[0].thisMonthPostCount, total: guildTotals.thisMonthPostCount, rank: getRank(guildUsers, "thisMonthPostCount", userId), enabled: selectedOptions.month }); guildStats.push({ label: "全年", count: targetUserRecord[0].thisYearPostCount, total: guildTotals.thisYearPostCount, rank: getRank(guildUsers, "thisYearPostCount", userId), enabled: selectedOptions.year }); guildStats.push({ label: "总计", count: targetUserRecord[0].totalPostCount, total: guildTotals.totalPostCount, rank: getRank(guildUsers, "totalPostCount", userId), enabled: selectedOptions.total }); acrossStats.push({ label: "昨日", count: getAcrossCount(allUsers, "yesterdayPostCount", userId), total: acrossTotals.yesterdayPostCount, rank: getAcrossRank(allUsers, "yesterdayPostCount", userId), enabled: selectedOptions.ydag }); acrossStats.push({ label: "今日", count: getAcrossCount(allUsers, "todayPostCount", userId), total: acrossTotals.todayPostCount, rank: getAcrossRank(allUsers, "todayPostCount", userId), enabled: selectedOptions.dag }); acrossStats.push({ label: "本周", count: getAcrossCount(allUsers, "thisWeekPostCount", userId), total: acrossTotals.thisWeekPostCount, rank: getAcrossRank(allUsers, "thisWeekPostCount", userId), enabled: selectedOptions.wag }); acrossStats.push({ label: "本月", count: getAcrossCount(allUsers, "thisMonthPostCount", userId), total: acrossTotals.thisMonthPostCount, rank: getAcrossRank(allUsers, "thisMonthPostCount", userId), enabled: selectedOptions.mag }); acrossStats.push({ label: "全年", count: getAcrossCount(allUsers, "thisYearPostCount", userId), total: acrossTotals.thisYearPostCount, rank: getAcrossRank(allUsers, "thisYearPostCount", userId), enabled: selectedOptions.yag }); acrossStats.push({ label: "总计", count: getAcrossCount(allUsers, "totalPostCount", userId), total: acrossTotals.totalPostCount, rank: getAcrossRank(allUsers, "totalPostCount", userId), enabled: selectedOptions.across }); const formatPercentage = /* @__PURE__ */ __name((count, total) => { if (total === 0) return "(0%)"; const percentage = count / total * 100; const numStr = percentage % 1 === 0 ? String(percentage) : percentage.toFixed(2); return `(${numStr}%)`; }, "formatPercentage"); const formatStatsTable = /* @__PURE__ */ __name((title, stats) => { const activeStats = stats.filter((s) => s.enabled && s.count > 0); if (activeStats.length === 0) return ""; const counts = activeStats.map((s) => String(s.count)); const percents = activeStats.map( (s) => formatPercentage(s.count, s.total) ); const maxCountWidth = Math.max(0, ...counts.map((s) => s.length)); const maxPercentWidth = Math.max(0, ...percents.map((s) => s.length)); let table = `${title} `; for (const row of activeStats) { const label = row.label.padEnd(2, " "); const countStr = String(row.count).padStart(maxCountWidth, " "); const percentStr = formatPercentage(row.count, row.total).padEnd( maxPercentWidth, " " ); const rankStr = row.rank ? `#${row.rank}` : "#-"; table += `${label} ${countStr} ${percentStr} ${rankStr} `; } return table; }, "formatStatsTable"); const guildTable = formatStatsTable("群发言", guildStats); const acrossTable = formatStatsTable("跨群发言", acrossStats); const body = [guildTable, acrossTable].filter(Boolean).join("\n"); if (!body) return `被查询对象在指定时段内无发言记录。`; const timestamp = (/* @__PURE__ */ new Date()).toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }); const header = `${timestamp} ${targetUserRecord[0].username} `; const message = header + body; if (config.isTextToImageConversionEnabled && ctx.markdownToImage) { try { const imageBuffer = await ctx.markdownToImage.convertToImage(message); return import_koishi.h.image(imageBuffer, `image/${config.imageType}`); } catch (error) { logger.warn("生成图片失败,将回退到文本输出:", error); } } return message; }); ctx.command("messageCounter.排行榜 [limit:number]", "用户发言排行榜").userFields(["id", "name"]).option("whites", "<users:text> 白名单,用空格、逗号等分隔").option("blacks", "<users:text> 黑名单,用空格、逗号等分隔").option("yesterday", "--yd").option("day", "-d").option("week", "-w").option("month", "-m").option("year", "-y").option("total", "-t").option("ydag", "跨群昨日").option("dag", "跨群今日").option("wag", "跨群本周").option("mag", "跨群本月").option("yag", "跨群本年").option("dragon", "圣龙王榜 (跨群总榜)").action(async ({ session, options }, limit) => { if (!session) return; const number = limit ?? config.defaultMaxDisplayCount; if (typeof number !== "number" || isNaN(number) || number < 0) { return "请输入大于等于 0 的数字作为排行榜显示人数。"; } const whites = parseList(options?.whites); const blacks = [ ...parseList(options?.blacks), ...config.hiddenUserIdsInLeaderboard ]; const period = getPeriodFromOptions(options, "today"); const isAcross = isAcrossGuild(options); const { field, name: periodName } = periodMapping[period]; const scopeName = isAcross ? "跨群" : "本群"; const rankTitle = `${scopeName}${periodName}发言排行榜`; const rankTimeTitle = getCurrentBeijingTime(); let records; if (isAcross) { records = await ctx.database.get("message_counter_records", {}); } else { records = await ctx.database.get("message_counter_records", { channelId: session.channelId }); } const filteredRecords = filterRecordsByWhitesAndBlacks( records, "userId", whites, blacks ); if (filteredRecords.length === 0) { return "当前范围内暂无发言记录。"; } const userPostCounts = {}; const userInfo = {}; let totalCount = 0; for (const record of filteredRecords) { const count = record[field]; userPostCounts[record.userId] = (userPostCounts[record.userId] || 0) + count; if (!userInfo[record.userId]) { userInfo[record.userId] = { username: record.username, avatar: record.userAvatar || `https://q1.qlogo.cn/g?b=qq&nk=${record.userId}&s=640` }; } totalCount += count; } const sortedUsers = Object.entries(userPostCounts).sort( ([, a], [, b]) => b - a ); const rankingData = prepareRankingData( sortedUsers, userInfo, totalCount, number, session.userId ); return renderLeaderboard({ rankTimeTitle, rankTitle, rankingData }); }); ctx.command("messageCounter.群排行榜 [limit:number]", "群发言排行榜").option("specificUser", "-s <user:text> 特定用户的群发言榜").option("whites", "<channels:text> 白名单群号").option("blacks", "<channels:text> 黑名单群号").option("yesterday", "--yd").option("day", "-d").option("week", "-w").option("month", "-m").option("year", "-y").option("total", "-t").action(async ({ session, options }, limit) => { if (!session) return; const number = limit ?? config.defaultMaxDisplayCount; if (typeof number !== "number" || isNaN(number) || number < 0) { return "请输入大于等于 0 的数字作为排行榜显示人数。"; } const whites = parseList(options?.whites); const blacks = [ ...parseList(options?.blacks), ...config.hiddenChannelIdsInLeaderboard ]; const period = getPeriodFromOptions(options, "today"); const { field, name: periodName } = periodMapping[period]; let records; let rankTitle; const rankTimeTitle = getCurrentBeijingTime(); if (options?.specificUser) { const at = import_koishi.h.select(options.specificUser, "at"); const userId = at.length ? at[0].attrs.id : options.specificUser; const userRecords = await ctx.database.get("message_counter_records", { userId, channelId: session.channelId }); const username = userRecords.length > 0 ? userRecords[0].username || `用户${userId}` : `用户${userId}`; rankTitle = `${username}的${periodName}群发言排行榜`; records = await ctx.database.get("message_counter_records", { userId }); } else { rankTitle = `全群${periodName}发言排行榜`; records = await ctx.database.get("message_counter_records", {}); } const filteredRecords = filterRecordsByWhitesAndBlacks( records, "channelId", whites, blacks ); if (filteredRecords.length === 0) { return `在当前条件下找不到任何群聊发言记录。`; } const { channelPostCounts, channelInfo, totalCount } = aggregateChannelData(filteredRecords, field); const sortedChannels = Object.entries(channelPostCounts).sort( ([, a], [, b]) => b - a ); const rankingData = prepareChannelRankingData( sortedChannels, channelInfo, totalCount, number, session.channelId ); return renderLeaderboard({ rankTimeTitle, rankTitle, rankingData }); }); ctx.command( "messageCounter.上传柱状条背景", "上传/更新自定义的水平柱状条背景图" ).action(async ({ session }) => { if (!session || !session.userId) { return "无法获取用户信息,请稍后再试。"; } if (!session.content) { return "请在发送指令的同时附带一张图片。新图片将会覆盖旧的背景。"; } const imageElements = import_koishi.h.select(session.content, "img"); if (imageElements.length === 0) { return "请在发送指令的同时附带一张图片。新图片将会覆盖旧的背景。"; } const { userId } = session; const cleanupOldBackground = /* @__PURE__ */ __name(async () => { try { const allFiles = await fs.readdir(barBgImgsPath); const userFiles = allFiles.filter( (file) => file.startsWith(`${userId}.`) ); if (userFiles.length > 0) { await Promise.all( userFiles.map((file) => fs.unlink(import_path.default.join(barBgImgsPath, file))) ); } } catch (error) { if (error.code !== "ENOENT") { logger.warn(`清理用户 ${userId} 的旧背景图时出错:`, error); } } }, "cleanupOldBackground"); try { const imageUrl = imageElements[0].attrs.src; if (!imageUrl) { throw new Error("未能从消息中提取图片 URL。"); } const buffer = Buffer.from( await ctx.http.get(imageUrl, { responseType: "arraybuffer" }) ); const imageSizeInMB = buffer.byteLength / 1024 / 1024; if (config.maxBarBgSize > 0 && imageSizeInMB > config.maxBarBgSize) { throw new Error( `图片文件过大(${imageSizeInMB.toFixed(2)}MB),请上传小于 ${config.maxBarBgSize}MB 的图片。` ); } if (ctx.canvas) { try { const image = await ctx.canvas.loadImage(buffer); if (config.maxBarBgWidth > 0 && image.naturalWidth > config.maxBarBgWidth || config.maxBarBgHeight > 0 && image.naturalHeight > config.maxBarBgHeight) { throw new Error( `图片尺寸(${image.naturalWidth}x${image.naturalHeight})超出限制(最大 ${config.maxBarBgWidth}x${config.maxBarBgHeight})。 建议尺寸为 850x50 像素。` ); } } catch (error) { logger.error("解析图片尺寸失败:", error); throw new Error( "无法解析图片尺寸,请尝试使用其他标准图片格式(如 PNG, JPEG)。" ); } } else { logger.warn("Canvas 服务未启用,跳过背景图尺寸检查。"); } await cleanupOldBackground(); const newFileName = `${userId}.png`; const newFilePath = import_path.default.join(barBgImgsPath, newFileName); await fs.writeFile(newFilePath, buffer); await reloadBarBgImgCache(); return "您的自定义柱状条背景已成功更新!"; } catch (error) { logger.error(`为用户 ${userId} 上传背景图失败:`, error); await cleanupOldBackground(); await reloadBarBgImgCache(); const userMessage = error instanceof Error ? error.message : "图片保存时发生未知错误,请联系管理员。"; return `图片上传失败: ${userMessage} 您之前的自定义背景(如有)已被移除。`; } }); ctx.command("messageCounter.重载资源", "重载图标、背景和字体资源", { authority: 2 }).action(async ({ session }) => { if (!session) return; await session.send("正在重新加载用户图标、背景图片和字体文件缓存..."); await reloadIconCache(); await reloadBarBgImgCache(); await reloadFontCache(); return `资源重载完毕! - 已加载 ${iconCache.length} 个用户图标。 - 已加载 ${barBgImgCache.length} 个柱状条背景图片。 - 已加载 ${fontFilesCache.length} 个字体文件。`; }); ctx.command("messageCounter.清理缓存", "清理过期的头像缓存文件", { authority: 3 }).option( "days", "-d <days:number> 清理超过指定天数未使用的缓存文件 (默认: 30)" ).action(async ({ session, options }) => { if (!session) return; const days = options.days ?? 30; if (typeof days !== "number" || days < 0) { return "请输入一个有效的天数(大于等于0)。"; } await session.send(`正在开始清理 ${days} 天前的头像缓存,请稍候...`); const cacheDir = avatarsPath; let deletedCount = 0; let totalFreedSize = 0; const now = Date.now(); const expirationTime = now - days * 24 * 60 * 60 * 1e3; try { const files = await fs.readdir(cacheDir); for (const file of files) { if (!file.endsWith(".json")) continue; const filePath = import_path.default.join(cacheDir, file); try { const stats = await fs.stat(filePath); const content = await fs.readFile(filePath, "utf-8"); const entry = JSON.parse(content); if (entry.timestamp < expirationTime) { await fs.unlink(filePath); deletedCount++; totalFreedSize += stats.size; } } catch (error) { logger.warn(`处理缓存文件 ${file} 时出错,已跳过:`, error); } } const freedSizeFormatted = formatBytes(totalFreedSize); return `缓存清理完成! - 共删除 ${deletedCount} 个过期缓存文件。 - 释放磁盘空间约 ${freedSizeFormatted}。`; } catch (error) { if (error.code === "ENOENT") { return "头像缓存目录不存在,无需清理。"; } logger.error("清理头像缓存时发生未知错误:", error); return "清理过程中发生错误,请查看控制台日志。"; } }); function formatBytes(bytes, decimals = 2) { if (bytes === 0) return "0 Bytes"; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; } __name(formatBytes, "formatBytes"); async function generateAndPushLeaderboard(period) { const pushPeriodConfig = { today: { field: "todayPostCount", name: "今日" }, yesterday: { field: "yesterdayPostCount", name: "昨日" }, week: { field: "thisWeekPostCount", name: "上周" }, month: { field: "thisMonthPostCount", name: "上月" }, year: { field: "thisYearPostCount", name: "去年" } }; const { field, name: periodName } = pushPeriodConfig[period]; logger.info(`[自动推送] 开始执行 ${periodName} 发言排行榜推送任务。`); const scopeName = "本群"; const rankTimeTitle = getCurrentBeijingTime(); const guildIdMap = /* @__PURE__ */ new Map(); try { const guildListPromises = ctx.bots.map(async (bot) => { if (!bot.online || !bot.getGuildList) return []; let next; do { const result = await bot.getGuildList(next); result.data.forEach((guild) => { if (!guildIdMap.has(guild.id)) { guildIdMap.set(guild.id, `${bot.platform}:${guild.id}`); } }); next = result.next; } while (next); }); await Promise.all(guildListPromises); } catch (error) { logger.error("[自动推送] 获取所有群聊列表时出错,任务可能不完整:", error); } const targetChannels = /* @__PURE__ */ new Set(); for (const channelId of config.pushChannelIds || []) { if (channelId.includes(":")) { targetChannels.add(channelId); } else if (guildIdMap.has(channelId)) { targetChannels.add(guildIdMap.get(channelId)); } else { logger.warn( `[自动推送] 无法在任何机器人实例中找到频道 ID: ${channelId},已跳过。` ); } } if (config.shouldSendLeaderboardNotificationsToAllChannels) { guildIdMap.forEach((prefixedId) => targetChannels.add(prefixedId)); } const excluded = new Set(config.excludedLeaderboardChannels || []); if (excluded.size > 0) { for (const id of Array.from(targetChannels)) { const unprefixedId = id.slice(id.indexOf(":") + 1); if (excluded.has(id) || excluded.has(unprefixedId)) { targetChannels.delete(id); } } } if (targetChannels.size === 0) { logger.info("[自动推送] 没有配置任何需要推送的频道,任务结束。"); return; } logger.info(`[自动推送] 将向 ${targetChannels.size} 个频道进行推送。`); for (const prefixedChannelId of targetChannels) { try { const platformSeparatorIndex = prefixedChannelId.indexOf(":"); const channelId = platformSeparatorIndex === -1 ? prefixedChannelId : prefixedChannelId.substring(platformSeparatorIndex + 1); const records = await ctx.database.get("message_counter_records", { channelId }); if (records.length === 0) { logger.info( `[自动推送] 频道 ${prefixedChannelId} 无发言记录,跳过。` ); continue; } const userPostCounts = {}; const userInfo = {}; let totalCount = 0; for (const record of records) { const count = record[field] || 0; userPostCounts[record.userId] = (userPostCounts[record.userId] || 0) + count; if (!userInfo[record.userId]) { userInfo[record.userId] = { username: record.username, avatar: record.userAvatar || `https://q1.qlogo.cn/g?b=qq&nk=${record.userId}&s=640` }; } totalCount += count; } const sortedUsers = Object.entries(userPostCounts).filter(([, count]) => count > 0).sort(([, a], [, b]) => b - a); if (sortedUsers.length === 0) { logger.info( `[自动推送] 频道 ${prefixedChannelId} 在 ${periodName} 榜单上无有效数据,跳过。` ); continue; } const rankingData = prepareRankingData( sortedUsers, userInfo, totalCount, config.defaultMaxDisplayCount ); if (config.isGeneratingRankingListPromptVisible) { await ctx.broadcast( [prefixedChannelId], `正在为本群生成${periodName}发言排行榜...` ); await (0, import_koishi.sleep)(config.leaderboardGenerationWaitTime * 1e3); } const rankTitle = `${scopeName}${periodName}发言排行榜`; const renderedMessage = await renderLeaderboard({ rankTimeTitle, rankTitle, rankingData }); await ctx.broadcast([prefixedChannelId], renderedMessage); logger.success( `[自动推送] 已成功向频道 ${prefixedChannelId} 推送${periodName}排行榜。` ); const randomDelay = Math.random() * config.groupPushDelayRandomizationSeconds; const delay = (config.delayBetweenGroupPushesInSeconds + randomDelay) * 1e3; if (delay > 0) { await (0, import_koishi.sleep)(delay); } } catch (error) { logger.error( `[自动推送] 向频道 ${prefixedChannelId} 推送时发生错误:`, error ); } } logger.info(`[自动推送] 所有推送任务执行完毕。`); } __name(generateAndPushLeaderboard, "generateAndPushLeaderboard"); async function performDragonKingMuting() { if (!config.enableMostActiveUserMuting || !config.muteChannelIds || config.muteChannelIds.length === 0) { return; } logger.info("[抓龙王] 开始执行禁言任务。"); await (0, import_koishi.sleep)(config.dragonKingDetainmentTime * 1e3); for (const channelId of config.muteChannelIds) { try { const records = await ctx.database.get("message_counter_records", { channelId, yesterdayPostCount: { $gt: 0 } // 只查找昨日有发言的 }); if (records.length === 0) { logger.info(`[抓龙王] 频道 ${channelId} 昨日无人发言,跳过。`); continue; } const topUser = records.sort( (a, b) => b.yesterdayPostCount - a.yesterdayPostCount )[0]; if (!topUser) continue; const durationInSeconds = config.detentionDuration * 24 * 60 * 60; await ctx.broadcast( [channelId], (0, import_koishi.h)("mute", { userId: topUser.userId, duration: durationInSeconds * 1e3 }) ); logger.success( `[抓龙王] 已在频道 ${channelId} 将昨日龙王 ${topUser.username} (${topUser.userId}) 禁言 ${config.detentionDuration} 天。` ); await ctx.broadcast( [channelId], `根据统计,昨日发言最多的是 ${(0, import_koishi.h)("at", { id: topUser.userId, name: topUser.username })},现执行禁言 ${config.detentionDuration} 天。` ); } catch (error) { logger.error(`[抓龙王] 在频道 ${channelId} 执行禁言时出错:`, error); } } } __name(performDragonKingMuting, "performDragonKingMuting"); const scheduledTasks = []; async function initializeResetStates() { logger.info("正在初始化并验证发言计数器的重置状态..."); const now = /* @__PURE__ */ new Date(); const state = await ctx.database.get("message_counter_state", {}); const stateMap = new Map(state.map((s) => [s.key, s.value])); const periods = [ "daily", "weekly", "monthly", "yearly" ]; for (const period of periods) { const key = `last_${period}_reset`; if (!stateMap.has(key)) { let baselineDate; switch (period) { case "daily": baselineDate = /* @__PURE__ */ new Date(); baselineDate.setHours(0, 0, 0, 0); break; case "weekly": baselineDate = new Date(now); baselineDate.setDate(now.getDate() - (now.getDay() + 6) % 7); baselineDate.setHours(0, 0, 0, 0); break; case "monthly": baselineDate = new Date(now.getFullYear(), now.getMonth(), 1); baselineDate.setHours(0, 0, 0, 0); break; case "yearly": baselineDate = new Date(now.getFullYear(), 0, 1); baselineDate.setHours(0, 0, 0, 0); break; } await ctx.database.upsert("message_counter_state", [ { key, value: baselineDate } ]); logger.info( `已为 '${period}' 周期初始化重置状态,基准时间:${baselineDate.toISOString()}` ); } } logger.info("所有周期的重置状态已验证完毕。"); } __name(initializeResetStates, "initializeResetStates"); async function isResetDue(period) { const now = /* @__PURE__ */ new Date(); const state = await ctx.database.get("message_counter_state", { key: `last_${period}_reset` }); const lastReset = state.length ? new Date(state[0].value) : /* @__PURE__ */ new Date(0); let periodStart; switch (period) { case "daily": periodStart = /* @__PURE__ */ new Date(); periodStart.setHours(0, 0, 0, 0); break; case "weekly": periodStart = new Date(now); periodStart.setDate(now.getDate() - (now.getDay() + 6) % 7); periodStart.setHours(0, 0, 0, 0); break; case "monthly": periodStart = new Date(now.getFullYear(), now.getMonth(), 1); periodStart.setHours(0, 0, 0, 0); break; case "yearly": periodStart = new Date(now.getFullYear(), 0, 1); periodStart.setHours(0, 0, 0, 0); break; } return lastReset < periodStart; } __name(isResetDue, "isResetDue"); async function checkForMissedResets() { logger.info("正在检查错过的计数器重置任务..."); const jobDefinitions = [ { period: "daily", field: "todayPostCount", message: "已补上错过的每日发言榜重置!" }, { period: "weekly", field: "thisWeekPostCount", message: "已补上错过的每周发言榜重置!" }, { period: "monthly", field: "thisMonthPostCount", message: "已补上错过的每月发言榜重置!" }, { period: "yearly", field: "thisYearPostCount", message: "已补上错过的每年发言榜重置!" } ]; for (const job of jobDefinitions) { if (await isResetDue(job.period)) { logger.info(`检测到错过的 ${job.period} 重置任务,正在执行...`); await resetCounter(job.field, job.message, job.period); } } logger.info("错过的计数器重置任务检查完毕。"); } __name(checkForMissedResets, "checkForMissedResets"); async function resetCounter(field, message, period) { if (field === "todayPostCount") { logger.info("正在更新昨日发言数..."); await ctx.database.set("message_counter_records", {}, (row) => ({ yesterdayPostCount: row.todayPostCount })); logger.success("更新昨日发言数完成。"); } await ctx.database.set("message_counter_records", {}, { [field]: 0 }); logger.success(message); await ctx.database.upsert("message_counter_state", [ { key: `last_${period}_reset`, value: /* @__PURE__ */ new Date() } ]); logger.success(`已更新 ${period} 周期的最后重置时间。`); } __name(resetCounter, "resetCounter"); function formatPercentageForDisplay(count, total) { if (total === 0) { return "(0%)"; } const percentage = count / total * 100; const formattedNumber = parseFloat(percentage.toFixed(2)); return `(${formattedNumber}%)`; } __name(formatPercentageForDisplay, "formatPercentageForDisplay"); async function getAvatarAsBase64(url) { const fallbackBase64 = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAMAAADDpiTIAAAC+lBMVEUAAACwsLCtra2wsLCvr6+ysrLT09Ourq6+vr64uLjGxsa6urqqqqq0tLSzs7PPz8/Dw8O