koishi-plugin-ets2-tools-tmp 3.4.6 → 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.
Files changed (33) hide show
  1. package/lib/command/ets-app/changePoint/changePointV1.js +28 -7
  2. package/lib/command/ets-app/pointRanking/pointRanking.js +12 -5
  3. package/lib/command/ets-app/queryPoint/queryPoint.js +15 -1
  4. package/lib/command/ets-app/queryPoint/queryPointV1.js +21 -5
  5. package/lib/command/ets-app/resetPassword/resetPasswordV1.js +32 -3
  6. package/lib/command/tmpBind.js +41 -4
  7. package/lib/command/tmpDlcMap.js +1 -1
  8. package/lib/command/tmpFootprint.js +3 -2
  9. package/lib/command/tmpMileageRanking.js +74 -23
  10. package/lib/command/tmpPosition.js +3 -2
  11. package/lib/command/tmpQuery/tmpQuery.js +70 -1
  12. package/lib/command/tmpQuery/tmpQueryImg.js +4 -3
  13. package/lib/command/tmpQuery/tmpQueryText.js +4 -3
  14. package/lib/command/tmpServer/tmpServer.js +47 -3
  15. package/lib/command/tmpServer/tmpServerImg.js +1 -1
  16. package/lib/command/tmpTraffic/tmpTraffic.js +64 -3
  17. package/lib/command/tmpTraffic/tmpTrafficMap.js +2 -2
  18. package/lib/command/tmpVersion.js +81 -9
  19. package/lib/command/tmpVtcMileageRanking.js +75 -22
  20. package/lib/command/tmpVtcOnline/tmpVtcOnlineImg.js +1 -1
  21. package/lib/database/guildBind.js +150 -1
  22. package/lib/database/model.js +12 -0
  23. package/lib/index.js +139 -15
  24. package/lib/resource/command-suggest.html +222 -0
  25. package/lib/resource/tmp-version.html +292 -0
  26. package/lib/util/commandSuggest.js +140 -0
  27. package/lib/util/commandSuggestMiddleware.js +210 -0
  28. package/lib/util/localImageHost.js +219 -0
  29. package/lib/util/messageRender.js +234 -0
  30. package/lib/util/qqMarkdown.js +171 -0
  31. package/lib/util/qqMediaUpload.js +165 -0
  32. package/lib/util/qqOfficial.js +233 -0
  33. package/package.json +1 -1
@@ -0,0 +1,292 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>TMP Version</title>
6
+ <style>
7
+ * { margin: 0; padding: 0; box-sizing: border-box; }
8
+
9
+ body {
10
+ font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
11
+ background: #06060b;
12
+ -webkit-font-smoothing: antialiased;
13
+ }
14
+
15
+ #container {
16
+ width: 560px;
17
+ background: #0a0a12;
18
+ position: relative;
19
+ overflow: hidden;
20
+ padding-bottom: 16px;
21
+ }
22
+
23
+ .grid-bg {
24
+ position: absolute;
25
+ inset: 0;
26
+ background-image:
27
+ linear-gradient(rgba(0, 200, 255, 0.03) 1px, transparent 1px),
28
+ linear-gradient(90deg, rgba(0, 200, 255, 0.03) 1px, transparent 1px);
29
+ background-size: 26px 26px;
30
+ pointer-events: none;
31
+ z-index: 0;
32
+ }
33
+
34
+ .glow-top {
35
+ position: absolute;
36
+ top: -180px;
37
+ left: 50%;
38
+ transform: translateX(-50%);
39
+ width: 620px;
40
+ height: 360px;
41
+ background: radial-gradient(ellipse at center, rgba(0, 200, 255, 0.16) 0%, rgba(0, 200, 255, 0.04) 45%, transparent 70%);
42
+ pointer-events: none;
43
+ z-index: 0;
44
+ }
45
+
46
+ .header {
47
+ position: relative;
48
+ z-index: 2;
49
+ padding: 22px 26px 18px;
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 14px;
53
+ border-bottom: 1px solid rgba(0, 200, 255, 0.1);
54
+ background: linear-gradient(180deg, rgba(0, 200, 255, 0.07) 0%, transparent 100%);
55
+ }
56
+
57
+ .header-badge {
58
+ width: 46px;
59
+ height: 46px;
60
+ flex-shrink: 0;
61
+ border-radius: 13px;
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: center;
65
+ font-size: 23px;
66
+ background: linear-gradient(145deg, rgba(0, 200, 255, 0.22), rgba(0, 200, 255, 0.06));
67
+ border: 1px solid rgba(0, 200, 255, 0.32);
68
+ box-shadow: 0 0 18px rgba(0, 200, 255, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.08);
69
+ }
70
+
71
+ .header-text { flex: 1; min-width: 0; }
72
+
73
+ .header-title {
74
+ font-size: 20px;
75
+ font-weight: 800;
76
+ color: #eaf6ff;
77
+ letter-spacing: 1.5px;
78
+ line-height: 1.25;
79
+ }
80
+
81
+ .header-title em {
82
+ font-style: normal;
83
+ color: #00c8ff;
84
+ text-shadow: 0 0 14px rgba(0, 200, 255, 0.55);
85
+ }
86
+
87
+ .header-sub {
88
+ margin-top: 3px;
89
+ font-size: 10px;
90
+ letter-spacing: 3.2px;
91
+ color: #3d5570;
92
+ text-transform: uppercase;
93
+ }
94
+
95
+ .stage-tag {
96
+ flex-shrink: 0;
97
+ font-size: 11px;
98
+ font-weight: 700;
99
+ letter-spacing: 1px;
100
+ padding: 6px 12px;
101
+ border-radius: 20px;
102
+ color: #ffb545;
103
+ background: rgba(255, 181, 69, 0.1);
104
+ border: 1px solid rgba(255, 181, 69, 0.34);
105
+ box-shadow: 0 0 14px rgba(255, 181, 69, 0.16);
106
+ }
107
+
108
+ .stage-tag:empty { display: none; }
109
+
110
+ .hero {
111
+ position: relative;
112
+ z-index: 2;
113
+ padding: 26px 26px 20px;
114
+ text-align: center;
115
+ }
116
+
117
+ .hero-caption {
118
+ font-size: 10px;
119
+ letter-spacing: 3.4px;
120
+ color: #3d5570;
121
+ text-transform: uppercase;
122
+ margin-bottom: 12px;
123
+ }
124
+
125
+ .version-big {
126
+ font-size: 42px;
127
+ font-weight: 800;
128
+ color: #eaf6ff;
129
+ letter-spacing: 2px;
130
+ text-shadow: 0 0 24px rgba(0, 200, 255, 0.4);
131
+ font-variant-numeric: tabular-nums;
132
+ }
133
+
134
+ .version-numeric {
135
+ margin-top: 6px;
136
+ font-size: 11px;
137
+ color: #5d7089;
138
+ letter-spacing: 2px;
139
+ font-variant-numeric: tabular-nums;
140
+ }
141
+
142
+ .info {
143
+ position: relative;
144
+ z-index: 2;
145
+ padding: 0 22px;
146
+ display: flex;
147
+ flex-direction: column;
148
+ gap: 8px;
149
+ }
150
+
151
+ .info-row {
152
+ display: flex;
153
+ align-items: center;
154
+ gap: 12px;
155
+ padding: 11px 14px;
156
+ border-radius: 11px;
157
+ background: rgba(255, 255, 255, 0.022);
158
+ border: 1px solid rgba(255, 255, 255, 0.055);
159
+ }
160
+
161
+ .info-icon {
162
+ width: 30px;
163
+ height: 30px;
164
+ flex-shrink: 0;
165
+ border-radius: 9px;
166
+ display: flex;
167
+ align-items: center;
168
+ justify-content: center;
169
+ font-size: 15px;
170
+ background: rgba(0, 200, 255, 0.1);
171
+ border: 1px solid rgba(0, 200, 255, 0.2);
172
+ }
173
+
174
+ .info-key {
175
+ font-size: 12px;
176
+ color: #5d7089;
177
+ letter-spacing: 0.6px;
178
+ flex-shrink: 0;
179
+ min-width: 96px;
180
+ }
181
+
182
+ .info-val {
183
+ margin-left: auto;
184
+ font-size: 14px;
185
+ font-weight: 700;
186
+ color: #00ff9d;
187
+ font-variant-numeric: tabular-nums;
188
+ letter-spacing: 0.4px;
189
+ white-space: nowrap;
190
+ }
191
+
192
+ .footer {
193
+ position: relative;
194
+ z-index: 2;
195
+ margin-top: 16px;
196
+ padding: 0 22px;
197
+ text-align: center;
198
+ }
199
+
200
+ .footer-line {
201
+ height: 1px;
202
+ background: linear-gradient(90deg, transparent, rgba(0, 200, 255, 0.2), transparent);
203
+ margin-bottom: 10px;
204
+ }
205
+
206
+ .footer-text {
207
+ font-size: 9.5px;
208
+ letter-spacing: 2.6px;
209
+ color: #22303e;
210
+ text-transform: uppercase;
211
+ }
212
+ </style>
213
+ </head>
214
+ <body>
215
+ <div id="container">
216
+ <div class="grid-bg"></div>
217
+ <div class="glow-top"></div>
218
+
219
+ <div class="header">
220
+ <div class="header-badge">🚛</div>
221
+ <div class="header-text">
222
+ <div class="header-title">TRUCKERS<em>MP</em> 版本</div>
223
+ <div class="header-sub">Current Version</div>
224
+ </div>
225
+ <div class="stage-tag" id="stage-tag"></div>
226
+ </div>
227
+
228
+ <div class="hero">
229
+ <div class="hero-caption">Now Running</div>
230
+ <div class="version-big" id="version-name">-</div>
231
+ <div class="version-numeric" id="version-numeric"></div>
232
+ </div>
233
+
234
+ <div class="info">
235
+ <div class="info-row">
236
+ <div class="info-icon">🕒</div>
237
+ <div class="info-key">更新时间</div>
238
+ <div class="info-val" id="time">-</div>
239
+ </div>
240
+ <div class="info-row">
241
+ <div class="info-icon">🚛</div>
242
+ <div class="info-key">欧卡支持版本</div>
243
+ <div class="info-val" id="ets-ver"></div>
244
+ </div>
245
+ <div class="info-row">
246
+ <div class="info-icon">🚚</div>
247
+ <div class="info-key">美卡支持版本</div>
248
+ <div class="info-val" id="ats-ver"></div>
249
+ </div>
250
+ </div>
251
+
252
+ <div class="footer">
253
+ <div class="footer-line"></div>
254
+ <div class="footer-text">TruckersMP · Version Monitor</div>
255
+ </div>
256
+ </div>
257
+
258
+ <script>
259
+ function setText(id, value) {
260
+ var el = document.getElementById(id);
261
+ if (el) el.textContent = (value === undefined || value === null || value === '') ? '-' : String(value);
262
+ }
263
+
264
+ /**
265
+ * UTC 时间转 UTC+8 显示
266
+ */
267
+ function toUTC8(utcString) {
268
+ if (!utcString) return '';
269
+ try {
270
+ var d = new Date(utcString);
271
+ if (isNaN(d.getTime())) return String(utcString);
272
+ var t = new Date(d.getTime() + 8 * 60 * 60 * 1000);
273
+ var p = function (n) { return String(n).padStart(2, '0'); };
274
+ return t.getFullYear() + '-' + p(t.getMonth() + 1) + '-' + p(t.getDate()) +
275
+ ' ' + p(t.getHours()) + ':' + p(t.getMinutes()) + ':' + p(t.getSeconds());
276
+ } catch (e) {
277
+ return String(utcString);
278
+ }
279
+ }
280
+
281
+ function setData(data) {
282
+ data = data || {};
283
+ setText('version-name', data.name);
284
+ setText('version-numeric', data.numeric ? 'BUILD ' + data.numeric : '');
285
+ setText('stage-tag', data.stage);
286
+ setText('time', toUTC8(data.time));
287
+ setText('ets-ver', data.ets);
288
+ setText('ats-ver', data.ats);
289
+ }
290
+ </script>
291
+ </body>
292
+ </html>
@@ -0,0 +1,140 @@
1
+ /**
2
+ * 指令纠错:用户打错指令时给出「猜你想问」提示。
3
+ *
4
+ * 只提示、不自动执行 —— 避免把一个善意的纠正变成误操作。
5
+ * 匹配用 Levenshtein(编辑距离),再叠加几条针对中文指令的实用规则。
6
+ */
7
+
8
+ /**
9
+ * Levenshtein 编辑距离(支持中文按字符计)。
10
+ * 超过 max 时提前返回 max + 1,避免长串无谓计算。
11
+ */
12
+ function levenshtein(a, b, max) {
13
+ const s = String(a == null ? '' : a);
14
+ const t = String(b == null ? '' : b);
15
+ if (s === t) return 0;
16
+
17
+ const n = s.length;
18
+ const m = t.length;
19
+ if (n === 0) return m;
20
+ if (m === 0) return n;
21
+ if (max != null && Math.abs(n - m) > max) return max + 1;
22
+
23
+ // 滚动数组,只需两行
24
+ let prev = new Array(m + 1);
25
+ let curr = new Array(m + 1);
26
+ for (let j = 0; j <= m; j++) prev[j] = j;
27
+
28
+ for (let i = 1; i <= n; i++) {
29
+ curr[0] = i;
30
+ let rowMin = curr[0];
31
+ const sc = s.charCodeAt(i - 1);
32
+
33
+ for (let j = 1; j <= m; j++) {
34
+ const cost = sc === t.charCodeAt(j - 1) ? 0 : 1;
35
+ const v = Math.min(
36
+ prev[j] + 1, // 删除
37
+ curr[j - 1] + 1, // 插入
38
+ prev[j - 1] + cost // 替换
39
+ );
40
+ curr[j] = v;
41
+ if (v < rowMin) rowMin = v;
42
+ }
43
+
44
+ if (max != null && rowMin > max) return max + 1;
45
+ const tmp = prev; prev = curr; curr = tmp;
46
+ }
47
+
48
+ return prev[m];
49
+ }
50
+
51
+ /**
52
+ * 指令名归一化:去掉参数部分、统一大小写与空白。
53
+ * 例如「vtc里程排行 Simulation」→「vtc里程排行」
54
+ */
55
+ function normalizeCommandText(raw) {
56
+ return String(raw == null ? '' : raw)
57
+ .trim()
58
+ // 去掉前缀(如 . 或 /)
59
+ .replace(/^[./#!]+/, '')
60
+ // 只取第一段(指令名不含空格)
61
+ .split(/\s+/)[0]
62
+ .toLowerCase();
63
+ }
64
+
65
+ /**
66
+ * 从一组候选指令里找出与输入最接近的。
67
+ *
68
+ * 规则(按优先级):
69
+ * 1. 完全相同 → 视为正确,返回 null(不需要提示)
70
+ * 2. 输入是某个指令的「前缀/子串」且长度足够 → 高分(如「vtc里程排行」之于「vtc里程排行榜」)
71
+ * 3. 编辑距离在阈值内 → 按距离排序
72
+ *
73
+ * @param {string} input 用户输入
74
+ * @param {string[]} candidates 候选指令名
75
+ * @param {object} [opts]
76
+ * @param {number} [opts.maxDistance] 最大编辑距离,默认按输入长度自适应
77
+ * @param {number} [opts.limit] 最多返回几个建议,默认 3
78
+ * @returns {Array<{ name: string, distance: number, score: number }>}
79
+ */
80
+ function suggestCommands(input, candidates, opts) {
81
+ const options = opts || {};
82
+ const limit = options.limit != null ? options.limit : 3;
83
+ const raw = normalizeCommandText(input);
84
+ if (!raw) return [];
85
+
86
+ const list = Array.isArray(candidates) ? candidates.filter(Boolean) : [];
87
+ // 输入太短时不做纠错:一两个字符的指令(如「查询」)容易被误判
88
+ if (raw.length < 2) return [];
89
+
90
+ // 自适应阈值:短指令允许 1,长指令允许 2,避免误报
91
+ const maxDistance = options.maxDistance != null
92
+ ? options.maxDistance
93
+ : (raw.length <= 3 ? 1 : 2);
94
+
95
+ const scored = [];
96
+ for (const name of list) {
97
+ const target = normalizeCommandText(name);
98
+ if (!target) continue;
99
+ if (target === raw) return []; // 完全匹配 → 这是有效指令,不该提示
100
+
101
+ const distance = levenshtein(raw, target, maxDistance);
102
+
103
+ let score = 0;
104
+ // 前缀关系优先判断(优先级高于纯编辑距离):
105
+ // 用户在指令名后多打了字(「路况查询」之于「路况」),本质上是对的指令 + 多余参数
106
+ const isTargetPrefixOfRaw = raw.length > target.length && raw.startsWith(target) && target.length >= 2;
107
+ const isRawPrefixOfTarget = target.length > raw.length && target.startsWith(raw) && raw.length >= 2;
108
+
109
+ if (isRawPrefixOfTarget) {
110
+ // 用户少打了字(「vtc里程排行」→「vtc里程排行榜」)——最常见,给最高分
111
+ score = 120 - (target.length - raw.length);
112
+ } else if (isTargetPrefixOfRaw) {
113
+ // 用户多打了字 —— 次高,且多余字符越少越可能
114
+ score = 110 - (raw.length - target.length);
115
+ } else if (distance <= maxDistance) {
116
+ score = 100 - distance * 10;
117
+ }
118
+
119
+ if (score > 0) {
120
+ scored.push({ name: target, distance, score, len: target.length });
121
+ }
122
+ }
123
+
124
+ scored.sort((a, b) => {
125
+ if (b.score !== a.score) return b.score - a.score;
126
+ if (a.distance !== b.distance) return a.distance - b.distance;
127
+ // 同分同距离时,更长的名字优先:
128
+ // 「路况查询」应更倾向「路况」(原指令带多余参数)而不是短名「查询」
129
+ if (b.len !== a.len) return b.len - a.len;
130
+ return a.name.localeCompare(b.name);
131
+ });
132
+
133
+ return scored.slice(0, limit).map(({ name, distance, score }) => ({ name, distance, score }));
134
+ }
135
+
136
+ module.exports = {
137
+ levenshtein,
138
+ normalizeCommandText,
139
+ suggestCommands,
140
+ };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * 「猜你想问」中间件:用户指令打错时给出提示。
3
+ *
4
+ * 只提示 + 给可点按钮,不自动执行 —— 避免误操作。
5
+ *
6
+ * 两种输入都要支持:
7
+ * - 带前缀:`.vtc里程排行` / `/vtc里程排行`
8
+ * - 不带前缀:`vtc里程排行`(QQ 机器人用户很少加前缀)
9
+ *
10
+ * 误伤防护:
11
+ * - 只在「首词能匹配到插件指令」时才介入,普通聊天一律放行;
12
+ * - 已存在的有效指令一律放行;
13
+ * - 人工回复的序号(纯数字)放行。
14
+ */
15
+
16
+ const { suggestCommands, normalizeCommandText, levenshtein } = require('./commandSuggest');
17
+ const qqOfficial = require('./qqOfficial');
18
+ const messageRender = require('./messageRender');
19
+
20
+ const { segment } = require('koishi');
21
+ const { resolve } = require('path');
22
+ const common = require('./common');
23
+
24
+ /** 去掉指令前缀(. / # ! 等)后取首词 */
25
+ function firstToken(raw) {
26
+ return String(raw == null ? '' : raw)
27
+ .trim()
28
+ .replace(/^[./#!]+/, '')
29
+ .split(/\s+/)[0] || '';
30
+ }
31
+
32
+ /**
33
+ * 从 session 里取出「用户输入的指令名」。
34
+ *
35
+ * @returns {string|null} 不是疑似指令时返回 null
36
+ */
37
+ function extractCommandText(session) {
38
+ // 已经被 Koishi 解析成有效命令 → 不需要纠错
39
+ if (session.argv && session.argv.command) return null;
40
+
41
+ const raw = String(session.content || '').trim();
42
+ if (!raw) return null;
43
+
44
+ // 纯数字(正在回复序号)不处理
45
+ if (/^\d+$/.test(raw)) return null;
46
+
47
+ // @ 开头的(纯 @ 某人)不处理
48
+ if (raw.startsWith('<at ')) return null;
49
+
50
+ const text = normalizeCommandText(firstToken(raw));
51
+ if (!text) return null;
52
+
53
+ // 太短的不纠错(如「查」「a」),避免误伤
54
+ if (text.length < 2) return null;
55
+
56
+ return text;
57
+ }
58
+
59
+ /**
60
+ * 判断这条消息是否「看起来像在调用插件指令」。
61
+ * 用于避免把普通聊天当成打错的指令。
62
+ *
63
+ * 判断依据必须与 suggestCommands 一致(真正的编辑距离),
64
+ * 否则会出现「积分排行」这种正确指令被误判成"打错"的情况。
65
+ */
66
+ function looksLikeCommand(text, candidates, maxDistance) {
67
+ if (!text) return false;
68
+
69
+ // 先扫一遍:只要命中任何一条真实指令,就认为这是正确输入,不纠错
70
+ for (const name of candidates) {
71
+ const target = normalizeCommandText(name);
72
+ if (target && target === text) return false;
73
+ }
74
+
75
+ for (const name of candidates) {
76
+ const target = normalizeCommandText(name);
77
+ if (!target) continue;
78
+
79
+ // 与实际建议逻辑保持完全一致
80
+ const distance = levenshtein(text, target, maxDistance);
81
+ if (distance <= maxDistance) return true;
82
+
83
+ // 前缀关系(少打字 / 多打字)
84
+ if (target.length > text.length && target.startsWith(text) && text.length >= 2) return true;
85
+ if (text.length > target.length && text.startsWith(target) && target.length >= 2) return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * 注册中间件。
92
+ * @param {object} ctx koishi context
93
+ * @param {object} cfg 插件配置
94
+ * @param {string[]} candidates 已知指令名列表
95
+ */
96
+ function register(ctx, cfg, candidates) {
97
+ if (!Array.isArray(candidates) || candidates.length === 0) return;
98
+ if (cfg.commandSuggest && cfg.commandSuggest.enable === false) return;
99
+
100
+ // 与 suggestCommands 保持一致的阈值
101
+ const cfgMax = cfg.commandSuggest && cfg.commandSuggest.maxDistance;
102
+ const fixedMax = (cfgMax != null && Number(cfgMax) > 0) ? Number(cfgMax) : null;
103
+
104
+ ctx.middleware(async (session, next) => {
105
+ try {
106
+ const input = extractCommandText(session);
107
+ if (!input) return next();
108
+
109
+ // 已经是有效指令 → 放行
110
+ const commander = ctx.$commander;
111
+ if (commander && commander.get && commander.get(input)) return next();
112
+
113
+ // 粗筛:不像指令的普通聊天直接放行
114
+ const maxDistance = fixedMax != null ? fixedMax : (input.length <= 3 ? 1 : 2);
115
+ if (!looksLikeCommand(input, candidates, maxDistance)) return next();
116
+
117
+ const suggestOpts = { limit: 3 };
118
+ if (fixedMax != null) suggestOpts.maxDistance = fixedMax;
119
+
120
+ const suggestions = suggestCommands(input, candidates, suggestOpts);
121
+ if (suggestions.length === 0) return next();
122
+
123
+ const names = suggestions.map(s => s.name);
124
+ const text = buildSuggestionText(names);
125
+ const buttons = buildSuggestionButtons(names);
126
+
127
+ // 图片模式(配置 type=2):用 puppeteer 渲染一张提示图
128
+ const type = cfg.commandSuggest && cfg.commandSuggest.type;
129
+ if (Number(type) === 2 && ctx.puppeteer) {
130
+ const image = await renderSuggestionImage(ctx, names, input);
131
+ if (image) {
132
+ return await messageRender.renderImage(session, image, {
133
+ buttons,
134
+ uploader: await messageRender.buildUploader(ctx, cfg, session),
135
+ });
136
+ }
137
+ }
138
+
139
+ return await messageRender.renderText(session, text, {
140
+ buttons,
141
+ markdown: true,
142
+ });
143
+ } catch (error) {
144
+ ctx.logger.warn(`[猜你想问] 处理失败: ${error.message}`);
145
+ return next();
146
+ }
147
+ }, true); // prepend:放在较前位置,但内部对有效指令一律 next()
148
+ }
149
+
150
+ /**
151
+ * 组装提示文案。
152
+ */
153
+ function buildSuggestionText(names) {
154
+ if (names.length === 1) {
155
+ return `猜你想问:${names[0]}?`;
156
+ }
157
+ const list = names.map((n, i) => `${i + 1}. ${n}`).join('\n');
158
+ return `猜你想问:\n${list}`;
159
+ }
160
+
161
+ /**
162
+ * 组装可点按钮(仅官方机器人有效,onebot 会被自动忽略)。
163
+ * 一行一个,避免手机端文字被压成省略号。
164
+ */
165
+ function buildSuggestionButtons(names) {
166
+ return names.map(name => [qqOfficial.inputButton(name, name, true)]);
167
+ }
168
+
169
+ /**
170
+ * 渲染「猜你想问」提示图。
171
+ * @returns {Promise<object|null>} 图片元素;渲染不可用/失败时返回 null(调用方回退文字)
172
+ */
173
+ async function renderSuggestionImage(ctx, names, input) {
174
+ if (!ctx.puppeteer) {
175
+ ctx.logger.debug("[猜你想问] 未启用 puppeteer,回退为文字提示");
176
+ return null;
177
+ }
178
+ let page;
179
+ try {
180
+ const data = { names, input: input || '' };
181
+ const htmlPath = resolve(__dirname, '../resource/command-suggest.html');
182
+
183
+ page = await ctx.puppeteer.page();
184
+ await page.setViewport({ width: 560, height: 400, deviceScaleFactor: 2 });
185
+ await page.goto(`file:///${htmlPath}`);
186
+ await page.evaluate(`setData(${JSON.stringify(data)})`);
187
+ await common.sleep(100);
188
+ await page.waitForNetworkIdle();
189
+
190
+ const element = await page.$("#container");
191
+ if (!element) throw new Error("未找到 #container");
192
+ return segment.image(await element.screenshot({ encoding: "binary" }), "image/jpeg");
193
+ } catch (error) {
194
+ ctx.logger.warn(`[猜你想问] 渲染提示图失败,回退为文字: ${error.message}`);
195
+ return null;
196
+ } finally {
197
+ if (page) {
198
+ try { await page.close(); } catch { }
199
+ }
200
+ }
201
+ }
202
+
203
+ module.exports = {
204
+ register,
205
+ extractCommandText,
206
+ looksLikeCommand,
207
+ buildSuggestionText,
208
+ buildSuggestionButtons,
209
+ renderSuggestionImage,
210
+ };