@zxh126/pi-qqbot 1.5.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 +42 -0
- package/README.md +26 -6
- package/config.ts +158 -21
- package/constants.ts +5 -0
- package/docs/README.zh-CN.md +24 -6
- package/docs/accounts.md +88 -0
- package/index.ts +197 -38
- package/lock.ts +204 -0
- package/logger.ts +23 -2
- package/package.json +2 -2
- package/qq/auth.ts +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,48 @@
|
|
|
3
3
|
本文件记录 pi-qqbot 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
|
4
4
|
版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
|
|
5
5
|
|
|
6
|
+
## [2.0.0] - 2026-10-25
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **多 QQ bot(一份配置、一台机器、多个 pi 进程)**:配置里用 `qqbots: [...]` 写多组
|
|
11
|
+
`appId` / `appSecret`,支持 `name`、以及逐账号覆盖 `sandbox` / `allowlist` / `queue` /
|
|
12
|
+
`progress` / `approve` / `commands`(未写的继承全局默认值,逐键合并)。
|
|
13
|
+
- **自动抢占**:每个 pi 进程启动时(`session_start`)按配置顺序找第一个**未上线**的 bot 接入;
|
|
14
|
+
抢不到的进程不接入 QQ、但每 5 秒重扫,别人退出后接管。一进程一把锁,绝不并发多接。
|
|
15
|
+
- **「不抢别人 reload 中临时释放的 bot」**:会话重建(`/reload`、`/new`、`/fork`、`/resume`)时,
|
|
16
|
+
释放锁**之前**写 20 秒预约窗口,其他进程跳过该 bot;抢锁后还会二次读预约记录,
|
|
17
|
+
撞上就让出(关掉竞态窗口)。新 runtime 用同一 pid 认回自己的 bot,静默不打扰。
|
|
18
|
+
- **身份可见**:接入日志带 bot 名称 / appId / cwd / pid;QQ 首条回执前加「(已接入 qqbot「work」)」;
|
|
19
|
+
`/status` 首行显示 `bot: work (…0143) · cwd: …`。
|
|
20
|
+
- **换进程接管提示**:`~/.pi/agent/pi-qqbot-<appId>.owner.json` 记录上一位持有者的 pid 与工作目录,
|
|
21
|
+
换进程时日志写新旧 pid/cwd,QQ 首条回执带「(qqbot「work」已由新实例接管 · 当前工作目录 …)」。
|
|
22
|
+
- `PI_QQBOT_ACCOUNT=<name|appId>` 钉选账号(调试/固定目录用),名字不存在则不接入并写日志。
|
|
23
|
+
- **抢占后先验凭据**:抢到锁立即换一次 `access_token`,失败就**放锁**并跳过该 bot(继续试下一个),
|
|
24
|
+
避免「凭据写错的 bot 占着锁位但永远连不上」;全部不可用时不空转重扫。
|
|
25
|
+
- **修复** `AuthManager`:QQ 平台对无效凭据返回 HTTP 200 + `code 100016`,以前只拿空 token 去连网关,
|
|
26
|
+
现在直接抛错(带 code/message),并在抢占阶段被发现。
|
|
27
|
+
- 新增文档 [docs/accounts.md](docs/accounts.md);新增测试 `test/config.test.mjs`(9)、
|
|
28
|
+
`test/lock.test.mjs`(8,真子进程 + 真 lockfile,含「同一工作目录也能各接一个」)、
|
|
29
|
+
`test/accounts.test.mjs`(9,端到端抢占/提示/凭据无效降级)、`test/auth.test.mjs`(3)。
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- 日志按账号分文件:`~/.pi/agent/pi-qqbot-<name>.log`(同机多进程不再交错);
|
|
34
|
+
单 bot 旧格式仍写 `pi-qqbot.log`。
|
|
35
|
+
- `/status` 增加 bot 身份与工作目录行;`/help` 增加多 bot 说明。
|
|
36
|
+
- 配置加载改为 `loadAccounts()`(返回按顺序排好的账号列表),`loadConfig()` 保留为兼容包装。
|
|
37
|
+
|
|
38
|
+
### Compatibility
|
|
39
|
+
|
|
40
|
+
- **旧的单 bot 配置零改动可用**(等价于名为 `default` 的账号,行为与 1.5.0 一致);
|
|
41
|
+
`QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 仍只用于单 bot 场景。
|
|
42
|
+
- 同时写顶层 `appId` 与 `qqbots` → 报错不接入(歧义不猜);单个账号凭据缺失/`appId` 重复/`name`
|
|
43
|
+
重复 → 跳过该条并写日志,其余照常。
|
|
44
|
+
- **bot 与工作目录无绑定**:分配只看配置顺序与在线状态,与 cwd 无关——同一目录开多个 pi
|
|
45
|
+
也照样一人接一个 bot。cwd 仅用于日志、接管提示与 `/status` 展示。
|
|
46
|
+
- 升级后建议重启(或 `/reload`)一次:新版本启动时才会按账号分文件写日志。
|
|
47
|
+
|
|
6
48
|
## [1.5.0] - 2026-10-25
|
|
7
49
|
|
|
8
50
|
### Added
|
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: se
|
|
|
19
19
|
| **Slash-command routing** | QQ messages starting with `/` are routed: pi extension commands, prompt templates and `/skill:name` are dispatched to pi; a set of pi built-ins (`/reload`, `/new`, `/compact`, `/session`, `/name`, `/model`, `/thinking`, `/quit`) is taken over; host-only commands are answered instead of being fed to the model. |
|
|
20
20
|
| **Progress reports** | While a run is in progress the bot posts a short progress summary (turns, tool calls, the tool currently running, tokens) every `progress.intervalMs` (default **15 s**). Ask at any time with `/progress` (aliases `/p`, 「进度」). `/progress off` pauses the automatic reports for the rest of the session, `/progress on` brings them back. If a report cannot be delivered the automatic reports stay quiet until you ask again. |
|
|
21
21
|
| **Secret redaction** | Anything leaving the machine is scrubbed first — progress reports, approval prompts, queue previews and the log file: `Authorization` headers (incl. QQ bot tokens), `sk-…` / `ghp_…` / `npm_…` / `AKIA…` / JWTs / PEM blocks, `token=` / `password=` / `--api-key` values, URL `user:pass@`, and long high-entropy strings. |
|
|
22
|
+
| **Multi-bot on one machine** | Put several QQ bots in one config (`qqbots: [...]`, see [docs/accounts.md](./docs/accounts.md)). Each pi process, in config order, claims the first bot that is not online yet and then verifies its credentials (a wrong secret releases the lock instead of squatting the slot); a process that finds every bot taken stays idle and rescans every 5 s, taking over as soon as one goes offline. A bot whose owner is merely reloading is never snatched (a 20 s reservation is left behind). The bot name is logged, shown in `/status` and prepended to the first QQ reply; taking over from another process also tells you the new working directory. |
|
|
22
23
|
| **Built-in commands** | `/status`, `/queue`, `/progress` (+ `/progress off` / `on`), `/reset`, `/cancel`, `/help`. |
|
|
23
24
|
|
|
24
25
|
## Install
|
|
@@ -68,7 +69,24 @@ Then create `~/.pi/agent/pi-qqbot.json`:
|
|
|
68
69
|
| `queue.max` | `20` | How many messages may wait while a task runs; `0` disables queueing (busy messages are refused as before) |
|
|
69
70
|
| `queue.ack` | `true` | Reply to QQ when a queued [message](#queueing-while-busy) starts (the "queued" ack is always sent) |
|
|
70
71
|
|
|
71
|
-
Credentials can also come from the `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` environment variables.
|
|
72
|
+
Credentials can also come from the `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` environment variables (single-bot setup only).
|
|
73
|
+
|
|
74
|
+
### Several bots (2.0)
|
|
75
|
+
|
|
76
|
+
```jsonc
|
|
77
|
+
{
|
|
78
|
+
"qqbots": [
|
|
79
|
+
{ "name": "work", "appId": "1903340143", "appSecret": "…", "sandbox": true },
|
|
80
|
+
{ "name": "home", "appId": "1020123456", "appSecret": "…" }
|
|
81
|
+
],
|
|
82
|
+
"queue": { "max": 20, "ack": true } // still the global defaults, per-bot overrides allowed
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Every pi process claims the first bot (in config order) that is not online; spare processes stand by and rescan
|
|
87
|
+
every 5 s. After claiming, the credentials are verified by fetching an `access_token`: a bot with a wrong
|
|
88
|
+
`appId`/`appSecret` releases its lock and is skipped instead of squatting the slot. `PI_QQBOT_ACCOUNT=<name|appId>`
|
|
89
|
+
pins one bot instead of auto-claiming. Full details: [docs/accounts.md](./docs/accounts.md).
|
|
72
90
|
|
|
73
91
|
## Usage
|
|
74
92
|
|
|
@@ -77,7 +95,7 @@ Start pi, then message the bot in QQ; pi handles it and replies.
|
|
|
77
95
|
| Command | Description |
|
|
78
96
|
|---|---|
|
|
79
97
|
| `/reset` | Clear the reply buffer, approval memory and session-wide auto-approve (**does not** switch sessions — send `/new` for that) |
|
|
80
|
-
| `/status` | Show busy/idle state, queue length, auto-approve state, pending approval count and the progress-report mode |
|
|
98
|
+
| `/status` | Show the bot this process serves (name + working directory), busy/idle state, queue length, auto-approve state, pending approval count and the progress-report mode |
|
|
81
99
|
| `/queue` | Show the queue (aliases: `/q`); `/queue clear` empties it, `/queue drop <id>` removes one entry |
|
|
82
100
|
| `/progress` | **Show the current progress summary immediately** (aliases: `/p`, 「进度」, 「当前进度」, 「进展」; plain-text questions like 「到哪了」「怎么样了」 work while busy). While you paused reports yourself it only replies once — use `/progress on` to resume; while reports are merely quota-quiet, asking resumes them |
|
|
83
101
|
| `/progress off` | **Pause automatic progress reports for the rest of the session** (aliases: `pause`, `stop`, `暂停`, 「暂停汇报」, 「关闭汇报」). Manual `/progress` still works, it just no longer resumes the timer |
|
|
@@ -259,8 +277,10 @@ Semantics:
|
|
|
259
277
|
The QQ gateway keeps only one active connection per bot. The extension uses a `proper-lockfile` lock at `~/.pi/agent/pi-qqbot-<appId>.lock`:
|
|
260
278
|
|
|
261
279
|
- the process that acquires the lock becomes the host and connects to QQ;
|
|
262
|
-
- other processes stand by
|
|
263
|
-
- when the host exits or crashes (30 second heartbeat timeout), a standby process takes over
|
|
280
|
+
- other processes stand by, retry every 5 seconds, and claim **any** bot of the list that is free;
|
|
281
|
+
- when the host exits or crashes (30 second heartbeat timeout), a standby process takes over;
|
|
282
|
+
- a host that is only rebuilding its session (`/reload`, `/new`, `/fork`, `/resume`) writes a 20 second reservation next to the lock, so nobody else grabs its bot in the gap;
|
|
283
|
+
- the last owner (pid + working directory) is kept in `~/.pi/agent/pi-qqbot-<appId>.owner.json` and is used to tell you that a **new process** took over, and from which directory.
|
|
264
284
|
|
|
265
285
|
## Prerequisites
|
|
266
286
|
|
|
@@ -270,12 +290,12 @@ The QQ gateway keeps only one active connection per bot. The extension uses a `p
|
|
|
270
290
|
|
|
271
291
|
## Logs
|
|
272
292
|
|
|
273
|
-
Debug logs are written to `~/.pi/agent/pi-qqbot.log`. Each line starts with a **local** timestamp that carries the UTC offset, e.g. `[2026-10-24T07:39:09.762+08:00]`, so it lines up with file mtimes and process start times without mental arithmetic.
|
|
293
|
+
Debug logs are written to `~/.pi/agent/pi-qqbot-<name>.log` (one file per bot, so several pi processes on the same machine never interleave). A single-bot v1 config keeps writing `pi-qqbot.log`. Each line starts with a **local** timestamp that carries the UTC offset, e.g. `[2026-10-24T07:39:09.762+08:00]`, so it lines up with file mtimes and process start times without mental arithmetic.
|
|
274
294
|
|
|
275
295
|
## Changelog
|
|
276
296
|
|
|
277
297
|
See [CHANGELOG.md](./CHANGELOG.md) — every release with what changed and why. The progress-report
|
|
278
|
-
pause/resume (`/progress off` / `on`) landed in **1.5.0**.
|
|
298
|
+
pause/resume (`/progress off` / `on`) landed in **1.5.0**; multi-bot auto-claiming in **2.0.0**.
|
|
279
299
|
|
|
280
300
|
## License
|
|
281
301
|
|
package/config.ts
CHANGED
|
@@ -41,41 +41,178 @@ export interface QQBotConfig {
|
|
|
41
41
|
progress: ProgressConfig;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* 配置里的一个 QQ bot。
|
|
46
|
+
*
|
|
47
|
+
* 除凭据外,所有字段都是「对全局默认值的逐键覆盖」——没写的就继承顶层
|
|
48
|
+
* `queue` / `progress` / `approve` / `commands` / `sandbox` / `allowlist`。
|
|
49
|
+
*/
|
|
50
|
+
export interface QQBotsEntry {
|
|
51
|
+
/** 展示名(日志、/status、抢占提示都用它);缺省 = `bot-<appId 后 6 位>` */
|
|
52
|
+
name?: string;
|
|
53
|
+
appId: string;
|
|
54
|
+
appSecret: string;
|
|
55
|
+
sandbox?: boolean;
|
|
56
|
+
allowlist?: string[];
|
|
57
|
+
markdownReply?: boolean;
|
|
58
|
+
streaming?: boolean;
|
|
59
|
+
toolProgress?: boolean;
|
|
60
|
+
approve?: Partial<ApproveConfig>;
|
|
61
|
+
commands?: Partial<CommandConfig>;
|
|
62
|
+
queue?: Partial<QueueConfig>;
|
|
63
|
+
progress?: Partial<ProgressConfig>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 校验并归一化后的账号(凭据 + 合并好的完整配置) */
|
|
67
|
+
export interface QQBotAccount {
|
|
68
|
+
name: string;
|
|
69
|
+
appId: string;
|
|
70
|
+
/** 该账号最终生效的配置:全局 defaults + 本账号覆盖 */
|
|
71
|
+
config: QQBotConfig;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface LoadAccountsResult {
|
|
75
|
+
accounts: QQBotAccount[];
|
|
76
|
+
/** 配置错误:有错误时**不启动网关**,避免「机器人没反应又不知道为什么」 */
|
|
77
|
+
errors: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DEFAULT_PROGRESS_MS = 15000;
|
|
81
|
+
|
|
82
|
+
const asObject = (value: unknown): any =>
|
|
83
|
+
value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
84
|
+
|
|
85
|
+
function readFile(): any {
|
|
46
86
|
try {
|
|
47
|
-
|
|
87
|
+
return asObject(JSON.parse(fs.readFileSync(CONFIG_PATH(), "utf8")));
|
|
48
88
|
} catch {
|
|
49
89
|
/* 文件不存在或非法不致命,凭据可完全来自环境变量 */
|
|
90
|
+
return {};
|
|
50
91
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 缺省名字:`bot-<appId 后 6 位>`,方便在多 bot 场景里一眼分辨 */
|
|
95
|
+
export function defaultAccountName(appId: string): string {
|
|
96
|
+
const tail = appId.replace(/[^0-9a-zA-Z]/g, "").slice(-6);
|
|
97
|
+
return tail ? `bot-${tail}` : "default";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 全局 defaults(顶层字段)与账号覆盖合并成一份可直接用的配置 */
|
|
101
|
+
export function resolveConfig(
|
|
102
|
+
file: any,
|
|
103
|
+
entry: QQBotsEntry,
|
|
104
|
+
fallback?: { appId: string; appSecret: string },
|
|
105
|
+
): QQBotConfig {
|
|
106
|
+
const appId = entry.appId ?? fallback?.appId ?? "";
|
|
107
|
+
const appSecret = entry.appSecret ?? fallback?.appSecret ?? "";
|
|
108
|
+
const envInterval = process.env.PI_QQBOT_PROGRESS_MS;
|
|
54
109
|
return {
|
|
55
110
|
appId,
|
|
56
111
|
appSecret,
|
|
57
|
-
sandbox: file.sandbox ?? false,
|
|
58
|
-
allowlist: file.allowlist ?? [],
|
|
59
|
-
markdownReply: file.markdownReply ?? false,
|
|
60
|
-
streaming: file.streaming ?? false,
|
|
61
|
-
toolProgress: file.toolProgress ?? false,
|
|
112
|
+
sandbox: entry.sandbox ?? file.sandbox ?? false,
|
|
113
|
+
allowlist: entry.allowlist ?? file.allowlist ?? [],
|
|
114
|
+
markdownReply: entry.markdownReply ?? file.markdownReply ?? false,
|
|
115
|
+
streaming: entry.streaming ?? file.streaming ?? false,
|
|
116
|
+
toolProgress: entry.toolProgress ?? file.toolProgress ?? false,
|
|
62
117
|
approve: {
|
|
63
|
-
tools: file.approve?.tools ?? ["bash", "powershell", "write", "edit"],
|
|
64
|
-
remember: file.approve?.remember ?? true,
|
|
118
|
+
tools: entry.approve?.tools ?? file.approve?.tools ?? ["bash", "powershell", "write", "edit"],
|
|
119
|
+
remember: entry.approve?.remember ?? file.approve?.remember ?? true,
|
|
65
120
|
},
|
|
66
121
|
commands: {
|
|
67
|
-
passthrough: file.commands?.passthrough ?? DEFAULT_COMMANDS.passthrough,
|
|
68
|
-
allowReload: file.commands?.allowReload ?? DEFAULT_COMMANDS.allowReload,
|
|
69
|
-
allowQuit: file.commands?.allowQuit ?? DEFAULT_COMMANDS.allowQuit,
|
|
70
|
-
allowSession: file.commands?.allowSession ?? DEFAULT_COMMANDS.allowSession,
|
|
122
|
+
passthrough: entry.commands?.passthrough ?? file.commands?.passthrough ?? DEFAULT_COMMANDS.passthrough,
|
|
123
|
+
allowReload: entry.commands?.allowReload ?? file.commands?.allowReload ?? DEFAULT_COMMANDS.allowReload,
|
|
124
|
+
allowQuit: entry.commands?.allowQuit ?? file.commands?.allowQuit ?? DEFAULT_COMMANDS.allowQuit,
|
|
125
|
+
allowSession: entry.commands?.allowSession ?? file.commands?.allowSession ?? DEFAULT_COMMANDS.allowSession,
|
|
71
126
|
},
|
|
72
127
|
queue: {
|
|
73
|
-
max: file.queue?.max ?? DEFAULT_QUEUE_MAX,
|
|
74
|
-
ack: file.queue?.ack ?? true,
|
|
128
|
+
max: entry.queue?.max ?? file.queue?.max ?? DEFAULT_QUEUE_MAX,
|
|
129
|
+
ack: entry.queue?.ack ?? file.queue?.ack ?? true,
|
|
75
130
|
},
|
|
76
131
|
progress: {
|
|
77
|
-
enabled: file.progress?.enabled ?? true,
|
|
78
|
-
intervalMs: Number(
|
|
132
|
+
enabled: entry.progress?.enabled ?? file.progress?.enabled ?? true,
|
|
133
|
+
intervalMs: Number(
|
|
134
|
+
envInterval ?? entry.progress?.intervalMs ?? file.progress?.intervalMs ?? DEFAULT_PROGRESS_MS,
|
|
135
|
+
),
|
|
79
136
|
},
|
|
80
137
|
};
|
|
81
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 读配置,返回**按配置顺序**排好的账号列表。
|
|
142
|
+
*
|
|
143
|
+
* 支持两种格式:
|
|
144
|
+
* - v2 多 bot:`{ "qqbots": [ { "name": "work", "appId": … }, … ], "queue": {…}, … }`
|
|
145
|
+
* - v1 单 bot:`{ "appId": …, "appSecret": …, … }`(或全部来自 `QQ_BOT_APPID/SECRET`),
|
|
146
|
+
* 等价于只有一个名叫 `default` 的账号,行为与 1.x 完全一致。
|
|
147
|
+
*/
|
|
148
|
+
export function loadAccounts(): LoadAccountsResult {
|
|
149
|
+
const file = readFile();
|
|
150
|
+
const errors: string[] = [];
|
|
151
|
+
const envAppId = process.env.QQ_BOT_APPID;
|
|
152
|
+
const envAppSecret = process.env.QQ_BOT_APPSECRET;
|
|
153
|
+
|
|
154
|
+
let entries: QQBotsEntry[];
|
|
155
|
+
if (Array.isArray(file.qqbots)) {
|
|
156
|
+
if (file.appId) {
|
|
157
|
+
errors.push("配置里同时出现顶层 appId 和 qqbots 数组:请只保留一种格式(多 bot 请写进 qqbots)");
|
|
158
|
+
return { accounts: [], errors };
|
|
159
|
+
}
|
|
160
|
+
entries = file.qqbots.map((item: any) => asObject(item));
|
|
161
|
+
if (entries.length === 0) {
|
|
162
|
+
errors.push("qqbots 是空数组:至少配置一个 bot,否则插件不会接入 QQ");
|
|
163
|
+
return { accounts: [], errors };
|
|
164
|
+
}
|
|
165
|
+
} else if (file.appId || envAppId) {
|
|
166
|
+
entries = [
|
|
167
|
+
{
|
|
168
|
+
name: "default",
|
|
169
|
+
appId: envAppId ?? file.appId,
|
|
170
|
+
appSecret: envAppSecret ?? file.appSecret,
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
} else {
|
|
174
|
+
return { accounts: [], errors };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const accounts: QQBotAccount[] = [];
|
|
178
|
+
const seenNames = new Set<string>();
|
|
179
|
+
const seenAppIds = new Set<string>();
|
|
180
|
+
|
|
181
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
182
|
+
const raw = asObject(entries[index]);
|
|
183
|
+
const appId = String(raw.appId ?? "").trim();
|
|
184
|
+
const appSecret = String(raw.appSecret ?? "").trim();
|
|
185
|
+
const label = `qqbots[${index}]${raw.name ? `(name=${raw.name})` : ""}`;
|
|
186
|
+
if (!appId || !appSecret) {
|
|
187
|
+
// 单个账号缺凭据只跳过它,别让整个插件都起不来
|
|
188
|
+
errors.push(`${label} 缺少 appId 或 appSecret,已跳过`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (seenAppIds.has(appId)) {
|
|
192
|
+
errors.push(`${label} 的 appId ${appId} 重复,已跳过(否则会双连接被平台踢)`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const name = String(raw.name ?? defaultAccountName(appId)).trim() || defaultAccountName(appId);
|
|
196
|
+
if (seenNames.has(name)) {
|
|
197
|
+
errors.push(`${label} 的 name "${name}" 与其他 bot 重复,已跳过(改名后重试)`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
seenAppIds.add(appId);
|
|
201
|
+
seenNames.add(name);
|
|
202
|
+
accounts.push({ name, appId, config: resolveConfig(file, raw) });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { accounts, errors };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 兼容旧调用:只取第一个账号的配置(1.x 单 bot 语义) */
|
|
209
|
+
export function loadConfig(): QQBotConfig | null {
|
|
210
|
+
const { accounts } = loadAccounts();
|
|
211
|
+
return accounts[0]?.config ?? null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 环境变量指定的账号名(调试/测试用:钉住某个 bot,不参与自动抢占) */
|
|
215
|
+
export function pinnedAccountName(): string | null {
|
|
216
|
+
const name = process.env.PI_QQBOT_ACCOUNT?.trim();
|
|
217
|
+
return name ? name : null;
|
|
218
|
+
}
|
package/constants.ts
CHANGED
|
@@ -20,3 +20,8 @@ const PI_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
|
20
20
|
export const PI_AGENT_DIR = PI_DIR;
|
|
21
21
|
export const CONFIG_PATH = () => path.join(PI_DIR, "pi-qqbot.json");
|
|
22
22
|
export const LOCK_PATH = (appId: string) => path.join(PI_DIR, `pi-qqbot-${appId}.lock`);
|
|
23
|
+
/** 记录「谁占着这个 bot」:pid/cwd/账号名 + 临时释放的预约窗口(换进程时用来生成提示) */
|
|
24
|
+
export const OWNER_PATH = (appId: string) => path.join(PI_DIR, `pi-qqbot-${appId}.owner.json`);
|
|
25
|
+
/** 日志文件(按账号分文件:多进程同机时不交错) */
|
|
26
|
+
export const LOG_PATH = (account: string, legacy = false) =>
|
|
27
|
+
path.join(PI_DIR, legacy ? "pi-qqbot.log" : `pi-qqbot-${account}.log`);
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
| **斜杠命令路由** | 以 `/` 开头的 QQ 消息会先被路由:pi 的扩展命令、prompt 模板、`/skill:name` 交给 pi 派发;一部分 pi 内置命令(`/reload`、`/new`、`/compact`、`/session`、`/name`、`/model`、`/thinking`、`/quit`)由本扩展接管;只能在主机执行的内置命令直接回执,不会送给模型。 |
|
|
20
20
|
| **进度汇报** | 任务运行期间按 `progress.intervalMs`(默认 **15 秒**)推一条进度摘要(轮次、工具调用、正在跑的工具、token 用量)。随时发 `/progress`(别名 `/p`、「进度」)可立即获取;`/progress off` 在本会话内暂停自动汇报,`/progress on` 恢复。推送失败后自动推送静默,直到你再次问询。 |
|
|
21
21
|
| **敏感信息脱敏** | 离开本机的文本先脱敏——进度摘要、审批提示、队列预览、日志文件:`Authorization` 头(含 QQ bot token)、`sk-…`/`ghp_…`/`npm_…`/`AKIA…`/JWT/PEM、`token=`/`password=`/`--api-key` 的值、URL 里的 `user:pass@`,以及超长高熵串。 |
|
|
22
|
+
| **多 bot 同机** | 一份配置里写多个 QQ bot(`qqbots: [...]`,详见 [accounts.md](./accounts.md)),同一台机器上每个 pi 进程按配置顺序接入第一个未上线的 bot;全部在线则待命、每 5 秒重扫,有人退出就接管。别人只是 `/reload` 临时释放的 bot 不会被抢(留 20 秒预约窗口)。接入/接管时会告知当前 bot 名称与工作目录。 |
|
|
22
23
|
| **内置指令** | `/status`、`/queue`、`/progress`、`/reset`、`/cancel`、`/help`。 |
|
|
23
24
|
|
|
24
25
|
## 安装
|
|
@@ -68,7 +69,22 @@ pi install ./pi-qqbot
|
|
|
68
69
|
| `progress.enabled` | `true` | 任务运行期间是否定期推送进度摘要(会话启动时读取,改完需 `/new` 或重启;运行时用 `/progress off`) |
|
|
69
70
|
| `progress.intervalMs` | `15000` | 推送间隔(可用环境变量 `PI_QQBOT_PROGRESS_MS` 覆盖,便于测试) |
|
|
70
71
|
|
|
71
|
-
也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET`
|
|
72
|
+
也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件里的凭据(仅单 bot 场景)。
|
|
73
|
+
|
|
74
|
+
### 多个 bot(2.0)
|
|
75
|
+
|
|
76
|
+
```jsonc
|
|
77
|
+
{
|
|
78
|
+
"qqbots": [
|
|
79
|
+
{ "name": "work", "appId": "1903340143", "appSecret": "…", "sandbox": true },
|
|
80
|
+
{ "name": "home", "appId": "1020123456", "appSecret": "…" }
|
|
81
|
+
],
|
|
82
|
+
"queue": { "max": 20, "ack": true } // 这里仍是全局默认,qqbots 内可逐键覆盖
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
多个 pi 进程按配置顺序各自接入一个未上线的 bot,抢不到的待命并每 5 秒重扫;
|
|
87
|
+
`PI_QQBOT_ACCOUNT=<name|appId>` 可以钉住某个 bot,不走自动抢占。细节见 [accounts.md](./accounts.md)。
|
|
72
88
|
|
|
73
89
|
## 使用
|
|
74
90
|
|
|
@@ -77,7 +93,7 @@ pi install ./pi-qqbot
|
|
|
77
93
|
| 指令 | 说明 |
|
|
78
94
|
|---|---|
|
|
79
95
|
| `/reset` | 重置:清空回复缓冲、审批记忆与自动同意开关(**不换会话**,换会话发 `/new`) |
|
|
80
|
-
| `/status` |
|
|
96
|
+
| `/status` | 查看本进程服务的 bot(名称 + 工作目录)、忙闲状态、队列长度、自动同意状态、待审批数量、自动汇报模式 |
|
|
81
97
|
| `/queue` | 查看队列(别名 `/q`);`/queue clear` 清空,`/queue drop <编号>` 删一条 |
|
|
82
98
|
| `/progress` | **立即获取当前进度摘要**(别名 `/p`、「进度」「当前进度」「进展」;忙碌时「到哪了」「怎么样了」这类问法同样生效)。手动暂停期间**问一次只回一次**,要重新打开定时汇报需发 `/progress on` 或「恢复汇报」;若只是配额静默,问一次即恢复 |
|
|
83
99
|
| `/progress off` | **在本会话内暂停自动进度汇报**(别名:`pause`、`stop`、`暂停`、「暂停汇报」、「关闭汇报」)。手动 `/progress` 仍可看一次,但不再解除暂停 |
|
|
@@ -245,8 +261,10 @@ pi 的会话一次只跑一轮 agent run,所以串行调度由 bot 自己做
|
|
|
245
261
|
QQ 网关对同一 bot 只保留一个活跃连接。插件用 `proper-lockfile` 文件锁(`~/.pi/agent/pi-qqbot-<appId>.lock`):
|
|
246
262
|
|
|
247
263
|
- 抢到锁的进程 = 宿主,连接 QQ;
|
|
248
|
-
- 其余进程待命,每 5
|
|
249
|
-
- 宿主退出或崩溃(30
|
|
264
|
+
- 其余进程待命,每 5 秒重扫,**列表里任何一个空闲的 bot** 都能抢;
|
|
265
|
+
- 宿主退出或崩溃(30 秒心跳超时)后,待命进程自动接管;
|
|
266
|
+
- 只是重建会话(`/reload`、`/new`、`/fork`、`/resume`)的宿主会在锁旁留 20 秒预约,别人不会趁空抢走;
|
|
267
|
+
- 上一位持有者的 pid 与工作目录记在 `~/.pi/agent/pi-qqbot-<appId>.owner.json`,用来提示「换进程接管」以及新实例的工作目录。
|
|
250
268
|
|
|
251
269
|
## 前置条件
|
|
252
270
|
|
|
@@ -256,11 +274,11 @@ QQ 网关对同一 bot 只保留一个活跃连接。插件用 `proper-lockfile`
|
|
|
256
274
|
|
|
257
275
|
## 日志
|
|
258
276
|
|
|
259
|
-
调试日志写入 `~/.pi/agent/pi-qqbot.log`。每行开头是**本地时间 + 时区偏移**(如 `[2026-10-24T07:39:09.762+08:00]`),和文件 mtime、进程启动时间直接对得上,不需要再拿 UTC 换算。
|
|
277
|
+
调试日志写入 `~/.pi/agent/pi-qqbot-<name>.log`(按 bot 分文件,同机多进程不交错);单 bot 旧格式仍写 `pi-qqbot.log`。每行开头是**本地时间 + 时区偏移**(如 `[2026-10-24T07:39:09.762+08:00]`),和文件 mtime、进程启动时间直接对得上,不需要再拿 UTC 换算。
|
|
260
278
|
|
|
261
279
|
## 变更日志
|
|
262
280
|
|
|
263
|
-
见 [CHANGELOG.md](../CHANGELOG.md):每个版本改了什么、为什么改。进度汇报的暂停/恢复(`/progress off` / `on`)在 **1.5.0**
|
|
281
|
+
见 [CHANGELOG.md](../CHANGELOG.md):每个版本改了什么、为什么改。进度汇报的暂停/恢复(`/progress off` / `on`)在 **1.5.0** 加入;多 bot 自动抢占在 **2.0.0**。
|
|
264
282
|
|
|
265
283
|
## License
|
|
266
284
|
|
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;
|
|
@@ -94,13 +99,22 @@ export function createInitialState(): State {
|
|
|
94
99
|
progressPaused: false,
|
|
95
100
|
progressMuted: false,
|
|
96
101
|
undelivered: null,
|
|
102
|
+
account: null,
|
|
103
|
+
candidates: [],
|
|
104
|
+
claimNote: null,
|
|
97
105
|
};
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
export default function (
|
|
101
109
|
pi: ExtensionAPI,
|
|
102
110
|
/** 仅测试用:注入状态,让事件处理器与 handleMessage 共享同一份状态 */
|
|
103
|
-
options?: {
|
|
111
|
+
options?: {
|
|
112
|
+
state?: State;
|
|
113
|
+
/** 仅测试用:不真连 QQ 网关(避免单测打网络),其余流程(抢锁/身份/提示)照常 */
|
|
114
|
+
disableGateway?: boolean;
|
|
115
|
+
/** 仅测试用:替换「抢到锁后验凭据」的实现(默认真的去换 access_token) */
|
|
116
|
+
verifyCredentials?: (account: QQBotAccount) => Promise<AuthManager>;
|
|
117
|
+
},
|
|
104
118
|
) {
|
|
105
119
|
const state: State = options?.state ?? createInitialState();
|
|
106
120
|
|
|
@@ -235,69 +249,205 @@ export default function (
|
|
|
235
249
|
return { block: true, reason: `QQ 用户拒绝了 ${summary}` };
|
|
236
250
|
});
|
|
237
251
|
|
|
238
|
-
// ---- 事件:session_start
|
|
239
|
-
pi.on("session_start", async () => {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
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(或配置不可用),插件未启用");
|
|
243
258
|
return;
|
|
244
259
|
}
|
|
245
|
-
|
|
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
|
+
|
|
246
277
|
// 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
|
|
247
278
|
state.approver.reset();
|
|
248
279
|
// 手动暂停的自动汇报同样只在本会话有效
|
|
249
280
|
state.progressMuted = false;
|
|
250
281
|
state.progressPaused = false;
|
|
251
|
-
// 队列上限来自配置;新会话不继承上一个会话遗留的待执行任务
|
|
252
|
-
state.queue.setLimit(cfg.queue.max);
|
|
253
282
|
state.queue.clear();
|
|
254
|
-
log("[setup] 配置已加载 sandbox=", cfg.sandbox, "queue.max=", cfg.queue.max);
|
|
255
283
|
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
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
|
+
);
|
|
260
314
|
|
|
261
|
-
|
|
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
|
+
}
|
|
337
|
+
|
|
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
|
+
}
|
|
262
346
|
const gateway = new QQGateway({
|
|
263
347
|
getGatewayUrl: createGatewayUrlFetcher(restBase, () => auth.getToken()),
|
|
264
348
|
getToken: () => auth.getToken(),
|
|
265
349
|
intents: INTENT_GROUP_AND_C2C,
|
|
266
350
|
connected: () => {},
|
|
267
351
|
disconnected: () => {},
|
|
268
|
-
message: (msg) => handleMessage(pi, state, api, cfg, msg),
|
|
352
|
+
message: (msg) => handleMessage(pi, state, api, state.cfg ?? cfg, msg),
|
|
269
353
|
});
|
|
270
354
|
state.gateway = gateway;
|
|
271
355
|
log("[lock] 启动 QQ 网关");
|
|
272
356
|
gateway.start();
|
|
273
357
|
};
|
|
274
358
|
|
|
275
|
-
//
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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();
|
|
290
434
|
});
|
|
291
435
|
|
|
292
436
|
// ---- 事件:session_shutdown 清理 ----
|
|
293
|
-
pi.on("session_shutdown", async () => {
|
|
294
|
-
|
|
437
|
+
pi.on("session_shutdown", async (event) => {
|
|
438
|
+
const reason = String((event as any)?.reason ?? "quit");
|
|
439
|
+
log("[shutdown] 清理资源 reason=", reason);
|
|
295
440
|
clearCancelTimer(state);
|
|
296
441
|
stopProgressTimer(state);
|
|
297
442
|
const dropped = state.queue.clear();
|
|
298
443
|
if (dropped.length > 0) log("[shutdown] 丢弃队列任务", dropped.length, "条");
|
|
299
444
|
if (state.stopRetry) state.stopRetry();
|
|
300
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
|
+
}
|
|
301
451
|
if (state.releaseLock) {
|
|
302
452
|
try {
|
|
303
453
|
await state.releaseLock();
|
|
@@ -308,6 +458,7 @@ export default function (
|
|
|
308
458
|
state.releaseLock = null;
|
|
309
459
|
state.gateway = null;
|
|
310
460
|
state.api = null;
|
|
461
|
+
state.account = null;
|
|
311
462
|
});
|
|
312
463
|
|
|
313
464
|
// ---- /qq:QQ 侧命令的桥接入口 ----
|
|
@@ -726,13 +877,14 @@ export async function handleMessage(
|
|
|
726
877
|
[
|
|
727
878
|
"pi-qqbot 指令:",
|
|
728
879
|
"/progress — 查看进度;/progress off 暂停自动汇报、/progress on 恢复(本会话有效)",
|
|
729
|
-
"/status —
|
|
880
|
+
"/status — 查看状态(当前 qqbot / 忙闲 / 队列 / 自动同意 / 待审批 / 自动汇报)",
|
|
730
881
|
"/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
|
|
731
882
|
"/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
|
|
732
883
|
"/cancel(或「取消」「停止」)— 中断正在运行的任务并清空队列",
|
|
733
884
|
"/help — 帮助",
|
|
734
885
|
"pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
|
|
735
886
|
"忙碌时发来的普通消息(含模板/skill 命令)会排队,当前任务结束后自动执行。",
|
|
887
|
+
"多 bot:配置了多个 qqbot 时,多个 pi 进程会按列表顺序各自接入一个,抢不到的待命重扫。",
|
|
736
888
|
"其余文本交给 pi 当作对话处理。",
|
|
737
889
|
].join("\n"),
|
|
738
890
|
);
|
|
@@ -740,6 +892,7 @@ export async function handleMessage(
|
|
|
740
892
|
}
|
|
741
893
|
if (cmd?.name === "status") {
|
|
742
894
|
const lines = [
|
|
895
|
+
`bot: ${state.account ? `${state.account.name} (…${state.account.appId.slice(-4)})` : "未接入"} · cwd: ${process.cwd()}`,
|
|
743
896
|
`状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
|
|
744
897
|
`队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
|
|
745
898
|
`自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
|
|
@@ -848,6 +1001,12 @@ async function replyTo(
|
|
|
848
1001
|
text: string,
|
|
849
1002
|
format: "text" | "markdown" = "text",
|
|
850
1003
|
): Promise<boolean> {
|
|
1004
|
+
// 本进程刚接入 / 刚接管这个 qqbot:第一条回执带上身份与工作目录
|
|
1005
|
+
if (state.claimNote) {
|
|
1006
|
+
const note = state.claimNote;
|
|
1007
|
+
state.claimNote = null;
|
|
1008
|
+
text = `${note}\n${text}`;
|
|
1009
|
+
}
|
|
851
1010
|
const ref = state.passiveRefs.get(openid);
|
|
852
1011
|
let delivered = true;
|
|
853
1012
|
for (const chunk of splitText(text)) {
|
package/lock.ts
CHANGED
|
@@ -1,11 +1,63 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import lockfile from "proper-lockfile";
|
|
3
|
+
import { LOCK_PATH, OWNER_PATH } from "./constants";
|
|
2
4
|
|
|
3
5
|
const LOCK_RETRY_MS = 5000; // 待命实例抢锁间隔
|
|
4
6
|
const LOCK_STALE_MS = 30000; // 锁文件超过 30 秒无心跳视为僵尸锁
|
|
5
7
|
const LOCK_UPDATE_MS = 10000; // 宿主每 10 秒心跳刷新锁
|
|
8
|
+
/**
|
|
9
|
+
* 「临时释放」的预约窗口:本进程 `/reload`、`/new` 这类会话重建时,锁会先释放再重新抢。
|
|
10
|
+
* 这段时间内别的进程不许来抢,否则同一台机器上的另一个 pi 会把 bot 抢走。
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_RESERVE_MS = 20000;
|
|
6
13
|
|
|
7
14
|
type Release = () => Promise<void>;
|
|
8
15
|
|
|
16
|
+
/** 一个 bot 当前/最后一位持有者的记录(放在锁旁边,进程退出后不删) */
|
|
17
|
+
export interface LockOwner {
|
|
18
|
+
account: string;
|
|
19
|
+
appId: string;
|
|
20
|
+
pid: number;
|
|
21
|
+
/** 持有者启动时的工作目录(换进程提示要用) */
|
|
22
|
+
cwd: string;
|
|
23
|
+
claimedAt: string;
|
|
24
|
+
/** 仅「临时释放」时写:谁在重载 */
|
|
25
|
+
releasedAt?: string;
|
|
26
|
+
/** 临时释放的原因(reload/new/fork/resume) */
|
|
27
|
+
releaseReason?: string;
|
|
28
|
+
/** 预约到期时间(epoch ms):到点前其他进程不得抢占 */
|
|
29
|
+
reservedUntil?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ClaimCandidate {
|
|
33
|
+
name: string;
|
|
34
|
+
appId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Claim {
|
|
38
|
+
name: string;
|
|
39
|
+
appId: string;
|
|
40
|
+
release: Release;
|
|
41
|
+
/** 抢到之前的持有者记录(没有则 null) */
|
|
42
|
+
previous: LockOwner | null;
|
|
43
|
+
/** 是否「换进程接管」(previous.pid 存在且不是自己) */
|
|
44
|
+
takeover: boolean;
|
|
45
|
+
/** 是否是「自己临时释放后又拿回来」(/reload 场景,静默) */
|
|
46
|
+
reclaimed: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ClaimSkip {
|
|
50
|
+
name: string;
|
|
51
|
+
reason: "online" | "reserved" | "raced";
|
|
52
|
+
/** reserved 时用于日志:谁在重载 */
|
|
53
|
+
owner?: LockOwner;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ClaimResult {
|
|
57
|
+
claim?: Claim;
|
|
58
|
+
skipped: ClaimSkip[];
|
|
59
|
+
}
|
|
60
|
+
|
|
9
61
|
/** 抢文件锁:成功返回 release 函数;被占/异常返回 null */
|
|
10
62
|
export async function tryAcquireLock(lockPath: string): Promise<Release | null> {
|
|
11
63
|
try {
|
|
@@ -21,6 +73,7 @@ export async function tryAcquireLock(lockPath: string): Promise<Release | null>
|
|
|
21
73
|
}
|
|
22
74
|
}
|
|
23
75
|
|
|
76
|
+
/** 抢锁+心跳刷新的组合:保持旧导出名,供单 bot 场景与测试使用 */
|
|
24
77
|
export function startLockRetry(lockPath: string, onAcquired: (release: Release) => void): () => void {
|
|
25
78
|
const timer = setInterval(async () => {
|
|
26
79
|
const release = await tryAcquireLock(lockPath);
|
|
@@ -31,3 +84,154 @@ export function startLockRetry(lockPath: string, onAcquired: (release: Release)
|
|
|
31
84
|
}, LOCK_RETRY_MS);
|
|
32
85
|
return () => clearInterval(timer);
|
|
33
86
|
}
|
|
87
|
+
|
|
88
|
+
export function readOwner(appId: string): LockOwner | null {
|
|
89
|
+
try {
|
|
90
|
+
const raw = JSON.parse(fs.readFileSync(OWNER_PATH(appId), "utf8"));
|
|
91
|
+
if (!raw || typeof raw !== "object") return null;
|
|
92
|
+
return {
|
|
93
|
+
account: String(raw.account ?? ""),
|
|
94
|
+
appId: String(raw.appId ?? appId),
|
|
95
|
+
pid: Number(raw.pid ?? 0),
|
|
96
|
+
cwd: String(raw.cwd ?? ""),
|
|
97
|
+
claimedAt: String(raw.claimedAt ?? ""),
|
|
98
|
+
releasedAt: raw.releasedAt ? String(raw.releasedAt) : undefined,
|
|
99
|
+
releaseReason: raw.releaseReason ? String(raw.releaseReason) : undefined,
|
|
100
|
+
reservedUntil: Number.isFinite(raw.reservedUntil) ? Number(raw.reservedUntil) : undefined,
|
|
101
|
+
};
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function writeOwner(appId: string, owner: LockOwner): void {
|
|
108
|
+
try {
|
|
109
|
+
fs.writeFileSync(OWNER_PATH(appId), JSON.stringify(owner, null, 2), "utf8");
|
|
110
|
+
} catch {
|
|
111
|
+
/* owner 记录只是观测量,写不进去不能影响接入 */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 接入成功后落盘身份(同时清掉上一次的临时释放预约) */
|
|
116
|
+
export function recordClaim(input: { name: string; appId: string }, now = Date.now()): LockOwner {
|
|
117
|
+
const owner: LockOwner = {
|
|
118
|
+
account: input.name,
|
|
119
|
+
appId: input.appId,
|
|
120
|
+
pid: process.pid,
|
|
121
|
+
cwd: process.cwd(),
|
|
122
|
+
claimedAt: new Date(now).toISOString(),
|
|
123
|
+
};
|
|
124
|
+
writeOwner(input.appId, owner);
|
|
125
|
+
return owner;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 「我要暂时放开,别来抢」:给其他进程留下预约窗口。
|
|
130
|
+
* 只在会话重建(reload/new/fork/resume)时调用;真正退出(quit/崩溃)什么都不写,
|
|
131
|
+
* 让别人立刻能接管。
|
|
132
|
+
*/
|
|
133
|
+
export function reserveBeforeRelease(
|
|
134
|
+
input: { name: string; appId: string },
|
|
135
|
+
reason: string,
|
|
136
|
+
now = Date.now(),
|
|
137
|
+
reserveMs = DEFAULT_RESERVE_MS,
|
|
138
|
+
): LockOwner | null {
|
|
139
|
+
const prev = readOwner(input.appId);
|
|
140
|
+
const owner: LockOwner = {
|
|
141
|
+
account: input.name,
|
|
142
|
+
appId: input.appId,
|
|
143
|
+
pid: process.pid,
|
|
144
|
+
cwd: process.cwd(),
|
|
145
|
+
claimedAt: prev?.claimedAt ?? new Date(now).toISOString(),
|
|
146
|
+
releasedAt: new Date(now).toISOString(),
|
|
147
|
+
releaseReason: reason,
|
|
148
|
+
reservedUntil: now + reserveMs,
|
|
149
|
+
};
|
|
150
|
+
writeOwner(input.appId, owner);
|
|
151
|
+
return owner;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 该 bot 此刻是否被别人预约着(临时释放窗口内) */
|
|
155
|
+
function reservedByOther(owner: LockOwner | null, now: number): boolean {
|
|
156
|
+
if (!owner?.reservedUntil) return false;
|
|
157
|
+
if (owner.pid === process.pid) return false; // 自己 reload:拿回来即可,不算抢占
|
|
158
|
+
return owner.reservedUntil > now;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 按顺序抢占:跳过「别人正在重载」和「已在线」的 bot,抢到第一个可用就返回。
|
|
163
|
+
*
|
|
164
|
+
* 抢到之后会再读一次 owner 记录——如果刚好在抢锁瞬间别人贴了预约,
|
|
165
|
+
* 立刻把锁放掉(关掉竞态窗口),换下一个候选。
|
|
166
|
+
*/
|
|
167
|
+
export async function claimAny(
|
|
168
|
+
candidates: ClaimCandidate[],
|
|
169
|
+
options: { preferredAppId?: string | null; now?: number } = {},
|
|
170
|
+
): Promise<ClaimResult> {
|
|
171
|
+
const skipped: ClaimSkip[] = [];
|
|
172
|
+
const ordered = [...candidates];
|
|
173
|
+
if (options.preferredAppId) {
|
|
174
|
+
const index = ordered.findIndex((c) => c.appId === options.preferredAppId);
|
|
175
|
+
if (index > 0) ordered.unshift(...ordered.splice(index, 1));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const candidate of ordered) {
|
|
179
|
+
const now = options.now ?? Date.now();
|
|
180
|
+
const before = readOwner(candidate.appId);
|
|
181
|
+
if (reservedByOther(before, now)) {
|
|
182
|
+
skipped.push({ name: candidate.name, reason: "reserved", owner: before ?? undefined });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const release = await tryAcquireLock(LOCK_PATH(candidate.appId));
|
|
186
|
+
if (!release) {
|
|
187
|
+
skipped.push({ name: candidate.name, reason: "online" });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const raced = readOwner(candidate.appId);
|
|
191
|
+
if (reservedByOther(raced, options.now ?? Date.now())) {
|
|
192
|
+
// 抢锁和别人的「临时释放」撞在一起了:让出去,别抢别人 reload 中的 bot
|
|
193
|
+
await release().catch(() => {});
|
|
194
|
+
skipped.push({ name: candidate.name, reason: "raced", owner: raced ?? undefined });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
recordClaim({ name: candidate.name, appId: candidate.appId }, now);
|
|
198
|
+
const previous = before;
|
|
199
|
+
const takeover = !!previous && previous.pid !== process.pid && previous.pid !== 0;
|
|
200
|
+
const reclaimed = !!previous && previous.pid === process.pid;
|
|
201
|
+
return {
|
|
202
|
+
claim: { name: candidate.name, appId: candidate.appId, release, previous, takeover, reclaimed },
|
|
203
|
+
skipped,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return { skipped };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 待命:每 5 秒重扫一遍候选。
|
|
211
|
+
*
|
|
212
|
+
* 注意「全上线了就忽略」= 本进程不接入、但持续重扫——否则别人退出后没人接管,
|
|
213
|
+
* 「同账号换进程要提示」这条需求永远触发不到。
|
|
214
|
+
*/
|
|
215
|
+
export function startClaimRetry(
|
|
216
|
+
getCandidates: () => ClaimCandidate[],
|
|
217
|
+
onClaim: (claim: Claim) => void,
|
|
218
|
+
options: { preferredAppId?: string | null; intervalMs?: number; maxAttempts?: number } = {},
|
|
219
|
+
): () => void {
|
|
220
|
+
let attempts = 0;
|
|
221
|
+
let stopped = false;
|
|
222
|
+
const timer = setInterval(async () => {
|
|
223
|
+
if (stopped) return;
|
|
224
|
+
attempts += 1;
|
|
225
|
+
const result = await claimAny(getCandidates(), { preferredAppId: options.preferredAppId });
|
|
226
|
+
if (result.claim) {
|
|
227
|
+
clearInterval(timer);
|
|
228
|
+
onClaim(result.claim);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (options.maxAttempts !== undefined && attempts >= options.maxAttempts) clearInterval(timer);
|
|
232
|
+
}, options.intervalMs ?? LOCK_RETRY_MS);
|
|
233
|
+
return () => {
|
|
234
|
+
stopped = true;
|
|
235
|
+
clearInterval(timer);
|
|
236
|
+
};
|
|
237
|
+
}
|
package/logger.ts
CHANGED
|
@@ -2,8 +2,29 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { redactSecrets } from "./redact";
|
|
5
|
+
import { LOG_PATH as ACCOUNT_LOG_PATH } from "./constants";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* 日志文件。默认仍写旧的单文件 `pi-qqbot.log`(1.x 行为、旧测试与旧排查习惯都靠它),
|
|
9
|
+
* 一旦 `configureLog(账号名)` 被调用(多 bot 场景)就改成 `pi-qqbot-<账号>.log`:
|
|
10
|
+
* 同一台机器跑多个 pi 进程时,各账号的日志不交织。
|
|
11
|
+
*/
|
|
12
|
+
let currentPath: string | null = null;
|
|
13
|
+
const legacyPath = () => path.join(os.homedir(), ".pi", "agent", "pi-qqbot.log");
|
|
14
|
+
const logPath = () => currentPath ?? legacyPath();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 指定本进程的账号:之后所有日志写进该账号的日志文件。
|
|
18
|
+
* `null` = 单 bot 场景,继续写旧的 `pi-qqbot.log`(v1 配置升级后日志位置不变)。
|
|
19
|
+
*/
|
|
20
|
+
export function configureLog(account: string | null): void {
|
|
21
|
+
currentPath = account ? ACCOUNT_LOG_PATH(account) : legacyPath();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 当前日志文件路径(诊断用) */
|
|
25
|
+
export function currentLogPath(): string {
|
|
26
|
+
return logPath();
|
|
27
|
+
}
|
|
7
28
|
|
|
8
29
|
const pad = (value: number, width = 2): string =>
|
|
9
30
|
String(Math.trunc(Math.abs(value))).padStart(width, "0");
|
|
@@ -30,7 +51,7 @@ export function log(...args: unknown[]) {
|
|
|
30
51
|
// 日志会留在磁盘上:落盘前统一脱敏(Authorization、token=、URL userinfo、私钥块等)
|
|
31
52
|
const line = `[${timestamp()}] ${redactSecrets(body)}\n`;
|
|
32
53
|
try {
|
|
33
|
-
fs.appendFileSync(
|
|
54
|
+
fs.appendFileSync(logPath(), line, "utf8");
|
|
34
55
|
} catch {
|
|
35
56
|
/* ignore */
|
|
36
57
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zxh126/pi-qqbot",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.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",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
]
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
|
-
"test": "node test/queue.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs"
|
|
30
|
+
"test": "node test/queue.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs && node test/config.test.mjs && node test/lock.test.mjs && node test/accounts.test.mjs && node test/auth.test.mjs"
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"*.ts",
|
package/qq/auth.ts
CHANGED
|
@@ -20,8 +20,16 @@ export class AuthManager {
|
|
|
20
20
|
});
|
|
21
21
|
if (!res.ok) throw new Error(`getAppAccessToken failed: HTTP ${res.status}`);
|
|
22
22
|
const data = (await res.json()) as any;
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const token = String(data.access_token ?? "");
|
|
24
|
+
// QQ 平台对无效凭据返回 HTTP 200 + code 100016,以前只在后面拿空 token 去连网关,
|
|
25
|
+
// 表现为「锁抢到了但永远连不上」;这里直接抛,抢占阶段就能发现。
|
|
26
|
+
if (!token) {
|
|
27
|
+
const code = data?.code ?? "-";
|
|
28
|
+
const message = data?.message ?? data?.msg ?? "未返回 access_token";
|
|
29
|
+
throw new Error(`getAppAccessToken 被拒: code=${code} ${message}`);
|
|
30
|
+
}
|
|
31
|
+
this.token = token;
|
|
32
|
+
this.expireAt = Date.now() + Number(data.expires_in ?? 0) * 1000;
|
|
25
33
|
return this.token;
|
|
26
34
|
}
|
|
27
35
|
}
|