@zxh126/pi-qqbot 1.4.0 → 2.0.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.
@@ -0,0 +1,88 @@
1
+ # 多 QQ bot(2.0)
2
+
3
+ 一份配置里可以写多个 QQ bot;同一台机器上每个 pi 进程启动时,按配置顺序找**第一个没上线的**
4
+ bot 接入,抢不到的进程待命并每 5 秒重扫。进程退出(或崩溃导致的锁心跳超时)后,该 bot 下线,
5
+ 待命进程接管。
6
+
7
+ ## 配置
8
+
9
+ ### 多 bot(v2)
10
+
11
+ ```jsonc
12
+ // ~/.pi/agent/pi-qqbot.json
13
+ {
14
+ "qqbots": [
15
+ { "name": "work", "appId": "1903340143", "appSecret": "……", "sandbox": true },
16
+ { "name": "home", "appId": "1020123456", "appSecret": "……", "sandbox": false }
17
+ ],
18
+
19
+ // 以下是全局默认值,qqbots 里可以逐键覆盖
20
+ "queue": { "max": 20, "ack": true },
21
+ "progress": { "enabled": true, "intervalMs": 15000 }
22
+ }
23
+ ```
24
+
25
+ - **顺序就是抢占顺序**:先起的进程拿 `work`,第二个进程拿 `home`,第三个进程抢不到就待命。
26
+ - `name` 缺省为 `bot-<appId 后 6 位>`;日志、`/status`、接管提示都用它。
27
+ - `name` 或 `appId` 重复、凭据缺失 → 该条被跳过并写日志,其余照常可用(不会双连接同一个 bot)。
28
+ - 同时写顶层 `appId` 和 `qqbots` → 报错,插件不接入(歧义不猜)。
29
+
30
+ ### 单 bot(v1,原格式,无需改动)
31
+
32
+ ```json
33
+ { "appId": "1903340143", "appSecret": "……", "sandbox": true }
34
+ ```
35
+
36
+ 等价于只有一个名叫 `default` 的 bot,行为与 1.x 完全一致。凭据也仍可来自
37
+ `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 环境变量(只用于单 bot 场景)。
38
+
39
+ ## 实例身份
40
+
41
+ | 场景 | 表现 |
42
+ |---|---|
43
+ | 首次接入 | 日志 `[lock] 接入 qqbot "work" (1903340143) · cwd=E:\Projects\foo · pid=1234`;QQ 首条回执前加「(已接入 qqbot「work」)」;`/status` 显示 `bot: work (…0143) · cwd: …` |
44
+ | 换进程接管 | 日志记录新旧 pid 与新旧 cwd;QQ 首条回执前加「(qqbot「work」已由新实例接管 · 当前工作目录 E:\Projects\foo)」 |
45
+ | 全部在线 | 日志 `[lock] 无可接入的 qqbot(…),本进程待命,5s 后重扫`;本进程不连 QQ,pi 本身照常用 |
46
+ | 会话重建(`/reload`、`/new`、`/fork`、`/resume`) | 释放锁**之前**先写 20 秒预约窗口,其他进程不会趁机抢走;新 runtime 用自己的 pid 认回同一个 bot,静默不打扰 |
47
+ | 凭据无效 | 抢到锁后先换一次 `access_token`;失败就**放锁跳过该 bot**(不占槽位),继续试下一个。日志:`[lock] qqbot "x" 凭据不可用:code=100016 invalid appid or secret → 已放锁并跳过该 bot`。全部不可用时不空转重扫 |
48
+
49
+ `/status` 一眼确认:
50
+
51
+ ```
52
+ bot: work (…0143) · cwd: E:\Projects\foo
53
+ 状态: 空闲
54
+ ```
55
+
56
+ > **工作目录与 bot 无绑定。** 分配只看「配置顺序 + 谁在线」,跟 cwd 无关:
57
+ > 同一台机器上可以在不同的项目目录各开一个 pi,也可以在一个大工作区(里面好几个项目)里
58
+ > 同时开几个 pi——后者照样一人接一个 bot,互不干扰。cwd 只用于日志、接管提示和 `/status` 展示,
59
+ > 让你能分辨「现在是哪个实例在管这个 bot」。
60
+
61
+ ## 可选:钉住某个 bot
62
+
63
+ 自动抢占是默认行为。调试或需要「这个目录固定用某个 bot」时,用环境变量钉住:
64
+
65
+ ```powershell
66
+ $env:PI_QQBOT_ACCOUNT = "work"; pi
67
+ ```
68
+
69
+ 钉选时只尝试该账号(可写 name 或 appId);名字不存在则**不接入**并写日志(不会乱接别的 bot)。
70
+
71
+ ## 能/不能
72
+
73
+ - **能**:同一台机器上开 N 个 pi,各接一个 bot,互不串话;目录随意(同一目录开多个也成立,目录只是 pi 的工作区);某个 pi 退出后由待命进程接管。
74
+ - **能**:一个 bot 同一时刻只有一个进程连着(锁 + 心跳 + 30 秒僵尸锁回收)。
75
+ - **不能**:一个 pi 进程同时服务多个 bot(扩展的 session 是单租户模型)。要多路并行就多开 pi 进程。
76
+
77
+ ## 日志
78
+
79
+ 多 bot 场景下日志按账号分文件:`~/.pi/agent/pi-qqbot-<name>.log`(单 bot 旧格式仍写
80
+ `pi-qqbot.log`)。身份记录与锁在 `~/.pi/agent/pi-qqbot-<appId>.lock` / `…owner.json`。
81
+
82
+ ## 排查:某个 bot 一直不上线
83
+
84
+ 1. 看日志:`凭据不可用:code=100016 invalid appid or secret` → appId/secret 写错了
85
+ (可用 `POST https://api.bot.qq.com/app/getAppAccessToken` 自己验一下);
86
+ 2. 看日志:`本轮无可接入的 qqbot(x:在线)` → 这个 bot 已被别的 pi 进程连着;
87
+ 3. 看日志:`被 pid 1234 重载中` → 对方正在 `/reload`,等几秒即可;
88
+ 4. 什么都没打 → 确认配置能解析(`loadAccounts()` 报错会写 `[setup] 配置问题:`)。
package/index.ts CHANGED
@@ -1,17 +1,16 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { loadConfig, QQBotConfig } from "./config";
2
+ import { loadAccounts, pinnedAccountName, type QQBotAccount, type QQBotConfig } from "./config";
3
3
  import {
4
4
  REST_BASE_PROD,
5
5
  REST_BASE_SANDBOX,
6
6
  INTENT_GROUP_AND_C2C,
7
7
  PASSIVE_WINDOW_MS,
8
8
  APPROVAL_TIMEOUT_MS,
9
- LOCK_PATH,
10
9
  DISPATCH_RETRY_MS,
11
10
  CANCEL_SETTLE_GRACE_MS,
12
11
  } from "./constants";
13
- import { log } from "./logger";
14
- import { tryAcquireLock, startLockRetry } from "./lock";
12
+ import { log, configureLog } from "./logger";
13
+ import { claimAny, startClaimRetry, reserveBeforeRelease, readOwner, type Claim } from "./lock";
15
14
  import { AuthManager } from "./qq/auth";
16
15
  import { QQApi } from "./qq/api";
17
16
  import { createGatewayUrlFetcher, QQGateway, QQMessage } from "./qq/gateway";
@@ -25,6 +24,12 @@ import { splitText } from "./util/chunk";
25
24
 
26
25
  interface State {
27
26
  cfg: QQBotConfig | null;
27
+ /** 本进程当前服务的 QQ bot(自动抢占的结果);未接入则为 null */
28
+ account: { name: string; appId: string } | null;
29
+ /** 本进程的候选 bot(按配置顺序),待命时用它重扫 */
30
+ candidates: { name: string; appId: string }[];
31
+ /** 首条回执前缀(接入成功 / 换进程接管时告知用户当前 bot 与工作目录) */
32
+ claimNote: string | null;
28
33
  api: QQApi | null;
29
34
  approver: Approver;
30
35
  gateway: QQGateway | null;
@@ -60,6 +65,8 @@ interface State {
60
65
  progressTimer: ReturnType<typeof setInterval> | null;
61
66
  /** 推送失败后暂停自动推送,直到用户主动询问 */
62
67
  progressPaused: boolean;
68
+ /** 用户手动暂停自动推送(本会话有效:跨 run 保留,/progress on 恢复) */
69
+ progressMuted: boolean;
63
70
  /** 最终答案发送失败时缓存,下一条消息时补发 */
64
71
  undelivered: { openid: string; text: string } | null;
65
72
  }
@@ -90,14 +97,24 @@ export function createInitialState(): State {
90
97
  progress: new ProgressTracker(),
91
98
  progressTimer: null,
92
99
  progressPaused: false,
100
+ progressMuted: false,
93
101
  undelivered: null,
102
+ account: null,
103
+ candidates: [],
104
+ claimNote: null,
94
105
  };
95
106
  }
96
107
 
97
108
  export default function (
98
109
  pi: ExtensionAPI,
99
110
  /** 仅测试用:注入状态,让事件处理器与 handleMessage 共享同一份状态 */
100
- options?: { state?: State },
111
+ options?: {
112
+ state?: State;
113
+ /** 仅测试用:不真连 QQ 网关(避免单测打网络),其余流程(抢锁/身份/提示)照常 */
114
+ disableGateway?: boolean;
115
+ /** 仅测试用:替换「抢到锁后验凭据」的实现(默认真的去换 access_token) */
116
+ verifyCredentials?: (account: QQBotAccount) => Promise<AuthManager>;
117
+ },
101
118
  ) {
102
119
  const state: State = options?.state ?? createInitialState();
103
120
 
@@ -232,66 +249,205 @@ export default function (
232
249
  return { block: true, reason: `QQ 用户拒绝了 ${summary}` };
233
250
  });
234
251
 
235
- // ---- 事件:session_start 抢锁 + 启动网关 ----
236
- pi.on("session_start", async () => {
237
- const cfg = loadConfig();
238
- if (!cfg) {
239
- log("[setup] 缺少 QQ_BOT_APPID/QQ_BOT_APPSECRET 或配置,插件未启用");
252
+ // ---- 事件:session_start 选/抢 qqbot + 启动网关 ----
253
+ pi.on("session_start", async (event) => {
254
+ const { accounts, errors } = loadAccounts();
255
+ for (const message of errors) log("[setup] 配置问题:", message);
256
+ if (accounts.length === 0) {
257
+ log("[setup] 未配置 QQ bot(或配置不可用),插件未启用");
240
258
  return;
241
259
  }
242
- state.cfg = cfg;
260
+
261
+ // 会话重建(/reload、/new…)时优先沿用自己的 bot:上一轮释放前写了 owner 记录
262
+ const pin = pinnedAccountName();
263
+ const pool: QQBotAccount[] = pin
264
+ ? accounts.filter((a) => a.name === pin || a.appId === pin)
265
+ : accounts;
266
+ if (pool.length === 0) {
267
+ log("[setup] PI_QQBOT_ACCOUNT=", pin, "不在配置里(可选:", accounts.map((a) => a.name).join(", "), "),插件未启用");
268
+ return;
269
+ }
270
+ state.candidates = pool.map((a) => ({ name: a.name, appId: a.appId }));
271
+
272
+ const ownPid = process.pid;
273
+ const preferredAppId = state.candidates
274
+ .map((c) => readOwner(c.appId))
275
+ .find((owner) => owner?.pid === ownPid)?.appId;
276
+
243
277
  // 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
244
278
  state.approver.reset();
245
- // 队列上限来自配置;新会话不继承上一个会话遗留的待执行任务
246
- state.queue.setLimit(cfg.queue.max);
279
+ // 手动暂停的自动汇报同样只在本会话有效
280
+ state.progressMuted = false;
281
+ state.progressPaused = false;
247
282
  state.queue.clear();
248
- log("[setup] 配置已加载 sandbox=", cfg.sandbox, "queue.max=", cfg.queue.max);
249
283
 
250
- const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
251
- const auth = new AuthManager(cfg.appId, cfg.appSecret);
252
- const api = new QQApi({ restBase, getToken: () => auth.getToken() });
253
- state.api = api;
284
+ // 凭据不可用的 bot 在本次会话内不再尝试(配置在 session_start 读取,改完 /reload 才生效)
285
+ const unusable = new Set<string>();
286
+ const verifyAccount =
287
+ options?.verifyCredentials ??
288
+ (async (account: QQBotAccount) => {
289
+ const auth = new AuthManager(account.appId, account.config.appSecret);
290
+ await auth.getToken(); // 抢到锁之后先验凭据,失败就不占着这个槽位
291
+ return auth;
292
+ });
293
+
294
+ const applyClaim = (claim: Claim, account: QQBotAccount, auth: AuthManager) => {
295
+ const cfg = account.config;
296
+ state.cfg = cfg;
297
+ state.account = { name: account.name, appId: account.appId };
298
+ state.releaseLock = claim.release;
299
+ // 只有一个 bot(含 v1 旧配置)→ 继续写旧文件名,升级后日志不断线
300
+ configureLog(accounts.length === 1 ? null : account.name);
301
+ state.queue.setLimit(cfg.queue.max);
302
+ log(
303
+ "[setup] 配置已加载 bot=",
304
+ `"${account.name}"`,
305
+ "appId=",
306
+ account.appId,
307
+ "sandbox=",
308
+ cfg.sandbox,
309
+ "queue.max=",
310
+ cfg.queue.max,
311
+ "reason=",
312
+ (event as any)?.reason ?? "startup",
313
+ );
314
+
315
+ // 提示:接入成功 / 换进程接管(后者一定要带工作目录)
316
+ const cwd = process.cwd();
317
+ if (claim.takeover && claim.previous) {
318
+ log(
319
+ "[lock] qqbot",
320
+ `"${account.name}"`,
321
+ "换进程接管: pid",
322
+ claim.previous.pid,
323
+ "→",
324
+ process.pid,
325
+ "· cwd",
326
+ claim.previous.cwd || "?",
327
+ "→",
328
+ cwd,
329
+ );
330
+ state.claimNote = `(qqbot「${account.name}」已由新实例接管 · 当前工作目录 ${cwd})`;
331
+ } else if (claim.reclaimed) {
332
+ log("[lock] 重新接入自己的 qqbot(同进程会话重建)");
333
+ } else {
334
+ log("[lock] 接入 qqbot", `"${account.name}"`, `(${account.appId})`, "· cwd=", cwd, "· pid=", process.pid);
335
+ state.claimNote = `(已接入 qqbot「${account.name}」)`;
336
+ }
254
337
 
255
- const startGateway = () => {
338
+ const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
339
+ const api = new QQApi({ restBase, getToken: () => auth.getToken() });
340
+ state.api = api;
341
+
342
+ if (options?.disableGateway) {
343
+ log("[setup] 测试模式:跳过 QQ 网关");
344
+ return;
345
+ }
256
346
  const gateway = new QQGateway({
257
347
  getGatewayUrl: createGatewayUrlFetcher(restBase, () => auth.getToken()),
258
348
  getToken: () => auth.getToken(),
259
349
  intents: INTENT_GROUP_AND_C2C,
260
350
  connected: () => {},
261
351
  disconnected: () => {},
262
- message: (msg) => handleMessage(pi, state, api, cfg, msg),
352
+ message: (msg) => handleMessage(pi, state, api, state.cfg ?? cfg, msg),
263
353
  });
264
354
  state.gateway = gateway;
265
355
  log("[lock] 启动 QQ 网关");
266
356
  gateway.start();
267
357
  };
268
358
 
269
- // 单实例锁:只有抢到锁的实例连接 QQ
270
- const lockPath = LOCK_PATH(cfg.appId);
271
- const release = await tryAcquireLock(lockPath);
272
- if (release) {
273
- state.releaseLock = release;
274
- log("[lock] 抢锁成功,本实例为 QQ bot 宿主");
275
- startGateway();
276
- } else {
277
- log("[lock] 锁被占用,本实例进入待命");
278
- state.stopRetry = startLockRetry(lockPath, (r) => {
279
- state.releaseLock = r;
280
- log("[lock] 抢锁成功,接管 QQ bot");
281
- startGateway();
282
- });
283
- }
359
+ // 抢一个、验一下凭据、验不过就放锁换下一个
360
+ const tryClaimOnce = async (): Promise<boolean> => {
361
+ const remaining = state.candidates.filter((c) => !unusable.has(c.appId));
362
+ if (remaining.length === 0) return false;
363
+ const result = await claimAny(remaining, { preferredAppId });
364
+ if (!result.claim) {
365
+ const detail = result.skipped
366
+ .map(
367
+ (s) =>
368
+ `${s.name}:${s.reason === "online" ? "在线" : s.reason === "reserved" ? `被 pid ${s.owner?.pid} 重载中` : "刚被抢走"}`,
369
+ )
370
+ .join(", ");
371
+ log("[lock] 本轮无可接入的 qqbot(", detail, ")");
372
+ return false;
373
+ }
374
+ const claim = result.claim;
375
+ const account = pool.find((a) => a.appId === claim.appId);
376
+ if (!account) {
377
+ await claim.release().catch(() => {});
378
+ unusable.add(claim.appId);
379
+ return tryClaimOnce();
380
+ }
381
+ try {
382
+ const auth = await verifyAccount(account);
383
+ applyClaim(claim, account, auth);
384
+ return true;
385
+ } catch (e) {
386
+ await claim.release().catch(() => {});
387
+ unusable.add(account.appId);
388
+ log(
389
+ "[lock] qqbot",
390
+ `"${account.name}"`,
391
+ "凭据不可用:",
392
+ String(e).slice(0, 160),
393
+ "→ 已放锁并跳过该 bot(改好配置后 /reload 再试)",
394
+ );
395
+ return tryClaimOnce();
396
+ }
397
+ };
398
+
399
+ // 待命:还有「可能变可用」的候选就 5s 重扫——否则别人退出后没人接管
400
+ const enterStandby = () => {
401
+ if (!state.candidates.some((c) => !unusable.has(c.appId))) {
402
+ log("[lock] 没有可接入的 qqbot(其余在线或凭据不可用),本进程不接入 QQ");
403
+ return;
404
+ }
405
+ log("[lock] 本进程待命,5s 后重扫");
406
+ state.stopRetry = startClaimRetry(
407
+ () => state.candidates.filter((c) => !unusable.has(c.appId)),
408
+ (claim) => {
409
+ state.stopRetry = null;
410
+ void (async () => {
411
+ const account = pool.find((a) => a.appId === claim.appId);
412
+ if (!account || state.gateway) {
413
+ await claim.release().catch(() => {});
414
+ enterStandby();
415
+ return;
416
+ }
417
+ try {
418
+ const auth = await verifyAccount(account);
419
+ applyClaim(claim, account, auth);
420
+ } catch (e) {
421
+ await claim.release().catch(() => {});
422
+ unusable.add(account.appId);
423
+ log("[lock] qqbot", `"${account.name}"`, "凭据不可用:", String(e).slice(0, 160), "→ 放锁继续待命");
424
+ enterStandby();
425
+ }
426
+ })();
427
+ },
428
+ { preferredAppId },
429
+ );
430
+ };
431
+
432
+ if (await tryClaimOnce()) return;
433
+ enterStandby();
284
434
  });
285
435
 
286
436
  // ---- 事件:session_shutdown 清理 ----
287
- pi.on("session_shutdown", async () => {
288
- log("[shutdown] 清理资源");
437
+ pi.on("session_shutdown", async (event) => {
438
+ const reason = String((event as any)?.reason ?? "quit");
439
+ log("[shutdown] 清理资源 reason=", reason);
289
440
  clearCancelTimer(state);
290
441
  stopProgressTimer(state);
291
442
  const dropped = state.queue.clear();
292
443
  if (dropped.length > 0) log("[shutdown] 丢弃队列任务", dropped.length, "条");
293
444
  if (state.stopRetry) state.stopRetry();
294
445
  state.gateway?.stop();
446
+ // reload/new/fork/resume 只是「本进程换会话」,马上会重新接入:先贴预约,别让别人抢走
447
+ if (state.account && state.releaseLock && reason !== "quit") {
448
+ reserveBeforeRelease(state.account, reason);
449
+ log("[lock] qqbot", `"${state.account.name}"`, "临时释放(reason=", reason, "),已留预约窗口");
450
+ }
295
451
  if (state.releaseLock) {
296
452
  try {
297
453
  await state.releaseLock();
@@ -302,6 +458,7 @@ export default function (
302
458
  state.releaseLock = null;
303
459
  state.gateway = null;
304
460
  state.api = null;
461
+ state.account = null;
305
462
  });
306
463
 
307
464
  // ---- /qq:QQ 侧命令的桥接入口 ----
@@ -719,13 +876,15 @@ export async function handleMessage(
719
876
  msg.openid,
720
877
  [
721
878
  "pi-qqbot 指令:",
722
- "/status — 查看状态(忙闲 / 队列 / 自动同意 / 待审批)",
879
+ "/progress — 查看进度;/progress off 暂停自动汇报、/progress on 恢复(本会话有效)",
880
+ "/status — 查看状态(当前 qqbot / 忙闲 / 队列 / 自动同意 / 待审批 / 自动汇报)",
723
881
  "/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
724
882
  "/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
725
883
  "/cancel(或「取消」「停止」)— 中断正在运行的任务并清空队列",
726
884
  "/help — 帮助",
727
885
  "pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
728
886
  "忙碌时发来的普通消息(含模板/skill 命令)会排队,当前任务结束后自动执行。",
887
+ "多 bot:配置了多个 qqbot 时,多个 pi 进程会按列表顺序各自接入一个,抢不到的待命重扫。",
729
888
  "其余文本交给 pi 当作对话处理。",
730
889
  ].join("\n"),
731
890
  );
@@ -733,28 +892,71 @@ export async function handleMessage(
733
892
  }
734
893
  if (cmd?.name === "status") {
735
894
  const lines = [
895
+ `bot: ${state.account ? `${state.account.name} (…${state.account.appId.slice(-4)})` : "未接入"} · cwd: ${process.cwd()}`,
736
896
  `状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
737
897
  `队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
738
898
  `自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
739
899
  `待审批: ${state.approver.pendingCount}`,
900
+ `自动汇报: ${describeProgressMode(state)}`,
740
901
  ];
741
902
  if (state.busy) {
742
903
  const snapshot = state.progress.summary();
743
904
  if (snapshot) lines.push("", snapshot);
744
- // 用户主动问询:恢复自动推送
745
- state.progressPaused = false;
905
+ // 用户主动问询:恢复自动推送(手动暂停的不算)
906
+ if (!state.progressMuted) state.progressPaused = false;
746
907
  }
747
908
  await replyTo(api, state, msg.openid, lines.join("\n"));
748
909
  return;
749
910
  }
750
911
  if (cmd?.name === "progress") {
912
+ const sub = (cmd.args ?? "").trim().toLowerCase();
913
+ if (MUTE_ARGS.has(sub)) {
914
+ const was = state.progressMuted;
915
+ state.progressMuted = true;
916
+ state.progressPaused = false;
917
+ log("[progress] 用户暂停自动进度推送(本会话有效)", was ? "(已经是暂停状态)" : "");
918
+ await replyTo(
919
+ api,
920
+ state,
921
+ msg.openid,
922
+ was
923
+ ? "自动汇报本来就已暂停。发 /progress on(或「恢复汇报」)可以恢复。"
924
+ : "已暂停自动进度汇报(本会话有效,换会话或重启后恢复默认开启)。需要时发 /progress 仍可查看一次,发 /progress on 恢复。",
925
+ );
926
+ return;
927
+ }
928
+ if (UNMUTE_ARGS.has(sub)) {
929
+ const was = state.progressMuted;
930
+ state.progressMuted = false;
931
+ state.progressPaused = false;
932
+ log("[progress] 用户恢复自动进度推送");
933
+ await replyTo(
934
+ api,
935
+ state,
936
+ msg.openid,
937
+ was
938
+ ? `已恢复自动进度汇报${state.cfg?.progress?.enabled === false ? "(但配置 progress.enabled=false,仍不会推送)" : ""}。`
939
+ : "自动汇报本来就是开启的。",
940
+ );
941
+ return;
942
+ }
943
+ if (sub) {
944
+ await replyTo(
945
+ api,
946
+ state,
947
+ msg.openid,
948
+ "用法: /progress(看一次)| /progress off(暂停自动汇报)| /progress on(恢复)。也可以说「暂停汇报」「恢复汇报」。",
949
+ );
950
+ return;
951
+ }
751
952
  const snapshot = state.progress.summary();
752
- state.progressPaused = false;
953
+ if (!state.progressMuted) state.progressPaused = false;
954
+ const body = snapshot || (state.busy ? "当前任务刚启动,还没有可汇报的进度。" : "当前没有正在运行的任务。");
753
955
  await replyTo(
754
956
  api,
755
957
  state,
756
958
  msg.openid,
757
- snapshot || (state.busy ? "当前任务刚启动,还没有可汇报的进度。" : "当前没有正在运行的任务。"),
959
+ state.progressMuted ? `${body}\n(自动汇报已暂停,发 /progress on 恢复)` : body,
758
960
  );
759
961
  return;
760
962
  }
@@ -774,7 +976,7 @@ export async function handleMessage(
774
976
  // 忙碌时问「进度」不该排队,直接回当前进度并恢复自动推送
775
977
  if (RE_PROGRESS_QUERY.test(msg.content.trim())) {
776
978
  const snapshot = state.progress.summary();
777
- state.progressPaused = false;
979
+ if (!state.progressMuted) state.progressPaused = false;
778
980
  await replyTo(api, state, msg.openid, snapshot || "任务刚启动,还没有可汇报的进度。");
779
981
  return;
780
982
  }
@@ -799,6 +1001,12 @@ async function replyTo(
799
1001
  text: string,
800
1002
  format: "text" | "markdown" = "text",
801
1003
  ): Promise<boolean> {
1004
+ // 本进程刚接入 / 刚接管这个 qqbot:第一条回执带上身份与工作目录
1005
+ if (state.claimNote) {
1006
+ const note = state.claimNote;
1007
+ state.claimNote = null;
1008
+ text = `${note}\n${text}`;
1009
+ }
802
1010
  const ref = state.passiveRefs.get(openid);
803
1011
  let delivered = true;
804
1012
  for (const chunk of splitText(text)) {
@@ -845,9 +1053,9 @@ function stopProgressTimer(state: State) {
845
1053
  }
846
1054
  }
847
1055
 
848
- /** 推一条进度摘要;失败就暂停自动推送(用户问一次即恢复) */
1056
+ /** 推一条进度摘要;失败就暂停自动推送(用户问一次即恢复);手动暂停(/progress off)管到底 */
849
1057
  async function pushProgress(state: State, reason: "tick" | "ask"): Promise<boolean> {
850
- if (reason === "tick" && state.progressPaused) return false;
1058
+ if (reason === "tick" && (state.progressPaused || state.progressMuted)) return false;
851
1059
  const api = state.api;
852
1060
  const openid = state.currentOpenid ?? state.lastOpenid;
853
1061
  if (!api || !openid || !state.busy || !state.progress.running) return false;
@@ -861,19 +1069,75 @@ async function pushProgress(state: State, reason: "tick" | "ask"): Promise<boole
861
1069
  return ok;
862
1070
  }
863
1071
 
1072
+ /** 自动汇报当前状态的人话描述(/status 用) */
1073
+ function describeProgressMode(state: State): string {
1074
+ const cfg = state.cfg?.progress;
1075
+ if (state.progressMuted) return "已暂停(本会话,发 /progress on 恢复)";
1076
+ if (cfg?.enabled === false) return "未启用(配置 progress.enabled=false)";
1077
+ if (state.progressPaused) return "推送失败已静默,发 /progress 恢复";
1078
+ const ms = Number.isFinite(cfg?.intervalMs) && (cfg?.intervalMs ?? 0) >= 50 ? cfg!.intervalMs : 15000;
1079
+ return `开启(每 ${Math.round(ms / 1000)}s)`;
1080
+ }
1081
+
1082
+ /** /progress 的子命令:暂停 / 恢复自动汇报 */
1083
+ const MUTE_ARGS = new Set([
1084
+ "off",
1085
+ "pause",
1086
+ "stop",
1087
+ "mute",
1088
+ "关",
1089
+ "关闭",
1090
+ "关掉",
1091
+ "停",
1092
+ "停止",
1093
+ "暂停",
1094
+ "暂停汇报",
1095
+ "停止汇报",
1096
+ ]);
1097
+ const UNMUTE_ARGS = new Set([
1098
+ "on",
1099
+ "resume",
1100
+ "start",
1101
+ "unmute",
1102
+ "开",
1103
+ "开启",
1104
+ "打开",
1105
+ "恢复",
1106
+ "继续",
1107
+ "恢复汇报",
1108
+ "开启汇报",
1109
+ "继续汇报",
1110
+ ]);
1111
+
1112
+ /** 不带斜杠的「暂停汇报」类说法(整句匹配,避免误伤正常聊天) */
1113
+ const RE_REPORTS_OFF =
1114
+ /^(?:请|帮我|给我)?\s*(?:暂停|停止|关闭|关掉|别|不要|别再)(?:自动)?(?:的)?(?:进度汇报|进度|进展|汇报|推送|播报|报告)(?:上报|推送|汇报)?(?:了|吧)?$/;
1115
+ const RE_REPORTS_ON =
1116
+ /^(?:请|帮我|给我)?\s*(?:恢复|继续|开启|打开|开始)(?:自动)?(?:的)?(?:进度汇报|进度|进展|汇报|推送|播报|报告)(?:上报|推送|汇报)?(?:吧)?$/;
1117
+
1118
+ /** 不带斜杠的进度问询:「进度」「当前进度」「现在进展」(可带子命令) */
1119
+ const BARE_PROGRESS_RE = /^(?:progress|(?:当前|现在|目前)?的?(?:进度|进展))(?:\s+([\s\S]*))?$/i;
1120
+
864
1121
  function parseCommand(text: string): { name: "reset" | "status" | "help" | "queue" | "progress"; args: string } | null {
865
1122
  // 注意:/new 现在属于 pi 的内置命令(由 slash 路由接管,真的开新会话),
866
1123
  // 本地的「清缓冲/清审批记忆」改名为 /reset。
867
1124
  // 斜杠形式:全部内置指令;不带斜杠时只接受明确表达「进度」的词,避免把聊天里的 "p" / "status" 误判成命令
868
- const m = /^\/(reset|status|help|queue|q|progress|p)(?:\s+([\s\S]*))?$/i.exec(text.trim())
869
- ?? /^(progress|进度|进展)(?:\s+([\s\S]*))?$/i.exec(text.trim());
870
- if (!m) return null;
871
- const raw = m[1].toLowerCase();
872
- const name = raw === "q" ? "queue" : raw === "p" || raw === "进度" || raw === "进展" ? "progress" : raw;
873
- return {
874
- name: name as "reset" | "status" | "help" | "queue" | "progress",
875
- args: (m[2] ?? "").trim(),
876
- };
1125
+ const t = text.trim();
1126
+ const slash = /^\/(reset|status|help|queue|q|progress|p)(?:\s+([\s\S]*))?$/i.exec(t);
1127
+ if (slash) {
1128
+ const raw = slash[1].toLowerCase();
1129
+ const name = raw === "q" ? "queue" : raw === "p" ? "progress" : raw;
1130
+ return {
1131
+ name: name as "reset" | "status" | "help" | "queue" | "progress",
1132
+ args: (slash[2] ?? "").trim(),
1133
+ };
1134
+ }
1135
+ // 裸文本:「进度」「当前进度」→ 看一次;「暂停汇报」「恢复汇报」→ 开关
1136
+ const bare = BARE_PROGRESS_RE.exec(t);
1137
+ if (bare) return { name: "progress", args: (bare[1] ?? "").trim() };
1138
+ if (RE_REPORTS_OFF.test(t)) return { name: "progress", args: "off" };
1139
+ if (RE_REPORTS_ON.test(t)) return { name: "progress", args: "on" };
1140
+ return null;
877
1141
  }
878
1142
 
879
1143
  /** /queue [clear|drop <编号>] */