@zxh126/pi-qqbot 2.4.1 → 2.6.2

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/constants.ts CHANGED
@@ -5,6 +5,8 @@ export const TOKEN_URL = "https://api.bot.qq.com/app/getAppAccessToken";
5
5
  export const REST_BASE_PROD = "https://api.bot.qq.com";
6
6
  export const REST_BASE_SANDBOX = "https://sandbox.api.sgroup.qq.com";
7
7
  export const INTENT_GROUP_AND_C2C = 1 << 25;
8
+ /** 按钮点击回调(INTERACTION_CREATE)*/
9
+ export const INTENT_INTERACTION = 1 << 26;
8
10
  export const PASSIVE_WINDOW_MS = 60 * 60 * 1000; // 单聊被动窗口 60 分钟
9
11
  export const MAX_REPLIES_PER_MSG_ID = 4;
10
12
  export const APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
@@ -12,9 +12,9 @@
12
12
  |---|---|
13
13
  | **对话驱动 agent** | QQ 单聊消息送入当前 pi 会话,AI 的回复发回 QQ(长文本自动分片)。 |
14
14
  | **消息队列** | 正在处理任务时,后续 QQ 消息会被**排队**而不是被拒;当前任务结束(settled)后按到达顺序自动逐条执行。`/cancel` 是例外——立即生效并清空队列。 |
15
- | **远程审批** | pi 执行 `bash` / `powershell` / `write` / `edit`,或任何带 `destructiveHint` 注解的工具前,先在 QQ 里申请审批。 |
15
+ | **远程审批** | pi 执行 `bash` / `powershell` / `write` / `edit`,或任何带 `destructiveHint` 注解的工具前,先在 QQ 里申请审批(`markdownReply` 开启时审批卡片带内联按钮,文本回复同样有效)。 |
16
16
  | **远程上下文提醒** | **每个会话第一次真正把 QQ 消息交给 pi 跑起来**时,才告诉它「对话来自远程 QQ 用户、本机可能锁屏」:桌面截屏 / 屏幕 OCR / UI 自动化 / 带界面的浏览器默认禁用,要图就离屏渲染再用 `qq_send_file` 发回。仅载入扩展、或本机 TUI 直接对话时都不注入;上下文压缩后自动重新注入。 |
17
- | **会话级「以后都同意」** | 回复「以后都同意」,**当前会话**内所有审批请求直接放行,连已经挂起的那条也一起放行。 |
17
+ | **会话级「以后都同意」** | 点审批卡片上的 🚀 以后都同意(或回复「以后都同意」),**当前会话**内所有审批请求直接放行,连已经挂起的那条也一起放行。 |
18
18
  | **单实例锁** | 只有一个 pi 进程作为宿主连接 QQ,其余待命,宿主退出后自动接管。 |
19
19
  | **中断正在运行的任务** | 发 `/cancel`(或「取消」/「停止」),当前任务立刻中断 —— QQ 回复、待审批工具调用、写到一半的回答、排队中的消息一并清理。 |
20
20
  | **斜杠命令路由** | 以 `/` 开头的 QQ 消息会先被路由:pi 的扩展命令、prompt 模板、`/skill:name` 交给 pi 派发;一部分 pi 内置命令(`/reload`、`/new`、`/compact`、`/session`、`/name`、`/model`、`/thinking`、`/quit`)由本扩展接管;只能在主机执行的内置命令直接回执,不会送给模型。 |
@@ -87,7 +87,7 @@ pi install ./pi-qqbot
87
87
  | `appId` / `appSecret` | 必填 | QQ 开放平台机器人凭据 |
88
88
  | `sandbox` | `false` | 未提审上线的 bot 用 `true`(沙箱环境) |
89
89
  | `allowlist` | `[]` | openid 白名单,空 = 不限制 |
90
- | `markdownReply` | `false` | QQ 单聊不支持 markdown,保持 `false` |
90
+ | `markdownReply` | `false` | 用 QQ markdown 消息(msg_type=2)回复,GFM 表格可渲染,审批与选择列表附带内联按钮(自定义按钮需平台内邀开通);需要该 bot 的 markdown 权限,发送失败自动回退纯文本 |
91
91
  | `streaming` | `false` | 预留,v1 未启用流式 |
92
92
  | `approve.tools` | `["bash","powershell","write","edit"]` | 需要审批的工具名;带 `destructiveHint` 注解的工具同样会拦截 |
93
93
  | `approve.remember` | `true` | 会话内按工具记住决策(同类工具不再重复询问) |
@@ -241,10 +241,10 @@ QQ run 还在跑时插话不处理(那时不会开新的 run)。`/status`
241
241
  | `/name [名字]` | 查看或设置会话名 | `commands.allowSession` |
242
242
  | `/model [provider/model]` | 列出可用模型或切换 | `commands.allowSession` |
243
243
  | `/thinking [等级]` | 查看或设置思考等级 | `commands.allowSession` |
244
- | `/tree` | 列出会话树上的分支点(编号),回编号即跳转 | `commands.allowSession` |
245
- | `/fork` | 列出之前的用户消息(编号),回编号即从该处分叉 | `commands.allowSession` |
244
+ | `/tree` | 列出会话树上的分支点(编号),回编号或点按钮即跳转 | `commands.allowSession` |
245
+ | `/fork` | 列出之前的用户消息(编号),回编号或点按钮即从该处分叉 | `commands.allowSession` |
246
246
  | `/clone` | 在当前叶子位置复制整个会话 | `commands.allowSession` |
247
- | `/resume` | 列出本项目其他会话(编号),回编号即切换 | `commands.allowSession` |
247
+ | `/resume` | 列出本项目其他会话(编号),回编号或点按钮即切换 | `commands.allowSession` |
248
248
  | `/quit` | 关掉主机上的 pi | `commands.allowQuit`(默认 **false**) |
249
249
  | `/update` | 从 npm 更新 pi-qqbot(`pi update`)并重载自己,见[从 QQ 更新自己](#从-qq-更新自己) | `commands.allowUpdate`(默认 **false**) |
250
250
 
@@ -277,10 +277,17 @@ custom entry 类型名,以及它写在 `~/.pi/agent/` 下的两个配置文件
277
277
  —— 先发 `/cancel`。
278
278
  - prompt 模板与 `/skill:name` 会开启一轮 agent run,因此忙碌时会像普通消息一样**排队**
279
279
  (见 [忙碌时的队列](#忙碌时的队列));自己管理 LLM 交互的扩展命令仍然立即执行。
280
- - 选择型命令(`/tree`、`/fork`、`/resume`)会回一个最多 10 项的编号列表,只对该用户有效、10 分钟内有效;
280
+ - 选择型命令(`/tree`、`/fork`、`/resume`)会回一个最多 10 项的编号列表(`markdownReply` 开启时附带点选按钮),只对该用户有效、10 分钟内有效;
281
281
  该用户下一条「纯数字」消息即执行选择,其它内容(或「取消」)则放弃。没有待选列表时,数字就是普通对话。
282
282
  - `/reload` 默认关闭:它会重启 bot,如果新代码加载失败,bot 会一直哑着,只能在主机上救。
283
283
  置 `commands.allowReload: true` 才启用。
284
+ - 直接发桥接命令(`/qq reload`、`/qq quit`、`/qq new`、`/qq list fork` …)走**同一套门禁**:子命令会先
285
+ 翻回对应的内置命令名,所以 `commands.allowReload` / `allowQuit` / `allowUpdate` / `allowSession`
286
+ 与忙碌检查全部生效。若按普通扩展命令派发(2.6.1 之前的行为),这些门禁会全被绕过 ——
287
+ `allowQuit: false` 时发 `/qq quit` 也能关掉主机 pi。自毁类子命令(`reload`、`new`、`quit`、`clone`)
288
+ 会**先回执再派发**,因为动作会换掉它自己用来回执的运行时。
289
+ - 派发回执显示**完整命令**(`已派发 /deploy prod 给 pi。`),超 80 字截断加 `…`;不再只显示首段,
290
+ 把参数吞掉(旧回执是 `已派发 /qq 给 pi。`,看起来像 `/qq reload` 的 `reload` 没生效)。
284
291
  - 为什么要绕一层:pi 只对**扩展命令**执行派发(`prompt()` → `_tryExecuteExtensionCommand`),而
285
292
  `reload()` / `newSession()` 又只能在命令上下文里调用。于是本扩展注册了 `/qq`,把 `/reload`
286
293
  改派成 `/qq reload`。内置命令名是硬编码的(pi 没有把内置清单导出给扩展);任何不认识的
@@ -441,7 +448,10 @@ provider 报错以前在 QQ 侧等于「没反应」:pi 在 assistant 消息
441
448
  要本会话内全部放行,回复「以后都同意」(可用「取消自动同意」关闭)。
442
449
  ```
443
450
 
444
- 回复用语(审批关键词以中文为主,也支持英文别名):
451
+ `markdownReply: true` 时卡片附带 ✅ 允许一次 / ❌ 拒绝 / 🚀 以后都同意 三个按钮,点按钮与回复等价;
452
+ 强制审批工具(`qq_send_file`)只显示前两个按钮。无论是否带按钮,文本回复始终有效。
453
+
454
+ 回复用语(审批关键词以中文为主,也支持英文别名;有按钮时点卡片按钮更快):
445
455
 
446
456
  | 意图 | 回复 |
447
457
  |---|---|
@@ -481,7 +491,7 @@ QQ 网关对同一 bot 只保留一个活跃连接。插件用 `proper-lockfile`
481
491
 
482
492
  ## 变更日志
483
493
 
484
- 见 [CHANGELOG.md](../CHANGELOG.md):每个版本改了什么、为什么改。进度汇报的暂停/恢复(`/progress off` / `on`)在 **1.5.0** 加入;多 bot 自动抢占在 **2.0.0**;接收 QQ 图片/文件(落盘后以 `图片路径 <path>` 这类标记行交给 pi)在 **2.1.0**;把本机文件发到 QQ(`qq_send_file` / `/sendfile`,富媒体分片上传)在 **2.2.0**。
494
+ 见 [CHANGELOG.zh-CN.md](../CHANGELOG.zh-CN.md):每个版本改了什么、为什么改。(English: [CHANGELOG.en.md](../CHANGELOG.en.md))进度汇报的暂停/恢复(`/progress off` / `on`)在 **1.5.0** 加入;多 bot 自动抢占在 **2.0.0**;接收 QQ 图片/文件(落盘后以 `图片路径 <path>` 这类标记行交给 pi)在 **2.1.0**;把本机文件发到 QQ(`qq_send_file` / `/sendfile`,富媒体分片上传)在 **2.2.0**。
485
495
 
486
496
  ## License
487
497
 
package/docs/accounts.md CHANGED
@@ -1,88 +1,88 @@
1
- # 多 QQ bot(2.0)
2
-
3
- 一份配置里可以写多个 QQ bot;同一台机器上每个 pi 进程启动时,按配置顺序找**第一个没上线的**
4
- bot 接入,抢不到的进程待命并每 5 秒重扫。进程退出(或崩溃导致的锁心跳超时)后,该 bot 下线,
5
- 待命进程接管。
6
-
7
- ## 配置
8
-
9
- ### 多 bot(v2)
10
-
11
- ```jsonc
12
- // ~/.pi/agent/pi-qqbot.json
13
- {
14
- "qqbots": [
15
- { "name": "work", "appId": "1903340143", "appSecret": "……", "sandbox": true },
16
- { "name": "home", "appId": "1020123456", "appSecret": "……", "sandbox": false }
17
- ],
18
-
19
- // 以下是全局默认值,qqbots 里可以逐键覆盖
20
- "queue": { "max": 20, "ack": true },
21
- "progress": { "enabled": true, "intervalMs": 15000 }
22
- }
23
- ```
24
-
25
- - **顺序就是抢占顺序**:先起的进程拿 `work`,第二个进程拿 `home`,第三个进程抢不到就待命。
26
- - `name` 缺省为 `bot-<appId 后 6 位>`;日志、`/status`、接管提示都用它。
27
- - `name` 或 `appId` 重复、凭据缺失 → 该条被跳过并写日志,其余照常可用(不会双连接同一个 bot)。
28
- - 同时写顶层 `appId` 和 `qqbots` → 报错,插件不接入(歧义不猜)。
29
-
30
- ### 单 bot(v1,原格式,无需改动)
31
-
32
- ```json
33
- { "appId": "1903340143", "appSecret": "……", "sandbox": true }
34
- ```
35
-
36
- 等价于只有一个名叫 `default` 的 bot,行为与 1.x 完全一致。凭据也仍可来自
37
- `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 环境变量(只用于单 bot 场景)。
38
-
39
- ## 实例身份
40
-
41
- | 场景 | 表现 |
42
- |---|---|
43
- | 首次接入 | 日志 `[lock] 接入 qqbot "work" (1903340143) · cwd=E:\Projects\foo · pid=1234`;QQ 首条回执前加「(已接入 qqbot「work」)」;`/status` 显示 `bot: work (…0143) · cwd: …` |
44
- | 换进程接管 | 日志记录新旧 pid 与新旧 cwd;QQ 首条回执前加「(qqbot「work」已由新实例接管 · 当前工作目录 E:\Projects\foo)」 |
45
- | 全部在线 | 日志 `[lock] 无可接入的 qqbot(…),本进程待命,5s 后重扫`;本进程不连 QQ,pi 本身照常用 |
46
- | 会话重建(`/reload`、`/new`、`/fork`、`/resume`) | 释放锁**之前**先写 20 秒预约窗口,其他进程不会趁机抢走;新 runtime 用自己的 pid 认回同一个 bot,静默不打扰 |
47
- | 凭据无效 | 抢到锁后先换一次 `access_token`;失败就**放锁跳过该 bot**(不占槽位),继续试下一个。日志:`[lock] qqbot "x" 凭据不可用:code=100016 invalid appid or secret → 已放锁并跳过该 bot`。全部不可用时不空转重扫 |
48
-
49
- `/status` 一眼确认:
50
-
51
- ```
52
- bot: work (…0143) · cwd: E:\Projects\foo
53
- 状态: 空闲
54
- ```
55
-
56
- > **工作目录与 bot 无绑定。** 分配只看「配置顺序 + 谁在线」,跟 cwd 无关:
57
- > 同一台机器上可以在不同的项目目录各开一个 pi,也可以在一个大工作区(里面好几个项目)里
58
- > 同时开几个 pi——后者照样一人接一个 bot,互不干扰。cwd 只用于日志、接管提示和 `/status` 展示,
59
- > 让你能分辨「现在是哪个实例在管这个 bot」。
60
-
61
- ## 可选:钉住某个 bot
62
-
63
- 自动抢占是默认行为。调试或需要「这个目录固定用某个 bot」时,用环境变量钉住:
64
-
65
- ```powershell
66
- $env:PI_QQBOT_ACCOUNT = "work"; pi
67
- ```
68
-
69
- 钉选时只尝试该账号(可写 name 或 appId);名字不存在则**不接入**并写日志(不会乱接别的 bot)。
70
-
71
- ## 能/不能
72
-
73
- - **能**:同一台机器上开 N 个 pi,各接一个 bot,互不串话;目录随意(同一目录开多个也成立,目录只是 pi 的工作区);某个 pi 退出后由待命进程接管。
74
- - **能**:一个 bot 同一时刻只有一个进程连着(锁 + 心跳 + 30 秒僵尸锁回收)。
75
- - **不能**:一个 pi 进程同时服务多个 bot(扩展的 session 是单租户模型)。要多路并行就多开 pi 进程。
76
-
77
- ## 日志
78
-
79
- 多 bot 场景下日志按账号分文件:`~/.pi/agent/pi-qqbot-<name>.log`(单 bot 旧格式仍写
80
- `pi-qqbot.log`)。身份记录与锁在 `~/.pi/agent/pi-qqbot-<appId>.lock` / `…owner.json`。
81
-
82
- ## 排查:某个 bot 一直不上线
83
-
84
- 1. 看日志:`凭据不可用:code=100016 invalid appid or secret` → appId/secret 写错了
85
- (可用 `POST https://api.bot.qq.com/app/getAppAccessToken` 自己验一下);
86
- 2. 看日志:`本轮无可接入的 qqbot(x:在线)` → 这个 bot 已被别的 pi 进程连着;
87
- 3. 看日志:`被 pid 1234 重载中` → 对方正在 `/reload`,等几秒即可;
88
- 4. 什么都没打 → 确认配置能解析(`loadAccounts()` 报错会写 `[setup] 配置问题:`)。
1
+ # 多 QQ bot(2.0)
2
+
3
+ 一份配置里可以写多个 QQ bot;同一台机器上每个 pi 进程启动时,按配置顺序找**第一个没上线的**
4
+ bot 接入,抢不到的进程待命并每 5 秒重扫。进程退出(或崩溃导致的锁心跳超时)后,该 bot 下线,
5
+ 待命进程接管。
6
+
7
+ ## 配置
8
+
9
+ ### 多 bot(v2)
10
+
11
+ ```jsonc
12
+ // ~/.pi/agent/pi-qqbot.json
13
+ {
14
+ "qqbots": [
15
+ { "name": "work", "appId": "1903340143", "appSecret": "……", "sandbox": true },
16
+ { "name": "home", "appId": "1020123456", "appSecret": "……", "sandbox": false }
17
+ ],
18
+
19
+ // 以下是全局默认值,qqbots 里可以逐键覆盖
20
+ "queue": { "max": 20, "ack": true },
21
+ "progress": { "enabled": true, "intervalMs": 15000 }
22
+ }
23
+ ```
24
+
25
+ - **顺序就是抢占顺序**:先起的进程拿 `work`,第二个进程拿 `home`,第三个进程抢不到就待命。
26
+ - `name` 缺省为 `bot-<appId 后 6 位>`;日志、`/status`、接管提示都用它。
27
+ - `name` 或 `appId` 重复、凭据缺失 → 该条被跳过并写日志,其余照常可用(不会双连接同一个 bot)。
28
+ - 同时写顶层 `appId` 和 `qqbots` → 报错,插件不接入(歧义不猜)。
29
+
30
+ ### 单 bot(v1,原格式,无需改动)
31
+
32
+ ```json
33
+ { "appId": "1903340143", "appSecret": "……", "sandbox": true }
34
+ ```
35
+
36
+ 等价于只有一个名叫 `default` 的 bot,行为与 1.x 完全一致。凭据也仍可来自
37
+ `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 环境变量(只用于单 bot 场景)。
38
+
39
+ ## 实例身份
40
+
41
+ | 场景 | 表现 |
42
+ |---|---|
43
+ | 首次接入 | 日志 `[lock] 接入 qqbot "work" (1903340143) · cwd=E:\Projects\foo · pid=1234`;QQ 首条回执前加「(已接入 qqbot「work」)」;`/status` 显示 `bot: work (…0143) · cwd: …` |
44
+ | 换进程接管 | 日志记录新旧 pid 与新旧 cwd;QQ 首条回执前加「(qqbot「work」已由新实例接管 · 当前工作目录 E:\Projects\foo)」 |
45
+ | 全部在线 | 日志 `[lock] 无可接入的 qqbot(…),本进程待命,5s 后重扫`;本进程不连 QQ,pi 本身照常用 |
46
+ | 会话重建(`/reload`、`/new`、`/fork`、`/resume`) | 释放锁**之前**先写 20 秒预约窗口,其他进程不会趁机抢走;新 runtime 用自己的 pid 认回同一个 bot,静默不打扰 |
47
+ | 凭据无效 | 抢到锁后先换一次 `access_token`;失败就**放锁跳过该 bot**(不占槽位),继续试下一个。日志:`[lock] qqbot "x" 凭据不可用:code=100016 invalid appid or secret → 已放锁并跳过该 bot`。全部不可用时不空转重扫 |
48
+
49
+ `/status` 一眼确认:
50
+
51
+ ```
52
+ bot: work (…0143) · cwd: E:\Projects\foo
53
+ 状态: 空闲
54
+ ```
55
+
56
+ > **工作目录与 bot 无绑定。** 分配只看「配置顺序 + 谁在线」,跟 cwd 无关:
57
+ > 同一台机器上可以在不同的项目目录各开一个 pi,也可以在一个大工作区(里面好几个项目)里
58
+ > 同时开几个 pi——后者照样一人接一个 bot,互不干扰。cwd 只用于日志、接管提示和 `/status` 展示,
59
+ > 让你能分辨「现在是哪个实例在管这个 bot」。
60
+
61
+ ## 可选:钉住某个 bot
62
+
63
+ 自动抢占是默认行为。调试或需要「这个目录固定用某个 bot」时,用环境变量钉住:
64
+
65
+ ```powershell
66
+ $env:PI_QQBOT_ACCOUNT = "work"; pi
67
+ ```
68
+
69
+ 钉选时只尝试该账号(可写 name 或 appId);名字不存在则**不接入**并写日志(不会乱接别的 bot)。
70
+
71
+ ## 能/不能
72
+
73
+ - **能**:同一台机器上开 N 个 pi,各接一个 bot,互不串话;目录随意(同一目录开多个也成立,目录只是 pi 的工作区);某个 pi 退出后由待命进程接管。
74
+ - **能**:一个 bot 同一时刻只有一个进程连着(锁 + 心跳 + 30 秒僵尸锁回收)。
75
+ - **不能**:一个 pi 进程同时服务多个 bot(扩展的 session 是单租户模型)。要多路并行就多开 pi 进程。
76
+
77
+ ## 日志
78
+
79
+ 多 bot 场景下日志按账号分文件:`~/.pi/agent/pi-qqbot-<name>.log`(单 bot 旧格式仍写
80
+ `pi-qqbot.log`)。身份记录与锁在 `~/.pi/agent/pi-qqbot-<appId>.lock` / `…owner.json`。
81
+
82
+ ## 排查:某个 bot 一直不上线
83
+
84
+ 1. 看日志:`凭据不可用:code=100016 invalid appid or secret` → appId/secret 写错了
85
+ (可用 `POST https://api.bot.qq.com/app/getAppAccessToken` 自己验一下);
86
+ 2. 看日志:`本轮无可接入的 qqbot(x:在线)` → 这个 bot 已被别的 pi 进程连着;
87
+ 3. 看日志:`被 pid 1234 重载中` → 对方正在 `/reload`,等几秒即可;
88
+ 4. 什么都没打 → 确认配置能解析(`loadAccounts()` 报错会写 `[setup] 配置问题:`)。
package/index.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  REST_BASE_PROD,
7
7
  REST_BASE_SANDBOX,
8
8
  INTENT_GROUP_AND_C2C,
9
+ INTENT_INTERACTION,
9
10
  PASSIVE_WINDOW_MS,
10
11
  APPROVAL_TIMEOUT_MS,
11
12
  DISPATCH_RETRY_MS,
@@ -34,7 +35,7 @@ import { log, configureLog } from "./logger";
34
35
  import { claimAny, startClaimRetry, reserveBeforeRelease, readOwner, type Claim } from "./lock";
35
36
  import { AuthManager } from "./qq/auth";
36
37
  import { QQApi } from "./qq/api";
37
- import { createGatewayUrlFetcher, QQGateway, QQMessage } from "./qq/gateway";
38
+ import { createGatewayUrlFetcher, QQGateway, QQMessage, type QQInteractionEvent } from "./qq/gateway";
38
39
  import { Approver } from "./approver";
39
40
  import { parsePickIndex, pickerExpired, REPLACES_RUNTIME, routeSlash, runBridge, type PickerState } from "./slash";
40
41
  import { agoLabel, MessageQueue, previewText, type QueuedJob } from "./queue";
@@ -82,6 +83,14 @@ import {
82
83
  } from "./billing";
83
84
  import { ReplyBuffer } from "./stream-buffer";
84
85
  import { splitText } from "./util/chunk";
86
+ import {
87
+ buildApprovalKeyboard,
88
+ buildPickerKeyboard,
89
+ decodeButtonData,
90
+ type ApprovalButtonData,
91
+ type InlineKeyboard,
92
+ type PickButtonData,
93
+ } from "./qq/keyboard";
85
94
 
86
95
  /** 发送本机文件的工具名 */
87
96
  const QQ_SEND_FILE_TOOL = "qq_send_file";
@@ -114,7 +123,7 @@ interface State {
114
123
  /** 发出列表的 QQ 用户:只有他能回编号 */
115
124
  pickerOwner: string | null;
116
125
  /** /qq 桥接命令的回执通道(每次收到 QQ 消息时刷新) */
117
- bridgeReply: ((text: string) => void) | null;
126
+ bridgeReply: ((text: string, keyboard?: InlineKeyboard) => void) | null;
118
127
  /** 本轮已被用户取消:settle 时丢弃半截回复 */
119
128
  cancelled: boolean;
120
129
  /** 忙碌时收到的任务:当前任务 settle 后逐条执行 */
@@ -369,7 +378,7 @@ export default function (
369
378
  const notice = recoverable ? billingNotice(errorInfo) : errorNotice(errorInfo);
370
379
  log("[reply] 本轮接口报错,转告 QQ:", notice.split("\n")[0]);
371
380
  for (const chunk of splitText(notice)) {
372
- await state.api.sendC2C(openid, chunk).catch((e) => {
381
+ await state.api.sendC2C(openid, chunk, { format: replyFormat(state) }).catch((e) => {
373
382
  log("[reply] 错误提示发送失败:", String(e));
374
383
  });
375
384
  }
@@ -377,7 +386,7 @@ export default function (
377
386
  log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
378
387
  let delivered = true;
379
388
  for (const chunk of splitText(text)) {
380
- await state.api.sendC2C(openid, chunk).catch((e) => {
389
+ await state.api.sendC2C(openid, chunk, { format: replyFormat(state) }).catch((e) => {
381
390
  delivered = false;
382
391
  log("[reply] 发送失败:", String(e));
383
392
  });
@@ -427,10 +436,13 @@ export default function (
427
436
  log("[approve] 工具需要审批:", summary);
428
437
  const { seq, done } = state.approver.register(summary, () => {
429
438
  // 超时通知
430
- state.api?.sendC2C(openid, `审批 #${seq} 已超时,已自动拒绝。`).catch(() => {});
439
+ state.api?.sendC2C(openid, `审批 #${seq} 已超时,已自动拒绝。`, { format: replyFormat(state) }).catch(() => {});
431
440
  });
432
441
  await state.api
433
- .sendC2C(openid, state.approver.render(seq, { strict: forced }))
442
+ .sendC2C(openid, state.approver.render(seq, { strict: forced }), {
443
+ format: replyFormat(state),
444
+ keyboard: buildApprovalKeyboard(seq, { strict: forced }),
445
+ })
434
446
  .catch(() => {});
435
447
  const decision = await done;
436
448
 
@@ -574,10 +586,24 @@ export default function (
574
586
  const gateway = new QQGateway({
575
587
  getGatewayUrl: createGatewayUrlFetcher(restBase, () => auth.getToken()),
576
588
  getToken: () => auth.getToken(),
577
- intents: INTENT_GROUP_AND_C2C,
589
+ intents: INTENT_GROUP_AND_C2C | INTENT_INTERACTION,
578
590
  connected: () => {},
579
591
  disconnected: () => {},
580
592
  message: (msg) => handleMessage(pi, state, api, state.cfg ?? cfg, msg),
593
+ interaction: (event) => {
594
+ // 先同步路由(settle 审批 / 派发选择),再立即 ACK(平台要求 5 秒内回应)
595
+ let matched = false;
596
+ try {
597
+ matched = handleInteraction(pi, state, event);
598
+ } catch (e) {
599
+ log("[interaction] 按钮处理异常:", String(e));
600
+ }
601
+ if (event.id) {
602
+ void api.acknowledgeInteraction(String(event.id), matched ? 0 : 3).catch((e) => {
603
+ log("[interaction] ACK 失败:", String(e));
604
+ });
605
+ }
606
+ },
581
607
  });
582
608
  state.gateway = gateway;
583
609
  log("[lock] 启动 QQ 网关");
@@ -709,7 +735,7 @@ export default function (
709
735
  const space = trimmed.indexOf(" ");
710
736
  const sub = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
711
737
  const rest = (space === -1 ? "" : trimmed.slice(space + 1)).trim();
712
- const reply = (text: string) => state.bridgeReply?.(text);
738
+ const reply = (text: string, keyboard?: InlineKeyboard) => state.bridgeReply?.(text, keyboard);
713
739
  // 发本机文件不需要命令上下文,先于 runBridge 处理
714
740
  if (sub === "sendfile") {
715
741
  try {
@@ -886,8 +912,8 @@ function startJob(pi: ExtensionAPI, state: State, job: QueuedJob) {
886
912
  state.currentOpenid = job.openid;
887
913
  state.lastOpenid = job.openid;
888
914
  state.pickerOwner = job.openid;
889
- state.bridgeReply = (text: string) => {
890
- void replyTo(state.api!, state, job.openid, text);
915
+ state.bridgeReply = (text: string, keyboard?: InlineKeyboard) => {
916
+ void replyTo(state.api!, state, job.openid, text, keyboard);
891
917
  };
892
918
  state.busy = true;
893
919
  state.cancelled = false;
@@ -984,8 +1010,8 @@ async function handleCredentialIntake(
984
1010
  msg: QQMessage,
985
1011
  intake: CredentialIntake,
986
1012
  ): Promise<void> {
987
- const reply = async (text: string) => {
988
- await replyTo(api, state, msg.openid, text);
1013
+ const reply = async (text: string, keyboard?: InlineKeyboard) => {
1014
+ await replyTo(api, state, msg.openid, text, keyboard);
989
1015
  };
990
1016
  const target = state.lastModel;
991
1017
 
@@ -1092,7 +1118,7 @@ async function handleCredentialIntake(
1092
1118
  };
1093
1119
  state.billing = null;
1094
1120
  log("[billing] 已注册 provider", providerId, "模型数", models.length, "→ 发编号选择");
1095
- await reply(formatModelPicker(providerId, base, models, borrowed));
1121
+ await reply(formatModelPicker(providerId, base, models, borrowed), buildPickerKeyboard(state.picker?.items ?? []));
1096
1122
  }
1097
1123
 
1098
1124
  /** 当前任务结束后,取队列里的下一条开始执行(空闲时才会真的派发) */
@@ -1305,8 +1331,8 @@ export async function handleMessage(
1305
1331
  return;
1306
1332
  }
1307
1333
  state.pickerOwner = msg.openid;
1308
- state.bridgeReply = (text: string) => {
1309
- void replyTo(api, state, msg.openid, text);
1334
+ state.bridgeReply = (text: string, keyboard?: InlineKeyboard) => {
1335
+ void replyTo(api, state, msg.openid, text, keyboard);
1310
1336
  };
1311
1337
  log("[cmd] 选择编号", index, "kind=", state.picker.kind);
1312
1338
  pi.sendUserMessage(`/qq pick ${index}`, { expandPromptTemplates: true });
@@ -1370,8 +1396,8 @@ export async function handleMessage(
1370
1396
  // 斜杠命令路由:pi 扩展命令 → 可接管的内置命令 → 仅主机可用的内置命令 → 普通文字
1371
1397
  state.lastOpenid = msg.openid;
1372
1398
  state.pickerOwner = msg.openid;
1373
- state.bridgeReply = (text: string) => {
1374
- void replyTo(api, state, msg.openid, text);
1399
+ state.bridgeReply = (text: string, keyboard?: InlineKeyboard) => {
1400
+ void replyTo(api, state, msg.openid, text, keyboard);
1375
1401
  };
1376
1402
  const routed = hasAttachment
1377
1403
  ? ("text" as const)
@@ -1592,12 +1618,18 @@ export async function handleMessage(
1592
1618
  }
1593
1619
  }
1594
1620
 
1621
+ /** 配置 markdownReply=true 时统一走 QQ markdown 消息(msg_type=2);无权限时 api 层自动回退纯文本 */
1622
+ function replyFormat(state: State): "text" | "markdown" {
1623
+ return state.cfg?.markdownReply ? "markdown" : "text";
1624
+ }
1625
+
1595
1626
  async function replyTo(
1596
1627
  api: QQApi,
1597
1628
  state: State,
1598
1629
  openid: string,
1599
1630
  text: string,
1600
- format: "text" | "markdown" = "text",
1631
+ format: "text" | "markdown" | "auto" = "auto",
1632
+ keyboard?: InlineKeyboard,
1601
1633
  ): Promise<boolean> {
1602
1634
  // 本进程刚接入 / 刚接管这个 qqbot:第一条回执带上身份与工作目录
1603
1635
  if (state.claimNote) {
@@ -1605,13 +1637,14 @@ async function replyTo(
1605
1637
  state.claimNote = null;
1606
1638
  text = `${note}\n${text}`;
1607
1639
  }
1640
+ const fmt = format === "auto" ? replyFormat(state) : format;
1608
1641
  const ref = state.passiveRefs.get(openid);
1609
1642
  let delivered = true;
1610
1643
  for (const chunk of splitText(text)) {
1611
1644
  const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
1612
1645
  try {
1613
- await api.sendC2C(openid, chunk, usePassive ? { msgId: ref.msgId, format } : { format });
1614
- log("[reply] 已发送 (passive=", usePassive, "):", chunk.slice(0, 50));
1646
+ await api.sendC2C(openid, chunk, usePassive ? { msgId: ref.msgId, format: fmt, keyboard } : { format: fmt, keyboard });
1647
+ log("[reply] 已发送 (passive=", usePassive, ", format=", fmt, "):", chunk.slice(0, 50));
1615
1648
  } catch (e) {
1616
1649
  delivered = false;
1617
1650
  log("[reply] 发送失败:", String(e));
@@ -1620,6 +1653,84 @@ async function replyTo(
1620
1653
  return delivered;
1621
1654
  }
1622
1655
 
1656
+ /** 审批按钮:按 seq 匹配 pending,settle 后 tool_call 的 done 继续原流程 */
1657
+ function handleApprovalButton(state: State, openid: string, button: ApprovalButtonData): boolean {
1658
+ const api = state.api;
1659
+ if (!api) return false;
1660
+ if (button.d === "always") {
1661
+ state.approver.setAutoAllow(true);
1662
+ const n = state.approver.allowAll();
1663
+ log("[approve] 按钮开启自动同意,放行", n, "个待审批");
1664
+ void replyTo(
1665
+ api,
1666
+ state,
1667
+ openid,
1668
+ [
1669
+ `已开启自动同意:本会话内需审批的工具全部放行${n > 0 ? `,已放行 ${n} 个待审批请求` : ""}。`,
1670
+ "回复「取消自动同意」或 /new 恢复逐条审批。",
1671
+ ].join("\n"),
1672
+ );
1673
+ return true;
1674
+ }
1675
+ if (button.d === "allow" || button.d === "deny") {
1676
+ const decision = button.d === "allow" ? "allow" : "block";
1677
+ const ok = state.approver.confirm(button.s, decision);
1678
+ const text = !ok
1679
+ ? `#${button.s} 不存在或已超时`
1680
+ : decision === "allow"
1681
+ ? `已批准 #${button.s}`
1682
+ : `已拒绝 #${button.s}`;
1683
+ void replyTo(api, state, openid, text);
1684
+ return ok; // 过期卡片 → false → ACK 重复操作
1685
+ }
1686
+ return false;
1687
+ }
1688
+
1689
+ /** 选择按钮:等价于「回复编号」,复用 /qq pick 派发路径 */
1690
+ function handlePickButton(pi: ExtensionAPI, state: State, openid: string, button: PickButtonData): boolean {
1691
+ const api = state.api;
1692
+ const picker = state.picker;
1693
+ if (!api || !picker || pickerExpired(picker)) return false;
1694
+ if (picker.openid !== openid) return false; // 只认发起人点击
1695
+ if (button.i < 1 || button.i > picker.items.length) return false;
1696
+ if (isBusy(state)) {
1697
+ void replyTo(api, state, openid, "当前有任务在跑,先发 /cancel 取消,再选择。");
1698
+ return true;
1699
+ }
1700
+ state.picker = null;
1701
+ state.pickerOwner = openid;
1702
+ state.bridgeReply = (text: string, keyboard?: InlineKeyboard) => {
1703
+ void replyTo(api, state, openid, text, keyboard);
1704
+ };
1705
+ log("[cmd] 按钮选择编号", button.i, "kind=", picker.kind);
1706
+ try {
1707
+ void Promise.resolve(pi.sendUserMessage(`/qq pick ${button.i}`, { expandPromptTemplates: true })).catch(
1708
+ (e) => log("[cmd] 按钮派发失败:", String(e)),
1709
+ );
1710
+ } catch (e) {
1711
+ log("[cmd] 按钮派发异常:", String(e));
1712
+ }
1713
+ return true;
1714
+ }
1715
+
1716
+ /**
1717
+ * 按钮点击统一入口(INTERACTION_CREATE):
1718
+ * 按 button_data 顶层 t 字段路由到审批/选择;返回是否命中(命中 → ACK 0,未命中 → ACK 3)。
1719
+ * 回复动作全部 fire-and-forget:本函数保持同步,保证 5 秒 ACK 窗口。
1720
+ */
1721
+ export function handleInteraction(pi: ExtensionAPI, state: State, event: QQInteractionEvent): boolean {
1722
+ const button = decodeButtonData(event.data?.resolved?.button_data);
1723
+ const openid = String(event.user_openid ?? "");
1724
+ if (!button || !openid) return false;
1725
+ // 白名单校验与消息路径一致
1726
+ if (state.cfg?.allowlist && state.cfg.allowlist.length > 0 && !state.cfg.allowlist.includes(openid)) {
1727
+ return false;
1728
+ }
1729
+ if (button.t === "approval") return handleApprovalButton(state, openid, button);
1730
+ if (button.t === "pick") return handlePickButton(pi, state, openid, button);
1731
+ return false;
1732
+ }
1733
+
1623
1734
  /** 用户问进度的表达(忙碌时用来区分「排队」和「问进度」) */
1624
1735
  // 忙碌时判断「这是在问进度」:只认句首的明确表达,避免把「帮我加个进度条」这类任务误当问询
1625
1736
  const RE_PROGRESS_QUERY = new RegExp(