@zxh126/pi-qqbot 1.3.2 → 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 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
- | **Built-in commands** | `/status`, `/queue`, `/reset`, `/cancel`, `/help`. |
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
 
@@ -117,7 +122,7 @@ Some extension commands never print into the conversation — they call `ctx.ui.
117
122
 
118
123
  | Command | What happens |
119
124
  |---|---|
120
- | `/usage [range]`, `/cost [range]` | re-dispatched as `/qq usage <range>`: the bridge runs the original `/usage` command, then reads the **fresh** `customType: "usage-report"` entry — first from `sessionManager.getEntries()`, and if that view has not picked up the append yet, straight from the tail of the session file — and renders it as text (requests, tokens, cache ratio, cost, per-model). **Usage unchanged** — keep sending `/usage today`. |
125
+ | `/usage [range]`, `/cost [range]` | re-dispatched as `/qq usage <range>`: the bridge runs the original `/usage` command, then **polls** for the fresh `customType: "usage-report"` entry for up to 2 s (the append lands ~100 ms *after* `sendUserMessage()` resolves, so a single read always misses it), falling back to the tail of the session file, and renders it as text (requests, tokens, cache ratio, cost, per-model). **Usage unchanged** — keep sending `/usage today`. |
121
126
  | `/usage rate <value>`, `/usage rate auto` | really executed (they write `~/.pi/agent/pi-usage-report.json` / refresh the cached rate); the bridge then reads those files back and reports the resulting rate, so an invalid value shows up as "unchanged" |
122
127
  | `/mcp`, `/llama` | answered: they need host-TUI selection dialogs / browser login, so nothing is dispatched |
123
128
 
@@ -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
  }
@@ -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
- | **内置指令** | `/status`、`/queue`、`/reset`、`/cancel`、`/help`。 |
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
 
@@ -115,7 +120,7 @@ TUI/RPC 前端。在 QQ 里的表现就是「已派发,然后没下文」。
115
120
 
116
121
  | 命令 | 现在发生什么 |
117
122
  |---|---|
118
- | `/usage [范围]`、`/cost [范围]` | 改派为 `/qq usage <范围>`:桥接先重跑原 `/usage` 命令,再取**本次新写入**的 `customType: "usage-report"` 条目(先读 `sessionManager.getEntries()` 的增量;发现另一个扩展的 `appendEntry` 还没反映到该视图时,直接读会话文件尾部),渲染成文本回 QQ(请求数/总 token/缓存比/费用/按模型)。**用法不变**,照旧发 `/usage today` |
123
+ | `/usage [范围]`、`/cost [范围]` | 改派为 `/qq usage <范围>`:桥接先重跑原 `/usage` 命令,再**轮询等待**本次新写入的 `customType: "usage-report"` 条目(`appendEntry` 比 `sendUserMessage()` 返回晚约 100 ms,只读一次必然落空),最多等 2 秒,仍未命中才退回读会话文件尾部;然后渲染成文本回 QQ(请求数/总 token/缓存比/费用/按模型)。**用法不变**,照旧发 `/usage today` |
119
124
  | `/mcp`、`/llama` | 直接回执:它们需要主机 TUI 的选择框 / 浏览器登录,不会派发 |
120
125
  | `/usage rate <值>`、`/usage rate auto` | 会真的执行(写 `~/.pi/agent/pi-usage-report.json` / 刷新汇率缓存),桥接再读回文件确认新汇率,无效值会体现在“汇率未变”上;只发 `/usage rate` 与 `/usage help` 则本地直接回执,不执行命令 |
121
126
 
@@ -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
- state.replyBuffer.append(ev.delta ?? ev.text ?? "");
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) => log("[reply] 发送失败:", String(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
- const m = /^\/(reset|status|help|queue|q)(?:\s+([\s\S]*))?$/i.exec(text.trim());
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: (raw === "q" ? "queue" : raw) as "reset" | "status" | "help" | "queue",
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}: ${String(input.command ?? "").slice(0, 200)}`;
924
+ return `${event.toolName}: ${clip(input.command)}`;
785
925
  case "write":
786
926
  case "edit":
787
927
  case "read":
788
- return `${event.toolName}: ${String(input.path ?? input.file ?? input.filePath ?? "").slice(0, 200)}`;
928
+ return `${event.toolName}: ${clip(input.path ?? input.file ?? input.filePath)}`;
789
929
  case "grep":
790
- return `${event.toolName}: ${String(input.pattern ?? "").slice(0, 200)}`;
930
+ return `${event.toolName}: ${clip(input.pattern)}`;
791
931
  default:
792
- return `${event.toolName}: ${JSON.stringify(input).slice(0, 200)}`;
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 line = `[${timestamp()}] ${args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}\n`;
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.2",
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
- "ws": "^8.18.0",
42
- "proper-lockfile": "^4.1.2"
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
- const flat = text.replace(/\s+/g, " ").trim();
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
- reply(text: string): Promise<void>;
127
+ /** 回用户一条消息(返回值忽略,允许实现返回是否送达) */
128
+ reply(text: string): Promise<unknown>;
128
129
  busy: boolean;
129
130
  cfg: CommandConfig;
130
131
  log(message: string, ...rest: unknown[]): void;
@@ -382,6 +383,13 @@ export function pickerExpired(picker: PickerState | null): boolean {
382
383
  /** pi-usage-report 写入 session 的 custom entry 类型(耦合点,约定值) */
383
384
  const USAGE_ENTRY_TYPE = "usage-report";
384
385
 
386
+ /**
387
+ * appendEntry 的写入比 `sendUserMessage` 的 resolve 晚(实测 promise 183ms 返回,
388
+ * entry 在之后 ~100ms 才出现在 getEntries 里),所以必须轮询而不是只读一次。
389
+ */
390
+ const USAGE_WAIT_TIMEOUT_MS = 2000;
391
+ const USAGE_POLL_INTERVAL_MS = 100;
392
+
385
393
  /** pi-usage-report 的配置/汇率缓存(读回来确认:命令真的生效了,而不是口头应下) */
386
394
  const USAGE_SETTINGS_FILE = "pi-usage-report.json";
387
395
  const USAGE_RATE_FILE = "pi-usage-report-rate.json";
@@ -483,7 +491,7 @@ function readUsageEntryFromFile(file: string | undefined, since: number): any |
483
491
  } catch {
484
492
  continue;
485
493
  }
486
- if (entry?.type !== "custom" || entry?.customType !== USAGE_ENTRY_TYPE) continue;
494
+ if (!isUsageEntry(entry)) continue;
487
495
  const when = entryTime(entry?.data?.generatedAt ?? entry?.timestamp);
488
496
  return Number.isFinite(when) && when >= since ? entry : undefined;
489
497
  }
@@ -504,6 +512,28 @@ function localStamp(value: unknown): string {
504
512
  return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
505
513
  }
506
514
 
515
+ const isUsageEntry = (entry: any): boolean =>
516
+ entry?.type === "custom" && entry?.customType === USAGE_ENTRY_TYPE;
517
+
518
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
519
+
520
+ /**
521
+ * 等本次 /usage 写的报告出现:每 100ms 查一次 getEntries(最多 2s),
522
+ * 全程未命中再试一次会话文件(custom entry 可能很晚才落盘)。
523
+ */
524
+ async function waitForUsageEntry(
525
+ ctx: any,
526
+ opts: { before: Set<unknown>; since: number; file?: string },
527
+ ): Promise<any | undefined> {
528
+ const deadline = Date.now() + USAGE_WAIT_TIMEOUT_MS;
529
+ while (Date.now() < deadline) {
530
+ const fresh = [...sessionEntries(ctx)].reverse().find((entry: any) => isUsageEntry(entry) && !opts.before.has(entry?.id));
531
+ if (fresh) return fresh;
532
+ await sleep(USAGE_POLL_INTERVAL_MS);
533
+ }
534
+ return readUsageEntryFromFile(opts.file, opts.since);
535
+ }
536
+
507
537
  /** 把 pi-usage-report 的 ReportData 渲染成 QQ 可读的纯文本 */
508
538
  function formatUsage(data: any): string {
509
539
  if (!data?.totals) return "用量报告为空。";
@@ -687,12 +717,8 @@ export async function runBridge(
687
717
  reply(`统计失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
688
718
  return;
689
719
  }
690
- // 先看 sessionManager 的增量,再退回读会话文件(appendEntry 可能还没同步到视图)
691
- const entry =
692
- [...sessionEntries(ctx)]
693
- .reverse()
694
- .find((e: any) => e?.type === "custom" && e?.customType === USAGE_ENTRY_TYPE && !before.has(e?.id)) ??
695
- readUsageEntryFromFile(file, since);
720
+ // 先轮询 getEntries 的增量(appendEntry 晚于 await 返回),超时再退回读会话文件
721
+ const entry = await waitForUsageEntry(ctx, { before, since, file });
696
722
  if (!entry) {
697
723
  reply("没拿到用量报告:/usage 已执行但找不到报告数据(可重试一次;若反复出现请把 pi-qqbot.log 尾部发我)。");
698
724
  return;