koishi-plugin-message-counter
Version:
Koishi 的消息数量统计插件。生成各种发言排行榜。
1,303 lines (1,270 loc) • 103 kB
JavaScript
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 assetsDir = import_path.default.resolve(__dirname, "..", "assets");
var fallbackBase64 = [""];
var name = "message-counter";
var inject = {
required: ["database", "cron"],
optional: ["markdownToImage", "puppeteer", "canvas"]
};
var usage = `## 📝 注意事项
- 只统计群聊消息
- 初始化需权限等级 3
- 依赖 database 与 cron 服务
- 生成图片时,需 puppeteer 提供 canvas 支持
## 🔍 关键指令
### \`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("排行榜默认显示的人数。设置为 0 则显示所有。"),
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("全局隐藏的用户 ID 列表,在所有用户排行榜中生效。"),
hiddenChannelIdsInLeaderboard: import_koishi.Schema.array(String).role("table").description("全局隐藏的频道 ID 列表,在群排行榜中生效。")
}).description("排行榜设置"),
// --- 图片生成 ---
import_koishi.Schema.intersect([
import_koishi.Schema.object({
isTextToImageConversionEnabled: import_koishi.Schema.boolean().default(false).description(
"是否将文本排行榜转为 Markdown 图片(依赖 `markdownToImage` 服务)。"
),
isLeaderboardToHorizontalBarChartConversionEnabled: import_koishi.Schema.boolean().default(false).description("是否将排行榜渲染为水平柱状图(依赖 `puppeteer` 服务)。")
}).description("图片生成"),
// 仅在开启柱状图功能时显示以下详细选项
import_koishi.Schema.union([
import_koishi.Schema.intersect([
import_koishi.Schema.object({
isLeaderboardToHorizontalBarChartConversionEnabled: import_koishi.Schema.const(true).required(),
imageType: import_koishi.Schema.union(["png", "jpeg", "webp"]).default("png").description(`生成的柱状图图片格式。`)
}).description("柱状图基础设置"),
import_koishi.Schema.object({
chartViewportWidth: import_koishi.Schema.number().min(1).default(1080).description(
"渲染页面的视口宽度(像素)。此值会影响图片清晰度及背景图的展示。"
),
deviceScaleFactor: import_koishi.Schema.number().min(0.1).max(4).step(0.1).default(1).description(
"设备像素比 (DPR)。更高的值可生成更清晰的图片(如 2 倍图),但会增加文件体积。"
),
waitUntil: import_koishi.Schema.union([
"load",
"domcontentloaded",
"networkidle0",
"networkidle2"
]).default("networkidle0").description(
"页面加载等待策略,影响图片生成速度和稳定性。`networkidle0` 最稳定。"
)
}).description("渲染与性能"),
import_koishi.Schema.object({
avatarCacheTTL: import_koishi.Schema.number().default(86400).description(
"头像缓存有效期(秒)。设置为 0 则永不刷新。过短的有效期会增加网络请求。"
),
avatarFailureCacheTTL: import_koishi.Schema.number().default(300).description(
"头像获取失败后的重试间隔(秒)。期间将使用默认头像,避免频繁请求无效链接。"
)
}).description("缓存设置"),
import_koishi.Schema.object({
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).step(0.05).default(0.6).description("自定义背景图在进度条区域的不透明度。"),
horizontalBarBackgroundFullOpacity: import_koishi.Schema.number().min(0).max(1).step(0.05).default(0).description("自定义背景图在整行背景区域的不透明度。")
}).description("样式定制"),
import_koishi.Schema.object({
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为不限制。")
}).description("上传限制"),
// 背景设置(条件化)
import_koishi.Schema.intersect([
import_koishi.Schema.object({
backgroundType: import_koishi.Schema.union([
import_koishi.Schema.const("none").description("默认渐变"),
import_koishi.Schema.const("api").description("API 获取"),
import_koishi.Schema.const("css").description("自定义 CSS")
]).default("none").description("图片整体背景类型。")
}),
import_koishi.Schema.union([
import_koishi.Schema.object({
backgroundType: import_koishi.Schema.const("api").required(),
apiBackgroundConfig: import_koishi.Schema.object({
apiUrl: import_koishi.Schema.string().description("获取背景图的 API 地址。").required(),
apiKey: import_koishi.Schema.string().role("secret").description("API 的访问凭证(可选)。"),
responseType: import_koishi.Schema.union(["binary", "url", "base64"]).default("binary").description("API 返回的数据类型。")
}).description("API 背景配置").collapse()
}),
import_koishi.Schema.object({
backgroundType: import_koishi.Schema.const("css").required(),
backgroundValue: import_koishi.Schema.string().role("textarea", { rows: [2, 4] }).default(
`html {
background: linear-gradient(135deg, #f6f8f9 0%, #e5ebee 100%);
}`
).description(
"自定义背景的 CSS 代码。建议使用 `html` 选择器来设置背景。"
)
}),
import_koishi.Schema.object({})
])
]).description("背景设置"),
import_koishi.Schema.object({
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' 目录中的字体文件名(不含后缀),或使用通用字体名称。`
)
}).description("字体设置")
]),
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.intersect([
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(
"其他定时发送今日排行榜的时间点(24小时制,如 `08:00`)。"
)
}).description("推送时机"),
import_koishi.Schema.object({
pushChannelIds: import_koishi.Schema.array(String).role("table").description("需要接收自动推送的频道 ID 列表。"),
shouldSendLeaderboardNotificationsToAllChannels: import_koishi.Schema.boolean().default(false).description(
"是否向机器人所在的所有群聊推送(可能造成打扰,请谨慎开启)。"
),
excludedLeaderboardChannels: import_koishi.Schema.array(String).role("table").description(
"当“向所有群聊推送”开启时,此处指定的频道将不会收到推送。"
)
}).description("推送目标"),
import_koishi.Schema.object({
isGeneratingRankingListPromptVisible: import_koishi.Schema.boolean().default(true).description("发送排行榜前,是否先发送一条“正在生成”的提示消息。"),
leaderboardGenerationWaitTime: import_koishi.Schema.number().min(0).default(3).description("发送提示消息后,等待多少秒再发送排行榜图片。"),
delayBetweenGroupPushesInSeconds: import_koishi.Schema.number().min(0).default(5).description(
"批量推送时,每个群之间的基础发送延迟(秒),以防风控。"
),
groupPushDelayRandomizationSeconds: import_koishi.Schema.number().min(0).default(10).description(
"在基础延迟之上,增加一个随机波动范围(秒),以模拟人工操作。"
)
}).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().min(1).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 PROCESSED = Symbol("message-counter.processed");
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`);
}
try {
const fallbackJson = await fs.readFile(
import_path.default.join(assetsDir, "fallbackBase64.json"),
"utf-8"
);
fallbackBase64 = JSON.parse(fallbackJson);
} catch (error) {
logger.warn("无法加载 fallbackBase64.json,将使用空的回退头像。", error);
}
const fontFiles = ["HarmonyOS_Sans_Medium.ttf"];
for (const fontFile of fontFiles) {
await copyAssetIfNotExists(
import_path.default.join(assetsDir, "fonts"),
fontsPath,
fontFile
);
}
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: ["channelId", "userId"]
}
);
ctx.model.extend(
"message_counter_state",
{
key: "string",
value: "timestamp"
},
{ primary: "key" }
);
const channelCtx = ctx.channel();
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("所有已安排的任务和缓存都已清除。");
});
channelCtx.middleware(async (session, next) => {
if (session[PROCESSED]) return next();
if (!session.userId || !session.channelId || session.author?.isBot && !config.isBotMessageTrackingEnabled) {
return next();
}
session[PROCESSED] = true;
const { userId, channelId, author } = session;
let sessionChannelName = session.event.channel.name;
const username = author?.nick || author?.name || userId;
const userAvatar = author?.avatar;
try {
const channelName = sessionChannelName || (channelId ? await getChannelName(session.bot, channelId) : 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
);
}
return next();
}, true);
if (config.isBotMessageTrackingEnabled) {
ctx.before("send", async (session) => {
if (!session.channelId) return;
const { channelId, bot } = 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 getChannelName(bot, channelId) || 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 channelUsers = await ctx.database.get("message_counter_records", {
channelId
});
const allUsers = await ctx.database.get("message_counter_records", {});
const channelStats = [];
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 channelTotals = accumulate(channelUsers);
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");
channelStats.push({
label: "昨日",
count: targetUserRecord[0].yesterdayPostCount,
total: channelTotals.yesterdayPostCount,
rank: getRank(channelUsers, "yesterdayPostCount", userId),
enabled: selectedOptions.yesterday
});
channelStats.push({
label: "今日",
count: targetUserRecord[0].todayPostCount,
total: channelTotals.todayPostCount,
rank: getRank(channelUsers, "todayPostCount", userId),
enabled: selectedOptions.day
});
channelStats.push({
label: "本周",
count: targetUserRecord[0].thisWeekPostCount,
total: channelTotals.thisWeekPostCount,
rank: getRank(channelUsers, "thisWeekPostCount", userId),
enabled: selectedOptions.week
});
channelStats.push({
label: "本月",
count: targetUserRecord[0].thisMonthPostCount,
total: channelTotals.thisMonthPostCount,
rank: getRank(channelUsers, "thisMonthPostCount", userId),
enabled: selectedOptions.month
});
channelStats.push({
label: "全年",
count: targetUserRecord[0].thisYearPostCount,
total: channelTotals.thisYearPostCount,
rank: getRank(channelUsers, "thisYearPostCount", userId),
enabled: selectedOptions.year
});
channelStats.push({
label: "总计",
count: targetUserRecord[0].totalPostCount,
total: channelTotals.totalPostCount,
rank: getRank(channelUsers, "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 channelTable = formatStatsTable("群发言", channelStats);
const acrossTable = formatStatsTable("跨群发言", acrossStats);
const body = [channelTable, 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 = isAcrossChannel(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 "清理过程中发生错误,请查看控制台日志。";
}
});
async function patchAndGetUsableFontPath(filePath) {
let buffer;
try {
buffer = await fs.readFile(filePath);
} catch (readError) {
logger.warn(
`读取字体文件 "${import_path.default.basename(filePath)}" 失败,已跳过。错误: ${readError.message}`
);
return filePath;
}
if (buffer.length < 12) return filePath;
const numTables = buffer.readUInt16BE(4);
for (let i = 0; i < numTables; i++) {
const recordOffset = 12 + i * 16;
if (recordOffset + 16 > buffer.length) break;
if (buffer.toString("ascii", recordOffset, recordOffset + 4) === "vhea") {
const tableOffset = buffer.readUInt32BE(recordOffset + 8);
if (tableOffset + 4 > buffer.length) break;
const version = buffer.readUInt32BE(tableOffset);
const INCORRECT_VERSION = 65537;
if (version === INCORRECT_VERSION) {
const parsedPath = import_path.default.parse(filePath);
const patchedFilename = `${parsedPath.name}-patched${parsedPath.ext}`;
const patchedFilePath = import_path.default.join(parsedPath.dir, patchedFilename);
try {
await fs.access(patchedFilePath);
return patchedFilePath;
} catch (e) {
logger.info(
`检测到字体 "${import_path.default.basename(
filePath
)}" 不规范,正在创建修复版本 "${patchedFilename}"...`
);
const CORRECT_VERSION = 65536;
buffer.writeUInt32BE(CORRECT_VERSION, tableOffset);
try {
await fs.writeFile(patchedFilePath, buffer);
logger.success(
`已成功创建修复后的字体文件 "${patchedFilename}"。`
);
return patchedFilePath;
} catch (writeError) {
logger.warn(`创建修复字体副本失败: ${writeError.message}`);
return filePath;
}
}
}
break;
}
}
return filePath;
}
__name(patchAndGetUsableFontPath, "patchAndGetUsableFontPath");
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 guildChannelIdMap = /* @__PURE__ */ new Map();
const lookupChannelIdMap = /* @__PURE__ */ new Map();
const isDirectChannelId = /* @__PURE__ */ __name((id) => {
const sepIdx = id.indexOf(":");
const unprefixed = sepIdx !== -1 ? id.substring(sepIdx + 1) : id;
return id.startsWith("private:") || unprefixed.startsWith("private:") || unprefixed.startsWith("private_");
}, "isDirectChannelId");
try {
const channelListPromises = ctx.bots.map(async (bot) => {
if (!bot.online || !bot.getGuildList) return [];
try {
let next;
do {
const result = await bot.getGuildList(next);
if (!result || !result.data) {
break;
}
if (Array.isArray(result.data)) {
result.data.forEach((channel) => {
if (isDirectChannelId(channel.id)) return;
if (!guildChannelIdMap.has(channel.id)) {
const prefixed = `${bot.platform}:${channel.id}`;
guildChannelIdMap.set(channel.id, prefixed);
lookupChannelIdMap.set(channel.id, prefixed);
}
});
}
next = result.next;
} while (next);
} catch (error) {
logger.warn(
`[自动推送] 机器人 ${bot.platform} 获取群聊列表失败,将尝试使用数据库记录回退: ${error.message}`
);
}
});
await Promise.all(channelListPromises);
} catch (error) {
logger.error("[自动推送] 获取群聊列表的主流程发生错误:", error);
}
try {
const allRecords = await ctx.database.get("message_counter_records", {}, [
"channelId"
]);
const dbChannelIds = new Set(allRecords.map((r) => r.channelId));
const activeBot = ctx.bots.find((b) => b.status === 1);
for (const dbCid of dbChannelIds) {
if (isDirectChannelId(dbCid)) continue;
if (!lookupChannelIdMap.has(dbCid) && activeBot) {
lookupChannelIdMap.set(dbCid, `${activeBot.platform}:${dbCid}`);
}
}
} catch (dbError) {
logger.warn("[自动推送] 读取数据库记录进行回退时出错:", dbError);
}
const targetChannels = /* @__PURE__ */ new Set();
for (const channelId of config.pushChannelIds || []) {
if (channelId.includes(":")) {
targetChannels.add(channelId);
} else if (lookupChannelIdMap.has(channelId)) {
targetChannels.add(lookupChannelIdMap.get(channelId));
} else {
const activeBot = ctx.bots.find((b) => b.status === 1);
if (activeBot) {
targetChannels.add(`${activeBot.platform}:${channelId}`);
} else {
logger.warn(
`[自动推送] 无法处理配置的频道 ID: ${channelId},未找到在线 Bot。`
);
}
}
}
if (config.shouldSendLeaderboardNotificationsToAllChannels) {
guildChannelIdMap.forEach((prefixedId) => targetChannels.add(prefixedId));
}
const excluded = new Set(config.excludedLeaderboardChannels || []);
if (excluded.size > 0) {
for (const id of Array.from(targetChannels)) {
const separatorIdx = id.indexOf(":");
const unprefixedId = separatorIdx !== -1 ? id.substring(separatorIdx + 1) : id;
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) {
try {
await ctx.broadcast(
[prefixedChannelId],
`正在为本群生成${periodName}发言排行榜...`
);
} catch (e) {
}
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 channel