@zxh126/pi-qqbot 0.1.0 → 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/README.md +77 -49
- package/README.zh-CN.md +117 -0
- package/approver.ts +81 -15
- package/config.ts +1 -1
- package/index.ts +69 -12
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
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
4
|
|
|
5
|
-
|
|
5
|
+
**English** | [简体中文](README.zh-CN.md)
|
|
6
6
|
|
|
7
|
-
|
|
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
8
|
|
|
9
|
-
|
|
10
|
-
- **QQ 远程审批**:pi 执行 `bash` / `powershell` 等命令类工具时,先在 QQ 里发「同意 N / 拒绝 N」请求,用户回复后放行/拦截。
|
|
11
|
-
- **单实例锁**:多个 pi 实例只会有「宿主」连接 QQ,其余待命,宿主退出后自动接管。
|
|
12
|
-
- **内置指令**:`/new` 重置、`/status` 状态、`/help` 帮助。
|
|
9
|
+
## Features
|
|
13
10
|
|
|
14
|
-
|
|
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`. |
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
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`:
|
|
19
31
|
|
|
20
32
|
```json
|
|
21
33
|
{
|
|
22
|
-
"appId": "
|
|
23
|
-
"appSecret": "
|
|
34
|
+
"appId": "YOUR_APP_ID",
|
|
35
|
+
"appSecret": "YOUR_APP_SECRET",
|
|
24
36
|
"sandbox": true,
|
|
25
37
|
"allowlist": [],
|
|
26
38
|
"markdownReply": false,
|
|
@@ -32,57 +44,73 @@
|
|
|
32
44
|
}
|
|
33
45
|
```
|
|
34
46
|
|
|
35
|
-
|
|
|
36
|
-
|
|
37
|
-
| `appId` / `appSecret` |
|
|
38
|
-
| `sandbox` | `false` |
|
|
39
|
-
| `allowlist` | `[]` | openid
|
|
40
|
-
| `markdownReply` | `false` | QQ
|
|
41
|
-
| `streaming` | `false` |
|
|
42
|
-
| `approve.tools` | `["bash","powershell","write","edit"]` |
|
|
43
|
-
| `approve.remember` | `true` |
|
|
44
|
-
|
|
45
|
-
也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件凭据。
|
|
46
|
-
|
|
47
|
-
## 使用
|
|
48
|
-
|
|
49
|
-
启动 pi 后,在 QQ 里给机器人发消息,pi 会处理并回复。内置指令:
|
|
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) |
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
- `/status` — 查看是否空闲
|
|
53
|
-
- `/help` — 帮助
|
|
57
|
+
Credentials can also come from the `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` environment variables.
|
|
54
58
|
|
|
55
|
-
##
|
|
59
|
+
## Usage
|
|
56
60
|
|
|
57
|
-
|
|
61
|
+
Start pi, then message the bot in QQ; pi handles it and replies.
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
-
|
|
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 |
|
|
62
68
|
|
|
63
|
-
|
|
69
|
+
Anything else is forwarded to pi as a user message.
|
|
64
70
|
|
|
65
|
-
|
|
71
|
+
## Remote approval
|
|
66
72
|
|
|
67
|
-
```
|
|
73
|
+
```text
|
|
68
74
|
[审批 #1] bash: rm -rf node_modules
|
|
69
75
|
回复「同意 1」批准,「拒绝 1」拒绝,「停止」终止本轮。
|
|
76
|
+
要本会话内全部放行,回复「以后都同意」(可用「取消自动同意」关闭)。
|
|
70
77
|
```
|
|
71
78
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
76
104
|
|
|
77
|
-
##
|
|
105
|
+
## Prerequisites
|
|
78
106
|
|
|
79
|
-
1.
|
|
80
|
-
2.
|
|
81
|
-
3.
|
|
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.
|
|
82
110
|
|
|
83
|
-
##
|
|
111
|
+
## Logs
|
|
84
112
|
|
|
85
|
-
|
|
113
|
+
Debug logs are written to `~/.pi/agent/pi-qqbot.log`.
|
|
86
114
|
|
|
87
115
|
## License
|
|
88
116
|
|
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
CHANGED
|
@@ -3,8 +3,19 @@
|
|
|
3
3
|
* - 每个待审批请求生成编号,发到 QQ,等用户回复「同意/拒绝 N」或「停止」。
|
|
4
4
|
* - register 返回一个 Promise,QQ 回复后 resolve 决策。
|
|
5
5
|
* - remember 记忆:会话内同意/拒绝某工具后,同类工具不再询问。
|
|
6
|
+
* - 自动同意(「以后都同意」):会话级开关,开启后本会话内所有需审批的工具直接放行,
|
|
7
|
+
* 直到 /new、新会话或回复「取消自动同意」为止(不跨会话、不写盘)。
|
|
6
8
|
*/
|
|
7
|
-
export type ApproveDecision = "allow" | "block" | "terminate";
|
|
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
|
+
}
|
|
8
19
|
|
|
9
20
|
interface PendingApproval {
|
|
10
21
|
summary: string;
|
|
@@ -12,12 +23,37 @@ interface PendingApproval {
|
|
|
12
23
|
resolve: (decision: ApproveDecision) => void;
|
|
13
24
|
}
|
|
14
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
|
+
|
|
15
37
|
export class Approver {
|
|
16
38
|
private pending = new Map<number, PendingApproval>();
|
|
17
39
|
private rememberMap = new Map<string, ApproveDecision>();
|
|
18
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
|
+
}
|
|
19
52
|
|
|
20
|
-
|
|
53
|
+
/** 待审批请求数量 */
|
|
54
|
+
get pendingCount(): number {
|
|
55
|
+
return this.pending.size;
|
|
56
|
+
}
|
|
21
57
|
|
|
22
58
|
/** 会话内是否已记住某工具的决策 */
|
|
23
59
|
isRemembered(toolName: string): ApproveDecision | undefined {
|
|
@@ -32,7 +68,7 @@ export class Approver {
|
|
|
32
68
|
const done = new Promise<ApproveDecision>((resolve) => {
|
|
33
69
|
const timer = setTimeout(() => {
|
|
34
70
|
this.pending.delete(seq);
|
|
35
|
-
resolve("
|
|
71
|
+
resolve("timeout");
|
|
36
72
|
onTimeout?.();
|
|
37
73
|
}, this.timeoutMs);
|
|
38
74
|
this.pending.set(seq, { summary, timer, resolve });
|
|
@@ -45,15 +81,29 @@ export class Approver {
|
|
|
45
81
|
return [
|
|
46
82
|
`[审批 #${seq}] ${item?.summary ?? ""}`,
|
|
47
83
|
`回复「同意 ${seq}」批准,「拒绝 ${seq}」拒绝,「停止」终止本轮。`,
|
|
84
|
+
`要本会话内全部放行,回复「以后都同意」(可用「取消自动同意」关闭)。`,
|
|
48
85
|
].join("\n");
|
|
49
86
|
}
|
|
50
87
|
|
|
51
|
-
|
|
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 {
|
|
52
98
|
const t = text.trim();
|
|
53
|
-
if (
|
|
54
|
-
|
|
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);
|
|
55
104
|
if (!m) return null;
|
|
56
|
-
|
|
105
|
+
const allow = /^(同意|批准|放行|允许|allow)$/i.test(m[1]);
|
|
106
|
+
return { decision: allow ? "allow" : "block", seq: m[2] ? Number(m[2]) : undefined };
|
|
57
107
|
}
|
|
58
108
|
|
|
59
109
|
confirm(seq: number, decision: ApproveDecision): boolean {
|
|
@@ -65,23 +115,39 @@ export class Approver {
|
|
|
65
115
|
return true;
|
|
66
116
|
}
|
|
67
117
|
|
|
118
|
+
/** 放行所有待审批请求(开启自动同意时用) */
|
|
119
|
+
allowAll(): number {
|
|
120
|
+
return this.resolveAll("allow");
|
|
121
|
+
}
|
|
122
|
+
|
|
68
123
|
/** 终止所有待审批请求(「停止」) */
|
|
69
124
|
terminateAll(): number {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
return count;
|
|
125
|
+
return this.resolveAll("terminate");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 开启/关闭会话级自动同意 */
|
|
129
|
+
setAutoAllow(on: boolean): void {
|
|
130
|
+
this.autoAllow = on;
|
|
78
131
|
}
|
|
79
132
|
|
|
80
133
|
remember(toolName: string, decision: ApproveDecision) {
|
|
81
134
|
this.rememberMap.set(toolName, decision);
|
|
82
135
|
}
|
|
83
136
|
|
|
137
|
+
/** 清空会话状态(remember 记忆 + 自动同意开关) */
|
|
84
138
|
reset() {
|
|
85
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;
|
|
86
152
|
}
|
|
87
153
|
}
|
package/config.ts
CHANGED
package/index.ts
CHANGED
|
@@ -80,6 +80,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
80
80
|
const needsApproval = cfg.approve.tools.includes(toolName) || destructive;
|
|
81
81
|
if (!needsApproval) return; // 放行
|
|
82
82
|
|
|
83
|
+
// 会话级自动同意(用户回复过「以后都同意」):本会话内直接放行,不落盘、不跨会话
|
|
84
|
+
if (state.approver.autoAllowed) {
|
|
85
|
+
log("[approve] 自动同意放行:", toolName);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
83
89
|
// 会话内已记住的决策
|
|
84
90
|
const remembered = cfg.approve.remember ? state.approver.isRemembered(toolName) : undefined;
|
|
85
91
|
if (remembered === "allow") return; // 已记住放行
|
|
@@ -104,7 +110,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
104
110
|
log("[approve] 用户停止");
|
|
105
111
|
return { block: true, terminate: true, reason: "QQ 用户停止" };
|
|
106
112
|
}
|
|
107
|
-
|
|
113
|
+
if (decision === "timeout") {
|
|
114
|
+
// 超时不写 remember:一次没回复不该永久封死该工具
|
|
115
|
+
log("[approve] 审批超时 #", seq);
|
|
116
|
+
return { block: true, reason: `QQ 审批超时(#${seq}):${summary}` };
|
|
117
|
+
}
|
|
118
|
+
// block(用户显式拒绝)
|
|
108
119
|
if (cfg.approve.remember) state.approver.remember(toolName, "block");
|
|
109
120
|
log("[approve] 用户拒绝 #", seq);
|
|
110
121
|
return { block: true, reason: `QQ 用户拒绝了 ${summary}` };
|
|
@@ -118,6 +129,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
118
129
|
return;
|
|
119
130
|
}
|
|
120
131
|
state.cfg = cfg;
|
|
132
|
+
// 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
|
|
133
|
+
state.approver.reset();
|
|
121
134
|
log("[setup] 配置已加载 sandbox=", cfg.sandbox);
|
|
122
135
|
|
|
123
136
|
const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
|
|
@@ -195,17 +208,52 @@ async function handleMessage(
|
|
|
195
208
|
if (parsed) {
|
|
196
209
|
if (parsed.decision === "terminate") {
|
|
197
210
|
const n = state.approver.terminateAll();
|
|
198
|
-
|
|
211
|
+
state.approver.setAutoAllow(false);
|
|
212
|
+
await replyTo(api, state, msg.openid, `已终止 ${n} 个待审批请求,自动同意已关闭`);
|
|
199
213
|
return;
|
|
200
214
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
state
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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] 审批类文本但无待审批,按普通消息处理");
|
|
209
257
|
}
|
|
210
258
|
|
|
211
259
|
// 内置指令
|
|
@@ -220,13 +268,22 @@ async function handleMessage(
|
|
|
220
268
|
return;
|
|
221
269
|
}
|
|
222
270
|
if (cmd === "status") {
|
|
223
|
-
await replyTo(
|
|
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
|
+
);
|
|
224
281
|
return;
|
|
225
282
|
}
|
|
226
283
|
if (cmd === "new") {
|
|
227
284
|
state.replyBuffer.reset();
|
|
228
285
|
state.approver.reset();
|
|
229
|
-
await replyTo(api, state, msg.openid, "
|
|
286
|
+
await replyTo(api, state, msg.openid, "已重置(含审批记忆/自动同意),下一条消息开启新对话。");
|
|
230
287
|
return;
|
|
231
288
|
}
|
|
232
289
|
|
package/package.json
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zxh126/pi-qqbot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Bridge the Pi coding agent into QQ private chats, with remote tool-call approval",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"keywords": ["pi-package", "qq", "bot"],
|
|
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
|
+
},
|
|
8
16
|
"pi": {
|
|
9
17
|
"extensions": ["./index.ts"]
|
|
10
18
|
},
|
|
11
|
-
"files": ["*.ts", "qq", "util", "README.md", "LICENSE"],
|
|
19
|
+
"files": ["*.ts", "qq", "util", "README.md", "README.zh-CN.md", "LICENSE"],
|
|
12
20
|
"dependencies": {
|
|
13
21
|
"ws": "^8.18.0",
|
|
14
22
|
"proper-lockfile": "^4.1.2"
|