# 第三步能力调研与实现规格
> # ⏸️ 状态:暂缓,**未实现**
>
> 这份文档记录的**调研结论是真的**(全部经真机验证),但**按钮功能已于 2026-10-10 按用户要求整批回退**,
> 当前代码里**没有任何按钮**。
>
> **为什么保留这份文档**:里面每一条实测结论都是花真机验证换来的——
> `markdownSupport` 默认 `false` 导致键盘不渲染、`action.type: 2` 会把 `data` 填进输入框、
> 5×5 可用、QQ 的 markdown **不吃下划线**、超长标签 QQ 自己截断。
> 这些与"要不要做按钮"无关,将来做任何 QQ 交互功能都用得上。
>
> ⚠️ **不要**把这份文档当成"已实现的设计"来读。第四节是**当时的设计意图**,不是现状。
> ## ⚠️ 实测结论(2026-10-10,真机验证过,**优先于下面的一切推断**)
>
> ### 1. 键盘只在 `msg_type=2`(markdown)下渲染
>
> `QQBotOptions.markdownSupport` **默认 `false`**(`QQBot.d.ts`:*"Whether the bot has
> markdown permission. Defaults to false."*),于是 `sendText` 发 `msg_type=0`,
> **纯文本不渲染键盘**——`sendTextWithKeyboard` 因此无效。
>
> 真机结果:走 `sendTextWithKeyboard` 的 ①③④ **全都没有按钮**;只有显式
> `sendMarkdown(target, text, { keyboard })` 的 ② 有按钮。
>
> **结论:凡是带按钮的消息,一律用 `sendMarkdown`。**
> 不选"全局开 `markdownSupport`",因为我方消息里**天然含下划线**
> (`qq_session_control`、`dsh_qq`、`pendingInteractions`),markdown 会把它当斜体标记
> 吃掉,渲染成 `qqsessioncontrol`——看起来像工具名坏了。
> 只让按钮消息走 markdown,且那些消息的内容由我们控制,风险最小。
>
> ### 2. 5×5(25 个按钮)与超长标签**都可行**
>
> 真机确认 ②③④ 完全可行。行列上限与 label 长度的具体边界未逐一测,但正常用量绰绰有余。
>
> ### 3. ★ 按钮用 `action.type: 2`(指令按钮),**不触发回调**
>
> 真机行为:**点击后把 `action.data` 填进输入框**,用户再按发送。
> 它不是 `INTERACTION_CREATE` 回调。
>
> **这是本步骤最重要的设计决定,因为它把四个功能都简化了:**
>
> | | 回调按钮(`type:1`) | **指令按钮(`type:2`)← 采用** |
> |---|---|---|
> | 处理 `INTERACTION_CREATE` | 要 | **不用** |
> | 5 秒内 ACK(`acknowledgeInteraction`) | 必须 | **不用** |
> | 误点后果 | **直接执行** | **只填进输入框,用户还能改** |
> | 复用现有解析 | 要新增入口 | **完全复用**:它就是"用户打了这几个字" |
> | 重放 / nonce | 要防 | **不用防**(每次都要用户亲手发送) |
>
> **按钮退化成"输入的快捷方式"。** 审批的「允许」「拒绝」、选项的「1」、
> 切换的「/switch 2」——全部走**已有的**解析路径
> (`#settleInteraction` / `parseApprovalReply` / `parseQuestionReply` / `SessionRouter`),
> **一行新的解析逻辑都不用写**。
>
> 因此 `action.data` 必须**就是用户本该输入的那串文本**,
> 而 `render_data.label` 与它保持一致(否则用户看到的按钮和填进去的内容对不上,
> 那比没有按钮更让人困惑)。
>
> ### 4. ❌ 流式输出**不做**(用户明确取消)
>
> `openStream` 的调研结论保留在下面供日后参考,但**本步骤不实现**。
>
> ### 5. SDK 会吞掉 handler 里的异常
>
> `USAGE.md:265`:*"handler 抛错会被 SDK 捕获并打到 `logger.error`,不会传播为
> unhandled rejection。"*
>
> **探针与插件都必须传 `logger`**,否则 handler 出错时日志里**一片空白**——
> 我为此浪费了一轮:探针的 `message` handler 因为在箭头函数里用了 `arguments`
> 而第一行就 ReferenceError,但因为没有 logger,现象只是"连上了、但发消息没反应"。
> **空白应当被当成"缺少观测",而不是"没有发生"。**
>
> ### 6. ★ markdown **不吃下划线**(推翻了我原先的担心),所以**不用转义**
>
> 真机截图确认(同一行三种写法并排):
>
> | 写法 | 显示结果 |
> |---|---|
> | 裸下划线 `qq_session_control 与 dsh_qq` | **下划线完好**,没有被渲染成斜体 |
> | 反引号包住 `` `qq_session_control` `` | 等宽代码样式,下划线完好 |
> | 反斜杠转义 `qq\_session\_control` | **反斜杠被消费掉**,下划线完好 |
>
> 结论:**按钮消息里可以照常写下划线**(工具名、`dsh_qq`、字段名都不必处理);
> **反斜杠转义不要用**(它只会多出一个被吃掉的字符)。
> 想强调标识符时可以用反引号(渲染成等宽,读起来更像代码),但那是可选的美化。
>
> ### 7. ★ `action.data` 会**原封不动**出现在输入框里
>
> 真机证据:探针 ⑤ 的按钮把 `data` 设成了内部记号 `escape:one`,
> 点击后**输入框里就是 `escape:one`**。
>
> **这是本步骤的设计铁律:**
>
> > `action.data` 必须**就是用户本该输入的那串文本**。
> > 放 `approve:allow`、`switch:2` 这种内部记号,用户会看到一串看不懂的东西——
> > **比没有按钮更糟**,因为他不知道该不该按发送。
>
> 所以:审批是 `允许`、选项是 `1`、切换是 `/switch 2`、停止是 `/cancel`。
> 相应地 `render_data.label` 与 `data` 保持**意思上的一致**(按钮上写的要让人预期它会填什么)。
>
> ⚠️ **"一致"不等于"逐字相等"**(实现时才发现,此处按实际实现澄清):
>
> - **用户本来就会打那个词**的按钮(审批的「允许」、选项的「1」)→ label 与 data **逐字相等**。
> - **动作按钮**(「重新生成」→ `/retry`、「停止」→ `/cancel`)与**会话按钮**
> (「2 修复登录」→ `/switch 2`)→ label 是给人看的,data 是那条指令。
> 把它们写成逐字相等反而更糟:按钮上写 `/switch 2` 或 `/retry`,
> 用户看到的是命令而不是他想要的东西。
>
> 断言按这个口径钉:审批/选项按钮钉"label === data",
> 动作/会话按钮钉"data 是恰好那串指令,且**不含任何内部记号形态**"。
>
> ### 8. QQ 会自己截断过长的按钮标签
>
> 真机:24 字的标签显示成「这是一个非常长的按钮标签用来试探长度上限究竟**...**」。
> 所以**标签长度要自己控制在合理范围**(会话标题这类用户数据尤其要截断),
> 不能指望 QQ 截得好看。
---
**结论先说:四个功能(审批按钮 / 选项按钮 / 会话列表与切换按钮 / 重新生成与停止按钮)
靠 `action.type: 2` 的指令按钮实现,不走 `INTERACTION_CREATE`。**
**流式输出不做。**
调研对象:已安装的 `@tencent-connect/qqbot-nodejs@1.0.4` 的**类型定义与 USAGE.md**。
每条都标了 `文件:行`,改动前请自行复核。
---
## 一、按钮:发送方式与形状
### 发送(**只用这一条**)
```ts
// QQBot.d.ts:356
sendMarkdown(target: ReplyTarget, content: string, opts?: { keyboard?: InlineKeyboard }): Promise
```
⚠️ **不要用 `sendTextWithKeyboard`**(`QQBot.d.ts:343`)——它在 `markdownSupport`
默认 `false` 时发 `msg_type=0`,**键盘不渲染**。真机已确认。
### 形状(`protocol/types.d.ts:135` 与 `:152`)
```ts
interface InlineKeyboard {
content: { rows: Array<{ buttons: KeyboardButton[] }> }
}
interface KeyboardButton {
id: string
render_data: { label: string; visited_label: string; style: number }
action: {
type: number // ★ 用 2(指令按钮):点击把 data 填进输入框
permission: { type: number }
data: string // ★ 必须就是"用户本该输入的那串文本"
click_limit?: number
}
group_id?: string
}
```
`render_data.style`:`0` = 灰、`1` = 蓝(真机确认两种都能显示)。
### 官方示例(`USAGE.md:288`)
```ts
{
id: "btn-confirm",
render_data: { label: "确认", visited_label: "已确认", style: 1 },
action: { type: 2, permission: { type: 2 }, data: "confirmed" },
}
```
---
## 二、回调按钮(**本步骤不用**,仅存档)
`USAGE.md:481` 的 §12:
```ts
bot.on("interaction", async (ctx, event) => {
console.log("按钮点击:", event.data.resolved.button_id, event.data.resolved.button_data);
// QQ 平台要求 5 秒内 ACK
await bot.acknowledgeInteraction(event.id, 0); // 0=成功
});
```
ACK code:`0` 成功、`1` 操作失败、`2` 操作频繁、`3` 重复操作、`4` 没有权限、`5` 仅管理员。
`InteractionEvent`(`types.d.ts:159`):`id`、`type`、`data.resolved.{button_id,button_data,user_id,message_id}`。
⚠️ `InteractionType`(`constants.d.ts:67`)只导出 `CONFIG_QUERY: 2001` /
`CONFIG_UPDATE: 2002` 两个**配置类**交互,按钮点击的 `data.type` 取值未确认;
而且从根模块 `import` 时它是 `undefined`。**所以处理逻辑不该依赖它。**
---
## 三、流式输出(**不做**,存档备查)
`QQBot.d.ts:444`:
```ts
openStream(opts: { target: ReplyTarget; eventId?: string; throttleMs?: number }): StreamSession
```
官方注释(`QQBot.d.ts:441`):*call `update(fullText)` repeatedly, then `complete()`.*
`update(fullText)` 传**全文**(协议 `input_mode: 'replace'`),节流内置(`throttleMs`)。
底层 `StreamMessageRequest`:`input_state: GENERATING(1)/DONE(10)`、`content_type: 'markdown'`、
`msg_id`、`msg_seq`、`index`、`stream_msg_id?`。
由字段可推出:流式挂在 `msg_id` 上(被动窗口内),所以**主动推送大概不能流式**;
`input_state` 必须显式以 `DONE` 收尾。
**用户已决定不做,本节仅存档。**
---
## 四、实现设计(四个功能,一个机制)
### 4.0 公共部件:`lib/buttons.js`
- `buildKeyboard(rows)` —— 把 `[[{label, data, style}]]` 组装成 `InlineKeyboard`
- `keyboardMessage(text, rows)` —— 一行封装:不要 keyboard 时退回纯文本 `sendText`,
要 keyboard 时走 `sendMarkdown`。**这个判断只该有一个地方做。**
- **markdown 转义**:因为按钮消息走 markdown,而我们的文本含下划线。
需要 `mdEscape(text)`(反引号包住标识符,或反斜杠转义)。
⚠️ 真机上"反引号 vs 裸下划线 vs 反斜杠"三种写法**尚未逐一看过**,
实现前先看一眼,或保守地一律用反引号包住标识符。
### 4.1 审批按钮(`prompt_keyboard`)
`#ask` 发审批提示时附一行 `[允许] [拒绝]`:
```js
[{ label: '允许', data: '允许', style: 1 }, { label: '拒绝', data: '拒绝', style: 0 }]
```
- 点击 → 输入框出现「允许」→ 用户发送 → 作为**普通文字消息**到达
- 走**现有的** `#settleInteraction` → `parseApprovalReply('允许')` → `'allowed-once'`
- **一行解析逻辑都不用改**
### 4.2 选项按钮(提问)
`presentQuestions(questions)` 已经把选项编成 `1. 方案A` 这样的编号。
按钮就是这些编号:
```js
rows = 每行最多 5 个(真机确认 5×5 可行),label = '1'、'2'…
data = '1'、'2'…
```
- 单选:点一个即可
- 多选:现有的 `parseQuestionReply` 已支持 `1,3` / `1 2` / 全角逗号;
多选时按钮只能填一个数字,所以**多选场景仍以打字为主**,
按钮作为"至少要选第一个"的便利(或**多选时干脆不放按钮**——
实现时按"不制造误导"的原则决定)
### 4.3 会话列表与切换按钮(新增)
`/list` 的回复已经是编号列表。给每一行对话一个按钮:
```js
label = '2 修复登录'(编号 + 标题,截断到合适长度)
data = '/switch 2'
```
- 点击 → 输入框出现 `/switch 2` → 用户发送 → **`SessionRouter` 现成的** `/switch` 处理
- `/switch` 成功后的确认消息**不附按钮**(微信的教训:确认必须由频道自己说清楚,
再挂一排按钮会让它看起来像个待办菜单)
- 对话数超过一行容量(5)时**分行**;超过两行(10)就只放前 10 个并提示用 `/list all`
### 4.4 重新生成 / 停止按钮(`action_button`)
在每条回复后附一行:
```js
[{ label: '重新生成', data: '/retry' }, { label: '停止', data: '/cancel', style: 0 }]
```
- `停止` → 复用**现成的** `/cancel` ✓
- `重新生成` → **需要在 `SessionRouter` 里新增 `/retry`**:
重新提交该会话最近一次的用户输入。
这要求运行日记住 `lastPrompt per session`(`#lastPrompts` Map)。
`/retry` 作为聊天指令本身也有用,不只是给按钮用。
- ⚠️ **每条回复都挂按钮可能造成刷屏感**。实现时应当:
只在"回答完成"的那一条挂(而不是流式的每一条),
并且**如果回复本来就带了别的键盘(如审批),就不要叠加**。
---
## 五、动手前必须补测的项
第一轮探针已经定案了大部分,剩下这些**实现前要亲眼看一下**:
| # | 待确认 | 为什么重要 |
|---|---|---|
| 1 | markdown 里下划线的三种写法(裸 / 反引号 / 反斜杠)各渲染成什么 | 决定按钮消息里工具名怎么写。**上一轮没看到结果** |
| 2 | `click_limit` 的默认行为(不设时能点几次) | 按钮该不该设它 |
| 3 | 点了按钮但**不发送**,直接改内容再发,会怎样 | 确认"指令按钮只是填输入框"这一结论的边界 |
| 4 | 超长 label 是被截断还是按钮不显示 | 会话标题很长时的表现 |
| 5 | 主动推送的消息能否带 keyboard | 「干完了告诉你」能不能也带按钮 |
---
## 六、给实现的硬约束(与既有事故记录一脉相承)
1. **客户端改动必须整份包在 IIFE 里**(`POSTMORTEM.md` 004:顶层作用域是所有插件共享的,
泄漏一个 `const` 会让**整个应用起不来**)。
2. **不得给客户端模块的 `inject` 新增必需服务**(客户端条目激活失败会走启动审计,
同样让整个应用起不来)。用 `ctx.get` 可选获取 + 降级。
3. **所有脱离 await 链的异步工作必须经 `#fire`**(`POSTMORTEM.md` 003:定时器与流回调里
同步抛出 = 宿主眼中的致命错误)。
4. **必须给 `QQBot` 传 `logger`**(本文件顶部第 5 条):否则 handler 抛错在日志里是空白。
5. **任何新设置项都要过上下限校验**,并且**每个设置项都必须真的有强制点**
(曾经查出 `allowCrossSessionNotify` 是个只存在于设置面板的死设置)。
6. **改完必须跑**:`verify.mjs`、`verify-client.mjs`、`check-cross-plugin.mjs`、`check-unbound.mjs`。
7. 新增的每个检查器/断言都要有**反向对照**(能抓住已知故障),否则它只是让人以为验过了。
---