@zxh126/pi-qqbot 2.3.0 → 2.4.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,1571 +1,1856 @@
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";
4
- import { loadAccounts, pinnedAccountName, type QQBotAccount, type QQBotConfig } from "./config";
5
- import {
6
- REST_BASE_PROD,
7
- REST_BASE_SANDBOX,
8
- INTENT_GROUP_AND_C2C,
9
- PASSIVE_WINDOW_MS,
10
- APPROVAL_TIMEOUT_MS,
11
- DISPATCH_RETRY_MS,
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,
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";
33
- import { log, configureLog } from "./logger";
34
- import { claimAny, startClaimRetry, reserveBeforeRelease, readOwner, type Claim } from "./lock";
35
- import { AuthManager } from "./qq/auth";
36
- import { QQApi } from "./qq/api";
37
- import { createGatewayUrlFetcher, QQGateway, QQMessage } from "./qq/gateway";
38
- import { Approver } from "./approver";
39
- import { parsePickIndex, pickerExpired, routeSlash, runBridge, type PickerState } from "./slash";
40
- import { agoLabel, MessageQueue, previewText, type QueuedJob } from "./queue";
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";
50
- import { redactSecrets } from "./redact";
51
- import { ReplyBuffer } from "./stream-buffer";
52
- import { splitText } from "./util/chunk";
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
-
59
- interface State {
60
- cfg: QQBotConfig | null;
61
- /** 本进程当前服务的 QQ bot(自动抢占的结果);未接入则为 null */
62
- account: { name: string; appId: string } | null;
63
- /** 本进程的候选 bot(按配置顺序),待命时用它重扫 */
64
- candidates: { name: string; appId: string }[];
65
- /** 首条回执前缀(接入成功 / 换进程接管时告知用户当前 bot 与工作目录) */
66
- claimNote: string | null;
67
- api: QQApi | null;
68
- approver: Approver;
69
- gateway: QQGateway | null;
70
- currentOpenid: string | null;
71
- replyBuffer: ReplyBuffer;
72
- releaseLock: (() => Promise<void>) | null;
73
- stopRetry: (() => void) | null;
74
- passiveRefs: Map<string, { msgId: string; receivedAt: number }>;
75
- busy: boolean;
76
- /** 当前 run 的上下文(用来 abort);run 结束或取消后清空 */
77
- ctx: ExtensionContext | null;
78
- /** 最近一条 QQ 消息的发送者:桥接命令用它回执 */
79
- lastOpenid: string | null;
80
- /** 待选择列表(/tree、/fork、/resume 发出后的编号等待状态) */
81
- picker: PickerState | null;
82
- /** 发出列表的 QQ 用户:只有他能回编号 */
83
- pickerOwner: string | null;
84
- /** /qq 桥接命令的回执通道(每次收到 QQ 消息时刷新) */
85
- bridgeReply: ((text: string) => void) | null;
86
- /** 本轮已被用户取消:settle 时丢弃半截回复 */
87
- cancelled: boolean;
88
- /** 忙碌时收到的任务:当前任务 settle 后逐条执行 */
89
- queue: MessageQueue;
90
- /** 正在出队派发(防 settled / 重试竞态下重复派发) */
91
- dispatching: boolean;
92
- /** 当前派发的任务已经真的启动了 run(agent_start 过):此时报错不重试,避免重复执行 */
93
- runStarted: boolean;
94
- /** /cancel 之后等 agent_settled 的兜底定时器 */
95
- cancelTimer: ReturnType<typeof setTimeout> | null;
96
- /** 任务进度摘要 */
97
- progress: ProgressTracker;
98
- /** 自动推送进度的定时器 */
99
- progressTimer: ReturnType<typeof setInterval> | null;
100
- /** 推送失败后暂停自动推送,直到用户主动询问 */
101
- progressPaused: boolean;
102
- /** 用户手动暂停自动推送(本会话有效:跨 run 保留,/progress on 恢复) */
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;
112
- /** 最终答案发送失败时缓存,下一条消息时补发 */
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;
120
- }
121
-
122
- /** 扩展运行所需的状态(导出只为测试用) */
123
- export function createInitialState(): State {
124
- return {
125
- cfg: null,
126
- api: null,
127
- approver: new Approver(APPROVAL_TIMEOUT_MS),
128
- gateway: null,
129
- currentOpenid: null,
130
- replyBuffer: new ReplyBuffer(),
131
- releaseLock: null,
132
- stopRetry: null,
133
- passiveRefs: new Map(),
134
- busy: false,
135
- ctx: null,
136
- lastOpenid: null,
137
- picker: null,
138
- pickerOwner: null,
139
- bridgeReply: null,
140
- cancelled: false,
141
- queue: new MessageQueue(),
142
- dispatching: false,
143
- runStarted: false,
144
- cancelTimer: null,
145
- progress: new ProgressTracker(),
146
- progressTimer: null,
147
- progressPaused: false,
148
- progressMuted: false,
149
- progressHeldForApproval: false,
150
- remoteNotified: false,
151
- lastRunOrigin: null,
152
- localHandoffPending: false,
153
- undelivered: null,
154
- getToken: null,
155
- attachmentFetch: null,
156
- restBase: null,
157
- account: null,
158
- candidates: [],
159
- claimNote: null,
160
- };
161
- }
162
-
163
- export default function (
164
- pi: ExtensionAPI,
165
- /** 仅测试用:注入状态,让事件处理器与 handleMessage 共享同一份状态 */
166
- options?: {
167
- state?: State;
168
- /** 仅测试用:不真连 QQ 网关(避免单测打网络),其余流程(抢锁/身份/提示)照常 */
169
- disableGateway?: boolean;
170
- /** 仅测试用:替换「抢到锁后验凭据」的实现(默认真的去换 access_token) */
171
- verifyCredentials?: (account: QQBotAccount) => Promise<AuthManager>;
172
- },
173
- ) {
174
- const state: State = options?.state ?? createInitialState();
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
-
229
- // ---- 事件:run 开始,记住 ctx(取消指令要调用 ctx.abort())----
230
- pi.on("agent_start", (_event, ctx) => {
231
- state.ctx = ctx;
232
- state.cancelled = false;
233
- state.runStarted = true;
234
- clearCancelTimer(state);
235
- state.progress.start();
236
- state.progressPaused = false;
237
- state.progressHeldForApproval = false;
238
- startProgressTimer(pi, state);
239
- });
240
-
241
- // ---- 事件:累积 assistant 文本 ----
242
- pi.on("message_update", (event) => {
243
- const ev = event.assistantMessageEvent as any;
244
- if (ev?.type === "text_delta") {
245
- const delta = ev.delta ?? ev.text ?? "";
246
- state.replyBuffer.append(delta);
247
- state.progress.appendText(delta);
248
- }
249
- });
250
-
251
- // ---- 事件:进度统计(工具/轮次/用量)----
252
- pi.on("tool_execution_start", (event) => {
253
- const ev = event as any;
254
- state.progress.toolStart(ev.toolCallId, ev.toolName, ev.args);
255
- });
256
-
257
- pi.on("tool_execution_end", (event) => {
258
- const ev = event as any;
259
- state.progress.toolEnd(ev.toolCallId, ev.toolName, ev.isError === true, ev.durationMs);
260
- });
261
-
262
- pi.on("turn_end", () => {
263
- state.progress.turn();
264
- });
265
-
266
- pi.on("message_end", (event) => {
267
- const message = (event as any).message;
268
- if (message?.role === "assistant") state.progress.addUsage(message.usage);
269
- });
270
-
271
- // ---- 事件:run 结束,发回复,然后接着跑队列里的下一条 ----
272
- pi.on("agent_settled", async () => {
273
- clearCancelTimer(state);
274
- stopProgressTimer(state);
275
- state.progress.stop();
276
- const cancelled = state.cancelled;
277
- const text = state.replyBuffer.take();
278
- const openid = state.currentOpenid;
279
- state.currentOpenid = null;
280
- state.busy = false;
281
- state.ctx = null;
282
- state.cancelled = false;
283
- state.runStarted = false;
284
- if (cancelled) {
285
- log("[reply] 本轮已被取消,丢弃半截回复(长度", text.length, ")");
286
- } else if (text && openid && state.api) {
287
- log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
288
- let delivered = true;
289
- for (const chunk of splitText(text)) {
290
- await state.api.sendC2C(openid, chunk).catch((e) => {
291
- delivered = false;
292
- log("[reply] 发送失败:", String(e));
293
- });
294
- }
295
- if (!delivered) {
296
- // 配额耗尽等情况:缓存下来,用户下次发消息时补发
297
- state.undelivered = { openid, text };
298
- log("[reply] 最终答案发送失败,已缓存待补发(openid=", openid, "len=", text.length, ")");
299
- }
300
- }
301
- // 本轮结束:取队列里的下一条继续执行(不 await,避免卡住 settled 的派发阶段)
302
- drainQueue(pi, state);
303
- });
304
-
305
- // ---- 事件:tool_call 审批 ----
306
- pi.on("tool_call", async (event) => {
307
- const openid = state.currentOpenid;
308
- if (!openid || !state.api || !state.cfg) return; // 无 QQ 上下文,放行
309
- const ev = event as any;
310
- const toolName: string = ev.toolName ?? "tool";
311
- const cfg = state.cfg;
312
-
313
- // 判断是否需要审批:配置的 tools 列表 + destructiveHint 兑底 + 强制审批工具(外发文件)
314
- const allTools = (pi.getAllTools() ?? []) as any[];
315
- const toolMeta = allTools.find((t) => t.name === toolName);
316
- const destructive = toolMeta?.annotations?.destructiveHint === true;
317
- const forced = FORCE_APPROVAL_TOOLS.has(toolName);
318
- const needsApproval = forced || cfg.approve.tools.includes(toolName) || destructive;
319
- if (!needsApproval) return; // 放行
320
-
321
- // 强制审批的工具(发送文件)每次都问:不吃「以后都同意」,也不吃会话内记住的决策
322
- if (!forced) {
323
- // 会话级自动同意(用户回复过「以后都同意」):本会话内直接放行,不落盘、不跨会话
324
- if (state.approver.autoAllowed) {
325
- log("[approve] 自动同意放行:", toolName);
326
- return;
327
- }
328
-
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
- }
335
-
336
- const summary = summarizeTool(ev);
337
- log("[approve] 工具需要审批:", summary);
338
- const { seq, done } = state.approver.register(summary, () => {
339
- // 超时通知
340
- state.api?.sendC2C(openid, `审批 #${seq} 已超时,已自动拒绝。`).catch(() => {});
341
- });
342
- await state.api
343
- .sendC2C(openid, state.approver.render(seq, { strict: forced }))
344
- .catch(() => {});
345
- const decision = await done;
346
-
347
- if (decision === "allow") {
348
- if (!forced && cfg.approve.remember) state.approver.remember(toolName, "allow");
349
- log("[approve] 用户批准 #", seq);
350
- return;
351
- }
352
- if (decision === "terminate") {
353
- log("[approve] 用户停止");
354
- return { block: true, terminate: true, reason: "QQ 用户停止" };
355
- }
356
- if (decision === "timeout") {
357
- // 超时不写 remember:一次没回复不该永久封死该工具
358
- log("[approve] 审批超时 #", seq);
359
- return { block: true, reason: `QQ 审批超时(#${seq}):${summary}` };
360
- }
361
- // block(用户显式拒绝)
362
- if (!forced && cfg.approve.remember) state.approver.remember(toolName, "block");
363
- log("[approve] 用户拒绝 #", seq);
364
- return { block: true, reason: `QQ 用户拒绝了 ${summary}` };
365
- });
366
-
367
- // ---- 事件:session_start 选/抢 qqbot + 启动网关 ----
368
- pi.on("session_start", async (event) => {
369
- const { accounts, errors } = loadAccounts();
370
- for (const message of errors) log("[setup] 配置问题:", message);
371
- if (accounts.length === 0) {
372
- log("[setup] 未配置 QQ bot(或配置不可用),插件未启用");
373
- return;
374
- }
375
-
376
- // 会话重建(/reload、/new…)时优先沿用自己的 bot:上一轮释放前写了 owner 记录
377
- const pin = pinnedAccountName();
378
- const pool: QQBotAccount[] = pin
379
- ? accounts.filter((a) => a.name === pin || a.appId === pin)
380
- : accounts;
381
- if (pool.length === 0) {
382
- log("[setup] PI_QQBOT_ACCOUNT=", pin, "不在配置里(可选:", accounts.map((a) => a.name).join(", "), "),插件未启用");
383
- return;
384
- }
385
- state.candidates = pool.map((a) => ({ name: a.name, appId: a.appId }));
386
-
387
- const ownPid = process.pid;
388
- const preferredAppId = state.candidates
389
- .map((c) => readOwner(c.appId))
390
- .find((owner) => owner?.pid === ownPid)?.appId;
391
-
392
- // 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
393
- state.approver.reset();
394
- // 远程上下文提醒同样只在本会话提醒一次
395
- state.remoteNotified = false;
396
- // 「QQ ↔ 本机」的来源切换状态也随会话重置
397
- state.lastRunOrigin = null;
398
- state.localHandoffPending = false;
399
- // 手动暂停的自动汇报同样只在本会话有效
400
- state.progressMuted = false;
401
- state.progressPaused = false;
402
- state.queue.clear();
403
-
404
- // 凭据不可用的 bot 在本次会话内不再尝试(配置在 session_start 读取,改完 /reload 才生效)
405
- const unusable = new Set<string>();
406
- const verifyAccount =
407
- options?.verifyCredentials ??
408
- (async (account: QQBotAccount) => {
409
- const auth = new AuthManager(account.appId, account.config.appSecret);
410
- await auth.getToken(); // 抢到锁之后先验凭据,失败就不占着这个槽位
411
- return auth;
412
- });
413
-
414
- const applyClaim = (claim: Claim, account: QQBotAccount, auth: AuthManager) => {
415
- const cfg = account.config;
416
- state.cfg = cfg;
417
- state.account = { name: account.name, appId: account.appId };
418
- state.releaseLock = claim.release;
419
- // 只有一个 bot(含 v1 旧配置)→ 继续写旧文件名,升级后日志不断线
420
- configureLog(accounts.length === 1 ? null : account.name);
421
- state.queue.setLimit(cfg.queue.max);
422
- log(
423
- "[setup] 配置已加载 bot=",
424
- `"${account.name}"`,
425
- "appId=",
426
- account.appId,
427
- "sandbox=",
428
- cfg.sandbox,
429
- "queue.max=",
430
- cfg.queue.max,
431
- "reason=",
432
- (event as any)?.reason ?? "startup",
433
- );
434
-
435
- // 提示:接入成功 / 换进程接管(后者一定要带工作目录)
436
- const cwd = process.cwd();
437
- if (claim.takeover && claim.previous) {
438
- log(
439
- "[lock] qqbot",
440
- `"${account.name}"`,
441
- "换进程接管: pid",
442
- claim.previous.pid,
443
- "→",
444
- process.pid,
445
- "· cwd",
446
- claim.previous.cwd || "?",
447
- "→",
448
- cwd,
449
- );
450
- state.claimNote = `(qqbot「${account.name}」已由新实例接管 · 当前工作目录 ${cwd})`;
451
- } else if (claim.reclaimed) {
452
- log("[lock] 重新接入自己的 qqbot(同进程会话重建)");
453
- } else {
454
- log("[lock] 接入 qqbot", `"${account.name}"`, `(${account.appId})`, "· cwd=", cwd, "· pid=", process.pid);
455
- state.claimNote = `(已接入 qqbot「${account.name}」)`;
456
- }
457
-
458
- const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
459
- const api = new QQApi({ restBase, getToken: () => auth.getToken() });
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
- }
477
-
478
- if (options?.disableGateway) {
479
- log("[setup] 测试模式:跳过 QQ 网关");
480
- return;
481
- }
482
- const gateway = new QQGateway({
483
- getGatewayUrl: createGatewayUrlFetcher(restBase, () => auth.getToken()),
484
- getToken: () => auth.getToken(),
485
- intents: INTENT_GROUP_AND_C2C,
486
- connected: () => {},
487
- disconnected: () => {},
488
- message: (msg) => handleMessage(pi, state, api, state.cfg ?? cfg, msg),
489
- });
490
- state.gateway = gateway;
491
- log("[lock] 启动 QQ 网关");
492
- gateway.start();
493
- };
494
-
495
- // 抢一个、验一下凭据、验不过就放锁换下一个
496
- const tryClaimOnce = async (): Promise<boolean> => {
497
- const remaining = state.candidates.filter((c) => !unusable.has(c.appId));
498
- if (remaining.length === 0) return false;
499
- const result = await claimAny(remaining, { preferredAppId });
500
- if (!result.claim) {
501
- const detail = result.skipped
502
- .map(
503
- (s) =>
504
- `${s.name}:${s.reason === "online" ? "在线" : s.reason === "reserved" ? `被 pid ${s.owner?.pid} 重载中` : "刚被抢走"}`,
505
- )
506
- .join(", ");
507
- log("[lock] 本轮无可接入的 qqbot(", detail, ")");
508
- return false;
509
- }
510
- const claim = result.claim;
511
- const account = pool.find((a) => a.appId === claim.appId);
512
- if (!account) {
513
- await claim.release().catch(() => {});
514
- unusable.add(claim.appId);
515
- return tryClaimOnce();
516
- }
517
- try {
518
- const auth = await verifyAccount(account);
519
- applyClaim(claim, account, auth);
520
- return true;
521
- } catch (e) {
522
- await claim.release().catch(() => {});
523
- unusable.add(account.appId);
524
- log(
525
- "[lock] qqbot",
526
- `"${account.name}"`,
527
- "凭据不可用:",
528
- String(e).slice(0, 160),
529
- "→ 已放锁并跳过该 bot(改好配置后 /reload 再试)",
530
- );
531
- return tryClaimOnce();
532
- }
533
- };
534
-
535
- // 待命:还有「可能变可用」的候选就 5s 重扫——否则别人退出后没人接管
536
- const enterStandby = () => {
537
- if (!state.candidates.some((c) => !unusable.has(c.appId))) {
538
- log("[lock] 没有可接入的 qqbot(其余在线或凭据不可用),本进程不接入 QQ");
539
- return;
540
- }
541
- log("[lock] 本进程待命,5s 后重扫");
542
- state.stopRetry = startClaimRetry(
543
- () => state.candidates.filter((c) => !unusable.has(c.appId)),
544
- (claim) => {
545
- state.stopRetry = null;
546
- void (async () => {
547
- const account = pool.find((a) => a.appId === claim.appId);
548
- if (!account || state.gateway) {
549
- await claim.release().catch(() => {});
550
- enterStandby();
551
- return;
552
- }
553
- try {
554
- const auth = await verifyAccount(account);
555
- applyClaim(claim, account, auth);
556
- } catch (e) {
557
- await claim.release().catch(() => {});
558
- unusable.add(account.appId);
559
- log("[lock] qqbot", `"${account.name}"`, "凭据不可用:", String(e).slice(0, 160), "→ 放锁继续待命");
560
- enterStandby();
561
- }
562
- })();
563
- },
564
- { preferredAppId },
565
- );
566
- };
567
-
568
- if (await tryClaimOnce()) return;
569
- enterStandby();
570
- });
571
-
572
- // ---- 事件:上下文压缩可能把「远程用户」提醒压掉:下一条消息重新注入 ----
573
- pi.on("session_compact", (event) => {
574
- state.remoteNotified = false;
575
- log("[remote] 上下文已压缩(reason=", String((event as any)?.reason ?? "?"), "),下一条消息重新注入提醒");
576
- });
577
-
578
- // ---- 事件:session_shutdown 清理 ----
579
- pi.on("session_shutdown", async (event) => {
580
- const reason = String((event as any)?.reason ?? "quit");
581
- log("[shutdown] 清理资源 reason=", reason);
582
- clearCancelTimer(state);
583
- stopProgressTimer(state);
584
- const dropped = state.queue.clear();
585
- if (dropped.length > 0) log("[shutdown] 丢弃队列任务", dropped.length, "条");
586
- if (state.stopRetry) state.stopRetry();
587
- state.gateway?.stop();
588
- // reload/new/fork/resume 只是「本进程换会话」,马上会重新接入:先贴预约,别让别人抢走
589
- if (state.account && state.releaseLock && reason !== "quit") {
590
- reserveBeforeRelease(state.account, reason);
591
- log("[lock] qqbot", `"${state.account.name}"`, "临时释放(reason=", reason, "),已留预约窗口");
592
- }
593
- if (state.releaseLock) {
594
- try {
595
- await state.releaseLock();
596
- } catch {
597
- /* ignore */
598
- }
599
- }
600
- state.releaseLock = null;
601
- state.gateway = null;
602
- state.api = null;
603
- state.getToken = null;
604
- state.restBase = null;
605
- state.account = null;
606
- });
607
-
608
- // ---- /qq:QQ 侧命令的桥接入口 ----
609
- // pi 只对「扩展命令」执行派发,内置命令(/reload、/new…)只能在 TUI/RPC 层跑;
610
- // 而 reload()/newSession() 又必须拿到 ExtensionCommandContext。于是注册一个扩展
611
- // 命令,QQ 侧收到 /reload 时改派 /qq reload,就能在合法的命令上下文里执行。
612
- pi.registerCommand("qq", {
613
- description:
614
- "pi-qqbot 内部桥接:/qq <reload|new|compact|quit|session|name|model|thinking|sendfile> [args]",
615
- handler: async (args, ctx) => {
616
- const trimmed = args.trim();
617
- const space = trimmed.indexOf(" ");
618
- const sub = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
619
- const rest = (space === -1 ? "" : trimmed.slice(space + 1)).trim();
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
- }
631
- try {
632
- await runBridge(sub, rest, ctx, pi, reply, state);
633
- } catch (e) {
634
- log("[/qq] 桥接失败:", String(e));
635
- reply(`/${sub} 执行失败:${String(e).slice(0, 200)}`);
636
- }
637
- },
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
- );
662
- }
663
-
664
- /** 取消/停止这类「打断当前任务」的表达 */
665
- /**
666
- * 取消/停止这类「打断当前任务」的表达。
667
- *
668
- * 注意分工:「停」「停下」「停一下」属于**中断任务**(用户定的:停 = 停止干活);
669
- * 想只关掉进度推送请用「别汇报了」「闭嘴」「/progress off」;
670
- * 「停止汇报」因为带了名词,不算取消,走静音。
671
- */
672
- const RE_CANCEL =
673
- /^(\/cancel|\/stop|\/abort|取消|停止|中止|终止|算了|停|停下|停一下|停停|停掉|别跑了|cancel|stop|abort)$/i;
674
-
675
- function isCancelIntent(text: string): boolean {
676
- return RE_CANCEL.test(text.trim());
677
- }
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
-
743
- /** 派发失败时最多重试几次(通常是 pi 上一轮还没收尾) */
744
- const MAX_DISPATCH_ATTEMPTS = 3;
745
-
746
- function clearCancelTimer(state: State) {
747
- if (state.cancelTimer) {
748
- clearTimeout(state.cancelTimer);
749
- state.cancelTimer = null;
750
- }
751
- }
752
-
753
- /**
754
- * 对外看是“忙”:有任务在跑、正在派发,或者队列里还有任务等着(比如派发失败后的重试窗口)。
755
- * 这三种情况新消息都应该进队列,而不是插队直接开新一轮。
756
- */
757
- function isBusy(state: State): boolean {
758
- return state.busy || state.dispatching || state.queue.size > 0;
759
- }
760
-
761
- /**
762
- * /cancel 之后 abort() 是异步的:正常路径靠 agent_settled 释放 busy 并继续队列;
763
- * 万一 abort 没能触发 settled(比如那一轮正好已经结束),宽限期后强制解锁。
764
- */
765
- function armCancelWatchdog(pi: ExtensionAPI, state: State) {
766
- clearCancelTimer(state);
767
- const timer = setTimeout(() => {
768
- state.cancelTimer = null;
769
- if (!state.cancelled) return;
770
- log("[cancel] abort 后", CANCEL_SETTLE_GRACE_MS, "ms 内没有 agent_settled,强制释放 busy");
771
- state.cancelled = false;
772
- state.busy = false;
773
- state.ctx = null;
774
- state.currentOpenid = null;
775
- state.replyBuffer.reset();
776
- drainQueue(pi, state);
777
- }, CANCEL_SETTLE_GRACE_MS);
778
- (timer as any).unref?.();
779
- state.cancelTimer = timer;
780
- }
781
-
782
- /**
783
- * 把一条任务交给 pi,并把它标记为「当前任务」。
784
- * 不 await sendUserMessage:它返回的 promise 要等整轮跑完,await 会卡住调用方
785
- * (agent_settled 里派发时更是不允许)。错误交给 onDispatchError 重试/回执。
786
- */
787
- function startJob(pi: ExtensionAPI, state: State, job: QueuedJob) {
788
- state.currentOpenid = job.openid;
789
- state.lastOpenid = job.openid;
790
- state.pickerOwner = job.openid;
791
- state.bridgeReply = (text: string) => {
792
- void replyTo(state.api!, state, job.openid, text);
793
- };
794
- state.busy = true;
795
- state.cancelled = false;
796
- state.runStarted = false;
797
- state.replyBuffer.reset();
798
- clearCancelTimer(state);
799
- log(
800
- "[msg] 送入 pi:",
801
- job.id > 0 ? `队列 #${job.id}` : "直接",
802
- "openid=",
803
- job.openid,
804
- "text=",
805
- job.text.slice(0, 60),
806
- );
807
- // 每会话第一条真正跑起来的 QQ 消息,由 before_agent_start 注入远程上下文说明。
808
- try {
809
- void Promise.resolve(
810
- pi.sendUserMessage(job.text, { expandPromptTemplates: true, deliverAs: "followUp" }),
811
- ).catch((error) => onDispatchError(pi, state, job, error));
812
- } catch (error) {
813
- onDispatchError(pi, state, job, error);
814
- }
815
- }
816
-
817
- /** 派发失败:先重试(放回队首),多次失败就把错误回给发送者并继续队列 */
818
- function onDispatchError(pi: ExtensionAPI, state: State, job: QueuedJob, error: unknown) {
819
- const message = String((error as any)?.message ?? error).slice(0, 200);
820
- log("[msg] 派发失败:", message);
821
- if (state.runStarted) {
822
- // run 已经启动了:这次报错属于这一轮自己的问题,收尾交给 agent_settled,不能重试
823
- log("[msg] 本轮已启动过 agent,不重试,等 agent_settled 收尾");
824
- return;
825
- }
826
- state.busy = false;
827
- state.currentOpenid = null;
828
- job.attempts += 1;
829
- if (job.attempts < MAX_DISPATCH_ATTEMPTS) {
830
- state.queue.unshift(job);
831
- log("[msg]", DISPATCH_RETRY_MS, "ms 后重试(第", job.attempts, "次)");
832
- const timer = setTimeout(() => drainQueue(pi, state), DISPATCH_RETRY_MS);
833
- (timer as any).unref?.();
834
- return;
835
- }
836
- const api = state.api;
837
- if (api) void replyTo(api, state, job.openid, `这条消息执行失败:${message}`);
838
- drainQueue(pi, state);
839
- }
840
-
841
- /** 当前任务结束后,取队列里的下一条开始执行(空闲时才会真的派发) */
842
- function drainQueue(pi: ExtensionAPI, state: State) {
843
- if (state.busy || state.dispatching) return;
844
- const job = state.queue.shift();
845
- if (!job) return;
846
- state.dispatching = true;
847
- try {
848
- log("[queue] 出队 #", job.id, "剩余", state.queue.size, "条", "text=", job.text.slice(0, 60));
849
- const api = state.api;
850
- if (api && state.cfg?.queue.ack !== false) {
851
- const rest = state.queue.size > 0 ? `(后面还有 ${state.queue.size} 条)` : "";
852
- void replyTo(api, state, job.openid, `开始执行队列 #${job.id}${rest}`);
853
- }
854
- startJob(pi, state, job);
855
- } finally {
856
- state.dispatching = false;
857
- }
858
- }
859
-
860
- /** 入队(忙碌时的统一入口):回执排队位置 */
861
- async function enqueueJob(
862
- api: QQApi,
863
- state: State,
864
- openid: string,
865
- text: string,
866
- ): Promise<boolean> {
867
- const result = state.queue.push(openid, text);
868
- if (!result.ok) {
869
- log("[queue] 队列已满(上限", state.queue.max, "),拒绝入队");
870
- if (state.queue.max === 0) {
871
- await replyTo(api, state, openid, "上一条消息还在处理中,请稍候…(队列已关闭:queue.max=0)");
872
- return false;
873
- }
874
- await replyTo(
875
- api,
876
- state,
877
- openid,
878
- `队列已满(上限 ${state.queue.max} 条),这条没排上。发 /queue 查看队列,或 /cancel 清空后重发。`,
879
- );
880
- return false;
881
- }
882
- log("[queue] 入队 #", result.job.id, "size=", state.queue.size, "ahead=", result.ahead);
883
- await replyTo(
884
- api,
885
- state,
886
- openid,
887
- `已排队 #${result.job.id}${result.ahead > 0 ? `(前面还有 ${result.ahead} 条)` : ""},当前任务结束后自动执行。发 /queue 查看队列,/cancel 可中止当前任务并清空队列。`,
888
- );
889
- return true;
890
- }
891
-
892
- function formatQueue(state: State): string {
893
- const lines = [`当前任务:${state.busy ? "处理中(发 /cancel 可中断)" : "空闲"}`];
894
- const jobs = state.queue.list();
895
- if (jobs.length === 0) {
896
- lines.push("队列:空");
897
- return lines.join("\n");
898
- }
899
- lines.push(`队列:${jobs.length} 条(上限 ${state.queue.max})`);
900
- jobs.slice(0, 10).forEach((job, index) => {
901
- lines.push(` ${index + 1}. #${job.id} · ${agoLabel(job.enqueuedAt)} · ${previewText(job.text)}`);
902
- });
903
- if (jobs.length > 10) lines.push(` … 还有 ${jobs.length - 10} 条`);
904
- lines.push("发 /queue clear 清空,/queue drop <编号> 删除一条。");
905
- return lines.join("\n");
906
- }
907
-
908
- /**
909
- * 用户发来取消指令:终止待审批、清空队列、abort 当前 run、丢弃半截回复,然后回执。
910
- * 取消本身永不入队(“取消除外”),立即生效。
911
- * (「取消自动同意」/「关闭自动同意」不属于取消指令,仍走审批分支。)
912
- */
913
- async function handleCancel(
914
- pi: ExtensionAPI,
915
- state: State,
916
- api: QQApi,
917
- openid: string,
918
- ): Promise<void> {
919
- const terminated = state.approver.terminateAll();
920
- const dropped = state.queue.clear();
921
- const running = state.busy || Boolean(state.ctx?.signal);
922
- if (!running) {
923
- const parts: string[] = [];
924
- if (terminated > 0) parts.push(`已终止 ${terminated} 个待审批请求`);
925
- if (dropped.length > 0) parts.push(`已清空队列(${dropped.length} 条)`);
926
- await replyTo(
927
- api,
928
- state,
929
- openid,
930
- parts.length > 0
931
- ? `${parts.join(",")},当前没有正在运行的任务。`
932
- : "当前没有正在运行的任务。",
933
- );
934
- return;
935
- }
936
-
937
- stopProgressTimer(state);
938
- state.progress.stop();
939
- state.cancelled = true;
940
- state.replyBuffer.reset();
941
- state.currentOpenid = null; // 被中止的 run 不再往 QQ 发半截回复
942
- const ctx = state.ctx;
943
- state.ctx = null;
944
- try {
945
- ctx?.abort();
946
- log("[cancel] 已请求中止当前 run,终止待审批", terminated, "个,清空队列", dropped.length, "条");
947
- } catch (e) {
948
- log("[cancel] abort 失败:", String(e));
949
- }
950
- // busy 保持 true 直到 agent_settled:期间新消息会排队,不会和正在收尾的 run 撞车
951
- armCancelWatchdog(pi, state);
952
- const notice: string[] = [];
953
- if (terminated > 0) notice.push(`并终止了 ${terminated} 个待审批请求`);
954
- if (dropped.length > 0) notice.push(`队列中的 ${dropped.length} 条已清空`);
955
- await replyTo(
956
- api,
957
- state,
958
- openid,
959
- `已取消当前任务${notice.length > 0 ? `,${notice.join(",")}` : ""}。可以发送新消息继续。`,
960
- );
961
- }
962
-
963
- // ---- QQ 消息处理 ----
964
- export async function handleMessage(
965
- pi: ExtensionAPI,
966
- state: State,
967
- api: QQApi,
968
- cfg: QQBotConfig,
969
- msg: QQMessage,
970
- ) {
971
- try {
972
- log("[msg] 收到消息 openid=", msg.openid, "content=", msg.content.slice(0, 100));
973
- if (cfg.allowlist.length > 0 && !cfg.allowlist.includes(msg.openid)) {
974
- log("[msg] 被 allowlist 拦截");
975
- return;
976
- }
977
- state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
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
-
1023
- // 上一条最终答案没发出去(配额耗尽):先补发
1024
- if (state.undelivered && state.undelivered.openid === msg.openid) {
1025
- const pending = state.undelivered;
1026
- state.undelivered = null;
1027
- log("[reply] 补发上一条未送达的回复,长度", pending.text.length);
1028
- const ok = await replyTo(api, state, msg.openid, `(上一条回复发送失败,补发如下)\n\n${pending.text}`);
1029
- if (!ok) {
1030
- state.undelivered = pending;
1031
- log("[reply] 补发仍然失败,继续保留");
1032
- }
1033
- }
1034
-
1035
- // 待选择的编号回复(/tree、/fork、/resume 之后);带附件时不当作编号选择
1036
- if (!hasAttachment && state.picker) {
1037
- if (pickerExpired(state.picker)) {
1038
- state.picker = null;
1039
- } else if (state.picker.openid === msg.openid) {
1040
- const index = parsePickIndex(msg.content);
1041
- if (index !== null) {
1042
- if (state.busy) {
1043
- await replyTo(api, state, msg.openid, "当前有任务在跑,先发 /cancel 取消,再回编号。");
1044
- return;
1045
- }
1046
- if (index > state.picker.items.length) {
1047
- await replyTo(api, state, msg.openid, `编号 ${index} 超出范围(1-${state.picker.items.length})。`);
1048
- return;
1049
- }
1050
- state.pickerOwner = msg.openid;
1051
- state.bridgeReply = (text: string) => {
1052
- void replyTo(api, state, msg.openid, text);
1053
- };
1054
- log("[cmd] 选择编号", index, "kind=", state.picker.kind);
1055
- pi.sendUserMessage(`/qq pick ${index}`, { expandPromptTemplates: true });
1056
- return;
1057
- }
1058
- if (/^(取消|算了|cancel)$/i.test(msg.content.trim())) {
1059
- state.picker = null;
1060
- await replyTo(api, state, msg.openid, "已放弃选择。");
1061
- return;
1062
- }
1063
- }
1064
- }
1065
-
1066
- // 取消指令优先于审批解析:「取消」不该被当成审批动作;取消也永不入队
1067
- if (isCancelIntent(msg.content)) {
1068
- await handleCancel(pi, state, api, msg.openid);
1069
- return;
1070
- }
1071
-
1072
- // 斜杠命令路由:pi 扩展命令 → 可接管的内置命令 → 仅主机可用的内置命令 → 普通文字
1073
- state.lastOpenid = msg.openid;
1074
- state.pickerOwner = msg.openid;
1075
- state.bridgeReply = (text: string) => {
1076
- void replyTo(api, state, msg.openid, text);
1077
- };
1078
- const routed = hasAttachment
1079
- ? ("text" as const)
1080
- : await routeSlash(
1081
- {
1082
- listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string; source?: string }[],
1083
- dispatch: (text, options) => {
1084
- // 提示模板 / /skill: 会开启一轮 agent run:和普通对话一样串行到队列
1085
- if (options?.runsTurn) {
1086
- if (!isBusy(state)) {
1087
- startJob(pi, state, state.queue.mint(msg.openid, text));
1088
- return "dispatched";
1089
- }
1090
- const queued = state.queue.push(msg.openid, text);
1091
- if (!queued.ok) {
1092
- log("[queue] 队列已满,命令未排队:", text.slice(0, 60));
1093
- return "rejected";
1094
- }
1095
- log("[queue] 命令入队 #", queued.job.id, text.slice(0, 60));
1096
- return "queued";
1097
- }
1098
- // 扩展命令 / 桥接命令(/qq …)不开 run,pi 允许流式期间执行,直接派发
1099
- void Promise.resolve(pi.sendUserMessage(text, { expandPromptTemplates: true })).catch(
1100
- (e) => log("[cmd] 派发失败:", String(e)),
1101
- );
1102
- return "dispatched";
1103
- },
1104
- reply: (text: string) => replyTo(api, state, msg.openid, text),
1105
- busy: isBusy(state),
1106
- cfg: cfg.commands,
1107
- log,
1108
- },
1109
- msg.content,
1110
- );
1111
- if (routed !== "text") return;
1112
-
1113
- // 远程审批回复优先(带附件时不当作审批动作)
1114
- const parsed = hasAttachment ? null : state.approver.parseReply(msg.content.trim());
1115
- if (parsed) {
1116
- if (parsed.decision === "terminate") {
1117
- const n = state.approver.terminateAll();
1118
- state.approver.setAutoAllow(false);
1119
- await replyTo(api, state, msg.openid, `已终止 ${n} 个待审批请求,自动同意已关闭`);
1120
- return;
1121
- }
1122
- if (parsed.decision === "always") {
1123
- // 「以后都同意」= 本会话内全部放行:立刻开启开关并放行当前所有待审批
1124
- state.approver.setAutoAllow(true);
1125
- const n = state.approver.allowAll();
1126
- log("[approve] 开启会话级自动同意,放行", n, "个待审批");
1127
- await replyTo(
1128
- api,
1129
- state,
1130
- msg.openid,
1131
- [
1132
- `已开启自动同意:本会话内需审批的工具全部放行${n > 0 ? `,已放行 ${n} 个待审批请求` : ""}。`,
1133
- "回复「取消自动同意」或 /new 恢复逐条审批。",
1134
- ].join("\n"),
1135
- );
1136
- return;
1137
- }
1138
- if (parsed.decision === "revoke") {
1139
- const was = state.approver.autoAllowed;
1140
- state.approver.setAutoAllow(false);
1141
- log("[approve] 关闭自动同意 was=", was);
1142
- await replyTo(
1143
- api,
1144
- state,
1145
- msg.openid,
1146
- was ? "已关闭自动同意,恢复逐条审批。" : "当前没有开启自动同意,仍需逐条审批。",
1147
- );
1148
- return;
1149
- }
1150
- // allow / block:没写编号时作用于最早的一条待审批
1151
- const seq = parsed.seq ?? state.approver.oldestSeq();
1152
- if (seq !== undefined) {
1153
- const ok = state.approver.confirm(seq, parsed.decision);
1154
- const text = !ok
1155
- ? `#${seq} 不存在或已超时`
1156
- : parsed.decision === "allow"
1157
- ? `已批准 #${seq}`
1158
- : `已拒绝 #${seq}`;
1159
- await replyTo(api, state, msg.openid, text);
1160
- return;
1161
- }
1162
- // 没有待审批:这句「同意/拒绝」当普通消息交给 pi,继续往下走
1163
- log("[msg] 审批类文本但无待审批,按普通消息处理");
1164
- }
1165
-
1166
- // 内置指令(带附件时绕过:附件消息一律当普通内容交给 pi)
1167
- const cmd = hasAttachment ? null : parseCommand(msg.content);
1168
- if (cmd?.name === "help") {
1169
- await replyTo(
1170
- api,
1171
- state,
1172
- msg.openid,
1173
- [
1174
- "pi-qqbot 指令:",
1175
- "/progress — 查看进度;/progress off 暂停自动汇报、/progress on 恢复(本会话有效)",
1176
- "/status — 查看状态(当前 qqbot / 忙闲 / 来源 / 队列 / 自动同意 / 待审批 / 自动汇报)",
1177
- "/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
1178
- "/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
1179
- "/cancel(或「取消」「停止」「停」)— 中断正在运行的任务并清空队列",
1180
- "/sendfile <路径> — 把本机文件发到你的 QQ(png/jpg/mp4/silk 直显,其余为文件卡片;外发需审批)",
1181
- "只关进度推送:/progress off(或「别汇报了」「闭嘴」);「停」是中断任务,不是静音",
1182
- "/help — 帮助",
1183
- "pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
1184
- "忙碌时发来的普通消息(含模板/skill 命令)会排队,当前任务结束后自动执行。",
1185
- "多 bot:配置了多个 qqbot 时,多个 pi 进程会按列表顺序各自接入一个,抢不到的待命重扫。",
1186
- "其余文本交给 pi 当作对话处理。",
1187
- ].join("\n"),
1188
- );
1189
- return;
1190
- }
1191
- if (cmd?.name === "status") {
1192
- const lines = [
1193
- `bot: ${state.account ? `${state.account.name} (…${state.account.appId.slice(-4)})` : "未接入"} · cwd: ${process.cwd()}`,
1194
- `状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
1195
- `来源: ${describeRunOrigin(state)}`,
1196
- `队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
1197
- `自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
1198
- `待审批: ${state.approver.pendingCount}`,
1199
- `自动汇报: ${describeProgressMode(state)}`,
1200
- ];
1201
- if (state.busy) {
1202
- const snapshot = state.progress.summary();
1203
- if (snapshot) lines.push("", snapshot);
1204
- // 用户主动问询:恢复自动推送(手动暂停的不算)
1205
- if (!state.progressMuted) state.progressPaused = false;
1206
- }
1207
- await replyTo(api, state, msg.openid, lines.join("\n"));
1208
- return;
1209
- }
1210
- if (cmd?.name === "progress") {
1211
- const sub = (cmd.args ?? "").trim().toLowerCase();
1212
- if (MUTE_ARGS.has(sub)) {
1213
- const was = state.progressMuted;
1214
- state.progressMuted = true;
1215
- state.progressPaused = false;
1216
- log("[progress] 用户暂停自动进度推送(本会话有效)", was ? "(已经是暂停状态)" : "");
1217
- await replyTo(
1218
- api,
1219
- state,
1220
- msg.openid,
1221
- was
1222
- ? "自动汇报本来就已暂停。发 /progress on(或「恢复汇报」)可以恢复。"
1223
- : "已暂停自动进度汇报(本会话有效,换会话或重启后恢复默认开启)。需要时发 /progress 仍可查看一次,发 /progress on 恢复。",
1224
- );
1225
- return;
1226
- }
1227
- if (UNMUTE_ARGS.has(sub)) {
1228
- const was = state.progressMuted;
1229
- state.progressMuted = false;
1230
- state.progressPaused = false;
1231
- log("[progress] 用户恢复自动进度推送");
1232
- await replyTo(
1233
- api,
1234
- state,
1235
- msg.openid,
1236
- was
1237
- ? `已恢复自动进度汇报${state.cfg?.progress?.enabled === false ? "(但配置 progress.enabled=false,仍不会推送)" : ""}。`
1238
- : "自动汇报本来就是开启的。",
1239
- );
1240
- return;
1241
- }
1242
- if (sub) {
1243
- await replyTo(
1244
- api,
1245
- state,
1246
- msg.openid,
1247
- "用法: /progress(看一次)| /progress off(暂停自动汇报)| /progress on(恢复)。也可以说「暂停汇报」「恢复汇报」。",
1248
- );
1249
- return;
1250
- }
1251
- const snapshot = state.progress.summary();
1252
- if (!state.progressMuted) state.progressPaused = false;
1253
- const body = snapshot || (state.busy ? "当前任务刚启动,还没有可汇报的进度。" : "当前没有正在运行的任务。");
1254
- await replyTo(
1255
- api,
1256
- state,
1257
- msg.openid,
1258
- state.progressMuted ? `${body}\n(自动汇报已暂停,发 /progress on 恢复)` : body,
1259
- );
1260
- return;
1261
- }
1262
- if (cmd?.name === "queue") {
1263
- await handleQueueCommand(api, state, msg.openid, cmd.args);
1264
- return;
1265
- }
1266
- if (cmd?.name === "reset") {
1267
- state.replyBuffer.reset();
1268
- state.approver.reset();
1269
- await replyTo(api, state, msg.openid, "已重置(回复缓冲/审批记忆/自动同意)。要开新会话请发 /new。");
1270
- return;
1271
- }
1272
-
1273
- // 忙检查:单会话串行处理——忙碌时入队,当前任务结束后自动继续
1274
- if (isBusy(state)) {
1275
- // 忙碌时问「进度」不该排队,直接回当前进度并恢复自动推送
1276
- if (!hasAttachment && RE_PROGRESS_QUERY.test(msg.content.trim())) {
1277
- const snapshot = state.progress.summary();
1278
- if (!state.progressMuted) state.progressPaused = false;
1279
- await replyTo(api, state, msg.openid, snapshot || "任务刚启动,还没有可汇报的进度。");
1280
- return;
1281
- }
1282
- await enqueueJob(api, state, msg.openid, incoming);
1283
- return;
1284
- }
1285
-
1286
- // ack
1287
- await replyTo(api, state, msg.openid, "收到,正在处理");
1288
-
1289
- startJob(pi, state, state.queue.mint(msg.openid, incoming));
1290
- } catch (e) {
1291
- log("[msg] 处理失败:", String(e));
1292
- await replyTo(api, state, msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
1293
- }
1294
- }
1295
-
1296
- async function replyTo(
1297
- api: QQApi,
1298
- state: State,
1299
- openid: string,
1300
- text: string,
1301
- format: "text" | "markdown" = "text",
1302
- ): Promise<boolean> {
1303
- // 本进程刚接入 / 刚接管这个 qqbot:第一条回执带上身份与工作目录
1304
- if (state.claimNote) {
1305
- const note = state.claimNote;
1306
- state.claimNote = null;
1307
- text = `${note}\n${text}`;
1308
- }
1309
- const ref = state.passiveRefs.get(openid);
1310
- let delivered = true;
1311
- for (const chunk of splitText(text)) {
1312
- const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
1313
- try {
1314
- await api.sendC2C(openid, chunk, usePassive ? { msgId: ref.msgId, format } : { format });
1315
- log("[reply] 已发送 (passive=", usePassive, "):", chunk.slice(0, 50));
1316
- } catch (e) {
1317
- delivered = false;
1318
- log("[reply] 发送失败:", String(e));
1319
- }
1320
- }
1321
- return delivered;
1322
- }
1323
-
1324
- /** 用户问进度的表达(忙碌时用来区分「排队」和「问进度」) */
1325
- // 忙碌时判断「这是在问进度」:只认句首的明确表达,避免把「帮我加个进度条」这类任务误当问询
1326
- const RE_PROGRESS_QUERY = new RegExp(
1327
- "^(?:" +
1328
- "\\/(?:progress|p|status)" +
1329
- "|(?:现在|当前|目前)?\\s*(?:的)?\\s*(?:progress|进度|进展)" +
1330
- "|跑到哪|怎么样了|到哪了|还在跑吗|好慢" +
1331
- ")",
1332
- "i",
1333
- );
1334
-
1335
- function startProgressTimer(pi: ExtensionAPI, state: State) {
1336
- stopProgressTimer(state);
1337
- const cfg = state.cfg?.progress;
1338
- if (!cfg?.enabled) return;
1339
- const interval = Number.isFinite(cfg.intervalMs) && cfg.intervalMs >= 50 ? cfg.intervalMs : 15000;
1340
- const timer = setInterval(() => {
1341
- void pushProgress(state, "tick");
1342
- }, interval);
1343
- (timer as any).unref?.();
1344
- state.progressTimer = timer;
1345
- log("[progress] 自动进度推送已启动,间隔", interval, "ms");
1346
- }
1347
-
1348
- function stopProgressTimer(state: State) {
1349
- if (state.progressTimer) {
1350
- clearInterval(state.progressTimer);
1351
- state.progressTimer = null;
1352
- }
1353
- }
1354
-
1355
- /** 推一条进度摘要;失败就暂停自动推送(用户问一次即恢复);手动暂停(/progress off)管到底 */
1356
- async function pushProgress(state: State, reason: "tick" | "ask"): Promise<boolean> {
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
- }
1367
- const api = state.api;
1368
- const openid = state.currentOpenid ?? state.lastOpenid;
1369
- if (!api || !openid || !state.busy || !state.progress.running) return false;
1370
- if (state.progressHeldForApproval) {
1371
- state.progressHeldForApproval = false;
1372
- log("[progress] 审批已处理,恢复自动进度推送");
1373
- }
1374
- const text = state.progress.summary();
1375
- if (!text) return false;
1376
- const ok = await replyTo(api, state, openid, text);
1377
- if (!ok) {
1378
- state.progressPaused = true;
1379
- log("[progress] 推送失败(多半是配额),暂停自动进度推送,直到用户询问");
1380
- }
1381
- return ok;
1382
- }
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
-
1391
- /** 自动汇报当前状态的人话描述(/status 用) */
1392
- function describeProgressMode(state: State): string {
1393
- const cfg = state.cfg?.progress;
1394
- if (state.progressMuted) return "已暂停(本会话,发 /progress on 恢复)";
1395
- if (cfg?.enabled === false) return "未启用(配置 progress.enabled=false)";
1396
- if (state.progressPaused) return "推送失败已静默,发 /progress 恢复";
1397
- const ms = Number.isFinite(cfg?.intervalMs) && (cfg?.intervalMs ?? 0) >= 50 ? cfg!.intervalMs : 15000;
1398
- const base = `开启(每 ${Math.round(ms / 1000)}s)`;
1399
- return state.approver.pendingCount > 0 ? `${base},等审批期间暂缓` : base;
1400
- }
1401
-
1402
- /** /progress 的子命令:暂停 / 恢复自动汇报 */
1403
- const MUTE_ARGS = new Set([
1404
- "off",
1405
- "pause",
1406
- "stop",
1407
- "mute",
1408
- "关",
1409
- "关闭",
1410
- "关掉",
1411
- "停",
1412
- "停止",
1413
- "暂停",
1414
- "暂停汇报",
1415
- "停止汇报",
1416
- ]);
1417
- const UNMUTE_ARGS = new Set([
1418
- "on",
1419
- "resume",
1420
- "start",
1421
- "unmute",
1422
- "开",
1423
- "开启",
1424
- "打开",
1425
- "恢复",
1426
- "继续",
1427
- "恢复汇报",
1428
- "开启汇报",
1429
- "继续汇报",
1430
- ]);
1431
-
1432
- /**
1433
- * 不带斜杠的口令(整句匹配,避免误伤正常聊天)。
1434
- *
1435
- * 三类:
1436
- * 1. 问进度:「汇报一下当前进度」「看一下进度」「进度如何」→ 回一次摘要
1437
- * 2. 静音:「暂停汇报进度」「别汇报了」「别说了」「闭嘴」「停」→ 暂停自动汇报
1438
- * 3. 恢复:「恢复汇报」「接着说」→ 重新打开自动汇报
1439
- *
1440
- * 注意:「停止/取消/中止」是**中断任务**(RE_CANCEL,更早判定),不是静音;
1441
- * 「停止汇报」才走静音。
1442
- */
1443
- const REPORT_LEAD = "(?:请|帮我|给我|麻烦|暂时|先)?\\s*";
1444
- /** 汇报名词的两种语序(进度汇报 / 汇报进度) */
1445
- const REPORT_NOUN =
1446
- "(?:(?:进度|进展)(?:汇报|报告|推送|播报|上报)?|(?:汇报|报告|推送|播报|上报)(?:进度|进展)?)";
1447
- const REPORT_TAIL = "\\s*(?:了|吧|哦|啊|呀|嘛|啦|呗)*";
1448
-
1449
- /** 问进度(含「汇报一下…」这类祈使句) */
1450
- const BARE_PROGRESS_RE = new RegExp(
1451
- "^" +
1452
- REPORT_LEAD +
1453
- "(?:(?:汇报|报告|报|说说|讲讲|看|看看|查|查查|查看|显示|展示)(?:一下|下|个|一眼)?\\s*)?" +
1454
- "(?:当前|现在|目前)?\\s*(?:的)?\\s*(?:progress|进度|进展)" +
1455
- "(?:\\s*(?:如何|怎么样|怎样|呢|啦|了|吗))?" +
1456
- "(?:\\s+([\\s\\S]*))?$",
1457
- "i",
1458
- );
1459
-
1460
- /** 静音:带「汇报」名词的 */
1461
- const RE_REPORTS_OFF = new RegExp(
1462
- "^" +
1463
- REPORT_LEAD +
1464
- "(?:暂停|停止|停下|关闭|关掉|别再|别|不要|不用|甭|闭嘴|安静|打住)\\s*" +
1465
- "(?:自动|定时|定期)?\\s*(?:的)?\\s*" +
1466
- REPORT_NOUN +
1467
- REPORT_TAIL +
1468
- "$",
1469
- );
1470
- /** 静音:不提「汇报」的纯闭嘴类(停/停下 不在此列——它们属于中断任务) */
1471
- const RE_SILENCE = new RegExp(
1472
- "^" +
1473
- REPORT_LEAD +
1474
- "(?:闭嘴|安静|安静点|安静下|别说了|别讲了|别吵了?|别推了|别发了|别报了|打住)" +
1475
- REPORT_TAIL +
1476
- "$",
1477
- );
1478
- /** 恢复:说清楚要恢复汇报,或者「接着说」 */
1479
- const RE_REPORTS_ON = new RegExp(
1480
- "^" +
1481
- REPORT_LEAD +
1482
- "(?:恢复|继续|开启|打开|开始|接着说|接着说吧|说下去|接着报)\\s*" +
1483
- "(?:自动|定时|定期)?\\s*(?:的)?\\s*" +
1484
- REPORT_NOUN +
1485
- REPORT_TAIL +
1486
- "$",
1487
- );
1488
- const RE_RESUME_TALK = new RegExp("^" + REPORT_LEAD + "(?:接着说|说下去|接着报)" + REPORT_TAIL + "$");
1489
-
1490
- /** 不带斜杠的进度问询:「进度」「当前进度」「汇报一下当前进度」(可带子命令) */
1491
-
1492
- function parseCommand(text: string): { name: "reset" | "status" | "help" | "queue" | "progress"; args: string } | null {
1493
- // 注意:/new 现在属于 pi 的内置命令(由 slash 路由接管,真的开新会话),
1494
- // 本地的「清缓冲/清审批记忆」改名为 /reset。
1495
- // 斜杠形式:全部内置指令;不带斜杠时只接受明确表达「进度」的词,避免把聊天里的 "p" / "status" 误判成命令
1496
- const t = text.trim();
1497
- const slash = /^\/(reset|status|help|queue|q|progress|p)(?:\s+([\s\S]*))?$/i.exec(t);
1498
- if (slash) {
1499
- const raw = slash[1].toLowerCase();
1500
- const name = raw === "q" ? "queue" : raw === "p" ? "progress" : raw;
1501
- return {
1502
- name: name as "reset" | "status" | "help" | "queue" | "progress",
1503
- args: (slash[2] ?? "").trim(),
1504
- };
1505
- }
1506
- // 裸文本:先认「问进度」,再认「静音/恢复」
1507
- const bare = BARE_PROGRESS_RE.exec(t);
1508
- if (bare) return { name: "progress", args: (bare[1] ?? "").trim() };
1509
- if (RE_REPORTS_OFF.test(t) || RE_SILENCE.test(t)) return { name: "progress", args: "off" };
1510
- if (RE_REPORTS_ON.test(t) || RE_RESUME_TALK.test(t)) return { name: "progress", args: "on" };
1511
- return null;
1512
- }
1513
-
1514
- /** /queue [clear|drop <编号>] */
1515
- async function handleQueueCommand(api: QQApi, state: State, openid: string, args: string) {
1516
- const parts = args.split(/\s+/).filter(Boolean);
1517
- const sub = parts[0]?.toLowerCase() ?? "";
1518
-
1519
- if (!sub) {
1520
- await replyTo(api, state, openid, formatQueue(state));
1521
- return;
1522
- }
1523
- if (/^(clear|reset|清空)$/i.test(sub)) {
1524
- const dropped = state.queue.clear();
1525
- await replyTo(
1526
- api,
1527
- state,
1528
- openid,
1529
- dropped.length > 0 ? `已清空队列(${dropped.length} 条)。` : "队列本来就是空的。",
1530
- );
1531
- return;
1532
- }
1533
- if (/^(drop|del|rm|删除|删|去掉)$/i.test(sub)) {
1534
- const id = Number.parseInt(parts[1] ?? "", 10);
1535
- if (!Number.isFinite(id)) {
1536
- await replyTo(api, state, openid, "用法:/queue drop <编号>(编号见 /queue)");
1537
- return;
1538
- }
1539
- const removed = state.queue.remove(id);
1540
- await replyTo(
1541
- api,
1542
- state,
1543
- openid,
1544
- removed ? `已从队列删除 #${id}:${previewText(removed.text)}` : `队列里没有 #${id},发 /queue 查看。`,
1545
- );
1546
- return;
1547
- }
1548
- await replyTo(api, state, openid, "用法:/queue 查看 / /queue clear 清空 / /queue drop <编号> 删一条");
1549
- }
1550
-
1551
- /** 从 tool_call 事件提取可读摘要,让用户在 QQ 里看清要执行什么 */
1552
- function summarizeTool(event: any): string {
1553
- const input = event.input ?? {};
1554
- // 审批提示会发到 QQ:先脱敏再截断
1555
- const clip = (value: unknown) => redactSecrets(String(value ?? "")).slice(0, 200);
1556
- switch (event.toolName) {
1557
- case "bash":
1558
- case "powershell":
1559
- return `${event.toolName}: ${clip(input.command)}`;
1560
- case "write":
1561
- case "edit":
1562
- case "read":
1563
- return `${event.toolName}: ${clip(input.path ?? input.file ?? input.filePath)}`;
1564
- case "grep":
1565
- return `${event.toolName}: ${clip(input.pattern)}`;
1566
- case "qq_send_file":
1567
- return `发送文件到 QQ: ${clip(input.path)}${input.name ? `(显示名 ${clip(input.name)})` : ""}`;
1568
- default:
1569
- return `${event.toolName}: ${clip(JSON.stringify(input))}`;
1570
- }
1571
- }
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";
4
+ import { loadAccounts, pinnedAccountName, type QQBotAccount, type QQBotConfig } from "./config";
5
+ import {
6
+ REST_BASE_PROD,
7
+ REST_BASE_SANDBOX,
8
+ INTENT_GROUP_AND_C2C,
9
+ PASSIVE_WINDOW_MS,
10
+ APPROVAL_TIMEOUT_MS,
11
+ DISPATCH_RETRY_MS,
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,
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";
33
+ import { log, configureLog } from "./logger";
34
+ import { claimAny, startClaimRetry, reserveBeforeRelease, readOwner, type Claim } from "./lock";
35
+ import { AuthManager } from "./qq/auth";
36
+ import { QQApi } from "./qq/api";
37
+ import { createGatewayUrlFetcher, QQGateway, QQMessage } from "./qq/gateway";
38
+ import { Approver } from "./approver";
39
+ import { parsePickIndex, pickerExpired, routeSlash, runBridge, type PickerState } from "./slash";
40
+ import { agoLabel, MessageQueue, previewText, type QueuedJob } from "./queue";
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";
50
+ import { redactSecrets } from "./redact";
51
+ import {
52
+ isUpdateSelfIntent,
53
+ runSelfUpdate,
54
+ installedVersion,
55
+ checkLatestVersion,
56
+ applyUpdate,
57
+ shellRunner,
58
+ } from "./selfupdate";
59
+ import {
60
+ billingNotice,
61
+ borrowModelMeta,
62
+ classifyProviderError,
63
+ errorNotice,
64
+ existingProviderIds,
65
+ filterChatModelIds,
66
+ formatModelPicker,
67
+ kindLabel,
68
+ maskSecret,
69
+ MODEL_PICK_LIMIT,
70
+ modelLabel,
71
+ normalizeBaseUrl,
72
+ parseCredentialMessage,
73
+ probeEndpoint,
74
+ providerIdForEndpoint,
75
+ providerModelConfig,
76
+ updateProviderKey,
77
+ upsertEndpointProvider,
78
+ type CredentialIntake,
79
+ type ErrorContext,
80
+ type ProviderErrorKind,
81
+ type ProbeResult,
82
+ } from "./billing";
83
+ import { ReplyBuffer } from "./stream-buffer";
84
+ import { splitText } from "./util/chunk";
85
+
86
+ /** 发送本机文件的工具名 */
87
+ const QQ_SEND_FILE_TOOL = "qq_send_file";
88
+ /** 外发文件必须审批:不受配置 approve.tools 影响,也不吃「以后都同意」/会话内记忆 */
89
+ const FORCE_APPROVAL_TOOLS = new Set<string>([QQ_SEND_FILE_TOOL]);
90
+
91
+ interface State {
92
+ cfg: QQBotConfig | null;
93
+ /** 本进程当前服务的 QQ bot(自动抢占的结果);未接入则为 null */
94
+ account: { name: string; appId: string } | null;
95
+ /** 本进程的候选 bot(按配置顺序),待命时用它重扫 */
96
+ candidates: { name: string; appId: string }[];
97
+ /** 首条回执前缀(接入成功 / 换进程接管时告知用户当前 bot 与工作目录) */
98
+ claimNote: string | null;
99
+ api: QQApi | null;
100
+ approver: Approver;
101
+ gateway: QQGateway | null;
102
+ currentOpenid: string | null;
103
+ replyBuffer: ReplyBuffer;
104
+ releaseLock: (() => Promise<void>) | null;
105
+ stopRetry: (() => void) | null;
106
+ passiveRefs: Map<string, { msgId: string; receivedAt: number }>;
107
+ busy: boolean;
108
+ /** 当前 run 的上下文(用来 abort);run 结束或取消后清空 */
109
+ ctx: ExtensionContext | null;
110
+ /** 最近一条 QQ 消息的发送者:桥接命令用它回执 */
111
+ lastOpenid: string | null;
112
+ /** 待选择列表(/tree、/fork、/resume 发出后的编号等待状态) */
113
+ picker: PickerState | null;
114
+ /** 发出列表的 QQ 用户:只有他能回编号 */
115
+ pickerOwner: string | null;
116
+ /** /qq 桥接命令的回执通道(每次收到 QQ 消息时刷新) */
117
+ bridgeReply: ((text: string) => void) | null;
118
+ /** 本轮已被用户取消:settle 时丢弃半截回复 */
119
+ cancelled: boolean;
120
+ /** 忙碌时收到的任务:当前任务 settle 后逐条执行 */
121
+ queue: MessageQueue;
122
+ /** 正在出队派发(防 settled / 重试竞态下重复派发) */
123
+ dispatching: boolean;
124
+ /** 当前派发的任务已经真的启动了 run(agent_start 过):此时报错不重试,避免重复执行 */
125
+ runStarted: boolean;
126
+ /** /cancel 之后等 agent_settled 的兜底定时器 */
127
+ cancelTimer: ReturnType<typeof setTimeout> | null;
128
+ /** 任务进度摘要 */
129
+ progress: ProgressTracker;
130
+ /** 自动推送进度的定时器 */
131
+ progressTimer: ReturnType<typeof setInterval> | null;
132
+ /** 推送失败后暂停自动推送,直到用户主动询问 */
133
+ progressPaused: boolean;
134
+ /** 用户手动暂停自动推送(本会话有效:跨 run 保留,/progress on 恢复) */
135
+ progressMuted: boolean;
136
+ /** 等审批期间暂缓自动进度推送(避免把审批消息顶走);审批处理完自动恢复 */
137
+ progressHeldForApproval: boolean;
138
+ /** 本会话是否已给 pi 注入过「远程用户」提醒(每会话一次,压缩后重新注入) */
139
+ remoteNotified: boolean;
140
+ /** 上一轮 run 的来源:null = 还没跑过 */
141
+ lastRunOrigin: "qq" | "local" | null;
142
+ /** 本机在 TUI 接管了对话,等下一轮 run 开跑时更正「远程前提作废」 */
143
+ localHandoffPending: boolean;
144
+ /** 最终答案发送失败时缓存,下一条消息时补发 */
145
+ undelivered: { openid: string; text: string } | null;
146
+ /** 本轮 run 中模型接口返回的错误(分类 + 已脱敏原文),结束时转告 QQ */
147
+ lastError: ErrorContext | null;
148
+ /** 最近一次欠费/鉴权失败:让用户直接发来的新 key 知道该落到哪个 provider */
149
+ billing: { kind: ProviderErrorKind; provider: string; modelId: string; at: number } | null;
150
+ /** 最近一次真正生效的模型(带 baseUrl,用于探测新 key 是否有效) */
151
+ lastModel: { provider: string; modelId: string; baseUrl?: string } | null;
152
+ /** 只读的 command/session 上下文:拿 modelRegistry 借内置目录里的模型元数据 */
153
+ registryCtx: ExtensionContext | null;
154
+ /** 附件下载 401/403 时重试用的凭据(接入后由 applyClaim 注入) */
155
+ getToken: (() => Promise<string>) | null;
156
+ /** 仅测试用:替换附件落盘时的 fetch */
157
+ attachmentFetch: typeof fetch | null;
158
+ /** 当前 bot 的 REST 基址(发文件用) */
159
+ restBase: string | null;
160
+ }
161
+
162
+ /** 扩展运行所需的状态(导出只为测试用) */
163
+ export function createInitialState(): State {
164
+ return {
165
+ cfg: null,
166
+ api: null,
167
+ approver: new Approver(APPROVAL_TIMEOUT_MS),
168
+ gateway: null,
169
+ currentOpenid: null,
170
+ replyBuffer: new ReplyBuffer(),
171
+ releaseLock: null,
172
+ stopRetry: null,
173
+ passiveRefs: new Map(),
174
+ busy: false,
175
+ ctx: null,
176
+ lastOpenid: null,
177
+ picker: null,
178
+ pickerOwner: null,
179
+ bridgeReply: null,
180
+ cancelled: false,
181
+ queue: new MessageQueue(),
182
+ dispatching: false,
183
+ runStarted: false,
184
+ cancelTimer: null,
185
+ progress: new ProgressTracker(),
186
+ progressTimer: null,
187
+ progressPaused: false,
188
+ progressMuted: false,
189
+ progressHeldForApproval: false,
190
+ remoteNotified: false,
191
+ lastRunOrigin: null,
192
+ localHandoffPending: false,
193
+ undelivered: null,
194
+ lastError: null,
195
+ billing: null,
196
+ lastModel: null,
197
+ registryCtx: null,
198
+ getToken: null,
199
+ attachmentFetch: null,
200
+ restBase: null,
201
+ account: null,
202
+ candidates: [],
203
+ claimNote: null,
204
+ };
205
+ }
206
+
207
+ export default function (
208
+ pi: ExtensionAPI,
209
+ /** 仅测试用:注入状态,让事件处理器与 handleMessage 共享同一份状态 */
210
+ options?: {
211
+ state?: State;
212
+ /** 仅测试用:不真连 QQ 网关(避免单测打网络),其余流程(抢锁/身份/提示)照常 */
213
+ disableGateway?: boolean;
214
+ /** 仅测试用:替换「抢到锁后验凭据」的实现(默认真的去换 access_token) */
215
+ verifyCredentials?: (account: QQBotAccount) => Promise<AuthManager>;
216
+ },
217
+ ) {
218
+ const state: State = options?.state ?? createInitialState();
219
+
220
+ // ---- 事件:本机用户在 TUI 直接打字(source=interactive)→ 记一笔「可能接管」----
221
+ // 只记待定,真正注入要等下一轮 run 开跑(before_agent_start)。
222
+ // 本扩展自己的 sendUserMessage 是 source=extension,不会走到这里;QQ run 跑着时接管不处理。
223
+ pi.on("input", (event) => {
224
+ const ev = event as any;
225
+ if (ev?.source !== "interactive") return;
226
+ if (state.busy) return;
227
+ if (state.lastRunOrigin !== "qq") return;
228
+ if (state.localHandoffPending) return;
229
+ state.localHandoffPending = true;
230
+ log("[remote] 检测到本机 TUI 输入,下一轮按「本机用户」前提处理");
231
+ });
232
+
233
+ // ---- 事件:真正开始一轮 run 时,按「来自 QQ 还是本机」准备系统提示 ----
234
+ // 不在扩展载入时注入,也不在本机 TUI 自己对话时注入:只有 startJob() 派发的
235
+ // QQ 消息真的开跑(state.busy + currentOpenid)才会走到这里。
236
+ pi.on("before_agent_start", (event) => {
237
+ const cfg = state.cfg;
238
+ if (!cfg) return;
239
+ if ((event as any).messageType !== "user") return;
240
+ const options = (event as any).systemPromptOptions;
241
+ if (!options || typeof options !== "object") return;
242
+
243
+ // 本扩展派发的 QQ 消息
244
+ if (state.busy && state.currentOpenid) {
245
+ const remote = resolveRemoteConfig(cfg);
246
+ if (remote.guidelines && Array.isArray(options.promptGuidelines)) {
247
+ const guidelines = buildRemoteGuidelines();
248
+ // 同一次 run 可能重复触发(如压缩后重试),别叠加两遍
249
+ if (!options.promptGuidelines.includes(guidelines[0])) options.promptGuidelines.push(...guidelines);
250
+ }
251
+ if (remote.notice && !state.remoteNotified) {
252
+ state.remoteNotified = true;
253
+ // 放进系统提示的 section:排在用户消息之前,模型不会把它当成要回复的消息;
254
+ // 只在本轮生效,操作规则由 guidelines 继续带在后续每一轮。
255
+ options.sections = { ...(options.sections ?? {}), [REMOTE_NOTICE_SECTION]: buildRemoteNotice(remote) };
256
+ log("[remote] 首次向 pi 交付 QQ 消息,已注入远程上下文说明(系统提示 section)");
257
+ }
258
+ // 又轮到 QQ 了:刚才的本机接管待定作废
259
+ state.localHandoffPending = false;
260
+ state.lastRunOrigin = "qq";
261
+ return;
262
+ }
263
+
264
+ // 本机 TUI/CLI 直接开跑:上一轮是 QQ 的话,更正一次「远程前提作废」
265
+ const handoff = state.localHandoffPending;
266
+ state.localHandoffPending = false;
267
+ state.lastRunOrigin = "local";
268
+ if (!handoff) return;
269
+ options.sections = { ...(options.sections ?? {}), [LOCAL_HANDOFF_SECTION]: buildLocalHandoffNotice() };
270
+ log("[remote] 本机接管本轮对话,已注入「远程前提作废」更正");
271
+ });
272
+
273
+ // ---- 事件:run 开始,记住 ctx(取消指令要调用 ctx.abort())----
274
+ pi.on("agent_start", (_event, ctx) => {
275
+ state.ctx = ctx;
276
+ state.cancelled = false;
277
+ state.runStarted = true;
278
+ state.lastError = null;
279
+ syncModelFromCtx(state, ctx);
280
+ clearCancelTimer(state);
281
+ state.progress.start();
282
+ state.progressPaused = false;
283
+ state.progressHeldForApproval = false;
284
+ startProgressTimer(pi, state);
285
+ });
286
+
287
+ // ---- 事件:累积 assistant 文本 ----
288
+ pi.on("message_update", (event) => {
289
+ const ev = event.assistantMessageEvent as any;
290
+ if (ev?.type === "text_delta") {
291
+ const delta = ev.delta ?? ev.text ?? "";
292
+ state.replyBuffer.append(delta);
293
+ state.progress.appendText(delta);
294
+ }
295
+ });
296
+
297
+ // ---- 事件:进度统计(工具/轮次/用量)----
298
+ pi.on("tool_execution_start", (event) => {
299
+ const ev = event as any;
300
+ state.progress.toolStart(ev.toolCallId, ev.toolName, ev.args);
301
+ });
302
+
303
+ pi.on("tool_execution_end", (event) => {
304
+ const ev = event as any;
305
+ state.progress.toolEnd(ev.toolCallId, ev.toolName, ev.isError === true, ev.durationMs);
306
+ });
307
+
308
+ pi.on("turn_end", () => {
309
+ state.progress.turn();
310
+ });
311
+
312
+ pi.on("message_end", (event) => {
313
+ const message = (event as any).message;
314
+ if (message?.role !== "assistant") return;
315
+ state.progress.addUsage(message.usage);
316
+ // 接口报错(402 余额不足 / 401 鉴权失败 / 5xx 等):pi 把 provider 返回的原文塞进
317
+ // errorMessage,且不会有任何 text_delta —— 不记下来 QQ 侧就是「没反应」。
318
+ if (message.stopReason === "error") {
319
+ const raw = String(message.errorMessage ?? "未知错误");
320
+ const kind = classifyProviderError(raw);
321
+ const detail = redactSecrets(raw).replace(/\s+/g, " ").trim().slice(0, 600);
322
+ const model = [message.provider, message.model].filter(Boolean).join("/") || null;
323
+ state.lastError = { kind, detail, model };
324
+ if (message.provider && message.model) {
325
+ state.lastModel = {
326
+ provider: String(message.provider),
327
+ modelId: String(message.model),
328
+ baseUrl: state.lastModel?.baseUrl,
329
+ };
330
+ }
331
+ log("[reply] 本轮模型接口报错:", kindLabel(kind), model ?? "", detail);
332
+ }
333
+ });
334
+
335
+ // ---- 事件:run 结束,发回复,然后接着跑队列里的下一条 ----
336
+ pi.on("agent_settled", async () => {
337
+ clearCancelTimer(state);
338
+ stopProgressTimer(state);
339
+ state.progress.stop();
340
+ const cancelled = state.cancelled;
341
+ const text = state.replyBuffer.take();
342
+ const errorInfo = state.lastError;
343
+ state.lastError = null;
344
+ // 本轮吐出了正文 = 模型跑通了:欠费状态作废,避免把用户下一条正文误当成 key
345
+ if (text && !cancelled) state.billing = null;
346
+ const openid = state.currentOpenid;
347
+ state.currentOpenid = null;
348
+ state.busy = false;
349
+ state.ctx = null;
350
+ state.cancelled = false;
351
+ state.runStarted = false;
352
+ if (cancelled) {
353
+ log("[reply] 本轮已被取消,丢弃半截回复(长度", text.length, ")");
354
+ } else if (!text && errorInfo && openid && state.api) {
355
+ // 欠费/鉴权类失败:除了报错,还要把「怎么恢复」和「当前是哪家服务」讲清楚,
356
+ // 并记住上下文,让用户直接把新 key / 新 endpoint 发过来就能用。
357
+ const recoverable =
358
+ errorInfo.kind === "billing" || errorInfo.kind === "auth" || errorInfo.kind === "permission";
359
+ if (recoverable) {
360
+ const [provider, ...rest] = String(errorInfo.model ?? "").split("/");
361
+ state.billing = {
362
+ kind: errorInfo.kind,
363
+ provider: provider || state.lastModel?.provider || "",
364
+ modelId: rest.join("/") || state.lastModel?.modelId || "",
365
+ at: Date.now(),
366
+ };
367
+ log("[billing] 记为可恢复的接口失败:", kindLabel(errorInfo.kind), errorInfo.model ?? "?");
368
+ }
369
+ const notice = recoverable ? billingNotice(errorInfo) : errorNotice(errorInfo);
370
+ log("[reply] 本轮接口报错,转告 QQ:", notice.split("\n")[0]);
371
+ for (const chunk of splitText(notice)) {
372
+ await state.api.sendC2C(openid, chunk).catch((e) => {
373
+ log("[reply] 错误提示发送失败:", String(e));
374
+ });
375
+ }
376
+ } else if (text && openid && state.api) {
377
+ log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
378
+ let delivered = true;
379
+ for (const chunk of splitText(text)) {
380
+ await state.api.sendC2C(openid, chunk).catch((e) => {
381
+ delivered = false;
382
+ log("[reply] 发送失败:", String(e));
383
+ });
384
+ }
385
+ if (!delivered) {
386
+ // 配额耗尽等情况:缓存下来,用户下次发消息时补发
387
+ state.undelivered = { openid, text };
388
+ log("[reply] 最终答案发送失败,已缓存待补发(openid=", openid, "len=", text.length, ")");
389
+ }
390
+ }
391
+ // 本轮结束:取队列里的下一条继续执行(不 await,避免卡住 settled 的派发阶段)
392
+ drainQueue(pi, state);
393
+ });
394
+
395
+ // ---- 事件:tool_call 审批 ----
396
+ pi.on("tool_call", async (event) => {
397
+ const openid = state.currentOpenid;
398
+ if (!openid || !state.api || !state.cfg) return; // 无 QQ 上下文,放行
399
+ const ev = event as any;
400
+ const toolName: string = ev.toolName ?? "tool";
401
+ const cfg = state.cfg;
402
+
403
+ // 判断是否需要审批:配置的 tools 列表 + destructiveHint 兑底 + 强制审批工具(外发文件)
404
+ const allTools = (pi.getAllTools() ?? []) as any[];
405
+ const toolMeta = allTools.find((t) => t.name === toolName);
406
+ const destructive = toolMeta?.annotations?.destructiveHint === true;
407
+ const forced = FORCE_APPROVAL_TOOLS.has(toolName);
408
+ const needsApproval = forced || cfg.approve.tools.includes(toolName) || destructive;
409
+ if (!needsApproval) return; // 放行
410
+
411
+ // 强制审批的工具(发送文件)每次都问:不吃「以后都同意」,也不吃会话内记住的决策
412
+ if (!forced) {
413
+ // 会话级自动同意(用户回复过「以后都同意」):本会话内直接放行,不落盘、不跨会话
414
+ if (state.approver.autoAllowed) {
415
+ log("[approve] 自动同意放行:", toolName);
416
+ return;
417
+ }
418
+
419
+ // 会话内已记住的决策
420
+ const remembered = cfg.approve.remember ? state.approver.isRemembered(toolName) : undefined;
421
+ if (remembered === "allow") return; // 已记住放行
422
+ if (remembered === "block") return { block: true, reason: `QQ 用户已拒绝 ${toolName}` };
423
+ if (remembered === "terminate") return { block: true, terminate: true, reason: "已停止" };
424
+ }
425
+
426
+ const summary = summarizeTool(ev);
427
+ log("[approve] 工具需要审批:", summary);
428
+ const { seq, done } = state.approver.register(summary, () => {
429
+ // 超时通知
430
+ state.api?.sendC2C(openid, `审批 #${seq} 已超时,已自动拒绝。`).catch(() => {});
431
+ });
432
+ await state.api
433
+ .sendC2C(openid, state.approver.render(seq, { strict: forced }))
434
+ .catch(() => {});
435
+ const decision = await done;
436
+
437
+ if (decision === "allow") {
438
+ if (!forced && cfg.approve.remember) state.approver.remember(toolName, "allow");
439
+ log("[approve] 用户批准 #", seq);
440
+ return;
441
+ }
442
+ if (decision === "terminate") {
443
+ log("[approve] 用户停止");
444
+ return { block: true, terminate: true, reason: "QQ 用户停止" };
445
+ }
446
+ if (decision === "timeout") {
447
+ // 超时不写 remember:一次没回复不该永久封死该工具
448
+ log("[approve] 审批超时 #", seq);
449
+ return { block: true, reason: `QQ 审批超时(#${seq}):${summary}` };
450
+ }
451
+ // block(用户显式拒绝)
452
+ if (!forced && cfg.approve.remember) state.approver.remember(toolName, "block");
453
+ log("[approve] 用户拒绝 #", seq);
454
+ return { block: true, reason: `QQ 用户拒绝了 ${summary}` };
455
+ });
456
+
457
+ // ---- 事件:session_start 选/抢 qqbot + 启动网关 ----
458
+ pi.on("session_start", async (event, ctx) => {
459
+ // 只读地存一份上下文:注册新 endpoint 时要拿 modelRegistry 借内置目录的模型元数据
460
+ state.registryCtx = ctx ?? state.registryCtx;
461
+ const { accounts, errors } = loadAccounts();
462
+ for (const message of errors) log("[setup] 配置问题:", message);
463
+ if (accounts.length === 0) {
464
+ log("[setup] 未配置 QQ bot(或配置不可用),插件未启用");
465
+ return;
466
+ }
467
+
468
+ // 会话重建(/reload、/new…)时优先沿用自己的 bot:上一轮释放前写了 owner 记录
469
+ const pin = pinnedAccountName();
470
+ const pool: QQBotAccount[] = pin
471
+ ? accounts.filter((a) => a.name === pin || a.appId === pin)
472
+ : accounts;
473
+ if (pool.length === 0) {
474
+ log("[setup] PI_QQBOT_ACCOUNT=", pin, "不在配置里(可选:", accounts.map((a) => a.name).join(", "), "),插件未启用");
475
+ return;
476
+ }
477
+ state.candidates = pool.map((a) => ({ name: a.name, appId: a.appId }));
478
+
479
+ const ownPid = process.pid;
480
+ const preferredAppId = state.candidates
481
+ .map((c) => readOwner(c.appId))
482
+ .find((owner) => owner?.pid === ownPid)?.appId;
483
+
484
+ // 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
485
+ state.approver.reset();
486
+ // 远程上下文提醒同样只在本会话提醒一次
487
+ state.remoteNotified = false;
488
+ // 「QQ ↔ 本机」的来源切换状态也随会话重置
489
+ state.lastRunOrigin = null;
490
+ state.localHandoffPending = false;
491
+ // 手动暂停的自动汇报同样只在本会话有效
492
+ state.progressMuted = false;
493
+ state.progressPaused = false;
494
+ state.queue.clear();
495
+
496
+ // 凭据不可用的 bot 在本次会话内不再尝试(配置在 session_start 读取,改完 /reload 才生效)
497
+ const unusable = new Set<string>();
498
+ const verifyAccount =
499
+ options?.verifyCredentials ??
500
+ (async (account: QQBotAccount) => {
501
+ const auth = new AuthManager(account.appId, account.config.appSecret);
502
+ await auth.getToken(); // 抢到锁之后先验凭据,失败就不占着这个槽位
503
+ return auth;
504
+ });
505
+
506
+ const applyClaim = (claim: Claim, account: QQBotAccount, auth: AuthManager) => {
507
+ const cfg = account.config;
508
+ state.cfg = cfg;
509
+ state.account = { name: account.name, appId: account.appId };
510
+ state.releaseLock = claim.release;
511
+ // 只有一个 bot(含 v1 旧配置)→ 继续写旧文件名,升级后日志不断线
512
+ configureLog(accounts.length === 1 ? null : account.name);
513
+ state.queue.setLimit(cfg.queue.max);
514
+ log(
515
+ "[setup] 配置已加载 bot=",
516
+ `"${account.name}"`,
517
+ "appId=",
518
+ account.appId,
519
+ "sandbox=",
520
+ cfg.sandbox,
521
+ "queue.max=",
522
+ cfg.queue.max,
523
+ "reason=",
524
+ (event as any)?.reason ?? "startup",
525
+ );
526
+
527
+ // 提示:接入成功 / 换进程接管(后者一定要带工作目录)
528
+ const cwd = process.cwd();
529
+ if (claim.takeover && claim.previous) {
530
+ log(
531
+ "[lock] qqbot",
532
+ `"${account.name}"`,
533
+ "换进程接管: pid",
534
+ claim.previous.pid,
535
+ "→",
536
+ process.pid,
537
+ "· cwd",
538
+ claim.previous.cwd || "?",
539
+ "→",
540
+ cwd,
541
+ );
542
+ state.claimNote = `(qqbot「${account.name}」已由新实例接管 · 当前工作目录 ${cwd})`;
543
+ } else if (claim.reclaimed) {
544
+ log("[lock] 重新接入自己的 qqbot(同进程会话重建)");
545
+ } else {
546
+ log("[lock] 接入 qqbot", `"${account.name}"`, `(${account.appId})`, "· cwd=", cwd, "· pid=", process.pid);
547
+ state.claimNote = `(已接入 qqbot「${account.name}」)`;
548
+ }
549
+
550
+ const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
551
+ const api = new QQApi({ restBase, getToken: () => auth.getToken() });
552
+ state.api = api;
553
+ state.getToken = () => auth.getToken();
554
+ state.restBase = restBase;
555
+
556
+ // 顺手清掉上一轮留下的临时附件(失败不影响启动)
557
+ const attCfg = attachmentConfig(cfg);
558
+ if (attCfg.enabled) {
559
+ const attDir = attCfg.dir || defaultAttachmentDir(cfg.appId);
560
+ void sweepAttachmentDir(attDir, {
561
+ retentionHours: attCfg.retentionHours,
562
+ maxFiles: attCfg.maxFiles,
563
+ })
564
+ .then((r) => {
565
+ if (r.removed > 0) log("[attachment] 清理临时附件", r.removed, "个", `(dir=${attDir})`);
566
+ })
567
+ .catch((e) => log("[attachment] 清理失败:", String(e).slice(0, 160)));
568
+ }
569
+
570
+ if (options?.disableGateway) {
571
+ log("[setup] 测试模式:跳过 QQ 网关");
572
+ return;
573
+ }
574
+ const gateway = new QQGateway({
575
+ getGatewayUrl: createGatewayUrlFetcher(restBase, () => auth.getToken()),
576
+ getToken: () => auth.getToken(),
577
+ intents: INTENT_GROUP_AND_C2C,
578
+ connected: () => {},
579
+ disconnected: () => {},
580
+ message: (msg) => handleMessage(pi, state, api, state.cfg ?? cfg, msg),
581
+ });
582
+ state.gateway = gateway;
583
+ log("[lock] 启动 QQ 网关");
584
+ gateway.start();
585
+ };
586
+
587
+ // 抢一个、验一下凭据、验不过就放锁换下一个
588
+ const tryClaimOnce = async (): Promise<boolean> => {
589
+ const remaining = state.candidates.filter((c) => !unusable.has(c.appId));
590
+ if (remaining.length === 0) return false;
591
+ const result = await claimAny(remaining, { preferredAppId });
592
+ if (!result.claim) {
593
+ const detail = result.skipped
594
+ .map(
595
+ (s) =>
596
+ `${s.name}:${s.reason === "online" ? "在线" : s.reason === "reserved" ? `被 pid ${s.owner?.pid} 重载中` : "刚被抢走"}`,
597
+ )
598
+ .join(", ");
599
+ log("[lock] 本轮无可接入的 qqbot(", detail, ")");
600
+ return false;
601
+ }
602
+ const claim = result.claim;
603
+ const account = pool.find((a) => a.appId === claim.appId);
604
+ if (!account) {
605
+ await claim.release().catch(() => {});
606
+ unusable.add(claim.appId);
607
+ return tryClaimOnce();
608
+ }
609
+ try {
610
+ const auth = await verifyAccount(account);
611
+ applyClaim(claim, account, auth);
612
+ return true;
613
+ } catch (e) {
614
+ await claim.release().catch(() => {});
615
+ unusable.add(account.appId);
616
+ log(
617
+ "[lock] qqbot",
618
+ `"${account.name}"`,
619
+ "凭据不可用:",
620
+ String(e).slice(0, 160),
621
+ "→ 已放锁并跳过该 bot(改好配置后 /reload 再试)",
622
+ );
623
+ return tryClaimOnce();
624
+ }
625
+ };
626
+
627
+ // 待命:还有「可能变可用」的候选就 5s 重扫——否则别人退出后没人接管
628
+ const enterStandby = () => {
629
+ if (!state.candidates.some((c) => !unusable.has(c.appId))) {
630
+ log("[lock] 没有可接入的 qqbot(其余在线或凭据不可用),本进程不接入 QQ");
631
+ return;
632
+ }
633
+ log("[lock] 本进程待命,5s 后重扫");
634
+ state.stopRetry = startClaimRetry(
635
+ () => state.candidates.filter((c) => !unusable.has(c.appId)),
636
+ (claim) => {
637
+ state.stopRetry = null;
638
+ void (async () => {
639
+ const account = pool.find((a) => a.appId === claim.appId);
640
+ if (!account || state.gateway) {
641
+ await claim.release().catch(() => {});
642
+ enterStandby();
643
+ return;
644
+ }
645
+ try {
646
+ const auth = await verifyAccount(account);
647
+ applyClaim(claim, account, auth);
648
+ } catch (e) {
649
+ await claim.release().catch(() => {});
650
+ unusable.add(account.appId);
651
+ log("[lock] qqbot", `"${account.name}"`, "凭据不可用:", String(e).slice(0, 160), "→ 放锁继续待命");
652
+ enterStandby();
653
+ }
654
+ })();
655
+ },
656
+ { preferredAppId },
657
+ );
658
+ };
659
+
660
+ if (await tryClaimOnce()) return;
661
+ enterStandby();
662
+ });
663
+
664
+ // ---- 事件:上下文压缩可能把「远程用户」提醒压掉:下一条消息重新注入 ----
665
+ pi.on("session_compact", (event) => {
666
+ state.remoteNotified = false;
667
+ log("[remote] 上下文已压缩(reason=", String((event as any)?.reason ?? "?"), "),下一条消息重新注入提醒");
668
+ });
669
+
670
+ // ---- 事件:session_shutdown 清理 ----
671
+ pi.on("session_shutdown", async (event) => {
672
+ const reason = String((event as any)?.reason ?? "quit");
673
+ log("[shutdown] 清理资源 reason=", reason);
674
+ clearCancelTimer(state);
675
+ stopProgressTimer(state);
676
+ const dropped = state.queue.clear();
677
+ if (dropped.length > 0) log("[shutdown] 丢弃队列任务", dropped.length, "条");
678
+ if (state.stopRetry) state.stopRetry();
679
+ state.gateway?.stop();
680
+ // reload/new/fork/resume 只是「本进程换会话」,马上会重新接入:先贴预约,别让别人抢走
681
+ if (state.account && state.releaseLock && reason !== "quit") {
682
+ reserveBeforeRelease(state.account, reason);
683
+ log("[lock] qqbot", `"${state.account.name}"`, "临时释放(reason=", reason, "),已留预约窗口");
684
+ }
685
+ if (state.releaseLock) {
686
+ try {
687
+ await state.releaseLock();
688
+ } catch {
689
+ /* ignore */
690
+ }
691
+ }
692
+ state.releaseLock = null;
693
+ state.gateway = null;
694
+ state.api = null;
695
+ state.getToken = null;
696
+ state.restBase = null;
697
+ state.account = null;
698
+ });
699
+
700
+ // ---- /qq:QQ 侧命令的桥接入口 ----
701
+ // pi 只对「扩展命令」执行派发,内置命令(/reload、/new…)只能在 TUI/RPC 层跑;
702
+ // 而 reload()/newSession() 又必须拿到 ExtensionCommandContext。于是注册一个扩展
703
+ // 命令,QQ 侧收到 /reload 时改派 /qq reload,就能在合法的命令上下文里执行。
704
+ pi.registerCommand("qq", {
705
+ description:
706
+ "pi-qqbot 内部桥接:/qq <reload|new|compact|quit|session|name|model|thinking|sendfile> [args]",
707
+ handler: async (args, ctx) => {
708
+ const trimmed = args.trim();
709
+ const space = trimmed.indexOf(" ");
710
+ const sub = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
711
+ const rest = (space === -1 ? "" : trimmed.slice(space + 1)).trim();
712
+ const reply = (text: string) => state.bridgeReply?.(text);
713
+ // 发本机文件不需要命令上下文,先于 runBridge 处理
714
+ if (sub === "sendfile") {
715
+ try {
716
+ reply(await sendFileToQQ(state, rest));
717
+ } catch (e) {
718
+ log("[/qq] sendfile 失败:", String(e));
719
+ reply(`发送失败:${explainUploadError(e)}`);
720
+ }
721
+ return;
722
+ }
723
+ try {
724
+ await runBridge(sub, rest, ctx, pi, reply, state);
725
+ } catch (e) {
726
+ log("[/qq] 桥接失败:", String(e));
727
+ reply(`/${sub} 执行失败:${String(e).slice(0, 200)}`);
728
+ }
729
+ // 命令可能换了模型(/model、编号选择):同步一份给「欠费后发新 key」用
730
+ syncModelFromCtx(state, ctx);
731
+ },
732
+ });
733
+
734
+ // ---- 工具:把本机文件发到 QQ ----
735
+ // 外发文件属于数据出境,必须在 QQ 里审批(见 FORCE_APPROVAL_TOOLS)。
736
+ pi.registerTool(
737
+ defineTool({
738
+ name: QQ_SEND_FILE_TOOL,
739
+ label: "Send file to QQ",
740
+ description:
741
+ "把本机上已有的文件发送到当前 QQ 对话(对方收到可下载的文件卡片/图片/视频/语音)。" +
742
+ "只在用户明确要求把某个文件发给他时使用;path 必须是本机绝对路径。" +
743
+ "发送前会先在 QQ 里自动申请审批,用户同意后才会真正上传。",
744
+ parameters: Type.Object({
745
+ path: Type.String({ description: "本机文件的绝对路径,例如 D:\\docs\\report.pdf" }),
746
+ name: Type.Optional(
747
+ Type.String({ description: "在 QQ 里显示的文件名(含扩展名),默认用原文件名" }),
748
+ ),
749
+ }),
750
+ async execute(_toolCallId: string, params: any) {
751
+ const text = await sendFileToQQ(state, String(params?.path ?? ""), params?.name ? String(params.name) : undefined);
752
+ return { content: [{ type: "text", text }], details: { tool: QQ_SEND_FILE_TOOL } };
753
+ },
754
+ }),
755
+ );
756
+ }
757
+
758
+ /** 取消/停止这类「打断当前任务」的表达 */
759
+ /**
760
+ * 取消/停止这类「打断当前任务」的表达。
761
+ *
762
+ * 注意分工:「停」「停下」「停一下」属于**中断任务**(用户定的:停 = 停止干活);
763
+ * 想只关掉进度推送请用「别汇报了」「闭嘴」「/progress off」;
764
+ * 「停止汇报」因为带了名词,不算取消,走静音。
765
+ */
766
+ const RE_CANCEL =
767
+ /^(\/cancel|\/stop|\/abort|取消|停止|中止|终止|算了|停|停下|停一下|停停|停掉|别跑了|cancel|stop|abort)$/i;
768
+
769
+ function isCancelIntent(text: string): boolean {
770
+ return RE_CANCEL.test(text.trim());
771
+ }
772
+
773
+ /** files 配置兜底:老配置 / 测试里可能没这个键 */
774
+ function filesConfig(cfg: QQBotConfig): { enabled: boolean; maxBytes: number } {
775
+ const raw = (cfg as any)?.files;
776
+ return {
777
+ enabled: raw?.enabled ?? true,
778
+ maxBytes: Number(raw?.maxBytes ?? DEFAULT_SEND_MAX_BYTES),
779
+ };
780
+ }
781
+
782
+ /**
783
+ * 把本机文件发到当前 QQ 对话。
784
+ * 工具(qq_send_file)与 `/sendfile` 命令共用;失败抛异常,文案由 explainUploadError 翻译。
785
+ */
786
+ async function sendFileToQQ(state: State, filePath: string, displayName?: string): Promise<string> {
787
+ const cfg = state.cfg;
788
+ if (!cfg) throw new Error("还没有接入 QQ bot(会话未就绪)");
789
+ const files = filesConfig(cfg);
790
+ if (!files.enabled) throw new Error("发送文件已关闭(配置 files.enabled=false)");
791
+ const openid = state.currentOpenid ?? state.lastOpenid;
792
+ if (!openid || !state.api) throw new Error("没有 QQ 会话目标(先让用户从 QQ 发一条消息)");
793
+ if (!state.restBase || !state.getToken) throw new Error("QQ 凭据未就绪");
794
+
795
+ const raw = String(filePath ?? "")
796
+ .trim()
797
+ .replace(/^["']|["']$/g, "")
798
+ .trim();
799
+ if (!raw) throw new Error("缺少文件路径(用法:/sendfile <绝对路径>)");
800
+
801
+ const media = await uploadRichMedia({
802
+ restBase: state.restBase,
803
+ openid,
804
+ getToken: state.getToken,
805
+ filePath: raw,
806
+ fileName: displayName,
807
+ maxBytes: files.maxBytes,
808
+ log,
809
+ onProgress: (done, total) => log("[send] 分片", done, "/", total),
810
+ });
811
+
812
+ const ref = state.passiveRefs.get(openid);
813
+ const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
814
+ await state.api.sendC2CMedia(openid, media.fileInfo, usePassive ? { msgId: ref.msgId } : {});
815
+ log("[send] 已发出", media.fileName, media.size, "bytes", `type=${media.fileType}`);
816
+ return `已发送「${media.fileName}」(${formatSize(media.size)},${mediaKindLabel(media.fileType)})`;
817
+ }
818
+
819
+ function formatSize(bytes: number): string {
820
+ if (!Number.isFinite(bytes) || bytes < 0) return "?";
821
+ if (bytes < 1024) return `${bytes} B`;
822
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
823
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
824
+ }
825
+ function attachmentConfig(cfg: QQBotConfig): AttachmentConfig {
826
+ const raw = (cfg as any)?.attachments;
827
+ return {
828
+ enabled: raw?.enabled ?? true,
829
+ dir: String(raw?.dir ?? "").trim(),
830
+ maxBytes: Number(raw?.maxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES),
831
+ maxPerMessage: Number(raw?.maxPerMessage ?? DEFAULT_ATTACHMENT_MAX_PER_MESSAGE),
832
+ retentionHours: Number(raw?.retentionHours ?? DEFAULT_ATTACHMENT_RETENTION_HOURS),
833
+ maxFiles: Number(raw?.maxFiles ?? DEFAULT_ATTACHMENT_MAX_FILES),
834
+ };
835
+ }
836
+
837
+ /** 派发失败时最多重试几次(通常是 pi 上一轮还没收尾) */
838
+ const MAX_DISPATCH_ATTEMPTS = 3;
839
+
840
+ function clearCancelTimer(state: State) {
841
+ if (state.cancelTimer) {
842
+ clearTimeout(state.cancelTimer);
843
+ state.cancelTimer = null;
844
+ }
845
+ }
846
+
847
+ /**
848
+ * 对外看是“忙”:有任务在跑、正在派发,或者队列里还有任务等着(比如派发失败后的重试窗口)。
849
+ * 这三种情况新消息都应该进队列,而不是插队直接开新一轮。
850
+ */
851
+ function isBusy(state: State): boolean {
852
+ return state.busy || state.dispatching || state.queue.size > 0;
853
+ }
854
+
855
+ /**
856
+ * /cancel 之后 abort() 是异步的:正常路径靠 agent_settled 释放 busy 并继续队列;
857
+ * 万一 abort 没能触发 settled(比如那一轮正好已经结束),宽限期后强制解锁。
858
+ */
859
+ function armCancelWatchdog(pi: ExtensionAPI, state: State) {
860
+ clearCancelTimer(state);
861
+ const timer = setTimeout(() => {
862
+ state.cancelTimer = null;
863
+ if (!state.cancelled) return;
864
+ log("[cancel] abort 后", CANCEL_SETTLE_GRACE_MS, "ms 内没有 agent_settled,强制释放 busy");
865
+ state.cancelled = false;
866
+ state.busy = false;
867
+ state.ctx = null;
868
+ state.currentOpenid = null;
869
+ state.replyBuffer.reset();
870
+ drainQueue(pi, state);
871
+ }, CANCEL_SETTLE_GRACE_MS);
872
+ (timer as any).unref?.();
873
+ state.cancelTimer = timer;
874
+ }
875
+
876
+ /**
877
+ * 把一条任务交给 pi,并把它标记为「当前任务」。
878
+ * 不 await sendUserMessage:它返回的 promise 要等整轮跑完,await 会卡住调用方
879
+ * (agent_settled 里派发时更是不允许)。错误交给 onDispatchError 重试/回执。
880
+ */
881
+ function startJob(pi: ExtensionAPI, state: State, job: QueuedJob) {
882
+ state.currentOpenid = job.openid;
883
+ state.lastOpenid = job.openid;
884
+ state.pickerOwner = job.openid;
885
+ state.bridgeReply = (text: string) => {
886
+ void replyTo(state.api!, state, job.openid, text);
887
+ };
888
+ state.busy = true;
889
+ state.cancelled = false;
890
+ state.runStarted = false;
891
+ state.replyBuffer.reset();
892
+ clearCancelTimer(state);
893
+ log(
894
+ "[msg] 送入 pi:",
895
+ job.id > 0 ? `队列 #${job.id}` : "直接",
896
+ "openid=",
897
+ job.openid,
898
+ "text=",
899
+ job.text.slice(0, 60),
900
+ );
901
+ // 每会话第一条真正跑起来的 QQ 消息,由 before_agent_start 注入远程上下文说明。
902
+ try {
903
+ void Promise.resolve(
904
+ pi.sendUserMessage(job.text, { expandPromptTemplates: true, deliverAs: "followUp" }),
905
+ ).catch((error) => onDispatchError(pi, state, job, error));
906
+ } catch (error) {
907
+ onDispatchError(pi, state, job, error);
908
+ }
909
+ }
910
+
911
+ /** 派发失败:先重试(放回队首),多次失败就把错误回给发送者并继续队列 */
912
+ function onDispatchError(pi: ExtensionAPI, state: State, job: QueuedJob, error: unknown) {
913
+ const message = String((error as any)?.message ?? error).slice(0, 200);
914
+ log("[msg] 派发失败:", message);
915
+ if (state.runStarted) {
916
+ // run 已经启动了:这次报错属于这一轮自己的问题,收尾交给 agent_settled,不能重试
917
+ log("[msg] 本轮已启动过 agent,不重试,等 agent_settled 收尾");
918
+ return;
919
+ }
920
+ state.busy = false;
921
+ state.currentOpenid = null;
922
+ job.attempts += 1;
923
+ if (job.attempts < MAX_DISPATCH_ATTEMPTS) {
924
+ state.queue.unshift(job);
925
+ log("[msg]", DISPATCH_RETRY_MS, "ms 后重试(第", job.attempts, "次)");
926
+ const timer = setTimeout(() => drainQueue(pi, state), DISPATCH_RETRY_MS);
927
+ (timer as any).unref?.();
928
+ return;
929
+ }
930
+ const api = state.api;
931
+ if (api) void replyTo(api, state, job.openid, `这条消息执行失败:${message}`);
932
+ drainQueue(pi, state);
933
+ }
934
+
935
+ /**
936
+ * 同步一份「当前真正生效的模型」到 state:
937
+ * 1. 欠费后用户发新 key 时,要知道落到哪个 provider;
938
+ * 2. 探测新 key 需要 baseUrl,只有 ctx.model 里有。
939
+ */
940
+ function syncModelFromCtx(state: State, ctx: any): void {
941
+ const model = ctx?.model;
942
+ if (!model?.provider || !model?.id) return;
943
+ state.lastModel = {
944
+ provider: String(model.provider),
945
+ modelId: String(model.id),
946
+ baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : state.lastModel?.baseUrl,
947
+ };
948
+ }
949
+
950
+ /** 把检测用的 fetch 换成注入实现(单测用),默认 globalThis.fetch */
951
+ function probeFetch(state: State): typeof fetch {
952
+ return state.attachmentFetch ?? fetch;
953
+ }
954
+
955
+ /** 裸 key 只在欠费上下文里认(平时贴一串随机字符不应当被吞掉);带显式前缀的总认 */
956
+ export function shouldAcceptCredential(billing: boolean, intake: CredentialIntake | null): boolean {
957
+ return !!intake && (intake.explicit || billing);
958
+ }
959
+
960
+ /**
961
+ * 欠费/鉴权失败后,用户在 QQ 里直接发来的凭据:
962
+ * - 新 key → 写 auth.json(并真探测一次)
963
+ * - endpoint + key → 写 models.json + registerProvider(当前进程立即生效),
964
+ * 再把模型列表做成编号选择发回给用户。
965
+ * 明文 key 不进日志、不回显(只回 maskSecret 片段)。
966
+ */
967
+ async function handleCredentialIntake(
968
+ pi: ExtensionAPI,
969
+ state: State,
970
+ api: QQApi,
971
+ msg: QQMessage,
972
+ intake: CredentialIntake,
973
+ ): Promise<void> {
974
+ const reply = async (text: string) => {
975
+ await replyTo(api, state, msg.openid, text);
976
+ };
977
+ const target = state.lastModel;
978
+
979
+ if (intake.kind === "key") {
980
+ const provider = target?.provider || state.billing?.provider || "";
981
+ if (!provider) {
982
+ await reply(
983
+ "我还不知道当前用的是哪个 provider。先发 /model 看当前模型,或发「https://api.xxx.com/v1 <你的 key>」注册新服务。",
984
+ );
985
+ return;
986
+ }
987
+ log("[billing] 收到新 key", maskSecret(intake.key), "provider=", provider);
988
+ const written = await updateProviderKey(provider, intake.key);
989
+ if (!written.ok) {
990
+ await reply(`❌ 写入 auth.json 失败:${written.error ?? "未知错误"}`);
991
+ return;
992
+ }
993
+ const modelLabel =
994
+ target && target.provider === provider
995
+ ? `${provider}/${target.modelId}`
996
+ : state.billing
997
+ ? `${provider}/${state.billing.modelId}`
998
+ : provider;
999
+ const baseUrl = target?.baseUrl ?? null;
1000
+ let probe: ProbeResult | null = null;
1001
+ if (baseUrl) {
1002
+ probe = await probeEndpoint(baseUrl, intake.key, probeFetch(state));
1003
+ log("[billing] 新 key 探测:", baseUrl, probe.ok ? "ok" : `失败 ${probe.error ?? probe.status ?? "?"}`);
1004
+ }
1005
+ const lines = [`✅ 已更新 ${provider} 的 key(${maskSecret(intake.key)}),当前模型:${modelLabel}`];
1006
+ if (probe?.ok) {
1007
+ lines.push(`探测通过(${probe.url} 返回 ${probe.ids.length} 个模型):直接再发一次刚才的问题就行。`);
1008
+ } else if (probe) {
1009
+ lines.push(`已保存,但探测没通过:${probe.error ?? "未知原因"}。key 可能无效,或该服务没有 /models 接口。`);
1010
+ } else {
1011
+ lines.push("没记录到 baseUrl,无法自动探测;发一条普通消息就能验证是否生效。");
1012
+ }
1013
+ state.billing = null;
1014
+ await reply(lines.join("\n"));
1015
+ return;
1016
+ }
1017
+
1018
+ // ---- endpoint + key:注册新 provider,然后让用户选模型 ----
1019
+ const base = normalizeBaseUrl(intake.baseUrl);
1020
+ log("[billing] 收到 endpoint", base, "key=", maskSecret(intake.key), "显式模型=", intake.models.length);
1021
+ const probe = await probeEndpoint(intake.baseUrl, intake.key, probeFetch(state));
1022
+ const rawIds = intake.models.length > 0 ? intake.models : probe.ids;
1023
+ if (rawIds.length === 0) {
1024
+ const why = probe.ok
1025
+ ? "该服务的 /models 没有返回任何模型"
1026
+ : `探测失败:${probe.error ?? `HTTP ${probe.status ?? "?"}`}`;
1027
+ await reply(
1028
+ [
1029
+ `❌ ${base} 暂时用不了(${why})`,
1030
+ "如果它没有 /models 接口,可以带上模型 id 再发一次:",
1031
+ "<endpoint> <key> models 模型1,模型2",
1032
+ ].join("\n"),
1033
+ );
1034
+ return;
1035
+ }
1036
+ const providerId = providerIdForEndpoint(intake.baseUrl, existingProviderIds());
1037
+ // `/models` 通常只给 id,没有上下文窗/输出上限;先从 pi 已有目录里同 id 的模型借(
1038
+ // 比如 glm-5.3 在 zai / zai-coding-cn 下已带 contextWindow=1e6、maxTokens=131072)。
1039
+ const catalog = (state.registryCtx?.modelRegistry?.getAll?.() ?? []) as any[];
1040
+ const ids = filterChatModelIds(rawIds.slice(0, 40));
1041
+ if (ids.length === 0) {
1042
+ await reply(`❌ ${base} 只返回了非对话类模型(embedding/rerank 等),没法当聊天模型用。`);
1043
+ return;
1044
+ }
1045
+ const metas = ids.map((id) => borrowModelMeta(id, catalog, base));
1046
+ const models = ids.map((id, index) => providerModelConfig(id, metas[index]));
1047
+ const borrowed = metas.filter(Boolean).length;
1048
+ log("[billing] 模型元数据:共", ids.length, "个,借到", borrowed, "个");
1049
+ const written = await upsertEndpointProvider({
1050
+ providerId,
1051
+ baseUrl: intake.baseUrl,
1052
+ apiKey: intake.key,
1053
+ models,
1054
+ });
1055
+ if (!written.ok) {
1056
+ await reply(`❌ 写入 models.json 失败:${written.error ?? "未知错误"}`);
1057
+ return;
1058
+ }
1059
+ // models.json 负责重启后仍在;registerProvider 让当前进程立刻能用(无需 /reload)
1060
+ try {
1061
+ pi.registerProvider(providerId, {
1062
+ name: `QQ 接入 (${providerId})`,
1063
+ baseUrl: base,
1064
+ api: "openai-completions",
1065
+ apiKey: intake.key,
1066
+ models: models as any,
1067
+ });
1068
+ } catch (error) {
1069
+ log("[billing] registerProvider 失败(重启后仍可用):", String(error).slice(0, 160));
1070
+ }
1071
+ state.picker = {
1072
+ kind: "model",
1073
+ items: models.slice(0, MODEL_PICK_LIMIT).map((model, index) => ({
1074
+ id: `${providerId}/${model.id}`,
1075
+ label: modelLabel(model.id, metas[index]),
1076
+ })),
1077
+ openid: msg.openid,
1078
+ createdAt: Date.now(),
1079
+ };
1080
+ state.billing = null;
1081
+ log("[billing] 已注册 provider", providerId, "模型数", models.length, "→ 发编号选择");
1082
+ await reply(formatModelPicker(providerId, base, models, borrowed));
1083
+ }
1084
+
1085
+ /** 当前任务结束后,取队列里的下一条开始执行(空闲时才会真的派发) */
1086
+ function drainQueue(pi: ExtensionAPI, state: State) {
1087
+ if (state.busy || state.dispatching) return;
1088
+ const job = state.queue.shift();
1089
+ if (!job) return;
1090
+ state.dispatching = true;
1091
+ try {
1092
+ log("[queue] 出队 #", job.id, "剩余", state.queue.size, "条", "text=", job.text.slice(0, 60));
1093
+ const api = state.api;
1094
+ if (api && state.cfg?.queue.ack !== false) {
1095
+ const rest = state.queue.size > 0 ? `(后面还有 ${state.queue.size} 条)` : "";
1096
+ void replyTo(api, state, job.openid, `开始执行队列 #${job.id}${rest}`);
1097
+ }
1098
+ startJob(pi, state, job);
1099
+ } finally {
1100
+ state.dispatching = false;
1101
+ }
1102
+ }
1103
+
1104
+ /** 入队(忙碌时的统一入口):回执排队位置 */
1105
+ async function enqueueJob(
1106
+ api: QQApi,
1107
+ state: State,
1108
+ openid: string,
1109
+ text: string,
1110
+ ): Promise<boolean> {
1111
+ const result = state.queue.push(openid, text);
1112
+ if (!result.ok) {
1113
+ log("[queue] 队列已满(上限", state.queue.max, "),拒绝入队");
1114
+ if (state.queue.max === 0) {
1115
+ await replyTo(api, state, openid, "上一条消息还在处理中,请稍候…(队列已关闭:queue.max=0)");
1116
+ return false;
1117
+ }
1118
+ await replyTo(
1119
+ api,
1120
+ state,
1121
+ openid,
1122
+ `队列已满(上限 ${state.queue.max} 条),这条没排上。发 /queue 查看队列,或 /cancel 清空后重发。`,
1123
+ );
1124
+ return false;
1125
+ }
1126
+ log("[queue] 入队 #", result.job.id, "size=", state.queue.size, "ahead=", result.ahead);
1127
+ await replyTo(
1128
+ api,
1129
+ state,
1130
+ openid,
1131
+ `已排队 #${result.job.id}${result.ahead > 0 ? `(前面还有 ${result.ahead} 条)` : ""},当前任务结束后自动执行。发 /queue 查看队列,/cancel 可中止当前任务并清空队列。`,
1132
+ );
1133
+ return true;
1134
+ }
1135
+
1136
+ function formatQueue(state: State): string {
1137
+ const lines = [`当前任务:${state.busy ? "处理中(发 /cancel 可中断)" : "空闲"}`];
1138
+ const jobs = state.queue.list();
1139
+ if (jobs.length === 0) {
1140
+ lines.push("队列:空");
1141
+ return lines.join("\n");
1142
+ }
1143
+ lines.push(`队列:${jobs.length} 条(上限 ${state.queue.max})`);
1144
+ jobs.slice(0, 10).forEach((job, index) => {
1145
+ lines.push(` ${index + 1}. #${job.id} · ${agoLabel(job.enqueuedAt)} · ${previewText(job.text)}`);
1146
+ });
1147
+ if (jobs.length > 10) lines.push(` … 还有 ${jobs.length - 10} 条`);
1148
+ lines.push("发 /queue clear 清空,/queue drop <编号> 删除一条。");
1149
+ return lines.join("\n");
1150
+ }
1151
+
1152
+ /**
1153
+ * 用户发来取消指令:终止待审批、清空队列、abort 当前 run、丢弃半截回复,然后回执。
1154
+ * 取消本身永不入队(“取消除外”),立即生效。
1155
+ * (「取消自动同意」/「关闭自动同意」不属于取消指令,仍走审批分支。)
1156
+ */
1157
+ async function handleCancel(
1158
+ pi: ExtensionAPI,
1159
+ state: State,
1160
+ api: QQApi,
1161
+ openid: string,
1162
+ ): Promise<void> {
1163
+ const terminated = state.approver.terminateAll();
1164
+ const dropped = state.queue.clear();
1165
+ const running = state.busy || Boolean(state.ctx?.signal);
1166
+ if (!running) {
1167
+ const parts: string[] = [];
1168
+ if (terminated > 0) parts.push(`已终止 ${terminated} 个待审批请求`);
1169
+ if (dropped.length > 0) parts.push(`已清空队列(${dropped.length} 条)`);
1170
+ await replyTo(
1171
+ api,
1172
+ state,
1173
+ openid,
1174
+ parts.length > 0
1175
+ ? `${parts.join(",")},当前没有正在运行的任务。`
1176
+ : "当前没有正在运行的任务。",
1177
+ );
1178
+ return;
1179
+ }
1180
+
1181
+ stopProgressTimer(state);
1182
+ state.progress.stop();
1183
+ state.cancelled = true;
1184
+ state.replyBuffer.reset();
1185
+ state.currentOpenid = null; // 被中止的 run 不再往 QQ 发半截回复
1186
+ const ctx = state.ctx;
1187
+ state.ctx = null;
1188
+ try {
1189
+ ctx?.abort();
1190
+ log("[cancel] 已请求中止当前 run,终止待审批", terminated, "个,清空队列", dropped.length, "条");
1191
+ } catch (e) {
1192
+ log("[cancel] abort 失败:", String(e));
1193
+ }
1194
+ // busy 保持 true 直到 agent_settled:期间新消息会排队,不会和正在收尾的 run 撞车
1195
+ armCancelWatchdog(pi, state);
1196
+ const notice: string[] = [];
1197
+ if (terminated > 0) notice.push(`并终止了 ${terminated} 个待审批请求`);
1198
+ if (dropped.length > 0) notice.push(`队列中的 ${dropped.length} 条已清空`);
1199
+ await replyTo(
1200
+ api,
1201
+ state,
1202
+ openid,
1203
+ `已取消当前任务${notice.length > 0 ? `,${notice.join(",")}` : ""}。可以发送新消息继续。`,
1204
+ );
1205
+ }
1206
+
1207
+ // ---- QQ 消息处理 ----
1208
+ export async function handleMessage(
1209
+ pi: ExtensionAPI,
1210
+ state: State,
1211
+ api: QQApi,
1212
+ cfg: QQBotConfig,
1213
+ msg: QQMessage,
1214
+ ) {
1215
+ try {
1216
+ log("[msg] 收到消息 openid=", msg.openid, "content=", msg.content.slice(0, 100));
1217
+ if (cfg.allowlist.length > 0 && !cfg.allowlist.includes(msg.openid)) {
1218
+ log("[msg] 被 allowlist 拦截");
1219
+ return;
1220
+ }
1221
+ state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
1222
+
1223
+ // ---- 附件落盘:图片 / 语音 / 视频 / 文档 / 压缩包 / 普通文件 ----
1224
+ // 落盘后拼成「图片路径 <path>」这类标记行交给 pi;有附件时绕过 picker/指令/审批路由
1225
+ const attachments = msg.attachments ?? [];
1226
+ const hasAttachment = attachments.length > 0;
1227
+ let incoming = msg.content;
1228
+ if (hasAttachment) {
1229
+ const acc = attachmentConfig(cfg);
1230
+ if (!acc.enabled) {
1231
+ const names = attachments.map((a, i) => a.filename || `附件 ${i + 1}`).join("、");
1232
+ log("[attachment] 已被配置忽略", attachments.length, "个附件");
1233
+ await replyTo(
1234
+ api,
1235
+ state,
1236
+ msg.openid,
1237
+ `已忽略 ${attachments.length} 个附件(配置 attachments.enabled=false):${names}。本次只处理文字内容。`,
1238
+ );
1239
+ if (!msg.content.trim()) return; // 纯附件 + 已忽略:不给 pi 送空消息
1240
+ } else {
1241
+ const dir = acc.dir || defaultAttachmentDir(cfg.appId);
1242
+ const { saved, failed } = await saveIncoming(attachments, {
1243
+ dir,
1244
+ maxBytes: acc.maxBytes,
1245
+ maxPerMessage: acc.maxPerMessage,
1246
+ fetchFn: state.attachmentFetch ?? undefined,
1247
+ credential: state.getToken,
1248
+ prefix: msg.msgId,
1249
+ });
1250
+ log(
1251
+ "[attachment] 落盘",
1252
+ saved.length,
1253
+ "个,失败",
1254
+ failed.length,
1255
+ "个 →",
1256
+ saved.map((s) => `${s.kind}:${s.name}`).join(", ") || "(无)",
1257
+ );
1258
+ const ack = renderAttachmentAck(saved, failed);
1259
+ if (ack) await replyTo(api, state, msg.openid, ack);
1260
+ incoming = [msg.content.trim(), ...renderAttachmentLines(saved), ...renderAttachmentFailureLines(failed)]
1261
+ .filter(Boolean)
1262
+ .join("\n");
1263
+ if (!incoming) return; // 全部失败且无正文:已经回执过,不再送空消息
1264
+ }
1265
+ }
1266
+
1267
+ // 上一条最终答案没发出去(配额耗尽):先补发
1268
+ if (state.undelivered && state.undelivered.openid === msg.openid) {
1269
+ const pending = state.undelivered;
1270
+ state.undelivered = null;
1271
+ log("[reply] 补发上一条未送达的回复,长度", pending.text.length);
1272
+ const ok = await replyTo(api, state, msg.openid, `(上一条回复发送失败,补发如下)\n\n${pending.text}`);
1273
+ if (!ok) {
1274
+ state.undelivered = pending;
1275
+ log("[reply] 补发仍然失败,继续保留");
1276
+ }
1277
+ }
1278
+
1279
+ // 待选择的编号回复(/tree、/fork、/resume 之后);带附件时不当作编号选择
1280
+ if (!hasAttachment && state.picker) {
1281
+ if (pickerExpired(state.picker)) {
1282
+ state.picker = null;
1283
+ } else if (state.picker.openid === msg.openid) {
1284
+ const index = parsePickIndex(msg.content);
1285
+ if (index !== null) {
1286
+ if (state.busy) {
1287
+ await replyTo(api, state, msg.openid, "当前有任务在跑,先发 /cancel 取消,再回编号。");
1288
+ return;
1289
+ }
1290
+ if (index > state.picker.items.length) {
1291
+ await replyTo(api, state, msg.openid, `编号 ${index} 超出范围(1-${state.picker.items.length})。`);
1292
+ return;
1293
+ }
1294
+ state.pickerOwner = msg.openid;
1295
+ state.bridgeReply = (text: string) => {
1296
+ void replyTo(api, state, msg.openid, text);
1297
+ };
1298
+ log("[cmd] 选择编号", index, "kind=", state.picker.kind);
1299
+ pi.sendUserMessage(`/qq pick ${index}`, { expandPromptTemplates: true });
1300
+ return;
1301
+ }
1302
+ if (/^(取消|算了|cancel)$/i.test(msg.content.trim())) {
1303
+ state.picker = null;
1304
+ await replyTo(api, state, msg.openid, "已放弃选择。");
1305
+ return;
1306
+ }
1307
+ }
1308
+ }
1309
+
1310
+ // 欠费/鉴权失败后的「凭据接手」:用户直接发 key,或 endpoint + key。
1311
+ // 裸 key 只在欠费上下文里认(平时用户贴一串随机字符不该被吞掉);带显式前缀的总认。
1312
+ if (!hasAttachment) {
1313
+ const intake = parseCredentialMessage(incoming);
1314
+ if (shouldAcceptCredential(!!state.billing, intake) && intake) {
1315
+ log("[billing] 识别为凭据接手:", intake.kind, intake.explicit ? "(显式)" : "(裸 key)");
1316
+ await handleCredentialIntake(pi, state, api, msg, intake);
1317
+ return;
1318
+ }
1319
+ }
1320
+
1321
+ // 取消指令优先于审批解析:「取消」不该被当成审批动作;取消也永不入队
1322
+ if (isCancelIntent(msg.content)) {
1323
+ await handleCancel(pi, state, api, msg.openid);
1324
+ return;
1325
+ }
1326
+
1327
+ // 「更新自己」:在线升级 pi-qqbot(npm + pi update + 重载),优先于斜杠路由
1328
+ if (isUpdateSelfIntent(msg.content)) {
1329
+ if (!cfg.commands?.allowUpdate) {
1330
+ await replyTo(api, state, msg.openid, "自我更新在 QQ 侧被禁用(配置 commands.allowUpdate=true 可开启)。也可直接发 /update。");
1331
+ return;
1332
+ }
1333
+ if (isBusy(state)) {
1334
+ await replyTo(api, state, msg.openid, "当前有任务在跑,先发 /cancel 取消,再更新。");
1335
+ return;
1336
+ }
1337
+ await runSelfUpdate({
1338
+ currentVersion: installedVersion(),
1339
+ checkLatest: () => checkLatestVersion(shellRunner),
1340
+ applyUpdate: () => applyUpdate(shellRunner),
1341
+ versionAfterUpdate: () => installedVersion(),
1342
+ reply: (text) => replyTo(api, state, msg.openid, text),
1343
+ // 更新后走 pi 自己的 reload:sendUserMessage("/qq reload") 与 takeover 同路径
1344
+ reload: () => {
1345
+ try {
1346
+ void Promise.resolve(pi.sendUserMessage("/qq reload", { expandPromptTemplates: true })).catch((error) =>
1347
+ log("[update] 重载派发失败:", String((error as any)?.message ?? error)),
1348
+ );
1349
+ } catch (error) {
1350
+ log("[update] 重载派发异常:", String(error));
1351
+ }
1352
+ },
1353
+ });
1354
+ return;
1355
+ }
1356
+
1357
+ // 斜杠命令路由:pi 扩展命令 → 可接管的内置命令 → 仅主机可用的内置命令 → 普通文字
1358
+ state.lastOpenid = msg.openid;
1359
+ state.pickerOwner = msg.openid;
1360
+ state.bridgeReply = (text: string) => {
1361
+ void replyTo(api, state, msg.openid, text);
1362
+ };
1363
+ const routed = hasAttachment
1364
+ ? ("text" as const)
1365
+ : await routeSlash(
1366
+ {
1367
+ listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string; source?: string }[],
1368
+ dispatch: (text, options) => {
1369
+ // 提示模板 / /skill: 会开启一轮 agent run:和普通对话一样串行到队列
1370
+ if (options?.runsTurn) {
1371
+ if (!isBusy(state)) {
1372
+ startJob(pi, state, state.queue.mint(msg.openid, text));
1373
+ return "dispatched";
1374
+ }
1375
+ const queued = state.queue.push(msg.openid, text);
1376
+ if (!queued.ok) {
1377
+ log("[queue] 队列已满,命令未排队:", text.slice(0, 60));
1378
+ return "rejected";
1379
+ }
1380
+ log("[queue] 命令入队 #", queued.job.id, text.slice(0, 60));
1381
+ return "queued";
1382
+ }
1383
+ // 扩展命令 / 桥接命令(/qq …)不开 run,pi 允许流式期间执行,直接派发
1384
+ void Promise.resolve(pi.sendUserMessage(text, { expandPromptTemplates: true })).catch(
1385
+ (e) => log("[cmd] 派发失败:", String(e)),
1386
+ );
1387
+ return "dispatched";
1388
+ },
1389
+ reply: (text: string) => replyTo(api, state, msg.openid, text),
1390
+ busy: isBusy(state),
1391
+ cfg: cfg.commands,
1392
+ log,
1393
+ },
1394
+ msg.content,
1395
+ );
1396
+ if (routed !== "text") return;
1397
+
1398
+ // 远程审批回复优先(带附件时不当作审批动作)
1399
+ const parsed = hasAttachment ? null : state.approver.parseReply(msg.content.trim());
1400
+ if (parsed) {
1401
+ if (parsed.decision === "terminate") {
1402
+ const n = state.approver.terminateAll();
1403
+ state.approver.setAutoAllow(false);
1404
+ await replyTo(api, state, msg.openid, `已终止 ${n} 个待审批请求,自动同意已关闭`);
1405
+ return;
1406
+ }
1407
+ if (parsed.decision === "always") {
1408
+ // 「以后都同意」= 本会话内全部放行:立刻开启开关并放行当前所有待审批
1409
+ state.approver.setAutoAllow(true);
1410
+ const n = state.approver.allowAll();
1411
+ log("[approve] 开启会话级自动同意,放行", n, "个待审批");
1412
+ await replyTo(
1413
+ api,
1414
+ state,
1415
+ msg.openid,
1416
+ [
1417
+ `已开启自动同意:本会话内需审批的工具全部放行${n > 0 ? `,已放行 ${n} 个待审批请求` : ""}。`,
1418
+ "回复「取消自动同意」或 /new 恢复逐条审批。",
1419
+ ].join("\n"),
1420
+ );
1421
+ return;
1422
+ }
1423
+ if (parsed.decision === "revoke") {
1424
+ const was = state.approver.autoAllowed;
1425
+ state.approver.setAutoAllow(false);
1426
+ log("[approve] 关闭自动同意 was=", was);
1427
+ await replyTo(
1428
+ api,
1429
+ state,
1430
+ msg.openid,
1431
+ was ? "已关闭自动同意,恢复逐条审批。" : "当前没有开启自动同意,仍需逐条审批。",
1432
+ );
1433
+ return;
1434
+ }
1435
+ // allow / block:没写编号时作用于最早的一条待审批
1436
+ const seq = parsed.seq ?? state.approver.oldestSeq();
1437
+ if (seq !== undefined) {
1438
+ const ok = state.approver.confirm(seq, parsed.decision);
1439
+ const text = !ok
1440
+ ? `#${seq} 不存在或已超时`
1441
+ : parsed.decision === "allow"
1442
+ ? `已批准 #${seq}`
1443
+ : `已拒绝 #${seq}`;
1444
+ await replyTo(api, state, msg.openid, text);
1445
+ return;
1446
+ }
1447
+ // 没有待审批:这句「同意/拒绝」当普通消息交给 pi,继续往下走
1448
+ log("[msg] 审批类文本但无待审批,按普通消息处理");
1449
+ }
1450
+
1451
+ // 内置指令(带附件时绕过:附件消息一律当普通内容交给 pi)
1452
+ const cmd = hasAttachment ? null : parseCommand(msg.content);
1453
+ if (cmd?.name === "help") {
1454
+ await replyTo(
1455
+ api,
1456
+ state,
1457
+ msg.openid,
1458
+ [
1459
+ "pi-qqbot 指令:",
1460
+ "/progress — 查看进度;/progress off 暂停自动汇报、/progress on 恢复(本会话有效)",
1461
+ "/status — 查看状态(当前 qqbot / 忙闲 / 来源 / 队列 / 自动同意 / 待审批 / 自动汇报)",
1462
+ "/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
1463
+ "/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
1464
+ "/cancel(或「取消」「停止」「停」)— 中断正在运行的任务并清空队列",
1465
+ "/sendfile <路径> — 把本机文件发到你的 QQ(png/jpg/mp4/silk 直显,其余为文件卡片;外发需审批)",
1466
+ "只关进度推送:/progress off(或「别汇报了」「闭嘴」);「停」是中断任务,不是静音",
1467
+ "/help — 帮助",
1468
+ "pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
1469
+ "忙碌时发来的普通消息(含模板/skill 命令)会排队,当前任务结束后自动执行。",
1470
+ "多 bot:配置了多个 qqbot 时,多个 pi 进程会按列表顺序各自接入一个,抢不到的待命重扫。",
1471
+ "其余文本交给 pi 当作对话处理。",
1472
+ ].join("\n"),
1473
+ );
1474
+ return;
1475
+ }
1476
+ if (cmd?.name === "status") {
1477
+ const lines = [
1478
+ `bot: ${state.account ? `${state.account.name} (…${state.account.appId.slice(-4)})` : "未接入"} · cwd: ${process.cwd()}`,
1479
+ `状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
1480
+ `来源: ${describeRunOrigin(state)}`,
1481
+ `队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
1482
+ `自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
1483
+ `待审批: ${state.approver.pendingCount}`,
1484
+ `自动汇报: ${describeProgressMode(state)}`,
1485
+ ];
1486
+ if (state.busy) {
1487
+ const snapshot = state.progress.summary();
1488
+ if (snapshot) lines.push("", snapshot);
1489
+ // 用户主动问询:恢复自动推送(手动暂停的不算)
1490
+ if (!state.progressMuted) state.progressPaused = false;
1491
+ }
1492
+ await replyTo(api, state, msg.openid, lines.join("\n"));
1493
+ return;
1494
+ }
1495
+ if (cmd?.name === "progress") {
1496
+ const sub = (cmd.args ?? "").trim().toLowerCase();
1497
+ if (MUTE_ARGS.has(sub)) {
1498
+ const was = state.progressMuted;
1499
+ state.progressMuted = true;
1500
+ state.progressPaused = false;
1501
+ log("[progress] 用户暂停自动进度推送(本会话有效)", was ? "(已经是暂停状态)" : "");
1502
+ await replyTo(
1503
+ api,
1504
+ state,
1505
+ msg.openid,
1506
+ was
1507
+ ? "自动汇报本来就已暂停。发 /progress on(或「恢复汇报」)可以恢复。"
1508
+ : "已暂停自动进度汇报(本会话有效,换会话或重启后恢复默认开启)。需要时发 /progress 仍可查看一次,发 /progress on 恢复。",
1509
+ );
1510
+ return;
1511
+ }
1512
+ if (UNMUTE_ARGS.has(sub)) {
1513
+ const was = state.progressMuted;
1514
+ state.progressMuted = false;
1515
+ state.progressPaused = false;
1516
+ log("[progress] 用户恢复自动进度推送");
1517
+ await replyTo(
1518
+ api,
1519
+ state,
1520
+ msg.openid,
1521
+ was
1522
+ ? `已恢复自动进度汇报${state.cfg?.progress?.enabled === false ? "(但配置 progress.enabled=false,仍不会推送)" : ""}。`
1523
+ : "自动汇报本来就是开启的。",
1524
+ );
1525
+ return;
1526
+ }
1527
+ if (sub) {
1528
+ await replyTo(
1529
+ api,
1530
+ state,
1531
+ msg.openid,
1532
+ "用法: /progress(看一次)| /progress off(暂停自动汇报)| /progress on(恢复)。也可以说「暂停汇报」「恢复汇报」。",
1533
+ );
1534
+ return;
1535
+ }
1536
+ const snapshot = state.progress.summary();
1537
+ if (!state.progressMuted) state.progressPaused = false;
1538
+ const body = snapshot || (state.busy ? "当前任务刚启动,还没有可汇报的进度。" : "当前没有正在运行的任务。");
1539
+ await replyTo(
1540
+ api,
1541
+ state,
1542
+ msg.openid,
1543
+ state.progressMuted ? `${body}\n(自动汇报已暂停,发 /progress on 恢复)` : body,
1544
+ );
1545
+ return;
1546
+ }
1547
+ if (cmd?.name === "queue") {
1548
+ await handleQueueCommand(api, state, msg.openid, cmd.args);
1549
+ return;
1550
+ }
1551
+ if (cmd?.name === "reset") {
1552
+ state.replyBuffer.reset();
1553
+ state.approver.reset();
1554
+ await replyTo(api, state, msg.openid, "已重置(回复缓冲/审批记忆/自动同意)。要开新会话请发 /new。");
1555
+ return;
1556
+ }
1557
+
1558
+ // 忙检查:单会话串行处理——忙碌时入队,当前任务结束后自动继续
1559
+ if (isBusy(state)) {
1560
+ // 忙碌时问「进度」不该排队,直接回当前进度并恢复自动推送
1561
+ if (!hasAttachment && RE_PROGRESS_QUERY.test(msg.content.trim())) {
1562
+ const snapshot = state.progress.summary();
1563
+ if (!state.progressMuted) state.progressPaused = false;
1564
+ await replyTo(api, state, msg.openid, snapshot || "任务刚启动,还没有可汇报的进度。");
1565
+ return;
1566
+ }
1567
+ await enqueueJob(api, state, msg.openid, incoming);
1568
+ return;
1569
+ }
1570
+
1571
+ // ack
1572
+ await replyTo(api, state, msg.openid, "收到,正在处理");
1573
+
1574
+ startJob(pi, state, state.queue.mint(msg.openid, incoming));
1575
+ } catch (e) {
1576
+ log("[msg] 处理失败:", String(e));
1577
+ await replyTo(api, state, msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
1578
+ }
1579
+ }
1580
+
1581
+ async function replyTo(
1582
+ api: QQApi,
1583
+ state: State,
1584
+ openid: string,
1585
+ text: string,
1586
+ format: "text" | "markdown" = "text",
1587
+ ): Promise<boolean> {
1588
+ // 本进程刚接入 / 刚接管这个 qqbot:第一条回执带上身份与工作目录
1589
+ if (state.claimNote) {
1590
+ const note = state.claimNote;
1591
+ state.claimNote = null;
1592
+ text = `${note}\n${text}`;
1593
+ }
1594
+ const ref = state.passiveRefs.get(openid);
1595
+ let delivered = true;
1596
+ for (const chunk of splitText(text)) {
1597
+ const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
1598
+ try {
1599
+ await api.sendC2C(openid, chunk, usePassive ? { msgId: ref.msgId, format } : { format });
1600
+ log("[reply] 已发送 (passive=", usePassive, "):", chunk.slice(0, 50));
1601
+ } catch (e) {
1602
+ delivered = false;
1603
+ log("[reply] 发送失败:", String(e));
1604
+ }
1605
+ }
1606
+ return delivered;
1607
+ }
1608
+
1609
+ /** 用户问进度的表达(忙碌时用来区分「排队」和「问进度」) */
1610
+ // 忙碌时判断「这是在问进度」:只认句首的明确表达,避免把「帮我加个进度条」这类任务误当问询
1611
+ const RE_PROGRESS_QUERY = new RegExp(
1612
+ "^(?:" +
1613
+ "\\/(?:progress|p|status)" +
1614
+ "|(?:现在|当前|目前)?\\s*(?:的)?\\s*(?:progress|进度|进展)" +
1615
+ "|跑到哪|怎么样了|到哪了|还在跑吗|好慢" +
1616
+ ")",
1617
+ "i",
1618
+ );
1619
+
1620
+ function startProgressTimer(pi: ExtensionAPI, state: State) {
1621
+ stopProgressTimer(state);
1622
+ const cfg = state.cfg?.progress;
1623
+ if (!cfg?.enabled) return;
1624
+ const interval = Number.isFinite(cfg.intervalMs) && cfg.intervalMs >= 50 ? cfg.intervalMs : 15000;
1625
+ const timer = setInterval(() => {
1626
+ void pushProgress(state, "tick");
1627
+ }, interval);
1628
+ (timer as any).unref?.();
1629
+ state.progressTimer = timer;
1630
+ log("[progress] 自动进度推送已启动,间隔", interval, "ms");
1631
+ }
1632
+
1633
+ function stopProgressTimer(state: State) {
1634
+ if (state.progressTimer) {
1635
+ clearInterval(state.progressTimer);
1636
+ state.progressTimer = null;
1637
+ }
1638
+ }
1639
+
1640
+ /** 推一条进度摘要;失败就暂停自动推送(用户问一次即恢复);手动暂停(/progress off)管到底 */
1641
+ async function pushProgress(state: State, reason: "tick" | "ask"): Promise<boolean> {
1642
+ if (reason === "tick" && (state.progressPaused || state.progressMuted)) return false;
1643
+ // 有审批在等用户回复:暂停自动推送,否则每 15s 一条进度会把审批提示顶出屏幕
1644
+ // (手动问进度不受影响:用户自己要看的)
1645
+ if (reason === "tick" && state.approver.pendingCount > 0) {
1646
+ if (!state.progressHeldForApproval) {
1647
+ state.progressHeldForApproval = true;
1648
+ log("[progress] 有", state.approver.pendingCount, "个待审批,暂缓自动进度推送");
1649
+ }
1650
+ return false;
1651
+ }
1652
+ const api = state.api;
1653
+ const openid = state.currentOpenid ?? state.lastOpenid;
1654
+ if (!api || !openid || !state.busy || !state.progress.running) return false;
1655
+ if (state.progressHeldForApproval) {
1656
+ state.progressHeldForApproval = false;
1657
+ log("[progress] 审批已处理,恢复自动进度推送");
1658
+ }
1659
+ const text = state.progress.summary();
1660
+ if (!text) return false;
1661
+ const ok = await replyTo(api, state, openid, text);
1662
+ if (!ok) {
1663
+ state.progressPaused = true;
1664
+ log("[progress] 推送失败(多半是配额),暂停自动进度推送,直到用户询问");
1665
+ }
1666
+ return ok;
1667
+ }
1668
+
1669
+ /** /status 用:最近一轮对话是谁发起的 */
1670
+ function describeRunOrigin(state: State): string {
1671
+ if (state.lastRunOrigin === "qq") return "QQ(远程)";
1672
+ if (state.lastRunOrigin === "local") return "本机(TUI)";
1673
+ return "尚未开始";
1674
+ }
1675
+
1676
+ /** 自动汇报当前状态的人话描述(/status 用) */
1677
+ function describeProgressMode(state: State): string {
1678
+ const cfg = state.cfg?.progress;
1679
+ if (state.progressMuted) return "已暂停(本会话,发 /progress on 恢复)";
1680
+ if (cfg?.enabled === false) return "未启用(配置 progress.enabled=false)";
1681
+ if (state.progressPaused) return "推送失败已静默,发 /progress 恢复";
1682
+ const ms = Number.isFinite(cfg?.intervalMs) && (cfg?.intervalMs ?? 0) >= 50 ? cfg!.intervalMs : 15000;
1683
+ const base = `开启(每 ${Math.round(ms / 1000)}s)`;
1684
+ return state.approver.pendingCount > 0 ? `${base},等审批期间暂缓` : base;
1685
+ }
1686
+
1687
+ /** /progress 的子命令:暂停 / 恢复自动汇报 */
1688
+ const MUTE_ARGS = new Set([
1689
+ "off",
1690
+ "pause",
1691
+ "stop",
1692
+ "mute",
1693
+ "关",
1694
+ "关闭",
1695
+ "关掉",
1696
+ "停",
1697
+ "停止",
1698
+ "暂停",
1699
+ "暂停汇报",
1700
+ "停止汇报",
1701
+ ]);
1702
+ const UNMUTE_ARGS = new Set([
1703
+ "on",
1704
+ "resume",
1705
+ "start",
1706
+ "unmute",
1707
+ "开",
1708
+ "开启",
1709
+ "打开",
1710
+ "恢复",
1711
+ "继续",
1712
+ "恢复汇报",
1713
+ "开启汇报",
1714
+ "继续汇报",
1715
+ ]);
1716
+
1717
+ /**
1718
+ * 不带斜杠的口令(整句匹配,避免误伤正常聊天)。
1719
+ *
1720
+ * 三类:
1721
+ * 1. 问进度:「汇报一下当前进度」「看一下进度」「进度如何」→ 回一次摘要
1722
+ * 2. 静音:「暂停汇报进度」「别汇报了」「别说了」「闭嘴」「停」→ 暂停自动汇报
1723
+ * 3. 恢复:「恢复汇报」「接着说」→ 重新打开自动汇报
1724
+ *
1725
+ * 注意:「停止/取消/中止」是**中断任务**(RE_CANCEL,更早判定),不是静音;
1726
+ * 「停止汇报」才走静音。
1727
+ */
1728
+ const REPORT_LEAD = "(?:请|帮我|给我|麻烦|暂时|先)?\\s*";
1729
+ /** 汇报名词的两种语序(进度汇报 / 汇报进度) */
1730
+ const REPORT_NOUN =
1731
+ "(?:(?:进度|进展)(?:汇报|报告|推送|播报|上报)?|(?:汇报|报告|推送|播报|上报)(?:进度|进展)?)";
1732
+ const REPORT_TAIL = "\\s*(?:了|吧|哦|啊|呀|嘛|啦|呗)*";
1733
+
1734
+ /** 问进度(含「汇报一下…」这类祈使句) */
1735
+ const BARE_PROGRESS_RE = new RegExp(
1736
+ "^" +
1737
+ REPORT_LEAD +
1738
+ "(?:(?:汇报|报告|报|说说|讲讲|看|看看|查|查查|查看|显示|展示)(?:一下|下|个|一眼)?\\s*)?" +
1739
+ "(?:当前|现在|目前)?\\s*(?:的)?\\s*(?:progress|进度|进展)" +
1740
+ "(?:\\s*(?:如何|怎么样|怎样|呢|啦|了|吗))?" +
1741
+ "(?:\\s+([\\s\\S]*))?$",
1742
+ "i",
1743
+ );
1744
+
1745
+ /** 静音:带「汇报」名词的 */
1746
+ const RE_REPORTS_OFF = new RegExp(
1747
+ "^" +
1748
+ REPORT_LEAD +
1749
+ "(?:暂停|停止|停下|关闭|关掉|别再|别|不要|不用|甭|闭嘴|安静|打住)\\s*" +
1750
+ "(?:自动|定时|定期)?\\s*(?:的)?\\s*" +
1751
+ REPORT_NOUN +
1752
+ REPORT_TAIL +
1753
+ "$",
1754
+ );
1755
+ /** 静音:不提「汇报」的纯闭嘴类(停/停下 不在此列——它们属于中断任务) */
1756
+ const RE_SILENCE = new RegExp(
1757
+ "^" +
1758
+ REPORT_LEAD +
1759
+ "(?:闭嘴|安静|安静点|安静下|别说了|别讲了|别吵了?|别推了|别发了|别报了|打住)" +
1760
+ REPORT_TAIL +
1761
+ "$",
1762
+ );
1763
+ /** 恢复:说清楚要恢复汇报,或者「接着说」 */
1764
+ const RE_REPORTS_ON = new RegExp(
1765
+ "^" +
1766
+ REPORT_LEAD +
1767
+ "(?:恢复|继续|开启|打开|开始|接着说|接着说吧|说下去|接着报)\\s*" +
1768
+ "(?:自动|定时|定期)?\\s*(?:的)?\\s*" +
1769
+ REPORT_NOUN +
1770
+ REPORT_TAIL +
1771
+ "$",
1772
+ );
1773
+ const RE_RESUME_TALK = new RegExp("^" + REPORT_LEAD + "(?:接着说|说下去|接着报)" + REPORT_TAIL + "$");
1774
+
1775
+ /** 不带斜杠的进度问询:「进度」「当前进度」「汇报一下当前进度」(可带子命令) */
1776
+
1777
+ function parseCommand(text: string): { name: "reset" | "status" | "help" | "queue" | "progress"; args: string } | null {
1778
+ // 注意:/new 现在属于 pi 的内置命令(由 slash 路由接管,真的开新会话),
1779
+ // 本地的「清缓冲/清审批记忆」改名为 /reset。
1780
+ // 斜杠形式:全部内置指令;不带斜杠时只接受明确表达「进度」的词,避免把聊天里的 "p" / "status" 误判成命令
1781
+ const t = text.trim();
1782
+ const slash = /^\/(reset|status|help|queue|q|progress|p)(?:\s+([\s\S]*))?$/i.exec(t);
1783
+ if (slash) {
1784
+ const raw = slash[1].toLowerCase();
1785
+ const name = raw === "q" ? "queue" : raw === "p" ? "progress" : raw;
1786
+ return {
1787
+ name: name as "reset" | "status" | "help" | "queue" | "progress",
1788
+ args: (slash[2] ?? "").trim(),
1789
+ };
1790
+ }
1791
+ // 裸文本:先认「问进度」,再认「静音/恢复」
1792
+ const bare = BARE_PROGRESS_RE.exec(t);
1793
+ if (bare) return { name: "progress", args: (bare[1] ?? "").trim() };
1794
+ if (RE_REPORTS_OFF.test(t) || RE_SILENCE.test(t)) return { name: "progress", args: "off" };
1795
+ if (RE_REPORTS_ON.test(t) || RE_RESUME_TALK.test(t)) return { name: "progress", args: "on" };
1796
+ return null;
1797
+ }
1798
+
1799
+ /** /queue [clear|drop <编号>] */
1800
+ async function handleQueueCommand(api: QQApi, state: State, openid: string, args: string) {
1801
+ const parts = args.split(/\s+/).filter(Boolean);
1802
+ const sub = parts[0]?.toLowerCase() ?? "";
1803
+
1804
+ if (!sub) {
1805
+ await replyTo(api, state, openid, formatQueue(state));
1806
+ return;
1807
+ }
1808
+ if (/^(clear|reset|清空)$/i.test(sub)) {
1809
+ const dropped = state.queue.clear();
1810
+ await replyTo(
1811
+ api,
1812
+ state,
1813
+ openid,
1814
+ dropped.length > 0 ? `已清空队列(${dropped.length} 条)。` : "队列本来就是空的。",
1815
+ );
1816
+ return;
1817
+ }
1818
+ if (/^(drop|del|rm|删除|删|去掉)$/i.test(sub)) {
1819
+ const id = Number.parseInt(parts[1] ?? "", 10);
1820
+ if (!Number.isFinite(id)) {
1821
+ await replyTo(api, state, openid, "用法:/queue drop <编号>(编号见 /queue)");
1822
+ return;
1823
+ }
1824
+ const removed = state.queue.remove(id);
1825
+ await replyTo(
1826
+ api,
1827
+ state,
1828
+ openid,
1829
+ removed ? `已从队列删除 #${id}:${previewText(removed.text)}` : `队列里没有 #${id},发 /queue 查看。`,
1830
+ );
1831
+ return;
1832
+ }
1833
+ await replyTo(api, state, openid, "用法:/queue 查看 / /queue clear 清空 / /queue drop <编号> 删一条");
1834
+ }
1835
+
1836
+ /** 从 tool_call 事件提取可读摘要,让用户在 QQ 里看清要执行什么 */
1837
+ function summarizeTool(event: any): string {
1838
+ const input = event.input ?? {};
1839
+ // 审批提示会发到 QQ:先脱敏再截断
1840
+ const clip = (value: unknown) => redactSecrets(String(value ?? "")).slice(0, 200);
1841
+ switch (event.toolName) {
1842
+ case "bash":
1843
+ case "powershell":
1844
+ return `${event.toolName}: ${clip(input.command)}`;
1845
+ case "write":
1846
+ case "edit":
1847
+ case "read":
1848
+ return `${event.toolName}: ${clip(input.path ?? input.file ?? input.filePath)}`;
1849
+ case "grep":
1850
+ return `${event.toolName}: ${clip(input.pattern)}`;
1851
+ case "qq_send_file":
1852
+ return `发送文件到 QQ: ${clip(input.path)}${input.name ? `(显示名 ${clip(input.name)})` : ""}`;
1853
+ default:
1854
+ return `${event.toolName}: ${clip(JSON.stringify(input))}`;
1855
+ }
1856
+ }