@zxh126/pi-qqbot 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +116 -2
- package/README.zh-CN.md +117 -0
- package/approver.ts +153 -0
- package/config.ts +45 -0
- package/constants.ts +16 -0
- package/index.ts +352 -0
- package/lock.ts +33 -0
- package/logger.ts +18 -0
- package/package.json +25 -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,117 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-qqbot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: send a message from QQ and let the agent work on the machine where pi runs — with remote approval before it executes dangerous tools.
|
|
4
|
+
|
|
5
|
+
**English** | [简体中文](README.zh-CN.md)
|
|
6
|
+
|
|
7
|
+
> **Single-session design (v1):** every QQ user shares one pi session. Multi-user / multi-session support is planned on top of the pi SDK.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
| Feature | Description |
|
|
12
|
+
|---|---|
|
|
13
|
+
| **Chat-driven agent** | QQ private messages are fed into the current pi session; the assistant's answer is sent back to QQ (long replies are split automatically). |
|
|
14
|
+
| **Remote approval** | Before pi runs `bash` / `powershell` / `write` / `edit` — or any tool annotated `destructiveHint` — the bot asks in QQ first. |
|
|
15
|
+
| **Session-scoped "always allow"** | Reply 「以后都同意」 and every approval request in the **current session** is auto-approved, including the one already waiting. |
|
|
16
|
+
| **Single-instance lock** | Only one pi process hosts the QQ gateway; the others stand by and take over automatically when the host exits. |
|
|
17
|
+
| **Built-in commands** | `/new`, `/status`, `/help`. |
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# from npm
|
|
23
|
+
pi install npm:@zxh126/pi-qqbot
|
|
24
|
+
|
|
25
|
+
# or from a local checkout (dependencies are needed here)
|
|
26
|
+
npm install
|
|
27
|
+
pi install ./pi-qqbot
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then create `~/.pi/agent/pi-qqbot.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"appId": "YOUR_APP_ID",
|
|
35
|
+
"appSecret": "YOUR_APP_SECRET",
|
|
36
|
+
"sandbox": true,
|
|
37
|
+
"allowlist": [],
|
|
38
|
+
"markdownReply": false,
|
|
39
|
+
"streaming": false,
|
|
40
|
+
"approve": {
|
|
41
|
+
"tools": ["bash", "powershell", "write", "edit"],
|
|
42
|
+
"remember": true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| Field | Default | Description |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `appId` / `appSecret` | required | Credentials of your QQ Open Platform bot |
|
|
50
|
+
| `sandbox` | `false` | Set `true` while the bot is not yet published (sandbox environment) |
|
|
51
|
+
| `allowlist` | `[]` | openid allowlist; empty means no restriction |
|
|
52
|
+
| `markdownReply` | `false` | QQ private chat does not support markdown, keep `false` |
|
|
53
|
+
| `streaming` | `false` | Reserved, streaming is not enabled in v1 |
|
|
54
|
+
| `approve.tools` | `["bash","powershell","write","edit"]` | Tools that require approval; tools annotated `destructiveHint` are always gated too |
|
|
55
|
+
| `approve.remember` | `true` | Remember the decision per tool inside the session (no more prompts for that tool) |
|
|
56
|
+
|
|
57
|
+
Credentials can also come from the `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` environment variables.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
Start pi, then message the bot in QQ; pi handles it and replies.
|
|
62
|
+
|
|
63
|
+
| Command | Description |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `/new` | Reset: clear the reply buffer, approval memory and session-wide auto-approve |
|
|
66
|
+
| `/status` | Show busy/idle state, auto-approve state and pending approval count |
|
|
67
|
+
| `/help` | Show help |
|
|
68
|
+
|
|
69
|
+
Anything else is forwarded to pi as a user message.
|
|
70
|
+
|
|
71
|
+
## Remote approval
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
[审批 #1] bash: rm -rf node_modules
|
|
75
|
+
回复「同意 1」批准,「拒绝 1」拒绝,「停止」终止本轮。
|
|
76
|
+
要本会话内全部放行,回复「以后都同意」(可用「取消自动同意」关闭)。
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Reply vocabulary (approval keywords are Chinese, with English aliases):
|
|
80
|
+
|
|
81
|
+
| Intent | Reply |
|
|
82
|
+
|---|---|
|
|
83
|
+
| Approve one request | `同意 1` / `allow 1` — a bare `同意` / `allow` applies to the oldest pending request |
|
|
84
|
+
| Reject one request | `拒绝 1` / `deny 1` — a bare `拒绝` / `deny` applies to the oldest pending request |
|
|
85
|
+
| Abort the pending requests | `停止` |
|
|
86
|
+
| Allow everything in this session | `以后都同意`, `一直同意`, `全部放行`, `别再问了`, `always allow` |
|
|
87
|
+
| Back to per-request approval | `取消自动同意`, `恢复审批`, `以后都不同意` |
|
|
88
|
+
|
|
89
|
+
Semantics:
|
|
90
|
+
|
|
91
|
+
- **Session scope only.** The auto-approve switch lives in memory and lasts until `/new`, a new pi session, or one of the "back to per-request approval" replies. Nothing is persisted, nothing survives a restart.
|
|
92
|
+
- The switch is an **explicit user instruction**, so it is **not** affected by `approve.remember`.
|
|
93
|
+
- `approve.remember: true` additionally remembers the decision **per tool** for the session: after approving `bash` once, later `bash` calls are not asked again (`/new` clears it).
|
|
94
|
+
- Requests time out after **10 minutes** and are rejected with a notification. A timeout is **not** remembered as a rejection — the tool is asked again next time.
|
|
95
|
+
- Approval text that arrives while no request is pending is forwarded to pi as a normal message.
|
|
96
|
+
|
|
97
|
+
## Single-instance lock
|
|
98
|
+
|
|
99
|
+
The QQ gateway keeps only one active connection per bot. The extension uses a `proper-lockfile` lock at `~/.pi/agent/pi-qqbot-<appId>.lock`:
|
|
100
|
+
|
|
101
|
+
- the process that acquires the lock becomes the host and connects to QQ;
|
|
102
|
+
- other processes stand by and retry every 5 seconds;
|
|
103
|
+
- when the host exits or crashes (30 second heartbeat timeout), a standby process takes over.
|
|
104
|
+
|
|
105
|
+
## Prerequisites
|
|
106
|
+
|
|
107
|
+
1. Create a bot on the [QQ Open Platform](https://q.qq.com/) and copy its **AppID** / **AppSecret**.
|
|
108
|
+
2. Before the bot is published, configure a **sandbox C2C account** and set `sandbox` to `true`.
|
|
109
|
+
3. In production, add the server's public egress IP to the bot's allowlist.
|
|
110
|
+
|
|
111
|
+
## Logs
|
|
112
|
+
|
|
113
|
+
Debug logs are written to `~/.pi/agent/pi-qqbot.log`.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# pi-qqbot
|
|
2
|
+
|
|
3
|
+
把 **[Pi](https://pi.dev) coding agent** 接入 QQ 单聊的桥接插件:在 QQ 里发一句话,让 AI 在运行 pi 的那台机器上干活 —— 执行危险工具前会先在 QQ 里向你申请审批。
|
|
4
|
+
|
|
5
|
+
[English](README.md) | **简体中文**
|
|
6
|
+
|
|
7
|
+
> **单会话设计(v1)**:所有 QQ 用户共用同一个 pi 会话。多用户/多会话后续基于 pi SDK 实现。
|
|
8
|
+
|
|
9
|
+
## 功能
|
|
10
|
+
|
|
11
|
+
| 功能 | 说明 |
|
|
12
|
+
|---|---|
|
|
13
|
+
| **对话驱动 agent** | QQ 单聊消息送入当前 pi 会话,AI 的回复发回 QQ(长文本自动分片)。 |
|
|
14
|
+
| **远程审批** | pi 执行 `bash` / `powershell` / `write` / `edit`,或任何带 `destructiveHint` 注解的工具前,先在 QQ 里申请审批。 |
|
|
15
|
+
| **会话级「以后都同意」** | 回复「以后都同意」,**当前会话**内所有审批请求直接放行,连已经挂起的那条也一起放行。 |
|
|
16
|
+
| **单实例锁** | 只有一个 pi 进程作为宿主连接 QQ,其余待命,宿主退出后自动接管。 |
|
|
17
|
+
| **内置指令** | `/new`、`/status`、`/help`。 |
|
|
18
|
+
|
|
19
|
+
## 安装
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 从 npm 安装
|
|
23
|
+
pi install npm:@zxh126/pi-qqbot
|
|
24
|
+
|
|
25
|
+
# 或从本地目录安装(需要先装依赖)
|
|
26
|
+
npm install
|
|
27
|
+
pi install ./pi-qqbot
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
然后创建 `~/.pi/agent/pi-qqbot.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"appId": "你的AppID",
|
|
35
|
+
"appSecret": "你的AppSecret",
|
|
36
|
+
"sandbox": true,
|
|
37
|
+
"allowlist": [],
|
|
38
|
+
"markdownReply": false,
|
|
39
|
+
"streaming": false,
|
|
40
|
+
"approve": {
|
|
41
|
+
"tools": ["bash", "powershell", "write", "edit"],
|
|
42
|
+
"remember": true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| 字段 | 默认 | 说明 |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `appId` / `appSecret` | 必填 | QQ 开放平台机器人凭据 |
|
|
50
|
+
| `sandbox` | `false` | 未提审上线的 bot 用 `true`(沙箱环境) |
|
|
51
|
+
| `allowlist` | `[]` | openid 白名单,空 = 不限制 |
|
|
52
|
+
| `markdownReply` | `false` | QQ 单聊不支持 markdown,保持 `false` |
|
|
53
|
+
| `streaming` | `false` | 预留,v1 未启用流式 |
|
|
54
|
+
| `approve.tools` | `["bash","powershell","write","edit"]` | 需要审批的工具名;带 `destructiveHint` 注解的工具同样会拦截 |
|
|
55
|
+
| `approve.remember` | `true` | 会话内按工具记住决策(同类工具不再重复询问) |
|
|
56
|
+
|
|
57
|
+
也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件里的凭据。
|
|
58
|
+
|
|
59
|
+
## 使用
|
|
60
|
+
|
|
61
|
+
启动 pi 后,在 QQ 里给机器人发消息,pi 会处理并回复。
|
|
62
|
+
|
|
63
|
+
| 指令 | 说明 |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `/new` | 重置:清空回复缓冲、审批记忆与自动同意开关 |
|
|
66
|
+
| `/status` | 查看忙闲状态、自动同意状态、待审批数量 |
|
|
67
|
+
| `/help` | 帮助 |
|
|
68
|
+
|
|
69
|
+
其余文本都作为用户消息交给 pi。
|
|
70
|
+
|
|
71
|
+
## 远程审批
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
[审批 #1] bash: rm -rf node_modules
|
|
75
|
+
回复「同意 1」批准,「拒绝 1」拒绝,「停止」终止本轮。
|
|
76
|
+
要本会话内全部放行,回复「以后都同意」(可用「取消自动同意」关闭)。
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
回复用语(审批关键词以中文为主,也支持英文别名):
|
|
80
|
+
|
|
81
|
+
| 意图 | 回复 |
|
|
82
|
+
|---|---|
|
|
83
|
+
| 批准某条 | `同意 1` / `allow 1`;裸 `同意` / `allow` 作用于最早的一条待审批 |
|
|
84
|
+
| 拒绝某条 | `拒绝 1` / `deny 1`;裸 `拒绝` / `deny` 作用于最早的一条待审批 |
|
|
85
|
+
| 终止当前待审批 | `停止` |
|
|
86
|
+
| 本会话全部放行 | `以后都同意`、`一直同意`、`全部放行`、`别再问了`、`always allow` |
|
|
87
|
+
| 恢复逐条审批 | `取消自动同意`、`恢复审批`、`以后都不同意` |
|
|
88
|
+
|
|
89
|
+
语义说明:
|
|
90
|
+
|
|
91
|
+
- **仅当前会话有效**:自动同意开关只存在于内存,`/new`、pi 新会话,或回复「取消自动同意」类用语后立即失效;不写盘、不跨进程保留。
|
|
92
|
+
- 该开关是**用户的显式指令**,**不受 `approve.remember` 配置限制**。
|
|
93
|
+
- `approve.remember: true` 时会额外**按工具名**记住决策:同意过一次 `bash`,本会话后续 `bash` 不再询问(`/new` 清除)。
|
|
94
|
+
- 审批请求 **10 分钟**超时后自动拒绝并通知;超时**不会**被记成“拒绝”,下次仍会询问。
|
|
95
|
+
- 没有待审批请求时,这类文本会作为普通消息转交给 pi。
|
|
96
|
+
|
|
97
|
+
## 单实例锁
|
|
98
|
+
|
|
99
|
+
QQ 网关对同一 bot 只保留一个活跃连接。插件用 `proper-lockfile` 文件锁(`~/.pi/agent/pi-qqbot-<appId>.lock`):
|
|
100
|
+
|
|
101
|
+
- 抢到锁的进程 = 宿主,连接 QQ;
|
|
102
|
+
- 其余进程待命,每 5 秒重试;
|
|
103
|
+
- 宿主退出或崩溃(30 秒心跳超时)后,待命进程自动接管。
|
|
104
|
+
|
|
105
|
+
## 前置条件
|
|
106
|
+
|
|
107
|
+
1. 在 [QQ 开放平台](https://q.qq.com/) 创建机器人,拿到 **AppID** 与 **AppSecret**。
|
|
108
|
+
2. 未上线阶段配置**沙箱单聊账号**,并把 `sandbox` 设为 `true`。
|
|
109
|
+
3. 正式环境填写服务器公网出口 IP 白名单。
|
|
110
|
+
|
|
111
|
+
## 日志
|
|
112
|
+
|
|
113
|
+
调试日志写入 `~/.pi/agent/pi-qqbot.log`。
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
package/approver.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ 远程审批:
|
|
3
|
+
* - 每个待审批请求生成编号,发到 QQ,等用户回复「同意/拒绝 N」或「停止」。
|
|
4
|
+
* - register 返回一个 Promise,QQ 回复后 resolve 决策。
|
|
5
|
+
* - remember 记忆:会话内同意/拒绝某工具后,同类工具不再询问。
|
|
6
|
+
* - 自动同意(「以后都同意」):会话级开关,开启后本会话内所有需审批的工具直接放行,
|
|
7
|
+
* 直到 /new、新会话或回复「取消自动同意」为止(不跨会话、不写盘)。
|
|
8
|
+
*/
|
|
9
|
+
export type ApproveDecision = "allow" | "block" | "terminate" | "timeout";
|
|
10
|
+
|
|
11
|
+
/** parseReply 的解析结果:前四种对应具体动作,always/revoke 操作会话级自动同意 */
|
|
12
|
+
export type ParsedDecision = "allow" | "block" | "terminate" | "always" | "revoke";
|
|
13
|
+
|
|
14
|
+
export interface ParsedReply {
|
|
15
|
+
decision: ParsedDecision;
|
|
16
|
+
/** 缺省表示「作用于最早的一条待审批」,没有待审批时按普通消息处理 */
|
|
17
|
+
seq?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PendingApproval {
|
|
21
|
+
summary: string;
|
|
22
|
+
timer: ReturnType<typeof setTimeout>;
|
|
23
|
+
resolve: (decision: ApproveDecision) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 「以后都同意」类表达 */
|
|
27
|
+
const RE_ALWAYS =
|
|
28
|
+
/(以后|之后|今后|一直|全都|全部|都)\s*(同意|允许|批准|放行)|别再(问|烦|提示)|always\s*(allow|yes)/i;
|
|
29
|
+
/** 「取消自动同意」类表达(含「以后都不同意」) */
|
|
30
|
+
const RE_REVOKE =
|
|
31
|
+
/(取消|关闭|结束|停止)\s*自动同意|^恢复审批$|(以后|之后|今后|都|全部)\s*不(同意|允许|批准|放行)/;
|
|
32
|
+
/** 「停止」终止本轮 */
|
|
33
|
+
const RE_TERMINATE = /^停止$/;
|
|
34
|
+
/** 带或不带编号的批准/拒绝 */
|
|
35
|
+
const RE_DECIDE = /^(同意|批准|放行|允许|allow|拒绝|驳回|deny)\s*(\d+)?$/i;
|
|
36
|
+
|
|
37
|
+
export class Approver {
|
|
38
|
+
private pending = new Map<number, PendingApproval>();
|
|
39
|
+
private rememberMap = new Map<string, ApproveDecision>();
|
|
40
|
+
private nextSeq = 1;
|
|
41
|
+
private autoAllow = false;
|
|
42
|
+
private timeoutMs: number;
|
|
43
|
+
|
|
44
|
+
constructor(timeoutMs: number) {
|
|
45
|
+
this.timeoutMs = timeoutMs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 会话级自动同意是否开启 */
|
|
49
|
+
get autoAllowed(): boolean {
|
|
50
|
+
return this.autoAllow;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 待审批请求数量 */
|
|
54
|
+
get pendingCount(): number {
|
|
55
|
+
return this.pending.size;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 会话内是否已记住某工具的决策 */
|
|
59
|
+
isRemembered(toolName: string): ApproveDecision | undefined {
|
|
60
|
+
return this.rememberMap.get(toolName);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
register(
|
|
64
|
+
summary: string,
|
|
65
|
+
onTimeout?: () => void,
|
|
66
|
+
): { seq: number; done: Promise<ApproveDecision> } {
|
|
67
|
+
const seq = this.nextSeq++;
|
|
68
|
+
const done = new Promise<ApproveDecision>((resolve) => {
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
this.pending.delete(seq);
|
|
71
|
+
resolve("timeout");
|
|
72
|
+
onTimeout?.();
|
|
73
|
+
}, this.timeoutMs);
|
|
74
|
+
this.pending.set(seq, { summary, timer, resolve });
|
|
75
|
+
});
|
|
76
|
+
return { seq, done };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
render(seq: number): string {
|
|
80
|
+
const item = this.pending.get(seq);
|
|
81
|
+
return [
|
|
82
|
+
`[审批 #${seq}] ${item?.summary ?? ""}`,
|
|
83
|
+
`回复「同意 ${seq}」批准,「拒绝 ${seq}」拒绝,「停止」终止本轮。`,
|
|
84
|
+
`要本会话内全部放行,回复「以后都同意」(可用「取消自动同意」关闭)。`,
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 最早(编号最小)的待审批请求 */
|
|
89
|
+
oldestSeq(): number | undefined {
|
|
90
|
+
let oldest: number | undefined;
|
|
91
|
+
for (const seq of this.pending.keys()) {
|
|
92
|
+
if (oldest === undefined || seq < oldest) oldest = seq;
|
|
93
|
+
}
|
|
94
|
+
return oldest;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
parseReply(text: string): ParsedReply | null {
|
|
98
|
+
const t = text.trim();
|
|
99
|
+
if (!t) return null;
|
|
100
|
+
if (RE_REVOKE.test(t)) return { decision: "revoke" };
|
|
101
|
+
if (RE_ALWAYS.test(t)) return { decision: "always" };
|
|
102
|
+
if (RE_TERMINATE.test(t)) return { decision: "terminate" };
|
|
103
|
+
const m = RE_DECIDE.exec(t);
|
|
104
|
+
if (!m) return null;
|
|
105
|
+
const allow = /^(同意|批准|放行|允许|allow)$/i.test(m[1]);
|
|
106
|
+
return { decision: allow ? "allow" : "block", seq: m[2] ? Number(m[2]) : undefined };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
confirm(seq: number, decision: ApproveDecision): boolean {
|
|
110
|
+
const item = this.pending.get(seq);
|
|
111
|
+
if (!item) return false;
|
|
112
|
+
clearTimeout(item.timer);
|
|
113
|
+
this.pending.delete(seq);
|
|
114
|
+
item.resolve(decision);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 放行所有待审批请求(开启自动同意时用) */
|
|
119
|
+
allowAll(): number {
|
|
120
|
+
return this.resolveAll("allow");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 终止所有待审批请求(「停止」) */
|
|
124
|
+
terminateAll(): number {
|
|
125
|
+
return this.resolveAll("terminate");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 开启/关闭会话级自动同意 */
|
|
129
|
+
setAutoAllow(on: boolean): void {
|
|
130
|
+
this.autoAllow = on;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
remember(toolName: string, decision: ApproveDecision) {
|
|
134
|
+
this.rememberMap.set(toolName, decision);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 清空会话状态(remember 记忆 + 自动同意开关) */
|
|
138
|
+
reset() {
|
|
139
|
+
this.rememberMap.clear();
|
|
140
|
+
this.autoAllow = false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private resolveAll(decision: ApproveDecision): number {
|
|
144
|
+
let count = 0;
|
|
145
|
+
for (const [seq, item] of [...this.pending]) {
|
|
146
|
+
clearTimeout(item.timer);
|
|
147
|
+
this.pending.delete(seq);
|
|
148
|
+
item.resolve(decision);
|
|
149
|
+
count++;
|
|
150
|
+
}
|
|
151
|
+
return count;
|
|
152
|
+
}
|
|
153
|
+
}
|
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,352 @@
|
|
|
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
|
+
if (state.approver.autoAllowed) {
|
|
85
|
+
log("[approve] 自动同意放行:", toolName);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 会话内已记住的决策
|
|
90
|
+
const remembered = cfg.approve.remember ? state.approver.isRemembered(toolName) : undefined;
|
|
91
|
+
if (remembered === "allow") return; // 已记住放行
|
|
92
|
+
if (remembered === "block") return { block: true, reason: `QQ 用户已拒绝 ${toolName}` };
|
|
93
|
+
if (remembered === "terminate") return { block: true, terminate: true, reason: "已停止" };
|
|
94
|
+
|
|
95
|
+
const summary = summarizeTool(ev);
|
|
96
|
+
log("[approve] 工具需要审批:", summary);
|
|
97
|
+
const { seq, done } = state.approver.register(summary, () => {
|
|
98
|
+
// 超时通知
|
|
99
|
+
state.api?.sendC2C(openid, `审批 #${seq} 已超时,已自动拒绝。`).catch(() => {});
|
|
100
|
+
});
|
|
101
|
+
await state.api.sendC2C(openid, state.approver.render(seq)).catch(() => {});
|
|
102
|
+
const decision = await done;
|
|
103
|
+
|
|
104
|
+
if (decision === "allow") {
|
|
105
|
+
if (cfg.approve.remember) state.approver.remember(toolName, "allow");
|
|
106
|
+
log("[approve] 用户批准 #", seq);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (decision === "terminate") {
|
|
110
|
+
log("[approve] 用户停止");
|
|
111
|
+
return { block: true, terminate: true, reason: "QQ 用户停止" };
|
|
112
|
+
}
|
|
113
|
+
if (decision === "timeout") {
|
|
114
|
+
// 超时不写 remember:一次没回复不该永久封死该工具
|
|
115
|
+
log("[approve] 审批超时 #", seq);
|
|
116
|
+
return { block: true, reason: `QQ 审批超时(#${seq}):${summary}` };
|
|
117
|
+
}
|
|
118
|
+
// block(用户显式拒绝)
|
|
119
|
+
if (cfg.approve.remember) state.approver.remember(toolName, "block");
|
|
120
|
+
log("[approve] 用户拒绝 #", seq);
|
|
121
|
+
return { block: true, reason: `QQ 用户拒绝了 ${summary}` };
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---- 事件:session_start 抢锁 + 启动网关 ----
|
|
125
|
+
pi.on("session_start", async () => {
|
|
126
|
+
const cfg = loadConfig();
|
|
127
|
+
if (!cfg) {
|
|
128
|
+
log("[setup] 缺少 QQ_BOT_APPID/QQ_BOT_APPSECRET 或配置,插件未启用");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
state.cfg = cfg;
|
|
132
|
+
// 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
|
|
133
|
+
state.approver.reset();
|
|
134
|
+
log("[setup] 配置已加载 sandbox=", cfg.sandbox);
|
|
135
|
+
|
|
136
|
+
const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
|
|
137
|
+
const auth = new AuthManager(cfg.appId, cfg.appSecret);
|
|
138
|
+
const api = new QQApi({ restBase, getToken: () => auth.getToken() });
|
|
139
|
+
state.api = api;
|
|
140
|
+
|
|
141
|
+
const startGateway = () => {
|
|
142
|
+
const gateway = new QQGateway({
|
|
143
|
+
getGatewayUrl: createGatewayUrlFetcher(restBase, () => auth.getToken()),
|
|
144
|
+
getToken: () => auth.getToken(),
|
|
145
|
+
intents: INTENT_GROUP_AND_C2C,
|
|
146
|
+
connected: () => {},
|
|
147
|
+
disconnected: () => {},
|
|
148
|
+
message: (msg) => handleMessage(pi, state, api, cfg, msg),
|
|
149
|
+
});
|
|
150
|
+
state.gateway = gateway;
|
|
151
|
+
log("[lock] 启动 QQ 网关");
|
|
152
|
+
gateway.start();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// 单实例锁:只有抢到锁的实例连接 QQ
|
|
156
|
+
const lockPath = LOCK_PATH(cfg.appId);
|
|
157
|
+
const release = await tryAcquireLock(lockPath);
|
|
158
|
+
if (release) {
|
|
159
|
+
state.releaseLock = release;
|
|
160
|
+
log("[lock] 抢锁成功,本实例为 QQ bot 宿主");
|
|
161
|
+
startGateway();
|
|
162
|
+
} else {
|
|
163
|
+
log("[lock] 锁被占用,本实例进入待命");
|
|
164
|
+
state.stopRetry = startLockRetry(lockPath, (r) => {
|
|
165
|
+
state.releaseLock = r;
|
|
166
|
+
log("[lock] 抢锁成功,接管 QQ bot");
|
|
167
|
+
startGateway();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---- 事件:session_shutdown 清理 ----
|
|
173
|
+
pi.on("session_shutdown", async () => {
|
|
174
|
+
log("[shutdown] 清理资源");
|
|
175
|
+
if (state.stopRetry) state.stopRetry();
|
|
176
|
+
state.gateway?.stop();
|
|
177
|
+
if (state.releaseLock) {
|
|
178
|
+
try {
|
|
179
|
+
await state.releaseLock();
|
|
180
|
+
} catch {
|
|
181
|
+
/* ignore */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
state.releaseLock = null;
|
|
185
|
+
state.gateway = null;
|
|
186
|
+
state.api = null;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- QQ 消息处理 ----
|
|
191
|
+
async function handleMessage(
|
|
192
|
+
pi: ExtensionAPI,
|
|
193
|
+
state: State,
|
|
194
|
+
api: QQApi,
|
|
195
|
+
cfg: QQBotConfig,
|
|
196
|
+
msg: QQMessage,
|
|
197
|
+
) {
|
|
198
|
+
try {
|
|
199
|
+
log("[msg] 收到消息 openid=", msg.openid, "content=", msg.content.slice(0, 100));
|
|
200
|
+
if (cfg.allowlist.length > 0 && !cfg.allowlist.includes(msg.openid)) {
|
|
201
|
+
log("[msg] 被 allowlist 拦截");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
|
|
205
|
+
|
|
206
|
+
// 远程审批回复优先
|
|
207
|
+
const parsed = state.approver.parseReply(msg.content.trim());
|
|
208
|
+
if (parsed) {
|
|
209
|
+
if (parsed.decision === "terminate") {
|
|
210
|
+
const n = state.approver.terminateAll();
|
|
211
|
+
state.approver.setAutoAllow(false);
|
|
212
|
+
await replyTo(api, state, msg.openid, `已终止 ${n} 个待审批请求,自动同意已关闭`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (parsed.decision === "always") {
|
|
216
|
+
// 「以后都同意」= 本会话内全部放行:立刻开启开关并放行当前所有待审批
|
|
217
|
+
state.approver.setAutoAllow(true);
|
|
218
|
+
const n = state.approver.allowAll();
|
|
219
|
+
log("[approve] 开启会话级自动同意,放行", n, "个待审批");
|
|
220
|
+
await replyTo(
|
|
221
|
+
api,
|
|
222
|
+
state,
|
|
223
|
+
msg.openid,
|
|
224
|
+
[
|
|
225
|
+
`已开启自动同意:本会话内需审批的工具全部放行${n > 0 ? `,已放行 ${n} 个待审批请求` : ""}。`,
|
|
226
|
+
"回复「取消自动同意」或 /new 恢复逐条审批。",
|
|
227
|
+
].join("\n"),
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (parsed.decision === "revoke") {
|
|
232
|
+
const was = state.approver.autoAllowed;
|
|
233
|
+
state.approver.setAutoAllow(false);
|
|
234
|
+
log("[approve] 关闭自动同意 was=", was);
|
|
235
|
+
await replyTo(
|
|
236
|
+
api,
|
|
237
|
+
state,
|
|
238
|
+
msg.openid,
|
|
239
|
+
was ? "已关闭自动同意,恢复逐条审批。" : "当前没有开启自动同意,仍需逐条审批。",
|
|
240
|
+
);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// allow / block:没写编号时作用于最早的一条待审批
|
|
244
|
+
const seq = parsed.seq ?? state.approver.oldestSeq();
|
|
245
|
+
if (seq !== undefined) {
|
|
246
|
+
const ok = state.approver.confirm(seq, parsed.decision);
|
|
247
|
+
const text = !ok
|
|
248
|
+
? `#${seq} 不存在或已超时`
|
|
249
|
+
: parsed.decision === "allow"
|
|
250
|
+
? `已批准 #${seq}`
|
|
251
|
+
: `已拒绝 #${seq}`;
|
|
252
|
+
await replyTo(api, state, msg.openid, text);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// 没有待审批:这句「同意/拒绝」当普通消息交给 pi,继续往下走
|
|
256
|
+
log("[msg] 审批类文本但无待审批,按普通消息处理");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 内置指令
|
|
260
|
+
const cmd = parseCommand(msg.content);
|
|
261
|
+
if (cmd === "help") {
|
|
262
|
+
await replyTo(
|
|
263
|
+
api,
|
|
264
|
+
state,
|
|
265
|
+
msg.openid,
|
|
266
|
+
["pi-qqbot 指令:", "/new — 开新会话", "/status — 查看状态", "/help — 帮助", "其余文本交给 pi 处理。"].join("\n"),
|
|
267
|
+
);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (cmd === "status") {
|
|
271
|
+
await replyTo(
|
|
272
|
+
api,
|
|
273
|
+
state,
|
|
274
|
+
msg.openid,
|
|
275
|
+
[
|
|
276
|
+
`状态: ${state.busy ? "处理中" : "空闲"}`,
|
|
277
|
+
`自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
|
|
278
|
+
`待审批: ${state.approver.pendingCount}`,
|
|
279
|
+
].join("\n"),
|
|
280
|
+
);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (cmd === "new") {
|
|
284
|
+
state.replyBuffer.reset();
|
|
285
|
+
state.approver.reset();
|
|
286
|
+
await replyTo(api, state, msg.openid, "已重置(含审批记忆/自动同意),下一条消息开启新对话。");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 忙检查:单会话串行处理
|
|
291
|
+
if (state.busy) {
|
|
292
|
+
await replyTo(api, state, msg.openid, "上一条消息还在处理中,请稍候…");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ack
|
|
297
|
+
await replyTo(api, state, msg.openid, "收到,正在处理");
|
|
298
|
+
|
|
299
|
+
state.currentOpenid = msg.openid;
|
|
300
|
+
state.busy = true;
|
|
301
|
+
state.replyBuffer.reset();
|
|
302
|
+
|
|
303
|
+
// 交给 pi 处理
|
|
304
|
+
pi.sendUserMessage(msg.content, { deliverAs: "followUp" });
|
|
305
|
+
log("[msg] 已送入 pi:", msg.content.slice(0, 60));
|
|
306
|
+
} catch (e) {
|
|
307
|
+
log("[msg] 处理失败:", String(e));
|
|
308
|
+
await replyTo(api, state, msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function replyTo(
|
|
313
|
+
api: QQApi,
|
|
314
|
+
state: State,
|
|
315
|
+
openid: string,
|
|
316
|
+
text: string,
|
|
317
|
+
format: "text" | "markdown" = "text",
|
|
318
|
+
) {
|
|
319
|
+
const ref = state.passiveRefs.get(openid);
|
|
320
|
+
for (const chunk of splitText(text)) {
|
|
321
|
+
const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
|
|
322
|
+
try {
|
|
323
|
+
await api.sendC2C(openid, chunk, usePassive ? { msgId: ref.msgId, format } : { format });
|
|
324
|
+
log("[reply] 已发送 (passive=", usePassive, "):", chunk.slice(0, 50));
|
|
325
|
+
} catch (e) {
|
|
326
|
+
log("[reply] 发送失败:", String(e));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function parseCommand(text: string): "new" | "status" | "help" | null {
|
|
332
|
+
const m = /^\/(new|status|help)\s*$/i.exec(text.trim());
|
|
333
|
+
return m ? (m[1].toLowerCase() as "new" | "status" | "help") : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 从 tool_call 事件提取可读摘要,让用户在 QQ 里看清要执行什么 */
|
|
337
|
+
function summarizeTool(event: any): string {
|
|
338
|
+
const input = event.input ?? {};
|
|
339
|
+
switch (event.toolName) {
|
|
340
|
+
case "bash":
|
|
341
|
+
case "powershell":
|
|
342
|
+
return `${event.toolName}: ${String(input.command ?? "").slice(0, 200)}`;
|
|
343
|
+
case "write":
|
|
344
|
+
case "edit":
|
|
345
|
+
case "read":
|
|
346
|
+
return `${event.toolName}: ${String(input.path ?? input.file ?? input.filePath ?? "").slice(0, 200)}`;
|
|
347
|
+
case "grep":
|
|
348
|
+
return `${event.toolName}: ${String(input.pattern ?? "").slice(0, 200)}`;
|
|
349
|
+
default:
|
|
350
|
+
return `${event.toolName}: ${JSON.stringify(input).slice(0, 200)}`;
|
|
351
|
+
}
|
|
352
|
+
}
|
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,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zxh126/pi-qqbot",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Bridge the Pi coding agent into QQ private chats, with remote tool-call approval",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["pi-package", "pi", "qq", "qqbot", "bot", "coding-agent", "approval"],
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://gitee.com/zxh126/pi-qqbot.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://gitee.com/zxh126/pi-qqbot#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://gitee.com/zxh126/pi-qqbot/issues"
|
|
15
|
+
},
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": ["./index.ts"]
|
|
18
|
+
},
|
|
19
|
+
"files": ["*.ts", "qq", "util", "README.md", "README.zh-CN.md", "LICENSE"],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"ws": "^8.18.0",
|
|
22
|
+
"proper-lockfile": "^4.1.2"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
26
|
+
}
|
|
27
|
+
}
|
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
|
+
}
|