@zxh126/pi-qqbot 2.0.1 → 2.3.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/CHANGELOG.md CHANGED
@@ -3,6 +3,126 @@
3
3
  本文件记录 pi-qqbot 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
4
  版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
5
5
 
6
+ ## [2.3.0] - 2026-10-31
7
+
8
+ ### Added
9
+
10
+ - **远程上下文提醒**:`before_agent_start` 里先判断「这一轮是不是本扩展刚派发的 QQ 消息」,是才把一段说明写进
11
+ 系统提示的 `qq-remote-context` section(排在用户消息之前,QQ 侧与 TUI 都看不到):说明对话来自远程 QQ 用户、
12
+ 对方看不到本机屏幕与弹窗;默认**禁用桌面截屏 / 屏幕 OCR / UI 自动化 / 键鼠模拟 / 带界面的浏览器**
13
+ (点名 `mcp__screenshot`、`mcp__winui`),需要图就离屏渲染生成 PNG 再用 `qq_send_file` 发回,
14
+ 只有确认是否锁屏时才允许截一次桌面。**仅仅载入扩展、或本机 TUI 直接对话时都不注入**,必须等第一条 QQ 消息真的开跑。
15
+ - **系统提示规则**:每次**来自 QQ 的** run 追加三条短规则(远程用户 / 锁屏→离屏渲染 /
16
+ 需要人工就直说),避免长会话被压缩摘要后丢失;同一轮重复触发不叠加,本机 TUI 自己的 run 不加。
17
+ - **压缩后重注入**:`session_compact` 之后清掉「已提醒」标记,下一条消息重新注入。
18
+ - **配置 `remote`**:`notice`(默认 `true`,每会话一次)、`guidelines`(默认 `true`)、`extra`(追加到提醒末尾,
19
+ 默认空);顶层默认值 + 每个 bot 逐键覆盖。
20
+ - **本机接管更正**:同一个会话里「上一轮来自 QQ、下一轮本机 TUI 直接打字」时,本机那一轮注入一次
21
+ `qq-local-handoff` section(「远程 QQ 用户」前提作废:可正常截屏、不需要 `qq_send_file`),连续本机对话不重复;
22
+ 用 `input` 事件的 `source=interactive` 识别(本扩展的 `sendUserMessage` 是 `extension`,不会误判);
23
+ QQ run 跑到一半时插话不算接管(没有新的 run)。`/status` 新增「来源: QQ(远程)/ 本机(TUI)」。
24
+
25
+ ### Fixed
26
+
27
+ - **等审批期间不再推自动进度**:有工具审批在等用户回复时,自动进度推送暂缓(否则每 15s 一条进度会把审批提示
28
+ 顶出屏幕),审批处理完(或超时)后下一拍自动恢复;主动问 `/progress` 不受影响;`/status` 会显示
29
+ 「等审批期间暂缓」。本会话内手动暂停(`/progress off`)与配额静默的语义不变。
30
+
31
+ ### Tests
32
+
33
+ - 新增 `test/remote-notice.test.mjs`(默认值 / 文案要素 / `extra` 追加 / 系统规则长度)。
34
+ - `test/config.test.mjs` 新增 `remote` 全局默认 + 账号逐键覆盖用例。
35
+ - `test/accounts.test.mjs` 新增 3 个用例:**本机直接对话不注入**、第一条 QQ 消息的 run 才注入且每会话一次、
36
+ 压缩后重注入;`remote.notice=false` 只关说明、`remote.extra` 追加;`remote.guidelines=false` 只关规则,
37
+ 且同一轮重复触发不叠加。
38
+ - `test/progress.test.mjs` 新增用例:等审批期间不推送、`/status` 显示暂缓、手动问进度仍回一次、审批处理完自动恢复。
39
+ - `test/accounts.test.mjs` 再加 2 个用例:本机接管只更正一次(`/status` 显示来源);交接待定会被新的 QQ 轮覆盖,
40
+ `source=extension` 的 input 不算接管。
41
+ - `test/remote-notice.test.mjs` 加 `qq-local-handoff` 的 tag 与文案断言。
42
+
43
+ ## [Unreleased]
44
+
45
+ ## [2.2.1] - 2026-02-11
46
+
47
+ ### Fixed
48
+
49
+ - **把「文案」当成了「指令」**:用户把审批提示整段引回来(或以「你这句话里说『取消自动同意』」这种句子讨论),
50
+ 旧代码用**未锚定**的正则,句子里出现「取消自动同意」「以后都同意」就会被当成真的审批动作(日志里能看到
51
+ 回了一句「当前没有开启自动同意」)。现在 `parseReply` 先去掉空白与标点做**整句匹配**,并卡长度上限(≤ 12 字),
52
+ 引用提示、长句、感叹句一律不解析,当普通聊天交给 pi。
53
+ - **审批提示太啰嗦**:从 3 行(含「停止」「以后都同意」「取消自动同意」三句)压到 2 行:
54
+ `[审批 #N] <摘要>` + `回「同意 N」或「拒绝 N」;想全部放行回「以后都同意」。`;
55
+ 发文件这类每次都要确认的工具改用 strict 文案(不提「以后都同意」)。
56
+
57
+ ### Tests
58
+
59
+ - 新增 `test/approver.test.mjs`(6 个用例:批/拒的各种写法、停止/以后都同意/取消自动同意、
60
+ 引用提示不解析、句中关键词不解析、「停止汇报」不算指令、超长句不解析、提示文案≤两行)。
61
+ - `test/queue.test.mjs` 同步 strict 文案断言(19 个用例)。
62
+
63
+ ## [2.2.0] - 2026-02-11
64
+
65
+ ### Added
66
+
67
+ - **把本机文件发到 QQ**:新增工具 `qq_send_file(path, name?)` 与命令 `/sendfile <路径>`。按官方富媒体接口走
68
+ 分片上传(`upload_prepare` → 分片 `PUT` → `upload_part_finish` → 合并拿 `file_info`),再用
69
+ `msg_type=7` + `media.file_info` 以**被动回复**发出(`srv_send_msg:false`,不烧主动额度)。
70
+ 本地文件没有公网 URL,而二进制直传只有 5MB 上限,所以统一走分片,不依赖中转。
71
+ - **`file_type` 判定**:png/jpg/jpeg → 图片(1)、mp4 → 视频(2)、silk → 语音(3)、其余 → 文件(4);
72
+ 否则 QQ 报 `850019`。图片/视频/语音直接展示,其他格式对方收到可下载的文件卡片。
73
+ - **外发文件强制审批(每次都要问)**:`qq_send_file` 被加入 `FORCE_APPROVAL_TOOLS`,无论 `approve.tools` 怎么配都
74
+ 必须先回 QQ 审批;并且**跳过会话级「以后都同意」与记住的决策**,每个文件单独确认,也不会写入 remember。
75
+ 审批文案会显示文件路径与显示名,并注明「需单独确认」。
76
+ - **配置 `files`**:`enabled`(默认 true)、`maxBytes`(默认 30MB;QQ 硬限 200MB),可按 bot 逐键覆盖。
77
+ - **错误码翻译**:`850031` 超大、`850019` 格式不支持、`40093002` 今日容量上限、`850027` 超时、
78
+ `850018` 禁言/限频、`40093001` 转存失败,都转成中文回执。
79
+
80
+ ### Changed
81
+
82
+ - `qq/api.ts`:新增 `sendC2CMedia(openid, fileInfo, { msgId })`,复用已有的发送队列与 `msg_seq` 计数。
83
+ - `slash.ts`:新增可接管命令 `sendfile`(不受 `commands.allowSession` 限制,由 `files.enabled` + 审批把关)。
84
+ - `index.ts`:`State` 记录 `restBase`;`/help` 增加 `/sendfile` 说明。
85
+ - `package.json`:新增 peer 依赖 `typebox`(工具参数 schema,pi 运行时已内置别名)。
86
+
87
+ ### Tests
88
+
89
+ - 新增 `test/upload.test.mjs`(9 个用例:file_type 映射、md5/sha1/md5_10m、分片全链路与字节回拼、
90
+ 图片类型、分片失败重试、本地校验(不存在/目录/空文件/超限)、合并缺 `file_info`、错误码翻译、
91
+ `sendC2CMedia` 的 `msg_type=7` 请求体)。
92
+ - `npm test` 新增该文件;全套 130 → 140 个用例全绿(queue 18 → 19,新增「发送文件每次都审批」)。
93
+
94
+ ## [2.1.0] - 2026-02-11
95
+
96
+ ### Added
97
+
98
+ - **接收 QQ 附件(图片 / 语音 / 视频 / 文档 / 压缩包 / 普通文件)**:收到的附件马上下载到临时文件,
99
+ 再把路径以标记行交给 pi——图片是 `图片路径 <path>`,其余按类型是 `语音路径` / `视频路径` /
100
+ `文档路径` / `压缩包路径` / `文件路径`。原有文字保持在第一行,与路径同属一轮对话;pi 的 `read` 工具
101
+ 可以直接打开图片,`bash` 可以处理文件。(v2.1.0 之前只保留图片且完全没落盘,纯图片消息会给 pi 送空串。)
102
+ - **QQ 回执**:落盘后先回一条「已收到 N 个附件(图片 ×1、文档 ×1),已存为临时文件并交给 pi:」+ 各条路径;
103
+ 下载失败会列出原因,文字部分照常执行。
104
+ - **临时文件自动清理**:落盘目录按日期分子目录(`<系统临时目录>/pi-qqbot/<bot>/<YYYY-MM-DD>/`),
105
+ 每次会话启动时按 `attachments.retentionHours`(默认 24 小时)与 `attachments.maxFiles`(默认 200)
106
+ 清理;目录里不是本插件建的 `<日期>` 子目录一律不动。
107
+ - **配置 `attachments`**:`enabled` / `dir` / `maxBytes` / `maxPerMessage` / `retentionHours` / `maxFiles`,
108
+ 支持像 `queue` / `progress` 一样在 `qqbots[]` 里逐 bot 覆盖。`enabled: false` 时回一句忽略提示、只处理文字。
109
+ - **新的 `attachment.ts`**:六类判定、文件名规整(只保留 `[0-9a-zA-Z._-]`)、并发下载(默认 3)、
110
+ 单个 25 MB 上限、下载超时 30 秒、401/403 带凭据重试一次、失败不影响其它附件。
111
+
112
+ ### Changed
113
+
114
+ - `qq/gateway.ts`:`QQMessage.attachments` 改为 `{ url, contentType, filename?, size? }[]`,
115
+ **不再丢弃非图片附件**;语音消息的消息体只有 `voice_wav_url` 时也取它当下载地址。
116
+ - 网关日志不再打整包 JSON(里面带 `rkey` 临时凭据),改为 openid / content 摘要 / 附件数量 / 来源主机。
117
+ - `handleMessage()`:带附件的消息**绕过**斜杠命令、审批解析、编号选择与内置指令,一律当普通内容(忙碌时照旧入队)。
118
+
119
+ ### Tests
120
+
121
+ - 新增 `test/attachment.test.mjs`(12 个用例:六类判定、扩展名、落盘内容、超限、401 重试、部分失败、
122
+ 单条上限、回执文案、目录清理)。
123
+ - `test/queue.test.mjs` 12 → 18 个用例(纯图片派发、文字+文档拼行、忙碌入队、`enabled=false` 忽略提示、
124
+ 绕过 `/status`、下载失败照发正文)。
125
+
6
126
  ## [2.0.1] - 2026-10-25
7
127
 
8
128
  ### Fixed
package/README.md CHANGED
@@ -13,6 +13,7 @@ Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: se
13
13
  | **Chat-driven agent** | QQ private messages are fed into the current pi session; the assistant's answer is sent back to QQ (long replies are split automatically). |
14
14
  | **Message queue** | While a task is running, further QQ messages are **queued** instead of rejected; each one starts automatically as soon as the current run settles. `/cancel` is the exception — it acts immediately and clears the queue. |
15
15
  | **Remote approval** | Before pi runs `bash` / `powershell` / `write` / `edit` — or any tool annotated `destructiveHint` — the bot asks in QQ first. |
16
+ | **Remote-context notice** | The first QQ message of a session is really handed to pi (loading the extension or chatting locally in the TUI adds nothing), then the bot tells it the conversation comes from a remote QQ user and that the machine may be locked: desktop screenshots / screen OCR / UI automation / headed browsers are off by default, images must be rendered off-screen and sent with `qq_send_file`. Re-injected after context compaction. |
16
17
  | **Session-scoped "always allow"** | Reply 「以后都同意」 and every approval request in the **current session** is auto-approved, including the one already waiting. |
17
18
  | **Single-instance lock** | Only one pi process hosts the QQ gateway; the others stand by and take over automatically when the host exits. |
18
19
  | **Cancel a running task** | Send `/cancel` (or 「取消」/「停止」) and the current run is aborted immediately — the QQ reply, the pending tool calls, the half-written answer and the pending queue are all cleaned up. |
@@ -20,7 +21,9 @@ Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: se
20
21
  | **Progress reports** | While a run is in progress the bot posts a short progress summary (turns, tool calls, the tool currently running, tokens) every `progress.intervalMs` (default **15 s**). Ask at any time with `/progress` (aliases `/p`, 「进度」). `/progress off` pauses the automatic reports for the rest of the session, `/progress on` brings them back. If a report cannot be delivered the automatic reports stay quiet until you ask again. |
21
22
  | **Secret redaction** | Anything leaving the machine is scrubbed first — progress reports, approval prompts, queue previews and the log file: `Authorization` headers (incl. QQ bot tokens), `sk-…` / `ghp_…` / `npm_…` / `AKIA…` / JWTs / PEM blocks, `token=` / `password=` / `--api-key` values, URL `user:pass@`, and long high-entropy strings. |
22
23
  | **Multi-bot on one machine** | Put several QQ bots in one config (`qqbots: [...]`, see [docs/accounts.md](./docs/accounts.md)). Each pi process, in config order, claims the first bot that is not online yet and then verifies its credentials (a wrong secret releases the lock instead of squatting the slot); a process that finds every bot taken stays idle and rescans every 5 s, taking over as soon as one goes offline. A bot whose owner is merely reloading is never snatched (a 20 s reservation is left behind). The bot name is logged, shown in `/status` and prepended to the first QQ reply; taking over from another process also tells you the new working directory. |
23
- | **Built-in commands** | `/status`, `/queue`, `/progress` (+ `/progress off` / `on`), `/reset`, `/cancel`, `/help`. |
24
+ | **Images & files** | Anything you attach in QQ is downloaded to a temp file and handed to pi as a marked line — `图片路径 <path>` for images, `语音路径` / `视频路径` / `文档路径` / `压缩包路径` / `文件路径` for the rest — and QQ gets a receipt with the counts and paths. pi's `read` tool can open the image, `bash` can process the file. |
25
+ | **Send files to your QQ** | Ask the agent to send a file from the machine (`把 D:\docs\report.pdf 发给我`) or run `/sendfile <path>`; the bot uploads it in 5 MB chunks (QQ rich media) and sends it as an image / video / voice / file card. Images (png/jpg), videos (mp4) and voice (silk) render natively; everything else arrives as a downloadable file card. **Always approved in QQ first.** |
26
+ | **Built-in commands** | `/status`, `/queue`, `/progress` (+ `/progress off` / `on`), `/reset`, `/cancel`, `/sendfile`, `/help`. |
24
27
 
25
28
  ## Install
26
29
 
@@ -50,6 +53,23 @@ Then create `~/.pi/agent/pi-qqbot.json`:
50
53
  "queue": {
51
54
  "max": 20,
52
55
  "ack": true
56
+ },
57
+ "attachments": {
58
+ "enabled": true,
59
+ "dir": "",
60
+ "maxBytes": 26214400,
61
+ "maxPerMessage": 10,
62
+ "retentionHours": 24,
63
+ "maxFiles": 200
64
+ },
65
+ "files": {
66
+ "enabled": true,
67
+ "maxBytes": 31457280
68
+ },
69
+ "remote": {
70
+ "notice": true,
71
+ "guidelines": true,
72
+ "extra": ""
53
73
  }
54
74
  }
55
75
  ```
@@ -68,6 +88,17 @@ Then create `~/.pi/agent/pi-qqbot.json`:
68
88
  | `progress.intervalMs` | `15000` | Interval of those reports (env override: `PI_QQBOT_PROGRESS_MS`) |
69
89
  | `queue.max` | `20` | How many messages may wait while a task runs; `0` disables queueing (busy messages are refused as before) |
70
90
  | `queue.ack` | `true` | Reply to QQ when a queued [message](#queueing-while-busy) starts (the "queued" ack is always sent) |
91
+ | `attachments.enabled` | `true` | Download attachments to temp files and pass their paths to pi; `false` = answer with an "ignored" notice and process the text only |
92
+ | `attachments.dir` | `""` | Where to save them; empty = `<system temp>/pi-qqbot/<bot>` (Windows: `%LOCALAPPDATA%\Temp\pi-qqbot\…`) |
93
+ | `attachments.maxBytes` | `26214400` | Per-attachment size limit (25 MB); larger ones are reported as failed, not downloaded |
94
+ | `attachments.maxPerMessage` | `10` | How many attachments of one message are processed |
95
+ | `attachments.retentionHours` | `24` | Files older than this are swept at session start |
96
+ | `attachments.maxFiles` | `200` | Keep at most this many files in the directory (oldest are deleted) |
97
+ | `files.enabled` | `true` | Allow sending local files to QQ (both the tool and `/sendfile`); `false` refuses |
98
+ | `files.maxBytes` | `31457280` | Per-file limit for sending (30 MB; QQ hard limit is 200 MB) |
99
+ | `remote.notice` | `true` | Inject a one-time "this is a remote QQ user" note before the first message handed to pi in a session (QQ-side invisible; re-injected after compaction) |
100
+ | `remote.guidelines` | `true` | Also append three short rules to the system prompt of every run started from QQ (remote user, locked screen → off-screen rendering, ask instead of waiting) |
101
+ | `remote.extra` | `""` | Extra text appended to the end of the built-in notice |
71
102
 
72
103
  Credentials can also come from the `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` environment variables (single-bot setup only).
73
104
 
@@ -95,14 +126,103 @@ Start pi, then message the bot in QQ; pi handles it and replies.
95
126
  | Command | Description |
96
127
  |---|---|
97
128
  | `/reset` | Clear the reply buffer, approval memory and session-wide auto-approve (**does not** switch sessions — send `/new` for that) |
98
- | `/status` | Show the bot this process serves (name + working directory), busy/idle state, queue length, auto-approve state, pending approval count and the progress-report mode |
129
+ | `/status` | Show the bot this process serves (name + working directory), busy/idle state, origin of the last turn (QQ / local TUI), queue length, auto-approve state, pending approval count and the progress-report mode |
99
130
  | `/queue` | Show the queue (aliases: `/q`); `/queue clear` empties it, `/queue drop <id>` removes one entry |
100
131
  | `/progress` | **Show the current progress summary immediately** (aliases: `/p`, 「进度」, 「当前进度」, 「进展」; plain-text questions like 「到哪了」「怎么样了」 work while busy). While you paused reports yourself it only replies once — use `/progress on` to resume; while reports are merely quota-quiet, asking resumes them |
101
132
  | `/progress off` | **Pause automatic progress reports for the rest of the session** (aliases: `pause`, `stop`, `暂停`, 「暂停汇报」, 「关闭汇报」). Manual `/progress` still works, it just no longer resumes the timer |
102
133
  | `/progress on` | **Resume automatic progress reports** (aliases: `resume`, `start`, `恢复`, 「恢复汇报」, 「继续汇报」) |
103
134
  | `/cancel` | **Interrupt the task that is running right now** and empty the queue (aliases: `/stop`, `/abort`, 「取消」, 「停止」, 「中止」, 「终止」, 「算了」, 「停」, 「停下」, 「停一下」, 「别跑了」). Note: a bare 「停」 interrupts the *task*; to silence progress reports say 「别汇报了」/「闭嘴」 or `/progress off` |
135
+ | `/sendfile <path>` | **Send a file from this machine to your QQ** (e.g. `/sendfile D:\docs\a.pdf`); QQ gets an image / video / voice / file card. Requires QQ-side approval when the agent initiates it |
104
136
  | `/help` | Show help |
105
137
 
138
+ ### Images and files from QQ
139
+
140
+ Send a picture (or a document, an archive, a voice/video message) and the bot downloads it to a temp file,
141
+ then hands pi a line with the path:
142
+
143
+ ```
144
+ 图片路径 C:\Users\you\AppData\Local\Temp\pi-qqbot\bot-340143\2026-02-11\msg-abc-1-103045.jpg
145
+ ```
146
+
147
+ | Attachment | Line sent to pi |
148
+ |---|---|
149
+ | image | `图片路径 <path>` |
150
+ | voice / audio | `语音路径 <path>` |
151
+ | video | `视频路径 <path>` |
152
+ | document (pdf, office, text, code… ) | `文档路径 <path>` |
153
+ | archive (zip, rar, 7z, tar…) | `压缩包路径 <path>` |
154
+ | anything else | `文件路径 <path>` |
155
+
156
+ Your own text is kept as the first line, so `看下这个` + an image becomes
157
+ `看下这个\n图片路径 C:\…\a.jpg` — one pi turn, paths included. QQ also gets a receipt such as
158
+ `已收到 2 个附件(图片 ×1、文档 ×1),已存为临时文件并交给 pi:` followed by the paths; failed downloads are
159
+ listed with the reason and the text is still delivered.
160
+
161
+ Notes:
162
+
163
+ - QQ's download URLs carry a short-lived `rkey`, so files are fetched the moment the message arrives.
164
+ - A message that has attachments **bypasses** slash commands, approval parsing and the numbered picker — it is
165
+ always treated as ordinary content for pi (an attached `/status` is not executed).
166
+ - Files are deleted by the `attachments.retentionHours` / `attachments.maxFiles` sweep at session start, so a
167
+ path handed to pi stays readable for the rest of the session (and usually a day).
168
+ - On file names: only `[0-9a-zA-Z._-]` are kept, prefixed with the QQ message id.
169
+
170
+ ### Sending files to QQ
171
+
172
+ Two ways to push a file from the machine to your chat:
173
+
174
+ | Trigger | How |
175
+ |---|---|
176
+ | Natural language | `把 D:\docs\报告.pdf 发给我` — the agent calls the `qq_send_file` tool with the path |
177
+ | Slash command | `/sendfile D:\docs\报告.pdf` (also `/sendfile "D:\my files\a.zip"`; no agent run needed) |
178
+
179
+ **Approval is mandatory on every send**: `qq_send_file` is force-gated no matter what `approve.tools` says, and it
180
+ ignores both the session-wide 「以后都同意」 and remembered decisions — sending a file is data leaving the machine, so
181
+ each upload is confirmed on its own. The prompt says so explicitly:
182
+ `[审批 #1] 发送文件到 QQ: D:\docs\a.pdf` + 「需单独确认」.
183
+
184
+ How it is delivered (QQ rich media, chunked upload):
185
+
186
+ | File | Delivered as |
187
+ |---|---|
188
+ | `.png` / `.jpg` / `.jpeg` | inline image |
189
+ | `.mp4` | inline video |
190
+ | `.silk` | voice bubble |
191
+ | anything else (pdf, zip, mp3, epub, exe…) | downloadable file card |
192
+
193
+ Notes:
194
+
195
+ - The file is uploaded in chunks (`upload_prepare` → `PUT` → `upload_part_finish` → merge) — the only path that works
196
+ for local files, since URL upload needs a public URL and the one-shot binary upload caps at 5 MB.
197
+ - Passive reply is used (`srv_send_msg: false` + `msg_type: 7` with `msg_id`/`msg_seq`), so it does not burn the active-message quota.
198
+ - Limits on QQ's side: 200 MB hard cap and a **daily total file quota** (`40093002`); the bot turns both into readable replies.
199
+ - The reply also tells you what was sent: `已发送「报告.pdf」(2.31 MB,文件卡片)`.
200
+
201
+ ### Remote-context notice
202
+
203
+ pi runs on the machine, you are in QQ — and that machine may be locked. So the first time a QQ message of a session is
204
+ actually handed to pi, the bot adds a short note to the **system prompt** (a `qq-remote-context` section, so it sits
205
+ before the user message and QQ never sees it) that says:
206
+
207
+ - the user is remote: no screen, no dialogs, keep replies concise;
208
+ - desktop screenshots, screen OCR, UI automation, keyboard/mouse simulation and headed browsers are off by default
209
+ (`mcp__screenshot`, `mcp__winui` are named explicitly) — on a locked machine they only capture the lock screen or fail;
210
+ - when an image is needed, render it off-screen (headless browser over HTML, SVG, matplotlib/Pillow, …) and send the PNG
211
+ with `qq_send_file` instead of trying to capture the desktop;
212
+ - capturing the desktop once is allowed only to check whether the machine is locked; everything else stays off-screen.
213
+
214
+ Timing matters: the note is only added from `before_agent_start` for a run this extension dispatched, so merely loading
215
+ the extension — or chatting with pi directly in the TUI on the host — sends nothing. It appears once per session (again
216
+ after context compaction; `remote.extra` appends your own lines). With `remote.guidelines` the same expectations are
217
+ condensed into three bullets that ride along in the system prompt of every run that came from QQ, so they survive
218
+ summarisation. Set `remote.notice: false` / `remote.guidelines: false` to turn either part off.
219
+
220
+ If you take over the same session from the host — turn N from QQ, turn N+1 typed in the TUI — the next local run gets a
221
+ one-time `qq-local-handoff` section instead: the remote/locked-screen premise is void (screenshots are fine, no
222
+ `qq_send_file` needed). Detection uses the `input` event (`source: "interactive"`; the bot's own `sendUserMessage` is
223
+ `"extension"`, so it never misfires), it only fires once per hand-off, and it is skipped while a QQ run is still
224
+ streaming (no new run starts then). `/status` shows `来源: QQ(远程)` or `来源: 本机(TUI)`.
225
+
106
226
  ### Slash commands from QQ
107
227
 
108
228
  Any message that starts with `/` is routed before it can become a chat prompt:
@@ -220,7 +340,10 @@ While a run is in progress, a summary is pushed to QQ every `progress.intervalMs
220
340
  - **Manual pause / resume**: `/progress off` (or 「暂停汇报」) silences the reports for the rest of the
221
341
  session — it survives `agent_settled` and the next run, and asking for `/progress` no longer turns it
222
342
  back on. `/progress on` (or 「恢复汇报」) resumes. A new session (`/new`, restart) starts enabled again.
223
- `/status` shows which of the four modes you are in (on / paused-by-you / quota-quiet / disabled).
343
+ `/status` shows which of the five modes you are in (on / paused-by-you / quota-quiet / held-during-approval / disabled).
344
+ - **Approvals stay visible**: while a tool approval is waiting for your reply, automatic reports are held back — otherwise
345
+ a 15 s progress line would keep pushing the prompt off your screen. They resume on the next interval after you answer
346
+ (or the approval times out). Asking for `/progress` yourself still works.
224
347
  - **The final answer is protected**: if it cannot be delivered (quota), it is cached and re-sent with a
225
348
  `(上一条回复发送失败,补发如下)` prefix as soon as you send the next message.
226
349
  - Timer is cleared on `agent_settled` / `/cancel` / session shutdown.
@@ -307,7 +430,9 @@ Debug logs are written to `~/.pi/agent/pi-qqbot-<name>.log` (one file per bot, s
307
430
  ## Changelog
308
431
 
309
432
  See [CHANGELOG.md](./CHANGELOG.md) — every release with what changed and why. The progress-report
310
- pause/resume (`/progress off` / `on`) landed in **1.5.0**; multi-bot auto-claiming in **2.0.0**.
433
+ pause/resume (`/progress off` / `on`) landed in **1.5.0**; multi-bot auto-claiming in **2.0.0**;
434
+ receiving QQ images/files (saved to temp files and handed to pi as `图片路径 <path>`) in **2.1.0**;
435
+ sending local files to QQ (`qq_send_file` / `/sendfile`, chunked rich-media upload) in **2.2.0**.
311
436
 
312
437
  ## License
313
438
 
package/approver.ts CHANGED
@@ -23,17 +23,33 @@ interface PendingApproval {
23
23
  resolve: (decision: ApproveDecision) => void;
24
24
  }
25
25
 
26
- /** 「以后都同意」类表达 */
26
+ /**
27
+ * 「以后都同意」类表达。
28
+ *
29
+ * 这些正则全部要求**整句匹配**(parseReply 里先归一化再卡长度):
30
+ * 用户把审批提示整段引回来、或写了「你这句话里说『取消自动同意』」时,
31
+ * 不能把文案里的词当成指令执行。
32
+ */
27
33
  const RE_ALWAYS =
28
- /(以后|之后|今后|一直|全都|全部|都)\s*(同意|允许|批准|放行)|别再(问|烦|提示)|always\s*(allow|yes)/i;
29
- /** 「取消自动同意」类表达(含「以后都不同意」) */
34
+ /^(?:(?:以后|之后|今后|一直|全都|全部|都)\s*(?:都)?\s*(?:同意|允许|批准|放行)|别再(?:问|烦|提示)了?|always\s*(?:allow|yes))$/i;
35
+ /** 「取消自动同意」类表达(含「以后都不同意」),同样要求整句 */
30
36
  const RE_REVOKE =
31
- /(取消|关闭|结束|停止)\s*自动同意|^恢复审批$|(以后|之后|今后|都|全部)\s*不(同意|允许|批准|放行)/;
37
+ /^(?:(?:取消|关闭|结束|停止)\s*自动同意|恢复审批|(?:以后|之后|今后|都|全部)\s*(?:都)?\s*不(?:同意|允许|批准|放行))$/;
32
38
  /** 「停止」终止本轮(index.ts 里同时会 abort 当前 run) */
33
39
  const RE_TERMINATE = /^停止$/;
34
40
  /** 带或不带编号的批准/拒绝 */
35
41
  const RE_DECIDE = /^(同意|批准|放行|允许|allow|拒绝|驳回|deny)\s*(\d+)?$/i;
36
42
 
43
+ /** 整句长度上限:超过它就是聊天(比如引用了审批提示),不是审批回复 */
44
+ const MAX_REPLY_CHARS = 12;
45
+
46
+ /** 去掉空白与常见标点/引号,把「同意 2。」和「\"同意2\"」归一成「同意2」 */
47
+ function normalizeReply(text: string): string {
48
+ return String(text ?? "")
49
+ .replace(/[\s\u3000]+/g, "")
50
+ .replace(/[,。!?、,.!?;;::…~~"'“”‘’「」『』()()\[\]【】]/g, "");
51
+ }
52
+
37
53
  export class Approver {
38
54
  private pending = new Map<number, PendingApproval>();
39
55
  private rememberMap = new Map<string, ApproveDecision>();
@@ -76,13 +92,16 @@ export class Approver {
76
92
  return { seq, done };
77
93
  }
78
94
 
79
- render(seq: number): string {
95
+ /**
96
+ * 审批提示。默认两三行就够:用户反馈过「太长」;
97
+ * strict 用于每次都要单独确认的工具(发文件),所以不提「以后都同意」。
98
+ */
99
+ render(seq: number, opts: { strict?: boolean } = {}): string {
80
100
  const item = this.pending.get(seq);
81
- return [
82
- `[审批 #${seq}] ${item?.summary ?? ""}`,
83
- `回复「同意 ${seq}」批准,「拒绝 ${seq}」拒绝,「停止」(/cancel)中断本轮任务。`,
84
- `要本会话内全部放行,回复「以后都同意」(可用「取消自动同意」关闭)。`,
85
- ].join("\n");
101
+ const hint = opts.strict
102
+ ? `回「同意 ${seq}」或「拒绝 ${seq}」,需单独确认。`
103
+ : `回「同意 ${seq}」或「拒绝 ${seq}」;想全部放行回「以后都同意」。`;
104
+ return [`[审批 #${seq}] ${item?.summary ?? ""}`, hint].join("\n");
86
105
  }
87
106
 
88
107
  /** 最早(编号最小)的待审批请求 */
@@ -95,8 +114,10 @@ export class Approver {
95
114
  }
96
115
 
97
116
  parseReply(text: string): ParsedReply | null {
98
- const t = text.trim();
99
- if (!t) return null;
117
+ const raw = String(text ?? "").trim();
118
+ if (!raw) return null;
119
+ const t = normalizeReply(raw);
120
+ if (!t || t.length > MAX_REPLY_CHARS) return null;
100
121
  if (RE_REVOKE.test(t)) return { decision: "revoke" };
101
122
  if (RE_ALWAYS.test(t)) return { decision: "always" };
102
123
  if (RE_TERMINATE.test(t)) return { decision: "terminate" };