@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.
- package/CHANGELOG.md +171 -0
- package/README.md +41 -10
- package/config.ts +158 -21
- package/constants.ts +5 -0
- package/docs/README.zh-CN.md +36 -9
- package/docs/accounts.md +88 -0
- package/index.ts +318 -54
- package/lock.ts +204 -0
- package/logger.ts +23 -2
- package/package.json +3 -2
- package/qq/auth.ts +10 -2
package/docs/accounts.md
ADDED
|
@@ -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 {
|
|
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 {
|
|
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?: {
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
"/
|
|
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
|
-
|
|
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
|
|
869
|
-
|
|
870
|
-
if (
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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 <编号>] */
|