@zxh126/pi-qqbot 1.0.0 → 1.2.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
@@ -7,6 +7,8 @@ import {
7
7
  PASSIVE_WINDOW_MS,
8
8
  APPROVAL_TIMEOUT_MS,
9
9
  LOCK_PATH,
10
+ DISPATCH_RETRY_MS,
11
+ CANCEL_SETTLE_GRACE_MS,
10
12
  } from "./constants";
11
13
  import { log } from "./logger";
12
14
  import { tryAcquireLock, startLockRetry } from "./lock";
@@ -14,7 +16,8 @@ import { AuthManager } from "./qq/auth";
14
16
  import { QQApi } from "./qq/api";
15
17
  import { createGatewayUrlFetcher, QQGateway, QQMessage } from "./qq/gateway";
16
18
  import { Approver } from "./approver";
17
- import { routeSlash, runBridge } from "./slash";
19
+ import { parsePickIndex, pickerExpired, routeSlash, runBridge, type PickerState } from "./slash";
20
+ import { agoLabel, MessageQueue, previewText, type QueuedJob } from "./queue";
18
21
  import { ReplyBuffer } from "./stream-buffer";
19
22
  import { splitText } from "./util/chunk";
20
23
 
@@ -33,14 +36,27 @@ interface State {
33
36
  ctx: ExtensionContext | null;
34
37
  /** 最近一条 QQ 消息的发送者:桥接命令用它回执 */
35
38
  lastOpenid: string | null;
39
+ /** 待选择列表(/tree、/fork、/resume 发出后的编号等待状态) */
40
+ picker: PickerState | null;
41
+ /** 发出列表的 QQ 用户:只有他能回编号 */
42
+ pickerOwner: string | null;
36
43
  /** /qq 桥接命令的回执通道(每次收到 QQ 消息时刷新) */
37
44
  bridgeReply: ((text: string) => void) | null;
38
45
  /** 本轮已被用户取消:settle 时丢弃半截回复 */
39
46
  cancelled: boolean;
47
+ /** 忙碌时收到的任务:当前任务 settle 后逐条执行 */
48
+ queue: MessageQueue;
49
+ /** 正在出队派发(防 settled / 重试竞态下重复派发) */
50
+ dispatching: boolean;
51
+ /** 当前派发的任务已经真的启动了 run(agent_start 过):此时报错不重试,避免重复执行 */
52
+ runStarted: boolean;
53
+ /** /cancel 之后等 agent_settled 的兜底定时器 */
54
+ cancelTimer: ReturnType<typeof setTimeout> | null;
40
55
  }
41
56
 
42
- export default function (pi: ExtensionAPI) {
43
- const state: State = {
57
+ /** 扩展运行所需的状态(导出只为测试用) */
58
+ export function createInitialState(): State {
59
+ return {
44
60
  cfg: null,
45
61
  api: null,
46
62
  approver: new Approver(APPROVAL_TIMEOUT_MS),
@@ -53,14 +69,30 @@ export default function (pi: ExtensionAPI) {
53
69
  busy: false,
54
70
  ctx: null,
55
71
  lastOpenid: null,
72
+ picker: null,
73
+ pickerOwner: null,
56
74
  bridgeReply: null,
57
75
  cancelled: false,
76
+ queue: new MessageQueue(),
77
+ dispatching: false,
78
+ runStarted: false,
79
+ cancelTimer: null,
58
80
  };
81
+ }
82
+
83
+ export default function (
84
+ pi: ExtensionAPI,
85
+ /** 仅测试用:注入状态,让事件处理器与 handleMessage 共享同一份状态 */
86
+ options?: { state?: State },
87
+ ) {
88
+ const state: State = options?.state ?? createInitialState();
59
89
 
60
90
  // ---- 事件:run 开始,记住 ctx(取消指令要调用 ctx.abort())----
61
91
  pi.on("agent_start", (_event, ctx) => {
62
92
  state.ctx = ctx;
63
93
  state.cancelled = false;
94
+ state.runStarted = true;
95
+ clearCancelTimer(state);
64
96
  });
65
97
 
66
98
  // ---- 事件:累积 assistant 文本 ----
@@ -71,8 +103,9 @@ export default function (pi: ExtensionAPI) {
71
103
  }
72
104
  });
73
105
 
74
- // ---- 事件:run 结束,发回复 ----
106
+ // ---- 事件:run 结束,发回复,然后接着跑队列里的下一条 ----
75
107
  pi.on("agent_settled", async () => {
108
+ clearCancelTimer(state);
76
109
  const cancelled = state.cancelled;
77
110
  const text = state.replyBuffer.take();
78
111
  const openid = state.currentOpenid;
@@ -80,15 +113,17 @@ export default function (pi: ExtensionAPI) {
80
113
  state.busy = false;
81
114
  state.ctx = null;
82
115
  state.cancelled = false;
116
+ state.runStarted = false;
83
117
  if (cancelled) {
84
118
  log("[reply] 本轮已被取消,丢弃半截回复(长度", text.length, ")");
85
- return;
86
- }
87
- if (!text || !openid || !state.api) return;
88
- log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
89
- for (const chunk of splitText(text)) {
90
- await state.api.sendC2C(openid, chunk).catch((e) => log("[reply] 发送失败:", String(e)));
119
+ } else if (text && openid && state.api) {
120
+ log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
121
+ for (const chunk of splitText(text)) {
122
+ await state.api.sendC2C(openid, chunk).catch((e) => log("[reply] 发送失败:", String(e)));
123
+ }
91
124
  }
125
+ // 本轮结束:取队列里的下一条继续执行(不 await,避免卡住 settled 的派发阶段)
126
+ drainQueue(pi, state);
92
127
  });
93
128
 
94
129
  // ---- 事件:tool_call 审批 ----
@@ -157,7 +192,10 @@ export default function (pi: ExtensionAPI) {
157
192
  state.cfg = cfg;
158
193
  // 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
159
194
  state.approver.reset();
160
- log("[setup] 配置已加载 sandbox=", cfg.sandbox);
195
+ // 队列上限来自配置;新会话不继承上一个会话遗留的待执行任务
196
+ state.queue.setLimit(cfg.queue.max);
197
+ state.queue.clear();
198
+ log("[setup] 配置已加载 sandbox=", cfg.sandbox, "queue.max=", cfg.queue.max);
161
199
 
162
200
  const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
163
201
  const auth = new AuthManager(cfg.appId, cfg.appSecret);
@@ -198,6 +236,9 @@ export default function (pi: ExtensionAPI) {
198
236
  // ---- 事件:session_shutdown 清理 ----
199
237
  pi.on("session_shutdown", async () => {
200
238
  log("[shutdown] 清理资源");
239
+ clearCancelTimer(state);
240
+ const dropped = state.queue.clear();
241
+ if (dropped.length > 0) log("[shutdown] 丢弃队列任务", dropped.length, "条");
201
242
  if (state.stopRetry) state.stopRetry();
202
243
  state.gateway?.stop();
203
244
  if (state.releaseLock) {
@@ -225,7 +266,7 @@ export default function (pi: ExtensionAPI) {
225
266
  const rest = (space === -1 ? "" : trimmed.slice(space + 1)).trim();
226
267
  const reply = (text: string) => state.bridgeReply?.(text);
227
268
  try {
228
- await runBridge(sub, rest, ctx, pi, reply);
269
+ await runBridge(sub, rest, ctx, pi, reply, state);
229
270
  } catch (e) {
230
271
  log("[/qq] 桥接失败:", String(e));
231
272
  reply(`/${sub} 执行失败:${String(e).slice(0, 200)}`);
@@ -242,49 +283,220 @@ function isCancelIntent(text: string): boolean {
242
283
  return RE_CANCEL.test(text.trim());
243
284
  }
244
285
 
286
+ /** 派发失败时最多重试几次(通常是 pi 上一轮还没收尾) */
287
+ const MAX_DISPATCH_ATTEMPTS = 3;
288
+
289
+ function clearCancelTimer(state: State) {
290
+ if (state.cancelTimer) {
291
+ clearTimeout(state.cancelTimer);
292
+ state.cancelTimer = null;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * 对外看是“忙”:有任务在跑、正在派发,或者队列里还有任务等着(比如派发失败后的重试窗口)。
298
+ * 这三种情况新消息都应该进队列,而不是插队直接开新一轮。
299
+ */
300
+ function isBusy(state: State): boolean {
301
+ return state.busy || state.dispatching || state.queue.size > 0;
302
+ }
303
+
304
+ /**
305
+ * /cancel 之后 abort() 是异步的:正常路径靠 agent_settled 释放 busy 并继续队列;
306
+ * 万一 abort 没能触发 settled(比如那一轮正好已经结束),宽限期后强制解锁。
307
+ */
308
+ function armCancelWatchdog(pi: ExtensionAPI, state: State) {
309
+ clearCancelTimer(state);
310
+ const timer = setTimeout(() => {
311
+ state.cancelTimer = null;
312
+ if (!state.cancelled) return;
313
+ log("[cancel] abort 后", CANCEL_SETTLE_GRACE_MS, "ms 内没有 agent_settled,强制释放 busy");
314
+ state.cancelled = false;
315
+ state.busy = false;
316
+ state.ctx = null;
317
+ state.currentOpenid = null;
318
+ state.replyBuffer.reset();
319
+ drainQueue(pi, state);
320
+ }, CANCEL_SETTLE_GRACE_MS);
321
+ (timer as any).unref?.();
322
+ state.cancelTimer = timer;
323
+ }
324
+
325
+ /**
326
+ * 把一条任务交给 pi,并把它标记为「当前任务」。
327
+ * 不 await sendUserMessage:它返回的 promise 要等整轮跑完,await 会卡住调用方
328
+ * (agent_settled 里派发时更是不允许)。错误交给 onDispatchError 重试/回执。
329
+ */
330
+ function startJob(pi: ExtensionAPI, state: State, job: QueuedJob) {
331
+ state.currentOpenid = job.openid;
332
+ state.lastOpenid = job.openid;
333
+ state.pickerOwner = job.openid;
334
+ state.bridgeReply = (text: string) => {
335
+ void replyTo(state.api!, state, job.openid, text);
336
+ };
337
+ state.busy = true;
338
+ state.cancelled = false;
339
+ state.runStarted = false;
340
+ state.replyBuffer.reset();
341
+ clearCancelTimer(state);
342
+ log(
343
+ "[msg] 送入 pi:",
344
+ job.id > 0 ? `队列 #${job.id}` : "直接",
345
+ "openid=",
346
+ job.openid,
347
+ "text=",
348
+ job.text.slice(0, 60),
349
+ );
350
+ try {
351
+ void Promise.resolve(
352
+ pi.sendUserMessage(job.text, { expandPromptTemplates: true, deliverAs: "followUp" }),
353
+ ).catch((error) => onDispatchError(pi, state, job, error));
354
+ } catch (error) {
355
+ onDispatchError(pi, state, job, error);
356
+ }
357
+ }
358
+
359
+ /** 派发失败:先重试(放回队首),多次失败就把错误回给发送者并继续队列 */
360
+ function onDispatchError(pi: ExtensionAPI, state: State, job: QueuedJob, error: unknown) {
361
+ const message = String((error as any)?.message ?? error).slice(0, 200);
362
+ log("[msg] 派发失败:", message);
363
+ if (state.runStarted) {
364
+ // run 已经启动了:这次报错属于这一轮自己的问题,收尾交给 agent_settled,不能重试
365
+ log("[msg] 本轮已启动过 agent,不重试,等 agent_settled 收尾");
366
+ return;
367
+ }
368
+ state.busy = false;
369
+ state.currentOpenid = null;
370
+ job.attempts += 1;
371
+ if (job.attempts < MAX_DISPATCH_ATTEMPTS) {
372
+ state.queue.unshift(job);
373
+ log("[msg]", DISPATCH_RETRY_MS, "ms 后重试(第", job.attempts, "次)");
374
+ const timer = setTimeout(() => drainQueue(pi, state), DISPATCH_RETRY_MS);
375
+ (timer as any).unref?.();
376
+ return;
377
+ }
378
+ const api = state.api;
379
+ if (api) void replyTo(api, state, job.openid, `这条消息执行失败:${message}`);
380
+ drainQueue(pi, state);
381
+ }
382
+
383
+ /** 当前任务结束后,取队列里的下一条开始执行(空闲时才会真的派发) */
384
+ function drainQueue(pi: ExtensionAPI, state: State) {
385
+ if (state.busy || state.dispatching) return;
386
+ const job = state.queue.shift();
387
+ if (!job) return;
388
+ state.dispatching = true;
389
+ try {
390
+ log("[queue] 出队 #", job.id, "剩余", state.queue.size, "条", "text=", job.text.slice(0, 60));
391
+ const api = state.api;
392
+ if (api && state.cfg?.queue.ack !== false) {
393
+ const rest = state.queue.size > 0 ? `(后面还有 ${state.queue.size} 条)` : "";
394
+ void replyTo(api, state, job.openid, `开始执行队列 #${job.id}${rest}`);
395
+ }
396
+ startJob(pi, state, job);
397
+ } finally {
398
+ state.dispatching = false;
399
+ }
400
+ }
401
+
402
+ /** 入队(忙碌时的统一入口):回执排队位置 */
403
+ async function enqueueJob(
404
+ api: QQApi,
405
+ state: State,
406
+ openid: string,
407
+ text: string,
408
+ ): Promise<boolean> {
409
+ const result = state.queue.push(openid, text);
410
+ if (!result.ok) {
411
+ log("[queue] 队列已满(上限", state.queue.max, "),拒绝入队");
412
+ if (state.queue.max === 0) {
413
+ await replyTo(api, state, openid, "上一条消息还在处理中,请稍候…(队列已关闭:queue.max=0)");
414
+ return false;
415
+ }
416
+ await replyTo(
417
+ api,
418
+ state,
419
+ openid,
420
+ `队列已满(上限 ${state.queue.max} 条),这条没排上。发 /queue 查看队列,或 /cancel 清空后重发。`,
421
+ );
422
+ return false;
423
+ }
424
+ log("[queue] 入队 #", result.job.id, "size=", state.queue.size, "ahead=", result.ahead);
425
+ await replyTo(
426
+ api,
427
+ state,
428
+ openid,
429
+ `已排队 #${result.job.id}${result.ahead > 0 ? `(前面还有 ${result.ahead} 条)` : ""},当前任务结束后自动执行。发 /queue 查看队列,/cancel 可中止当前任务并清空队列。`,
430
+ );
431
+ return true;
432
+ }
433
+
434
+ function formatQueue(state: State): string {
435
+ const lines = [`当前任务:${state.busy ? "处理中(发 /cancel 可中断)" : "空闲"}`];
436
+ const jobs = state.queue.list();
437
+ if (jobs.length === 0) {
438
+ lines.push("队列:空");
439
+ return lines.join("\n");
440
+ }
441
+ lines.push(`队列:${jobs.length} 条(上限 ${state.queue.max})`);
442
+ jobs.slice(0, 10).forEach((job, index) => {
443
+ lines.push(` ${index + 1}. #${job.id} · ${agoLabel(job.enqueuedAt)} · ${previewText(job.text)}`);
444
+ });
445
+ if (jobs.length > 10) lines.push(` … 还有 ${jobs.length - 10} 条`);
446
+ lines.push("发 /queue clear 清空,/queue drop <编号> 删除一条。");
447
+ return lines.join("\n");
448
+ }
449
+
245
450
  /**
246
- * 用户发来取消指令:终止待审批、abort 当前 run、丢弃半截回复,然后回执。
451
+ * 用户发来取消指令:终止待审批、清空队列、abort 当前 run、丢弃半截回复,然后回执。
452
+ * 取消本身永不入队(“取消除外”),立即生效。
247
453
  * (「取消自动同意」/「关闭自动同意」不属于取消指令,仍走审批分支。)
248
454
  */
249
455
  async function handleCancel(
456
+ pi: ExtensionAPI,
250
457
  state: State,
251
458
  api: QQApi,
252
459
  openid: string,
253
460
  ): Promise<void> {
254
461
  const terminated = state.approver.terminateAll();
462
+ const dropped = state.queue.clear();
255
463
  const running = state.busy || Boolean(state.ctx?.signal);
256
-
257
464
  if (!running) {
465
+ const parts: string[] = [];
466
+ if (terminated > 0) parts.push(`已终止 ${terminated} 个待审批请求`);
467
+ if (dropped.length > 0) parts.push(`已清空队列(${dropped.length} 条)`);
258
468
  await replyTo(
259
469
  api,
260
470
  state,
261
471
  openid,
262
- terminated > 0
263
- ? `已终止 ${terminated} 个待审批请求,当前没有正在运行的任务。`
472
+ parts.length > 0
473
+ ? `${parts.join(",")},当前没有正在运行的任务。`
264
474
  : "当前没有正在运行的任务。",
265
475
  );
266
476
  return;
267
477
  }
268
478
 
269
479
  state.cancelled = true;
270
- state.busy = false;
271
480
  state.replyBuffer.reset();
272
481
  state.currentOpenid = null; // 被中止的 run 不再往 QQ 发半截回复
273
482
  const ctx = state.ctx;
274
483
  state.ctx = null;
275
484
  try {
276
485
  ctx?.abort();
277
- log("[cancel] 已请求中止当前 run,终止待审批", terminated, "个");
486
+ log("[cancel] 已请求中止当前 run,终止待审批", terminated, "个,清空队列", dropped.length, "条");
278
487
  } catch (e) {
279
488
  log("[cancel] abort 失败:", String(e));
280
489
  }
490
+ // busy 保持 true 直到 agent_settled:期间新消息会排队,不会和正在收尾的 run 撞车
491
+ armCancelWatchdog(pi, state);
492
+ const notice: string[] = [];
493
+ if (terminated > 0) notice.push(`并终止了 ${terminated} 个待审批请求`);
494
+ if (dropped.length > 0) notice.push(`队列中的 ${dropped.length} 条已清空`);
281
495
  await replyTo(
282
496
  api,
283
497
  state,
284
498
  openid,
285
- terminated > 0
286
- ? `已取消当前任务,并终止了 ${terminated} 个待审批请求。可以发送新消息继续。`
287
- : "已取消当前任务。可以发送新消息继续。",
499
+ `已取消当前任务${notice.length > 0 ? `,${notice.join(",")}` : ""}。可以发送新消息继续。`,
288
500
  );
289
501
  }
290
502
 
@@ -304,23 +516,75 @@ export async function handleMessage(
304
516
  }
305
517
  state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
306
518
 
307
- // 取消指令优先于审批解析:「取消」不该被当成审批动作
519
+ // 待选择的编号回复(/tree、/fork、/resume 之后)
520
+ if (state.picker) {
521
+ if (pickerExpired(state.picker)) {
522
+ state.picker = null;
523
+ } else if (state.picker.openid === msg.openid) {
524
+ const index = parsePickIndex(msg.content);
525
+ if (index !== null) {
526
+ if (state.busy) {
527
+ await replyTo(api, state, msg.openid, "当前有任务在跑,先发 /cancel 取消,再回编号。");
528
+ return;
529
+ }
530
+ if (index > state.picker.items.length) {
531
+ await replyTo(api, state, msg.openid, `编号 ${index} 超出范围(1-${state.picker.items.length})。`);
532
+ return;
533
+ }
534
+ state.pickerOwner = msg.openid;
535
+ state.bridgeReply = (text: string) => {
536
+ void replyTo(api, state, msg.openid, text);
537
+ };
538
+ log("[cmd] 选择编号", index, "kind=", state.picker.kind);
539
+ pi.sendUserMessage(`/qq pick ${index}`, { expandPromptTemplates: true });
540
+ return;
541
+ }
542
+ if (/^(取消|算了|cancel)$/i.test(msg.content.trim())) {
543
+ state.picker = null;
544
+ await replyTo(api, state, msg.openid, "已放弃选择。");
545
+ return;
546
+ }
547
+ }
548
+ }
549
+
550
+ // 取消指令优先于审批解析:「取消」不该被当成审批动作;取消也永不入队
308
551
  if (isCancelIntent(msg.content)) {
309
- await handleCancel(state, api, msg.openid);
552
+ await handleCancel(pi, state, api, msg.openid);
310
553
  return;
311
554
  }
312
555
 
313
556
  // 斜杠命令路由:pi 扩展命令 → 可接管的内置命令 → 仅主机可用的内置命令 → 普通文字
314
557
  state.lastOpenid = msg.openid;
558
+ state.pickerOwner = msg.openid;
315
559
  state.bridgeReply = (text: string) => {
316
560
  void replyTo(api, state, msg.openid, text);
317
561
  };
318
562
  const routed = await routeSlash(
319
563
  {
320
- listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string }[],
321
- dispatch: (text: string) => pi.sendUserMessage(text, { expandPromptTemplates: true }),
564
+ listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string; source?: string }[],
565
+ dispatch: (text, options) => {
566
+ // 提示模板 / /skill: 会开启一轮 agent run:和普通对话一样串行到队列
567
+ if (options?.runsTurn) {
568
+ if (!isBusy(state)) {
569
+ startJob(pi, state, state.queue.mint(msg.openid, text));
570
+ return "dispatched";
571
+ }
572
+ const queued = state.queue.push(msg.openid, text);
573
+ if (!queued.ok) {
574
+ log("[queue] 队列已满,命令未排队:", text.slice(0, 60));
575
+ return "rejected";
576
+ }
577
+ log("[queue] 命令入队 #", queued.job.id, text.slice(0, 60));
578
+ return "queued";
579
+ }
580
+ // 扩展命令 / 桥接命令(/qq …)不开 run,pi 允许流式期间执行,直接派发
581
+ void Promise.resolve(pi.sendUserMessage(text, { expandPromptTemplates: true })).catch(
582
+ (e) => log("[cmd] 派发失败:", String(e)),
583
+ );
584
+ return "dispatched";
585
+ },
322
586
  reply: (text: string) => replyTo(api, state, msg.openid, text),
323
- busy: state.busy,
587
+ busy: isBusy(state),
324
588
  cfg: cfg.commands,
325
589
  log,
326
590
  },
@@ -383,60 +647,60 @@ export async function handleMessage(
383
647
 
384
648
  // 内置指令
385
649
  const cmd = parseCommand(msg.content);
386
- if (cmd === "help") {
650
+ if (cmd?.name === "help") {
387
651
  await replyTo(
388
652
  api,
389
653
  state,
390
654
  msg.openid,
391
655
  [
392
656
  "pi-qqbot 指令:",
393
- "/status — 查看状态(忙闲 / 自动同意 / 待审批)",
657
+ "/status — 查看状态(忙闲 / 队列 / 自动同意 / 待审批)",
658
+ "/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
394
659
  "/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
395
- "/cancel(或「取消」「停止」)— 中断正在运行的任务",
660
+ "/cancel(或「取消」「停止」)— 中断正在运行的任务并清空队列",
396
661
  "/help — 帮助",
397
662
  "pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
663
+ "忙碌时发来的普通消息(含模板/skill 命令)会排队,当前任务结束后自动执行。",
398
664
  "其余文本交给 pi 当作对话处理。",
399
665
  ].join("\n"),
400
666
  );
401
667
  return;
402
668
  }
403
- if (cmd === "status") {
669
+ if (cmd?.name === "status") {
404
670
  await replyTo(
405
671
  api,
406
672
  state,
407
673
  msg.openid,
408
674
  [
409
- `状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : "空闲"}`,
675
+ `状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
676
+ `队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
410
677
  `自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
411
678
  `待审批: ${state.approver.pendingCount}`,
412
679
  ].join("\n"),
413
680
  );
414
681
  return;
415
682
  }
416
- if (cmd === "reset") {
683
+ if (cmd?.name === "queue") {
684
+ await handleQueueCommand(api, state, msg.openid, cmd.args);
685
+ return;
686
+ }
687
+ if (cmd?.name === "reset") {
417
688
  state.replyBuffer.reset();
418
689
  state.approver.reset();
419
690
  await replyTo(api, state, msg.openid, "已重置(回复缓冲/审批记忆/自动同意)。要开新会话请发 /new。");
420
691
  return;
421
692
  }
422
693
 
423
- // 忙检查:单会话串行处理
424
- if (state.busy) {
425
- await replyTo(api, state, msg.openid, "上一条消息还在处理中,请稍候…");
694
+ // 忙检查:单会话串行处理——忙碌时入队,当前任务结束后自动继续
695
+ if (isBusy(state)) {
696
+ await enqueueJob(api, state, msg.openid, msg.content);
426
697
  return;
427
698
  }
428
699
 
429
700
  // ack
430
701
  await replyTo(api, state, msg.openid, "收到,正在处理");
431
702
 
432
- state.currentOpenid = msg.openid;
433
- state.busy = true;
434
- state.cancelled = false;
435
- state.replyBuffer.reset();
436
-
437
- // 交给 pi 处理
438
- pi.sendUserMessage(msg.content, { deliverAs: "followUp" });
439
- log("[msg] 已送入 pi:", msg.content.slice(0, 60));
703
+ startJob(pi, state, state.queue.mint(msg.openid, msg.content));
440
704
  } catch (e) {
441
705
  log("[msg] 处理失败:", String(e));
442
706
  await replyTo(api, state, msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
@@ -462,11 +726,53 @@ async function replyTo(
462
726
  }
463
727
  }
464
728
 
465
- function parseCommand(text: string): "reset" | "status" | "help" | null {
729
+ function parseCommand(text: string): { name: "reset" | "status" | "help" | "queue"; args: string } | null {
466
730
  // 注意:/new 现在属于 pi 的内置命令(由 slash 路由接管,真的开新会话),
467
731
  // 本地的「清缓冲/清审批记忆」改名为 /reset。
468
- const m = /^\/(reset|status|help)\s*$/i.exec(text.trim());
469
- return m ? (m[1].toLowerCase() as "reset" | "status" | "help") : null;
732
+ const m = /^\/(reset|status|help|queue|q)(?:\s+([\s\S]*))?$/i.exec(text.trim());
733
+ if (!m) return null;
734
+ const raw = m[1].toLowerCase();
735
+ return {
736
+ name: (raw === "q" ? "queue" : raw) as "reset" | "status" | "help" | "queue",
737
+ args: (m[2] ?? "").trim(),
738
+ };
739
+ }
740
+
741
+ /** /queue [clear|drop <编号>] */
742
+ async function handleQueueCommand(api: QQApi, state: State, openid: string, args: string) {
743
+ const parts = args.split(/\s+/).filter(Boolean);
744
+ const sub = parts[0]?.toLowerCase() ?? "";
745
+
746
+ if (!sub) {
747
+ await replyTo(api, state, openid, formatQueue(state));
748
+ return;
749
+ }
750
+ if (/^(clear|reset|清空)$/i.test(sub)) {
751
+ const dropped = state.queue.clear();
752
+ await replyTo(
753
+ api,
754
+ state,
755
+ openid,
756
+ dropped.length > 0 ? `已清空队列(${dropped.length} 条)。` : "队列本来就是空的。",
757
+ );
758
+ return;
759
+ }
760
+ if (/^(drop|del|rm|删除|删|去掉)$/i.test(sub)) {
761
+ const id = Number.parseInt(parts[1] ?? "", 10);
762
+ if (!Number.isFinite(id)) {
763
+ await replyTo(api, state, openid, "用法:/queue drop <编号>(编号见 /queue)");
764
+ return;
765
+ }
766
+ const removed = state.queue.remove(id);
767
+ await replyTo(
768
+ api,
769
+ state,
770
+ openid,
771
+ removed ? `已从队列删除 #${id}:${previewText(removed.text)}` : `队列里没有 #${id},发 /queue 查看。`,
772
+ );
773
+ return;
774
+ }
775
+ await replyTo(api, state, openid, "用法:/queue 查看 / /queue clear 清空 / /queue drop <编号> 删一条");
470
776
  }
471
777
 
472
778
  /** 从 tool_call 事件提取可读摘要,让用户在 QQ 里看清要执行什么 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxh126/pi-qqbot",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Bridge the Pi coding agent into QQ private chats, with remote tool-call approval",
6
6
  "license": "MIT",
@@ -26,6 +26,9 @@
26
26
  "./index.ts"
27
27
  ]
28
28
  },
29
+ "scripts": {
30
+ "test": "node test/queue.test.mjs"
31
+ },
29
32
  "files": [
30
33
  "*.ts",
31
34
  "qq",