@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.
@@ -20,7 +20,9 @@
20
20
  | **进度汇报** | 任务运行期间按 `progress.intervalMs`(默认 **15 秒**)推一条进度摘要(轮次、工具调用、正在跑的工具、token 用量)。随时发 `/progress`(别名 `/p`、「进度」)可立即获取;`/progress off` 在本会话内暂停自动汇报,`/progress on` 恢复。推送失败后自动推送静默,直到你再次问询。 |
21
21
  | **敏感信息脱敏** | 离开本机的文本先脱敏——进度摘要、审批提示、队列预览、日志文件:`Authorization` 头(含 QQ bot token)、`sk-…`/`ghp_…`/`npm_…`/`AKIA…`/JWT/PEM、`token=`/`password=`/`--api-key` 的值、URL 里的 `user:pass@`,以及超长高熵串。 |
22
22
  | **多 bot 同机** | 一份配置里写多个 QQ bot(`qqbots: [...]`,详见 [accounts.md](./accounts.md)),同一台机器上每个 pi 进程按配置顺序接入第一个未上线的 bot;全部在线则待命、每 5 秒重扫,有人退出就接管。别人只是 `/reload` 临时释放的 bot 不会被抢(留 20 秒预约窗口)。接入/接管时会告知当前 bot 名称与工作目录。 |
23
- | **内置指令** | `/status`、`/queue`、`/progress`、`/reset`、`/cancel`、`/help`。 |
23
+ | **图片与文件** | QQ 里发来的附件会先下载到临时文件,再以标记行交给 pi——图片是 `图片路径 <path>`,其余按类型是 `语音路径` / `视频路径` / `文档路径` / `压缩包路径` / `文件路径`;QQ 侧收到一条回执(数量、类型、路径)。pi 的 `read` 工具能直接看图片,`bash` 能处理文件。 |
24
+ | **把本机文件发到 QQ** | 让 agent 把机器上的文件发给你(「把 D:\docs\报告.pdf 发给我」)或发 `/sendfile <路径>`;机器人按 5MB 分片上传(QQ 富媒体),图片(png/jpg) / 视频(mp4) / 语音(silk) 直接展示,其余变成可下载的文件卡片。**外发必须先在 QQ 里审批。** |
25
+ | **内置指令** | `/status`、`/queue`、`/progress`、`/reset`、`/cancel`、`/sendfile`、`/help`。 |
24
26
 
25
27
  ## 安装
26
28
 
@@ -50,6 +52,18 @@ pi install ./pi-qqbot
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 @@ pi install ./pi-qqbot
68
82
  | `queue.ack` | `true` | 排队消息开始执行时是否回执 QQ(入队回执总是会发) |
69
83
  | `progress.enabled` | `true` | 任务运行期间是否定期推送进度摘要(会话启动时读取,改完需 `/new` 或重启;运行时用 `/progress off`) |
70
84
  | `progress.intervalMs` | `15000` | 推送间隔(可用环境变量 `PI_QQBOT_PROGRESS_MS` 覆盖,便于测试) |
85
+ | `attachments.enabled` | `true` | 是否把附件下载到临时文件并把路径交给 pi;`false` = 只回一句忽略提示、只处理文字 |
86
+ | `attachments.dir` | `""` | 落盘目录;空 = 系统临时文件夹下的 `pi-qqbot/<bot>`(Windows:`%LOCALAPPDATA%\Temp\pi-qqbot\…`) |
87
+ | `attachments.maxBytes` | `26214400` | 单个附件上限(25 MB),超出的记为下载失败、不落盘 |
88
+ | `attachments.maxPerMessage` | `10` | 单条消息最多处理几个附件 |
89
+ | `attachments.retentionHours` | `24` | 超过此时长的临时文件在会话启动时清理 |
90
+ | `attachments.maxFiles` | `200` | 目录内最多保留多少个文件(超出删最旧) |
91
+ | `files.enabled` | `true` | 是否允许把本机文件发到 QQ(工具与 `/sendfile` 共用);`false` 则拒绕 |
92
+ | `files.maxBytes` | `31457280` | 发送单个文件的上限(30MB;QQ 硬限 200MB) |
71
93
 
72
94
  也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件里的凭据(仅单 bot 场景)。
73
95
 
@@ -101,6 +123,64 @@ pi install ./pi-qqbot
101
123
  | `/cancel` | **中断当前正在运行的任务**并清空队列(别名:`/stop`、`/abort`、「取消」、「停止」、「中止」、「终止」、「算了」、「停」、「停下」、「停一下」、「别跑了」)。注意:单说「停」是中断**任务**,要关进度推送请说「别汇报了」「闭嘴」或 `/progress off` |
102
124
  | `/help` | 帮助 |
103
125
 
126
+ ### QQ 里发来的图片与文件
127
+
128
+ 发一张图(或文档、压缩包、语音/视频),机器人会把它下载到临时文件,然后给 pi 一行路径:
129
+
130
+ ```
131
+ 图片路径 C:\Users\you\AppData\Local\Temp\pi-qqbot\bot-340143\2026-02-11\msg-abc-1-103045.jpg
132
+ ```
133
+
134
+ | 附件类型 | 交给 pi 的行 |
135
+ |---|---|
136
+ | 图片 | `图片路径 <path>` |
137
+ | 语音 / 音频 | `语音路径 <path>` |
138
+ | 视频 | `视频路径 <path>` |
139
+ | 文档(pdf、office、文本、代码…) | `文档路径 <path>` |
140
+ | 压缩包(zip、rar、7z、tar…) | `压缩包路径 <path>` |
141
+ | 其它 | `文件路径 <path>` |
142
+
143
+ 你自己的文字保持在第一行:发「看下这个」+ 一张图,送给 pi 的就是
144
+ `看下这个\n图片路径 C:\…\a.jpg`——同一轮,路径已在里面。QQ 侧还会收到回执,例如
145
+ `已收到 2 个附件(图片 ×1、文档 ×1),已存为临时文件并交给 pi:` 后跟各条路径;下载失败的附件会列出原因,
146
+ 文字部分照常执行。
147
+
148
+ 注意:
149
+
150
+ - QQ 的下载地址带短期有效的 `rkey`,所以收到消息的当下就下载。
151
+ - 带附件的消息会**绕过**斜杠命令、审批解析与编号选择,一律当作普通内容交给 pi(附带 `/status` 不会被执行)。
152
+ - 临时文件由 `attachments.retentionHours` / `attachments.maxFiles` 在会话启动时清理;交给 pi 的路径在当前会话(通常一天内)都可读。
153
+ - 落盘文件名只保留 `[0-9a-zA-Z._-]`,并以 QQ 消息 id 作为前缀。
154
+
155
+ ### 把本机文件发到 QQ
156
+
157
+ 两种触发方式:
158
+
159
+ | 触发 | 用法 |
160
+ |---|---|
161
+ | 自然语言 | 「把 D:\docs\报告.pdf 发给我」——agent 会调 `qq_send_file` 工具 |
162
+ | 斜杠命令 | `/sendfile D:\docs\报告.pdf`(路径带空格就加引号;不开 run,直接执行) |
163
+
164
+ **审批是硬性的,而且每次都问**:`qq_send_file` 无论 `approve.tools` 怎么配都强制走审批,并且**不吃**会话级
165
+ 「以后都同意」、也不吃「同意后记住」,因为外发文件等于数据出境,每个文件单独确认。提示里会写明:
166
+ `[审批 #1] 发送文件到 QQ: D:\…` + 「需单独确认」。
167
+
168
+ 发送形态(QQ 富媒体分片上传):
169
+
170
+ | 文件 | 对方看到 |
171
+ |---|---|
172
+ | `.png` / `.jpg` / `.jpeg` | 图片直接展示 |
173
+ | `.mp4` | 视频直接播放 |
174
+ | `.silk` | 语音条 |
175
+ | 其他(pdf / zip / mp3 / epub / exe…) | 可下载的文件卡片 |
176
+
177
+ 注:
178
+
179
+ - 必须走分片上传(`upload_prepare` → `PUT` → `upload_part_finish` → 合并):URL 上传要求公网可访问,二进制直传只有 5MB 上限。
180
+ - 用被动回复(`srv_send_msg: false` + `msg_type: 7` 带 `msg_id`/`msg_seq`),不烧主动消息额度。
181
+ - QQ 侧还有 **200MB 硬上限**和**每天发送文件总容量上限**(错误码 `40093002`),都翻成中文回执。
182
+ - 成功后会回一行:`已发送「报告.pdf」(2.31 MB,文件卡片)`。
183
+
104
184
  ### QQ 里的斜杠命令
105
185
 
106
186
  任何以 `/` 开头的消息都会先走路由,再决定是否当成对话:
@@ -290,7 +370,7 @@ QQ 网关对同一 bot 只保留一个活跃连接。插件用 `proper-lockfile`
290
370
 
291
371
  ## 变更日志
292
372
 
293
- 见 [CHANGELOG.md](../CHANGELOG.md):每个版本改了什么、为什么改。进度汇报的暂停/恢复(`/progress off` / `on`)在 **1.5.0** 加入;多 bot 自动抢占在 **2.0.0**。
373
+ 见 [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**。
294
374
 
295
375
  ## License
296
376
 
package/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineTool } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
2
4
  import { loadAccounts, pinnedAccountName, type QQBotAccount, type QQBotConfig } from "./config";
3
5
  import {
4
6
  REST_BASE_PROD,
@@ -8,7 +10,26 @@ import {
8
10
  APPROVAL_TIMEOUT_MS,
9
11
  DISPATCH_RETRY_MS,
10
12
  CANCEL_SETTLE_GRACE_MS,
13
+ DEFAULT_ATTACHMENT_MAX_BYTES,
14
+ DEFAULT_ATTACHMENT_MAX_FILES,
15
+ DEFAULT_ATTACHMENT_MAX_PER_MESSAGE,
16
+ DEFAULT_ATTACHMENT_RETENTION_HOURS,
17
+ DEFAULT_SEND_MAX_BYTES,
11
18
  } from "./constants";
19
+ import {
20
+ defaultAttachmentDir,
21
+ renderAttachmentAck,
22
+ renderAttachmentFailureLines,
23
+ renderAttachmentLines,
24
+ saveIncoming,
25
+ sweepAttachmentDir,
26
+ type AttachmentConfig,
27
+ } from "./attachment";
28
+ import {
29
+ explainUploadError,
30
+ mediaKindLabel,
31
+ uploadRichMedia,
32
+ } from "./qq/upload";
12
33
  import { log, configureLog } from "./logger";
13
34
  import { claimAny, startClaimRetry, reserveBeforeRelease, readOwner, type Claim } from "./lock";
14
35
  import { AuthManager } from "./qq/auth";
@@ -22,6 +43,11 @@ import { redactSecrets } from "./redact";
22
43
  import { ReplyBuffer } from "./stream-buffer";
23
44
  import { splitText } from "./util/chunk";
24
45
 
46
+ /** 发送本机文件的工具名 */
47
+ const QQ_SEND_FILE_TOOL = "qq_send_file";
48
+ /** 外发文件必须审批:不受配置 approve.tools 影响,也不吃「以后都同意」/会话内记忆 */
49
+ const FORCE_APPROVAL_TOOLS = new Set<string>([QQ_SEND_FILE_TOOL]);
50
+
25
51
  interface State {
26
52
  cfg: QQBotConfig | null;
27
53
  /** 本进程当前服务的 QQ bot(自动抢占的结果);未接入则为 null */
@@ -69,6 +95,12 @@ interface State {
69
95
  progressMuted: boolean;
70
96
  /** 最终答案发送失败时缓存,下一条消息时补发 */
71
97
  undelivered: { openid: string; text: string } | null;
98
+ /** 附件下载 401/403 时重试用的凭据(接入后由 applyClaim 注入) */
99
+ getToken: (() => Promise<string>) | null;
100
+ /** 仅测试用:替换附件落盘时的 fetch */
101
+ attachmentFetch: typeof fetch | null;
102
+ /** 当前 bot 的 REST 基址(发文件用) */
103
+ restBase: string | null;
72
104
  }
73
105
 
74
106
  /** 扩展运行所需的状态(导出只为测试用) */
@@ -99,6 +131,9 @@ export function createInitialState(): State {
99
131
  progressPaused: false,
100
132
  progressMuted: false,
101
133
  undelivered: null,
134
+ getToken: null,
135
+ attachmentFetch: null,
136
+ restBase: null,
102
137
  account: null,
103
138
  candidates: [],
104
139
  claimNote: null,
@@ -201,24 +236,28 @@ export default function (
201
236
  const toolName: string = ev.toolName ?? "tool";
202
237
  const cfg = state.cfg;
203
238
 
204
- // 判断是否需要审批:配置的 tools 列表 + destructiveHint 兑底
239
+ // 判断是否需要审批:配置的 tools 列表 + destructiveHint 兑底 + 强制审批工具(外发文件)
205
240
  const allTools = (pi.getAllTools() ?? []) as any[];
206
241
  const toolMeta = allTools.find((t) => t.name === toolName);
207
242
  const destructive = toolMeta?.annotations?.destructiveHint === true;
208
- const needsApproval = cfg.approve.tools.includes(toolName) || destructive;
243
+ const forced = FORCE_APPROVAL_TOOLS.has(toolName);
244
+ const needsApproval = forced || cfg.approve.tools.includes(toolName) || destructive;
209
245
  if (!needsApproval) return; // 放行
210
246
 
211
- // 会话级自动同意(用户回复过「以后都同意」):本会话内直接放行,不落盘、不跨会话
212
- if (state.approver.autoAllowed) {
213
- log("[approve] 自动同意放行:", toolName);
214
- return;
215
- }
247
+ // 强制审批的工具(发送文件)每次都问:不吃「以后都同意」,也不吃会话内记住的决策
248
+ if (!forced) {
249
+ // 会话级自动同意(用户回复过「以后都同意」):本会话内直接放行,不落盘、不跨会话
250
+ if (state.approver.autoAllowed) {
251
+ log("[approve] 自动同意放行:", toolName);
252
+ return;
253
+ }
216
254
 
217
- // 会话内已记住的决策
218
- const remembered = cfg.approve.remember ? state.approver.isRemembered(toolName) : undefined;
219
- if (remembered === "allow") return; // 已记住放行
220
- if (remembered === "block") return { block: true, reason: `QQ 用户已拒绝 ${toolName}` };
221
- if (remembered === "terminate") return { block: true, terminate: true, reason: "已停止" };
255
+ // 会话内已记住的决策
256
+ const remembered = cfg.approve.remember ? state.approver.isRemembered(toolName) : undefined;
257
+ if (remembered === "allow") return; // 已记住放行
258
+ if (remembered === "block") return { block: true, reason: `QQ 用户已拒绝 ${toolName}` };
259
+ if (remembered === "terminate") return { block: true, terminate: true, reason: "已停止" };
260
+ }
222
261
 
223
262
  const summary = summarizeTool(ev);
224
263
  log("[approve] 工具需要审批:", summary);
@@ -226,11 +265,13 @@ export default function (
226
265
  // 超时通知
227
266
  state.api?.sendC2C(openid, `审批 #${seq} 已超时,已自动拒绝。`).catch(() => {});
228
267
  });
229
- await state.api.sendC2C(openid, state.approver.render(seq)).catch(() => {});
268
+ await state.api
269
+ .sendC2C(openid, state.approver.render(seq, { strict: forced }))
270
+ .catch(() => {});
230
271
  const decision = await done;
231
272
 
232
273
  if (decision === "allow") {
233
- if (cfg.approve.remember) state.approver.remember(toolName, "allow");
274
+ if (!forced && cfg.approve.remember) state.approver.remember(toolName, "allow");
234
275
  log("[approve] 用户批准 #", seq);
235
276
  return;
236
277
  }
@@ -244,7 +285,7 @@ export default function (
244
285
  return { block: true, reason: `QQ 审批超时(#${seq}):${summary}` };
245
286
  }
246
287
  // block(用户显式拒绝)
247
- if (cfg.approve.remember) state.approver.remember(toolName, "block");
288
+ if (!forced && cfg.approve.remember) state.approver.remember(toolName, "block");
248
289
  log("[approve] 用户拒绝 #", seq);
249
290
  return { block: true, reason: `QQ 用户拒绝了 ${summary}` };
250
291
  });
@@ -338,6 +379,22 @@ export default function (
338
379
  const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
339
380
  const api = new QQApi({ restBase, getToken: () => auth.getToken() });
340
381
  state.api = api;
382
+ state.getToken = () => auth.getToken();
383
+ state.restBase = restBase;
384
+
385
+ // 顺手清掉上一轮留下的临时附件(失败不影响启动)
386
+ const attCfg = attachmentConfig(cfg);
387
+ if (attCfg.enabled) {
388
+ const attDir = attCfg.dir || defaultAttachmentDir(cfg.appId);
389
+ void sweepAttachmentDir(attDir, {
390
+ retentionHours: attCfg.retentionHours,
391
+ maxFiles: attCfg.maxFiles,
392
+ })
393
+ .then((r) => {
394
+ if (r.removed > 0) log("[attachment] 清理临时附件", r.removed, "个", `(dir=${attDir})`);
395
+ })
396
+ .catch((e) => log("[attachment] 清理失败:", String(e).slice(0, 160)));
397
+ }
341
398
 
342
399
  if (options?.disableGateway) {
343
400
  log("[setup] 测试模式:跳过 QQ 网关");
@@ -458,6 +515,8 @@ export default function (
458
515
  state.releaseLock = null;
459
516
  state.gateway = null;
460
517
  state.api = null;
518
+ state.getToken = null;
519
+ state.restBase = null;
461
520
  state.account = null;
462
521
  });
463
522
 
@@ -466,13 +525,24 @@ export default function (
466
525
  // 而 reload()/newSession() 又必须拿到 ExtensionCommandContext。于是注册一个扩展
467
526
  // 命令,QQ 侧收到 /reload 时改派 /qq reload,就能在合法的命令上下文里执行。
468
527
  pi.registerCommand("qq", {
469
- description: "pi-qqbot 内部桥接:/qq <reload|new|compact|quit|session|name|model|thinking> [args]",
528
+ description:
529
+ "pi-qqbot 内部桥接:/qq <reload|new|compact|quit|session|name|model|thinking|sendfile> [args]",
470
530
  handler: async (args, ctx) => {
471
531
  const trimmed = args.trim();
472
532
  const space = trimmed.indexOf(" ");
473
533
  const sub = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
474
534
  const rest = (space === -1 ? "" : trimmed.slice(space + 1)).trim();
475
535
  const reply = (text: string) => state.bridgeReply?.(text);
536
+ // 发本机文件不需要命令上下文,先于 runBridge 处理
537
+ if (sub === "sendfile") {
538
+ try {
539
+ reply(await sendFileToQQ(state, rest));
540
+ } catch (e) {
541
+ log("[/qq] sendfile 失败:", String(e));
542
+ reply(`发送失败:${explainUploadError(e)}`);
543
+ }
544
+ return;
545
+ }
476
546
  try {
477
547
  await runBridge(sub, rest, ctx, pi, reply, state);
478
548
  } catch (e) {
@@ -481,6 +551,29 @@ export default function (
481
551
  }
482
552
  },
483
553
  });
554
+
555
+ // ---- 工具:把本机文件发到 QQ ----
556
+ // 外发文件属于数据出境,必须在 QQ 里审批(见 FORCE_APPROVAL_TOOLS)。
557
+ pi.registerTool(
558
+ defineTool({
559
+ name: QQ_SEND_FILE_TOOL,
560
+ label: "Send file to QQ",
561
+ description:
562
+ "把本机上已有的文件发送到当前 QQ 对话(对方收到可下载的文件卡片/图片/视频/语音)。" +
563
+ "只在用户明确要求把某个文件发给他时使用;path 必须是本机绝对路径。" +
564
+ "发送前会先在 QQ 里自动申请审批,用户同意后才会真正上传。",
565
+ parameters: Type.Object({
566
+ path: Type.String({ description: "本机文件的绝对路径,例如 D:\\docs\\report.pdf" }),
567
+ name: Type.Optional(
568
+ Type.String({ description: "在 QQ 里显示的文件名(含扩展名),默认用原文件名" }),
569
+ ),
570
+ }),
571
+ async execute(_toolCallId: string, params: any) {
572
+ const text = await sendFileToQQ(state, String(params?.path ?? ""), params?.name ? String(params.name) : undefined);
573
+ return { content: [{ type: "text", text }], details: { tool: QQ_SEND_FILE_TOOL } };
574
+ },
575
+ }),
576
+ );
484
577
  }
485
578
 
486
579
  /** 取消/停止这类「打断当前任务」的表达 */
@@ -498,6 +591,70 @@ function isCancelIntent(text: string): boolean {
498
591
  return RE_CANCEL.test(text.trim());
499
592
  }
500
593
 
594
+ /** files 配置兜底:老配置 / 测试里可能没这个键 */
595
+ function filesConfig(cfg: QQBotConfig): { enabled: boolean; maxBytes: number } {
596
+ const raw = (cfg as any)?.files;
597
+ return {
598
+ enabled: raw?.enabled ?? true,
599
+ maxBytes: Number(raw?.maxBytes ?? DEFAULT_SEND_MAX_BYTES),
600
+ };
601
+ }
602
+
603
+ /**
604
+ * 把本机文件发到当前 QQ 对话。
605
+ * 工具(qq_send_file)与 `/sendfile` 命令共用;失败抛异常,文案由 explainUploadError 翻译。
606
+ */
607
+ async function sendFileToQQ(state: State, filePath: string, displayName?: string): Promise<string> {
608
+ const cfg = state.cfg;
609
+ if (!cfg) throw new Error("还没有接入 QQ bot(会话未就绪)");
610
+ const files = filesConfig(cfg);
611
+ if (!files.enabled) throw new Error("发送文件已关闭(配置 files.enabled=false)");
612
+ const openid = state.currentOpenid ?? state.lastOpenid;
613
+ if (!openid || !state.api) throw new Error("没有 QQ 会话目标(先让用户从 QQ 发一条消息)");
614
+ if (!state.restBase || !state.getToken) throw new Error("QQ 凭据未就绪");
615
+
616
+ const raw = String(filePath ?? "")
617
+ .trim()
618
+ .replace(/^["']|["']$/g, "")
619
+ .trim();
620
+ if (!raw) throw new Error("缺少文件路径(用法:/sendfile <绝对路径>)");
621
+
622
+ const media = await uploadRichMedia({
623
+ restBase: state.restBase,
624
+ openid,
625
+ getToken: state.getToken,
626
+ filePath: raw,
627
+ fileName: displayName,
628
+ maxBytes: files.maxBytes,
629
+ log,
630
+ onProgress: (done, total) => log("[send] 分片", done, "/", total),
631
+ });
632
+
633
+ const ref = state.passiveRefs.get(openid);
634
+ const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
635
+ await state.api.sendC2CMedia(openid, media.fileInfo, usePassive ? { msgId: ref.msgId } : {});
636
+ log("[send] 已发出", media.fileName, media.size, "bytes", `type=${media.fileType}`);
637
+ return `已发送「${media.fileName}」(${formatSize(media.size)},${mediaKindLabel(media.fileType)})`;
638
+ }
639
+
640
+ function formatSize(bytes: number): string {
641
+ if (!Number.isFinite(bytes) || bytes < 0) return "?";
642
+ if (bytes < 1024) return `${bytes} B`;
643
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
644
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
645
+ }
646
+ function attachmentConfig(cfg: QQBotConfig): AttachmentConfig {
647
+ const raw = (cfg as any)?.attachments;
648
+ return {
649
+ enabled: raw?.enabled ?? true,
650
+ dir: String(raw?.dir ?? "").trim(),
651
+ maxBytes: Number(raw?.maxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES),
652
+ maxPerMessage: Number(raw?.maxPerMessage ?? DEFAULT_ATTACHMENT_MAX_PER_MESSAGE),
653
+ retentionHours: Number(raw?.retentionHours ?? DEFAULT_ATTACHMENT_RETENTION_HOURS),
654
+ maxFiles: Number(raw?.maxFiles ?? DEFAULT_ATTACHMENT_MAX_FILES),
655
+ };
656
+ }
657
+
501
658
  /** 派发失败时最多重试几次(通常是 pi 上一轮还没收尾) */
502
659
  const MAX_DISPATCH_ATTEMPTS = 3;
503
660
 
@@ -733,6 +890,50 @@ export async function handleMessage(
733
890
  }
734
891
  state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
735
892
 
893
+ // ---- 附件落盘:图片 / 语音 / 视频 / 文档 / 压缩包 / 普通文件 ----
894
+ // 落盘后拼成「图片路径 <path>」这类标记行交给 pi;有附件时绕过 picker/指令/审批路由
895
+ const attachments = msg.attachments ?? [];
896
+ const hasAttachment = attachments.length > 0;
897
+ let incoming = msg.content;
898
+ if (hasAttachment) {
899
+ const acc = attachmentConfig(cfg);
900
+ if (!acc.enabled) {
901
+ const names = attachments.map((a, i) => a.filename || `附件 ${i + 1}`).join("、");
902
+ log("[attachment] 已被配置忽略", attachments.length, "个附件");
903
+ await replyTo(
904
+ api,
905
+ state,
906
+ msg.openid,
907
+ `已忽略 ${attachments.length} 个附件(配置 attachments.enabled=false):${names}。本次只处理文字内容。`,
908
+ );
909
+ if (!msg.content.trim()) return; // 纯附件 + 已忽略:不给 pi 送空消息
910
+ } else {
911
+ const dir = acc.dir || defaultAttachmentDir(cfg.appId);
912
+ const { saved, failed } = await saveIncoming(attachments, {
913
+ dir,
914
+ maxBytes: acc.maxBytes,
915
+ maxPerMessage: acc.maxPerMessage,
916
+ fetchFn: state.attachmentFetch ?? undefined,
917
+ credential: state.getToken,
918
+ prefix: msg.msgId,
919
+ });
920
+ log(
921
+ "[attachment] 落盘",
922
+ saved.length,
923
+ "个,失败",
924
+ failed.length,
925
+ "个 →",
926
+ saved.map((s) => `${s.kind}:${s.name}`).join(", ") || "(无)",
927
+ );
928
+ const ack = renderAttachmentAck(saved, failed);
929
+ if (ack) await replyTo(api, state, msg.openid, ack);
930
+ incoming = [msg.content.trim(), ...renderAttachmentLines(saved), ...renderAttachmentFailureLines(failed)]
931
+ .filter(Boolean)
932
+ .join("\n");
933
+ if (!incoming) return; // 全部失败且无正文:已经回执过,不再送空消息
934
+ }
935
+ }
936
+
736
937
  // 上一条最终答案没发出去(配额耗尽):先补发
737
938
  if (state.undelivered && state.undelivered.openid === msg.openid) {
738
939
  const pending = state.undelivered;
@@ -745,8 +946,8 @@ export async function handleMessage(
745
946
  }
746
947
  }
747
948
 
748
- // 待选择的编号回复(/tree、/fork、/resume 之后)
749
- if (state.picker) {
949
+ // 待选择的编号回复(/tree、/fork、/resume 之后);带附件时不当作编号选择
950
+ if (!hasAttachment && state.picker) {
750
951
  if (pickerExpired(state.picker)) {
751
952
  state.picker = null;
752
953
  } else if (state.picker.openid === msg.openid) {
@@ -788,7 +989,9 @@ export async function handleMessage(
788
989
  state.bridgeReply = (text: string) => {
789
990
  void replyTo(api, state, msg.openid, text);
790
991
  };
791
- const routed = await routeSlash(
992
+ const routed = hasAttachment
993
+ ? ("text" as const)
994
+ : await routeSlash(
792
995
  {
793
996
  listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string; source?: string }[],
794
997
  dispatch: (text, options) => {
@@ -821,8 +1024,8 @@ export async function handleMessage(
821
1024
  );
822
1025
  if (routed !== "text") return;
823
1026
 
824
- // 远程审批回复优先
825
- const parsed = state.approver.parseReply(msg.content.trim());
1027
+ // 远程审批回复优先(带附件时不当作审批动作)
1028
+ const parsed = hasAttachment ? null : state.approver.parseReply(msg.content.trim());
826
1029
  if (parsed) {
827
1030
  if (parsed.decision === "terminate") {
828
1031
  const n = state.approver.terminateAll();
@@ -874,8 +1077,8 @@ export async function handleMessage(
874
1077
  log("[msg] 审批类文本但无待审批,按普通消息处理");
875
1078
  }
876
1079
 
877
- // 内置指令
878
- const cmd = parseCommand(msg.content);
1080
+ // 内置指令(带附件时绕过:附件消息一律当普通内容交给 pi)
1081
+ const cmd = hasAttachment ? null : parseCommand(msg.content);
879
1082
  if (cmd?.name === "help") {
880
1083
  await replyTo(
881
1084
  api,
@@ -888,6 +1091,7 @@ export async function handleMessage(
888
1091
  "/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
889
1092
  "/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
890
1093
  "/cancel(或「取消」「停止」「停」)— 中断正在运行的任务并清空队列",
1094
+ "/sendfile <路径> — 把本机文件发到你的 QQ(png/jpg/mp4/silk 直显,其余为文件卡片;外发需审批)",
891
1095
  "只关进度推送:/progress off(或「别汇报了」「闭嘴」);「停」是中断任务,不是静音",
892
1096
  "/help — 帮助",
893
1097
  "pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
@@ -982,20 +1186,20 @@ export async function handleMessage(
982
1186
  // 忙检查:单会话串行处理——忙碌时入队,当前任务结束后自动继续
983
1187
  if (isBusy(state)) {
984
1188
  // 忙碌时问「进度」不该排队,直接回当前进度并恢复自动推送
985
- if (RE_PROGRESS_QUERY.test(msg.content.trim())) {
1189
+ if (!hasAttachment && RE_PROGRESS_QUERY.test(msg.content.trim())) {
986
1190
  const snapshot = state.progress.summary();
987
1191
  if (!state.progressMuted) state.progressPaused = false;
988
1192
  await replyTo(api, state, msg.openid, snapshot || "任务刚启动,还没有可汇报的进度。");
989
1193
  return;
990
1194
  }
991
- await enqueueJob(api, state, msg.openid, msg.content);
1195
+ await enqueueJob(api, state, msg.openid, incoming);
992
1196
  return;
993
1197
  }
994
1198
 
995
1199
  // ack
996
1200
  await replyTo(api, state, msg.openid, "收到,正在处理");
997
1201
 
998
- startJob(pi, state, state.queue.mint(msg.openid, msg.content));
1202
+ startJob(pi, state, state.queue.mint(msg.openid, incoming));
999
1203
  } catch (e) {
1000
1204
  log("[msg] 处理失败:", String(e));
1001
1205
  await replyTo(api, state, msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
@@ -1251,6 +1455,8 @@ function summarizeTool(event: any): string {
1251
1455
  return `${event.toolName}: ${clip(input.path ?? input.file ?? input.filePath)}`;
1252
1456
  case "grep":
1253
1457
  return `${event.toolName}: ${clip(input.pattern)}`;
1458
+ case "qq_send_file":
1459
+ return `发送文件到 QQ: ${clip(input.path)}${input.name ? `(显示名 ${clip(input.name)})` : ""}`;
1254
1460
  default:
1255
1461
  return `${event.toolName}: ${clip(JSON.stringify(input))}`;
1256
1462
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxh126/pi-qqbot",
3
- "version": "2.0.1",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
5
  "description": "Bridge the Pi coding agent into QQ private chats, with remote tool-call approval",
6
6
  "license": "MIT",
@@ -27,7 +27,7 @@
27
27
  ]
28
28
  },
29
29
  "scripts": {
30
- "test": "node test/queue.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs && node test/config.test.mjs && node test/lock.test.mjs && node test/accounts.test.mjs && node test/auth.test.mjs"
30
+ "test": "node test/queue.test.mjs && node test/approver.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs && node test/config.test.mjs && node test/lock.test.mjs && node test/accounts.test.mjs && node test/auth.test.mjs && node test/attachment.test.mjs && node test/upload.test.mjs"
31
31
  },
32
32
  "files": [
33
33
  "*.ts",
@@ -43,10 +43,18 @@
43
43
  "ws": "^8.18.0"
44
44
  },
45
45
  "peerDependencies": {
46
- "@earendil-works/pi-coding-agent": "*"
46
+ "@earendil-works/pi-coding-agent": "*",
47
+ "typebox": "*"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "typebox": {
51
+ "optional": true
52
+ }
47
53
  },
48
54
  "devDependencies": {
49
55
  "@types/proper-lockfile": "^4.1.4",
50
- "@types/ws": "^8.18.2"
56
+ "@types/ws": "^8.18.2",
57
+ "jiti": "^2.6.1",
58
+ "typebox": "^1.3.27"
51
59
  }
52
60
  }
package/qq/api.ts CHANGED
@@ -11,6 +11,10 @@ export interface SendOptions {
11
11
  format?: "text" | "markdown";
12
12
  }
13
13
 
14
+ export interface MediaSendOptions {
15
+ msgId?: string;
16
+ }
17
+
14
18
  export class QQApi {
15
19
  private queue: Promise<void> = Promise.resolve();
16
20
  private seqCounters = new Map<string, number>();
@@ -23,6 +27,13 @@ export class QQApi {
23
27
  return task;
24
28
  }
25
29
 
30
+ /** 发富媒体(msg_type=7):fileInfo 来自 `/v2/users/{openid}/files` 的上传结果 */
31
+ sendC2CMedia(openid: string, fileInfo: string, options: MediaSendOptions = {}): Promise<void> {
32
+ const task = this.queue.then(() => this.doMedia(openid, fileInfo, options));
33
+ this.queue = task.catch(() => {});
34
+ return task;
35
+ }
36
+
26
37
  private nextSeq(msgId?: string): number | undefined {
27
38
  if (!msgId) return undefined;
28
39
  const used = this.seqCounters.get(msgId) ?? 0;
@@ -52,6 +63,21 @@ export class QQApi {
52
63
  }
53
64
  }
54
65
 
66
+ private async doMedia(openid: string, fileInfo: string, options: MediaSendOptions): Promise<void> {
67
+ let msgId = options.msgId;
68
+ let seq: number | undefined;
69
+ if (msgId) {
70
+ seq = this.nextSeq(msgId);
71
+ if (seq === undefined) msgId = undefined; // 被动额度用尽 → 主动消息
72
+ }
73
+ const body: any = { msg_type: 7, media: { file_info: fileInfo } };
74
+ if (msgId && seq !== undefined) {
75
+ body.msg_id = msgId;
76
+ body.msg_seq = seq;
77
+ }
78
+ await this.postWithRetry(openid, body);
79
+ }
80
+
55
81
  private async doSend(openid: string, content: string, options: SendOptions): Promise<void> {
56
82
  const format = options.format ?? "text";
57
83
  let msgId = options.msgId;