@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 +120 -0
- package/README.md +129 -4
- package/approver.ts +33 -12
- package/attachment.ts +502 -0
- package/config.ts +62 -0
- package/constants.ts +24 -0
- package/docs/README.zh-CN.md +117 -4
- package/index.ts +342 -28
- package/package.json +12 -4
- package/qq/api.ts +26 -0
- package/qq/gateway.ts +41 -11
- package/qq/upload.ts +288 -0
- package/remote.ts +81 -0
- package/slash.ts +17 -1
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
|
|
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 (
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
321
|
+
// 强制审批的工具(发送文件)每次都问:不吃「以后都同意」,也不吃会话内记住的决策
|
|
322
|
+
if (!forced) {
|
|
323
|
+
// 会话级自动同意(用户回复过「以后都同意」):本会话内直接放行,不落盘、不跨会话
|
|
324
|
+
if (state.approver.autoAllowed) {
|
|
325
|
+
log("[approve] 自动同意放行:", toolName);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
216
328
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
}
|