@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/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,13 +27,14 @@
|
|
|
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",
|
|
34
34
|
"qq",
|
|
35
35
|
"util",
|
|
36
36
|
"README.md",
|
|
37
|
+
"CHANGELOG.md",
|
|
37
38
|
"docs",
|
|
38
39
|
"LICENSE"
|
|
39
40
|
],
|
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
|
}
|