@zxh126/pi-qqbot 1.3.3 → 1.5.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/CHANGELOG.md ADDED
@@ -0,0 +1,129 @@
1
+ # Changelog
2
+
3
+ 本文件记录 pi-qqbot 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
+ 版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
5
+
6
+ ## [1.5.0] - 2026-10-25
7
+
8
+ ### Added
9
+
10
+ - **暂停 / 恢复自动进度汇报(会话内有效)**:`/progress off`(别名 `/p pause`、`暂停`、`stop`、
11
+ 「暂停汇报」「关闭自动汇报」)停掉定时推送,`/progress on`(别名 `resume`、`start`、`恢复`、
12
+ 「恢复汇报」「继续汇报」)重新打开。
13
+ - 手动暂停跨 run 保留(`agent_start` 不再顺手复位),只在 `session_start`(`/new`、`/reload`、重启)复位为默认开启。
14
+ - 手动暂停期间,`/progress`、「进度」、「当前进度」、「现在进度」、「进展」**只回一次当前摘要**,
15
+ 不会偷偷把 15 s 定时器打开,回复末尾附「(自动汇报已暂停,发 /progress on 恢复)」提示。
16
+ - 与原有的「配额静默」严格区分:配额静默是发送失败后的自动暂停,问一次进度即恢复;
17
+ 手动暂停只有 `on`/「恢复汇报」能解除。
18
+ - `/progress <看不懂的参数>` 回用法提示,不再静默忽略。
19
+ - `/status` 新增 `自动汇报:` 一行,区分四种模式:`开启(每 Ns)` / `已暂停(本会话…)` /
20
+ `推送失败已静默` / `未启用(配置 progress.enabled=false)`。
21
+ - 裸文本口令「当前进度」「现在进度」「目前进展」(可带子命令,如「进度 暂停」)也走进度查询。
22
+ - 测试:`test/progress.test.mjs` 新增 7 个用例(暂停/恢复即时生效、跨 run 保留、暂停后手动查看不解除、
23
+ 中文口令且不误伤任务句、空闲时问「当前进度」、`/status` 文案、未知子命令)。
24
+
25
+ ### Changed
26
+
27
+ - `/progress`(无参数)在**手动暂停**状态下不再解除暂停(此前任何一次问询都会清掉暂停标志)。
28
+ - 「推送失败已静默」与「用户手动暂停」拆成两个独立状态(`progressPaused` / `progressMuted`),
29
+ 互不覆盖。
30
+ - README(英文)与 `docs/README.zh-CN.md` 的指令表、进度小节、`progress.enabled` 说明同步更新。
31
+
32
+ ## [1.4.0] - 2026-10-24
33
+
34
+ ### Added
35
+
36
+ - 任务运行期间按 `progress.intervalMs`(默认 15 s)推送进度摘要:轮次、工具调用次数与前三名、
37
+ 失败次数、最近完成的工具(含耗时)、正在跑的工具、token 用量。全部由本地事件拼装,
38
+ 不调用模型、不消耗 token。
39
+ - 全链路敏感信息脱敏(`redact.ts`):进度摘要、审批提示、队列预览、日志落盘统一过一遍,
40
+ 覆盖 `Authorization` / QQ bot token / `sk-…` / `ghp_…` / `npm_…` / `AKIA…` / JWT / PEM /
41
+ `token=` / `password=` / `--api-key` / URL `user:pass@` / 高熵串。
42
+ - `/progress`(别名 `/p`、「进度」)与忙碌时的「到哪了」「怎么样了」问询。
43
+ - 最终答案因配额发不出去时缓存,下条消息以「(上一条回复发送失败,补发如下)」补发。
44
+ - 配置 `progress.enabled` / `progress.intervalMs`(环境变量 `PI_QQBOT_PROGRESS_MS` 可覆盖)。
45
+
46
+ ## [1.3.3] - 2026-10-24
47
+
48
+ ### Fixed
49
+
50
+ - `/usage` 报告读回改为轮询等待:`appendEntry` 晚于命令 `await` 返回,导致偶发读不到刚写入的报告。
51
+
52
+ ## [1.3.2] - 2026-10-24
53
+
54
+ ### Fixed
55
+
56
+ - `/usage` 读回兜底:`getEntries` 尚未刷新时直接读会话文件尾部;`generatedAt` 兼容毫秒时间戳。
57
+
58
+ ## [1.3.1] - 2026-10-24
59
+
60
+ ### Fixed
61
+
62
+ - `/usage rate` 真正执行并读回配置文件确认,不再「口头成功」。
63
+
64
+ ## [1.3.0] - 2026-10-24
65
+
66
+ ### Added
67
+
68
+ - `/usage`、`/cost`(只往 `ctx.ui.*` 输出的扩展命令)桥接回 QQ,不再沉默。
69
+ - `/mcp`、`/llama` 这类需要主机 TUI 选择框/登录的命令改为明确回执说明。
70
+
71
+ ## [1.2.1] - 2026-10-24
72
+
73
+ ### Changed
74
+
75
+ - 日志时间戳改为本地时间并带 UTC 偏移(如 `[2026-10-24T07:39:09.762+08:00]`),与文件 mtime、
76
+ 进程启动时间直接对齐,不再需要心算时区。
77
+
78
+ ## [1.2.0] - 2026-10-24
79
+
80
+ ### Added
81
+
82
+ - 忙碌时收到的消息进入队列,当前任务 settle 后自动续跑;`/cancel` 永远不入队、立即生效。
83
+ - `/queue`(别名 `/q`)查看队列,`/queue clear`、`/queue drop <编号>` 管理队列。
84
+ - 配置 `queue.max`(0 = 关闭排队)、`queue.ack`。
85
+
86
+ ## [1.1.0] - 2026-10-24
87
+
88
+ ### Added
89
+
90
+ - `/tree`、`/fork`、`/resume` 的编号选择器接管:回一个最多 10 项的列表,只对发出者有效、10 分钟过期;
91
+ - `/clone` 支持。
92
+
93
+ ## [1.0.0] - 2026-10-24
94
+
95
+ ### Added
96
+
97
+ - 接管 pi 的斜杠命令(白名单 `commands.passthrough` / `allowReload` / `allowQuit` / `allowSession`):
98
+ `/reload`、`/new`、`/compact`、`/session`、`/name`、`/model`、`/thinking`、`/quit`,
99
+ 以及扩展命令、prompt 模板、`/skill:name` 的透传。
100
+ - 有任务在跑时,`/reload`、`/new`、`/compact`、`/quit`、`/tree`、`/fork`、`/clone`、`/resume` 拒绝执行。
101
+
102
+ ## [0.3.0] - 2026-10-24
103
+
104
+ ### Added
105
+
106
+ - `/cancel`(别名 `/stop`、`/abort`、「取消」「停止」「中止」「终止」「算了」)中断正在运行的任务:
107
+ 先终止待审批、清空队列,再 `ctx.abort()`,丢弃半截回复;8 秒看门狗兜底释放忙碌标记。
108
+
109
+ ## [0.2.1] - 2026-10-24
110
+
111
+ ### Changed
112
+
113
+ - 中文 README 移入 `docs/README.zh-CN.md`,修复 npm 页面默认 readme 被中文版顶掉。
114
+
115
+ ## [0.2.0] - 2026-10-24
116
+
117
+ ### Added
118
+
119
+ - 会话级「以后都同意」自动放行(`approve.remember`),含已在等待的那一条;
120
+ 「取消自动同意」「恢复审批」等**长得像取消**的说法仍走审批分支。
121
+ - 中英文双语 README。
122
+
123
+ ## [0.1.0] - 2026-10-16
124
+
125
+ ### Added
126
+
127
+ - 初始版本:把 Pi coding agent 接入 QQ 单聊(C2C),单会话 + `proper-lockfile` 单实例锁(宿主接管)、
128
+ 危险工具(`bash` / `powershell` / `write` / `edit` 及 `destructiveHint` 工具)执行前远程审批、
129
+ 长回复自动分段。
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`, 「进度」). `/progress off` pauses the automatic reports for the rest of the session, `/progress on` brings them back. 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` (+ `/progress off` / `on`), `/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 (read at session start — restart or `/new` after changing; at runtime use `/progress off`) |
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
 
@@ -73,8 +77,11 @@ Start pi, then message the bot in QQ; pi handles it and replies.
73
77
  | Command | Description |
74
78
  |---|---|
75
79
  | `/reset` | Clear the reply buffer, approval memory and session-wide auto-approve (**does not** switch sessions — send `/new` for that) |
76
- | `/status` | Show busy/idle state, queue length, auto-approve state and pending approval count |
80
+ | `/status` | Show busy/idle state, queue length, auto-approve state, pending approval count and the progress-report mode |
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** (aliases: `/p`, 「进度」, 「当前进度」, 「进展」; plain-text questions like 「到哪了」「怎么样了」 work while busy). While you paused reports yourself it only replies once — use `/progress on` to resume; while reports are merely quota-quiet, asking resumes them |
83
+ | `/progress off` | **Pause automatic progress reports for the rest of the session** (aliases: `pause`, `stop`, `暂停`, 「暂停汇报」, 「关闭汇报」). Manual `/progress` still works, it just no longer resumes the timer |
84
+ | `/progress on` | **Resume automatic progress reports** (aliases: `resume`, `start`, `恢复`, 「恢复汇报」, 「继续汇报」) |
78
85
  | `/cancel` | **Interrupt the task that is running right now** and empty the queue (aliases: `/stop`, `/abort`, 「取消」, 「停止」, 「中止」, 「终止」, 「算了」) |
79
86
  | `/help` | Show help |
80
87
 
@@ -176,6 +183,31 @@ Semantics:
176
183
  - `/cancel` empties the queue as well, because "cancel" means "stop doing all of this". Use `/queue drop <id>`
177
184
  to remove a single entry instead.
178
185
 
186
+ ### Progress reports
187
+
188
+ While a run is in progress, a summary is pushed to QQ every `progress.intervalMs` (15 s by default):
189
+
190
+ ```
191
+ [进度 02:15] · 第 3 轮 · 工具 6 次(bash 4 / read 2)· 失败 1
192
+ 最近: bash "npm test" · 6.2s
193
+ 正在: 分析测试输出… · 12.3k tok
194
+ ```
195
+
196
+ - Built purely from local event data (`tool_execution_*`, `turn_end`, `message_end`, `message_update`)
197
+ — **no model call, no tokens spent**.
198
+ - Tool arguments are redacted and truncated, so a command carrying a token never reaches QQ in clear.
199
+ - **Quota-aware**: one QQ message buys only 4 passive replies, so a long run will eventually run out.
200
+ The first failed send stops automatic reports for that run (no retry spam); send `/progress`
201
+ (or 「进度」) and they resume.
202
+ - **Manual pause / resume**: `/progress off` (or 「暂停汇报」) silences the reports for the rest of the
203
+ session — it survives `agent_settled` and the next run, and asking for `/progress` no longer turns it
204
+ back on. `/progress on` (or 「恢复汇报」) resumes. A new session (`/new`, restart) starts enabled again.
205
+ `/status` shows which of the four modes you are in (on / paused-by-you / quota-quiet / disabled).
206
+ - **The final answer is protected**: if it cannot be delivered (quota), it is cached and re-sent with a
207
+ `(上一条回复发送失败,补发如下)` prefix as soon as you send the next message.
208
+ - Timer is cleared on `agent_settled` / `/cancel` / session shutdown.
209
+ - Set `progress.enabled: false` to turn the whole thing off (across sessions; read at session start).
210
+
179
211
  ### Cancelling a run
180
212
 
181
213
  Long tasks started from QQ (installs, builds, a runaway agent loop) can be interrupted without going
@@ -240,6 +272,11 @@ The QQ gateway keeps only one active connection per bot. The extension uses a `p
240
272
 
241
273
  Debug logs are written to `~/.pi/agent/pi-qqbot.log`. Each line starts with a **local** timestamp that carries the UTC offset, e.g. `[2026-10-24T07:39:09.762+08:00]`, so it lines up with file mtimes and process start times without mental arithmetic.
242
274
 
275
+ ## Changelog
276
+
277
+ See [CHANGELOG.md](./CHANGELOG.md) — every release with what changed and why. The progress-report
278
+ pause/resume (`/progress off` / `on`) landed in **1.5.0**.
279
+
243
280
  ## License
244
281
 
245
282
  MIT
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`、「进度」)可立即获取;`/progress off` 在本会话内暂停自动汇报,`/progress on` 恢复。推送失败后自动推送静默,直到你再次问询。 |
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` | 任务运行期间是否定期推送进度摘要(会话启动时读取,改完需 `/new` 或重启;运行时用 `/progress off`) |
69
+ | `progress.intervalMs` | `15000` | 推送间隔(可用环境变量 `PI_QQBOT_PROGRESS_MS` 覆盖,便于测试) |
66
70
 
67
71
  也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件里的凭据。
68
72
 
@@ -73,8 +77,11 @@ pi install ./pi-qqbot
73
77
  | 指令 | 说明 |
74
78
  |---|---|
75
79
  | `/reset` | 重置:清空回复缓冲、审批记忆与自动同意开关(**不换会话**,换会话发 `/new`) |
76
- | `/status` | 查看忙闲状态、队列长度、自动同意状态、待审批数量 |
80
+ | `/status` | 查看忙闲状态、队列长度、自动同意状态、待审批数量、自动汇报模式 |
77
81
  | `/queue` | 查看队列(别名 `/q`);`/queue clear` 清空,`/queue drop <编号>` 删一条 |
82
+ | `/progress` | **立即获取当前进度摘要**(别名 `/p`、「进度」「当前进度」「进展」;忙碌时「到哪了」「怎么样了」这类问法同样生效)。手动暂停期间**问一次只回一次**,要重新打开定时汇报需发 `/progress on` 或「恢复汇报」;若只是配额静默,问一次即恢复 |
83
+ | `/progress off` | **在本会话内暂停自动进度汇报**(别名:`pause`、`stop`、`暂停`、「暂停汇报」、「关闭汇报」)。手动 `/progress` 仍可看一次,但不再解除暂停 |
84
+ | `/progress on` | **恢复自动进度汇报**(别名:`resume`、`start`、`恢复`、「恢复汇报」、「继续汇报」) |
78
85
  | `/cancel` | **中断当前正在运行的任务**并清空队列(别名:`/stop`、`/abort`、「取消」、「停止」、「中止」、「终止」、「算了」) |
79
86
  | `/help` | 帮助 |
80
87
 
@@ -167,6 +174,29 @@ pi 的会话一次只跑一轮 agent run,所以串行调度由 bot 自己做
167
174
  都立即生效。
168
175
  - `/cancel` 会连队列一起清空(“取消”就是“都别做了”);只想删一条请用 `/queue drop <编号>`。
169
176
 
177
+ ### 任务进度汇报
178
+
179
+ 任务运行期间,每 `progress.intervalMs`(默认 15 秒)推一条摘要到 QQ:
180
+
181
+ ```
182
+ [进度 02:15] · 第 3 轮 · 工具 6 次(bash 4 / read 2)· 失败 1
183
+ 最近: bash "npm test" · 6.2s
184
+ 正在: 分析测试输出… · 12.3k tok
185
+ ```
186
+
187
+ - 全部由本地事件拼出来(`tool_execution_*`、`turn_end`、`message_end`、`message_update`),
188
+ **不调用模型、不花 token**。
189
+ - 工具参数先脱敏再截断,带凭据的命令不会明文出现在 QQ 里。
190
+ - **配额感知**:一条用户消息只有 4 次被动回复额度,长任务迟早会用完。第一次发送失败就停掉本轮
191
+ 的自动推送(不重试、不刷屏);你发 `/progress`(或「进度」)即恢复。
192
+ - **手动暂停/恢复**:`/progress off`(或「暂停汇报」)在本会话内停掉自动推送——跨 run 保留,
193
+ 之后问 `/progress` 也不会把它打开;`/progress on`(或「恢复汇报」)恢复。开新会话(`/new`、重启)后
194
+ 恢复默认开启。`/status` 会显示当前处于哪种模式(开启 / 手动暂停 / 配额静默 / 配置关闭)。
195
+ - **保护最终答案**:答案因配额发不出去时会被缓存,等你发下一条消息时以
196
+ `(上一条回复发送失败,补发如下)` 前缀补发。
197
+ - `agent_settled` / `/cancel` / 会话关闭时定时器都会清掉。
198
+ - 想彻底关掉(跨会话):`progress.enabled: false`(改完需 `/new` 或重启才生效)。
199
+
170
200
  ### 取消正在运行的任务
171
201
 
172
202
  从 QQ 发起的耗时任务(装依赖、编译、agent 跑飞了)不必回到机器上处理:
@@ -228,6 +258,10 @@ QQ 网关对同一 bot 只保留一个活跃连接。插件用 `proper-lockfile`
228
258
 
229
259
  调试日志写入 `~/.pi/agent/pi-qqbot.log`。每行开头是**本地时间 + 时区偏移**(如 `[2026-10-24T07:39:09.762+08:00]`),和文件 mtime、进程启动时间直接对得上,不需要再拿 UTC 换算。
230
260
 
261
+ ## 变更日志
262
+
263
+ 见 [CHANGELOG.md](../CHANGELOG.md):每个版本改了什么、为什么改。进度汇报的暂停/恢复(`/progress off` / `on`)在 **1.5.0** 加入。
264
+
231
265
  ## License
232
266
 
233
267
  MIT
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,16 @@ 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
+ /** 用户手动暂停自动推送(本会话有效:跨 run 保留,/progress on 恢复) */
64
+ progressMuted: boolean;
65
+ /** 最终答案发送失败时缓存,下一条消息时补发 */
66
+ undelivered: { openid: string; text: string } | null;
55
67
  }
56
68
 
57
69
  /** 扩展运行所需的状态(导出只为测试用) */
@@ -77,6 +89,11 @@ export function createInitialState(): State {
77
89
  dispatching: false,
78
90
  runStarted: false,
79
91
  cancelTimer: null,
92
+ progress: new ProgressTracker(),
93
+ progressTimer: null,
94
+ progressPaused: false,
95
+ progressMuted: false,
96
+ undelivered: null,
80
97
  };
81
98
  }
82
99
 
@@ -93,19 +110,46 @@ export default function (
93
110
  state.cancelled = false;
94
111
  state.runStarted = true;
95
112
  clearCancelTimer(state);
113
+ state.progress.start();
114
+ state.progressPaused = false;
115
+ startProgressTimer(pi, state);
96
116
  });
97
117
 
98
118
  // ---- 事件:累积 assistant 文本 ----
99
119
  pi.on("message_update", (event) => {
100
120
  const ev = event.assistantMessageEvent as any;
101
121
  if (ev?.type === "text_delta") {
102
- state.replyBuffer.append(ev.delta ?? ev.text ?? "");
122
+ const delta = ev.delta ?? ev.text ?? "";
123
+ state.replyBuffer.append(delta);
124
+ state.progress.appendText(delta);
103
125
  }
104
126
  });
105
127
 
128
+ // ---- 事件:进度统计(工具/轮次/用量)----
129
+ pi.on("tool_execution_start", (event) => {
130
+ const ev = event as any;
131
+ state.progress.toolStart(ev.toolCallId, ev.toolName, ev.args);
132
+ });
133
+
134
+ pi.on("tool_execution_end", (event) => {
135
+ const ev = event as any;
136
+ state.progress.toolEnd(ev.toolCallId, ev.toolName, ev.isError === true, ev.durationMs);
137
+ });
138
+
139
+ pi.on("turn_end", () => {
140
+ state.progress.turn();
141
+ });
142
+
143
+ pi.on("message_end", (event) => {
144
+ const message = (event as any).message;
145
+ if (message?.role === "assistant") state.progress.addUsage(message.usage);
146
+ });
147
+
106
148
  // ---- 事件:run 结束,发回复,然后接着跑队列里的下一条 ----
107
149
  pi.on("agent_settled", async () => {
108
150
  clearCancelTimer(state);
151
+ stopProgressTimer(state);
152
+ state.progress.stop();
109
153
  const cancelled = state.cancelled;
110
154
  const text = state.replyBuffer.take();
111
155
  const openid = state.currentOpenid;
@@ -118,8 +162,17 @@ export default function (
118
162
  log("[reply] 本轮已被取消,丢弃半截回复(长度", text.length, ")");
119
163
  } else if (text && openid && state.api) {
120
164
  log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
165
+ let delivered = true;
121
166
  for (const chunk of splitText(text)) {
122
- await state.api.sendC2C(openid, chunk).catch((e) => log("[reply] 发送失败:", String(e)));
167
+ await state.api.sendC2C(openid, chunk).catch((e) => {
168
+ delivered = false;
169
+ log("[reply] 发送失败:", String(e));
170
+ });
171
+ }
172
+ if (!delivered) {
173
+ // 配额耗尽等情况:缓存下来,用户下次发消息时补发
174
+ state.undelivered = { openid, text };
175
+ log("[reply] 最终答案发送失败,已缓存待补发(openid=", openid, "len=", text.length, ")");
123
176
  }
124
177
  }
125
178
  // 本轮结束:取队列里的下一条继续执行(不 await,避免卡住 settled 的派发阶段)
@@ -192,6 +245,9 @@ export default function (
192
245
  state.cfg = cfg;
193
246
  // 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
194
247
  state.approver.reset();
248
+ // 手动暂停的自动汇报同样只在本会话有效
249
+ state.progressMuted = false;
250
+ state.progressPaused = false;
195
251
  // 队列上限来自配置;新会话不继承上一个会话遗留的待执行任务
196
252
  state.queue.setLimit(cfg.queue.max);
197
253
  state.queue.clear();
@@ -237,6 +293,7 @@ export default function (
237
293
  pi.on("session_shutdown", async () => {
238
294
  log("[shutdown] 清理资源");
239
295
  clearCancelTimer(state);
296
+ stopProgressTimer(state);
240
297
  const dropped = state.queue.clear();
241
298
  if (dropped.length > 0) log("[shutdown] 丢弃队列任务", dropped.length, "条");
242
299
  if (state.stopRetry) state.stopRetry();
@@ -476,6 +533,8 @@ async function handleCancel(
476
533
  return;
477
534
  }
478
535
 
536
+ stopProgressTimer(state);
537
+ state.progress.stop();
479
538
  state.cancelled = true;
480
539
  state.replyBuffer.reset();
481
540
  state.currentOpenid = null; // 被中止的 run 不再往 QQ 发半截回复
@@ -516,6 +575,18 @@ export async function handleMessage(
516
575
  }
517
576
  state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
518
577
 
578
+ // 上一条最终答案没发出去(配额耗尽):先补发
579
+ if (state.undelivered && state.undelivered.openid === msg.openid) {
580
+ const pending = state.undelivered;
581
+ state.undelivered = null;
582
+ log("[reply] 补发上一条未送达的回复,长度", pending.text.length);
583
+ const ok = await replyTo(api, state, msg.openid, `(上一条回复发送失败,补发如下)\n\n${pending.text}`);
584
+ if (!ok) {
585
+ state.undelivered = pending;
586
+ log("[reply] 补发仍然失败,继续保留");
587
+ }
588
+ }
589
+
519
590
  // 待选择的编号回复(/tree、/fork、/resume 之后)
520
591
  if (state.picker) {
521
592
  if (pickerExpired(state.picker)) {
@@ -654,7 +725,8 @@ export async function handleMessage(
654
725
  msg.openid,
655
726
  [
656
727
  "pi-qqbot 指令:",
657
- "/status — 查看状态(忙闲 / 队列 / 自动同意 / 待审批)",
728
+ "/progress — 查看进度;/progress off 暂停自动汇报、/progress on 恢复(本会话有效)",
729
+ "/status — 查看状态(忙闲 / 队列 / 自动同意 / 待审批 / 自动汇报)",
658
730
  "/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
659
731
  "/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
660
732
  "/cancel(或「取消」「停止」)— 中断正在运行的任务并清空队列",
@@ -667,16 +739,71 @@ export async function handleMessage(
667
739
  return;
668
740
  }
669
741
  if (cmd?.name === "status") {
742
+ const lines = [
743
+ `状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
744
+ `队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
745
+ `自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
746
+ `待审批: ${state.approver.pendingCount}`,
747
+ `自动汇报: ${describeProgressMode(state)}`,
748
+ ];
749
+ if (state.busy) {
750
+ const snapshot = state.progress.summary();
751
+ if (snapshot) lines.push("", snapshot);
752
+ // 用户主动问询:恢复自动推送(手动暂停的不算)
753
+ if (!state.progressMuted) state.progressPaused = false;
754
+ }
755
+ await replyTo(api, state, msg.openid, lines.join("\n"));
756
+ return;
757
+ }
758
+ if (cmd?.name === "progress") {
759
+ const sub = (cmd.args ?? "").trim().toLowerCase();
760
+ if (MUTE_ARGS.has(sub)) {
761
+ const was = state.progressMuted;
762
+ state.progressMuted = true;
763
+ state.progressPaused = false;
764
+ log("[progress] 用户暂停自动进度推送(本会话有效)", was ? "(已经是暂停状态)" : "");
765
+ await replyTo(
766
+ api,
767
+ state,
768
+ msg.openid,
769
+ was
770
+ ? "自动汇报本来就已暂停。发 /progress on(或「恢复汇报」)可以恢复。"
771
+ : "已暂停自动进度汇报(本会话有效,换会话或重启后恢复默认开启)。需要时发 /progress 仍可查看一次,发 /progress on 恢复。",
772
+ );
773
+ return;
774
+ }
775
+ if (UNMUTE_ARGS.has(sub)) {
776
+ const was = state.progressMuted;
777
+ state.progressMuted = false;
778
+ state.progressPaused = false;
779
+ log("[progress] 用户恢复自动进度推送");
780
+ await replyTo(
781
+ api,
782
+ state,
783
+ msg.openid,
784
+ was
785
+ ? `已恢复自动进度汇报${state.cfg?.progress?.enabled === false ? "(但配置 progress.enabled=false,仍不会推送)" : ""}。`
786
+ : "自动汇报本来就是开启的。",
787
+ );
788
+ return;
789
+ }
790
+ if (sub) {
791
+ await replyTo(
792
+ api,
793
+ state,
794
+ msg.openid,
795
+ "用法: /progress(看一次)| /progress off(暂停自动汇报)| /progress on(恢复)。也可以说「暂停汇报」「恢复汇报」。",
796
+ );
797
+ return;
798
+ }
799
+ const snapshot = state.progress.summary();
800
+ if (!state.progressMuted) state.progressPaused = false;
801
+ const body = snapshot || (state.busy ? "当前任务刚启动,还没有可汇报的进度。" : "当前没有正在运行的任务。");
670
802
  await replyTo(
671
803
  api,
672
804
  state,
673
805
  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"),
806
+ state.progressMuted ? `${body}\n(自动汇报已暂停,发 /progress on 恢复)` : body,
680
807
  );
681
808
  return;
682
809
  }
@@ -693,6 +820,13 @@ export async function handleMessage(
693
820
 
694
821
  // 忙检查:单会话串行处理——忙碌时入队,当前任务结束后自动继续
695
822
  if (isBusy(state)) {
823
+ // 忙碌时问「进度」不该排队,直接回当前进度并恢复自动推送
824
+ if (RE_PROGRESS_QUERY.test(msg.content.trim())) {
825
+ const snapshot = state.progress.summary();
826
+ if (!state.progressMuted) state.progressPaused = false;
827
+ await replyTo(api, state, msg.openid, snapshot || "任务刚启动,还没有可汇报的进度。");
828
+ return;
829
+ }
696
830
  await enqueueJob(api, state, msg.openid, msg.content);
697
831
  return;
698
832
  }
@@ -713,29 +847,138 @@ async function replyTo(
713
847
  openid: string,
714
848
  text: string,
715
849
  format: "text" | "markdown" = "text",
716
- ) {
850
+ ): Promise<boolean> {
717
851
  const ref = state.passiveRefs.get(openid);
852
+ let delivered = true;
718
853
  for (const chunk of splitText(text)) {
719
854
  const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
720
855
  try {
721
856
  await api.sendC2C(openid, chunk, usePassive ? { msgId: ref.msgId, format } : { format });
722
857
  log("[reply] 已发送 (passive=", usePassive, "):", chunk.slice(0, 50));
723
858
  } catch (e) {
859
+ delivered = false;
724
860
  log("[reply] 发送失败:", String(e));
725
861
  }
726
862
  }
863
+ return delivered;
864
+ }
865
+
866
+ /** 用户问进度的表达(忙碌时用来区分「排队」和「问进度」) */
867
+ // 忙碌时判断「这是在问进度」:只认句首的明确表达,避免把「帮我加个进度条」这类任务误当问询
868
+ const RE_PROGRESS_QUERY = new RegExp(
869
+ "^(?:" +
870
+ "\\/(?:progress|p|status)" +
871
+ "|(?:现在|当前|目前)?\\s*(?:的)?\\s*(?:progress|进度|进展)" +
872
+ "|跑到哪|怎么样了|到哪了|还在跑吗|好慢" +
873
+ ")",
874
+ "i",
875
+ );
876
+
877
+ function startProgressTimer(pi: ExtensionAPI, state: State) {
878
+ stopProgressTimer(state);
879
+ const cfg = state.cfg?.progress;
880
+ if (!cfg?.enabled) return;
881
+ const interval = Number.isFinite(cfg.intervalMs) && cfg.intervalMs >= 50 ? cfg.intervalMs : 15000;
882
+ const timer = setInterval(() => {
883
+ void pushProgress(state, "tick");
884
+ }, interval);
885
+ (timer as any).unref?.();
886
+ state.progressTimer = timer;
887
+ log("[progress] 自动进度推送已启动,间隔", interval, "ms");
888
+ }
889
+
890
+ function stopProgressTimer(state: State) {
891
+ if (state.progressTimer) {
892
+ clearInterval(state.progressTimer);
893
+ state.progressTimer = null;
894
+ }
895
+ }
896
+
897
+ /** 推一条进度摘要;失败就暂停自动推送(用户问一次即恢复);手动暂停(/progress off)管到底 */
898
+ async function pushProgress(state: State, reason: "tick" | "ask"): Promise<boolean> {
899
+ if (reason === "tick" && (state.progressPaused || state.progressMuted)) return false;
900
+ const api = state.api;
901
+ const openid = state.currentOpenid ?? state.lastOpenid;
902
+ if (!api || !openid || !state.busy || !state.progress.running) return false;
903
+ const text = state.progress.summary();
904
+ if (!text) return false;
905
+ const ok = await replyTo(api, state, openid, text);
906
+ if (!ok) {
907
+ state.progressPaused = true;
908
+ log("[progress] 推送失败(多半是配额),暂停自动进度推送,直到用户询问");
909
+ }
910
+ return ok;
911
+ }
912
+
913
+ /** 自动汇报当前状态的人话描述(/status 用) */
914
+ function describeProgressMode(state: State): string {
915
+ const cfg = state.cfg?.progress;
916
+ if (state.progressMuted) return "已暂停(本会话,发 /progress on 恢复)";
917
+ if (cfg?.enabled === false) return "未启用(配置 progress.enabled=false)";
918
+ if (state.progressPaused) return "推送失败已静默,发 /progress 恢复";
919
+ const ms = Number.isFinite(cfg?.intervalMs) && (cfg?.intervalMs ?? 0) >= 50 ? cfg!.intervalMs : 15000;
920
+ return `开启(每 ${Math.round(ms / 1000)}s)`;
727
921
  }
728
922
 
729
- function parseCommand(text: string): { name: "reset" | "status" | "help" | "queue"; args: string } | null {
923
+ /** /progress 的子命令:暂停 / 恢复自动汇报 */
924
+ const MUTE_ARGS = new Set([
925
+ "off",
926
+ "pause",
927
+ "stop",
928
+ "mute",
929
+ "关",
930
+ "关闭",
931
+ "关掉",
932
+ "停",
933
+ "停止",
934
+ "暂停",
935
+ "暂停汇报",
936
+ "停止汇报",
937
+ ]);
938
+ const UNMUTE_ARGS = new Set([
939
+ "on",
940
+ "resume",
941
+ "start",
942
+ "unmute",
943
+ "开",
944
+ "开启",
945
+ "打开",
946
+ "恢复",
947
+ "继续",
948
+ "恢复汇报",
949
+ "开启汇报",
950
+ "继续汇报",
951
+ ]);
952
+
953
+ /** 不带斜杠的「暂停汇报」类说法(整句匹配,避免误伤正常聊天) */
954
+ const RE_REPORTS_OFF =
955
+ /^(?:请|帮我|给我)?\s*(?:暂停|停止|关闭|关掉|别|不要|别再)(?:自动)?(?:的)?(?:进度汇报|进度|进展|汇报|推送|播报|报告)(?:上报|推送|汇报)?(?:了|吧)?$/;
956
+ const RE_REPORTS_ON =
957
+ /^(?:请|帮我|给我)?\s*(?:恢复|继续|开启|打开|开始)(?:自动)?(?:的)?(?:进度汇报|进度|进展|汇报|推送|播报|报告)(?:上报|推送|汇报)?(?:吧)?$/;
958
+
959
+ /** 不带斜杠的进度问询:「进度」「当前进度」「现在进展」(可带子命令) */
960
+ const BARE_PROGRESS_RE = /^(?:progress|(?:当前|现在|目前)?的?(?:进度|进展))(?:\s+([\s\S]*))?$/i;
961
+
962
+ function parseCommand(text: string): { name: "reset" | "status" | "help" | "queue" | "progress"; args: string } | null {
730
963
  // 注意:/new 现在属于 pi 的内置命令(由 slash 路由接管,真的开新会话),
731
964
  // 本地的「清缓冲/清审批记忆」改名为 /reset。
732
- const m = /^\/(reset|status|help|queue|q)(?:\s+([\s\S]*))?$/i.exec(text.trim());
733
- if (!m) return null;
734
- const raw = m[1].toLowerCase();
735
- return {
736
- name: (raw === "q" ? "queue" : raw) as "reset" | "status" | "help" | "queue",
737
- args: (m[2] ?? "").trim(),
738
- };
965
+ // 斜杠形式:全部内置指令;不带斜杠时只接受明确表达「进度」的词,避免把聊天里的 "p" / "status" 误判成命令
966
+ const t = text.trim();
967
+ const slash = /^\/(reset|status|help|queue|q|progress|p)(?:\s+([\s\S]*))?$/i.exec(t);
968
+ if (slash) {
969
+ const raw = slash[1].toLowerCase();
970
+ const name = raw === "q" ? "queue" : raw === "p" ? "progress" : raw;
971
+ return {
972
+ name: name as "reset" | "status" | "help" | "queue" | "progress",
973
+ args: (slash[2] ?? "").trim(),
974
+ };
975
+ }
976
+ // 裸文本:「进度」「当前进度」→ 看一次;「暂停汇报」「恢复汇报」→ 开关
977
+ const bare = BARE_PROGRESS_RE.exec(t);
978
+ if (bare) return { name: "progress", args: (bare[1] ?? "").trim() };
979
+ if (RE_REPORTS_OFF.test(t)) return { name: "progress", args: "off" };
980
+ if (RE_REPORTS_ON.test(t)) return { name: "progress", args: "on" };
981
+ return null;
739
982
  }
740
983
 
741
984
  /** /queue [clear|drop <编号>] */
@@ -778,17 +1021,19 @@ async function handleQueueCommand(api: QQApi, state: State, openid: string, args
778
1021
  /** 从 tool_call 事件提取可读摘要,让用户在 QQ 里看清要执行什么 */
779
1022
  function summarizeTool(event: any): string {
780
1023
  const input = event.input ?? {};
1024
+ // 审批提示会发到 QQ:先脱敏再截断
1025
+ const clip = (value: unknown) => redactSecrets(String(value ?? "")).slice(0, 200);
781
1026
  switch (event.toolName) {
782
1027
  case "bash":
783
1028
  case "powershell":
784
- return `${event.toolName}: ${String(input.command ?? "").slice(0, 200)}`;
1029
+ return `${event.toolName}: ${clip(input.command)}`;
785
1030
  case "write":
786
1031
  case "edit":
787
1032
  case "read":
788
- return `${event.toolName}: ${String(input.path ?? input.file ?? input.filePath ?? "").slice(0, 200)}`;
1033
+ return `${event.toolName}: ${clip(input.path ?? input.file ?? input.filePath)}`;
789
1034
  case "grep":
790
- return `${event.toolName}: ${String(input.pattern ?? "").slice(0, 200)}`;
1035
+ return `${event.toolName}: ${clip(input.pattern)}`;
791
1036
  default:
792
- return `${event.toolName}: ${JSON.stringify(input).slice(0, 200)}`;
1037
+ return `${event.toolName}: ${clip(JSON.stringify(input))}`;
793
1038
  }
794
1039
  }
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.3",
3
+ "version": "1.5.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,21 +27,26 @@
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",
34
34
  "qq",
35
35
  "util",
36
36
  "README.md",
37
+ "CHANGELOG.md",
37
38
  "docs",
38
39
  "LICENSE"
39
40
  ],
40
41
  "dependencies": {
41
- "ws": "^8.18.0",
42
- "proper-lockfile": "^4.1.2"
42
+ "proper-lockfile": "^4.1.2",
43
+ "ws": "^8.18.0"
43
44
  },
44
45
  "peerDependencies": {
45
46
  "@earendil-works/pi-coding-agent": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@types/proper-lockfile": "^4.1.4",
50
+ "@types/ws": "^8.18.2"
46
51
  }
47
52
  }
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;