koishi-plugin-ets2-tools-tmp 3.4.3 → 3.4.5
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/tmpActivityService/tmpActivityServiceV1.js +197 -26
- package/lib/command/tmpActivityService.js +12 -3
- package/lib/command/tmpMileageRanking.js +1 -1
- package/lib/command/tmpVersionCheck.js +278 -12
- package/lib/command/tmpVtcMileageRanking.js +1 -1
- package/lib/database/guildBind.js +16 -9
- package/lib/database/model.js +1 -1
- package/lib/index.js +16 -5
- package/lib/resource/version-update.html +706 -0
- package/lib/util/tmpIdPicker.js +4 -4
- package/package.json +1 -1
|
@@ -1,43 +1,87 @@
|
|
|
1
|
+
const evmOpenApi = require('../../api/evmOpenApi');
|
|
2
|
+
|
|
3
|
+
// serverId 在日志/消息里统一展示:数字型保持数字,其它原样返回
|
|
4
|
+
function numericOrRaw(value) {
|
|
5
|
+
const n = Number(value);
|
|
6
|
+
return Number.isFinite(n) ? n : value;
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
module.exports = {
|
|
10
|
+
/**
|
|
11
|
+
* 列表接口最多翻几页(每页 100 条)。
|
|
12
|
+
* 平台 2.0 的 /activity/info/list 默认按开始时间倒序,今日活动通常在第 1 页,
|
|
13
|
+
* 但为了不依赖排序(以及以后数据量变大),这里仍然按 totalPage 翻页,只是加个上限防止死循环。
|
|
14
|
+
*/
|
|
15
|
+
ACTIVITY_LIST_MAX_PAGE: 10,
|
|
16
|
+
ACTIVITY_LIST_PAGE_SIZE: 100,
|
|
17
|
+
|
|
2
18
|
async updateTodayActivities() {
|
|
3
19
|
try {
|
|
4
20
|
this.todayActivities = [];
|
|
5
21
|
|
|
6
22
|
const baseUrl = this.cfg.mainSettings?.url || "open.vtcm.link";
|
|
7
23
|
const token = this.cfg.mainSettings?.token || "";
|
|
8
|
-
const
|
|
9
|
-
|
|
24
|
+
const pageSize = this.ACTIVITY_LIST_PAGE_SIZE || 100;
|
|
25
|
+
const buildUrl = (page) => `https://${baseUrl}/api/activity/info/list?token=${token}&page=${page}&limit=${pageSize}&themeName=`;
|
|
26
|
+
|
|
27
|
+
this.logger.api(`请求V1车队平台API: ${buildUrl(1).replace(token, "***")}`);
|
|
10
28
|
|
|
11
29
|
const startTime = Date.now();
|
|
12
|
-
const
|
|
30
|
+
const firstResponse = await this.ctx.http.get(buildUrl(1), { timeout: 10000 });
|
|
13
31
|
const duration = Date.now() - startTime;
|
|
14
|
-
this.logger.api(`V1车队平台API响应耗时: ${duration}ms, 状态码: ${
|
|
32
|
+
this.logger.api(`V1车队平台API响应耗时: ${duration}ms, 状态码: ${firstResponse.code}`);
|
|
15
33
|
|
|
16
34
|
if (this.cfg.debug?.logApiResponses) {
|
|
17
35
|
this.logger.api("V1车队平台API响应详情:", {
|
|
18
|
-
code:
|
|
19
|
-
totalCount:
|
|
20
|
-
|
|
36
|
+
code: firstResponse.code,
|
|
37
|
+
totalCount: firstResponse.data?.totalCount,
|
|
38
|
+
totalPage: firstResponse.data?.totalPage,
|
|
39
|
+
listCount: firstResponse.data?.list?.length
|
|
21
40
|
});
|
|
22
41
|
}
|
|
23
42
|
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
43
|
+
if (firstResponse.code !== 0 || !Array.isArray(firstResponse.data?.list)) {
|
|
44
|
+
this.logger.error(`[V1活动更新] 车队平台API返回错误: ${firstResponse.msg || '未知错误'} (代码: ${firstResponse.code || '无'})`);
|
|
45
|
+
this.todayActivities = [];
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
28
48
|
|
|
29
|
-
|
|
30
|
-
|
|
49
|
+
let list = firstResponse.data.list.slice();
|
|
50
|
+
const maxPage = Math.min(Number(firstResponse.data.totalPage) || 1, this.ACTIVITY_LIST_MAX_PAGE || 10);
|
|
31
51
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
for (let page = 2; page <= maxPage; page++) {
|
|
53
|
+
try {
|
|
54
|
+
const response = await this.ctx.http.get(buildUrl(page), { timeout: 10000 });
|
|
55
|
+
if (response.code === 0 && Array.isArray(response.data?.list)) {
|
|
56
|
+
list = list.concat(response.data.list);
|
|
57
|
+
} else {
|
|
58
|
+
this.logger.warn(`[V1活动更新] 第 ${page} 页返回错误: ${response.msg || '未知错误'} (代码: ${response.code || '无'})`);
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
// 翻页失败不影响第 1 页结果,今日活动通常就在第 1 页
|
|
62
|
+
this.logger.warn(`[V1活动更新] 第 ${page} 页请求失败: ${error.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
36
65
|
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
66
|
+
const now = new Date();
|
|
67
|
+
const today = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`;
|
|
68
|
+
this.logger.debug(`[V1活动更新] 当前本地日期: ${today}, UTC日期: ${new Date().toISOString().split("T")[0]}`);
|
|
69
|
+
|
|
70
|
+
const originalCount = list.length;
|
|
71
|
+
this.logger.debug(`[V1活动更新] API返回活动总数: ${originalCount}(共 ${maxPage} 页)`);
|
|
72
|
+
|
|
73
|
+
this.todayActivities = list.filter((activity) => {
|
|
74
|
+
const activityDate = activity.startTime?.split(" ")[0];
|
|
75
|
+
return activityDate === today;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
this.logger.info(`[V1活动更新] 从车队平台找到 ${this.todayActivities.length}/${originalCount} 个今日活动`);
|
|
79
|
+
|
|
80
|
+
if (this.cfg.debug?.debugMode) {
|
|
81
|
+
for (const activity of this.todayActivities) {
|
|
82
|
+
const profile = this.resolveProfileState(activity);
|
|
83
|
+
this.logger.debug(`[V1活动更新] 今日活动 #${activity.id} "${activity.themeName}" 档位状态: ${profile.uploaded ? "已上传" : "未上传"}(来源: ${profile.source}, hasProfile=${activity.hasProfile}, profileFile=${activity.profileFile ? "有" : "空"}, state=${activity.state})`);
|
|
84
|
+
}
|
|
41
85
|
}
|
|
42
86
|
} catch (error) {
|
|
43
87
|
this.logger.error("[V1活动更新] 获取车队平台活动列表失败:", error.message);
|
|
@@ -45,6 +89,84 @@ module.exports = {
|
|
|
45
89
|
}
|
|
46
90
|
},
|
|
47
91
|
|
|
92
|
+
/**
|
|
93
|
+
* 判断活动档(profile)是否已上传。
|
|
94
|
+
*
|
|
95
|
+
* 平台 2.0 起,列表接口 /api/activity/info/list **不再返回 profileFile**(恒为 null),
|
|
96
|
+
* 改用布尔字段 hasProfile 表示档位是否已上传;只有详情接口 /api/activity/info/info/{id}
|
|
97
|
+
* 才带 profileFile。旧版本(1.0)的列表则只有 profileFile。
|
|
98
|
+
* 所以这里必须优先看 hasProfile,否则会永远判定为“未上传”。
|
|
99
|
+
*
|
|
100
|
+
* @returns {{uploaded: boolean, source: 'hasProfile'|'profileFile'|'unknown'}}
|
|
101
|
+
*/
|
|
102
|
+
resolveProfileState(activity) {
|
|
103
|
+
if (!activity) return { uploaded: false, source: 'unknown' };
|
|
104
|
+
|
|
105
|
+
// 2.0:以 hasProfile 为准(注意 false 也是有效值,不能用 || 兜底)
|
|
106
|
+
if (typeof activity.hasProfile === 'boolean') {
|
|
107
|
+
return { uploaded: activity.hasProfile, source: 'hasProfile' };
|
|
108
|
+
}
|
|
109
|
+
if (activity.hasProfile === 1 || activity.hasProfile === 0) {
|
|
110
|
+
return { uploaded: activity.hasProfile === 1, source: 'hasProfile' };
|
|
111
|
+
}
|
|
112
|
+
if (activity.hasProfile === '1' || activity.hasProfile === '0') {
|
|
113
|
+
return { uploaded: activity.hasProfile === '1', source: 'hasProfile' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 1.0:没有 hasProfile,退回 profileFile 判空
|
|
117
|
+
if ('profileFile' in activity) {
|
|
118
|
+
return { uploaded: !!activity.profileFile, source: 'profileFile' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { uploaded: false, source: 'unknown' };
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 查 TMP 服务器名(serverId → serverName)。
|
|
126
|
+
*
|
|
127
|
+
* 数据源 https://evmapi.cxnnn.cn/server/list?game=1(欧卡)。注意该接口返回的是
|
|
128
|
+
* **常驻服**(Simulation 1/2、Arcade、ProMods…),活动专属服(如 serverId=13 的
|
|
129
|
+
* "Revival Event | #26")并不在里面,此时返回 null 由调用方兜底。
|
|
130
|
+
*
|
|
131
|
+
* 一次检查只拉一次列表,结果缓存在实例上(同一次 checkAutoClockStatus 内的多个活动共用)。
|
|
132
|
+
*/
|
|
133
|
+
async getEvmServerNameMap() {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
// 5 分钟缓存:同一次检查里多个活动共用,也不至于跨天用过期数据
|
|
136
|
+
if (this._evmServerCache && now - this._evmServerCache.at < 5 * 60 * 1000) {
|
|
137
|
+
return this._evmServerCache.map;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const map = new Map();
|
|
141
|
+
let ok = false;
|
|
142
|
+
try {
|
|
143
|
+
const result = await evmOpenApi.serverList(this.ctx.http, 'ETS2');
|
|
144
|
+
if (result && !result.error && Array.isArray(result.data)) {
|
|
145
|
+
for (const server of result.data) {
|
|
146
|
+
if (server && server.serverId != null) {
|
|
147
|
+
map.set(Number(server.serverId), server.serverName);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
ok = map.size > 0;
|
|
151
|
+
if (ok) {
|
|
152
|
+
this.logger.debug(`[自动打卡检查] evm 服务器列表已加载,共 ${map.size} 个常驻服`);
|
|
153
|
+
} else {
|
|
154
|
+
this.logger.warn('[自动打卡检查] evm 服务器列表为空,无法确认服务器名称');
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
this.logger.warn('[自动打卡检查] evm 服务器列表查询失败,无法确认服务器名称');
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.logger.warn(`[自动打卡检查] evm 服务器列表查询异常: ${error.message}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 只在成功时缓存:查询失败不缓存,避免 5 分钟内一直拿空结果
|
|
164
|
+
if (ok) {
|
|
165
|
+
this._evmServerCache = { at: now, map };
|
|
166
|
+
}
|
|
167
|
+
return map;
|
|
168
|
+
},
|
|
169
|
+
|
|
48
170
|
async checkAutoClock(activity) {
|
|
49
171
|
try {
|
|
50
172
|
const baseUrl = this.cfg.mainSettings?.url || "open.vtcm.link";
|
|
@@ -60,9 +182,26 @@ module.exports = {
|
|
|
60
182
|
const serverId = response.data.serverId;
|
|
61
183
|
this.logger.debug(`活动 "${activity.themeName}" V1自动打卡状态: ${enableAutoClock}, serverId: ${serverId}`);
|
|
62
184
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
185
|
+
// 宽松判断:接口可能给数字 1 或字符串 "1"
|
|
186
|
+
if (Number(enableAutoClock) === 1) {
|
|
187
|
+
if (serverId != null && serverId !== '') {
|
|
188
|
+
const resolved = await this.resolveAutoClockServerName(serverId);
|
|
189
|
+
let message = '今日活动自动打卡已设置';
|
|
190
|
+
if (resolved.reason === 'found') {
|
|
191
|
+
message = `今日活动自动打卡已设置,打卡服务器名称:${resolved.name}`;
|
|
192
|
+
} else if (resolved.reason === 'not_found') {
|
|
193
|
+
// 查不到就如实说,不拿平台的活动名顶替(可能与实际开服不一致)
|
|
194
|
+
message = `今日活动自动打卡已设置,但未查到服务器名称(serverId: ${serverId},可能尚未开服)`;
|
|
195
|
+
} else {
|
|
196
|
+
message = `今日活动自动打卡已设置,但服务器列表查询失败,无法确认服务器名称(serverId: ${serverId})`;
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
status: 'set',
|
|
200
|
+
message,
|
|
201
|
+
serverId: numericOrRaw(serverId),
|
|
202
|
+
serverName: resolved.reason === 'found' ? resolved.name : null,
|
|
203
|
+
serverNameReason: resolved.reason
|
|
204
|
+
};
|
|
66
205
|
} else {
|
|
67
206
|
return { status: 'no_server', message: '今日活动打卡服务器未设置' };
|
|
68
207
|
}
|
|
@@ -71,11 +210,43 @@ module.exports = {
|
|
|
71
210
|
}
|
|
72
211
|
} else {
|
|
73
212
|
this.logger.error(`V1活动详情API返回错误: ${response.msg || '未知错误'} (代码: ${response.code || '无'})`);
|
|
74
|
-
return { status: '
|
|
213
|
+
return { status: 'error', message: '今日活动打卡状态查询失败(接口返回错误)' };
|
|
75
214
|
}
|
|
76
215
|
} catch (error) {
|
|
77
216
|
this.logger.error(`检查V1活动 "${activity.themeName}" 自动打卡状态失败:`, error.message);
|
|
78
|
-
return { status: '
|
|
217
|
+
return { status: 'error', message: '今日活动打卡状态查询失败(请求异常)' };
|
|
79
218
|
}
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 按 serverId 在 evm 服务器列表里查服务器名。
|
|
223
|
+
*
|
|
224
|
+
* 只认 evm 的权威数据:查不到就如实返回 reason,**绝不回退用平台活动自带的 serverName**
|
|
225
|
+
* ——平台那个字段是活动创建时填的,与实际开服选择可能不一致,拿来顶替会误导管理。
|
|
226
|
+
*
|
|
227
|
+
* @returns {{name: string|null, reason: 'found'|'not_found'|'unavailable'}}
|
|
228
|
+
*/
|
|
229
|
+
async resolveAutoClockServerName(serverId) {
|
|
230
|
+
const numericId = Number(serverId);
|
|
231
|
+
|
|
232
|
+
let map;
|
|
233
|
+
try {
|
|
234
|
+
map = await this.getEvmServerNameMap();
|
|
235
|
+
} catch (error) {
|
|
236
|
+
this.logger.warn(`[自动打卡检查] 查询服务器名失败: ${error.message}`);
|
|
237
|
+
return { name: null, reason: 'unavailable' };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 列表为空说明接口异常(正常至少有 7 个常驻服),按“查不到”处理
|
|
241
|
+
if (!map || map.size === 0) {
|
|
242
|
+
return { name: null, reason: 'unavailable' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (Number.isFinite(numericId) && map.has(numericId)) {
|
|
246
|
+
return { name: map.get(numericId), reason: 'found' };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this.logger.debug(`[自动打卡检查] serverId=${serverId} 不在 evm 服务器列表(可能尚未开服)`);
|
|
250
|
+
return { name: null, reason: 'not_found' };
|
|
80
251
|
}
|
|
81
252
|
};
|
|
@@ -323,13 +323,18 @@ class ActivityService {
|
|
|
323
323
|
this.logger.debug(`开始检查 ${this.todayActivities.length} 个活动的档位状态`);
|
|
324
324
|
|
|
325
325
|
for (const activity of this.todayActivities) {
|
|
326
|
-
|
|
326
|
+
// 平台 2.0 列表接口只给 hasProfile,不再给 profileFile(恒为 null),
|
|
327
|
+
// 统一走 resolveProfileState 判断,避免永远提示“未上传”。
|
|
328
|
+
const profileState = typeof this.resolveProfileState === 'function'
|
|
329
|
+
? this.resolveProfileState(activity)
|
|
330
|
+
: { uploaded: !!activity.profileFile, source: 'profileFile' };
|
|
331
|
+
const hasProfile = profileState.uploaded;
|
|
327
332
|
let message = hasProfile ? this.cfg.messages.profileUploaded : this.cfg.messages.profileNotUploaded;
|
|
328
333
|
if (hasProfile && activity.state == 0) {
|
|
329
334
|
message += ",当前状态草稿。";
|
|
330
335
|
}
|
|
331
336
|
const fullMessage = `活动 "${activity.themeName || '未知活动'}" - ${message}`;
|
|
332
|
-
this.logger.message(`活动档位检查: "${activity.themeName || '未知活动'}" - ${hasProfile ? "已上传" : "未上传"}
|
|
337
|
+
this.logger.message(`活动档位检查: "${activity.themeName || '未知活动'}" - ${hasProfile ? "已上传" : "未上传"}(判定依据: ${profileState.source})`);
|
|
333
338
|
|
|
334
339
|
for (const groupId of this.cfg.admin.groups) {
|
|
335
340
|
try {
|
|
@@ -723,7 +728,11 @@ class ActivityService {
|
|
|
723
728
|
|
|
724
729
|
let message;
|
|
725
730
|
if (autoClockResult.status === 'set') {
|
|
726
|
-
|
|
731
|
+
// checkAutoClock 已拼好服务器名(查不到名时退回不含服务器名的文案)
|
|
732
|
+
message = autoClockResult.message || `今日活动自动打卡已设置`;
|
|
733
|
+
} else if (autoClockResult.status === 'error') {
|
|
734
|
+
// 接口异常不冒充“未设置”,避免误报
|
|
735
|
+
message = `请注意,今日活动打卡状态查询失败,请手动确认。\n 活动名称: ${activity.themeName || '未知活动'}`;
|
|
727
736
|
} else if (autoClockResult.status === 'no_server') {
|
|
728
737
|
message = `请注意,今日活动打卡服务器未设置。\n 活动名称: ${activity.themeName || '未知活动'}`;
|
|
729
738
|
} else {
|
|
@@ -30,10 +30,10 @@ module.exports = async (ctx, session, rankingType, target) => {
|
|
|
30
30
|
if (ownerName) tmpIdPicker.cacheAtName(session.platform, ownerKey, ownerName);
|
|
31
31
|
}
|
|
32
32
|
// 排行榜只高亮一个人:直接用默认绑定,不弹序号询问
|
|
33
|
+
// 这里只用 tmp_id,高亮名字来自排行榜接口返回的数据 → 不必刷新 tmp_name
|
|
33
34
|
const bindings = await guildBind.listOf(ctx.database, session.platform, ownerKey);
|
|
34
35
|
let bindTmpId = null;
|
|
35
36
|
if (bindings.length > 0) {
|
|
36
|
-
await guildBind.ensureNames(ctx, bindings);
|
|
37
37
|
bindTmpId = String((bindings.find(b => guildBind.isDefault(b)) || bindings[0]).tmp_id);
|
|
38
38
|
}
|
|
39
39
|
let playerMileageRanking = null;
|
|
@@ -3,12 +3,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.VersionCheckService = void 0;
|
|
4
4
|
|
|
5
5
|
const koishi_1 = require("koishi");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const common_1 = require("../util/common");
|
|
8
|
+
|
|
9
|
+
// TMP 官方文件清单(含每个文件的 MD5),用于比对版本更新前后哪些文件被替换
|
|
10
|
+
const FILES_API = "https://update.ets2mp.com/files.json";
|
|
11
|
+
// 文字通知里最多列出多少个文件,超出只显示数量,避免消息过长
|
|
12
|
+
const MAX_LISTED_FILES = 30;
|
|
6
13
|
|
|
7
14
|
class VersionCheckService {
|
|
8
15
|
constructor(ctx, config) {
|
|
9
16
|
this.ctx = ctx;
|
|
10
17
|
this.cfg = config;
|
|
11
18
|
this.currentVersion = null;
|
|
19
|
+
// 上一次检查时的文件清单快照(FilePath -> {md5, type}),用于比对文件变化
|
|
20
|
+
this.fileManifest = null;
|
|
12
21
|
this.timers = [];
|
|
13
22
|
this.logger = this.initLogger();
|
|
14
23
|
}
|
|
@@ -71,6 +80,7 @@ class VersionCheckService {
|
|
|
71
80
|
if (response && response.name) {
|
|
72
81
|
const versionInfo = {
|
|
73
82
|
name: response.name,
|
|
83
|
+
stage: response.stage,
|
|
74
84
|
time: this.convertToUTC8(response.time),
|
|
75
85
|
supported_game_version: response.supported_game_version,
|
|
76
86
|
supported_ats_game_version: response.supported_ats_game_version
|
|
@@ -80,7 +90,14 @@ class VersionCheckService {
|
|
|
80
90
|
|
|
81
91
|
if (this.currentVersion && this.currentVersion.name !== versionInfo.name) {
|
|
82
92
|
this.logger.info(`检测到版本更新: ${this.currentVersion.name} -> ${versionInfo.name}`);
|
|
83
|
-
|
|
93
|
+
|
|
94
|
+
// 比对文件 MD5,找出本次更新实际替换了哪些文件
|
|
95
|
+
const changedFiles = await this.detectChangedFiles();
|
|
96
|
+
if (changedFiles.length > 0) {
|
|
97
|
+
this.logger.info(`本次更新涉及 ${changedFiles.length} 个文件变化`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await this.sendVersionUpdateNotification(this.currentVersion, versionInfo, changedFiles);
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
this.currentVersion = versionInfo;
|
|
@@ -92,6 +109,108 @@ class VersionCheckService {
|
|
|
92
109
|
}
|
|
93
110
|
}
|
|
94
111
|
|
|
112
|
+
/**
|
|
113
|
+
* 拉取官方文件清单并转成 { FilePath -> Md5 } 映射。
|
|
114
|
+
* 失败时返回 null(调用方据此区分“没变化”和“查不到”)。
|
|
115
|
+
*/
|
|
116
|
+
async fetchFileManifest() {
|
|
117
|
+
try {
|
|
118
|
+
const response = await this.ctx.http.get(FILES_API, { timeout: 15000 });
|
|
119
|
+
const files = response && Array.isArray(response.Files) ? response.Files : null;
|
|
120
|
+
if (!files) {
|
|
121
|
+
this.logger.warn("文件清单格式不正确,跳过文件变化比对");
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const manifest = new Map();
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
if (file && file.FilePath) {
|
|
128
|
+
manifest.set(file.FilePath, {
|
|
129
|
+
md5: String(file.Md5 || "").toLowerCase(),
|
|
130
|
+
type: file.Type || ""
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
this.logger.debug(`文件清单已加载,共 ${manifest.size} 个文件`);
|
|
135
|
+
return manifest;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
this.logger.warn(`获取文件清单失败: ${error.message}`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 比对「上一次版本的文件清单」与「当前清单」,返回 MD5 变化的文件。
|
|
144
|
+
*
|
|
145
|
+
* 说明:files.json 只提供**当前**版本的文件列表,没有历史版本归档
|
|
146
|
+
* (/0.7.10.0/files.json 等路径均 404)。所以只能在两次检查之间自己留存快照:
|
|
147
|
+
* - 有上一次快照 → 逐文件比对 MD5,新增/删除/改动都算变化;
|
|
148
|
+
* - 没有快照(首次运行或重启)→ 退化为“清单中所有文件都视为本次更新内容”,
|
|
149
|
+
* 并在结果里标注 snapshotMissing,由文案说明这是完整清单而非差异。
|
|
150
|
+
*
|
|
151
|
+
* @returns {Array|null} 变化列表;清单获取失败时返回 null(区别于“没有变化”的空数组)
|
|
152
|
+
*/
|
|
153
|
+
async detectChangedFiles() {
|
|
154
|
+
const current = await this.fetchFileManifest();
|
|
155
|
+
if (!current) {
|
|
156
|
+
// 查不到清单 ≠ 没有变化,不能返回 [],否则会误报“未检测到文件变化”
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const previous = this.fileManifest;
|
|
161
|
+
this.fileManifest = current;
|
|
162
|
+
|
|
163
|
+
if (!previous) {
|
|
164
|
+
this.logger.debug("没有上一次的文件清单快照,本次按完整清单输出");
|
|
165
|
+
return this.buildFullFileList(current, true);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const changed = [];
|
|
169
|
+
for (const [filePath, info] of current) {
|
|
170
|
+
const old = previous.get(filePath);
|
|
171
|
+
if (!old) {
|
|
172
|
+
changed.push({ path: filePath, type: info.type, kind: 'added' });
|
|
173
|
+
} else if (old.md5 !== info.md5) {
|
|
174
|
+
changed.push({ path: filePath, type: info.type, kind: 'modified' });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// 上一次有、这次没有 → 被移除
|
|
178
|
+
for (const [filePath, info] of previous) {
|
|
179
|
+
if (!current.has(filePath)) {
|
|
180
|
+
changed.push({ path: filePath, type: info.type, kind: 'removed' });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 稳定排序:先 ets2/ats,再 system;同类按路径
|
|
185
|
+
const typeOrder = { ets2: 0, ats: 1, system: 2 };
|
|
186
|
+
changed.sort((a, b) => {
|
|
187
|
+
const ta = typeOrder[a.type] !== undefined ? typeOrder[a.type] : 9;
|
|
188
|
+
const tb = typeOrder[b.type] !== undefined ? typeOrder[b.type] : 9;
|
|
189
|
+
if (ta !== tb) return ta - tb;
|
|
190
|
+
return a.path.localeCompare(b.path);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return changed;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 没有快照时的退化结果:清单内所有文件 */
|
|
197
|
+
buildFullFileList(manifest, snapshotMissing) {
|
|
198
|
+
const list = [];
|
|
199
|
+
for (const [filePath, info] of manifest) {
|
|
200
|
+
list.push({ path: filePath, type: info.type, kind: 'unknown' });
|
|
201
|
+
}
|
|
202
|
+
const typeOrder = { ets2: 0, ats: 1, system: 2 };
|
|
203
|
+
list.sort((a, b) => {
|
|
204
|
+
const ta = typeOrder[a.type] !== undefined ? typeOrder[a.type] : 9;
|
|
205
|
+
const tb = typeOrder[b.type] !== undefined ? typeOrder[b.type] : 9;
|
|
206
|
+
if (ta !== tb) return ta - tb;
|
|
207
|
+
return a.path.localeCompare(b.path);
|
|
208
|
+
});
|
|
209
|
+
// 用不可枚举标记,避免污染后续 JSON 序列化
|
|
210
|
+
Object.defineProperty(list, 'snapshotMissing', { value: !!snapshotMissing, enumerable: false });
|
|
211
|
+
return list;
|
|
212
|
+
}
|
|
213
|
+
|
|
95
214
|
convertToUTC8(utcTimeString) {
|
|
96
215
|
try {
|
|
97
216
|
const utcDate = new Date(utcTimeString);
|
|
@@ -109,28 +228,175 @@ class VersionCheckService {
|
|
|
109
228
|
}
|
|
110
229
|
}
|
|
111
230
|
|
|
112
|
-
async sendVersionUpdateNotification(oldVersion, newVersion) {
|
|
113
|
-
|
|
231
|
+
async sendVersionUpdateNotification(oldVersion, newVersion, changedFiles) {
|
|
232
|
+
// null = 清单查询失败;数组 = 比对结果(可能为空)
|
|
233
|
+
const fileCount = changedFiles === null ? '查询失败' : `${changedFiles.length}`;
|
|
234
|
+
this.logger.info(`发送版本更新通知到 ${this.cfg.groups.length} 个群组(变化文件: ${fileCount})`);
|
|
114
235
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
更新时间: ${newVersion.time}(UTC+8)
|
|
119
|
-
欧卡支持版本: ${newVersion.supported_game_version}
|
|
120
|
-
美卡支持版本: ${newVersion.supported_ats_game_version}`;
|
|
121
|
-
|
|
122
|
-
this.logger.info(`发送版本更新通知到 ${this.cfg.groups.length} 个群组`);
|
|
236
|
+
// 内容只构建一次(图片渲染较慢,不要每个群各渲染一遍)
|
|
237
|
+
const message = await this.buildUpdateContent(oldVersion, newVersion, changedFiles);
|
|
238
|
+
const isImage = typeof message === "object" && message !== null;
|
|
123
239
|
|
|
124
240
|
for (const groupId of this.cfg.groups) {
|
|
125
241
|
try {
|
|
126
242
|
await this.sendToGroup(groupId, message);
|
|
127
|
-
this.logger.info(`已发送版本更新通知到群组 ${groupId}
|
|
243
|
+
this.logger.info(`已发送版本更新通知到群组 ${groupId}(${isImage ? "图片" : "文字"})`);
|
|
128
244
|
} catch (error) {
|
|
129
245
|
this.logger.error(`发送版本更新通知到群组 ${groupId} 失败:`, error.message);
|
|
130
246
|
}
|
|
131
247
|
}
|
|
132
248
|
}
|
|
133
249
|
|
|
250
|
+
/**
|
|
251
|
+
* 构建通知内容。notifyType:1=文字(默认),2=图片。
|
|
252
|
+
* 图片渲染失败(如未启用 puppeteer)会**自动回退为文字**,保证通知不丢。
|
|
253
|
+
* @returns 文字字符串,或图片 segment 元素
|
|
254
|
+
*/
|
|
255
|
+
async buildUpdateContent(oldVersion, newVersion, changedFiles) {
|
|
256
|
+
const notifyType = Number(this.cfg.notifyType) || 1;
|
|
257
|
+
if (notifyType === 2) {
|
|
258
|
+
const image = await this.renderUpdateImage(oldVersion, newVersion, changedFiles);
|
|
259
|
+
if (image) {
|
|
260
|
+
return image;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return this.buildUpdateText(oldVersion, newVersion, changedFiles);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 插件配置里的「更新通知附加文本」,原样追加在通知末尾。
|
|
268
|
+
* 支持 \n 转义换行,方便在配置面板里写多行。
|
|
269
|
+
*/
|
|
270
|
+
getExtraText() {
|
|
271
|
+
const raw = this.cfg.extraText;
|
|
272
|
+
if (raw == null) return "";
|
|
273
|
+
const text = String(raw).trim();
|
|
274
|
+
if (!text) return "";
|
|
275
|
+
return text.replace(/\\n/g, "\n");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
buildUpdateText(oldVersion, newVersion, changedFiles) {
|
|
279
|
+
let message = `📢 TMP版本更新通知
|
|
280
|
+
|
|
281
|
+
旧版本: ${oldVersion.name}
|
|
282
|
+
新版本: ${newVersion.name}
|
|
283
|
+
|
|
284
|
+
更新时间: ${newVersion.time}(UTC+8)
|
|
285
|
+
欧卡支持版本: ${this.formatGameVersion(oldVersion.supported_game_version, newVersion.supported_game_version)}
|
|
286
|
+
美卡支持版本: ${this.formatGameVersion(oldVersion.supported_ats_game_version, newVersion.supported_ats_game_version)}`;
|
|
287
|
+
|
|
288
|
+
message += this.formatChangedFiles(changedFiles);
|
|
289
|
+
|
|
290
|
+
const extra = this.getExtraText();
|
|
291
|
+
if (extra) {
|
|
292
|
+
message += `\n\n${extra}`;
|
|
293
|
+
}
|
|
294
|
+
return message;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 把变化文件列表拼成消息片段。
|
|
299
|
+
* - files 为 null → 清单获取失败,明确说明“查询失败”,不冒充“无变化”
|
|
300
|
+
* - 空数组 → 确实没有文件变化
|
|
301
|
+
* - 超过 MAX_LISTED_FILES 条 → 只列前若干条 + 「等共 N 个」
|
|
302
|
+
*/
|
|
303
|
+
formatChangedFiles(files) {
|
|
304
|
+
if (files === null) {
|
|
305
|
+
return `\n\n更新文件: 文件清单获取失败,无法比对变化`;
|
|
306
|
+
}
|
|
307
|
+
if (!files || files.length === 0) {
|
|
308
|
+
return `\n\n更新文件: 未检测到文件变化`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const snapshotMissing = files.snapshotMissing === true;
|
|
312
|
+
const title = snapshotMissing
|
|
313
|
+
? `更新文件(共 ${files.length} 个,未留存上次清单,以下为完整列表)`
|
|
314
|
+
: `更新文件(共 ${files.length} 个)`;
|
|
315
|
+
|
|
316
|
+
const shown = files.slice(0, MAX_LISTED_FILES);
|
|
317
|
+
const lines = shown.map((file) => {
|
|
318
|
+
const mark = file.kind === 'added' ? '[新增] '
|
|
319
|
+
: file.kind === 'removed' ? '[移除] '
|
|
320
|
+
: file.kind === 'modified' ? '[更新] '
|
|
321
|
+
: '';
|
|
322
|
+
return `· ${mark}${file.path}`;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
let text = `\n\n${title}\n${lines.join('\n')}`;
|
|
326
|
+
if (files.length > shown.length) {
|
|
327
|
+
text += `\n… 等共 ${files.length} 个文件`;
|
|
328
|
+
}
|
|
329
|
+
return text;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 游戏支持版本:有变化时显示「旧 → 新」,没变则只显示当前值。
|
|
334
|
+
*/
|
|
335
|
+
formatGameVersion(oldValue, newValue) {
|
|
336
|
+
if (oldValue && newValue && String(oldValue) !== String(newValue)) {
|
|
337
|
+
return `${oldValue} → ${newValue}`;
|
|
338
|
+
}
|
|
339
|
+
return newValue || oldValue || "未知";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 用 puppeteer 渲染版本更新卡片
|
|
344
|
+
* @returns 图片 segment 元素;渲染不可用时返回 null,由调用方回退文字
|
|
345
|
+
*/
|
|
346
|
+
async renderUpdateImage(oldVersion, newVersion, changedFiles) {
|
|
347
|
+
if (!this.ctx.puppeteer) {
|
|
348
|
+
this.logger.warn("未启用 puppeteer,版本更新通知回退为文字形式");
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
let page;
|
|
352
|
+
try {
|
|
353
|
+
// null = 清单查询失败,交给模板显示对应提示
|
|
354
|
+
const files = changedFiles === null ? [] : (Array.isArray(changedFiles) ? changedFiles : []);
|
|
355
|
+
const data = {
|
|
356
|
+
oldName: oldVersion.name,
|
|
357
|
+
newName: newVersion.name,
|
|
358
|
+
time: newVersion.time,
|
|
359
|
+
stage: newVersion.stage,
|
|
360
|
+
// 游戏支持版本也带上旧值,卡片才能显示「旧 → 新」对比
|
|
361
|
+
etsOld: oldVersion.supported_game_version,
|
|
362
|
+
etsNew: newVersion.supported_game_version,
|
|
363
|
+
atsOld: oldVersion.supported_ats_game_version,
|
|
364
|
+
atsNew: newVersion.supported_ats_game_version,
|
|
365
|
+
// 变化文件:路径 + 变化类型,卡片里按组展示
|
|
366
|
+
files: files.map((file) => ({
|
|
367
|
+
path: file.path,
|
|
368
|
+
kind: file.kind || 'unknown',
|
|
369
|
+
type: file.type || ''
|
|
370
|
+
})),
|
|
371
|
+
filesSnapshotMissing: changedFiles !== null && changedFiles.snapshotMissing === true,
|
|
372
|
+
filesUnavailable: changedFiles === null,
|
|
373
|
+
extraText: this.getExtraText()
|
|
374
|
+
};
|
|
375
|
+
const htmlPath = path_1.resolve(__dirname, "../resource/version-update.html");
|
|
376
|
+
this.logger.debug(`渲染版本更新卡片: ${htmlPath}`);
|
|
377
|
+
|
|
378
|
+
page = await this.ctx.puppeteer.page();
|
|
379
|
+
await page.setViewport({ width: 560, height: 900, deviceScaleFactor: 2 });
|
|
380
|
+
await page.goto(`file:///${htmlPath}`);
|
|
381
|
+
await page.evaluate(`setData(${JSON.stringify(data)})`);
|
|
382
|
+
await common_1.sleep(100);
|
|
383
|
+
await page.waitForNetworkIdle();
|
|
384
|
+
|
|
385
|
+
const element = await page.$("#container");
|
|
386
|
+
if (!element) {
|
|
387
|
+
throw new Error("未找到 #container");
|
|
388
|
+
}
|
|
389
|
+
return koishi_1.segment.image(await element.screenshot({ encoding: "binary" }), "image/jpeg");
|
|
390
|
+
} catch (error) {
|
|
391
|
+
this.logger.error(`渲染版本更新卡片失败,回退为文字形式: ${error.message}`);
|
|
392
|
+
return null;
|
|
393
|
+
} finally {
|
|
394
|
+
if (page) {
|
|
395
|
+
try { await page.close(); } catch { }
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
134
400
|
async sendToGroup(groupId, message) {
|
|
135
401
|
const onebotBots = this.ctx.bots.filter((bot) => {
|
|
136
402
|
return bot.platform === "onebot";
|
|
@@ -34,10 +34,10 @@ module.exports = async (ctx, cfg, session, rankingType, target) => {
|
|
|
34
34
|
if (ownerName) tmpIdPicker.cacheAtName(session.platform, ownerKey, ownerName);
|
|
35
35
|
}
|
|
36
36
|
// 排行榜只高亮一个人:直接用默认绑定,不弹序号询问
|
|
37
|
+
// 这里只用 tmp_id,高亮名字来自排行榜接口返回的数据 → 不必刷新 tmp_name
|
|
37
38
|
const bindings = await guildBind.listOf(ctx.database, session.platform, ownerKey);
|
|
38
39
|
let bindTmpId = null;
|
|
39
40
|
if (bindings.length > 0) {
|
|
40
|
-
await guildBind.ensureNames(ctx, bindings);
|
|
41
41
|
bindTmpId = String((bindings.find(b => guildBind.isDefault(b)) || bindings[0]).tmp_id);
|
|
42
42
|
}
|
|
43
43
|
let playerMileageRanking = null;
|