koishi-plugin-ets2-tools-tmp 3.4.5 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/command/ets-app/changePoint/changePointV1.js +28 -7
- package/lib/command/ets-app/pointRanking/pointRanking.js +12 -5
- package/lib/command/ets-app/queryPoint/queryPoint.js +15 -1
- package/lib/command/ets-app/queryPoint/queryPointV1.js +21 -5
- package/lib/command/ets-app/resetPassword/resetPasswordV1.js +32 -3
- package/lib/command/tmpBind.js +41 -4
- package/lib/command/tmpDlcMap.js +1 -1
- package/lib/command/tmpFootprint.js +3 -2
- package/lib/command/tmpMileageRanking.js +74 -23
- package/lib/command/tmpPosition.js +3 -2
- package/lib/command/tmpQuery/tmpQuery.js +70 -1
- package/lib/command/tmpQuery/tmpQueryImg.js +4 -3
- package/lib/command/tmpQuery/tmpQueryText.js +4 -3
- package/lib/command/tmpServer/tmpServer.js +47 -3
- package/lib/command/tmpServer/tmpServerImg.js +1 -1
- package/lib/command/tmpTraffic/tmpTraffic.js +64 -3
- package/lib/command/tmpTraffic/tmpTrafficMap.js +2 -2
- package/lib/command/tmpVersion.js +81 -9
- package/lib/command/tmpVersionCheck.js +53 -10
- package/lib/command/tmpVtcMileageRanking.js +75 -22
- package/lib/command/tmpVtcOnline/tmpVtcOnlineImg.js +1 -1
- package/lib/database/guildBind.js +150 -1
- package/lib/database/model.js +12 -0
- package/lib/index.js +139 -15
- package/lib/resource/command-suggest.html +222 -0
- package/lib/resource/tmp-version.html +292 -0
- package/lib/util/commandSuggest.js +140 -0
- package/lib/util/commandSuggestMiddleware.js +210 -0
- package/lib/util/localImageHost.js +219 -0
- package/lib/util/messageRender.js +234 -0
- package/lib/util/qqMarkdown.js +171 -0
- package/lib/util/qqMediaUpload.js +165 -0
- package/lib/util/qqOfficial.js +233 -0
- package/package.json +1 -1
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
const tmpServerText = require("./tmpServerText");
|
|
2
2
|
const tmpServerImg = require("./tmpServerImg");
|
|
3
|
+
const messageRender = require("../../util/messageRender");
|
|
4
|
+
const qqOfficial = require("../../util/qqOfficial");
|
|
5
|
+
const qqMediaUpload = require("../../util/qqMediaUpload");
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
|
-
*
|
|
8
|
+
* 查询服务器列表。
|
|
9
|
+
*
|
|
10
|
+
* 输出包装:QQ 官方机器人下把文本结果包成 qq:markdown 并附加按钮;
|
|
11
|
+
* onebot 原样返回,行为与以前完全一致。
|
|
5
12
|
*/
|
|
6
|
-
module.exports = async (ctx, cfg, game) => {
|
|
13
|
+
module.exports = async (ctx, cfg, session, game) => {
|
|
14
|
+
const result = await queryRaw(ctx, cfg, session, game);
|
|
15
|
+
|
|
16
|
+
if (!qqOfficial.isQQOfficial(session) || result == null) {
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
return await decorate(ctx, cfg, session, result, game);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** 按配置分发(保持原有逻辑不变) */
|
|
23
|
+
async function queryRaw(ctx, cfg, session, game) {
|
|
7
24
|
switch (cfg.tmpServer?.type) {
|
|
8
25
|
case 1:
|
|
9
26
|
return await tmpServerText(ctx, game);
|
|
@@ -12,4 +29,31 @@ module.exports = async (ctx, cfg, game) => {
|
|
|
12
29
|
default:
|
|
13
30
|
return '指令配置错误';
|
|
14
31
|
}
|
|
15
|
-
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function decorate(ctx, cfg, session, result, game) {
|
|
35
|
+
// 图片元素:图片 + 按钮
|
|
36
|
+
if (typeof result === 'object' && result.type && result.type !== 'text') {
|
|
37
|
+
return await messageRender.renderImage(session, result, {
|
|
38
|
+
buttons: buildButtons(game),
|
|
39
|
+
uploader: await messageRender.buildUploader(ctx, cfg, session),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const text = typeof result === 'string' ? result : String(result);
|
|
43
|
+
const isError = /失败|错误|不正确/.test(text);
|
|
44
|
+
return await messageRender.renderText(session, text, {
|
|
45
|
+
buttons: isError ? [] : buildButtons(game),
|
|
46
|
+
markdown: true,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 服务器结果下方按钮:在欧卡/美卡之间快速切换 */
|
|
51
|
+
function buildButtons(game) {
|
|
52
|
+
const isAts = game === 'ATS';
|
|
53
|
+
return [
|
|
54
|
+
[
|
|
55
|
+
qqOfficial.inputButton(isAts ? '看欧卡' : '看美卡', isAts ? '欧卡服务器' : '美卡服务器', true),
|
|
56
|
+
qqOfficial.inputButton('路况', '路况 s1'),
|
|
57
|
+
],
|
|
58
|
+
];
|
|
59
|
+
}
|
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
const tmpTrafficMap = require("./tmpTrafficMap");
|
|
2
2
|
const tmpTrafficText = require("./tmpTrafficText");
|
|
3
|
+
const messageRender = require("../../util/messageRender");
|
|
4
|
+
const qqOfficial = require("../../util/qqOfficial");
|
|
5
|
+
const qqMediaUpload = require("../../util/qqMediaUpload");
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
|
-
*
|
|
8
|
+
* 查询路况。
|
|
9
|
+
*
|
|
10
|
+
* 输出包装:QQ 官方机器人下把文本结果包成 qq:markdown 并附加按钮;
|
|
11
|
+
* onebot 原样返回,行为与以前完全一致。
|
|
5
12
|
*/
|
|
6
|
-
module.exports = async (ctx, cfg, serverName) => {
|
|
13
|
+
module.exports = async (ctx, cfg, session, serverName) => {
|
|
14
|
+
const result = await queryRaw(ctx, cfg, serverName);
|
|
15
|
+
|
|
16
|
+
if (!qqOfficial.isQQOfficial(session) || result == null) {
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
return await decorate(ctx, cfg, session, result, serverName);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** 按配置分发(保持原有逻辑不变) */
|
|
23
|
+
async function queryRaw(ctx, cfg, serverName) {
|
|
7
24
|
switch (cfg.tmpTraffic?.type) {
|
|
8
25
|
case 1:
|
|
9
26
|
return await tmpTrafficText(ctx, cfg, serverName);
|
|
@@ -12,4 +29,48 @@ module.exports = async (ctx, cfg, serverName) => {
|
|
|
12
29
|
default:
|
|
13
30
|
return '指令配置错误';
|
|
14
31
|
}
|
|
15
|
-
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function decorate(ctx, cfg, session, result, serverName) {
|
|
35
|
+
// 图片(热力图):图片 + 按钮
|
|
36
|
+
if (typeof result === 'object' && result.type && result.type !== 'text') {
|
|
37
|
+
return await messageRender.renderImage(session, result, {
|
|
38
|
+
buttons: buildButtons(serverName),
|
|
39
|
+
uploader: await messageRender.buildUploader(ctx, cfg, session),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const text = typeof result === 'string' ? result : String(result);
|
|
43
|
+
const isError = /失败|错误|不正确|请输入/.test(text);
|
|
44
|
+
return await messageRender.renderText(session, text, {
|
|
45
|
+
buttons: isError ? [] : buildButtons(serverName),
|
|
46
|
+
markdown: true,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 常用服务器快捷切换。
|
|
52
|
+
* 注意:路况指令只认短别名(s1 / s2 / p / a / 美s ...),
|
|
53
|
+
* 按钮里必须填「路况 s1」这种真实可用的写法,不能写服务器全名。
|
|
54
|
+
*/
|
|
55
|
+
function buildButtons(serverName) {
|
|
56
|
+
const current = String(serverName || '').toLowerCase();
|
|
57
|
+
const all = [
|
|
58
|
+
{ alias: 's1', label: 'S1 路况' },
|
|
59
|
+
{ alias: 's2', label: 'S2 路况' },
|
|
60
|
+
{ alias: 'a', label: 'Arcade' },
|
|
61
|
+
{ alias: 'p', label: 'ProMods' },
|
|
62
|
+
];
|
|
63
|
+
// 当前所在服务器不再重复给出按钮
|
|
64
|
+
const quick = all
|
|
65
|
+
.filter(x => x.alias !== current)
|
|
66
|
+
.slice(0, 3)
|
|
67
|
+
.map(x => qqOfficial.inputButton(x.label, `路况 ${x.alias}`));
|
|
68
|
+
|
|
69
|
+
// 每行最多 2 个,避免手机端文字被截断
|
|
70
|
+
const rows = [];
|
|
71
|
+
for (let i = 0; i < quick.length; i += 2) {
|
|
72
|
+
rows.push(quick.slice(i, i + 2));
|
|
73
|
+
}
|
|
74
|
+
rows.push([qqOfficial.inputButton('欧卡服务器', '欧卡服务器')]);
|
|
75
|
+
return rows;
|
|
76
|
+
}
|
|
@@ -151,7 +151,7 @@ module.exports = async (ctx, cfg, serverName) => {
|
|
|
151
151
|
const element = await page.$("#container");
|
|
152
152
|
return (segment.image(await element.screenshot({
|
|
153
153
|
encoding: "binary"
|
|
154
|
-
}), "image/
|
|
154
|
+
}), "image/jpeg"));
|
|
155
155
|
}
|
|
156
156
|
catch {
|
|
157
157
|
return '渲染异常,请重试';
|
|
@@ -188,7 +188,7 @@ module.exports = async (ctx, cfg, serverName) => {
|
|
|
188
188
|
const element = await page.$("#container");
|
|
189
189
|
return (segment.image(await element.screenshot({
|
|
190
190
|
encoding: "binary"
|
|
191
|
-
}), "image/
|
|
191
|
+
}), "image/jpeg"));
|
|
192
192
|
}
|
|
193
193
|
catch {
|
|
194
194
|
return '渲染异常,请重试';
|
|
@@ -1,14 +1,86 @@
|
|
|
1
|
+
const { segment } = require("koishi");
|
|
2
|
+
const { resolve } = require("path");
|
|
3
|
+
const common = require("../util/common");
|
|
1
4
|
const truckersMpApi = require("../api/truckersMpApi");
|
|
2
|
-
|
|
5
|
+
const messageRender = require("../util/messageRender");
|
|
6
|
+
const qqOfficial = require("../util/qqOfficial");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 查询 TMP 当前版本。
|
|
10
|
+
*
|
|
11
|
+
* 输出形式由配置 tmpVersion.type 决定:1=文字,2=图片(默认)。
|
|
12
|
+
* QQ 官方机器人下会自动附加按钮;onebot 行为与原版一致。
|
|
13
|
+
*/
|
|
14
|
+
module.exports = async (ctx, cfg, session) => {
|
|
15
|
+
const type = messageRender.readType(cfg, 'tmpVersion.type');
|
|
16
|
+
// 仅图片模式需要 puppeteer
|
|
17
|
+
if (messageRender.wantImage(type) && !ctx.puppeteer) {
|
|
18
|
+
return '未启用 Puppeteer 功能';
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
// 查询版本信息
|
|
4
|
-
|
|
5
|
-
if (result.error) {
|
|
22
|
+
const result = await truckersMpApi.version(ctx.http);
|
|
23
|
+
if (result.error || !result.data) {
|
|
6
24
|
return '查询失败,请稍后再试';
|
|
7
25
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return
|
|
26
|
+
|
|
27
|
+
const data = result.data;
|
|
28
|
+
const text = buildVersionText(data);
|
|
29
|
+
const buttons = buildButtons(session);
|
|
30
|
+
|
|
31
|
+
return await messageRender.renderByType(ctx, session, cfg, 'tmpVersion.type', {
|
|
32
|
+
text,
|
|
33
|
+
buttons,
|
|
34
|
+
caption: 'TruckersMP 当前版本',
|
|
35
|
+
alt: 'TMP 版本',
|
|
36
|
+
image: async () => {
|
|
37
|
+
if (!ctx.puppeteer) return null;
|
|
38
|
+
let page;
|
|
39
|
+
try {
|
|
40
|
+
const payload = {
|
|
41
|
+
name: data.name,
|
|
42
|
+
numeric: data.numeric,
|
|
43
|
+
stage: data.stage,
|
|
44
|
+
time: data.time,
|
|
45
|
+
ets: data.supported_game_version,
|
|
46
|
+
ats: data.supported_ats_game_version,
|
|
47
|
+
};
|
|
48
|
+
const htmlPath = resolve(__dirname, '../resource/tmp-version.html');
|
|
49
|
+
|
|
50
|
+
page = await ctx.puppeteer.page();
|
|
51
|
+
await page.setViewport({ width: 560, height: 700, deviceScaleFactor: 2 });
|
|
52
|
+
await page.goto(`file:///${htmlPath}`);
|
|
53
|
+
await page.evaluate(`setData(${JSON.stringify(payload)})`);
|
|
54
|
+
await common.sleep(100);
|
|
55
|
+
await page.waitForNetworkIdle();
|
|
56
|
+
|
|
57
|
+
const element = await page.$("#container");
|
|
58
|
+
if (!element) return null;
|
|
59
|
+
return segment.image(await element.screenshot({ encoding: "binary" }), "image/jpeg");
|
|
60
|
+
} catch (e) {
|
|
61
|
+
ctx.logger.warn(`[tmp版本] 渲染失败,回退文字: ${e.message}`);
|
|
62
|
+
return null;
|
|
63
|
+
} finally {
|
|
64
|
+
if (page) {
|
|
65
|
+
try { await page.close(); } catch { }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
14
70
|
};
|
|
71
|
+
|
|
72
|
+
/** 文字版(图片渲染失败或配置为文字时使用) */
|
|
73
|
+
function buildVersionText(data) {
|
|
74
|
+
return `TMP版本:${data.name}\n` +
|
|
75
|
+
`欧卡支持版本: ${data.supported_game_version}\n` +
|
|
76
|
+
`美卡支持版本: ${data.supported_ats_game_version}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 官方机器人下的快捷按钮 */
|
|
80
|
+
function buildButtons(session) {
|
|
81
|
+
if (!qqOfficial.isQQOfficial(session)) return [];
|
|
82
|
+
return [
|
|
83
|
+
[qqOfficial.inputButton('刷新版本', 'tmp版本', true)],
|
|
84
|
+
[qqOfficial.inputButton('欧卡服务器', '欧卡服务器'), qqOfficial.inputButton('规则查询', '规则查询')],
|
|
85
|
+
];
|
|
86
|
+
}
|
|
@@ -18,6 +18,7 @@ class VersionCheckService {
|
|
|
18
18
|
this.currentVersion = null;
|
|
19
19
|
// 上一次检查时的文件清单快照(FilePath -> {md5, type}),用于比对文件变化
|
|
20
20
|
this.fileManifest = null;
|
|
21
|
+
this.disposed = false;
|
|
21
22
|
this.timers = [];
|
|
22
23
|
this.logger = this.initLogger();
|
|
23
24
|
}
|
|
@@ -53,18 +54,51 @@ class VersionCheckService {
|
|
|
53
54
|
|
|
54
55
|
setupVersionCheck() {
|
|
55
56
|
this.logger.info("设置TMP版本检查服务");
|
|
56
|
-
|
|
57
|
-
this.logger.debug("启动时立即检查版本");
|
|
58
|
-
this.checkVersion();
|
|
59
57
|
|
|
60
58
|
const checkInterval = this.cfg.checkInterval * 60 * 1000;
|
|
61
59
|
this.logger.info(`设置定时版本检查,间隔: ${this.cfg.checkInterval} 分钟`);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
|
|
61
|
+
// 顺序很重要:先建立文件清单基线,再开始版本检查。
|
|
62
|
+
// 否则第一次真发生版本更新时 fileManifest 仍是 null,只能退化成“输出完整列表”。
|
|
63
|
+
this.runInitialCheck()
|
|
64
|
+
.catch((error) => {
|
|
65
|
+
this.logger.error("启动版本检查失败:", error.message);
|
|
66
|
+
})
|
|
67
|
+
.finally(() => {
|
|
68
|
+
// 初始化失败也要把定时器挂上,否则整个功能就停摆了
|
|
69
|
+
if (this.disposed) return;
|
|
70
|
+
const intervalTimer = setInterval(async () => {
|
|
71
|
+
this.logger.debug("执行定时版本检查");
|
|
72
|
+
await this.checkVersion();
|
|
73
|
+
}, checkInterval);
|
|
74
|
+
this.timers.push(intervalTimer);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 启动时的初始化:先拉文件清单做基线,再做第一次版本检查。
|
|
80
|
+
*/
|
|
81
|
+
async runInitialCheck() {
|
|
82
|
+
await this.initBaselineManifest();
|
|
83
|
+
|
|
84
|
+
this.logger.debug("启动时立即检查版本");
|
|
85
|
+
await this.checkVersion();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 建立文件清单基线(FilePath -> {md5, type})。
|
|
90
|
+
*
|
|
91
|
+
* files.json 只有“当前版本”的清单、没有历史归档,所以必须在启动时就把当前清单存下来,
|
|
92
|
+
* 下次版本更新时才能逐文件比对 MD5。基线建立失败不影响功能,只是首次更新会退化成完整列表。
|
|
93
|
+
*/
|
|
94
|
+
async initBaselineManifest() {
|
|
95
|
+
const manifest = await this.fetchFileManifest();
|
|
96
|
+
if (manifest && manifest.size > 0) {
|
|
97
|
+
this.fileManifest = manifest;
|
|
98
|
+
this.logger.info(`已建立文件清单基线,共 ${manifest.size} 个文件(版本更新时可精确比对 MD5)`);
|
|
99
|
+
} else {
|
|
100
|
+
this.logger.warn("启动时未能建立文件清单基线,首次版本更新将输出完整文件列表");
|
|
101
|
+
}
|
|
68
102
|
}
|
|
69
103
|
|
|
70
104
|
async checkVersion() {
|
|
@@ -88,12 +122,20 @@ class VersionCheckService {
|
|
|
88
122
|
|
|
89
123
|
this.logger.info(`获取到TMP版本信息: ${versionInfo.name} (${versionInfo.time})`);
|
|
90
124
|
|
|
125
|
+
// 基线可能因启动时网络抖动没建立成功,这里顺带补一次(失败也不影响本次检查)
|
|
126
|
+
if (!this.fileManifest) {
|
|
127
|
+
await this.initBaselineManifest();
|
|
128
|
+
}
|
|
129
|
+
|
|
91
130
|
if (this.currentVersion && this.currentVersion.name !== versionInfo.name) {
|
|
92
131
|
this.logger.info(`检测到版本更新: ${this.currentVersion.name} -> ${versionInfo.name}`);
|
|
93
132
|
|
|
94
133
|
// 比对文件 MD5,找出本次更新实际替换了哪些文件
|
|
134
|
+
// 返回 null 表示清单查询失败(区别于“没有变化”的空数组)
|
|
95
135
|
const changedFiles = await this.detectChangedFiles();
|
|
96
|
-
if (changedFiles
|
|
136
|
+
if (changedFiles === null) {
|
|
137
|
+
this.logger.warn("文件清单获取失败,本次更新通知不包含文件变化");
|
|
138
|
+
} else if (changedFiles.length > 0) {
|
|
97
139
|
this.logger.info(`本次更新涉及 ${changedFiles.length} 个文件变化`);
|
|
98
140
|
}
|
|
99
141
|
|
|
@@ -425,6 +467,7 @@ class VersionCheckService {
|
|
|
425
467
|
|
|
426
468
|
cleanup() {
|
|
427
469
|
this.logger.debug("插件卸载,开始清理版本检查服务资源");
|
|
470
|
+
this.disposed = true;
|
|
428
471
|
this.timers.forEach((timer) => {
|
|
429
472
|
clearTimeout(timer);
|
|
430
473
|
clearInterval(timer);
|
|
@@ -4,8 +4,12 @@ const common = require('../util/common');
|
|
|
4
4
|
const evmOpenApi = require('../api/evmOpenApi');
|
|
5
5
|
const guildBind = require('../database/guildBind');
|
|
6
6
|
const tmpIdPicker = require('../util/tmpIdPicker');
|
|
7
|
+
const messageRender = require('../util/messageRender');
|
|
8
|
+
const qqOfficial = require('../util/qqOfficial');
|
|
7
9
|
module.exports = async (ctx, cfg, session, rankingType, target) => {
|
|
8
|
-
|
|
10
|
+
// 仅在图片模式下才需要 puppeteer;文本模式不拦截
|
|
11
|
+
const wantImage = messageRender.wantImage(messageRender.readType(cfg, 'tmpVtcMileageRanking.type'));
|
|
12
|
+
if (wantImage && !ctx.puppeteer) {
|
|
9
13
|
return '未启用 Puppeteer 功能';
|
|
10
14
|
}
|
|
11
15
|
const vtcId = cfg.tmpActivityService?.api?.vtcId;
|
|
@@ -51,26 +55,75 @@ module.exports = async (ctx, cfg, session, rankingType, target) => {
|
|
|
51
55
|
mileageRankingList: mileageRankingList.data,
|
|
52
56
|
playerMileageRanking: playerMileageRanking
|
|
53
57
|
};
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
58
|
+
|
|
59
|
+
const buttons = buildRankingButtons(session, cfg, rankingType);
|
|
60
|
+
const text = buildRankingText(data);
|
|
61
|
+
|
|
62
|
+
// 按配置选择文字/图片(QQ 官方机器人会自动附带按钮)
|
|
63
|
+
return await messageRender.renderByType(ctx, session, cfg, 'tmpVtcMileageRanking.type', {
|
|
64
|
+
text,
|
|
65
|
+
buttons,
|
|
66
|
+
caption: '车队' + (Number(rankingType) === 2 ? '今日' : '总') + '里程排行榜',
|
|
67
|
+
alt: '里程排行榜',
|
|
68
|
+
image: async () => {
|
|
69
|
+
if (!ctx.puppeteer) return null;
|
|
70
|
+
let page;
|
|
71
|
+
try {
|
|
72
|
+
page = await ctx.puppeteer.page();
|
|
73
|
+
await page.setViewport({ width: 1000, height: 1000, deviceScaleFactor: 2 });
|
|
74
|
+
await page.goto(`file:///${resolve(__dirname, '../resource/mileage-leaderboard.html')}`);
|
|
75
|
+
await page.evaluate(`setData(${JSON.stringify(data)})`);
|
|
76
|
+
await page.waitForNetworkIdle();
|
|
77
|
+
await common.sleep(500);
|
|
78
|
+
const element = await page.$("#container");
|
|
79
|
+
return segment.image(await element.screenshot({ encoding: "binary" }), "image/jpeg");
|
|
80
|
+
} catch (e) {
|
|
81
|
+
console.info(e);
|
|
82
|
+
return null;
|
|
83
|
+
} finally {
|
|
84
|
+
if (page) {
|
|
85
|
+
try { await page.close(); } catch { }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
74
88
|
}
|
|
75
|
-
}
|
|
89
|
+
});
|
|
76
90
|
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 排行榜按钮(仅 QQ 官方机器人可见,onebot 自动忽略)。
|
|
94
|
+
*/
|
|
95
|
+
function buildRankingButtons(session, cfg, rankingType) {
|
|
96
|
+
if (!qqOfficial.isQQOfficial(session)) return [];
|
|
97
|
+
const rows = [];
|
|
98
|
+
const isToday = Number(rankingType) === 2;
|
|
99
|
+
if (isToday) {
|
|
100
|
+
rows.push([qqOfficial.inputButton('查看总榜', 'vtc里程排行榜', true)]);
|
|
101
|
+
} else {
|
|
102
|
+
rows.push([qqOfficial.inputButton('查看今日榜', 'vtc今日里程排行榜', true)]);
|
|
103
|
+
}
|
|
104
|
+
rows.push([qqOfficial.inputButton('我的绑定', '我的绑定')]);
|
|
105
|
+
return rows;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 排行榜文字版(图片不可用或配置为文字时使用)。
|
|
110
|
+
*/
|
|
111
|
+
function buildRankingText(data) {
|
|
112
|
+
const typeText = Number(data.rankingType) === 2 ? '今日' : '总';
|
|
113
|
+
const list = Array.isArray(data.mileageRankingList) ? data.mileageRankingList : [];
|
|
114
|
+
const lines = [`## 车队${typeText}里程排行榜`, ''];
|
|
115
|
+
list.slice(0, 10).forEach((p, i) => {
|
|
116
|
+
lines.push(`${i + 1}. ${p.name || '未知'} ${formatKm(p.mileage)}`);
|
|
117
|
+
});
|
|
118
|
+
if (data.playerMileageRanking) {
|
|
119
|
+
const me = data.playerMileageRanking;
|
|
120
|
+
const rank = list.indexOf(me) + 1;
|
|
121
|
+
lines.push('', `**你的排名**:第 ${rank || '-'} 名 ${formatKm(me.mileage)}`);
|
|
122
|
+
}
|
|
123
|
+
return lines.join('\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatKm(v) {
|
|
127
|
+
const n = Number(v);
|
|
128
|
+
return Number.isFinite(n) ? `${n.toLocaleString('en-US')} km` : '-';
|
|
129
|
+
}
|
|
@@ -37,7 +37,7 @@ module.exports = async (ctx, cfg) => {
|
|
|
37
37
|
await common.sleep(100);
|
|
38
38
|
await page.waitForNetworkIdle();
|
|
39
39
|
const element = await page.$('#container');
|
|
40
|
-
return segment.image(await element.screenshot({ encoding: 'binary' }), 'image/
|
|
40
|
+
return segment.image(await element.screenshot({ encoding: 'binary' }), 'image/jpeg');
|
|
41
41
|
} catch {
|
|
42
42
|
return '渲染异常,请重试';
|
|
43
43
|
} finally {
|
|
@@ -66,6 +66,50 @@ module.exports = {
|
|
|
66
66
|
}
|
|
67
67
|
return guildBindList.sort((a, b) => a.id - b.id);
|
|
68
68
|
},
|
|
69
|
+
/**
|
|
70
|
+
* 获取绑定列表,**带跨群兜底**(QQ 官方机器人专用能力)。
|
|
71
|
+
*
|
|
72
|
+
* 背景:QQ 官方机器人在不同群下发的 openid 可能不同,
|
|
73
|
+
* 所以 (platform, user_id) 查不到时,再用 union_id 兜一次 —— 用户只要在任意一个群绑定过,
|
|
74
|
+
* 换群后依然能查到自己的绑定。
|
|
75
|
+
*
|
|
76
|
+
* onebot 完全不受影响:
|
|
77
|
+
* - onebot 的 session 没有 unionId,`identity` 传 null,走的就是原 list();
|
|
78
|
+
* - 原有 `list()` 保持不变,仍然按 (platform, user_id) 精确查。
|
|
79
|
+
*
|
|
80
|
+
* @param {object} db 数据源
|
|
81
|
+
* @param {string} platform 平台
|
|
82
|
+
* @param {string} userId 用户编号(onebot=QQ号,qq=openid)
|
|
83
|
+
* @param {object} [identity] QQ 官方身份 {openid, unionId};onebot 传 null/不传
|
|
84
|
+
* @returns 绑定行数组(按 id 排序)
|
|
85
|
+
*/
|
|
86
|
+
async listWithUnion(db, platform, userId, identity) {
|
|
87
|
+
const own = await this.list(db, platform, userId);
|
|
88
|
+
if (own.length > 0) {
|
|
89
|
+
return own;
|
|
90
|
+
}
|
|
91
|
+
// 没有 union 身份(onebot / 未申请权限)→ 与 list() 完全一致,不产生额外查询
|
|
92
|
+
const unionId = identity && identity.unionId;
|
|
93
|
+
if (!unionId) {
|
|
94
|
+
return own;
|
|
95
|
+
}
|
|
96
|
+
const crossRows = await this.listByUnionId(db, unionId);
|
|
97
|
+
if (crossRows.length === 0) {
|
|
98
|
+
return own;
|
|
99
|
+
}
|
|
100
|
+
// 只取同一平台的,避免 qq 与 onebot 的数据互相串
|
|
101
|
+
return crossRows
|
|
102
|
+
.filter(r => String(r.platform) === String(platform))
|
|
103
|
+
.sort((a, b) => a.id - b.id);
|
|
104
|
+
},
|
|
105
|
+
/**
|
|
106
|
+
* 获取默认绑定,带跨群兜底(语义同 listWithUnion)。
|
|
107
|
+
*/
|
|
108
|
+
async getWithUnion(db, platform, userId, identity) {
|
|
109
|
+
const rows = await this.listWithUnion(db, platform, userId, identity);
|
|
110
|
+
if (rows.length === 0) return null;
|
|
111
|
+
return rows.find(r => this.isDefault(r)) || rows[0];
|
|
112
|
+
},
|
|
69
113
|
/**
|
|
70
114
|
* 获取指定用户(可含平台前缀,如 qq:123)的全部绑定。
|
|
71
115
|
* 用于 @别人 查询:内部统一去掉 `qq:` 这类前缀再匹配,兼容库里两种存法。
|
|
@@ -127,6 +171,31 @@ module.exports = {
|
|
|
127
171
|
const result = await this.setDefault(ctx, db, platform, userId, rows[idx - 1].id);
|
|
128
172
|
return { ...result, total: rows.length };
|
|
129
173
|
},
|
|
174
|
+
/**
|
|
175
|
+
* 按序号设为默认(跨群安全版):直接用「已查到的行」,不再按 (platform,user_id) 反查。
|
|
176
|
+
* @param rows 调用方已查到的绑定行(顺序即展示顺序)
|
|
177
|
+
*/
|
|
178
|
+
async setDefaultByIndexFromRows(db, rows, index) {
|
|
179
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
180
|
+
const idx = parseInt(index, 10);
|
|
181
|
+
if (isNaN(idx) || idx < 1 || idx > list.length) {
|
|
182
|
+
return { row: null, changed: false, total: list.length };
|
|
183
|
+
}
|
|
184
|
+
const target = list[idx - 1];
|
|
185
|
+
if (this.isDefault(target)) {
|
|
186
|
+
return { row: target, changed: false, total: list.length };
|
|
187
|
+
}
|
|
188
|
+
// 同一批行里其他默认标记清掉,保证「至多一个默认」
|
|
189
|
+
await Promise.all(list
|
|
190
|
+
.filter(r => r.id !== target.id && this.isDefault(r))
|
|
191
|
+
.map(r => db.set('tmp_guild_bind', r.id, { is_default: 0 })));
|
|
192
|
+
await db.set('tmp_guild_bind', target.id, { is_default: 1 });
|
|
193
|
+
target.is_default = 1;
|
|
194
|
+
for (const r of list) {
|
|
195
|
+
if (r.id !== target.id) r.is_default = 0;
|
|
196
|
+
}
|
|
197
|
+
return { row: target, changed: true, total: list.length };
|
|
198
|
+
},
|
|
130
199
|
/**
|
|
131
200
|
* 追加绑定(同一 tmpId 重复绑定时不会重复入库)
|
|
132
201
|
* 第一个绑定自动成为默认绑定。
|
|
@@ -134,10 +203,17 @@ module.exports = {
|
|
|
134
203
|
* @param tmpName TMP 玩家名称(用于多绑定列表展示,可为 null)
|
|
135
204
|
* @returns { exists, row, isDefault } exists=true 表示该 tmpId 已绑定过
|
|
136
205
|
*/
|
|
137
|
-
|
|
206
|
+
/**
|
|
207
|
+
* 新增绑定。
|
|
208
|
+
* @param {object} [identity] QQ 官方机器人身份 {openid, unionId};
|
|
209
|
+
* 传了就会一并写入 openid / union_id 列(onebot 下不传,保持为 null)
|
|
210
|
+
*/
|
|
211
|
+
async add(db, platform, userId, tmpId, tmpName, identity) {
|
|
138
212
|
const guildBindList = await this.list(db, platform, userId);
|
|
139
213
|
const existed = guildBindList.find(row => String(row.tmp_id) === String(tmpId));
|
|
140
214
|
if (existed) {
|
|
215
|
+
// 已绑定过:顺带补齐 openid / union_id(老数据升级路径)
|
|
216
|
+
await this.fillIdentity(db, existed, identity);
|
|
141
217
|
return { exists: true, row: existed, isDefault: this.isDefault(existed) };
|
|
142
218
|
}
|
|
143
219
|
// 还没有任何默认绑定(含首次绑定、以及历史数据全无标记)时,新绑定即默认
|
|
@@ -145,12 +221,85 @@ module.exports = {
|
|
|
145
221
|
const row = await db.create('tmp_guild_bind', {
|
|
146
222
|
platform: platform,
|
|
147
223
|
user_id: userId,
|
|
224
|
+
openid: (identity && identity.openid) || null,
|
|
225
|
+
union_id: (identity && identity.unionId) || null,
|
|
148
226
|
tmp_id: tmpId,
|
|
149
227
|
tmp_name: tmpName || null,
|
|
150
228
|
is_default: needDefault ? 1 : 0
|
|
151
229
|
});
|
|
152
230
|
return { exists: false, row, isDefault: needDefault };
|
|
153
231
|
},
|
|
232
|
+
/**
|
|
233
|
+
* 给已有绑定补齐 openid / union_id(只在拿到新值且与库里不同时才写)。
|
|
234
|
+
*/
|
|
235
|
+
async fillIdentity(db, row, identity) {
|
|
236
|
+
if (!row || !identity) return row;
|
|
237
|
+
const patch = {};
|
|
238
|
+
if (identity.openid && row.openid !== identity.openid) patch.openid = identity.openid;
|
|
239
|
+
if (identity.unionId && row.union_id !== identity.unionId) patch.union_id = identity.unionId;
|
|
240
|
+
if (Object.keys(patch).length === 0) return row;
|
|
241
|
+
await db.set('tmp_guild_bind', row.id, patch);
|
|
242
|
+
Object.assign(row, patch);
|
|
243
|
+
return row;
|
|
244
|
+
},
|
|
245
|
+
/**
|
|
246
|
+
* 按 union_id 查全部绑定(跨群统一身份)。
|
|
247
|
+
*
|
|
248
|
+
* 场景:QQ 官方机器人在不同群下发的 openid 可能不同,但 union_id 一致。
|
|
249
|
+
* 用户在 A 群绑定过,去 B 群用同一个 union_id 就能直接认出来。
|
|
250
|
+
*
|
|
251
|
+
* @returns 绑定行数组,可能为空数组
|
|
252
|
+
*/
|
|
253
|
+
async listByUnionId(db, unionId) {
|
|
254
|
+
if (!unionId) return [];
|
|
255
|
+
const rows = await db.get('tmp_guild_bind', { union_id: String(unionId) });
|
|
256
|
+
return rows || [];
|
|
257
|
+
},
|
|
258
|
+
/**
|
|
259
|
+
* 按 openid 查绑定(同群内身份)。
|
|
260
|
+
*/
|
|
261
|
+
async listByOpenid(db, openid) {
|
|
262
|
+
if (!openid) return [];
|
|
263
|
+
const rows = await db.get('tmp_guild_bind', { openid: String(openid) });
|
|
264
|
+
return rows || [];
|
|
265
|
+
},
|
|
266
|
+
/**
|
|
267
|
+
* 按「已查到的绑定行」删除(跨群安全版)。
|
|
268
|
+
*
|
|
269
|
+
* 跨群场景下 user_id 是对方群的 openid,不能再用 (platform,user_id) 反查,
|
|
270
|
+
* 所以直接把「用户看到的那批行」传进来,删的正是他眼前那一条。
|
|
271
|
+
*
|
|
272
|
+
* @param rows 调用方已经查到的绑定行(顺序即展示顺序)
|
|
273
|
+
* @param id 要删除的行主键
|
|
274
|
+
* @returns { removedDefault, promoted } 语义同 remove()
|
|
275
|
+
*/
|
|
276
|
+
async removeFromRows(db, rows, id) {
|
|
277
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
278
|
+
const target = list.find(r => r.id === id);
|
|
279
|
+
const wasDefault = this.isDefault(target);
|
|
280
|
+
await db.remove('tmp_guild_bind', id);
|
|
281
|
+
if (!wasDefault) {
|
|
282
|
+
return { removedDefault: false, promoted: null };
|
|
283
|
+
}
|
|
284
|
+
const rest = list.filter(r => r.id !== id);
|
|
285
|
+
if (rest.length === 0) {
|
|
286
|
+
return { removedDefault: true, promoted: null };
|
|
287
|
+
}
|
|
288
|
+
await db.set('tmp_guild_bind', rest[0].id, { is_default: 1 });
|
|
289
|
+
rest[0].is_default = 1;
|
|
290
|
+
return { removedDefault: true, promoted: rest[0] };
|
|
291
|
+
},
|
|
292
|
+
/**
|
|
293
|
+
* 删除「已查到的绑定行」全部(跨群安全版)。
|
|
294
|
+
* @returns 实际删除的条数
|
|
295
|
+
*/
|
|
296
|
+
async removeAllFromRows(db, rows) {
|
|
297
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
298
|
+
for (const row of list) {
|
|
299
|
+
await db.remove('tmp_guild_bind', row.id);
|
|
300
|
+
}
|
|
301
|
+
return list.length;
|
|
302
|
+
},
|
|
154
303
|
/**
|
|
155
304
|
* 删除指定主键的绑定;若删掉的正是默认绑定,自动把剩下的第一条顶为默认,
|
|
156
305
|
* 避免「有绑定却没有默认」的空档。
|