@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/README.md +67 -13
- package/config.ts +14 -0
- package/constants.ts +4 -0
- package/docs/README.zh-CN.md +58 -10
- package/index.ts +352 -46
- package/package.json +4 -1
- package/queue.ts +118 -0
- package/slash.ts +261 -31
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
263
|
-
?
|
|
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
|
-
|
|
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
|
|
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
|
|
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 === "
|
|
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
|
|
425
|
-
await
|
|
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.
|
|
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
|
|
469
|
-
|
|
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.
|
|
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",
|