@zxh126/pi-qqbot 1.3.3 → 1.4.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 +27 -1
- package/config.ts +13 -0
- package/docs/README.zh-CN.md +26 -1
- package/index.ts +156 -16
- package/logger.ts +4 -1
- package/package.json +8 -4
- package/progress.ts +176 -0
- package/qq/auth.ts +1 -1
- package/qq/gateway.ts +2 -2
- package/queue.ts +4 -2
- package/redact.ts +136 -0
- package/slash.ts +2 -1
package/README.md
CHANGED
|
@@ -17,7 +17,9 @@ Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: se
|
|
|
17
17
|
| **Single-instance lock** | Only one pi process hosts the QQ gateway; the others stand by and take over automatically when the host exits. |
|
|
18
18
|
| **Cancel a running task** | Send `/cancel` (or 「取消」/「停止」) and the current run is aborted immediately — the QQ reply, the pending tool calls, the half-written answer and the pending queue are all cleaned up. |
|
|
19
19
|
| **Slash-command routing** | QQ messages starting with `/` are routed: pi extension commands, prompt templates and `/skill:name` are dispatched to pi; a set of pi built-ins (`/reload`, `/new`, `/compact`, `/session`, `/name`, `/model`, `/thinking`, `/quit`) is taken over; host-only commands are answered instead of being fed to the model. |
|
|
20
|
-
| **
|
|
20
|
+
| **Progress reports** | While a run is in progress the bot posts a short progress summary (turns, tool calls, the tool currently running, tokens) every `progress.intervalMs` (default **15 s**). Ask at any time with `/progress` (aliases `/p`, 「进度」). If a report cannot be delivered the automatic reports stay quiet until you ask again. |
|
|
21
|
+
| **Secret redaction** | Anything leaving the machine is scrubbed first — progress reports, approval prompts, queue previews and the log file: `Authorization` headers (incl. QQ bot tokens), `sk-…` / `ghp_…` / `npm_…` / `AKIA…` / JWTs / PEM blocks, `token=` / `password=` / `--api-key` values, URL `user:pass@`, and long high-entropy strings. |
|
|
22
|
+
| **Built-in commands** | `/status`, `/queue`, `/progress`, `/reset`, `/cancel`, `/help`. |
|
|
21
23
|
|
|
22
24
|
## Install
|
|
23
25
|
|
|
@@ -61,6 +63,8 @@ Then create `~/.pi/agent/pi-qqbot.json`:
|
|
|
61
63
|
| `approve.tools` | `["bash","powershell","write","edit"]` | Tools that require approval; tools annotated `destructiveHint` are always gated too |
|
|
62
64
|
| `approve.remember` | `true` | Remember the decision per tool inside the session (no more prompts for that tool) |
|
|
63
65
|
| `commands` | see below | Slash-command routing gates: `passthrough`, `allowReload`, `allowQuit`, `allowSession` |
|
|
66
|
+
| `progress.enabled` | `true` | Post periodic progress reports while a task is running |
|
|
67
|
+
| `progress.intervalMs` | `15000` | Interval of those reports (env override: `PI_QQBOT_PROGRESS_MS`) |
|
|
64
68
|
| `queue.max` | `20` | How many messages may wait while a task runs; `0` disables queueing (busy messages are refused as before) |
|
|
65
69
|
| `queue.ack` | `true` | Reply to QQ when a queued [message](#queueing-while-busy) starts (the "queued" ack is always sent) |
|
|
66
70
|
|
|
@@ -75,6 +79,7 @@ Start pi, then message the bot in QQ; pi handles it and replies.
|
|
|
75
79
|
| `/reset` | Clear the reply buffer, approval memory and session-wide auto-approve (**does not** switch sessions — send `/new` for that) |
|
|
76
80
|
| `/status` | Show busy/idle state, queue length, auto-approve state and pending approval count |
|
|
77
81
|
| `/queue` | Show the queue (aliases: `/q`); `/queue clear` empties it, `/queue drop <id>` removes one entry |
|
|
82
|
+
| `/progress` | **Show the current progress summary immediately** and resume automatic progress reports (aliases: `/p`, 「进度」, 「进展」; plain-text questions like 「到哪了」「怎么样了」 work while busy) |
|
|
78
83
|
| `/cancel` | **Interrupt the task that is running right now** and empty the queue (aliases: `/stop`, `/abort`, 「取消」, 「停止」, 「中止」, 「终止」, 「算了」) |
|
|
79
84
|
| `/help` | Show help |
|
|
80
85
|
|
|
@@ -176,6 +181,27 @@ Semantics:
|
|
|
176
181
|
- `/cancel` empties the queue as well, because "cancel" means "stop doing all of this". Use `/queue drop <id>`
|
|
177
182
|
to remove a single entry instead.
|
|
178
183
|
|
|
184
|
+
### Progress reports
|
|
185
|
+
|
|
186
|
+
While a run is in progress, a summary is pushed to QQ every `progress.intervalMs` (15 s by default):
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
[进度 02:15] · 第 3 轮 · 工具 6 次(bash 4 / read 2)· 失败 1
|
|
190
|
+
最近: bash "npm test" · 6.2s
|
|
191
|
+
正在: 分析测试输出… · 12.3k tok
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
- Built purely from local event data (`tool_execution_*`, `turn_end`, `message_end`, `message_update`)
|
|
195
|
+
— **no model call, no tokens spent**.
|
|
196
|
+
- Tool arguments are redacted and truncated, so a command carrying a token never reaches QQ in clear.
|
|
197
|
+
- **Quota-aware**: one QQ message buys only 4 passive replies, so a long run will eventually run out.
|
|
198
|
+
The first failed send stops automatic reports for that run (no retry spam); send `/progress`
|
|
199
|
+
(or 「进度」) and they resume.
|
|
200
|
+
- **The final answer is protected**: if it cannot be delivered (quota), it is cached and re-sent with a
|
|
201
|
+
`(上一条回复发送失败,补发如下)` prefix as soon as you send the next message.
|
|
202
|
+
- Timer is cleared on `agent_settled` / `/cancel` / session shutdown.
|
|
203
|
+
- Set `progress.enabled: false` to turn the whole thing off.
|
|
204
|
+
|
|
179
205
|
### Cancelling a run
|
|
180
206
|
|
|
181
207
|
Long tasks started from QQ (installs, builds, a runaway agent loop) can be interrupted without going
|
package/config.ts
CHANGED
|
@@ -17,6 +17,13 @@ export interface ApproveConfig {
|
|
|
17
17
|
remember: boolean;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export interface ProgressConfig {
|
|
21
|
+
/** 是否自动推送进度摘要(默认开) */
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
/** 推送间隔(毫秒,默认 15000;可用 PI_QQBOT_PROGRESS_MS 覆盖,便于测试) */
|
|
24
|
+
intervalMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
export interface QQBotConfig {
|
|
21
28
|
appId: string;
|
|
22
29
|
appSecret: string;
|
|
@@ -30,6 +37,8 @@ export interface QQBotConfig {
|
|
|
30
37
|
commands: CommandConfig;
|
|
31
38
|
/** 忙碌时的消息队列 */
|
|
32
39
|
queue: QueueConfig;
|
|
40
|
+
/** 任务进度摘要推送 */
|
|
41
|
+
progress: ProgressConfig;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export function loadConfig(): QQBotConfig | null {
|
|
@@ -64,5 +73,9 @@ export function loadConfig(): QQBotConfig | null {
|
|
|
64
73
|
max: file.queue?.max ?? DEFAULT_QUEUE_MAX,
|
|
65
74
|
ack: file.queue?.ack ?? true,
|
|
66
75
|
},
|
|
76
|
+
progress: {
|
|
77
|
+
enabled: file.progress?.enabled ?? true,
|
|
78
|
+
intervalMs: Number(process.env.PI_QQBOT_PROGRESS_MS ?? file.progress?.intervalMs ?? 15000),
|
|
79
|
+
},
|
|
67
80
|
};
|
|
68
81
|
}
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
| **单实例锁** | 只有一个 pi 进程作为宿主连接 QQ,其余待命,宿主退出后自动接管。 |
|
|
18
18
|
| **中断正在运行的任务** | 发 `/cancel`(或「取消」/「停止」),当前任务立刻中断 —— QQ 回复、待审批工具调用、写到一半的回答、排队中的消息一并清理。 |
|
|
19
19
|
| **斜杠命令路由** | 以 `/` 开头的 QQ 消息会先被路由:pi 的扩展命令、prompt 模板、`/skill:name` 交给 pi 派发;一部分 pi 内置命令(`/reload`、`/new`、`/compact`、`/session`、`/name`、`/model`、`/thinking`、`/quit`)由本扩展接管;只能在主机执行的内置命令直接回执,不会送给模型。 |
|
|
20
|
-
|
|
|
20
|
+
| **进度汇报** | 任务运行期间按 `progress.intervalMs`(默认 **15 秒**)推一条进度摘要(轮次、工具调用、正在跑的工具、token 用量)。随时发 `/progress`(别名 `/p`、「进度」)可立即获取;推送失败后自动推送静默,直到你再次问询。 |
|
|
21
|
+
| **敏感信息脱敏** | 离开本机的文本先脱敏——进度摘要、审批提示、队列预览、日志文件:`Authorization` 头(含 QQ bot token)、`sk-…`/`ghp_…`/`npm_…`/`AKIA…`/JWT/PEM、`token=`/`password=`/`--api-key` 的值、URL 里的 `user:pass@`,以及超长高熵串。 |
|
|
22
|
+
| **内置指令** | `/status`、`/queue`、`/progress`、`/reset`、`/cancel`、`/help`。 |
|
|
21
23
|
|
|
22
24
|
## 安装
|
|
23
25
|
|
|
@@ -63,6 +65,8 @@ pi install ./pi-qqbot
|
|
|
63
65
|
| `commands` | 见下 | 斜杠命令路由开关:`passthrough`、`allowReload`、`allowQuit`、`allowSession` |
|
|
64
66
|
| `queue.max` | `20` | 任务运行期间最多排队多少条;`0` = 关闭排队(忙碌时直接拒绍,回到旧行为) |
|
|
65
67
|
| `queue.ack` | `true` | 排队消息开始执行时是否回执 QQ(入队回执总是会发) |
|
|
68
|
+
| `progress.enabled` | `true` | 任务运行期间是否定期推送进度摘要 |
|
|
69
|
+
| `progress.intervalMs` | `15000` | 推送间隔(可用环境变量 `PI_QQBOT_PROGRESS_MS` 覆盖,便于测试) |
|
|
66
70
|
|
|
67
71
|
也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件里的凭据。
|
|
68
72
|
|
|
@@ -75,6 +79,7 @@ pi install ./pi-qqbot
|
|
|
75
79
|
| `/reset` | 重置:清空回复缓冲、审批记忆与自动同意开关(**不换会话**,换会话发 `/new`) |
|
|
76
80
|
| `/status` | 查看忙闲状态、队列长度、自动同意状态、待审批数量 |
|
|
77
81
|
| `/queue` | 查看队列(别名 `/q`);`/queue clear` 清空,`/queue drop <编号>` 删一条 |
|
|
82
|
+
| `/progress` | **立即获取当前进度摘要**并恢复自动推送(别名 `/p`、「进度」「进展」;忙碌时「到哪了」「怎么样了」这类问法同样生效) |
|
|
78
83
|
| `/cancel` | **中断当前正在运行的任务**并清空队列(别名:`/stop`、`/abort`、「取消」、「停止」、「中止」、「终止」、「算了」) |
|
|
79
84
|
| `/help` | 帮助 |
|
|
80
85
|
|
|
@@ -167,6 +172,26 @@ pi 的会话一次只跑一轮 agent run,所以串行调度由 bot 自己做
|
|
|
167
172
|
都立即生效。
|
|
168
173
|
- `/cancel` 会连队列一起清空(“取消”就是“都别做了”);只想删一条请用 `/queue drop <编号>`。
|
|
169
174
|
|
|
175
|
+
### 任务进度汇报
|
|
176
|
+
|
|
177
|
+
任务运行期间,每 `progress.intervalMs`(默认 15 秒)推一条摘要到 QQ:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
[进度 02:15] · 第 3 轮 · 工具 6 次(bash 4 / read 2)· 失败 1
|
|
181
|
+
最近: bash "npm test" · 6.2s
|
|
182
|
+
正在: 分析测试输出… · 12.3k tok
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- 全部由本地事件拼出来(`tool_execution_*`、`turn_end`、`message_end`、`message_update`),
|
|
186
|
+
**不调用模型、不花 token**。
|
|
187
|
+
- 工具参数先脱敏再截断,带凭据的命令不会明文出现在 QQ 里。
|
|
188
|
+
- **配额感知**:一条用户消息只有 4 次被动回复额度,长任务迟早会用完。第一次发送失败就停掉本轮
|
|
189
|
+
的自动推送(不重试、不刷屏);你发 `/progress`(或「进度」)即恢复。
|
|
190
|
+
- **保护最终答案**:答案因配额发不出去时会被缓存,等你发下一条消息时以
|
|
191
|
+
`(上一条回复发送失败,补发如下)` 前缀补发。
|
|
192
|
+
- `agent_settled` / `/cancel` / 会话关闭时定时器都会清掉。
|
|
193
|
+
- 想彻底关掉:`progress.enabled: false`。
|
|
194
|
+
|
|
170
195
|
### 取消正在运行的任务
|
|
171
196
|
|
|
172
197
|
从 QQ 发起的耗时任务(装依赖、编译、agent 跑飞了)不必回到机器上处理:
|
package/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { createGatewayUrlFetcher, QQGateway, QQMessage } from "./qq/gateway";
|
|
|
18
18
|
import { Approver } from "./approver";
|
|
19
19
|
import { parsePickIndex, pickerExpired, routeSlash, runBridge, type PickerState } from "./slash";
|
|
20
20
|
import { agoLabel, MessageQueue, previewText, type QueuedJob } from "./queue";
|
|
21
|
+
import { ProgressTracker } from "./progress";
|
|
22
|
+
import { redactSecrets } from "./redact";
|
|
21
23
|
import { ReplyBuffer } from "./stream-buffer";
|
|
22
24
|
import { splitText } from "./util/chunk";
|
|
23
25
|
|
|
@@ -52,6 +54,14 @@ interface State {
|
|
|
52
54
|
runStarted: boolean;
|
|
53
55
|
/** /cancel 之后等 agent_settled 的兜底定时器 */
|
|
54
56
|
cancelTimer: ReturnType<typeof setTimeout> | null;
|
|
57
|
+
/** 任务进度摘要 */
|
|
58
|
+
progress: ProgressTracker;
|
|
59
|
+
/** 自动推送进度的定时器 */
|
|
60
|
+
progressTimer: ReturnType<typeof setInterval> | null;
|
|
61
|
+
/** 推送失败后暂停自动推送,直到用户主动询问 */
|
|
62
|
+
progressPaused: boolean;
|
|
63
|
+
/** 最终答案发送失败时缓存,下一条消息时补发 */
|
|
64
|
+
undelivered: { openid: string; text: string } | null;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
/** 扩展运行所需的状态(导出只为测试用) */
|
|
@@ -77,6 +87,10 @@ export function createInitialState(): State {
|
|
|
77
87
|
dispatching: false,
|
|
78
88
|
runStarted: false,
|
|
79
89
|
cancelTimer: null,
|
|
90
|
+
progress: new ProgressTracker(),
|
|
91
|
+
progressTimer: null,
|
|
92
|
+
progressPaused: false,
|
|
93
|
+
undelivered: null,
|
|
80
94
|
};
|
|
81
95
|
}
|
|
82
96
|
|
|
@@ -93,19 +107,46 @@ export default function (
|
|
|
93
107
|
state.cancelled = false;
|
|
94
108
|
state.runStarted = true;
|
|
95
109
|
clearCancelTimer(state);
|
|
110
|
+
state.progress.start();
|
|
111
|
+
state.progressPaused = false;
|
|
112
|
+
startProgressTimer(pi, state);
|
|
96
113
|
});
|
|
97
114
|
|
|
98
115
|
// ---- 事件:累积 assistant 文本 ----
|
|
99
116
|
pi.on("message_update", (event) => {
|
|
100
117
|
const ev = event.assistantMessageEvent as any;
|
|
101
118
|
if (ev?.type === "text_delta") {
|
|
102
|
-
|
|
119
|
+
const delta = ev.delta ?? ev.text ?? "";
|
|
120
|
+
state.replyBuffer.append(delta);
|
|
121
|
+
state.progress.appendText(delta);
|
|
103
122
|
}
|
|
104
123
|
});
|
|
105
124
|
|
|
125
|
+
// ---- 事件:进度统计(工具/轮次/用量)----
|
|
126
|
+
pi.on("tool_execution_start", (event) => {
|
|
127
|
+
const ev = event as any;
|
|
128
|
+
state.progress.toolStart(ev.toolCallId, ev.toolName, ev.args);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
pi.on("tool_execution_end", (event) => {
|
|
132
|
+
const ev = event as any;
|
|
133
|
+
state.progress.toolEnd(ev.toolCallId, ev.toolName, ev.isError === true, ev.durationMs);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
pi.on("turn_end", () => {
|
|
137
|
+
state.progress.turn();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
pi.on("message_end", (event) => {
|
|
141
|
+
const message = (event as any).message;
|
|
142
|
+
if (message?.role === "assistant") state.progress.addUsage(message.usage);
|
|
143
|
+
});
|
|
144
|
+
|
|
106
145
|
// ---- 事件:run 结束,发回复,然后接着跑队列里的下一条 ----
|
|
107
146
|
pi.on("agent_settled", async () => {
|
|
108
147
|
clearCancelTimer(state);
|
|
148
|
+
stopProgressTimer(state);
|
|
149
|
+
state.progress.stop();
|
|
109
150
|
const cancelled = state.cancelled;
|
|
110
151
|
const text = state.replyBuffer.take();
|
|
111
152
|
const openid = state.currentOpenid;
|
|
@@ -118,8 +159,17 @@ export default function (
|
|
|
118
159
|
log("[reply] 本轮已被取消,丢弃半截回复(长度", text.length, ")");
|
|
119
160
|
} else if (text && openid && state.api) {
|
|
120
161
|
log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
|
|
162
|
+
let delivered = true;
|
|
121
163
|
for (const chunk of splitText(text)) {
|
|
122
|
-
await state.api.sendC2C(openid, chunk).catch((e) =>
|
|
164
|
+
await state.api.sendC2C(openid, chunk).catch((e) => {
|
|
165
|
+
delivered = false;
|
|
166
|
+
log("[reply] 发送失败:", String(e));
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (!delivered) {
|
|
170
|
+
// 配额耗尽等情况:缓存下来,用户下次发消息时补发
|
|
171
|
+
state.undelivered = { openid, text };
|
|
172
|
+
log("[reply] 最终答案发送失败,已缓存待补发(openid=", openid, "len=", text.length, ")");
|
|
123
173
|
}
|
|
124
174
|
}
|
|
125
175
|
// 本轮结束:取队列里的下一条继续执行(不 await,避免卡住 settled 的派发阶段)
|
|
@@ -237,6 +287,7 @@ export default function (
|
|
|
237
287
|
pi.on("session_shutdown", async () => {
|
|
238
288
|
log("[shutdown] 清理资源");
|
|
239
289
|
clearCancelTimer(state);
|
|
290
|
+
stopProgressTimer(state);
|
|
240
291
|
const dropped = state.queue.clear();
|
|
241
292
|
if (dropped.length > 0) log("[shutdown] 丢弃队列任务", dropped.length, "条");
|
|
242
293
|
if (state.stopRetry) state.stopRetry();
|
|
@@ -476,6 +527,8 @@ async function handleCancel(
|
|
|
476
527
|
return;
|
|
477
528
|
}
|
|
478
529
|
|
|
530
|
+
stopProgressTimer(state);
|
|
531
|
+
state.progress.stop();
|
|
479
532
|
state.cancelled = true;
|
|
480
533
|
state.replyBuffer.reset();
|
|
481
534
|
state.currentOpenid = null; // 被中止的 run 不再往 QQ 发半截回复
|
|
@@ -516,6 +569,18 @@ export async function handleMessage(
|
|
|
516
569
|
}
|
|
517
570
|
state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
|
|
518
571
|
|
|
572
|
+
// 上一条最终答案没发出去(配额耗尽):先补发
|
|
573
|
+
if (state.undelivered && state.undelivered.openid === msg.openid) {
|
|
574
|
+
const pending = state.undelivered;
|
|
575
|
+
state.undelivered = null;
|
|
576
|
+
log("[reply] 补发上一条未送达的回复,长度", pending.text.length);
|
|
577
|
+
const ok = await replyTo(api, state, msg.openid, `(上一条回复发送失败,补发如下)\n\n${pending.text}`);
|
|
578
|
+
if (!ok) {
|
|
579
|
+
state.undelivered = pending;
|
|
580
|
+
log("[reply] 补发仍然失败,继续保留");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
519
584
|
// 待选择的编号回复(/tree、/fork、/resume 之后)
|
|
520
585
|
if (state.picker) {
|
|
521
586
|
if (pickerExpired(state.picker)) {
|
|
@@ -667,16 +732,29 @@ export async function handleMessage(
|
|
|
667
732
|
return;
|
|
668
733
|
}
|
|
669
734
|
if (cmd?.name === "status") {
|
|
735
|
+
const lines = [
|
|
736
|
+
`状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
|
|
737
|
+
`队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
|
|
738
|
+
`自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
|
|
739
|
+
`待审批: ${state.approver.pendingCount}`,
|
|
740
|
+
];
|
|
741
|
+
if (state.busy) {
|
|
742
|
+
const snapshot = state.progress.summary();
|
|
743
|
+
if (snapshot) lines.push("", snapshot);
|
|
744
|
+
// 用户主动问询:恢复自动推送
|
|
745
|
+
state.progressPaused = false;
|
|
746
|
+
}
|
|
747
|
+
await replyTo(api, state, msg.openid, lines.join("\n"));
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (cmd?.name === "progress") {
|
|
751
|
+
const snapshot = state.progress.summary();
|
|
752
|
+
state.progressPaused = false;
|
|
670
753
|
await replyTo(
|
|
671
754
|
api,
|
|
672
755
|
state,
|
|
673
756
|
msg.openid,
|
|
674
|
-
|
|
675
|
-
`状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
|
|
676
|
-
`队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
|
|
677
|
-
`自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
|
|
678
|
-
`待审批: ${state.approver.pendingCount}`,
|
|
679
|
-
].join("\n"),
|
|
757
|
+
snapshot || (state.busy ? "当前任务刚启动,还没有可汇报的进度。" : "当前没有正在运行的任务。"),
|
|
680
758
|
);
|
|
681
759
|
return;
|
|
682
760
|
}
|
|
@@ -693,6 +771,13 @@ export async function handleMessage(
|
|
|
693
771
|
|
|
694
772
|
// 忙检查:单会话串行处理——忙碌时入队,当前任务结束后自动继续
|
|
695
773
|
if (isBusy(state)) {
|
|
774
|
+
// 忙碌时问「进度」不该排队,直接回当前进度并恢复自动推送
|
|
775
|
+
if (RE_PROGRESS_QUERY.test(msg.content.trim())) {
|
|
776
|
+
const snapshot = state.progress.summary();
|
|
777
|
+
state.progressPaused = false;
|
|
778
|
+
await replyTo(api, state, msg.openid, snapshot || "任务刚启动,还没有可汇报的进度。");
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
696
781
|
await enqueueJob(api, state, msg.openid, msg.content);
|
|
697
782
|
return;
|
|
698
783
|
}
|
|
@@ -713,27 +798,80 @@ async function replyTo(
|
|
|
713
798
|
openid: string,
|
|
714
799
|
text: string,
|
|
715
800
|
format: "text" | "markdown" = "text",
|
|
716
|
-
) {
|
|
801
|
+
): Promise<boolean> {
|
|
717
802
|
const ref = state.passiveRefs.get(openid);
|
|
803
|
+
let delivered = true;
|
|
718
804
|
for (const chunk of splitText(text)) {
|
|
719
805
|
const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
|
|
720
806
|
try {
|
|
721
807
|
await api.sendC2C(openid, chunk, usePassive ? { msgId: ref.msgId, format } : { format });
|
|
722
808
|
log("[reply] 已发送 (passive=", usePassive, "):", chunk.slice(0, 50));
|
|
723
809
|
} catch (e) {
|
|
810
|
+
delivered = false;
|
|
724
811
|
log("[reply] 发送失败:", String(e));
|
|
725
812
|
}
|
|
726
813
|
}
|
|
814
|
+
return delivered;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** 用户问进度的表达(忙碌时用来区分「排队」和「问进度」) */
|
|
818
|
+
// 忙碌时判断「这是在问进度」:只认句首的明确表达,避免把「帮我加个进度条」这类任务误当问询
|
|
819
|
+
const RE_PROGRESS_QUERY = new RegExp(
|
|
820
|
+
"^(?:" +
|
|
821
|
+
"\\/(?:progress|p|status)" +
|
|
822
|
+
"|(?:现在|当前|目前)?\\s*(?:的)?\\s*(?:progress|进度|进展)" +
|
|
823
|
+
"|跑到哪|怎么样了|到哪了|还在跑吗|好慢" +
|
|
824
|
+
")",
|
|
825
|
+
"i",
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
function startProgressTimer(pi: ExtensionAPI, state: State) {
|
|
829
|
+
stopProgressTimer(state);
|
|
830
|
+
const cfg = state.cfg?.progress;
|
|
831
|
+
if (!cfg?.enabled) return;
|
|
832
|
+
const interval = Number.isFinite(cfg.intervalMs) && cfg.intervalMs >= 50 ? cfg.intervalMs : 15000;
|
|
833
|
+
const timer = setInterval(() => {
|
|
834
|
+
void pushProgress(state, "tick");
|
|
835
|
+
}, interval);
|
|
836
|
+
(timer as any).unref?.();
|
|
837
|
+
state.progressTimer = timer;
|
|
838
|
+
log("[progress] 自动进度推送已启动,间隔", interval, "ms");
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function stopProgressTimer(state: State) {
|
|
842
|
+
if (state.progressTimer) {
|
|
843
|
+
clearInterval(state.progressTimer);
|
|
844
|
+
state.progressTimer = null;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** 推一条进度摘要;失败就暂停自动推送(用户问一次即恢复) */
|
|
849
|
+
async function pushProgress(state: State, reason: "tick" | "ask"): Promise<boolean> {
|
|
850
|
+
if (reason === "tick" && state.progressPaused) return false;
|
|
851
|
+
const api = state.api;
|
|
852
|
+
const openid = state.currentOpenid ?? state.lastOpenid;
|
|
853
|
+
if (!api || !openid || !state.busy || !state.progress.running) return false;
|
|
854
|
+
const text = state.progress.summary();
|
|
855
|
+
if (!text) return false;
|
|
856
|
+
const ok = await replyTo(api, state, openid, text);
|
|
857
|
+
if (!ok) {
|
|
858
|
+
state.progressPaused = true;
|
|
859
|
+
log("[progress] 推送失败(多半是配额),暂停自动进度推送,直到用户询问");
|
|
860
|
+
}
|
|
861
|
+
return ok;
|
|
727
862
|
}
|
|
728
863
|
|
|
729
|
-
function parseCommand(text: string): { name: "reset" | "status" | "help" | "queue"; args: string } | null {
|
|
864
|
+
function parseCommand(text: string): { name: "reset" | "status" | "help" | "queue" | "progress"; args: string } | null {
|
|
730
865
|
// 注意:/new 现在属于 pi 的内置命令(由 slash 路由接管,真的开新会话),
|
|
731
866
|
// 本地的「清缓冲/清审批记忆」改名为 /reset。
|
|
732
|
-
|
|
867
|
+
// 斜杠形式:全部内置指令;不带斜杠时只接受明确表达「进度」的词,避免把聊天里的 "p" / "status" 误判成命令
|
|
868
|
+
const m = /^\/(reset|status|help|queue|q|progress|p)(?:\s+([\s\S]*))?$/i.exec(text.trim())
|
|
869
|
+
?? /^(progress|进度|进展)(?:\s+([\s\S]*))?$/i.exec(text.trim());
|
|
733
870
|
if (!m) return null;
|
|
734
871
|
const raw = m[1].toLowerCase();
|
|
872
|
+
const name = raw === "q" ? "queue" : raw === "p" || raw === "进度" || raw === "进展" ? "progress" : raw;
|
|
735
873
|
return {
|
|
736
|
-
name:
|
|
874
|
+
name: name as "reset" | "status" | "help" | "queue" | "progress",
|
|
737
875
|
args: (m[2] ?? "").trim(),
|
|
738
876
|
};
|
|
739
877
|
}
|
|
@@ -778,17 +916,19 @@ async function handleQueueCommand(api: QQApi, state: State, openid: string, args
|
|
|
778
916
|
/** 从 tool_call 事件提取可读摘要,让用户在 QQ 里看清要执行什么 */
|
|
779
917
|
function summarizeTool(event: any): string {
|
|
780
918
|
const input = event.input ?? {};
|
|
919
|
+
// 审批提示会发到 QQ:先脱敏再截断
|
|
920
|
+
const clip = (value: unknown) => redactSecrets(String(value ?? "")).slice(0, 200);
|
|
781
921
|
switch (event.toolName) {
|
|
782
922
|
case "bash":
|
|
783
923
|
case "powershell":
|
|
784
|
-
return `${event.toolName}: ${
|
|
924
|
+
return `${event.toolName}: ${clip(input.command)}`;
|
|
785
925
|
case "write":
|
|
786
926
|
case "edit":
|
|
787
927
|
case "read":
|
|
788
|
-
return `${event.toolName}: ${
|
|
928
|
+
return `${event.toolName}: ${clip(input.path ?? input.file ?? input.filePath)}`;
|
|
789
929
|
case "grep":
|
|
790
|
-
return `${event.toolName}: ${
|
|
930
|
+
return `${event.toolName}: ${clip(input.pattern)}`;
|
|
791
931
|
default:
|
|
792
|
-
return `${event.toolName}: ${JSON.stringify(input)
|
|
932
|
+
return `${event.toolName}: ${clip(JSON.stringify(input))}`;
|
|
793
933
|
}
|
|
794
934
|
}
|
package/logger.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { redactSecrets } from "./redact";
|
|
4
5
|
|
|
5
6
|
const LOG_PATH = path.join(os.homedir(), ".pi", "agent", "pi-qqbot.log");
|
|
6
7
|
|
|
@@ -25,7 +26,9 @@ export function timestamp(date = new Date()): string {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export function log(...args: unknown[]) {
|
|
28
|
-
const
|
|
29
|
+
const body = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
|
|
30
|
+
// 日志会留在磁盘上:落盘前统一脱敏(Authorization、token=、URL userinfo、私钥块等)
|
|
31
|
+
const line = `[${timestamp()}] ${redactSecrets(body)}\n`;
|
|
29
32
|
try {
|
|
30
33
|
fs.appendFileSync(LOG_PATH, line, "utf8");
|
|
31
34
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zxh126/pi-qqbot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Bridge the Pi coding agent into QQ private chats, with remote tool-call approval",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
]
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
|
-
"test": "node test/queue.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs"
|
|
30
|
+
"test": "node test/queue.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs"
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"*.ts",
|
|
@@ -38,10 +38,14 @@
|
|
|
38
38
|
"LICENSE"
|
|
39
39
|
],
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"
|
|
42
|
-
"
|
|
41
|
+
"proper-lockfile": "^4.1.2",
|
|
42
|
+
"ws": "^8.18.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@earendil-works/pi-coding-agent": "*"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
49
|
+
"@types/ws": "^8.18.2"
|
|
46
50
|
}
|
|
47
51
|
}
|
package/progress.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务进度摘要:把 run 期间的事件(轮次、工具调用、流式文本、token)压成一条
|
|
3
|
+
* 适合发到 QQ 的短消息。纯本地字符串拼接,不产生任何模型调用。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { redactSecrets, shortRedacted } from "./redact";
|
|
7
|
+
|
|
8
|
+
export interface ProgressSettings {
|
|
9
|
+
/** 是否自动推送进度 */
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
/** 推送间隔(毫秒) */
|
|
12
|
+
intervalMs: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MAX_PROGRESS_CHARS = 320;
|
|
16
|
+
const TOOL_PREVIEW_CHARS = 60;
|
|
17
|
+
const TAIL_CHARS = 120;
|
|
18
|
+
|
|
19
|
+
/** 把工具参数压成一句人能看懂的话(已脱敏、已截断) */
|
|
20
|
+
export function toolPreview(toolName: string, args: any): string {
|
|
21
|
+
const input = args ?? {};
|
|
22
|
+
switch (toolName) {
|
|
23
|
+
case "bash":
|
|
24
|
+
case "powershell":
|
|
25
|
+
return shortRedacted(input.command ?? "");
|
|
26
|
+
case "write":
|
|
27
|
+
case "edit":
|
|
28
|
+
case "read":
|
|
29
|
+
return shortRedacted(input.path ?? input.file ?? input.filePath ?? "");
|
|
30
|
+
case "grep":
|
|
31
|
+
case "bgrep":
|
|
32
|
+
return shortRedacted(input.pattern ?? "");
|
|
33
|
+
case "glob":
|
|
34
|
+
case "find":
|
|
35
|
+
return shortRedacted(input.pattern ?? input.path ?? "");
|
|
36
|
+
default:
|
|
37
|
+
try {
|
|
38
|
+
return shortRedacted(JSON.stringify(input));
|
|
39
|
+
} catch {
|
|
40
|
+
return shortRedacted(String(input));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fmtDuration = (ms: number): string => {
|
|
46
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
47
|
+
const h = Math.floor(total / 3600);
|
|
48
|
+
const m = Math.floor((total % 3600) / 60);
|
|
49
|
+
const s = total % 60;
|
|
50
|
+
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
51
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const fmtTokens = (n: number): string =>
|
|
55
|
+
n >= 1_000_000 ? `${(n / 1_000_000).toFixed(2)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n);
|
|
56
|
+
|
|
57
|
+
interface OpenTool {
|
|
58
|
+
name: string;
|
|
59
|
+
preview: string;
|
|
60
|
+
startedAt: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class ProgressTracker {
|
|
64
|
+
running = false;
|
|
65
|
+
private startedAt = 0;
|
|
66
|
+
private turns = 0;
|
|
67
|
+
private counts = new Map<string, number>();
|
|
68
|
+
private failures = 0;
|
|
69
|
+
private tokens = 0;
|
|
70
|
+
private tail = "";
|
|
71
|
+
private open = new Map<string, OpenTool>();
|
|
72
|
+
private last: { name: string; preview: string; ms?: number; ok?: boolean } | null = null;
|
|
73
|
+
|
|
74
|
+
start(now = Date.now()): void {
|
|
75
|
+
this.running = true;
|
|
76
|
+
this.startedAt = now;
|
|
77
|
+
this.turns = 0;
|
|
78
|
+
this.counts.clear();
|
|
79
|
+
this.failures = 0;
|
|
80
|
+
this.tokens = 0;
|
|
81
|
+
this.tail = "";
|
|
82
|
+
this.open.clear();
|
|
83
|
+
this.last = null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
stop(): void {
|
|
87
|
+
this.running = false;
|
|
88
|
+
this.open.clear();
|
|
89
|
+
this.tail = "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
toolStart(callId: string, toolName: string, args: any, now = Date.now()): void {
|
|
93
|
+
if (!this.running) return;
|
|
94
|
+
const name = toolName || "tool";
|
|
95
|
+
this.open.set(callId, { name, preview: toolPreview(name, args), startedAt: now });
|
|
96
|
+
this.counts.set(name, (this.counts.get(name) ?? 0) + 1);
|
|
97
|
+
this.tail = "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
toolEnd(callId: string, toolName: string, isError: boolean, durationMs?: number, now = Date.now()): void {
|
|
101
|
+
if (!this.running) return;
|
|
102
|
+
const open = this.open.get(callId);
|
|
103
|
+
this.open.delete(callId);
|
|
104
|
+
if (isError) this.failures += 1;
|
|
105
|
+
this.last = {
|
|
106
|
+
name: open?.name ?? toolName ?? "tool",
|
|
107
|
+
preview: open?.preview ?? "",
|
|
108
|
+
ms: typeof durationMs === "number" ? durationMs : open ? now - open.startedAt : undefined,
|
|
109
|
+
ok: !isError,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
turn(): void {
|
|
114
|
+
if (this.running) this.turns += 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
addUsage(usage: any): void {
|
|
118
|
+
if (!this.running) return;
|
|
119
|
+
const total = Number(usage?.totalTokens ?? 0);
|
|
120
|
+
if (Number.isFinite(total) && total > 0) this.tokens += total;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
appendText(delta: string): void {
|
|
124
|
+
if (!this.running || !delta) return;
|
|
125
|
+
this.tail = `${this.tail}${delta}`.slice(-TAIL_CHARS * 2);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 当前正在执行的工具(如果卡在某个长命令上,这比「正在写」有用) */
|
|
129
|
+
private runningTool(): OpenTool | null {
|
|
130
|
+
let newest: OpenTool | null = null;
|
|
131
|
+
for (const tool of this.open.values()) {
|
|
132
|
+
if (!newest || tool.startedAt > newest.startedAt) newest = tool;
|
|
133
|
+
}
|
|
134
|
+
return newest;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 一条可直接发到 QQ 的摘要;没有可说的内容时返回空串 */
|
|
138
|
+
summary(now = Date.now()): string {
|
|
139
|
+
if (!this.running) return "";
|
|
140
|
+
const head: string[] = [`[进度 ${fmtDuration(now - this.startedAt)}]`];
|
|
141
|
+
if (this.turns > 0) head.push(`第 ${this.turns} 轮`);
|
|
142
|
+
|
|
143
|
+
const totalCalls = [...this.counts.values()].reduce((sum, n) => sum + n, 0);
|
|
144
|
+
if (totalCalls > 0) {
|
|
145
|
+
const top = [...this.counts.entries()]
|
|
146
|
+
.sort((a, b) => b[1] - a[1])
|
|
147
|
+
.slice(0, 3)
|
|
148
|
+
.map(([name, n]) => `${name} ${n}`)
|
|
149
|
+
.join(" / ");
|
|
150
|
+
head.push(`工具 ${totalCalls} 次(${top})`);
|
|
151
|
+
}
|
|
152
|
+
if (this.failures > 0) head.push(`失败 ${this.failures}`);
|
|
153
|
+
|
|
154
|
+
const lines: string[] = [head.join(" · ")];
|
|
155
|
+
|
|
156
|
+
if (this.last) {
|
|
157
|
+
const timing = this.last.ms !== undefined ? ` · ${(this.last.ms / 1000).toFixed(1)}s` : "";
|
|
158
|
+
const status = this.last.ok === false ? " · 失败" : "";
|
|
159
|
+
const preview = this.last.preview ? ` "${this.last.preview}"` : "";
|
|
160
|
+
lines.push(`最近: ${this.last.name}${preview}${timing}${status}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const active = this.runningTool();
|
|
164
|
+
if (active) {
|
|
165
|
+
lines.push(`正在: ${active.name}${active.preview ? ` "${active.preview}"` : ""}(已 ${(Math.max(0, now - active.startedAt) / 1000).toFixed(0)}s)`);
|
|
166
|
+
} else {
|
|
167
|
+
const tail = shortRedacted(this.tail.slice(-TAIL_CHARS), TAIL_CHARS);
|
|
168
|
+
if (tail) lines.push(`正在: ${tail}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (this.tokens > 0) lines.push(`已用 ${fmtTokens(this.tokens)} tok`);
|
|
172
|
+
|
|
173
|
+
const text = redactSecrets(lines.join("\n"));
|
|
174
|
+
return text.length > MAX_PROGRESS_CHARS ? `${text.slice(0, MAX_PROGRESS_CHARS)}…` : text;
|
|
175
|
+
}
|
|
176
|
+
}
|
package/qq/auth.ts
CHANGED
|
@@ -20,7 +20,7 @@ export class AuthManager {
|
|
|
20
20
|
});
|
|
21
21
|
if (!res.ok) throw new Error(`getAppAccessToken failed: HTTP ${res.status}`);
|
|
22
22
|
const data = (await res.json()) as any;
|
|
23
|
-
this.token = data.access_token;
|
|
23
|
+
this.token = String(data.access_token ?? "");
|
|
24
24
|
this.expireAt = Date.now() + Number(data.expires_in) * 1000;
|
|
25
25
|
return this.token;
|
|
26
26
|
}
|
package/qq/gateway.ts
CHANGED
|
@@ -105,7 +105,7 @@ export class QQGateway {
|
|
|
105
105
|
const ws = new WebSocket(url);
|
|
106
106
|
this.ws = ws;
|
|
107
107
|
ws.on("open", () => log("[gateway] WebSocket 已连接,等待 HELLO..."));
|
|
108
|
-
ws.on("message", (raw) => {
|
|
108
|
+
ws.on("message", (raw: any) => {
|
|
109
109
|
let pkt: any;
|
|
110
110
|
try {
|
|
111
111
|
pkt = JSON.parse(raw.toString());
|
|
@@ -115,7 +115,7 @@ export class QQGateway {
|
|
|
115
115
|
}
|
|
116
116
|
this.handlePacket(pkt);
|
|
117
117
|
});
|
|
118
|
-
ws.on("close", (code) => {
|
|
118
|
+
ws.on("close", (code: number) => {
|
|
119
119
|
log("[gateway] WebSocket 关闭 code=", code);
|
|
120
120
|
this.opts.disconnected();
|
|
121
121
|
this.scheduleReconnect();
|
package/queue.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { redactSecrets } from "./redact";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* QQ 侧任务队列。
|
|
3
5
|
*
|
|
@@ -29,12 +31,12 @@ export type PushResult =
|
|
|
29
31
|
| { ok: false; reason: "full" };
|
|
30
32
|
|
|
31
33
|
export const DEFAULT_QUEUE_MAX = 20;
|
|
32
|
-
|
|
33
34
|
/** 单条预览的最大字符数 */
|
|
34
35
|
const PREVIEW_MAX = 48;
|
|
35
36
|
|
|
36
37
|
export function previewText(text: string, max = PREVIEW_MAX): string {
|
|
37
|
-
|
|
38
|
+
// 预览会回显到 QQ 和日志:先脱敏
|
|
39
|
+
const flat = redactSecrets(text).replace(/\s+/g, " ").trim();
|
|
38
40
|
return flat.length > max ? `${flat.slice(0, max)}…` : flat || "(空消息)";
|
|
39
41
|
}
|
|
40
42
|
|
package/redact.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 敏感信息脱敏。
|
|
3
|
+
*
|
|
4
|
+
* 所有要「离开本地」或「落盘」的文本都应该先过这里:
|
|
5
|
+
* - 发到 QQ 的消息(进度摘要、审批提示、队列预览)
|
|
6
|
+
* - pi-qqbot.log
|
|
7
|
+
*
|
|
8
|
+
* 原则:
|
|
9
|
+
* 1. 认得出的凭据一律替换成 `***`(保留键名,方便排错)
|
|
10
|
+
* 2. 不碰正常内容(路径、命令、中文句子、git sha、UUID)
|
|
11
|
+
* 3. 幂等:对已脱敏文本再跑一次不会再变形
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** PEM 私钥 / 证书块:整块替换 */
|
|
15
|
+
const PEM_BLOCK =
|
|
16
|
+
/-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|CERTIFICATE)-----[\s\S]*?-----END [A-Z0-9 ]*(?:PRIVATE KEY|CERTIFICATE)-----/g;
|
|
17
|
+
|
|
18
|
+
/** `Authorization: Bearer xxx` / `QQBot xxx` / `Bot xxx` / `Token xxx` */
|
|
19
|
+
const AUTH_HEADER =
|
|
20
|
+
/\b(authorization\s*[:=]\s*)(?:(?:bearer|basic|token|qqbot|bot|digest)\s+)?[^\s"',;]{6,}/gi;
|
|
21
|
+
|
|
22
|
+
/** URL 里的 user:password@host */
|
|
23
|
+
const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi;
|
|
24
|
+
|
|
25
|
+
/** 敏感的键名(键值对 / JSON / 查询参数 / --flag) */
|
|
26
|
+
const SENSITIVE_KEYS = [
|
|
27
|
+
'access[_-]?token',
|
|
28
|
+
'refresh[_-]?token',
|
|
29
|
+
'id[_-]?token',
|
|
30
|
+
'auth[_-]?token',
|
|
31
|
+
'api[_-]?key',
|
|
32
|
+
'apikey',
|
|
33
|
+
'x-api-key',
|
|
34
|
+
'client[_-]?id',
|
|
35
|
+
'client[_-]?secret',
|
|
36
|
+
'app[_-]?secret',
|
|
37
|
+
'appsecret',
|
|
38
|
+
'secret[_-]?key',
|
|
39
|
+
'private[_-]?key',
|
|
40
|
+
'session[_-]?id',
|
|
41
|
+
'sessionid',
|
|
42
|
+
'password',
|
|
43
|
+
'passwd',
|
|
44
|
+
'pwd',
|
|
45
|
+
'passphrase',
|
|
46
|
+
'credentials?',
|
|
47
|
+
'authorization',
|
|
48
|
+
'cookie',
|
|
49
|
+
'set-cookie',
|
|
50
|
+
'signature',
|
|
51
|
+
'sign',
|
|
52
|
+
'nonce',
|
|
53
|
+
'ticket',
|
|
54
|
+
'secret',
|
|
55
|
+
].join('|');
|
|
56
|
+
|
|
57
|
+
/** `key=value` / `"key": "value"` / `key: value`(值两侧的引号一并吃掉) */
|
|
58
|
+
const KEY_VALUE = new RegExp(
|
|
59
|
+
`((?<![A-Za-z0-9_])(?:${SENSITIVE_KEYS})(?:["']?\\s*[:=]\\s*["']?))(?!\\*\\*\\*)[^\\s"',;&]{3,}`,
|
|
60
|
+
'gi',
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
/** 命令行形式:`--token xxx` / `--api-key xxx`(只认带短横线的 flag,避免误伤普通词) */
|
|
64
|
+
const FLAG_VALUE = new RegExp(
|
|
65
|
+
`((?:^|\\s)--?(?:access-token|api-key|apikey|token|auth-token|client-secret|secret|password|passwd|pwd)\\s+["']?)(?!\\*\\*\\*)[^\\s"',;&]{3,}`,
|
|
66
|
+
'gi',
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** 中文键名 */
|
|
70
|
+
const CN_KEY_VALUE = /((?:密码|密钥|秘钥|令牌|凭证|口令)\s*[::=]\s*)(?!\*{3})\S+/g;
|
|
71
|
+
|
|
72
|
+
/** 已知凭据格式 */
|
|
73
|
+
const TOKEN_SHAPES: Array<[RegExp, string]> = [
|
|
74
|
+
[/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{12,}/g, 'sk-***'],
|
|
75
|
+
[/\bgithub_pat_[A-Za-z0-9_]{20,}/g, 'github_pat_***'],
|
|
76
|
+
[/\bgh[pousr]_[A-Za-z0-9]{20,}/g, 'gh*_***'],
|
|
77
|
+
[/\bnpm_[A-Za-z0-9]{30,}/g, 'npm_***'],
|
|
78
|
+
[/\bpypi-[A-Za-z0-9_-]{20,}/g, 'pypi-***'],
|
|
79
|
+
[/\bglpat-[A-Za-z0-9_-]{16,}/g, 'glpat-***'],
|
|
80
|
+
[/\bhf_[A-Za-z0-9]{20,}/g, 'hf_***'],
|
|
81
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA***'],
|
|
82
|
+
[/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, 'xox*-***'],
|
|
83
|
+
[/\bAIza[0-9A-Za-z_-]{30,}/g, 'AIza***'],
|
|
84
|
+
[/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g, 'sk_live_***'],
|
|
85
|
+
[/\bSG\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, 'SG.***'],
|
|
86
|
+
[/\bROBOT1\.0_[A-Za-z0-9._!-]{10,}/g, 'ROBOT1.0_***'],
|
|
87
|
+
[/\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, 'eyJ*** (JWT)'],
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 兜底:超长高熵串(≥44 字符且同时含大小写与数字)。
|
|
92
|
+
* 刻意放过纯小写十六进制(git sha / md5)与带连字符的 UUID,避免把正常内容打码。
|
|
93
|
+
*/
|
|
94
|
+
const HIGH_ENTROPY = /\b(?=[A-Za-z0-9+/=_-]{44,}\b)(?=[^\s]*[a-z])(?=[^\s]*[A-Z])(?=[^\s]*\d)[A-Za-z0-9+/=_-]{44,}\b/g;
|
|
95
|
+
|
|
96
|
+
export function redactSecrets(input: string): string {
|
|
97
|
+
if (!input) return input;
|
|
98
|
+
let out = input;
|
|
99
|
+
out = out.replace(PEM_BLOCK, '<已隐藏的私钥/证书>');
|
|
100
|
+
out = out.replace(AUTH_HEADER, '$1***');
|
|
101
|
+
out = out.replace(URL_USERINFO, '$1***:***@');
|
|
102
|
+
out = out.replace(KEY_VALUE, '$1***');
|
|
103
|
+
out = out.replace(FLAG_VALUE, '$1***');
|
|
104
|
+
out = out.replace(CN_KEY_VALUE, '$1***');
|
|
105
|
+
for (const [pattern, replacement] of TOKEN_SHAPES) out = out.replace(pattern, replacement);
|
|
106
|
+
out = out.replace(HIGH_ENTROPY, '***');
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 递归脱敏:字符串值处理,数组/对象继续下钻,其余原样 */
|
|
111
|
+
export function redactDeep<T>(value: T, depth = 0): T {
|
|
112
|
+
if (depth > 6 || value == null) return value;
|
|
113
|
+
if (typeof value === 'string') return redactSecrets(value) as unknown as T;
|
|
114
|
+
if (Array.isArray(value)) return value.map((item) => redactDeep(item, depth + 1)) as unknown as T;
|
|
115
|
+
if (typeof value === 'object') {
|
|
116
|
+
const out: Record<string, unknown> = {};
|
|
117
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
118
|
+
out[key] = /pass|secret|token|key|credential|cookie|auth|sig/i.test(key)
|
|
119
|
+
? typeof item === 'string'
|
|
120
|
+
? '***'
|
|
121
|
+
: '***'
|
|
122
|
+
: redactDeep(item, depth + 1);
|
|
123
|
+
}
|
|
124
|
+
return out as unknown as T;
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 单行化 + 脱敏 + 截断(用于 QQ 消息里的参数摘要) */
|
|
130
|
+
export function shortRedacted(value: unknown, limit = 60): string {
|
|
131
|
+
const flat = String(value ?? '')
|
|
132
|
+
.replace(/\s+/g, ' ')
|
|
133
|
+
.trim();
|
|
134
|
+
const safe = redactSecrets(flat);
|
|
135
|
+
return safe.length > limit ? `${safe.slice(0, limit)}…` : safe;
|
|
136
|
+
}
|
package/slash.ts
CHANGED
|
@@ -124,7 +124,8 @@ export interface RouteDeps {
|
|
|
124
124
|
*/
|
|
125
125
|
dispatch(text: string, options?: { runsTurn?: boolean }): DispatchResult;
|
|
126
126
|
/** answer the QQ user */
|
|
127
|
-
|
|
127
|
+
/** 回用户一条消息(返回值忽略,允许实现返回是否送达) */
|
|
128
|
+
reply(text: string): Promise<unknown>;
|
|
128
129
|
busy: boolean;
|
|
129
130
|
cfg: CommandConfig;
|
|
130
131
|
log(message: string, ...rest: unknown[]): void;
|