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