@zxh126/pi-qqbot 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +88 -2
- package/approver.ts +87 -0
- package/config.ts +45 -0
- package/constants.ts +16 -0
- package/index.ts +295 -0
- package/lock.ts +33 -0
- package/logger.ts +18 -0
- package/package.json +17 -4
- package/qq/api.ts +81 -0
- package/qq/auth.ts +27 -0
- package/qq/gateway.ts +235 -0
- package/stream-buffer.ts +22 -0
- package/util/chunk.ts +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-qqbot contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-qqbot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把 [Pi](https://pi.dev) coding agent 接入 QQ 单聊的桥接插件:在 QQ 里和 AI 对话,驱动 pi 干活。
|
|
4
|
+
|
|
5
|
+
> 单会话版:所有 QQ 用户共用同一个 pi 会话(v1 设计,多用户多会话后续用 SDK 方案实现)。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- **对话驱动 pi**:QQ 单聊消息送入当前 pi 会话,AI 回复发回 QQ。
|
|
10
|
+
- **QQ 远程审批**:pi 执行 `bash` / `powershell` 等命令类工具时,先在 QQ 里发「同意 N / 拒绝 N」请求,用户回复后放行/拦截。
|
|
11
|
+
- **单实例锁**:多个 pi 实例只会有「宿主」连接 QQ,其余待命,宿主退出后自动接管。
|
|
12
|
+
- **内置指令**:`/new` 重置、`/status` 状态、`/help` 帮助。
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
1. 把本目录放到 `~/.pi/agent/extensions/pi-qqbot/`(或作为 Pi Package:`pi install ./pi-qqbot`)。
|
|
17
|
+
2. 安装依赖:`npm install`。
|
|
18
|
+
3. 创建配置 `~/.pi/agent/pi-qqbot.json`:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"appId": "你的AppID",
|
|
23
|
+
"appSecret": "你的AppSecret",
|
|
24
|
+
"sandbox": true,
|
|
25
|
+
"allowlist": [],
|
|
26
|
+
"markdownReply": false,
|
|
27
|
+
"streaming": false,
|
|
28
|
+
"approve": {
|
|
29
|
+
"tools": ["bash", "powershell", "write", "edit"],
|
|
30
|
+
"remember": true
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| 字段 | 默认 | 说明 |
|
|
36
|
+
|------|------|------|
|
|
37
|
+
| `appId` / `appSecret` | 必填 | QQ 开放平台机器人凭据 |
|
|
38
|
+
| `sandbox` | `false` | 未提审上线的 bot 用 `true`(沙箱环境) |
|
|
39
|
+
| `allowlist` | `[]` | openid 白名单,空 = 不限制 |
|
|
40
|
+
| `markdownReply` | `false` | QQ 单聊不支持 markdown,保持 `false` |
|
|
41
|
+
| `streaming` | `false` | 预留,v1 未启用流式 |
|
|
42
|
+
| `approve.tools` | `["bash","powershell","write","edit"]` | 需要 QQ 审批的工具名;另有 `destructiveHint` 注解的工具也会拦截 |
|
|
43
|
+
| `approve.remember` | `true` | 会话内记住选择:同意/拒绝后同类工具不再询问 |
|
|
44
|
+
|
|
45
|
+
也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件凭据。
|
|
46
|
+
|
|
47
|
+
## 使用
|
|
48
|
+
|
|
49
|
+
启动 pi 后,在 QQ 里给机器人发消息,pi 会处理并回复。内置指令:
|
|
50
|
+
|
|
51
|
+
- `/new` — 重置会话(清空当前回复缓冲)
|
|
52
|
+
- `/status` — 查看是否空闲
|
|
53
|
+
- `/help` — 帮助
|
|
54
|
+
|
|
55
|
+
## 单实例锁
|
|
56
|
+
|
|
57
|
+
QQ 网关对同一 bot 只保留一个活跃连接。插件用 `proper-lockfile` 文件锁(`~/.pi/agent/pi-qqbot-<appId>.lock`):
|
|
58
|
+
|
|
59
|
+
- 抢到锁的实例 = 宿主,连接 QQ;
|
|
60
|
+
- 其余实例待命,每 5 秒重试;
|
|
61
|
+
- 宿主退出或崩溃(30 秒心跳超时)后,待命实例自动接管。
|
|
62
|
+
|
|
63
|
+
## 远程审批
|
|
64
|
+
|
|
65
|
+
pi 执行 `bash` / `powershell` / `write` / `edit` 等工具时(或带 `destructiveHint` 注解的工具),会先在 QQ 里发审批请求:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
[审批 #1] bash: rm -rf node_modules
|
|
69
|
+
回复「同意 1」批准,「拒绝 1」拒绝,「停止」终止本轮。
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- 回复「同意 N」/「拒绝 N」:放行/拦截对应工具调用;
|
|
73
|
+
- 回复「停止」:终止所有待审批请求;
|
|
74
|
+
- `approve.remember: true` 时,本次会话内同类工具记住选择,不再重复询问(`/new` 重置记忆);
|
|
75
|
+
- 超时(10 分钟)自动拒绝并通知。
|
|
76
|
+
|
|
77
|
+
## 前置条件
|
|
78
|
+
|
|
79
|
+
1. 在 [QQ 开放平台](https://q.qq.com/) 创建机器人,拿到 **AppID** 与 **AppSecret**。
|
|
80
|
+
2. 未上线阶段配置**沙箱单聊账号**,并把 `sandbox` 设为 `true`。
|
|
81
|
+
3. 正式环境填写服务器公网出口 IP 白名单。
|
|
82
|
+
|
|
83
|
+
## 日志
|
|
84
|
+
|
|
85
|
+
调试日志写入 `~/.pi/agent/pi-qqbot.log`。
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
package/approver.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ 远程审批:
|
|
3
|
+
* - 每个待审批请求生成编号,发到 QQ,等用户回复「同意/拒绝 N」或「停止」。
|
|
4
|
+
* - register 返回一个 Promise,QQ 回复后 resolve 决策。
|
|
5
|
+
* - remember 记忆:会话内同意/拒绝某工具后,同类工具不再询问。
|
|
6
|
+
*/
|
|
7
|
+
export type ApproveDecision = "allow" | "block" | "terminate";
|
|
8
|
+
|
|
9
|
+
interface PendingApproval {
|
|
10
|
+
summary: string;
|
|
11
|
+
timer: ReturnType<typeof setTimeout>;
|
|
12
|
+
resolve: (decision: ApproveDecision) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class Approver {
|
|
16
|
+
private pending = new Map<number, PendingApproval>();
|
|
17
|
+
private rememberMap = new Map<string, ApproveDecision>();
|
|
18
|
+
private nextSeq = 1;
|
|
19
|
+
|
|
20
|
+
constructor(private timeoutMs: number) {}
|
|
21
|
+
|
|
22
|
+
/** 会话内是否已记住某工具的决策 */
|
|
23
|
+
isRemembered(toolName: string): ApproveDecision | undefined {
|
|
24
|
+
return this.rememberMap.get(toolName);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
register(
|
|
28
|
+
summary: string,
|
|
29
|
+
onTimeout?: () => void,
|
|
30
|
+
): { seq: number; done: Promise<ApproveDecision> } {
|
|
31
|
+
const seq = this.nextSeq++;
|
|
32
|
+
const done = new Promise<ApproveDecision>((resolve) => {
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
this.pending.delete(seq);
|
|
35
|
+
resolve("block");
|
|
36
|
+
onTimeout?.();
|
|
37
|
+
}, this.timeoutMs);
|
|
38
|
+
this.pending.set(seq, { summary, timer, resolve });
|
|
39
|
+
});
|
|
40
|
+
return { seq, done };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render(seq: number): string {
|
|
44
|
+
const item = this.pending.get(seq);
|
|
45
|
+
return [
|
|
46
|
+
`[审批 #${seq}] ${item?.summary ?? ""}`,
|
|
47
|
+
`回复「同意 ${seq}」批准,「拒绝 ${seq}」拒绝,「停止」终止本轮。`,
|
|
48
|
+
].join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
parseReply(text: string): { decision: ApproveDecision; seq?: number } | null {
|
|
52
|
+
const t = text.trim();
|
|
53
|
+
if (/^停止$/.test(t)) return { decision: "terminate" };
|
|
54
|
+
const m = /^(同意|拒绝)\s*(\d+)$/.exec(t);
|
|
55
|
+
if (!m) return null;
|
|
56
|
+
return { decision: m[1] === "同意" ? "allow" : "block", seq: Number(m[2]) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
confirm(seq: number, decision: ApproveDecision): boolean {
|
|
60
|
+
const item = this.pending.get(seq);
|
|
61
|
+
if (!item) return false;
|
|
62
|
+
clearTimeout(item.timer);
|
|
63
|
+
this.pending.delete(seq);
|
|
64
|
+
item.resolve(decision);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 终止所有待审批请求(「停止」) */
|
|
69
|
+
terminateAll(): number {
|
|
70
|
+
let count = 0;
|
|
71
|
+
for (const [seq, item] of [...this.pending]) {
|
|
72
|
+
clearTimeout(item.timer);
|
|
73
|
+
this.pending.delete(seq);
|
|
74
|
+
item.resolve("terminate");
|
|
75
|
+
count++;
|
|
76
|
+
}
|
|
77
|
+
return count;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
remember(toolName: string, decision: ApproveDecision) {
|
|
81
|
+
this.rememberMap.set(toolName, decision);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
reset() {
|
|
85
|
+
this.rememberMap.clear();
|
|
86
|
+
}
|
|
87
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { CONFIG_PATH } from "./constants";
|
|
3
|
+
|
|
4
|
+
export interface ApproveConfig {
|
|
5
|
+
/** 需要 QQ 审批的工具名(如 bash/powershell/write/edit) */
|
|
6
|
+
tools: string[];
|
|
7
|
+
/** 会话内记住选择:同意/拒绝后同类工具不再询问 */
|
|
8
|
+
remember: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface QQBotConfig {
|
|
12
|
+
appId: string;
|
|
13
|
+
appSecret: string;
|
|
14
|
+
sandbox: boolean;
|
|
15
|
+
allowlist: string[];
|
|
16
|
+
markdownReply: boolean;
|
|
17
|
+
streaming: boolean;
|
|
18
|
+
toolProgress: boolean;
|
|
19
|
+
approve: ApproveConfig;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadConfig(): QQBotConfig | null {
|
|
23
|
+
let file: any = {};
|
|
24
|
+
try {
|
|
25
|
+
file = JSON.parse(fs.readFileSync(CONFIG_PATH(), "utf8"));
|
|
26
|
+
} catch {
|
|
27
|
+
/* 文件不存在或非法不致命,凭据可完全来自环境变量 */
|
|
28
|
+
}
|
|
29
|
+
const appId = process.env.QQ_BOT_APPID ?? file.appId;
|
|
30
|
+
const appSecret = process.env.QQ_BOT_APPSECRET ?? file.appSecret;
|
|
31
|
+
if (!appId || !appSecret) return null;
|
|
32
|
+
return {
|
|
33
|
+
appId,
|
|
34
|
+
appSecret,
|
|
35
|
+
sandbox: file.sandbox ?? false,
|
|
36
|
+
allowlist: file.allowlist ?? [],
|
|
37
|
+
markdownReply: file.markdownReply ?? false,
|
|
38
|
+
streaming: file.streaming ?? false,
|
|
39
|
+
toolProgress: file.toolProgress ?? false,
|
|
40
|
+
approve: {
|
|
41
|
+
tools: file.approve?.tools ?? ["bash", "powershell", "write", "edit"],
|
|
42
|
+
remember: file.approve?.remember ?? true,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
package/constants.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const TOKEN_URL = "https://api.bot.qq.com/app/getAppAccessToken";
|
|
5
|
+
export const REST_BASE_PROD = "https://api.bot.qq.com";
|
|
6
|
+
export const REST_BASE_SANDBOX = "https://sandbox.api.sgroup.qq.com";
|
|
7
|
+
export const INTENT_GROUP_AND_C2C = 1 << 25;
|
|
8
|
+
export const PASSIVE_WINDOW_MS = 60 * 60 * 1000; // 单聊被动窗口 60 分钟
|
|
9
|
+
export const MAX_REPLIES_PER_MSG_ID = 4;
|
|
10
|
+
export const APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
11
|
+
export const STREAM_FLUSH_INTERVAL_MS = 1200;
|
|
12
|
+
export const GATEWAY_PATH = "/gateway";
|
|
13
|
+
|
|
14
|
+
const PI_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
15
|
+
export const CONFIG_PATH = () => path.join(PI_DIR, "pi-qqbot.json");
|
|
16
|
+
export const LOCK_PATH = (appId: string) => path.join(PI_DIR, `pi-qqbot-${appId}.lock`);
|
package/index.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfig, QQBotConfig } from "./config";
|
|
3
|
+
import {
|
|
4
|
+
REST_BASE_PROD,
|
|
5
|
+
REST_BASE_SANDBOX,
|
|
6
|
+
INTENT_GROUP_AND_C2C,
|
|
7
|
+
PASSIVE_WINDOW_MS,
|
|
8
|
+
APPROVAL_TIMEOUT_MS,
|
|
9
|
+
LOCK_PATH,
|
|
10
|
+
} from "./constants";
|
|
11
|
+
import { log } from "./logger";
|
|
12
|
+
import { tryAcquireLock, startLockRetry } from "./lock";
|
|
13
|
+
import { AuthManager } from "./qq/auth";
|
|
14
|
+
import { QQApi } from "./qq/api";
|
|
15
|
+
import { createGatewayUrlFetcher, QQGateway, QQMessage } from "./qq/gateway";
|
|
16
|
+
import { Approver } from "./approver";
|
|
17
|
+
import { ReplyBuffer } from "./stream-buffer";
|
|
18
|
+
import { splitText } from "./util/chunk";
|
|
19
|
+
|
|
20
|
+
interface State {
|
|
21
|
+
cfg: QQBotConfig | null;
|
|
22
|
+
api: QQApi | null;
|
|
23
|
+
approver: Approver;
|
|
24
|
+
gateway: QQGateway | null;
|
|
25
|
+
currentOpenid: string | null;
|
|
26
|
+
replyBuffer: ReplyBuffer;
|
|
27
|
+
releaseLock: (() => Promise<void>) | null;
|
|
28
|
+
stopRetry: (() => void) | null;
|
|
29
|
+
passiveRefs: Map<string, { msgId: string; receivedAt: number }>;
|
|
30
|
+
busy: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function (pi: ExtensionAPI) {
|
|
34
|
+
const state: State = {
|
|
35
|
+
cfg: null,
|
|
36
|
+
api: null,
|
|
37
|
+
approver: new Approver(APPROVAL_TIMEOUT_MS),
|
|
38
|
+
gateway: null,
|
|
39
|
+
currentOpenid: null,
|
|
40
|
+
replyBuffer: new ReplyBuffer(),
|
|
41
|
+
releaseLock: null,
|
|
42
|
+
stopRetry: null,
|
|
43
|
+
passiveRefs: new Map(),
|
|
44
|
+
busy: false,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---- 事件:累积 assistant 文本 ----
|
|
48
|
+
pi.on("message_update", (event) => {
|
|
49
|
+
const ev = event.assistantMessageEvent as any;
|
|
50
|
+
if (ev?.type === "text_delta") {
|
|
51
|
+
state.replyBuffer.append(ev.delta ?? ev.text ?? "");
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---- 事件:run 结束,发回复 ----
|
|
56
|
+
pi.on("agent_settled", async () => {
|
|
57
|
+
const text = state.replyBuffer.take();
|
|
58
|
+
const openid = state.currentOpenid;
|
|
59
|
+
state.currentOpenid = null;
|
|
60
|
+
state.busy = false;
|
|
61
|
+
if (!text || !openid || !state.api) return;
|
|
62
|
+
log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
|
|
63
|
+
for (const chunk of splitText(text)) {
|
|
64
|
+
await state.api.sendC2C(openid, chunk).catch((e) => log("[reply] 发送失败:", String(e)));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ---- 事件:tool_call 审批 ----
|
|
69
|
+
pi.on("tool_call", async (event) => {
|
|
70
|
+
const openid = state.currentOpenid;
|
|
71
|
+
if (!openid || !state.api || !state.cfg) return; // 无 QQ 上下文,放行
|
|
72
|
+
const ev = event as any;
|
|
73
|
+
const toolName: string = ev.toolName ?? "tool";
|
|
74
|
+
const cfg = state.cfg;
|
|
75
|
+
|
|
76
|
+
// 判断是否需要审批:配置的 tools 列表 + destructiveHint 兑底
|
|
77
|
+
const allTools = (pi.getAllTools() ?? []) as any[];
|
|
78
|
+
const toolMeta = allTools.find((t) => t.name === toolName);
|
|
79
|
+
const destructive = toolMeta?.annotations?.destructiveHint === true;
|
|
80
|
+
const needsApproval = cfg.approve.tools.includes(toolName) || destructive;
|
|
81
|
+
if (!needsApproval) return; // 放行
|
|
82
|
+
|
|
83
|
+
// 会话内已记住的决策
|
|
84
|
+
const remembered = cfg.approve.remember ? state.approver.isRemembered(toolName) : undefined;
|
|
85
|
+
if (remembered === "allow") return; // 已记住放行
|
|
86
|
+
if (remembered === "block") return { block: true, reason: `QQ 用户已拒绝 ${toolName}` };
|
|
87
|
+
if (remembered === "terminate") return { block: true, terminate: true, reason: "已停止" };
|
|
88
|
+
|
|
89
|
+
const summary = summarizeTool(ev);
|
|
90
|
+
log("[approve] 工具需要审批:", summary);
|
|
91
|
+
const { seq, done } = state.approver.register(summary, () => {
|
|
92
|
+
// 超时通知
|
|
93
|
+
state.api?.sendC2C(openid, `审批 #${seq} 已超时,已自动拒绝。`).catch(() => {});
|
|
94
|
+
});
|
|
95
|
+
await state.api.sendC2C(openid, state.approver.render(seq)).catch(() => {});
|
|
96
|
+
const decision = await done;
|
|
97
|
+
|
|
98
|
+
if (decision === "allow") {
|
|
99
|
+
if (cfg.approve.remember) state.approver.remember(toolName, "allow");
|
|
100
|
+
log("[approve] 用户批准 #", seq);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (decision === "terminate") {
|
|
104
|
+
log("[approve] 用户停止");
|
|
105
|
+
return { block: true, terminate: true, reason: "QQ 用户停止" };
|
|
106
|
+
}
|
|
107
|
+
// block
|
|
108
|
+
if (cfg.approve.remember) state.approver.remember(toolName, "block");
|
|
109
|
+
log("[approve] 用户拒绝 #", seq);
|
|
110
|
+
return { block: true, reason: `QQ 用户拒绝了 ${summary}` };
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ---- 事件:session_start 抢锁 + 启动网关 ----
|
|
114
|
+
pi.on("session_start", async () => {
|
|
115
|
+
const cfg = loadConfig();
|
|
116
|
+
if (!cfg) {
|
|
117
|
+
log("[setup] 缺少 QQ_BOT_APPID/QQ_BOT_APPSECRET 或配置,插件未启用");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
state.cfg = cfg;
|
|
121
|
+
log("[setup] 配置已加载 sandbox=", cfg.sandbox);
|
|
122
|
+
|
|
123
|
+
const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
|
|
124
|
+
const auth = new AuthManager(cfg.appId, cfg.appSecret);
|
|
125
|
+
const api = new QQApi({ restBase, getToken: () => auth.getToken() });
|
|
126
|
+
state.api = api;
|
|
127
|
+
|
|
128
|
+
const startGateway = () => {
|
|
129
|
+
const gateway = new QQGateway({
|
|
130
|
+
getGatewayUrl: createGatewayUrlFetcher(restBase, () => auth.getToken()),
|
|
131
|
+
getToken: () => auth.getToken(),
|
|
132
|
+
intents: INTENT_GROUP_AND_C2C,
|
|
133
|
+
connected: () => {},
|
|
134
|
+
disconnected: () => {},
|
|
135
|
+
message: (msg) => handleMessage(pi, state, api, cfg, msg),
|
|
136
|
+
});
|
|
137
|
+
state.gateway = gateway;
|
|
138
|
+
log("[lock] 启动 QQ 网关");
|
|
139
|
+
gateway.start();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// 单实例锁:只有抢到锁的实例连接 QQ
|
|
143
|
+
const lockPath = LOCK_PATH(cfg.appId);
|
|
144
|
+
const release = await tryAcquireLock(lockPath);
|
|
145
|
+
if (release) {
|
|
146
|
+
state.releaseLock = release;
|
|
147
|
+
log("[lock] 抢锁成功,本实例为 QQ bot 宿主");
|
|
148
|
+
startGateway();
|
|
149
|
+
} else {
|
|
150
|
+
log("[lock] 锁被占用,本实例进入待命");
|
|
151
|
+
state.stopRetry = startLockRetry(lockPath, (r) => {
|
|
152
|
+
state.releaseLock = r;
|
|
153
|
+
log("[lock] 抢锁成功,接管 QQ bot");
|
|
154
|
+
startGateway();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ---- 事件:session_shutdown 清理 ----
|
|
160
|
+
pi.on("session_shutdown", async () => {
|
|
161
|
+
log("[shutdown] 清理资源");
|
|
162
|
+
if (state.stopRetry) state.stopRetry();
|
|
163
|
+
state.gateway?.stop();
|
|
164
|
+
if (state.releaseLock) {
|
|
165
|
+
try {
|
|
166
|
+
await state.releaseLock();
|
|
167
|
+
} catch {
|
|
168
|
+
/* ignore */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
state.releaseLock = null;
|
|
172
|
+
state.gateway = null;
|
|
173
|
+
state.api = null;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- QQ 消息处理 ----
|
|
178
|
+
async function handleMessage(
|
|
179
|
+
pi: ExtensionAPI,
|
|
180
|
+
state: State,
|
|
181
|
+
api: QQApi,
|
|
182
|
+
cfg: QQBotConfig,
|
|
183
|
+
msg: QQMessage,
|
|
184
|
+
) {
|
|
185
|
+
try {
|
|
186
|
+
log("[msg] 收到消息 openid=", msg.openid, "content=", msg.content.slice(0, 100));
|
|
187
|
+
if (cfg.allowlist.length > 0 && !cfg.allowlist.includes(msg.openid)) {
|
|
188
|
+
log("[msg] 被 allowlist 拦截");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
|
|
192
|
+
|
|
193
|
+
// 远程审批回复优先
|
|
194
|
+
const parsed = state.approver.parseReply(msg.content.trim());
|
|
195
|
+
if (parsed) {
|
|
196
|
+
if (parsed.decision === "terminate") {
|
|
197
|
+
const n = state.approver.terminateAll();
|
|
198
|
+
await replyTo(api, state, msg.openid, `已终止 ${n} 个待审批请求`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const ok = state.approver.confirm(parsed.seq!, parsed.decision);
|
|
202
|
+
await replyTo(
|
|
203
|
+
api,
|
|
204
|
+
state,
|
|
205
|
+
msg.openid,
|
|
206
|
+
ok ? (parsed.decision === "allow" ? `已批准 #${parsed.seq}` : `已拒绝 #${parsed.seq}`) : `#${parsed.seq} 不存在或已超时`,
|
|
207
|
+
);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 内置指令
|
|
212
|
+
const cmd = parseCommand(msg.content);
|
|
213
|
+
if (cmd === "help") {
|
|
214
|
+
await replyTo(
|
|
215
|
+
api,
|
|
216
|
+
state,
|
|
217
|
+
msg.openid,
|
|
218
|
+
["pi-qqbot 指令:", "/new — 开新会话", "/status — 查看状态", "/help — 帮助", "其余文本交给 pi 处理。"].join("\n"),
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (cmd === "status") {
|
|
223
|
+
await replyTo(api, state, msg.openid, `状态: ${state.busy ? "处理中" : "空闲"}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (cmd === "new") {
|
|
227
|
+
state.replyBuffer.reset();
|
|
228
|
+
state.approver.reset();
|
|
229
|
+
await replyTo(api, state, msg.openid, "已重置,下一条消息开启新对话。");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 忙检查:单会话串行处理
|
|
234
|
+
if (state.busy) {
|
|
235
|
+
await replyTo(api, state, msg.openid, "上一条消息还在处理中,请稍候…");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ack
|
|
240
|
+
await replyTo(api, state, msg.openid, "收到,正在处理");
|
|
241
|
+
|
|
242
|
+
state.currentOpenid = msg.openid;
|
|
243
|
+
state.busy = true;
|
|
244
|
+
state.replyBuffer.reset();
|
|
245
|
+
|
|
246
|
+
// 交给 pi 处理
|
|
247
|
+
pi.sendUserMessage(msg.content, { deliverAs: "followUp" });
|
|
248
|
+
log("[msg] 已送入 pi:", msg.content.slice(0, 60));
|
|
249
|
+
} catch (e) {
|
|
250
|
+
log("[msg] 处理失败:", String(e));
|
|
251
|
+
await replyTo(api, state, msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function replyTo(
|
|
256
|
+
api: QQApi,
|
|
257
|
+
state: State,
|
|
258
|
+
openid: string,
|
|
259
|
+
text: string,
|
|
260
|
+
format: "text" | "markdown" = "text",
|
|
261
|
+
) {
|
|
262
|
+
const ref = state.passiveRefs.get(openid);
|
|
263
|
+
for (const chunk of splitText(text)) {
|
|
264
|
+
const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
|
|
265
|
+
try {
|
|
266
|
+
await api.sendC2C(openid, chunk, usePassive ? { msgId: ref.msgId, format } : { format });
|
|
267
|
+
log("[reply] 已发送 (passive=", usePassive, "):", chunk.slice(0, 50));
|
|
268
|
+
} catch (e) {
|
|
269
|
+
log("[reply] 发送失败:", String(e));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parseCommand(text: string): "new" | "status" | "help" | null {
|
|
275
|
+
const m = /^\/(new|status|help)\s*$/i.exec(text.trim());
|
|
276
|
+
return m ? (m[1].toLowerCase() as "new" | "status" | "help") : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 从 tool_call 事件提取可读摘要,让用户在 QQ 里看清要执行什么 */
|
|
280
|
+
function summarizeTool(event: any): string {
|
|
281
|
+
const input = event.input ?? {};
|
|
282
|
+
switch (event.toolName) {
|
|
283
|
+
case "bash":
|
|
284
|
+
case "powershell":
|
|
285
|
+
return `${event.toolName}: ${String(input.command ?? "").slice(0, 200)}`;
|
|
286
|
+
case "write":
|
|
287
|
+
case "edit":
|
|
288
|
+
case "read":
|
|
289
|
+
return `${event.toolName}: ${String(input.path ?? input.file ?? input.filePath ?? "").slice(0, 200)}`;
|
|
290
|
+
case "grep":
|
|
291
|
+
return `${event.toolName}: ${String(input.pattern ?? "").slice(0, 200)}`;
|
|
292
|
+
default:
|
|
293
|
+
return `${event.toolName}: ${JSON.stringify(input).slice(0, 200)}`;
|
|
294
|
+
}
|
|
295
|
+
}
|
package/lock.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import lockfile from "proper-lockfile";
|
|
2
|
+
|
|
3
|
+
const LOCK_RETRY_MS = 5000; // 待命实例抢锁间隔
|
|
4
|
+
const LOCK_STALE_MS = 30000; // 锁文件超过 30 秒无心跳视为僵尸锁
|
|
5
|
+
const LOCK_UPDATE_MS = 10000; // 宿主每 10 秒心跳刷新锁
|
|
6
|
+
|
|
7
|
+
type Release = () => Promise<void>;
|
|
8
|
+
|
|
9
|
+
/** 抢文件锁:成功返回 release 函数;被占/异常返回 null */
|
|
10
|
+
export async function tryAcquireLock(lockPath: string): Promise<Release | null> {
|
|
11
|
+
try {
|
|
12
|
+
const release = await lockfile.lock(lockPath, {
|
|
13
|
+
retries: 0,
|
|
14
|
+
stale: LOCK_STALE_MS,
|
|
15
|
+
update: LOCK_UPDATE_MS,
|
|
16
|
+
realpath: false,
|
|
17
|
+
});
|
|
18
|
+
return release;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function startLockRetry(lockPath: string, onAcquired: (release: Release) => void): () => void {
|
|
25
|
+
const timer = setInterval(async () => {
|
|
26
|
+
const release = await tryAcquireLock(lockPath);
|
|
27
|
+
if (release) {
|
|
28
|
+
clearInterval(timer);
|
|
29
|
+
onAcquired(release);
|
|
30
|
+
}
|
|
31
|
+
}, LOCK_RETRY_MS);
|
|
32
|
+
return () => clearInterval(timer);
|
|
33
|
+
}
|
package/logger.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const LOG_PATH = path.join(os.homedir(), ".pi", "agent", "pi-qqbot.log");
|
|
6
|
+
|
|
7
|
+
function ts() {
|
|
8
|
+
return new Date().toISOString();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function log(...args: unknown[]) {
|
|
12
|
+
const line = `[${ts()}] ${args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}\n`;
|
|
13
|
+
try {
|
|
14
|
+
fs.appendFileSync(LOG_PATH, line, "utf8");
|
|
15
|
+
} catch {
|
|
16
|
+
/* ignore */
|
|
17
|
+
}
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zxh126/pi-qqbot",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "把 Pi coding agent 接入 QQ 单聊的桥接插件(单会话版)",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["pi-package", "qq", "bot"],
|
|
8
|
+
"pi": {
|
|
9
|
+
"extensions": ["./index.ts"]
|
|
10
|
+
},
|
|
11
|
+
"files": ["*.ts", "qq", "util", "README.md", "LICENSE"],
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"ws": "^8.18.0",
|
|
14
|
+
"proper-lockfile": "^4.1.2"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/qq/api.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { MAX_REPLIES_PER_MSG_ID } from "../constants";
|
|
2
|
+
|
|
3
|
+
export interface QQApiOptions {
|
|
4
|
+
restBase: string;
|
|
5
|
+
getToken: () => Promise<string>;
|
|
6
|
+
fetchFn?: typeof fetch;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SendOptions {
|
|
10
|
+
msgId?: string;
|
|
11
|
+
format?: "text" | "markdown";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class QQApi {
|
|
15
|
+
private queue: Promise<void> = Promise.resolve();
|
|
16
|
+
private seqCounters = new Map<string, number>();
|
|
17
|
+
|
|
18
|
+
constructor(private opts: QQApiOptions) {}
|
|
19
|
+
|
|
20
|
+
sendC2C(openid: string, content: string, options: SendOptions = {}): Promise<void> {
|
|
21
|
+
const task = this.queue.then(() => this.doSend(openid, content, options));
|
|
22
|
+
this.queue = task.catch(() => {});
|
|
23
|
+
return task;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private nextSeq(msgId?: string): number | undefined {
|
|
27
|
+
if (!msgId) return undefined;
|
|
28
|
+
const used = this.seqCounters.get(msgId) ?? 0;
|
|
29
|
+
if (used >= MAX_REPLIES_PER_MSG_ID) return undefined;
|
|
30
|
+
const seq = used + 1;
|
|
31
|
+
this.seqCounters.set(msgId, seq);
|
|
32
|
+
if (this.seqCounters.size > 500) this.seqCounters.clear();
|
|
33
|
+
return seq;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private async postWithRetry(openid: string, body: any): Promise<void> {
|
|
37
|
+
const fetchFn = this.opts.fetchFn ?? fetch;
|
|
38
|
+
for (let attempt = 0; attempt <= 3; attempt++) {
|
|
39
|
+
const token = await this.opts.getToken();
|
|
40
|
+
const res = await fetchFn(`${this.opts.restBase}/v2/users/${openid}/messages`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json", Authorization: `QQBot ${token}` },
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
});
|
|
45
|
+
if (res.status === 429 && attempt < 3) {
|
|
46
|
+
const retryAfter = Number(res.headers.get("Retry-After") ?? "1");
|
|
47
|
+
await new Promise((r) => setTimeout(r, Math.min(retryAfter, 30) * 1000));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!res.ok) throw new Error(`sendC2C failed: HTTP ${res.status} ${await res.text()}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async doSend(openid: string, content: string, options: SendOptions): Promise<void> {
|
|
56
|
+
const format = options.format ?? "text";
|
|
57
|
+
let msgId = options.msgId;
|
|
58
|
+
let seq: number | undefined;
|
|
59
|
+
if (msgId) {
|
|
60
|
+
seq = this.nextSeq(msgId);
|
|
61
|
+
if (seq === undefined) msgId = undefined; // 被动额度用尽 → 主动消息
|
|
62
|
+
}
|
|
63
|
+
const makeBody = (fmt: "text" | "markdown") => {
|
|
64
|
+
const body: any = fmt === "markdown" ? { msg_type: 2, markdown: { content } } : { msg_type: 0, content };
|
|
65
|
+
if (msgId && seq !== undefined) {
|
|
66
|
+
body.msg_id = msgId;
|
|
67
|
+
body.msg_seq = seq;
|
|
68
|
+
}
|
|
69
|
+
return body;
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
await this.postWithRetry(openid, makeBody(format));
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (format === "markdown") {
|
|
75
|
+
await this.postWithRetry(openid, makeBody("text"));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/qq/auth.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { TOKEN_URL } from "../constants";
|
|
2
|
+
|
|
3
|
+
export class AuthManager {
|
|
4
|
+
private token: string | null = null;
|
|
5
|
+
private expireAt = 0;
|
|
6
|
+
|
|
7
|
+
constructor(
|
|
8
|
+
private appId: string,
|
|
9
|
+
private appSecret: string,
|
|
10
|
+
private fetchFn: typeof fetch = fetch,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
async getToken(): Promise<string> {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
if (this.token && now < this.expireAt - 60_000) return this.token;
|
|
16
|
+
const res = await this.fetchFn(TOKEN_URL, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
body: JSON.stringify({ appId: this.appId, clientSecret: this.appSecret }),
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) throw new Error(`getAppAccessToken failed: HTTP ${res.status}`);
|
|
22
|
+
const data = (await res.json()) as any;
|
|
23
|
+
this.token = data.access_token;
|
|
24
|
+
this.expireAt = Date.now() + Number(data.expires_in) * 1000;
|
|
25
|
+
return this.token;
|
|
26
|
+
}
|
|
27
|
+
}
|
package/qq/gateway.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { GATEWAY_PATH } from "../constants";
|
|
3
|
+
import { log } from "../logger";
|
|
4
|
+
|
|
5
|
+
const OP_DISPATCH = 0;
|
|
6
|
+
const OP_HEARTBEAT = 1;
|
|
7
|
+
const OP_IDENTIFY = 2;
|
|
8
|
+
const OP_RESUME = 6;
|
|
9
|
+
const OP_HELLO = 10;
|
|
10
|
+
const OP_HEARTBEAT_ACK = 11;
|
|
11
|
+
|
|
12
|
+
export interface QQMessage {
|
|
13
|
+
openid: string;
|
|
14
|
+
content: string;
|
|
15
|
+
msgId: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
attachments: { url: string }[];
|
|
18
|
+
quotedText?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface QQGatewayOptions {
|
|
22
|
+
getGatewayUrl: () => Promise<string>;
|
|
23
|
+
getToken: () => Promise<string>;
|
|
24
|
+
intents: number;
|
|
25
|
+
connected: () => void;
|
|
26
|
+
disconnected: () => void;
|
|
27
|
+
message: (msg: QQMessage) => Promise<void> | void;
|
|
28
|
+
reconnectBaseMs?: number;
|
|
29
|
+
maxSeen?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class QQGateway {
|
|
33
|
+
private ws: WebSocket | null = null;
|
|
34
|
+
private sessionId: string | null = null;
|
|
35
|
+
private lastSeq: number | null = null;
|
|
36
|
+
private resumeAttempted = false;
|
|
37
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
38
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
private reconnectAttempt = 0;
|
|
40
|
+
private stopped = false;
|
|
41
|
+
private seenIds = new Set<string>();
|
|
42
|
+
private seenOrder: string[] = [];
|
|
43
|
+
|
|
44
|
+
constructor(private opts: QQGatewayOptions) {}
|
|
45
|
+
|
|
46
|
+
private isDuplicate(key: string): boolean {
|
|
47
|
+
if (!key) return false;
|
|
48
|
+
if (this.seenIds.has(key)) return true;
|
|
49
|
+
this.seenIds.add(key);
|
|
50
|
+
this.seenOrder.push(key);
|
|
51
|
+
const cap = this.opts.maxSeen ?? 1000;
|
|
52
|
+
if (this.seenOrder.length > cap) {
|
|
53
|
+
const oldest = this.seenOrder.shift();
|
|
54
|
+
if (oldest !== undefined) this.seenIds.delete(oldest);
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
start() {
|
|
60
|
+
this.stopped = false;
|
|
61
|
+
this.connect().catch(() => this.scheduleReconnect());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
stop() {
|
|
65
|
+
this.stopped = true;
|
|
66
|
+
this.cleanup();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private cleanup() {
|
|
70
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
71
|
+
this.heartbeatTimer = null;
|
|
72
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
73
|
+
this.reconnectTimer = null;
|
|
74
|
+
if (this.ws) {
|
|
75
|
+
this.ws.removeAllListeners();
|
|
76
|
+
try {
|
|
77
|
+
this.ws.close();
|
|
78
|
+
} catch {
|
|
79
|
+
/* ignore */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
this.ws = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private scheduleReconnect() {
|
|
86
|
+
if (this.stopped) return;
|
|
87
|
+
if (this.resumeAttempted) {
|
|
88
|
+
this.resumeAttempted = false;
|
|
89
|
+
this.sessionId = null;
|
|
90
|
+
this.lastSeq = null;
|
|
91
|
+
}
|
|
92
|
+
const base = this.opts.reconnectBaseMs ?? 1000;
|
|
93
|
+
const delay = Math.min(base * 2 ** this.reconnectAttempt, 60_000);
|
|
94
|
+
this.reconnectAttempt++;
|
|
95
|
+
this.reconnectTimer = setTimeout(() => {
|
|
96
|
+
if (this.stopped) return;
|
|
97
|
+
this.connect().catch(() => this.scheduleReconnect());
|
|
98
|
+
}, delay);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async connect() {
|
|
102
|
+
log("[gateway] 开始连接...");
|
|
103
|
+
const url = await this.opts.getGatewayUrl();
|
|
104
|
+
log("[gateway] 网关地址:", url);
|
|
105
|
+
const ws = new WebSocket(url);
|
|
106
|
+
this.ws = ws;
|
|
107
|
+
ws.on("open", () => log("[gateway] WebSocket 已连接,等待 HELLO..."));
|
|
108
|
+
ws.on("message", (raw) => {
|
|
109
|
+
let pkt: any;
|
|
110
|
+
try {
|
|
111
|
+
pkt = JSON.parse(raw.toString());
|
|
112
|
+
} catch (e) {
|
|
113
|
+
log("[gateway] 丢弃非 JSON 网关帧:", String(e).slice(0, 120));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.handlePacket(pkt);
|
|
117
|
+
});
|
|
118
|
+
ws.on("close", (code) => {
|
|
119
|
+
log("[gateway] WebSocket 关闭 code=", code);
|
|
120
|
+
this.opts.disconnected();
|
|
121
|
+
this.scheduleReconnect();
|
|
122
|
+
});
|
|
123
|
+
ws.on("error", (e: any) => {
|
|
124
|
+
log("[gateway] WebSocket 错误:", String(e?.message ?? e));
|
|
125
|
+
ws.terminate();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private handlePacket(pkt: any) {
|
|
130
|
+
switch (pkt.op) {
|
|
131
|
+
case OP_HELLO: {
|
|
132
|
+
const interval = pkt.d.heartbeat_interval;
|
|
133
|
+
this.startHeartbeat(interval);
|
|
134
|
+
if (this.sessionId !== null && this.lastSeq !== null) {
|
|
135
|
+
this.resumeAttempted = true;
|
|
136
|
+
void this.sendResume().catch(() => this.ws?.terminate());
|
|
137
|
+
} else {
|
|
138
|
+
this.resumeAttempted = false;
|
|
139
|
+
void this.sendIdentify().catch(() => this.ws?.terminate());
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case OP_HEARTBEAT_ACK:
|
|
144
|
+
break;
|
|
145
|
+
case OP_DISPATCH: {
|
|
146
|
+
this.lastSeq = pkt.s ?? this.lastSeq;
|
|
147
|
+
const d = pkt.d ?? {};
|
|
148
|
+
if (this.isDuplicate(pkt.id ?? String(d.msg_id ?? d.id ?? ""))) return;
|
|
149
|
+
this.handleDispatch(pkt.t ?? "", d);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async sendIdentify() {
|
|
156
|
+
const token = await this.opts.getToken();
|
|
157
|
+
log("[gateway] 发送 identify...");
|
|
158
|
+
this.ws?.send(
|
|
159
|
+
JSON.stringify({
|
|
160
|
+
op: OP_IDENTIFY,
|
|
161
|
+
d: { token: `QQBot ${token}`, intents: this.opts.intents, shard: [0, 1] },
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async sendResume() {
|
|
167
|
+
const token = await this.opts.getToken();
|
|
168
|
+
this.ws?.send(
|
|
169
|
+
JSON.stringify({
|
|
170
|
+
op: OP_RESUME,
|
|
171
|
+
d: { token: `QQBot ${token}`, session_id: this.sessionId, seq: this.lastSeq },
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private startHeartbeat(intervalMs: number) {
|
|
177
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
178
|
+
this.heartbeatTimer = setInterval(() => {
|
|
179
|
+
this.ws?.send(JSON.stringify({ op: OP_HEARTBEAT, d: this.lastSeq }));
|
|
180
|
+
}, intervalMs);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private handleDispatch(t: string, d: any) {
|
|
184
|
+
if (t === "READY") {
|
|
185
|
+
this.sessionId = String(d.session_id);
|
|
186
|
+
this.resumeAttempted = false;
|
|
187
|
+
this.reconnectAttempt = 0;
|
|
188
|
+
log("[gateway] ✅ READY,bot 已上线 session=", this.sessionId);
|
|
189
|
+
this.opts.connected();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (t === "RESUMED") {
|
|
193
|
+
this.resumeAttempted = false;
|
|
194
|
+
this.reconnectAttempt = 0;
|
|
195
|
+
log("[gateway] RESUMED 会话恢复");
|
|
196
|
+
this.opts.connected();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (t === "C2C_MESSAGE_CREATE") {
|
|
200
|
+
log("[gateway] C2C 原始数据:", JSON.stringify(d).slice(0, 600));
|
|
201
|
+
const rawAttachments = Array.isArray(d.attachments) ? d.attachments : [];
|
|
202
|
+
this.opts.message({
|
|
203
|
+
openid: String(d.author?.user_openid ?? d.author?.id ?? d.openid ?? ""),
|
|
204
|
+
content: String(d.content ?? ""),
|
|
205
|
+
msgId: String(d.id ?? d.msg_id ?? ""),
|
|
206
|
+
timestamp: Date.parse(String(d.timestamp ?? "")) || Date.now(),
|
|
207
|
+
attachments: rawAttachments
|
|
208
|
+
.filter((a: any) => !!a && typeof a === "object")
|
|
209
|
+
.filter(
|
|
210
|
+
(a: any) =>
|
|
211
|
+
String(a.content_type ?? a.contentType ?? "") === "image" ||
|
|
212
|
+
String(a.url ?? "").match(/\.(png|jpe?g|gif|webp)/i),
|
|
213
|
+
)
|
|
214
|
+
.map((a: any) => ({ url: String(a.url ?? "") }))
|
|
215
|
+
.filter((a: any) => a.url),
|
|
216
|
+
quotedText: undefined,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function createGatewayUrlFetcher(
|
|
223
|
+
restBase: string,
|
|
224
|
+
getToken: () => Promise<string>,
|
|
225
|
+
fetchFn: typeof fetch = fetch,
|
|
226
|
+
): () => Promise<string> {
|
|
227
|
+
return async () => {
|
|
228
|
+
const token = await getToken();
|
|
229
|
+
const res = await fetchFn(`${restBase}${GATEWAY_PATH}`, {
|
|
230
|
+
headers: { Authorization: `QQBot ${token}` },
|
|
231
|
+
});
|
|
232
|
+
if (!res.ok) throw new Error(`get gateway failed: HTTP ${res.status}`);
|
|
233
|
+
return (await res.json()).url;
|
|
234
|
+
};
|
|
235
|
+
}
|
package/stream-buffer.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** 单会话回复文本累积:message_update 的 text_delta 累加,agent_settled 时取走 */
|
|
2
|
+
export class ReplyBuffer {
|
|
3
|
+
private text = "";
|
|
4
|
+
|
|
5
|
+
append(delta: string) {
|
|
6
|
+
if (typeof delta === "string") this.text += delta;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
peek(): string {
|
|
10
|
+
return this.text;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
take(): string {
|
|
14
|
+
const t = this.text;
|
|
15
|
+
this.text = "";
|
|
16
|
+
return t;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
reset() {
|
|
20
|
+
this.text = "";
|
|
21
|
+
}
|
|
22
|
+
}
|
package/util/chunk.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function splitText(text: string, maxBytes = 1900): string[] {
|
|
2
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text ? [text] : [];
|
|
3
|
+
const parts: string[] = [];
|
|
4
|
+
let current = "";
|
|
5
|
+
let currentBytes = 0;
|
|
6
|
+
for (const seg of text.split(/(?<=\n)/)) {
|
|
7
|
+
const segBytes = Buffer.byteLength(seg, "utf8");
|
|
8
|
+
if (segBytes > maxBytes) {
|
|
9
|
+
if (current) {
|
|
10
|
+
parts.push(current);
|
|
11
|
+
current = "";
|
|
12
|
+
currentBytes = 0;
|
|
13
|
+
}
|
|
14
|
+
let buf = "";
|
|
15
|
+
let bufBytes = 0;
|
|
16
|
+
for (const ch of seg) {
|
|
17
|
+
const chBytes = Buffer.byteLength(ch, "utf8");
|
|
18
|
+
if (bufBytes + chBytes > maxBytes) {
|
|
19
|
+
parts.push(buf);
|
|
20
|
+
buf = ch;
|
|
21
|
+
bufBytes = chBytes;
|
|
22
|
+
} else {
|
|
23
|
+
buf += ch;
|
|
24
|
+
bufBytes += chBytes;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
current = buf;
|
|
28
|
+
currentBytes = bufBytes;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (currentBytes + segBytes > maxBytes) {
|
|
32
|
+
parts.push(current);
|
|
33
|
+
current = seg;
|
|
34
|
+
currentBytes = segBytes;
|
|
35
|
+
} else {
|
|
36
|
+
current += seg;
|
|
37
|
+
currentBytes += segBytes;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (current) parts.push(current);
|
|
41
|
+
return parts;
|
|
42
|
+
}
|