@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/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";
@@ -18,10 +39,23 @@ import { Approver } from "./approver";
18
39
  import { parsePickIndex, pickerExpired, routeSlash, runBridge, type PickerState } from "./slash";
19
40
  import { agoLabel, MessageQueue, previewText, type QueuedJob } from "./queue";
20
41
  import { ProgressTracker } from "./progress";
42
+ import {
43
+ buildLocalHandoffNotice,
44
+ buildRemoteGuidelines,
45
+ buildRemoteNotice,
46
+ LOCAL_HANDOFF_SECTION,
47
+ REMOTE_NOTICE_SECTION,
48
+ resolveRemoteConfig,
49
+ } from "./remote";
21
50
  import { redactSecrets } from "./redact";
22
51
  import { ReplyBuffer } from "./stream-buffer";
23
52
  import { splitText } from "./util/chunk";
24
53
 
54
+ /** 发送本机文件的工具名 */
55
+ const QQ_SEND_FILE_TOOL = "qq_send_file";
56
+ /** 外发文件必须审批:不受配置 approve.tools 影响,也不吃「以后都同意」/会话内记忆 */
57
+ const FORCE_APPROVAL_TOOLS = new Set<string>([QQ_SEND_FILE_TOOL]);
58
+
25
59
  interface State {
26
60
  cfg: QQBotConfig | null;
27
61
  /** 本进程当前服务的 QQ bot(自动抢占的结果);未接入则为 null */
@@ -67,8 +101,22 @@ interface State {
67
101
  progressPaused: boolean;
68
102
  /** 用户手动暂停自动推送(本会话有效:跨 run 保留,/progress on 恢复) */
69
103
  progressMuted: boolean;
104
+ /** 等审批期间暂缓自动进度推送(避免把审批消息顶走);审批处理完自动恢复 */
105
+ progressHeldForApproval: boolean;
106
+ /** 本会话是否已给 pi 注入过「远程用户」提醒(每会话一次,压缩后重新注入) */
107
+ remoteNotified: boolean;
108
+ /** 上一轮 run 的来源:null = 还没跑过 */
109
+ lastRunOrigin: "qq" | "local" | null;
110
+ /** 本机在 TUI 接管了对话,等下一轮 run 开跑时更正「远程前提作废」 */
111
+ localHandoffPending: boolean;
70
112
  /** 最终答案发送失败时缓存,下一条消息时补发 */
71
113
  undelivered: { openid: string; text: string } | null;
114
+ /** 附件下载 401/403 时重试用的凭据(接入后由 applyClaim 注入) */
115
+ getToken: (() => Promise<string>) | null;
116
+ /** 仅测试用:替换附件落盘时的 fetch */
117
+ attachmentFetch: typeof fetch | null;
118
+ /** 当前 bot 的 REST 基址(发文件用) */
119
+ restBase: string | null;
72
120
  }
73
121
 
74
122
  /** 扩展运行所需的状态(导出只为测试用) */
@@ -98,7 +146,14 @@ export function createInitialState(): State {
98
146
  progressTimer: null,
99
147
  progressPaused: false,
100
148
  progressMuted: false,
149
+ progressHeldForApproval: false,
150
+ remoteNotified: false,
151
+ lastRunOrigin: null,
152
+ localHandoffPending: false,
101
153
  undelivered: null,
154
+ getToken: null,
155
+ attachmentFetch: null,
156
+ restBase: null,
102
157
  account: null,
103
158
  candidates: [],
104
159
  claimNote: null,
@@ -118,6 +173,59 @@ export default function (
118
173
  ) {
119
174
  const state: State = options?.state ?? createInitialState();
120
175
 
176
+ // ---- 事件:本机用户在 TUI 直接打字(source=interactive)→ 记一笔「可能接管」----
177
+ // 只记待定,真正注入要等下一轮 run 开跑(before_agent_start)。
178
+ // 本扩展自己的 sendUserMessage 是 source=extension,不会走到这里;QQ run 跑着时接管不处理。
179
+ pi.on("input", (event) => {
180
+ const ev = event as any;
181
+ if (ev?.source !== "interactive") return;
182
+ if (state.busy) return;
183
+ if (state.lastRunOrigin !== "qq") return;
184
+ if (state.localHandoffPending) return;
185
+ state.localHandoffPending = true;
186
+ log("[remote] 检测到本机 TUI 输入,下一轮按「本机用户」前提处理");
187
+ });
188
+
189
+ // ---- 事件:真正开始一轮 run 时,按「来自 QQ 还是本机」准备系统提示 ----
190
+ // 不在扩展载入时注入,也不在本机 TUI 自己对话时注入:只有 startJob() 派发的
191
+ // QQ 消息真的开跑(state.busy + currentOpenid)才会走到这里。
192
+ pi.on("before_agent_start", (event) => {
193
+ const cfg = state.cfg;
194
+ if (!cfg) return;
195
+ if ((event as any).messageType !== "user") return;
196
+ const options = (event as any).systemPromptOptions;
197
+ if (!options || typeof options !== "object") return;
198
+
199
+ // 本扩展派发的 QQ 消息
200
+ if (state.busy && state.currentOpenid) {
201
+ const remote = resolveRemoteConfig(cfg);
202
+ if (remote.guidelines && Array.isArray(options.promptGuidelines)) {
203
+ const guidelines = buildRemoteGuidelines();
204
+ // 同一次 run 可能重复触发(如压缩后重试),别叠加两遍
205
+ if (!options.promptGuidelines.includes(guidelines[0])) options.promptGuidelines.push(...guidelines);
206
+ }
207
+ if (remote.notice && !state.remoteNotified) {
208
+ state.remoteNotified = true;
209
+ // 放进系统提示的 section:排在用户消息之前,模型不会把它当成要回复的消息;
210
+ // 只在本轮生效,操作规则由 guidelines 继续带在后续每一轮。
211
+ options.sections = { ...(options.sections ?? {}), [REMOTE_NOTICE_SECTION]: buildRemoteNotice(remote) };
212
+ log("[remote] 首次向 pi 交付 QQ 消息,已注入远程上下文说明(系统提示 section)");
213
+ }
214
+ // 又轮到 QQ 了:刚才的本机接管待定作废
215
+ state.localHandoffPending = false;
216
+ state.lastRunOrigin = "qq";
217
+ return;
218
+ }
219
+
220
+ // 本机 TUI/CLI 直接开跑:上一轮是 QQ 的话,更正一次「远程前提作废」
221
+ const handoff = state.localHandoffPending;
222
+ state.localHandoffPending = false;
223
+ state.lastRunOrigin = "local";
224
+ if (!handoff) return;
225
+ options.sections = { ...(options.sections ?? {}), [LOCAL_HANDOFF_SECTION]: buildLocalHandoffNotice() };
226
+ log("[remote] 本机接管本轮对话,已注入「远程前提作废」更正");
227
+ });
228
+
121
229
  // ---- 事件:run 开始,记住 ctx(取消指令要调用 ctx.abort())----
122
230
  pi.on("agent_start", (_event, ctx) => {
123
231
  state.ctx = ctx;
@@ -126,6 +234,7 @@ export default function (
126
234
  clearCancelTimer(state);
127
235
  state.progress.start();
128
236
  state.progressPaused = false;
237
+ state.progressHeldForApproval = false;
129
238
  startProgressTimer(pi, state);
130
239
  });
131
240
 
@@ -201,24 +310,28 @@ export default function (
201
310
  const toolName: string = ev.toolName ?? "tool";
202
311
  const cfg = state.cfg;
203
312
 
204
- // 判断是否需要审批:配置的 tools 列表 + destructiveHint 兑底
313
+ // 判断是否需要审批:配置的 tools 列表 + destructiveHint 兑底 + 强制审批工具(外发文件)
205
314
  const allTools = (pi.getAllTools() ?? []) as any[];
206
315
  const toolMeta = allTools.find((t) => t.name === toolName);
207
316
  const destructive = toolMeta?.annotations?.destructiveHint === true;
208
- const needsApproval = cfg.approve.tools.includes(toolName) || destructive;
317
+ const forced = FORCE_APPROVAL_TOOLS.has(toolName);
318
+ const needsApproval = forced || cfg.approve.tools.includes(toolName) || destructive;
209
319
  if (!needsApproval) return; // 放行
210
320
 
211
- // 会话级自动同意(用户回复过「以后都同意」):本会话内直接放行,不落盘、不跨会话
212
- if (state.approver.autoAllowed) {
213
- log("[approve] 自动同意放行:", toolName);
214
- return;
215
- }
321
+ // 强制审批的工具(发送文件)每次都问:不吃「以后都同意」,也不吃会话内记住的决策
322
+ if (!forced) {
323
+ // 会话级自动同意(用户回复过「以后都同意」):本会话内直接放行,不落盘、不跨会话
324
+ if (state.approver.autoAllowed) {
325
+ log("[approve] 自动同意放行:", toolName);
326
+ return;
327
+ }
216
328
 
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: "已停止" };
329
+ // 会话内已记住的决策
330
+ const remembered = cfg.approve.remember ? state.approver.isRemembered(toolName) : undefined;
331
+ if (remembered === "allow") return; // 已记住放行
332
+ if (remembered === "block") return { block: true, reason: `QQ 用户已拒绝 ${toolName}` };
333
+ if (remembered === "terminate") return { block: true, terminate: true, reason: "已停止" };
334
+ }
222
335
 
223
336
  const summary = summarizeTool(ev);
224
337
  log("[approve] 工具需要审批:", summary);
@@ -226,11 +339,13 @@ export default function (
226
339
  // 超时通知
227
340
  state.api?.sendC2C(openid, `审批 #${seq} 已超时,已自动拒绝。`).catch(() => {});
228
341
  });
229
- await state.api.sendC2C(openid, state.approver.render(seq)).catch(() => {});
342
+ await state.api
343
+ .sendC2C(openid, state.approver.render(seq, { strict: forced }))
344
+ .catch(() => {});
230
345
  const decision = await done;
231
346
 
232
347
  if (decision === "allow") {
233
- if (cfg.approve.remember) state.approver.remember(toolName, "allow");
348
+ if (!forced && cfg.approve.remember) state.approver.remember(toolName, "allow");
234
349
  log("[approve] 用户批准 #", seq);
235
350
  return;
236
351
  }
@@ -244,7 +359,7 @@ export default function (
244
359
  return { block: true, reason: `QQ 审批超时(#${seq}):${summary}` };
245
360
  }
246
361
  // block(用户显式拒绝)
247
- if (cfg.approve.remember) state.approver.remember(toolName, "block");
362
+ if (!forced && cfg.approve.remember) state.approver.remember(toolName, "block");
248
363
  log("[approve] 用户拒绝 #", seq);
249
364
  return { block: true, reason: `QQ 用户拒绝了 ${summary}` };
250
365
  });
@@ -276,6 +391,11 @@ export default function (
276
391
 
277
392
  // 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
278
393
  state.approver.reset();
394
+ // 远程上下文提醒同样只在本会话提醒一次
395
+ state.remoteNotified = false;
396
+ // 「QQ ↔ 本机」的来源切换状态也随会话重置
397
+ state.lastRunOrigin = null;
398
+ state.localHandoffPending = false;
279
399
  // 手动暂停的自动汇报同样只在本会话有效
280
400
  state.progressMuted = false;
281
401
  state.progressPaused = false;
@@ -338,6 +458,22 @@ export default function (
338
458
  const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
339
459
  const api = new QQApi({ restBase, getToken: () => auth.getToken() });
340
460
  state.api = api;
461
+ state.getToken = () => auth.getToken();
462
+ state.restBase = restBase;
463
+
464
+ // 顺手清掉上一轮留下的临时附件(失败不影响启动)
465
+ const attCfg = attachmentConfig(cfg);
466
+ if (attCfg.enabled) {
467
+ const attDir = attCfg.dir || defaultAttachmentDir(cfg.appId);
468
+ void sweepAttachmentDir(attDir, {
469
+ retentionHours: attCfg.retentionHours,
470
+ maxFiles: attCfg.maxFiles,
471
+ })
472
+ .then((r) => {
473
+ if (r.removed > 0) log("[attachment] 清理临时附件", r.removed, "个", `(dir=${attDir})`);
474
+ })
475
+ .catch((e) => log("[attachment] 清理失败:", String(e).slice(0, 160)));
476
+ }
341
477
 
342
478
  if (options?.disableGateway) {
343
479
  log("[setup] 测试模式:跳过 QQ 网关");
@@ -433,6 +569,12 @@ export default function (
433
569
  enterStandby();
434
570
  });
435
571
 
572
+ // ---- 事件:上下文压缩可能把「远程用户」提醒压掉:下一条消息重新注入 ----
573
+ pi.on("session_compact", (event) => {
574
+ state.remoteNotified = false;
575
+ log("[remote] 上下文已压缩(reason=", String((event as any)?.reason ?? "?"), "),下一条消息重新注入提醒");
576
+ });
577
+
436
578
  // ---- 事件:session_shutdown 清理 ----
437
579
  pi.on("session_shutdown", async (event) => {
438
580
  const reason = String((event as any)?.reason ?? "quit");
@@ -458,6 +600,8 @@ export default function (
458
600
  state.releaseLock = null;
459
601
  state.gateway = null;
460
602
  state.api = null;
603
+ state.getToken = null;
604
+ state.restBase = null;
461
605
  state.account = null;
462
606
  });
463
607
 
@@ -466,13 +610,24 @@ export default function (
466
610
  // 而 reload()/newSession() 又必须拿到 ExtensionCommandContext。于是注册一个扩展
467
611
  // 命令,QQ 侧收到 /reload 时改派 /qq reload,就能在合法的命令上下文里执行。
468
612
  pi.registerCommand("qq", {
469
- description: "pi-qqbot 内部桥接:/qq <reload|new|compact|quit|session|name|model|thinking> [args]",
613
+ description:
614
+ "pi-qqbot 内部桥接:/qq <reload|new|compact|quit|session|name|model|thinking|sendfile> [args]",
470
615
  handler: async (args, ctx) => {
471
616
  const trimmed = args.trim();
472
617
  const space = trimmed.indexOf(" ");
473
618
  const sub = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
474
619
  const rest = (space === -1 ? "" : trimmed.slice(space + 1)).trim();
475
620
  const reply = (text: string) => state.bridgeReply?.(text);
621
+ // 发本机文件不需要命令上下文,先于 runBridge 处理
622
+ if (sub === "sendfile") {
623
+ try {
624
+ reply(await sendFileToQQ(state, rest));
625
+ } catch (e) {
626
+ log("[/qq] sendfile 失败:", String(e));
627
+ reply(`发送失败:${explainUploadError(e)}`);
628
+ }
629
+ return;
630
+ }
476
631
  try {
477
632
  await runBridge(sub, rest, ctx, pi, reply, state);
478
633
  } catch (e) {
@@ -481,6 +636,29 @@ export default function (
481
636
  }
482
637
  },
483
638
  });
639
+
640
+ // ---- 工具:把本机文件发到 QQ ----
641
+ // 外发文件属于数据出境,必须在 QQ 里审批(见 FORCE_APPROVAL_TOOLS)。
642
+ pi.registerTool(
643
+ defineTool({
644
+ name: QQ_SEND_FILE_TOOL,
645
+ label: "Send file to QQ",
646
+ description:
647
+ "把本机上已有的文件发送到当前 QQ 对话(对方收到可下载的文件卡片/图片/视频/语音)。" +
648
+ "只在用户明确要求把某个文件发给他时使用;path 必须是本机绝对路径。" +
649
+ "发送前会先在 QQ 里自动申请审批,用户同意后才会真正上传。",
650
+ parameters: Type.Object({
651
+ path: Type.String({ description: "本机文件的绝对路径,例如 D:\\docs\\report.pdf" }),
652
+ name: Type.Optional(
653
+ Type.String({ description: "在 QQ 里显示的文件名(含扩展名),默认用原文件名" }),
654
+ ),
655
+ }),
656
+ async execute(_toolCallId: string, params: any) {
657
+ const text = await sendFileToQQ(state, String(params?.path ?? ""), params?.name ? String(params.name) : undefined);
658
+ return { content: [{ type: "text", text }], details: { tool: QQ_SEND_FILE_TOOL } };
659
+ },
660
+ }),
661
+ );
484
662
  }
485
663
 
486
664
  /** 取消/停止这类「打断当前任务」的表达 */
@@ -498,6 +676,70 @@ function isCancelIntent(text: string): boolean {
498
676
  return RE_CANCEL.test(text.trim());
499
677
  }
500
678
 
679
+ /** files 配置兜底:老配置 / 测试里可能没这个键 */
680
+ function filesConfig(cfg: QQBotConfig): { enabled: boolean; maxBytes: number } {
681
+ const raw = (cfg as any)?.files;
682
+ return {
683
+ enabled: raw?.enabled ?? true,
684
+ maxBytes: Number(raw?.maxBytes ?? DEFAULT_SEND_MAX_BYTES),
685
+ };
686
+ }
687
+
688
+ /**
689
+ * 把本机文件发到当前 QQ 对话。
690
+ * 工具(qq_send_file)与 `/sendfile` 命令共用;失败抛异常,文案由 explainUploadError 翻译。
691
+ */
692
+ async function sendFileToQQ(state: State, filePath: string, displayName?: string): Promise<string> {
693
+ const cfg = state.cfg;
694
+ if (!cfg) throw new Error("还没有接入 QQ bot(会话未就绪)");
695
+ const files = filesConfig(cfg);
696
+ if (!files.enabled) throw new Error("发送文件已关闭(配置 files.enabled=false)");
697
+ const openid = state.currentOpenid ?? state.lastOpenid;
698
+ if (!openid || !state.api) throw new Error("没有 QQ 会话目标(先让用户从 QQ 发一条消息)");
699
+ if (!state.restBase || !state.getToken) throw new Error("QQ 凭据未就绪");
700
+
701
+ const raw = String(filePath ?? "")
702
+ .trim()
703
+ .replace(/^["']|["']$/g, "")
704
+ .trim();
705
+ if (!raw) throw new Error("缺少文件路径(用法:/sendfile <绝对路径>)");
706
+
707
+ const media = await uploadRichMedia({
708
+ restBase: state.restBase,
709
+ openid,
710
+ getToken: state.getToken,
711
+ filePath: raw,
712
+ fileName: displayName,
713
+ maxBytes: files.maxBytes,
714
+ log,
715
+ onProgress: (done, total) => log("[send] 分片", done, "/", total),
716
+ });
717
+
718
+ const ref = state.passiveRefs.get(openid);
719
+ const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
720
+ await state.api.sendC2CMedia(openid, media.fileInfo, usePassive ? { msgId: ref.msgId } : {});
721
+ log("[send] 已发出", media.fileName, media.size, "bytes", `type=${media.fileType}`);
722
+ return `已发送「${media.fileName}」(${formatSize(media.size)},${mediaKindLabel(media.fileType)})`;
723
+ }
724
+
725
+ function formatSize(bytes: number): string {
726
+ if (!Number.isFinite(bytes) || bytes < 0) return "?";
727
+ if (bytes < 1024) return `${bytes} B`;
728
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
729
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
730
+ }
731
+ function attachmentConfig(cfg: QQBotConfig): AttachmentConfig {
732
+ const raw = (cfg as any)?.attachments;
733
+ return {
734
+ enabled: raw?.enabled ?? true,
735
+ dir: String(raw?.dir ?? "").trim(),
736
+ maxBytes: Number(raw?.maxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES),
737
+ maxPerMessage: Number(raw?.maxPerMessage ?? DEFAULT_ATTACHMENT_MAX_PER_MESSAGE),
738
+ retentionHours: Number(raw?.retentionHours ?? DEFAULT_ATTACHMENT_RETENTION_HOURS),
739
+ maxFiles: Number(raw?.maxFiles ?? DEFAULT_ATTACHMENT_MAX_FILES),
740
+ };
741
+ }
742
+
501
743
  /** 派发失败时最多重试几次(通常是 pi 上一轮还没收尾) */
502
744
  const MAX_DISPATCH_ATTEMPTS = 3;
503
745
 
@@ -562,6 +804,7 @@ function startJob(pi: ExtensionAPI, state: State, job: QueuedJob) {
562
804
  "text=",
563
805
  job.text.slice(0, 60),
564
806
  );
807
+ // 每会话第一条真正跑起来的 QQ 消息,由 before_agent_start 注入远程上下文说明。
565
808
  try {
566
809
  void Promise.resolve(
567
810
  pi.sendUserMessage(job.text, { expandPromptTemplates: true, deliverAs: "followUp" }),
@@ -733,6 +976,50 @@ export async function handleMessage(
733
976
  }
734
977
  state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
735
978
 
979
+ // ---- 附件落盘:图片 / 语音 / 视频 / 文档 / 压缩包 / 普通文件 ----
980
+ // 落盘后拼成「图片路径 <path>」这类标记行交给 pi;有附件时绕过 picker/指令/审批路由
981
+ const attachments = msg.attachments ?? [];
982
+ const hasAttachment = attachments.length > 0;
983
+ let incoming = msg.content;
984
+ if (hasAttachment) {
985
+ const acc = attachmentConfig(cfg);
986
+ if (!acc.enabled) {
987
+ const names = attachments.map((a, i) => a.filename || `附件 ${i + 1}`).join("、");
988
+ log("[attachment] 已被配置忽略", attachments.length, "个附件");
989
+ await replyTo(
990
+ api,
991
+ state,
992
+ msg.openid,
993
+ `已忽略 ${attachments.length} 个附件(配置 attachments.enabled=false):${names}。本次只处理文字内容。`,
994
+ );
995
+ if (!msg.content.trim()) return; // 纯附件 + 已忽略:不给 pi 送空消息
996
+ } else {
997
+ const dir = acc.dir || defaultAttachmentDir(cfg.appId);
998
+ const { saved, failed } = await saveIncoming(attachments, {
999
+ dir,
1000
+ maxBytes: acc.maxBytes,
1001
+ maxPerMessage: acc.maxPerMessage,
1002
+ fetchFn: state.attachmentFetch ?? undefined,
1003
+ credential: state.getToken,
1004
+ prefix: msg.msgId,
1005
+ });
1006
+ log(
1007
+ "[attachment] 落盘",
1008
+ saved.length,
1009
+ "个,失败",
1010
+ failed.length,
1011
+ "个 →",
1012
+ saved.map((s) => `${s.kind}:${s.name}`).join(", ") || "(无)",
1013
+ );
1014
+ const ack = renderAttachmentAck(saved, failed);
1015
+ if (ack) await replyTo(api, state, msg.openid, ack);
1016
+ incoming = [msg.content.trim(), ...renderAttachmentLines(saved), ...renderAttachmentFailureLines(failed)]
1017
+ .filter(Boolean)
1018
+ .join("\n");
1019
+ if (!incoming) return; // 全部失败且无正文:已经回执过,不再送空消息
1020
+ }
1021
+ }
1022
+
736
1023
  // 上一条最终答案没发出去(配额耗尽):先补发
737
1024
  if (state.undelivered && state.undelivered.openid === msg.openid) {
738
1025
  const pending = state.undelivered;
@@ -745,8 +1032,8 @@ export async function handleMessage(
745
1032
  }
746
1033
  }
747
1034
 
748
- // 待选择的编号回复(/tree、/fork、/resume 之后)
749
- if (state.picker) {
1035
+ // 待选择的编号回复(/tree、/fork、/resume 之后);带附件时不当作编号选择
1036
+ if (!hasAttachment && state.picker) {
750
1037
  if (pickerExpired(state.picker)) {
751
1038
  state.picker = null;
752
1039
  } else if (state.picker.openid === msg.openid) {
@@ -788,7 +1075,9 @@ export async function handleMessage(
788
1075
  state.bridgeReply = (text: string) => {
789
1076
  void replyTo(api, state, msg.openid, text);
790
1077
  };
791
- const routed = await routeSlash(
1078
+ const routed = hasAttachment
1079
+ ? ("text" as const)
1080
+ : await routeSlash(
792
1081
  {
793
1082
  listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string; source?: string }[],
794
1083
  dispatch: (text, options) => {
@@ -821,8 +1110,8 @@ export async function handleMessage(
821
1110
  );
822
1111
  if (routed !== "text") return;
823
1112
 
824
- // 远程审批回复优先
825
- const parsed = state.approver.parseReply(msg.content.trim());
1113
+ // 远程审批回复优先(带附件时不当作审批动作)
1114
+ const parsed = hasAttachment ? null : state.approver.parseReply(msg.content.trim());
826
1115
  if (parsed) {
827
1116
  if (parsed.decision === "terminate") {
828
1117
  const n = state.approver.terminateAll();
@@ -874,8 +1163,8 @@ export async function handleMessage(
874
1163
  log("[msg] 审批类文本但无待审批,按普通消息处理");
875
1164
  }
876
1165
 
877
- // 内置指令
878
- const cmd = parseCommand(msg.content);
1166
+ // 内置指令(带附件时绕过:附件消息一律当普通内容交给 pi)
1167
+ const cmd = hasAttachment ? null : parseCommand(msg.content);
879
1168
  if (cmd?.name === "help") {
880
1169
  await replyTo(
881
1170
  api,
@@ -884,10 +1173,11 @@ export async function handleMessage(
884
1173
  [
885
1174
  "pi-qqbot 指令:",
886
1175
  "/progress — 查看进度;/progress off 暂停自动汇报、/progress on 恢复(本会话有效)",
887
- "/status — 查看状态(当前 qqbot / 忙闲 / 队列 / 自动同意 / 待审批 / 自动汇报)",
1176
+ "/status — 查看状态(当前 qqbot / 忙闲 / 来源 / 队列 / 自动同意 / 待审批 / 自动汇报)",
888
1177
  "/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
889
1178
  "/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
890
1179
  "/cancel(或「取消」「停止」「停」)— 中断正在运行的任务并清空队列",
1180
+ "/sendfile <路径> — 把本机文件发到你的 QQ(png/jpg/mp4/silk 直显,其余为文件卡片;外发需审批)",
891
1181
  "只关进度推送:/progress off(或「别汇报了」「闭嘴」);「停」是中断任务,不是静音",
892
1182
  "/help — 帮助",
893
1183
  "pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
@@ -902,6 +1192,7 @@ export async function handleMessage(
902
1192
  const lines = [
903
1193
  `bot: ${state.account ? `${state.account.name} (…${state.account.appId.slice(-4)})` : "未接入"} · cwd: ${process.cwd()}`,
904
1194
  `状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
1195
+ `来源: ${describeRunOrigin(state)}`,
905
1196
  `队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
906
1197
  `自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
907
1198
  `待审批: ${state.approver.pendingCount}`,
@@ -982,20 +1273,20 @@ export async function handleMessage(
982
1273
  // 忙检查:单会话串行处理——忙碌时入队,当前任务结束后自动继续
983
1274
  if (isBusy(state)) {
984
1275
  // 忙碌时问「进度」不该排队,直接回当前进度并恢复自动推送
985
- if (RE_PROGRESS_QUERY.test(msg.content.trim())) {
1276
+ if (!hasAttachment && RE_PROGRESS_QUERY.test(msg.content.trim())) {
986
1277
  const snapshot = state.progress.summary();
987
1278
  if (!state.progressMuted) state.progressPaused = false;
988
1279
  await replyTo(api, state, msg.openid, snapshot || "任务刚启动,还没有可汇报的进度。");
989
1280
  return;
990
1281
  }
991
- await enqueueJob(api, state, msg.openid, msg.content);
1282
+ await enqueueJob(api, state, msg.openid, incoming);
992
1283
  return;
993
1284
  }
994
1285
 
995
1286
  // ack
996
1287
  await replyTo(api, state, msg.openid, "收到,正在处理");
997
1288
 
998
- startJob(pi, state, state.queue.mint(msg.openid, msg.content));
1289
+ startJob(pi, state, state.queue.mint(msg.openid, incoming));
999
1290
  } catch (e) {
1000
1291
  log("[msg] 处理失败:", String(e));
1001
1292
  await replyTo(api, state, msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
@@ -1064,9 +1355,22 @@ function stopProgressTimer(state: State) {
1064
1355
  /** 推一条进度摘要;失败就暂停自动推送(用户问一次即恢复);手动暂停(/progress off)管到底 */
1065
1356
  async function pushProgress(state: State, reason: "tick" | "ask"): Promise<boolean> {
1066
1357
  if (reason === "tick" && (state.progressPaused || state.progressMuted)) return false;
1358
+ // 有审批在等用户回复:暂停自动推送,否则每 15s 一条进度会把审批提示顶出屏幕
1359
+ // (手动问进度不受影响:用户自己要看的)
1360
+ if (reason === "tick" && state.approver.pendingCount > 0) {
1361
+ if (!state.progressHeldForApproval) {
1362
+ state.progressHeldForApproval = true;
1363
+ log("[progress] 有", state.approver.pendingCount, "个待审批,暂缓自动进度推送");
1364
+ }
1365
+ return false;
1366
+ }
1067
1367
  const api = state.api;
1068
1368
  const openid = state.currentOpenid ?? state.lastOpenid;
1069
1369
  if (!api || !openid || !state.busy || !state.progress.running) return false;
1370
+ if (state.progressHeldForApproval) {
1371
+ state.progressHeldForApproval = false;
1372
+ log("[progress] 审批已处理,恢复自动进度推送");
1373
+ }
1070
1374
  const text = state.progress.summary();
1071
1375
  if (!text) return false;
1072
1376
  const ok = await replyTo(api, state, openid, text);
@@ -1077,6 +1381,13 @@ async function pushProgress(state: State, reason: "tick" | "ask"): Promise<boole
1077
1381
  return ok;
1078
1382
  }
1079
1383
 
1384
+ /** /status 用:最近一轮对话是谁发起的 */
1385
+ function describeRunOrigin(state: State): string {
1386
+ if (state.lastRunOrigin === "qq") return "QQ(远程)";
1387
+ if (state.lastRunOrigin === "local") return "本机(TUI)";
1388
+ return "尚未开始";
1389
+ }
1390
+
1080
1391
  /** 自动汇报当前状态的人话描述(/status 用) */
1081
1392
  function describeProgressMode(state: State): string {
1082
1393
  const cfg = state.cfg?.progress;
@@ -1084,7 +1395,8 @@ function describeProgressMode(state: State): string {
1084
1395
  if (cfg?.enabled === false) return "未启用(配置 progress.enabled=false)";
1085
1396
  if (state.progressPaused) return "推送失败已静默,发 /progress 恢复";
1086
1397
  const ms = Number.isFinite(cfg?.intervalMs) && (cfg?.intervalMs ?? 0) >= 50 ? cfg!.intervalMs : 15000;
1087
- return `开启(每 ${Math.round(ms / 1000)}s)`;
1398
+ const base = `开启(每 ${Math.round(ms / 1000)}s)`;
1399
+ return state.approver.pendingCount > 0 ? `${base},等审批期间暂缓` : base;
1088
1400
  }
1089
1401
 
1090
1402
  /** /progress 的子命令:暂停 / 恢复自动汇报 */
@@ -1251,6 +1563,8 @@ function summarizeTool(event: any): string {
1251
1563
  return `${event.toolName}: ${clip(input.path ?? input.file ?? input.filePath)}`;
1252
1564
  case "grep":
1253
1565
  return `${event.toolName}: ${clip(input.pattern)}`;
1566
+ case "qq_send_file":
1567
+ return `发送文件到 QQ: ${clip(input.path)}${input.name ? `(显示名 ${clip(input.name)})` : ""}`;
1254
1568
  default:
1255
1569
  return `${event.toolName}: ${clip(JSON.stringify(input))}`;
1256
1570
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxh126/pi-qqbot",
3
- "version": "2.0.1",
3
+ "version": "2.3.0",
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/remote-notice.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
  }