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.
Files changed (34) 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/tmpVersionCheck.js +53 -10
  20. package/lib/command/tmpVtcMileageRanking.js +75 -22
  21. package/lib/command/tmpVtcOnline/tmpVtcOnlineImg.js +1 -1
  22. package/lib/database/guildBind.js +150 -1
  23. package/lib/database/model.js +12 -0
  24. package/lib/index.js +139 -15
  25. package/lib/resource/command-suggest.html +222 -0
  26. package/lib/resource/tmp-version.html +292 -0
  27. package/lib/util/commandSuggest.js +140 -0
  28. package/lib/util/commandSuggestMiddleware.js +210 -0
  29. package/lib/util/localImageHost.js +219 -0
  30. package/lib/util/messageRender.js +234 -0
  31. package/lib/util/qqMarkdown.js +171 -0
  32. package/lib/util/qqMediaUpload.js +165 -0
  33. package/lib/util/qqOfficial.js +233 -0
  34. package/package.json +1 -1
@@ -0,0 +1,219 @@
1
+ /**
2
+ * 本地图片托管:把截图写成文件,并注册 HTTP 路由对外提供公网 URL。
3
+ *
4
+ * 为什么需要它:
5
+ * - QQ 官方机器人的 qqbot-media 方案有「单机器人 100 张」上限,且手机端不渲染;
6
+ * - markdown 里用**公网 https URL** 是最稳妥的(所有客户端都支持)。
7
+ *
8
+ * 用法:配置 publicBaseUrl(如 https://bd.114512.xyz:5155)后,
9
+ * 本模块会把图片存到 <data>/tmp-images/<hash>.png,
10
+ * 并提供 <publicBaseUrl>/tmp-images/<hash>.png 的访问地址。
11
+ *
12
+ * 依赖:Koishi 的 server 服务(koishi.yml 里已有 server 插件)。
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const crypto = require('crypto');
18
+
19
+ /** 路由前缀 */
20
+ const ROUTE_PREFIX = '/tmp-images';
21
+
22
+ /** 已注册过路由的 ctx(避免重复注册) */
23
+ const registered = new WeakSet();
24
+
25
+ /** 图片保留时长(毫秒):超过自动清理,避免磁盘无限增长 */
26
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
27
+
28
+ /**
29
+ * 计算图片存储目录。
30
+ * 优先用 Koishi 的 baseDir/data,取不到就退到当前工作目录下的 data。
31
+ */
32
+ function resolveImageDir(ctx) {
33
+ const base = (ctx && ctx.baseDir) ||
34
+ (ctx && ctx.app && ctx.app.baseDir) ||
35
+ process.cwd();
36
+ return path.join(base, 'data', 'tmp-images');
37
+ }
38
+
39
+ /**
40
+ * 从公网地址里取 URL 路径前缀(子路径部署时用)。
41
+ * 例如 https://bot.114512.xyz/koishi → '/koishi'
42
+ * https://bot.114512.xyz → ''
43
+ */
44
+ function getPathPrefix(cfg) {
45
+ const base = getPublicBaseUrl(cfg);
46
+ if (!base) return '';
47
+ try {
48
+ const u = new URL(base);
49
+ const p = String(u.pathname || '').replace(/\/+$/, '');
50
+ return p === '/' ? '' : p;
51
+ } catch {
52
+ return '';
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 注册静态路由(幂等)。
58
+ *
59
+ * 路由会带上公网地址里的子路径前缀,保证「生成的 URL」和「实际挂载路径」一致。
60
+ * 例如配置 https://bot.114512.xyz/koishi 时,路由为 /koishi/tmp-images/:name。
61
+ *
62
+ * @param {object} ctx koishi context
63
+ * @param {object} cfg 插件配置
64
+ * @returns {string|null} 图片目录;server 服务不可用时返回 null
65
+ */
66
+ function registerStaticRoute(ctx, cfg) {
67
+ if (!ctx.server || typeof ctx.server.get !== 'function') {
68
+ return null;
69
+ }
70
+ if (registered.has(ctx)) {
71
+ return resolveImageDir(ctx);
72
+ }
73
+
74
+ const dir = resolveImageDir(ctx);
75
+ try {
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ } catch (error) {
78
+ ctx.logger.warn(`[本地图片] 创建目录失败: ${error.message}`);
79
+ return null;
80
+ }
81
+
82
+ const prefix = getPathPrefix(cfg);
83
+ const routePath = `${prefix}${ROUTE_PREFIX}/:name`;
84
+
85
+ ctx.server.get(routePath, (koaCtx) => {
86
+ const raw = String(koaCtx.params && koaCtx.params.name || '');
87
+ // 防目录穿越:只允许安全文件名
88
+ const name = path.basename(raw);
89
+ if (!/^[A-Za-z0-9_-]+\.(png|jpg|jpeg|gif|webp)$/i.test(name)) {
90
+ koaCtx.status = 404;
91
+ return;
92
+ }
93
+ const file = path.join(dir, name);
94
+ if (!fs.existsSync(file)) {
95
+ koaCtx.status = 404;
96
+ return;
97
+ }
98
+ const ext = path.extname(name).toLowerCase();
99
+ const mime = ext === '.png' ? 'image/png'
100
+ : ext === '.gif' ? 'image/gif'
101
+ : ext === '.webp' ? 'image/webp'
102
+ : 'image/jpeg';
103
+ koaCtx.type = mime;
104
+ // 图片内容不变(按 hash 命名),可长缓存
105
+ koaCtx.set('Cache-Control', 'public, max-age=86400');
106
+ koaCtx.body = fs.createReadStream(file);
107
+ });
108
+
109
+ registered.add(ctx);
110
+ return dir;
111
+ }
112
+
113
+ /**
114
+ * 清理过期图片(按 mtime)。
115
+ * @returns {number} 删除的文件数
116
+ */
117
+ function cleanupExpired(ctx, dir) {
118
+ let removed = 0;
119
+ try {
120
+ const now = Date.now();
121
+ for (const name of fs.readdirSync(dir)) {
122
+ const file = path.join(dir, name);
123
+ try {
124
+ const stat = fs.statSync(file);
125
+ if (stat.isFile() && now - stat.mtimeMs > MAX_AGE_MS) {
126
+ fs.unlinkSync(file);
127
+ removed++;
128
+ }
129
+ } catch { }
130
+ }
131
+ } catch { }
132
+ if (removed > 0 && ctx.logger) {
133
+ ctx.logger.debug(`[本地图片] 已清理 ${removed} 个过期文件`);
134
+ }
135
+ return removed;
136
+ }
137
+
138
+ /**
139
+ * 把图片元素存成本地文件,返回可公网访问的 URL。
140
+ *
141
+ * @param {object} ctx koishi context
142
+ * @param {object} cfg 插件配置(需要 qqOfficial.publicBaseUrl)
143
+ * @param {object} imageSegment 图片元素
144
+ * @returns {Promise<string|null>} 公网 URL;未配置或失败返回 null
145
+ */
146
+ async function saveImageForPublicUrl(ctx, cfg, imageSegment) {
147
+ const baseUrl = getPublicBaseUrl(cfg);
148
+ if (!baseUrl) return null;
149
+
150
+ const dir = registerStaticRoute(ctx, cfg);
151
+ if (!dir) return null;
152
+
153
+ const src = imageSegment && imageSegment.attrs && (imageSegment.attrs.src || imageSegment.attrs.url);
154
+ if (!src) return null;
155
+
156
+ // 已是公网 URL:直接返回
157
+ if (/^https?:\/\//i.test(src)) return src;
158
+
159
+ const m = /^data:([^;]+);base64,(.*)$/s.exec(src);
160
+ if (!m) return null;
161
+
162
+ const mime = String(m[1] || '').toLowerCase();
163
+ const ext = mime === 'image/png' ? 'png'
164
+ : mime === 'image/gif' ? 'gif'
165
+ : mime === 'image/webp' ? 'webp'
166
+ : 'jpg';
167
+
168
+ let buffer;
169
+ try {
170
+ buffer = Buffer.from(m[2], 'base64');
171
+ } catch {
172
+ return null;
173
+ }
174
+ if (!buffer.length) return null;
175
+
176
+ // 按内容 hash 命名:同一张图不重复写盘
177
+ const hash = crypto.createHash('sha1').update(buffer).digest('hex').slice(0, 20);
178
+ const fileName = `${hash}.${ext}`;
179
+ const filePath = path.join(dir, fileName);
180
+
181
+ try {
182
+ if (!fs.existsSync(filePath)) {
183
+ fs.writeFileSync(filePath, buffer);
184
+ // 顺手清理过期文件
185
+ cleanupExpired(ctx, dir);
186
+ } else {
187
+ // 命中已有文件:刷新 mtime,延长保留期
188
+ try { fs.utimesSync(filePath, new Date(), new Date()); } catch { }
189
+ }
190
+ } catch (error) {
191
+ ctx.logger.warn(`[本地图片] 写入失败: ${error.message}`);
192
+ return null;
193
+ }
194
+
195
+ return `${baseUrl}${ROUTE_PREFIX}/${fileName}`;
196
+ }
197
+
198
+ /**
199
+ * 读取配置里的公网地址(去掉末尾斜杠)。
200
+ */
201
+ function getPublicBaseUrl(cfg) {
202
+ const raw = cfg && cfg.qqOfficial && cfg.qqOfficial.publicBaseUrl;
203
+ if (!raw) return null;
204
+ const text = String(raw).trim();
205
+ if (!text) return null;
206
+ if (!/^https?:\/\//i.test(text)) return null;
207
+ return text.replace(/\/+$/, '');
208
+ }
209
+
210
+ module.exports = {
211
+ ROUTE_PREFIX,
212
+ MAX_AGE_MS,
213
+ getPublicBaseUrl,
214
+ getPathPrefix,
215
+ registerStaticRoute,
216
+ saveImageForPublicUrl,
217
+ cleanupExpired,
218
+ resolveImageDir,
219
+ };
@@ -0,0 +1,234 @@
1
+ /**
2
+ * 消息输出层:统一处理「文本 / 图片」两种呈现方式,并区分 onebot 与 QQ 官方机器人。
3
+ *
4
+ * 设计要点:
5
+ * - 业务代码只关心「内容」和「要不要图片」,不用处理平台差异;
6
+ * - onebot:图片就发图片,文本就发文本(原有行为完全不变);
7
+ * - QQ 官方:走「原生 markdown + keyboard」直接发送,
8
+ * 因为走元素层无法控制按钮分行(br 无效、多个 button-group 会产生空行)。
9
+ *
10
+ * 所有函数都返回可交给 session.send 的内容;
11
+ * 官方机器人下若已由本模块直接发送,则返回 SENT 标记由调用方忽略。
12
+ */
13
+
14
+ const qqOfficial = require('./qqOfficial');
15
+ const qqMediaUpload = require('./qqMediaUpload');
16
+ const qqMarkdown = require('./qqMarkdown');
17
+ const localImageHost = require('./localImageHost');
18
+
19
+ /** 表示「消息已由本模块直接发送完毕」,调用方无需再返回内容 */
20
+ const SENT = Symbol('qq-message-sent');
21
+
22
+ /**
23
+ * 是否应使用图片形式。
24
+ * @param {number|string} type 1=文本,2=图片(沿用插件现有 type 约定)
25
+ */
26
+ function wantImage(type) {
27
+ return Number(type) === 2;
28
+ }
29
+
30
+ /**
31
+ * 把纯文本转成一个适合 markdown 的正文。
32
+ * 官方适配器在 <qq:markdown> 内不转义,外面的文本会被转义,
33
+ * 所以业务文本要放进来才能保留 `**`、`#` 等语法。
34
+ */
35
+ function markdownText(text) {
36
+ return qqOfficial.markdownBlock(text);
37
+ }
38
+
39
+ /**
40
+ * 文本输出。
41
+ *
42
+ * @param {object} session koishi session
43
+ * @param {string} text 文本内容
44
+ * @param {object} [opts]
45
+ * @param {boolean} [opts.markdown] 官方机器人是否用 markdown 包裹(默认 true)
46
+ * @param {Array<Array<object>>} [opts.buttons] 按钮行(仅官方机器人有效)
47
+ * @returns {string|Array<object>|Promise<Symbol>}
48
+ */
49
+ async function renderText(session, text, opts) {
50
+ const options = opts || {};
51
+ const body = String(text == null ? '' : text);
52
+
53
+ // onebot 及未启用官方适配器:保持原样,绝不加任何元素
54
+ if (!qqOfficial.isQQOfficial(session)) {
55
+ return body;
56
+ }
57
+
58
+ const useMarkdown = options.markdown !== false;
59
+ const buttons = Array.isArray(options.buttons) ? options.buttons : [];
60
+
61
+ if (!useMarkdown && buttons.length === 0) {
62
+ return body;
63
+ }
64
+
65
+ // 官方机器人:优先走原生发送(可精确控制按钮分行)
66
+ if (typeof qqMarkdown.sendMarkdownWithButtons === 'function' && typeof session?.bot?.internal?.sendMessage === 'function') {
67
+ const sent = await qqMarkdown.sendMarkdownWithButtons(session, {
68
+ markdown: useMarkdown ? body : '',
69
+ buttons,
70
+ });
71
+ if (sent) return SENT;
72
+ }
73
+
74
+ // 回退:元素层(按钮可能挤在一行,但至少能发出去)
75
+ const children = [];
76
+ if (useMarkdown) {
77
+ children.push(markdownText(body));
78
+ } else {
79
+ children.push(require('koishi').h.text(body));
80
+ }
81
+ if (buttons.length > 0) {
82
+ children.push(qqOfficial.buttonGroup(buttons));
83
+ }
84
+ return children;
85
+ }
86
+
87
+ /**
88
+ * 图片 + 可选按钮的输出。
89
+ *
90
+ * @param {object} session
91
+ * @param {object} imageSegment segment.image(...) 产出的图片元素
92
+ * @param {object} [opts]
93
+ * @param {Array<Array<object>>} [opts.buttons] 按钮行(仅官方机器人有效)
94
+ * @param {string} [opts.caption] 图片上方的说明文本(官方机器人可选)
95
+ * @param {function(object): Promise<string|null>} [opts.uploader]
96
+ * 官方机器人下,用于把本地图片转成 markdown 可用 URL 的函数
97
+ * @returns {object|Array<object>|Promise<Symbol>}
98
+ */
99
+ async function renderImage(session, imageSegment, opts) {
100
+ const options = opts || {};
101
+
102
+ // onebot:原样返回图片元素,行为不变
103
+ if (!qqOfficial.isQQOfficial(session)) {
104
+ return imageSegment;
105
+ }
106
+
107
+ const buttons = Array.isArray(options.buttons) ? options.buttons : [];
108
+ const uploader = options.uploader || options.defaultUploader;
109
+
110
+ // 优先:上传图片 → 写进 markdown → 原生发送(图片与按钮同一条消息)
111
+ if (typeof uploader === 'function' && options.inline !== false) {
112
+ const inline = await renderInlineImage(session, imageSegment, buttons, { ...options, uploader });
113
+ if (inline) return inline;
114
+ }
115
+
116
+ // 退化:图片一条 + 按钮一条(保持可用)
117
+ if (buttons.length === 0) {
118
+ return imageSegment;
119
+ }
120
+ const children = [imageSegment];
121
+ if (options.caption) {
122
+ children.push(markdownText(options.caption));
123
+ }
124
+ children.push(qqOfficial.buttonGroup(buttons));
125
+ return children;
126
+ }
127
+
128
+ /**
129
+ * 把图片塞进 markdown 正文,与按钮共用一条消息。
130
+ * @returns {Promise<Symbol|Array<object>|null>} 成功返回 SENT;退化时返回元素数组;失败返回 null
131
+ */
132
+ async function renderInlineImage(session, imageSegment, buttons, options) {
133
+ let imageUrl = null;
134
+ try {
135
+ imageUrl = await options.uploader(imageSegment);
136
+ } catch (error) {
137
+ imageUrl = null;
138
+ }
139
+
140
+ if (!imageUrl) {
141
+ // 上传失败:交给调用方退化处理
142
+ if (buttons.length === 0) return null;
143
+ const children = [imageSegment];
144
+ if (options.caption) children.push(markdownText(options.caption));
145
+ children.push(qqOfficial.buttonGroup(buttons));
146
+ return children;
147
+ }
148
+
149
+ // markdown:说明文字 + 图片
150
+ const lines = [];
151
+ if (options.caption) lines.push(options.caption);
152
+ lines.push(`![${options.alt || 'image'}](${imageUrl})`);
153
+ const markdown = lines.join('\n');
154
+
155
+ // 原生发送(一条消息:图片 + 按钮)
156
+ if (typeof session?.bot?.internal?.sendMessage === 'function') {
157
+ const sent = await qqMarkdown.sendMarkdownWithButtons(session, { markdown, buttons });
158
+ if (sent) return SENT;
159
+ }
160
+
161
+ // 回退元素层
162
+ return qqOfficial.markdownWithButtons(markdown, buttons);
163
+ }
164
+
165
+ /**
166
+ * 按配置的 type 选择输出形式。
167
+ *
168
+ * @param {object} session
169
+ * @param {object} cfg 插件配置
170
+ * @param {string} typePath 取 type 的路径,如 'tmpMileageRanking.type'
171
+ * @param {object} content
172
+ * @param {string} content.text 文本内容
173
+ * @param {function(): Promise<object>} [content.image] 生成图片的异步函数
174
+ * @param {Array<Array<object>>} [content.buttons] 按钮行
175
+ * @returns {Promise<object|string|Symbol>}
176
+ */
177
+ async function renderByType(ctx, session, cfg, typePath, content) {
178
+ const type = readType(cfg, typePath);
179
+ const buttons = content.buttons || [];
180
+
181
+ if (wantImage(type) && typeof content.image === 'function') {
182
+ const image = await content.image();
183
+ if (image) {
184
+ // 官方机器人:把图片转成 markdown 可引用的地址,和按钮合成一条消息
185
+ return renderImage(session, image, {
186
+ buttons,
187
+ caption: content.caption,
188
+ alt: content.alt,
189
+ uploader: qqOfficial.isQQOfficial(session)
190
+ ? await buildUploader(ctx, cfg, session)
191
+ : null,
192
+ });
193
+ }
194
+ // 图片生成失败 → 回退文本,保证有回应
195
+ }
196
+
197
+ return renderText(session, content.text, { buttons });
198
+ }
199
+
200
+ /**
201
+ * 构造图片上传器:
202
+ * 1. 配置了 qqOfficial.publicBaseUrl → 存本地文件,用公网 URL(无数量限制,手机端也可显示)
203
+ * 2. 否则退回 QQ 官方 media-images 上传(有限制,但可用)
204
+ */
205
+ async function buildUploader(ctx, cfg, session) {
206
+ // 有公网域名:走本地文件 + 静态路由
207
+ if (ctx && localImageHost.getPublicBaseUrl(cfg)) {
208
+ return (seg) => localImageHost.saveImageForPublicUrl(ctx, cfg, seg);
209
+ }
210
+ // 回退:QQ 官方素材上传
211
+ return (seg) => qqMediaUpload.uploadImageForMarkdown(session, seg);
212
+ }
213
+
214
+ /** 读取形如 'a.b.c' 的配置路径 */
215
+ function readType(cfg, path) {
216
+ if (!path) return 1;
217
+ let cur = cfg;
218
+ for (const key of String(path).split('.')) {
219
+ if (cur == null) return 1;
220
+ cur = cur[key];
221
+ }
222
+ return cur == null ? 1 : cur;
223
+ }
224
+
225
+ module.exports = {
226
+ SENT,
227
+ wantImage,
228
+ markdownText,
229
+ renderText,
230
+ renderImage,
231
+ renderByType,
232
+ buildUploader,
233
+ readType,
234
+ };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * QQ 官方机器人:直接构造原生的 markdown + keyboard 消息。
3
+ *
4
+ * 为什么不用 Koishi 元素层:
5
+ * - `<button-group>` 里用 `<br>` 分隔行**无效**(适配器不处理 br,所有按钮挤一行);
6
+ * - 每个按钮一个 `<button-group>` 会产生**空行**(适配器 flush 时多 push 了空数组);
7
+ * - 结果就是按钮被压缩成「再...」「我...」这种省略号。
8
+ *
9
+ * 所以这里绕过元素层,直接拼 QQ 官方的 payload:
10
+ * { msg_type: 2, markdown: { content }, keyboard: { content: { rows } } }
11
+ * 这样每一行放几个按钮完全可控。
12
+ */
13
+
14
+ const qqOfficial = require('./qqOfficial');
15
+
16
+ const MSG_TIMEOUT = 5 * 60 * 1000 - 2000;
17
+
18
+ /**
19
+ * 构造单个按钮的原生结构(与适配器 decodeButton 保持一致)。
20
+ *
21
+ * 支持两种入参写法:
22
+ * - 原生风格:{ label, type: 'input'|'link'|'callback', text|href|id, primary }
23
+ * - 便捷风格:{ label, text } → 自动视为 input 按钮
24
+ * - 便捷风格:{ label, href } → 自动视为 link 按钮
25
+ * - 也可直接传 qqOfficial.inputButton() 产出的元素,自动转换
26
+ *
27
+ * @returns {object} QQ 官方按钮结构
28
+ */
29
+ function nativeButton(opts) {
30
+ const o = opts || {};
31
+
32
+ // 已经是原生结构(有 render_data + action)→ 幂等返回,避免二次转换把 label 清空
33
+ if (o.render_data && o.action) {
34
+ return {
35
+ render_data: {
36
+ label: String(o.render_data.label == null ? '' : o.render_data.label),
37
+ visited_label: String(o.render_data.visited_label == null ? o.render_data.label || '' : o.render_data.visited_label),
38
+ style: o.render_data.style === 1 ? 1 : 0,
39
+ },
40
+ action: {
41
+ type: o.action.type === 2 ? 2 : o.action.type === 0 ? 0 : 1,
42
+ permission: { type: 2 },
43
+ data: String(o.action.data == null ? '' : o.action.data),
44
+ },
45
+ };
46
+ }
47
+
48
+ // 已经是 Koishi 元素(inputButton 等的产出):从 attrs 里取
49
+ let label, type, text, href, id, primary;
50
+ if (o.type && o.attrs) {
51
+ const a = o.attrs || {};
52
+ label = String(Array.isArray(o.children) ? o.children.join('') : '');
53
+ type = a.type || 'input';
54
+ text = a.text;
55
+ href = a.href;
56
+ id = a.id;
57
+ primary = a.class === 'primary';
58
+ } else {
59
+ label = String(o.label == null ? '' : o.label);
60
+ type = o.type || (o.href ? 'link' : 'input');
61
+ text = o.text;
62
+ href = o.href;
63
+ id = o.id;
64
+ primary = !!o.primary;
65
+ }
66
+
67
+ const actionType = type === 'input' ? 2 : type === 'link' ? 0 : 1;
68
+ let data = '';
69
+ if (actionType === 2) data = String(text == null ? '' : text);
70
+ else if (actionType === 0) data = String(href == null ? '' : href);
71
+ else data = String(id == null ? '' : id);
72
+
73
+ return {
74
+ render_data: {
75
+ label,
76
+ visited_label: label,
77
+ style: primary ? 1 : 0,
78
+ },
79
+ action: {
80
+ type: actionType,
81
+ permission: { type: 2 },
82
+ data,
83
+ },
84
+ };
85
+ }
86
+
87
+ /**
88
+ * 把「按钮行」转成原生 keyboard 结构。
89
+ * @param {Array<Array<object>>} rows 行数组;每行是若干按钮
90
+ * @returns {object|null} keyboard;无按钮返回 null
91
+ */
92
+ function buildKeyboard(rows) {
93
+ if (!Array.isArray(rows)) return null;
94
+ const out = [];
95
+ for (const row of rows) {
96
+ if (!Array.isArray(row) || row.length === 0) continue;
97
+ out.push({ buttons: row.map(nativeButton) });
98
+ }
99
+ // QQ 键盘最多 5 行,每行最多 5 个按钮
100
+ const limited = out.slice(0, 5).map(r => ({ buttons: r.buttons.slice(0, 5) }));
101
+ return limited.length ? { content: { rows: limited } } : null;
102
+ }
103
+
104
+ /**
105
+ * 发送一条「markdown 正文 + 按钮」的官方消息(一条消息,不是多条)。
106
+ *
107
+ * @param {object} session koishi session
108
+ * @param {object} content
109
+ * @param {string} content.markdown markdown 正文
110
+ * @param {Array<Array<object>>} [content.buttons] 按钮行
111
+ * @returns {Promise<object|null>} 发送结果;不支持时返回 null(调用方回退到元素层)
112
+ */
113
+ async function sendMarkdownWithButtons(session, content) {
114
+ const c = content || {};
115
+ const markdown = String(c.markdown == null ? '' : c.markdown);
116
+
117
+ const bot = session && session.bot;
118
+ if (!bot || !bot.internal) return null;
119
+
120
+ const keyboard = buildKeyboard(c.buttons);
121
+
122
+ // 被动回复:群聊用 msg_id(同一消息内可多次回复,msg_seq 递增)
123
+ const data = {
124
+ msg_type: 2,
125
+ markdown: { content: markdown || ' ' },
126
+ msg_id: undefined,
127
+ msg_seq: undefined,
128
+ event_id: undefined,
129
+ };
130
+ if (keyboard) data.keyboard = keyboard;
131
+
132
+ // 复用被动回复窗口(5 分钟内可以 msg_id 连续回复)
133
+ const now = Date.now();
134
+ if (session.messageId && session.timestamp && now - session.timestamp < MSG_TIMEOUT) {
135
+ session['seq'] = (session['seq'] || 0) + 1;
136
+ data.msg_id = session.messageId;
137
+ data.msg_seq = session['seq'];
138
+ } else if (session.qq && session.qq.id && session.timestamp && now - session.timestamp < MSG_TIMEOUT) {
139
+ data.event_id = session.qq.id;
140
+ } else if (session.messageId) {
141
+ data.msg_id = session.messageId;
142
+ data.msg_seq = 1;
143
+ }
144
+
145
+ const send = async () => {
146
+ if (session.isDirect) {
147
+ return await bot.internal.sendPrivateMessage(session.channelId, data);
148
+ }
149
+ return await bot.internal.sendMessage(session.channelId, data);
150
+ };
151
+
152
+ try {
153
+ return await send();
154
+ } catch (error) {
155
+ // 被动回复窗口过期:不带头重发一次
156
+ const noPassive = !data.msg_id && !data.event_id;
157
+ if (!noPassive) {
158
+ delete data.msg_id;
159
+ delete data.msg_seq;
160
+ delete data.event_id;
161
+ try { return await send(); } catch { return null; }
162
+ }
163
+ return null;
164
+ }
165
+ }
166
+
167
+ module.exports = {
168
+ nativeButton,
169
+ buildKeyboard,
170
+ sendMarkdownWithButtons,
171
+ };