@zxh126/pi-qqbot 2.4.0 → 2.6.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.
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`)由本扩展接管;只能在主机执行的内置命令直接回执,不会送给模型。 |
@@ -24,6 +24,7 @@
24
24
  | **图片与文件** | QQ 里发来的附件会先下载到临时文件,再以标记行交给 pi——图片是 `图片路径 <path>`,其余按类型是 `语音路径` / `视频路径` / `文档路径` / `压缩包路径` / `文件路径`;QQ 侧收到一条回执(数量、类型、路径)。pi 的 `read` 工具能直接看图片,`bash` 能处理文件。 |
25
25
  | **把本机文件发到 QQ** | 让 agent 把机器上的文件发给你(「把 D:\docs\报告.pdf 发给我」)或发 `/sendfile <路径>`;机器人按 5MB 分片上传(QQ 富媒体),图片(png/jpg) / 视频(mp4) / 语音(silk) 直接展示,其余变成可下载的文件卡片。**外发必须先在 QQ 里审批。** |
26
26
  | **内置指令** | `/status`、`/queue`、`/progress`、`/reset`、`/cancel`、`/sendfile`、`/update`、`/help`。 |
27
+ | **`!` / `!!` 只回执、不执行** | 这两个 shell 快捷前缀只在 pi 的 TUI / RPC 前端生效,扩展 API 没有等价能力,所以 QQ 侧只回一条说明,既不执行、也不把 `!ls` 当聊天丢给模型。要跑命令直接用中文说需求,agent 会调 `bash` 工具并在执行前向你申请审批。 |
27
28
 
28
29
  ## 安装
29
30
 
@@ -86,7 +87,7 @@ pi install ./pi-qqbot
86
87
  | `appId` / `appSecret` | 必填 | QQ 开放平台机器人凭据 |
87
88
  | `sandbox` | `false` | 未提审上线的 bot 用 `true`(沙箱环境) |
88
89
  | `allowlist` | `[]` | openid 白名单,空 = 不限制 |
89
- | `markdownReply` | `false` | QQ 单聊不支持 markdown,保持 `false` |
90
+ | `markdownReply` | `false` | 用 QQ markdown 消息(msg_type=2)回复,GFM 表格可渲染,审批与选择列表附带内联按钮(自定义按钮需平台内邀开通);需要该 bot 的 markdown 权限,发送失败自动回退纯文本 |
90
91
  | `streaming` | `false` | 预留,v1 未启用流式 |
91
92
  | `approve.tools` | `["bash","powershell","write","edit"]` | 需要审批的工具名;带 `destructiveHint` 注解的工具同样会拦截 |
92
93
  | `approve.remember` | `true` | 会话内按工具记住决策(同类工具不再重复询问) |
@@ -240,16 +241,22 @@ QQ run 还在跑时插话不处理(那时不会开新的 run)。`/status`
240
241
  | `/name [名字]` | 查看或设置会话名 | `commands.allowSession` |
241
242
  | `/model [provider/model]` | 列出可用模型或切换 | `commands.allowSession` |
242
243
  | `/thinking [等级]` | 查看或设置思考等级 | `commands.allowSession` |
243
- | `/tree` | 列出会话树上的分支点(编号),回编号即跳转 | `commands.allowSession` |
244
- | `/fork` | 列出之前的用户消息(编号),回编号即从该处分叉 | `commands.allowSession` |
244
+ | `/tree` | 列出会话树上的分支点(编号),回编号或点按钮即跳转 | `commands.allowSession` |
245
+ | `/fork` | 列出之前的用户消息(编号),回编号或点按钮即从该处分叉 | `commands.allowSession` |
245
246
  | `/clone` | 在当前叶子位置复制整个会话 | `commands.allowSession` |
246
- | `/resume` | 列出本项目其他会话(编号),回编号即切换 | `commands.allowSession` |
247
+ | `/resume` | 列出本项目其他会话(编号),回编号或点按钮即切换 | `commands.allowSession` |
247
248
  | `/quit` | 关掉主机上的 pi | `commands.allowQuit`(默认 **false**) |
248
249
  | `/update` | 从 npm 更新 pi-qqbot(`pi update`)并重载自己,见[从 QQ 更新自己](#从-qq-更新自己) | `commands.allowUpdate`(默认 **false**) |
249
250
 
250
251
  只能在主机执行(回执,不转发给模型):`/settings`、`/hotkeys`、`/changelog`、`/trust`、`/login`、
251
252
  `/logout`、`/scoped-models`、`/llama`、`/import`、`/export`、`/share`、`/bug`、`/copy`、`/privacy`。
252
253
 
254
+ 以 `!` 或 `!!` 开头的消息(全角 `!` / `!!` 也认)**不会被执行**。这两个前缀只在主机 pi 的 TUI / RPC 前端
255
+ 被当成 shell 命令(`!` 跑完把输出交给模型,`!!` 跑完把输出排除出上下文),而扩展 API 没有等价入口:
256
+ `pi.sendUserMessage()` 不做这个分派,`pi.exec()` 又不过审批、不写会话历史。所以 `!ls` 只会收到一条
257
+ 「QQ 侧不支持」的回执,也不会被当成正文送给模型。想跑命令直接用中文说需求(例如「列一下当前目录里的仓库」),
258
+ agent 会调 `bash` 工具,执行前在 QQ 里向你申请审批。单独一个 `!`、`!!!`、以及句末感叹号仍按普通对话处理。
259
+
253
260
  ### 只往 TUI 打字的命令,现在会回到 QQ
254
261
 
255
262
  有些扩展命令从不往对话里输出,而是调 `ctx.ui.notify()`(配合 `pi.appendEntry()`)——只渲染在主机
@@ -270,7 +277,7 @@ custom entry 类型名,以及它写在 `~/.pi/agent/` 下的两个配置文件
270
277
  —— 先发 `/cancel`。
271
278
  - prompt 模板与 `/skill:name` 会开启一轮 agent run,因此忙碌时会像普通消息一样**排队**
272
279
  (见 [忙碌时的队列](#忙碌时的队列));自己管理 LLM 交互的扩展命令仍然立即执行。
273
- - 选择型命令(`/tree`、`/fork`、`/resume`)会回一个最多 10 项的编号列表,只对该用户有效、10 分钟内有效;
280
+ - 选择型命令(`/tree`、`/fork`、`/resume`)会回一个最多 10 项的编号列表(`markdownReply` 开启时附带点选按钮),只对该用户有效、10 分钟内有效;
274
281
  该用户下一条「纯数字」消息即执行选择,其它内容(或「取消」)则放弃。没有待选列表时,数字就是普通对话。
275
282
  - `/reload` 默认关闭:它会重启 bot,如果新代码加载失败,bot 会一直哑着,只能在主机上救。
276
283
  置 `commands.allowReload: true` 才启用。
@@ -278,6 +285,10 @@ custom entry 类型名,以及它写在 `~/.pi/agent/` 下的两个配置文件
278
285
  `reload()` / `newSession()` 又只能在命令上下文里调用。于是本扩展注册了 `/qq`,把 `/reload`
279
286
  改派成 `/qq reload`。内置命令名是硬编码的(pi 没有把内置清单导出给扩展);任何不认识的
280
287
  `/xxx` 都会退回当普通文字。
288
+ - 桥接命令不会在「换掉运行时」之后再去碰旧 ctx:`reload` / `update` / `new` / `clone` / `quit`
289
+ (以及 `tree` / `fork` / `resume` 的编号选择)会让旧 `ExtensionCommandContext` 立刻失效,之后**读任何
290
+ 属性** pi 都会抛 `This extension ctx is stale after session replacement or reload`。这些情况下模型缓存同步
291
+ 直接跳过(`REPLACES_RUNTIME` + 防御式读取),不再多报一条错误。
281
292
 
282
293
  ### 忙碌时的队列
283
294
 
@@ -430,7 +441,10 @@ provider 报错以前在 QQ 侧等于「没反应」:pi 在 assistant 消息
430
441
  要本会话内全部放行,回复「以后都同意」(可用「取消自动同意」关闭)。
431
442
  ```
432
443
 
433
- 回复用语(审批关键词以中文为主,也支持英文别名):
444
+ `markdownReply: true` 时卡片附带 ✅ 允许一次 / ❌ 拒绝 / 🚀 以后都同意 三个按钮,点按钮与回复等价;
445
+ 强制审批工具(`qq_send_file`)只显示前两个按钮。无论是否带按钮,文本回复始终有效。
446
+
447
+ 回复用语(审批关键词以中文为主,也支持英文别名;有按钮时点卡片按钮更快):
434
448
 
435
449
  | 意图 | 回复 |
436
450
  |---|---|
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,9 +35,9 @@ 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
- import { parsePickIndex, pickerExpired, routeSlash, runBridge, type PickerState } from "./slash";
40
+ import { parsePickIndex, pickerExpired, REPLACES_RUNTIME, routeSlash, runBridge, type PickerState } from "./slash";
40
41
  import { agoLabel, MessageQueue, previewText, type QueuedJob } from "./queue";
41
42
  import { ProgressTracker } from "./progress";
42
43
  import {
@@ -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 {
@@ -725,8 +751,12 @@ export default function (
725
751
  } catch (e) {
726
752
  log("[/qq] 桥接失败:", String(e));
727
753
  reply(`/${sub} 执行失败:${String(e).slice(0, 200)}`);
754
+ return;
728
755
  }
729
- // 命令可能换了模型(/model、编号选择):同步一份给「欠费后发新 key」用
756
+ // 命令可能换了模型(/model、编号选择):同步一份给「欠费后发新 key」用。
757
+ // 但 reload / update / new / quit / clone 会把运行时换掉,pi 立刻把旧 ctx 判为 stale,
758
+ // 这时再读 ctx.model 就是错的(新实例会自己同步)——直接跳过,不发多余的错误。
759
+ if (REPLACES_RUNTIME.has(sub)) return;
730
760
  syncModelFromCtx(state, ctx);
731
761
  },
732
762
  });
@@ -882,8 +912,8 @@ function startJob(pi: ExtensionAPI, state: State, job: QueuedJob) {
882
912
  state.currentOpenid = job.openid;
883
913
  state.lastOpenid = job.openid;
884
914
  state.pickerOwner = job.openid;
885
- state.bridgeReply = (text: string) => {
886
- 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);
887
917
  };
888
918
  state.busy = true;
889
919
  state.cancelled = false;
@@ -936,9 +966,18 @@ function onDispatchError(pi: ExtensionAPI, state: State, job: QueuedJob, error:
936
966
  * 同步一份「当前真正生效的模型」到 state:
937
967
  * 1. 欠费后用户发新 key 时,要知道落到哪个 provider;
938
968
  * 2. 探测新 key 需要 baseUrl,只有 ctx.model 里有。
969
+ *
970
+ * ctx 可能是 **stale** 的:`reload()` / `newSession()` / `fork()` / `switchSession()` / `navigateTree()`
971
+ * 之后 pi 会把旧 ctx 的每个 guard getter 都变成抛错("This extension ctx is stale after session
972
+ * replacement or reload")。这只是个顺带的缓存,读不到就算了,不能让错误冒到命令处理器外面。
939
973
  */
940
974
  function syncModelFromCtx(state: State, ctx: any): void {
941
- const model = ctx?.model;
975
+ let model: any;
976
+ try {
977
+ model = ctx?.model;
978
+ } catch {
979
+ return;
980
+ }
942
981
  if (!model?.provider || !model?.id) return;
943
982
  state.lastModel = {
944
983
  provider: String(model.provider),
@@ -971,8 +1010,8 @@ async function handleCredentialIntake(
971
1010
  msg: QQMessage,
972
1011
  intake: CredentialIntake,
973
1012
  ): Promise<void> {
974
- const reply = async (text: string) => {
975
- await replyTo(api, state, msg.openid, text);
1013
+ const reply = async (text: string, keyboard?: InlineKeyboard) => {
1014
+ await replyTo(api, state, msg.openid, text, keyboard);
976
1015
  };
977
1016
  const target = state.lastModel;
978
1017
 
@@ -1079,7 +1118,7 @@ async function handleCredentialIntake(
1079
1118
  };
1080
1119
  state.billing = null;
1081
1120
  log("[billing] 已注册 provider", providerId, "模型数", models.length, "→ 发编号选择");
1082
- await reply(formatModelPicker(providerId, base, models, borrowed));
1121
+ await reply(formatModelPicker(providerId, base, models, borrowed), buildPickerKeyboard(state.picker?.items ?? []));
1083
1122
  }
1084
1123
 
1085
1124
  /** 当前任务结束后,取队列里的下一条开始执行(空闲时才会真的派发) */
@@ -1292,8 +1331,8 @@ export async function handleMessage(
1292
1331
  return;
1293
1332
  }
1294
1333
  state.pickerOwner = msg.openid;
1295
- state.bridgeReply = (text: string) => {
1296
- void replyTo(api, state, msg.openid, text);
1334
+ state.bridgeReply = (text: string, keyboard?: InlineKeyboard) => {
1335
+ void replyTo(api, state, msg.openid, text, keyboard);
1297
1336
  };
1298
1337
  log("[cmd] 选择编号", index, "kind=", state.picker.kind);
1299
1338
  pi.sendUserMessage(`/qq pick ${index}`, { expandPromptTemplates: true });
@@ -1357,8 +1396,8 @@ export async function handleMessage(
1357
1396
  // 斜杠命令路由:pi 扩展命令 → 可接管的内置命令 → 仅主机可用的内置命令 → 普通文字
1358
1397
  state.lastOpenid = msg.openid;
1359
1398
  state.pickerOwner = msg.openid;
1360
- state.bridgeReply = (text: string) => {
1361
- void replyTo(api, state, msg.openid, text);
1399
+ state.bridgeReply = (text: string, keyboard?: InlineKeyboard) => {
1400
+ void replyTo(api, state, msg.openid, text, keyboard);
1362
1401
  };
1363
1402
  const routed = hasAttachment
1364
1403
  ? ("text" as const)
@@ -1465,6 +1504,7 @@ export async function handleMessage(
1465
1504
  "/sendfile <路径> — 把本机文件发到你的 QQ(png/jpg/mp4/silk 直显,其余为文件卡片;外发需审批)",
1466
1505
  "只关进度推送:/progress off(或「别汇报了」「闭嘴」);「停」是中断任务,不是静音",
1467
1506
  "/help — 帮助",
1507
+ "不支持 ! / !! 命令:那是主机 pi 的 shell 快捷方式,QQ 侧只回执不执行;要跑命令直接用中文说需求。",
1468
1508
  "pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
1469
1509
  "忙碌时发来的普通消息(含模板/skill 命令)会排队,当前任务结束后自动执行。",
1470
1510
  "多 bot:配置了多个 qqbot 时,多个 pi 进程会按列表顺序各自接入一个,抢不到的待命重扫。",
@@ -1578,12 +1618,18 @@ export async function handleMessage(
1578
1618
  }
1579
1619
  }
1580
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
+
1581
1626
  async function replyTo(
1582
1627
  api: QQApi,
1583
1628
  state: State,
1584
1629
  openid: string,
1585
1630
  text: string,
1586
- format: "text" | "markdown" = "text",
1631
+ format: "text" | "markdown" | "auto" = "auto",
1632
+ keyboard?: InlineKeyboard,
1587
1633
  ): Promise<boolean> {
1588
1634
  // 本进程刚接入 / 刚接管这个 qqbot:第一条回执带上身份与工作目录
1589
1635
  if (state.claimNote) {
@@ -1591,13 +1637,14 @@ async function replyTo(
1591
1637
  state.claimNote = null;
1592
1638
  text = `${note}\n${text}`;
1593
1639
  }
1640
+ const fmt = format === "auto" ? replyFormat(state) : format;
1594
1641
  const ref = state.passiveRefs.get(openid);
1595
1642
  let delivered = true;
1596
1643
  for (const chunk of splitText(text)) {
1597
1644
  const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
1598
1645
  try {
1599
- await api.sendC2C(openid, chunk, usePassive ? { msgId: ref.msgId, format } : { format });
1600
- 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));
1601
1648
  } catch (e) {
1602
1649
  delivered = false;
1603
1650
  log("[reply] 发送失败:", String(e));
@@ -1606,6 +1653,84 @@ async function replyTo(
1606
1653
  return delivered;
1607
1654
  }
1608
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
+
1609
1734
  /** 用户问进度的表达(忙碌时用来区分「排队」和「问进度」) */
1610
1735
  // 忙碌时判断「这是在问进度」:只认句首的明确表达,避免把「帮我加个进度条」这类任务误当问询
1611
1736
  const RE_PROGRESS_QUERY = new RegExp(