@zxh126/pi-qqbot 2.0.1 → 2.2.1

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,87 @@
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.2.1] - 2026-02-11
7
+
8
+ ### Fixed
9
+
10
+ - **把「文案」当成了「指令」**:用户把审批提示整段引回来(或以「你这句话里说『取消自动同意』」这种句子讨论),
11
+ 旧代码用**未锚定**的正则,句子里出现「取消自动同意」「以后都同意」就会被当成真的审批动作(日志里能看到
12
+ 回了一句「当前没有开启自动同意」)。现在 `parseReply` 先去掉空白与标点做**整句匹配**,并卡长度上限(≤ 12 字),
13
+ 引用提示、长句、感叹句一律不解析,当普通聊天交给 pi。
14
+ - **审批提示太啰嗦**:从 3 行(含「停止」「以后都同意」「取消自动同意」三句)压到 2 行:
15
+ `[审批 #N] <摘要>` + `回「同意 N」或「拒绝 N」;想全部放行回「以后都同意」。`;
16
+ 发文件这类每次都要确认的工具改用 strict 文案(不提「以后都同意」)。
17
+
18
+ ### Tests
19
+
20
+ - 新增 `test/approver.test.mjs`(6 个用例:批/拒的各种写法、停止/以后都同意/取消自动同意、
21
+ 引用提示不解析、句中关键词不解析、「停止汇报」不算指令、超长句不解析、提示文案≤两行)。
22
+ - `test/queue.test.mjs` 同步 strict 文案断言(19 个用例)。
23
+
24
+ ## [2.2.0] - 2026-02-11
25
+
26
+ ### Added
27
+
28
+ - **把本机文件发到 QQ**:新增工具 `qq_send_file(path, name?)` 与命令 `/sendfile <路径>`。按官方富媒体接口走
29
+ 分片上传(`upload_prepare` → 分片 `PUT` → `upload_part_finish` → 合并拿 `file_info`),再用
30
+ `msg_type=7` + `media.file_info` 以**被动回复**发出(`srv_send_msg:false`,不烧主动额度)。
31
+ 本地文件没有公网 URL,而二进制直传只有 5MB 上限,所以统一走分片,不依赖中转。
32
+ - **`file_type` 判定**:png/jpg/jpeg → 图片(1)、mp4 → 视频(2)、silk → 语音(3)、其余 → 文件(4);
33
+ 否则 QQ 报 `850019`。图片/视频/语音直接展示,其他格式对方收到可下载的文件卡片。
34
+ - **外发文件强制审批(每次都要问)**:`qq_send_file` 被加入 `FORCE_APPROVAL_TOOLS`,无论 `approve.tools` 怎么配都
35
+ 必须先回 QQ 审批;并且**跳过会话级「以后都同意」与记住的决策**,每个文件单独确认,也不会写入 remember。
36
+ 审批文案会显示文件路径与显示名,并注明「需单独确认」。
37
+ - **配置 `files`**:`enabled`(默认 true)、`maxBytes`(默认 30MB;QQ 硬限 200MB),可按 bot 逐键覆盖。
38
+ - **错误码翻译**:`850031` 超大、`850019` 格式不支持、`40093002` 今日容量上限、`850027` 超时、
39
+ `850018` 禁言/限频、`40093001` 转存失败,都转成中文回执。
40
+
41
+ ### Changed
42
+
43
+ - `qq/api.ts`:新增 `sendC2CMedia(openid, fileInfo, { msgId })`,复用已有的发送队列与 `msg_seq` 计数。
44
+ - `slash.ts`:新增可接管命令 `sendfile`(不受 `commands.allowSession` 限制,由 `files.enabled` + 审批把关)。
45
+ - `index.ts`:`State` 记录 `restBase`;`/help` 增加 `/sendfile` 说明。
46
+ - `package.json`:新增 peer 依赖 `typebox`(工具参数 schema,pi 运行时已内置别名)。
47
+
48
+ ### Tests
49
+
50
+ - 新增 `test/upload.test.mjs`(9 个用例:file_type 映射、md5/sha1/md5_10m、分片全链路与字节回拼、
51
+ 图片类型、分片失败重试、本地校验(不存在/目录/空文件/超限)、合并缺 `file_info`、错误码翻译、
52
+ `sendC2CMedia` 的 `msg_type=7` 请求体)。
53
+ - `npm test` 新增该文件;全套 130 → 140 个用例全绿(queue 18 → 19,新增「发送文件每次都审批」)。
54
+
55
+ ## [2.1.0] - 2026-02-11
56
+
57
+ ### Added
58
+
59
+ - **接收 QQ 附件(图片 / 语音 / 视频 / 文档 / 压缩包 / 普通文件)**:收到的附件马上下载到临时文件,
60
+ 再把路径以标记行交给 pi——图片是 `图片路径 <path>`,其余按类型是 `语音路径` / `视频路径` /
61
+ `文档路径` / `压缩包路径` / `文件路径`。原有文字保持在第一行,与路径同属一轮对话;pi 的 `read` 工具
62
+ 可以直接打开图片,`bash` 可以处理文件。(v2.1.0 之前只保留图片且完全没落盘,纯图片消息会给 pi 送空串。)
63
+ - **QQ 回执**:落盘后先回一条「已收到 N 个附件(图片 ×1、文档 ×1),已存为临时文件并交给 pi:」+ 各条路径;
64
+ 下载失败会列出原因,文字部分照常执行。
65
+ - **临时文件自动清理**:落盘目录按日期分子目录(`<系统临时目录>/pi-qqbot/<bot>/<YYYY-MM-DD>/`),
66
+ 每次会话启动时按 `attachments.retentionHours`(默认 24 小时)与 `attachments.maxFiles`(默认 200)
67
+ 清理;目录里不是本插件建的 `<日期>` 子目录一律不动。
68
+ - **配置 `attachments`**:`enabled` / `dir` / `maxBytes` / `maxPerMessage` / `retentionHours` / `maxFiles`,
69
+ 支持像 `queue` / `progress` 一样在 `qqbots[]` 里逐 bot 覆盖。`enabled: false` 时回一句忽略提示、只处理文字。
70
+ - **新的 `attachment.ts`**:六类判定、文件名规整(只保留 `[0-9a-zA-Z._-]`)、并发下载(默认 3)、
71
+ 单个 25 MB 上限、下载超时 30 秒、401/403 带凭据重试一次、失败不影响其它附件。
72
+
73
+ ### Changed
74
+
75
+ - `qq/gateway.ts`:`QQMessage.attachments` 改为 `{ url, contentType, filename?, size? }[]`,
76
+ **不再丢弃非图片附件**;语音消息的消息体只有 `voice_wav_url` 时也取它当下载地址。
77
+ - 网关日志不再打整包 JSON(里面带 `rkey` 临时凭据),改为 openid / content 摘要 / 附件数量 / 来源主机。
78
+ - `handleMessage()`:带附件的消息**绕过**斜杠命令、审批解析、编号选择与内置指令,一律当普通内容(忙碌时照旧入队)。
79
+
80
+ ### Tests
81
+
82
+ - 新增 `test/attachment.test.mjs`(12 个用例:六类判定、扩展名、落盘内容、超限、401 重试、部分失败、
83
+ 单条上限、回执文案、目录清理)。
84
+ - `test/queue.test.mjs` 12 → 18 个用例(纯图片派发、文字+文档拼行、忙碌入队、`enabled=false` 忽略提示、
85
+ 绕过 `/status`、下载失败照发正文)。
86
+
6
87
  ## [2.0.1] - 2026-10-25
7
88
 
8
89
  ### Fixed
package/README.md CHANGED
@@ -20,7 +20,9 @@ Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: se
20
20
  | **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
21
  | **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
22
  | **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`. |
23
+ | **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. |
24
+ | **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.** |
25
+ | **Built-in commands** | `/status`, `/queue`, `/progress` (+ `/progress off` / `on`), `/reset`, `/cancel`, `/sendfile`, `/help`. |
24
26
 
25
27
  ## Install
26
28
 
@@ -50,6 +52,18 @@ Then create `~/.pi/agent/pi-qqbot.json`:
50
52
  "queue": {
51
53
  "max": 20,
52
54
  "ack": true
55
+ },
56
+ "attachments": {
57
+ "enabled": true,
58
+ "dir": "",
59
+ "maxBytes": 26214400,
60
+ "maxPerMessage": 10,
61
+ "retentionHours": 24,
62
+ "maxFiles": 200
63
+ },
64
+ "files": {
65
+ "enabled": true,
66
+ "maxBytes": 31457280
53
67
  }
54
68
  }
55
69
  ```
@@ -68,6 +82,14 @@ Then create `~/.pi/agent/pi-qqbot.json`:
68
82
  | `progress.intervalMs` | `15000` | Interval of those reports (env override: `PI_QQBOT_PROGRESS_MS`) |
69
83
  | `queue.max` | `20` | How many messages may wait while a task runs; `0` disables queueing (busy messages are refused as before) |
70
84
  | `queue.ack` | `true` | Reply to QQ when a queued [message](#queueing-while-busy) starts (the "queued" ack is always sent) |
85
+ | `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 |
86
+ | `attachments.dir` | `""` | Where to save them; empty = `<system temp>/pi-qqbot/<bot>` (Windows: `%LOCALAPPDATA%\Temp\pi-qqbot\…`) |
87
+ | `attachments.maxBytes` | `26214400` | Per-attachment size limit (25 MB); larger ones are reported as failed, not downloaded |
88
+ | `attachments.maxPerMessage` | `10` | How many attachments of one message are processed |
89
+ | `attachments.retentionHours` | `24` | Files older than this are swept at session start |
90
+ | `attachments.maxFiles` | `200` | Keep at most this many files in the directory (oldest are deleted) |
91
+ | `files.enabled` | `true` | Allow sending local files to QQ (both the tool and `/sendfile`); `false` refuses |
92
+ | `files.maxBytes` | `31457280` | Per-file limit for sending (30 MB; QQ hard limit is 200 MB) |
71
93
 
72
94
  Credentials can also come from the `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` environment variables (single-bot setup only).
73
95
 
@@ -101,8 +123,72 @@ Start pi, then message the bot in QQ; pi handles it and replies.
101
123
  | `/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
124
  | `/progress on` | **Resume automatic progress reports** (aliases: `resume`, `start`, `恢复`, 「恢复汇报」, 「继续汇报」) |
103
125
  | `/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` |
126
+ | `/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
127
  | `/help` | Show help |
105
128
 
129
+ ### Images and files from QQ
130
+
131
+ Send a picture (or a document, an archive, a voice/video message) and the bot downloads it to a temp file,
132
+ then hands pi a line with the path:
133
+
134
+ ```
135
+ 图片路径 C:\Users\you\AppData\Local\Temp\pi-qqbot\bot-340143\2026-02-11\msg-abc-1-103045.jpg
136
+ ```
137
+
138
+ | Attachment | Line sent to pi |
139
+ |---|---|
140
+ | image | `图片路径 <path>` |
141
+ | voice / audio | `语音路径 <path>` |
142
+ | video | `视频路径 <path>` |
143
+ | document (pdf, office, text, code… ) | `文档路径 <path>` |
144
+ | archive (zip, rar, 7z, tar…) | `压缩包路径 <path>` |
145
+ | anything else | `文件路径 <path>` |
146
+
147
+ Your own text is kept as the first line, so `看下这个` + an image becomes
148
+ `看下这个\n图片路径 C:\…\a.jpg` — one pi turn, paths included. QQ also gets a receipt such as
149
+ `已收到 2 个附件(图片 ×1、文档 ×1),已存为临时文件并交给 pi:` followed by the paths; failed downloads are
150
+ listed with the reason and the text is still delivered.
151
+
152
+ Notes:
153
+
154
+ - QQ's download URLs carry a short-lived `rkey`, so files are fetched the moment the message arrives.
155
+ - A message that has attachments **bypasses** slash commands, approval parsing and the numbered picker — it is
156
+ always treated as ordinary content for pi (an attached `/status` is not executed).
157
+ - Files are deleted by the `attachments.retentionHours` / `attachments.maxFiles` sweep at session start, so a
158
+ path handed to pi stays readable for the rest of the session (and usually a day).
159
+ - On file names: only `[0-9a-zA-Z._-]` are kept, prefixed with the QQ message id.
160
+
161
+ ### Sending files to QQ
162
+
163
+ Two ways to push a file from the machine to your chat:
164
+
165
+ | Trigger | How |
166
+ |---|---|
167
+ | Natural language | `把 D:\docs\报告.pdf 发给我` — the agent calls the `qq_send_file` tool with the path |
168
+ | Slash command | `/sendfile D:\docs\报告.pdf` (also `/sendfile "D:\my files\a.zip"`; no agent run needed) |
169
+
170
+ **Approval is mandatory on every send**: `qq_send_file` is force-gated no matter what `approve.tools` says, and it
171
+ ignores both the session-wide 「以后都同意」 and remembered decisions — sending a file is data leaving the machine, so
172
+ each upload is confirmed on its own. The prompt says so explicitly:
173
+ `[审批 #1] 发送文件到 QQ: D:\docs\a.pdf` + 「需单独确认」.
174
+
175
+ How it is delivered (QQ rich media, chunked upload):
176
+
177
+ | File | Delivered as |
178
+ |---|---|
179
+ | `.png` / `.jpg` / `.jpeg` | inline image |
180
+ | `.mp4` | inline video |
181
+ | `.silk` | voice bubble |
182
+ | anything else (pdf, zip, mp3, epub, exe…) | downloadable file card |
183
+
184
+ Notes:
185
+
186
+ - The file is uploaded in chunks (`upload_prepare` → `PUT` → `upload_part_finish` → merge) — the only path that works
187
+ for local files, since URL upload needs a public URL and the one-shot binary upload caps at 5 MB.
188
+ - 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.
189
+ - Limits on QQ's side: 200 MB hard cap and a **daily total file quota** (`40093002`); the bot turns both into readable replies.
190
+ - The reply also tells you what was sent: `已发送「报告.pdf」(2.31 MB,文件卡片)`.
191
+
106
192
  ### Slash commands from QQ
107
193
 
108
194
  Any message that starts with `/` is routed before it can become a chat prompt:
@@ -307,7 +393,9 @@ Debug logs are written to `~/.pi/agent/pi-qqbot-<name>.log` (one file per bot, s
307
393
  ## Changelog
308
394
 
309
395
  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**.
396
+ pause/resume (`/progress off` / `on`) landed in **1.5.0**; multi-bot auto-claiming in **2.0.0**;
397
+ receiving QQ images/files (saved to temp files and handed to pi as `图片路径 <path>`) in **2.1.0**;
398
+ sending local files to QQ (`qq_send_file` / `/sendfile`, chunked rich-media upload) in **2.2.0**.
311
399
 
312
400
  ## License
313
401
 
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" };