@zxh126/pi-qqbot 2.6.0 → 2.6.2

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.
@@ -0,0 +1,611 @@
1
+ # Changelog
2
+
3
+ All notable changes to pi-qqbot are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
+ and versioning follows [SemVer](https://semver.org/spec/v2.0.0.html).
5
+
6
+ > Chinese changelog: [CHANGELOG.zh-CN.md](CHANGELOG.zh-CN.md)
7
+
8
+ ## [2.6.2] - 2026-11-01
9
+
10
+ ### Fixed
11
+
12
+ - **`/qq …` bypassed the command gates**: `/qq` is itself an extension command, so sending `/qq reload`,
13
+ `/qq quit` or `/qq new` landed in the passthrough branch of `routeSlash`, which checks neither
14
+ `commands.allowReload` / `allowQuit` / `allowUpdate` / `allowSession` nor the busy state (`needsIdle`).
15
+ With the defaults (`allowQuit: false`) `/qq quit` from QQ could shut the host pi down. `/qq` is now routed
16
+ *before* passthrough: `bridgeGate()` maps the sub-command back to its built-in name (multi-word sub-commands
17
+ `list tree` / `list fork` / `list resume` are matched as a whole first) and reuses the same
18
+ `allowed()` / `needsIdle` checks.
19
+ - **Dispatch receipts swallowed the argument**: `/qq reload` used to be answered with
20
+ `已派发 /qq 给 pi。` — `parseSlash` only takes the first word as the command name, so it looked like `reload`
21
+ had been ignored. Receipts and logs now echo the whole command (truncated at 80 characters with `…`).
22
+
23
+ ### Changed
24
+
25
+ - Destructive bridge sub-commands (`reload` / `new` / `quit` / `clone`) now answer **before** they are
26
+ dispatched, so the receipt cannot lose the race against `ctx.reload()` / `newSession()`.
27
+ - `/qq sendfile …`, `/qq usage …`, `/qq pick …` and unknown sub-commands no longer get an extra generic
28
+ receipt (the bridge answers for itself), which removes one useless message.
29
+ - Cleaned up a leftover `+` line in `slash.ts` from an older hand-patched edit
30
+ (`+reply(formatPicker(picker))` — harmless but dirty).
31
+
32
+ ### Compatibility
33
+
34
+ - `/reload`, `/new`, `/quit`, `/compact` and friends behave exactly as before; only `/qq <sub>` is now
35
+ held to the same gates.
36
+ - No config change required: deployments with `allowReload: true` keep working, and `/qq quit` is answered
37
+ with a refusal when `allowQuit` is false (the default).
38
+
39
+ ### Tests
40
+
41
+ - New `test/qq-bridge-gate.test.mjs` (21 assertions): sub-command mapping, three gate refusals, busy
42
+ refusal, receipt-before-dispatch ordering, multi-word sub-commands, receipt truncation, `/reload` regression.
43
+ - Full suite: 274 assertions across 22 test files pass; `tsc --noEmit --strict` is clean for the changed
44
+ `slash.ts`.
45
+
46
+ ## [2.6.1] - 2026-11-01
47
+
48
+ ### Changed
49
+
50
+ - **Per-language changelogs**: the Chinese changelog is renamed to `CHANGELOG.zh-CN.md` and this English
51
+ `CHANGELOG.en.md` is added. `README.md` links to `CHANGELOG.en.md`, `docs/README.zh-CN.md` links to
52
+ `CHANGELOG.zh-CN.md`.
53
+
54
+ ## [2.6.0] - 2026-11-01
55
+
56
+ ### Added
57
+
58
+ - **Inline buttons for approvals and single-choice lists**: approval cards carry ✅ 允许一次 / ❌ 拒绝 /
59
+ 🚀 以后都同意 buttons (strict tools show only the first two); the `/tree`, `/fork`, `/resume` and
60
+ model-selection lists carry option buttons (2 columns, at most 5 rows, labels truncated at 10 characters
61
+ while the full list stays in the message body). Buttons are attached only to markdown replies
62
+ (`markdownReply=true`).
63
+ - **`INTERACTION_CREATE` event pipeline**: the gateway subscribes to intent `1<<26` and dispatches
64
+ interaction events; `QQApi.acknowledgeInteraction` replies with `PUT /interactions/{id}` inside the
65
+ 5-second window (matched → ACK 0; expired / unauthorized / out-of-range → ACK 3, duplicate operation).
66
+ - **`qq/keyboard.ts`**: keyboard construction plus `button_data` encode/decode, routed by the top-level
67
+ `t` discriminator (`approval` / `pick`).
68
+
69
+ ### Changed
70
+
71
+ - `QQApi.sendC2C`'s `SendOptions.keyboard` is only sent with markdown messages; when a markdown send
72
+ fails and falls back to plain text, the keyboard is dropped automatically.
73
+ - `replyTo` / `bridgeReply` gain an optional `keyboard` passthrough. Picker-button clicks reuse the
74
+ "reply with a number" `/qq pick` dispatch path; approval-button clicks reuse `Approver.confirm` /
75
+ `allowAll`. Text replies (同意/拒绝 N, numbers) all remain as fallbacks.
76
+
77
+ ### Compatibility
78
+
79
+ - With `markdownReply=false` the behaviour is exactly as before (no keyboard, plain-text replies).
80
+ - Custom buttons require the "invited" enablement on the QQ Open Platform; if it is not granted, the
81
+ buttons do not render and numbered/text replies are unaffected.
82
+
83
+ ## [2.5.0] - 2026-11-01
84
+
85
+ ### Added
86
+
87
+ - **`markdownReply` takes effect**: set `true` and all replies go through QQ markdown messages
88
+ (`msg_type=2`); GFM tables and code blocks are rendered by the QQ client. Previously this config was
89
+ parsed but never consumed. On send failure (e.g. no markdown permission) `qq/api.ts` automatically
90
+ falls back to plain text — safe by design.
91
+ - **`util/chunk.ts` splitter keeps tables intact**: `splitText` preserves GFM tables as whole blocks
92
+ (table rows are buffered and flushed as a block when a non-table line appears) and never splits at
93
+ code-fence boundaries; long replies no longer show tables cut in half.
94
+
95
+ ### Changed
96
+
97
+ - The reply format is driven by `state.cfg.markdownReply`: `replyTo`'s `format` defaults to `auto` and
98
+ applies to final answers, error notices, approval prompts and every receipt; an explicit `"text"`
99
+ still forces plain text.
100
+
101
+ ### Compatibility
102
+
103
+ - `markdownReply` still defaults to `false`; behaviour is unchanged when it is disabled.
104
+
105
+ ## [2.4.1] - 2026-11-01
106
+
107
+ ### Added
108
+
109
+ - **`!` / `!!` get an explicit receipt instead of being silently dropped**: these prefixes (full-width
110
+ `!` / `!!` included) are shell shortcuts only in pi's host TUI / RPC front ends (`interactive-mode`'s
111
+ `text.startsWith("!")`, the RPC `bash` control command); the extension API has no equivalent
112
+ (`pi.sendUserMessage()` does not dispatch them, `pi.exec()` bypasses approval and session history).
113
+ `routeSlash()` now intercepts them first and replies explaining why they cannot run and what to do
114
+ instead (describe the task in words; it then goes through the `bash` tool with a QQ approval first).
115
+ Neither executed nor forwarded to the model as chat.
116
+ - **`/help` gains a line**: `!` / `!!` are not supported.
117
+
118
+ ### Changed
119
+
120
+ - **Detection deliberately tightened**: a bang command requires "prefix + non-empty command" (a bare
121
+ `!`, `!!`, `!!!`, `/xxx`, or a sentence-final exclamation such as `你好!` stays ordinary chat), so
122
+ emotional exclamation marks are not swallowed.
123
+
124
+ ### Fixed
125
+
126
+ - **`/qq reload` no longer errors with `This extension ctx is stale after session replacement or reload`**
127
+ (same for the numbered picks of `/new`, `/clone`, `/resume`, `/fork`, `/tree`): after `runBridge()`
128
+ the handler still called `syncModelFromCtx()` to read `ctx.model`, while `reload()` / `newSession()` /
129
+ `fork()` / `switchSession()` / `navigateTree()` make pi turn every guard getter of the old
130
+ `ExtensionCommandContext` into a throw (`runner.js`'s `createCommandContext` keeps lazy descriptors on
131
+ purpose). Now a `REPLACES_RUNTIME` set (`reload` / `update` / `new` / `quit` / `clone`) skips the ctx
132
+ teardown entirely when the runtime was replaced; `syncModelFromCtx()` itself is fault-tolerant (it
133
+ backs the picks of tree/fork/resume, whose branches only the runtime knows); and after a bridge error
134
+ the code stops touching ctx. Regression test `test/bridge-stale-ctx.test.mjs` reproduces the original
135
+ stack (`syncModelFromCtx` ← `handler`) with a fake ctx whose every getter throws: 3 cases failed
136
+ before the fix, all 5 pass after.
137
+
138
+ ### Compatibility
139
+
140
+ - Pure additions: slash-command routing, approval, queue, progress and credential intake are unaffected.
141
+ - One behaviour change: `!xxx` / `!!xxx` used to be forwarded to the model as ordinary text; they are
142
+ now answered without execution. If you relied on sending `!xxx` to make the model call `bash` itself,
143
+ describe the task normally instead.
144
+ - Messages with attachments still bypass all command routing (including bang detection), as before.
145
+ - `/reload`, `/update`, `/new`, `/clone` still do what they did (the session is really replaced or
146
+ reloaded); they just no longer report an extra error.
147
+ - Tests: new `test/bang-notice.test.mjs` (6 cases) and `test/bridge-stale-ctx.test.mjs` (5 cases); all
148
+ 19 test files green under `npm test`.
149
+ - Painless upgrade: config format, command names, approval and queue semantics unchanged; `!` / `!!`
150
+ were already "not executed" — only the silence became an explicit receipt.
151
+
152
+ ## [2.4.0] - 2026-11-01
153
+
154
+ ### Added
155
+
156
+ - **No more silence when the model runs out of credit or its key is invalid**: on a provider error pi
157
+ only leaves `stopReason: "error"` + `errorMessage` on the assistant message and produces **no body
158
+ text**, so QQ used to see just "no reaction". Now `message_end` classifies the error (`billing` /
159
+ `auth` / `permission` / `rate` / `other`) and `agent_settled` replies with the **current model**
160
+ (`provider/model`), the server's original message and three recovery paths. 401 / 402 / `1311 plan
161
+ not open` all take the "recoverable" branch; errors with body text, user-cancelled runs and 500-class
162
+ errors behave as before.
163
+ - **Remote key replacement**: after running out of credit, sending a bare key (`sk-…` / `ark-…` / `AIza…`
164
+ / hex / base64 / local-service placeholders like `ollama`, quoted or backticked also accepted) writes
165
+ it into the failing provider in `~/.pi/agent/auth.json` and really probes it once with
166
+ `GET <baseUrl>/models`, replying with a masked fragment and the probe result. **No `sk-` prefix is
167
+ assumed**: the only criteria are "non-empty + printable ASCII + no whitespace"; a bare string (the
168
+ whole message is the key) additionally requires length ≥ 12 with digits / uppercase / separators, so
169
+ ordinary text like `configuration.ts` is not swallowed.
170
+ - **Onboarding a new endpoint from QQ**: send `https://host/v1 <key>` or `endpoint <url> <key> models a,b`
171
+ → probes `/models` (falls back to `/v1` without a version suffix; 401/403 immediately marks the key
172
+ invalid) → filters out non-chat ids (`embedding` / `rerank` / speech / image generation) → writes
173
+ `models.json` + `pi.registerProvider()` (usable in the running process immediately) → sends a
174
+ **numbered list with context sizes**; replying with a number calls `pi.setModel()` (reuses the
175
+ existing picker, valid 10 minutes). The provider id is auto-derived as `qq-<host>`, numbered on
176
+ collision.
177
+ - **Model parameters align with the built-in catalog**: `/models` usually returns only ids, so
178
+ `borrowModelMeta()` borrows `contextWindow` / `maxTokens` / `reasoning` / `input` / `thinkingLevelMap` /
179
+ `cost` / `compat` / `inputLimits` from `modelRegistry.getAll()` (built-in catalog + configured
180
+ providers) by model id, ordered "same host > same-vendor domain family > `openai-completions`".
181
+ Conservative defaults (128k / 16k) apply only when nothing can be borrowed, and the receipt says how
182
+ many were filled in and how many are defaults.
183
+ - **Self-update from QQ**: `/update` (or 「更新自己」「升级一下插件」「自更新」) → `npm view` for the
184
+ latest version → **`pi update npm:@zxh126/pi-qqbot`** → **version-verification gate** (the on-disk
185
+ `package.json` must equal the registry version, otherwise it only reports and does not reload) →
186
+ `/qq reload` (`ctx.reload()`). Natural-language triggers are deliberately narrow — a bare 「更新」
187
+ is not recognised.
188
+
189
+ ### Changed
190
+
191
+ - **Config `commands.allowUpdate`** (default `false`, in the style of `allowReload` / `allowQuit`):
192
+ gates `/update` and 「更新自己」; a top-level default plus per-bot override.
193
+ - **`PickerKind` gains `model`**: numbered selection supports "models of a newly onboarded service";
194
+ picking one calls `pi.setModel()` directly; when the model is no longer in the catalog it says so
195
+ instead of silently proceeding, prompting a fresh `/model`.
196
+ - **Error notices and logs**: `errorMessage` is uniformly run through `redactSecrets()` + whitespace
197
+ collapse + 600-char clip; the error class and current model are written to `pi-qqbot.log`
198
+ (`[reply] 本轮模型接口报错: <class> <provider/model> <original>`), inspectable afterwards.
199
+
200
+ ### Compatibility
201
+
202
+ - Pure additions: without `commands.allowUpdate` nothing changes; outside the billing/auth-failure state
203
+ a bare key is not treated as a credential (the explicit `key …` form excepted).
204
+ - Requires pi to provide `message_end`'s `stopReason` / `errorMessage`, `pi.registerProvider()` and
205
+ `pi.setModel()` (all present in pi 1.4.x).
206
+ - Disk writes touch only pi's own `auth.json` / `models.json`, using pi's own `proper-lockfile` +
207
+ atomic rename; `models.json` only adds or edits its own provider, never touching others.
208
+ - The install directory is shared: `/update` only updates the disk; other pi processes still running
209
+ old code must reload or restart on their own.
210
+
211
+ ### Tests
212
+
213
+ - **14 suites / 191 cases** all green (`npm test`).
214
+ - New `test/billing.test.mjs` (26): error classification, Chinese copy, `parseCredentialMessage` forms,
215
+ `looksLikeSecret` (including non-`sk-` prefixes, explicit/bare tightness), `stripWrapping`,
216
+ `normalizeBaseUrl` / `extractModelIds`, `probeEndpoint` (success / `/v1` fallback / immediate 401 /
217
+ network error), `providerIdForEndpoint`, `borrowModelMeta`, `filterChatModelIds`, `modelLabel`,
218
+ `updateProviderKey` / `upsertEndpointProvider` persistence.
219
+ - New `test/credential-intake.test.mjs` (11): drives the real `handleMessage` — bare key while broke,
220
+ no swallow when not broke, explicit prefix, endpoint registration + numbered list, metadata
221
+ borrowing, probe 401, resend with `models`, numbered pick `setModel`, non-`sk` key, quoted key.
222
+ - New `test/selfupdate.test.mjs` (13): version comparison (incl. prerelease), npm output parsing, five
223
+ flow outcomes (latest / updated / check failure / update failure / version mismatch),
224
+ `isUpdateSelfIntent`, `/update` gating.
225
+ - New `test/error-notice.test.mjs` (8): 402 / 401 / 500 / with body / cancelled / cleared after a good
226
+ run / credential gate / redaction.
227
+
228
+ ## [2.3.0] - 2026-10-31
229
+
230
+ ### Added
231
+
232
+ - **Remote-context notice**: in `before_agent_start` it first checks "was this turn dispatched by this
233
+ extension as a QQ message", and only then writes a notice into the system prompt's
234
+ `qq-remote-context` section (before the user message, invisible on both QQ and the TUI): the
235
+ conversation comes from a remote QQ user who cannot see the local screen or dialogs; desktop
236
+ screenshots / screen OCR / UI automation / keyboard-mouse simulation / headed browsers are **off by
237
+ default** (`mcp__screenshot`, `mcp__winui` named explicitly) — render images off-screen to PNG and
238
+ send them back with `qq_send_file`; capturing the desktop once is allowed only to check whether the
239
+ machine is locked. Merely loading the extension or chatting in the local TUI injects nothing — it
240
+ must wait for the first QQ message to actually start a run.
241
+ - **System-prompt rules**: every run **from QQ** appends three short rules (remote user / locked screen
242
+ → off-screen rendering / say so plainly when human help is needed) so they survive compaction of a
243
+ long session; no stacking within one turn, and local TUI runs get nothing.
244
+ - **Re-injection after compaction**: `session_compact` clears the "noticed" flag so the next message
245
+ injects the notice again.
246
+ - **Config `remote`**: `notice` (default `true`, once per session), `guidelines` (default `true`),
247
+ `extra` (appended to the notice, empty by default); a top-level default plus per-bot override.
248
+ - **Local hand-off correction**: when, within one session, the previous turn came from QQ and the next
249
+ is typed directly in the local TUI, that local turn injects a one-time `qq-local-handoff` section
250
+ ("remote QQ user" premise void: screenshots are fine, no `qq_send_file` needed); consecutive local
251
+ turns do not repeat it. Detected via the `input` event's `source=interactive` (this extension's
252
+ `sendUserMessage` is `extension`, so it never misfires); interjecting mid-run of a QQ turn does not
253
+ count as a hand-off (no new run). `/status` gains `来源: QQ(远程)/ 本机(TUI)`.
254
+
255
+ ### Fixed
256
+
257
+ - **No automatic progress while an approval is pending**: when a tool approval waits for the user,
258
+ automatic progress pushes are held back (otherwise one line every 15 s would push the approval
259
+ prompt off-screen); they resume on the next interval after the approval is handled (or times out).
260
+ Asking `/progress` yourself still works; `/status` shows 「等审批期间暂缓」. The in-session manual
261
+ pause (`/progress off`) and quota-silence semantics are unchanged.
262
+
263
+ ### Tests
264
+
265
+ - New `test/remote-notice.test.mjs` (defaults / copy elements / `extra` append / system-rule length).
266
+ - `test/config.test.mjs` gains `remote` global-default + per-account override cases.
267
+ - `test/accounts.test.mjs` gains 3 cases: **local TUI chat injects nothing**, only the first QQ
268
+ message's run injects and once per session, re-injection after compaction; `remote.notice=false`
269
+ disables only the notice and `remote.extra` appends; `remote.guidelines=false` disables only the
270
+ rules, and repeated triggers within one turn do not stack.
271
+ - `test/progress.test.mjs` gains cases: no push while an approval is pending, `/status` shows the hold,
272
+ asking for progress still answers once, pushes resume after the approval is handled.
273
+ - `test/accounts.test.mjs` gains 2 more cases: the local hand-off corrects exactly once (`/status`
274
+ shows the origin); the pending hand-off is overwritten by a new QQ turn, and an `extension`-source
275
+ input is not a hand-off.
276
+ - `test/remote-notice.test.mjs` gains `qq-local-handoff` tag and copy assertions.
277
+
278
+ ## [2.2.1] - 2026-02-11
279
+
280
+ ### Fixed
281
+
282
+ - **Copy text was treated as a command**: when a user quoted the whole approval prompt back (or
283
+ discussed it in a sentence like 「你这句话里说『取消自动同意』」), the old **unanchored** regex
284
+ matched 「取消自动同意」 / 「以后都同意」 inside the sentence and acted on it (the log even showed
285
+ a reply of 「当前没有开启自动同意」). `parseReply` now strips whitespace and punctuation and does
286
+ **whole-sentence matching** with a length cap (≤ 12 chars); quoted prompts, long sentences and
287
+ exclamations are never parsed — they go to pi as ordinary chat.
288
+ - **The approval prompt was too wordy**: compressed from 3 lines (with 「停止」/「以后都同意」/
289
+ 「取消自动同意」) to 2 lines: `[审批 #N] <summary>` + `回「同意 N」或「拒绝 N」;想全部放行回
290
+ 「以后都同意」。`; tools that must be confirmed every time (file sending) use the strict copy
291
+ (no 「以后都同意」).
292
+
293
+ ### Tests
294
+
295
+ - New `test/approver.test.mjs` (6 cases: approve/reject variants, stop / always-allow / revoke-always,
296
+ quoted prompt not parsed, mid-sentence keywords not parsed, 「停止汇报」 not a command, overlong
297
+ sentence not parsed, prompt ≤ 2 lines).
298
+ - `test/queue.test.mjs` syncs the strict-copy assertions (19 cases).
299
+
300
+ ## [2.2.0] - 2026-02-11
301
+
302
+ ### Added
303
+
304
+ - **Sending local files to QQ**: new tool `qq_send_file(path, name?)` and command `/sendfile <path>`.
305
+ Uploads through the official rich-media API in chunks (`upload_prepare` → chunked `PUT` →
306
+ `upload_part_finish` → merge for `file_info`), then sends with `msg_type=7` + `media.file_info` as a
307
+ **passive reply** (`srv_send_msg:false`, no active-quota burn). Local files have no public URL and
308
+ the one-shot binary upload caps at 5 MB, so everything goes through chunking, no relay dependency.
309
+ - **`file_type` mapping**: png/jpg/jpeg → image (1), mp4 → video (2), silk → voice (3), everything
310
+ else → file (4); otherwise QQ answers `850019`. Images/videos/voice display inline; other formats
311
+ arrive as a downloadable file card.
312
+ - **Outbound files are force-gated (asked every time)**: `qq_send_file` is added to
313
+ `FORCE_APPROVAL_TOOLS` — a QQ approval is mandatory no matter what `approve.tools` says, and it
314
+ **skips the session-wide 「以后都同意」 and remembered decisions**; each file is confirmed on its
315
+ own and is never written into remember. The prompt shows the file path and display name, with
316
+ 「需单独确认」.
317
+ - **Config `files`**: `enabled` (default true), `maxBytes` (default 30 MB; QQ hard cap 200 MB),
318
+ overridable per bot.
319
+ - **Error-code translation**: `850031` too large, `850019` format unsupported, `40093002` daily
320
+ capacity reached, `850027` timeout, `850018` muted/rate-limited, `40093001` transfer failed — all
321
+ turned into Chinese receipts.
322
+
323
+ ### Changed
324
+
325
+ - `qq/api.ts`: new `sendC2CMedia(openid, fileInfo, { msgId })`, reusing the existing send queue and
326
+ `msg_seq` counting.
327
+ - `slash.ts`: new takable command `sendfile` (not gated by `commands.allowSession`; guarded by
328
+ `files.enabled` + approval).
329
+ - `index.ts`: `State` records `restBase`; `/help` gains the `/sendfile` entry.
330
+ - `package.json`: new peer dependency `typebox` (tool-parameter schema; aliased by the pi runtime).
331
+
332
+ ### Tests
333
+
334
+ - New `test/upload.test.mjs` (9 cases: `file_type` mapping, md5/sha1/md5_10m, the full chunked
335
+ pipeline with byte reassembly, image type, chunk retry, local validation (missing / directory /
336
+ empty / over limit), merge without `file_info`, error-code translation, `sendC2CMedia`'s
337
+ `msg_type=7` body).
338
+ - Added to `npm test`; the whole suite went 130 → 140 cases green (queue 18 → 19, with the new
339
+ "file sends are always approved" case).
340
+
341
+ ## [2.1.0] - 2026-02-11
342
+
343
+ ### Added
344
+
345
+ - **Receiving QQ attachments (images / voice / video / documents / archives / plain files)**: incoming
346
+ attachments are downloaded to temp files immediately and their paths are handed to pi as marker
347
+ lines — images as `图片路径 <path>`, the rest by type as `语音路径` / `视频路径` / `文档路径` /
348
+ `压缩包路径` / `文件路径`. The original text stays on the first line, in the same turn as the
349
+ paths; pi's `read` tool can open the image directly and `bash` can process the files. (Before
350
+ 2.1.0 only images were kept and never saved to disk; a pure-image message delivered an empty
351
+ string to pi.)
352
+ - **QQ receipt**: after saving, the bot replies 「已收到 N 个附件(图片 ×1、文档 ×1),已存为临时文件
353
+ 并交给 pi:」 followed by each path; failed downloads are listed with the reason, and the text part
354
+ still runs.
355
+ - **Automatic temp-file cleanup**: the save directory is split by date
356
+ (`<system temp>/pi-qqbot/<bot>/<YYYY-MM-DD>/`) and swept at session start according to
357
+ `attachments.retentionHours` (default 24 h) and `attachments.maxFiles` (default 200); `<date>`
358
+ subdirectories not created by this extension are never touched.
359
+ - **Config `attachments`**: `enabled` / `dir` / `maxBytes` / `maxPerMessage` / `retentionHours` /
360
+ `maxFiles`, overridable per bot in `qqbots[]` like `queue` / `progress`. With `enabled: false` it
361
+ replies with an ignore notice and processes text only.
362
+ - **New `attachment.ts`**: six-type detection, filename sanitising (only `[0-9a-zA-Z._-]` kept),
363
+ concurrent downloads (default 3), 25 MB per-attachment cap, 30 s download timeout, one retry with
364
+ credentials on 401/403, a failed attachment never blocks the others.
365
+
366
+ ### Changed
367
+
368
+ - `qq/gateway.ts`: `QQMessage.attachments` becomes `{ url, contentType, filename?, size? }[]` and
369
+ **no longer drops non-image attachments**; when a voice message body only has `voice_wav_url`, that
370
+ is used as the download URL.
371
+ - The gateway log no longer prints the whole JSON packet (it carries the short-lived `rkey`
372
+ credential); it logs openid / content summary / attachment count / source host instead.
373
+ - `handleMessage()`: messages with attachments **bypass** slash commands, approval parsing, numbered
374
+ selection and built-in commands — always ordinary content (still queued when busy).
375
+
376
+ ### Tests
377
+
378
+ - New `test/attachment.test.mjs` (12 cases: six-type detection, extension rules, saved content, over
379
+ limit, 401 retry, partial failure, per-message cap, receipt copy, directory cleanup).
380
+ - `test/queue.test.mjs` 12 → 18 cases (pure-image dispatch, text+document line joining, busy queueing,
381
+ `enabled=false` ignore notice, `/status` bypass, failed download still delivers the text).
382
+
383
+ ## [2.0.1] - 2026-10-25
384
+
385
+ ### Fixed
386
+
387
+ - **Word order: 「暂停汇报进度」 was not recognised** and leaked to the model as ordinary chat. Both
388
+ orders — 「暂停**进度汇报**」 and 「暂停**汇报进度**」 — are now recognised; trailing particles
389
+ like 「别汇报了**吧**」 are also caught.
390
+
391
+ ### Changed
392
+
393
+ - **Colloquial mute words expanded**: 「别汇报了」「别说了」「别讲了」「别推了」「别发了」「别报了」
394
+ 「闭嘴」「安静」「打住」「甭」 etc. are equivalent to `/progress off` (resume with 「恢复汇报」 /
395
+ 「接着说」).
396
+ - **「停」 now interrupts the task** (per user feedback): `停`/`停下`/`停一下`/`停停`/`停掉`/`别跑了`
397
+ join `RE_CANCEL` and are fully equivalent to `/cancel`; only noun-carrying phrases like 「停止汇报」
398
+ or pure shut-up words mute the reports.
399
+ - **Asking for progress is a command**: 「汇报一下当前进度」「看一下进度」「说说进度」「进度如何」
400
+ 「进度怎么样」 are no longer forwarded to pi as chat; while busy they answer once with a summary
401
+ without queueing, while idle they answer 「当前没有正在运行的任务」.
402
+ - `/help` distinguishes the two meanings: to stop only the pushes use `/progress off` / 「别汇报了」 /
403
+ 「闭嘴」; 「停」 interrupts the task.
404
+ - Both READMEs gain the colloquial-shortcut table (intent → things to say).
405
+
406
+ ### Tests
407
+
408
+ - `test/progress.test.mjs` 16 → 22 cases (new/rewritten: both word orders, colloquial mute words,
409
+ 「停/停下/停一下/停止/取消」 all interrupt, 「停止汇报」 still mutes, three poses of asking for
410
+ progress, no false positives).
411
+
412
+ ## [2.0.0] - 2026-10-25
413
+
414
+ ### Added
415
+
416
+ - **Multiple QQ bots (one config, one machine, several pi processes)**: the config takes
417
+ `qqbots: [...]` with multiple `appId` / `appSecret` pairs, supporting `name` and per-account
418
+ overrides of `sandbox` / `allowlist` / `queue` / `progress` / `approve` / `commands` (unwritten keys
419
+ inherit the global defaults, merged per key).
420
+ - **Automatic claiming**: every pi process, on start (`session_start`), claims the first **offline**
421
+ bot in config order; processes that cannot claim stay off QQ but rescan every 5 seconds and take
422
+ over when someone exits. One lock per process — never concurrent double-connection.
423
+ - **"Never steal a bot temporarily released by a reload"**: on session rebuild (`/reload`, `/new`,
424
+ `/fork`, `/resume`) a 20-second reservation is written **before** releasing the lock, so other
425
+ processes skip that bot; after claiming, the reservation record is re-read and yielded on collision
426
+ (closing the race window). The new runtime reclaims its own bot with the same pid, silently.
427
+ - **Visible identity**: the claim log carries bot name / appId / cwd / pid; the first QQ receipt is
428
+ prefixed with 「(已接入 qqbot「work」)」; `/status`'s first line shows `bot: work (…0143) · cwd: …`.
429
+ - **Takeover notices**: `~/.pi/agent/pi-qqbot-<appId>.owner.json` records the previous holder's pid and
430
+ working directory; on takeover the log writes the old and new pid/cwd and the first QQ receipt
431
+ carries 「(qqbot「work」已由新实例接管 · 当前工作目录 …)」.
432
+ - `PI_QQBOT_ACCOUNT=<name|appId>` pins an account (for debugging or a fixed directory); an unknown
433
+ name means no connection, with a log line.
434
+ - **Credentials are verified right after claiming**: the claim immediately exchanges an
435
+ `access_token`; on failure it **releases the lock** and skips that bot (trying the next one), so a
436
+ bot with a wrong secret can no longer hold the slot while never connecting; when nothing works it
437
+ stops rescanning pointlessly.
438
+ - **Fix** `AuthManager`: the QQ platform answers invalid credentials with HTTP 200 + `code 100016`;
439
+ the old code went on to connect the gateway with an empty token. It now throws immediately (with
440
+ code/message) and is caught during claiming.
441
+ - New doc [docs/accounts.md](docs/accounts.md); new tests `test/config.test.mjs` (9),
442
+ `test/lock.test.mjs` (8, real subprocesses + real lockfile, including "two processes in the same
443
+ working directory each get one bot"), `test/accounts.test.mjs` (9, end-to-end claiming / notices /
444
+ invalid-credential degradation), `test/auth.test.mjs` (3).
445
+
446
+ ### Changed
447
+
448
+ - Logs are per account: `~/.pi/agent/pi-qqbot-<name>.log` (several processes on one machine no longer
449
+ interleave); the single-bot v1 format still writes `pi-qqbot.log`.
450
+ - `/status` gains the bot identity and working-directory lines; `/help` gains the multi-bot note.
451
+ - Config loading becomes `loadAccounts()` (returns the account list in order); `loadConfig()` stays as
452
+ a compatibility wrapper.
453
+
454
+ ### Compatibility
455
+
456
+ - **Old single-bot configs work unchanged** (equivalent to an account named `default`, behaving like
457
+ 1.5.0); `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` still apply to the single-bot scenario only.
458
+ - Writing both the top-level `appId` and `qqbots` → error, no connection (ambiguity is not guessed);
459
+ an account with missing credentials / duplicate `appId` / duplicate `name` is skipped with a log
460
+ line, the rest work normally.
461
+ - **A bot is not bound to a working directory**: allocation depends only on config order and online
462
+ state, not on cwd — several pi processes in the same directory each get their own bot. cwd is only
463
+ used for logs, takeover notices and `/status`.
464
+ - Recommended: restart (or `/reload`) once after the upgrade — per-account log files only appear on a
465
+ fresh start.
466
+
467
+ ## [1.5.0] - 2026-10-25
468
+
469
+ ### Added
470
+
471
+ - **Pause / resume automatic progress reports (per session)**: `/progress off` (aliases `/p pause`,
472
+ `暂停`, `stop`, 「暂停汇报」「关闭自动汇报」) stops the timer; `/progress on` (aliases `resume`,
473
+ `start`, `恢复`, 「恢复汇报」「继续汇报」) turns it back on.
474
+ - The manual pause survives runs (`agent_start` no longer resets it) and only resets to enabled on
475
+ `session_start` (`/new`, `/reload`, restart).
476
+ - While manually paused, `/progress`, 「进度」, 「当前进度」, 「现在进度」, 「进展」 **answer once
477
+ with the current summary** without secretly re-opening the 15 s timer, and the reply ends with
478
+ 「(自动汇报已暂停,发 /progress on 恢复)」.
479
+ - Strictly separate from the existing "quota silence": quota silence is the automatic pause after a
480
+ failed send, cleared by asking once; the manual pause is cleared only by `on` / 「恢复汇报」.
481
+ - `/progress <gibberish>` answers with usage instead of being silently ignored.
482
+ - `/status` gains an `自动汇报:` line distinguishing four modes: `开启(每 Ns)` / `已暂停(本会话…)`
483
+ / `推送失败已静默` / `未启用(配置 progress.enabled=false)`.
484
+ - Bare-text queries 「当前进度」「现在进度」「目前进展」 (sub-commands allowed, e.g. 「进度 暂停」)
485
+ also route to the progress query.
486
+ - Tests: `test/progress.test.mjs` gains 7 cases (pause/resume immediate effect, survives runs, asking
487
+ while paused does not unpause, Chinese commands without false positives, idle 「当前进度」,
488
+ `/status` copy, unknown subcommand).
489
+
490
+ ### Changed
491
+
492
+ - `/progress` (no argument) no longer clears the pause while manually paused (previously any query
493
+ wiped the pause flag).
494
+ - "Failed-send silence" and "user pause" are two separate states (`progressPaused` / `progressMuted`)
495
+ that no longer overwrite each other.
496
+ - README (English) and `docs/README.zh-CN.md` command table, progress section and `progress.enabled`
497
+ notes updated in sync.
498
+
499
+ ## [1.4.0] - 2026-10-24
500
+
501
+ ### Added
502
+
503
+ - While a task runs, a progress summary is pushed every `progress.intervalMs` (default 15 s): turns,
504
+ tool-call count with the top three tools, failure count, the most recently finished tool (with
505
+ duration), the tool currently running, and token usage. Built purely from local events — no model
506
+ call, no tokens spent.
507
+ - End-to-end secret redaction (`redact.ts`): progress summaries, approval prompts, queue previews and
508
+ log writes all pass through one scrubber covering `Authorization` / QQ bot tokens / `sk-…` /
509
+ `ghp_…` / `npm_…` / `AKIA…` / JWT / PEM / `token=` / `password=` / `--api-key` / URL `user:pass@` /
510
+ high-entropy strings.
511
+ - `/progress` (aliases `/p`, 「进度」) and the busy-time queries 「到哪了」 / 「怎么样了」.
512
+ - A final answer that cannot be delivered (quota) is cached and re-sent with
513
+ 「(上一条回复发送失败,补发如下)」 on the next message.
514
+ - Config `progress.enabled` / `progress.intervalMs` (env override `PI_QQBOT_PROGRESS_MS`).
515
+
516
+ ## [1.3.3] - 2026-10-24
517
+
518
+ ### Fixed
519
+
520
+ - The `/usage` report read-back now polls: `appendEntry` lands after the command's `await` returns,
521
+ which occasionally missed the freshly written report.
522
+
523
+ ## [1.3.2] - 2026-10-24
524
+
525
+ ### Fixed
526
+
527
+ - `/usage` read-back fallback: when `getEntries` has not refreshed yet, read the tail of the session
528
+ file directly; `generatedAt` tolerates millisecond timestamps.
529
+
530
+ ## [1.3.1] - 2026-10-24
531
+
532
+ ### Fixed
533
+
534
+ - `/usage rate` now really executes and reads the config file back to confirm, instead of claiming
535
+ success in words only.
536
+
537
+ ## [1.3.0] - 2026-10-24
538
+
539
+ ### Added
540
+
541
+ - `/usage`, `/cost` (extension commands that only print to `ctx.ui.*`) are bridged back to QQ instead
542
+ of being silent.
543
+ - `/mcp`, `/llama` — commands that need host-TUI selection dialogs or login — answer with an explicit
544
+ explanation instead.
545
+
546
+ ## [1.2.1] - 2026-10-24
547
+
548
+ ### Changed
549
+
550
+ - Log timestamps use local time with a UTC offset (e.g. `[2026-10-24T07:39:09.762+08:00]`), aligning
551
+ directly with file mtimes and process start times — no timezone arithmetic needed.
552
+
553
+ ## [1.2.0] - 2026-10-24
554
+
555
+ ### Added
556
+
557
+ - Messages arriving while busy go into a queue and resume automatically after the current task
558
+ settles; `/cancel` is never queued and takes effect immediately.
559
+ - `/queue` (alias `/q`) to view the queue; `/queue clear`, `/queue drop <编号>` to manage it.
560
+ - Config `queue.max` (0 = queueing off) and `queue.ack`.
561
+
562
+ ## [1.1.0] - 2026-10-24
563
+
564
+ ### Added
565
+
566
+ - Numbered-selection takeover for `/tree`, `/fork`, `/resume`: replies with a list of at most 10
567
+ entries, valid only for the asking user, expiring in 10 minutes.
568
+ - `/clone` support.
569
+
570
+ ## [1.0.0] - 2026-10-24
571
+
572
+ ### Added
573
+
574
+ - Takes over pi's slash commands (allowlist `commands.passthrough` / `allowReload` / `allowQuit` /
575
+ `allowSession`): `/reload`, `/new`, `/compact`, `/session`, `/name`, `/model`, `/thinking`,
576
+ `/quit`, plus passthrough of extension commands, prompt templates and `/skill:name`.
577
+ - While a task is running, `/reload`, `/new`, `/compact`, `/quit`, `/tree`, `/fork`, `/clone`,
578
+ `/resume` refuse to execute.
579
+
580
+ ## [0.3.0] - 2026-10-24
581
+
582
+ ### Added
583
+
584
+ - `/cancel` (aliases `/stop`, `/abort`, 「取消」「停止」「中止」「终止」「算了」) interrupts the
585
+ running task: first terminates pending approvals and empties the queue, then `ctx.abort()` and
586
+ drops the half-written reply; an 8-second watchdog releases the busy flag as a fallback.
587
+
588
+ ## [0.2.1] - 2026-10-24
589
+
590
+ ### Changed
591
+
592
+ - The Chinese README moves into `docs/README.zh-CN.md`, fixing the npm page defaulting to the Chinese
593
+ readme.
594
+
595
+ ## [0.2.0] - 2026-10-24
596
+
597
+ ### Added
598
+
599
+ - Session-level "always allow" (`approve.remember`), including the request already waiting;
600
+ phrases that only look like cancelling — 「取消自动同意」「恢复审批」 — still go through the
601
+ approval branch.
602
+ - Bilingual README (Chinese and English).
603
+
604
+ ## [0.1.0] - 2026-10-16
605
+
606
+ ### Added
607
+
608
+ - Initial release: bridges the Pi coding agent into QQ private chats (C2C), single session +
609
+ `proper-lockfile` single-instance lock (host takeover), remote approval before dangerous tools
610
+ (`bash` / `powershell` / `write` / `edit` and `destructiveHint` tools) run, automatic segmentation
611
+ of long replies.
@@ -3,6 +3,46 @@
3
3
  本文件记录 pi-qqbot 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
4
  版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
5
5
 
6
+ > 英文版 changelog:[CHANGELOG.en.md](CHANGELOG.en.md)
7
+
8
+ ## [2.6.2] - 2026-11-01
9
+
10
+ ### Fixed
11
+
12
+ - **`/qq …` 绕过命令门禁**:`/qq` 本身就是扩展命令,所以直接发 `/qq reload`、`/qq quit`、`/qq new`
13
+ 会落进 `routeSlash` 的 passthrough 分支,既不查 `commands.allowReload` / `allowQuit` / `allowUpdate` /
14
+ `allowSession`,也不查忙碌状态(`needsIdle`)——默认配置(`allowQuit: false`)下 QQ 侧发 `/qq quit`
15
+ 就能把主机 pi 关掉。现在 `/qq` 在 passthrough **之前**分流:`bridgeGate()` 把子命令翻回内置命令名
16
+ (多词 `list tree` / `list fork` / `list resume` 先整体匹配),复用同一套 `allowed()` / `needsIdle` 判定。
17
+ - **派发回执吞掉参数**:`/qq reload` 过去回「已派发 /qq 给 pi。」——`parseSlash` 只取首段当命令名,
18
+ 看起来像 `reload` 没被受理。现在回执与日志都显示完整命令(超 80 字截断加 `…`)。
19
+
20
+ ### Changed
21
+
22
+ - 自毁类桥接子命令(`reload` / `new` / `quit` / `clone`)改为**先回执再派发**,避免回执和
23
+ `ctx.reload()` / `newSession()` 抢时序。
24
+ - `/qq sendfile …`、`/qq usage …`、`/qq pick …` 以及未知子命令不再额外发一条通用回执(子命令自己会回),
25
+ 减少一次无意义的消息。
26
+ - 顺手清掉 `slash.ts` 里一处历史补丁残留的空行 `+`(`+reply(formatPicker(picker))`,语义上无害但脏)。
27
+
28
+ ### Compatibility
29
+
30
+ - 发 `/reload`、`/new`、`/quit`、`/compact` 等原命令的行为与门禁不变;只是 `/qq <sub>` 现在受同样约束。
31
+ - 无需改配置:原本 `allowReload: true` 的部署照旧可用;`allowQuit: false`(默认)下 `/qq quit` 变成回执拒绝。
32
+
33
+ ### Tests
34
+
35
+ - 新增 `test/qq-bridge-gate.test.mjs`(21 条断言):子命令映射、三种门禁拒绝、忙碌拒绝、
36
+ 先回执后派发顺序、多词 sub、回执截断、`/reload` 未回归。
37
+ - 全套 274 条断言 / 22 个测试文件通过;`tsc --noEmit --strict` 对改动文件 `slash.ts` 无报错。
38
+
39
+ ## [2.6.1] - 2026-11-01
40
+
41
+ ### Changed
42
+
43
+ - **changelog 分语种**:中文版更名 `CHANGELOG.zh-CN.md`,新增英文版 `CHANGELOG.en.md`(含全部历史版本);
44
+ README.md(英文)引用 `CHANGELOG.en.md`,docs/README.zh-CN.md 引用 `CHANGELOG.zh-CN.md`。
45
+
6
46
  ## [2.6.0] - 2026-11-01
7
47
 
8
48
  ### Added
package/README.md CHANGED
@@ -302,6 +302,15 @@ Guards:
302
302
  else (or 「取消」) abandons it. A number without a pending list is ordinary chat.
303
303
  - `/reload` is off by default: it restarts the bot, and if the new code fails to load the bot stays
304
304
  down until someone fixes it on the host. Turn it on with `commands.allowReload: true`.
305
+ - Sending the bridge command yourself (`/qq reload`, `/qq quit`, `/qq new`, `/qq list fork`, …) hits the
306
+ **same** gates: the sub-command is mapped back to its built-in name first, so `commands.allowReload` /
307
+ `allowQuit` / `allowUpdate` / `allowSession` and the busy check all apply. Routing it as a plain
308
+ extension command would have bypassed every gate — with `allowQuit: false`, `/qq quit` could shut the
309
+ host pi down. Destructive sub-commands (`reload`, `new`, `quit`, `clone`) answer *before* they are
310
+ dispatched, because they replace the runtime they would answer through.
311
+ - Dispatch receipts echo the **whole** command (`已派发 /deploy prod 给 pi。`) instead of only the first
312
+ word, truncated at 80 characters with `…`. Previously `/qq reload` was answered with
313
+ `已派发 /qq 给 pi。`, which looked like the argument had been dropped.
305
314
  - Why the indirection: pi only dispatches **extension** commands from a message
306
315
  (`prompt()` → `_tryExecuteExtensionCommand`), and `reload()` / `newSession()` are command-context
307
316
  APIs. So this extension registers `/qq` and forwards `/reload` → `/qq reload`. Built-in names are
@@ -525,7 +534,7 @@ Debug logs are written to `~/.pi/agent/pi-qqbot-<name>.log` (one file per bot, s
525
534
 
526
535
  ## Changelog
527
536
 
528
- See [CHANGELOG.md](./CHANGELOG.md) — every release with what changed and why. The progress-report
537
+ See [CHANGELOG.en.md](./CHANGELOG.en.md) — every release with what changed and why. (中文:[CHANGELOG.zh-CN.md](./CHANGELOG.zh-CN.md)) The progress-report
529
538
  pause/resume (`/progress off` / `on`) landed in **1.5.0**; multi-bot auto-claiming in **2.0.0**;
530
539
  receiving QQ images/files (saved to temp files and handed to pi as `图片路径 <path>`) in **2.1.0**;
531
540
  sending local files to QQ (`qq_send_file` / `/sendfile`, chunked rich-media upload) in **2.2.0**.
@@ -281,6 +281,13 @@ custom entry 类型名,以及它写在 `~/.pi/agent/` 下的两个配置文件
281
281
  该用户下一条「纯数字」消息即执行选择,其它内容(或「取消」)则放弃。没有待选列表时,数字就是普通对话。
282
282
  - `/reload` 默认关闭:它会重启 bot,如果新代码加载失败,bot 会一直哑着,只能在主机上救。
283
283
  置 `commands.allowReload: true` 才启用。
284
+ - 直接发桥接命令(`/qq reload`、`/qq quit`、`/qq new`、`/qq list fork` …)走**同一套门禁**:子命令会先
285
+ 翻回对应的内置命令名,所以 `commands.allowReload` / `allowQuit` / `allowUpdate` / `allowSession`
286
+ 与忙碌检查全部生效。若按普通扩展命令派发(2.6.1 之前的行为),这些门禁会全被绕过 ——
287
+ `allowQuit: false` 时发 `/qq quit` 也能关掉主机 pi。自毁类子命令(`reload`、`new`、`quit`、`clone`)
288
+ 会**先回执再派发**,因为动作会换掉它自己用来回执的运行时。
289
+ - 派发回执显示**完整命令**(`已派发 /deploy prod 给 pi。`),超 80 字截断加 `…`;不再只显示首段,
290
+ 把参数吞掉(旧回执是 `已派发 /qq 给 pi。`,看起来像 `/qq reload` 的 `reload` 没生效)。
284
291
  - 为什么要绕一层:pi 只对**扩展命令**执行派发(`prompt()` → `_tryExecuteExtensionCommand`),而
285
292
  `reload()` / `newSession()` 又只能在命令上下文里调用。于是本扩展注册了 `/qq`,把 `/reload`
286
293
  改派成 `/qq reload`。内置命令名是硬编码的(pi 没有把内置清单导出给扩展);任何不认识的
@@ -484,7 +491,7 @@ QQ 网关对同一 bot 只保留一个活跃连接。插件用 `proper-lockfile`
484
491
 
485
492
  ## 变更日志
486
493
 
487
- 见 [CHANGELOG.md](../CHANGELOG.md):每个版本改了什么、为什么改。进度汇报的暂停/恢复(`/progress off` / `on`)在 **1.5.0** 加入;多 bot 自动抢占在 **2.0.0**;接收 QQ 图片/文件(落盘后以 `图片路径 <path>` 这类标记行交给 pi)在 **2.1.0**;把本机文件发到 QQ(`qq_send_file` / `/sendfile`,富媒体分片上传)在 **2.2.0**。
494
+ 见 [CHANGELOG.zh-CN.md](../CHANGELOG.zh-CN.md):每个版本改了什么、为什么改。(English: [CHANGELOG.en.md](../CHANGELOG.en.md))进度汇报的暂停/恢复(`/progress off` / `on`)在 **1.5.0** 加入;多 bot 自动抢占在 **2.0.0**;接收 QQ 图片/文件(落盘后以 `图片路径 <path>` 这类标记行交给 pi)在 **2.1.0**;把本机文件发到 QQ(`qq_send_file` / `/sendfile`,富媒体分片上传)在 **2.2.0**。
488
495
 
489
496
  ## License
490
497
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxh126/pi-qqbot",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
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,14 +27,15 @@
27
27
  ]
28
28
  },
29
29
  "scripts": {
30
- "test": "node test/queue.test.mjs && node test/approver.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/bang-notice.test.mjs && node test/bridge-stale-ctx.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs && node test/config.test.mjs && node test/remote-notice.test.mjs && node test/lock.test.mjs && node test/accounts.test.mjs && node test/auth.test.mjs && node test/attachment.test.mjs && node test/upload.test.mjs && node test/error-notice.test.mjs && node test/billing.test.mjs && node test/credential-intake.test.mjs && node test/selfupdate.test.mjs && node test/chunk.test.mjs && node test/interactions.test.mjs"
30
+ "test": "node test/queue.test.mjs && node test/approver.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/bang-notice.test.mjs && node test/bridge-stale-ctx.test.mjs && node test/qq-bridge-gate.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs && node test/config.test.mjs && node test/remote-notice.test.mjs && node test/lock.test.mjs && node test/accounts.test.mjs && node test/auth.test.mjs && node test/attachment.test.mjs && node test/upload.test.mjs && node test/error-notice.test.mjs && node test/billing.test.mjs && node test/credential-intake.test.mjs && node test/selfupdate.test.mjs && node test/chunk.test.mjs && node test/interactions.test.mjs"
31
31
  },
32
32
  "files": [
33
33
  "*.ts",
34
34
  "qq",
35
35
  "util",
36
36
  "README.md",
37
- "CHANGELOG.md",
37
+ "CHANGELOG.en.md",
38
+ "CHANGELOG.zh-CN.md",
38
39
  "docs",
39
40
  "LICENSE"
40
41
  ],
package/slash.ts CHANGED
@@ -133,6 +133,33 @@ update: { sub: "update", needsIdle: true, hint: "在线更新 pi-qqbot 并重载
133
133
  sendfile: { sub: "sendfile", needsArgs: true, hint: "把本机文件发送到你的 QQ" },
134
134
  };
135
135
 
136
+ /**
137
+ * `/qq <sub> [args]` → 对应的 TAKEOVER 条目。
138
+ *
139
+ * `/qq` 自己也是**扩展命令**,所以直接发 `/qq reload` 会先命中 passthrough 分支:既不查
140
+ * `commands.allow*`,也不查 `needsIdle`(等于绕过 `/reload`、`/quit` 的门禁),回执还会
141
+ * 显示成「已派发 /qq 给 pi。」把参数吞掉。路由前先用这张表把子命令翻回内置命令名,
142
+ * 复用同一套判定。多词 sub(`list tree` / `list fork` / `list resume`)先整体匹配,再退回首词。
143
+ */
144
+ export function bridgeGate(args: string): { name: string; takeover: Takeover } | undefined {
145
+ const lower = args.trim().toLowerCase();
146
+ if (!lower) return undefined;
147
+ const entries = Object.entries(TAKEOVER) as [string, Takeover][];
148
+ const exact = entries.find(([, item]) => item.sub.toLowerCase() === lower);
149
+ if (exact) return { name: exact[0], takeover: exact[1] };
150
+ const first = lower.split(/\s+/)[0]!;
151
+ const byFirst = entries.find(([, item]) => item.sub.split(/\s+/)[0]!.toLowerCase() === first);
152
+ return byFirst ? { name: byFirst[0], takeover: byFirst[1] } : undefined;
153
+ }
154
+
155
+ /** 命令被禁用时该提示改哪个配置项 */
156
+ export function gateConfigKey(name: string): string {
157
+ if (name === "reload") return "allowReload";
158
+ if (name === "quit") return "allowQuit";
159
+ if (name === "update") return "allowUpdate";
160
+ return "allowSession";
161
+ }
162
+
136
163
  /**
137
164
  * `/qq <sub>` 里会**换掉运行时**的动作。
138
165
  *
@@ -235,6 +262,11 @@ const allowed = (cfg: CommandConfig, name: string): boolean => {
235
262
  return cfg.allowSession;
236
263
  };
237
264
 
265
+ /** 回执/日志里展示命令原文:太长就截断,别把 QQ 消息撑爆,也别只显示首段把参数吞掉 */
266
+ function previewCommand(raw: string, max = 80): string {
267
+ return raw.length > max ? `${raw.slice(0, max)}…` : raw;
268
+ }
269
+
238
270
  /** Route one slash message. Returns "text" when it should be treated as chat. */
239
271
  export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteResult> {
240
272
  // 0. `!` / `!!`:主机前端的 shell 快捷方式,扩展 API 够不到 → 明确回执,别当聊天丢给模型
@@ -249,6 +281,36 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
249
281
  if (!parsed) return "text";
250
282
  const { name } = parsed;
251
283
 
284
+ // 0.5 `/qq …`:桥接命令自身是扩展命令,落到下面的 passthrough 会**绕开** `commands.allow*`
285
+ // 与 `needsIdle`(默认配置下 QQ 侧发 `/qq quit` 就能关掉主机),且通用回执只显示 `/qq`。
286
+ // 这里提前分流:按内置命令的门禁判定,自毁类**先回执再派发**(动作会换掉运行时)。
287
+ if (name === "qq") {
288
+ const shown = previewCommand(parsed.raw);
289
+ const gate = bridgeGate(parsed.args);
290
+ if (!gate) {
291
+ deps.log("[cmd] 派发 /qq 桥接命令:", shown);
292
+ deps.dispatch(parsed.raw);
293
+ return "dispatched";
294
+ }
295
+ const builtin = gate.name;
296
+ const takeover = gate.takeover;
297
+ if (!allowed(deps.cfg, builtin)) {
298
+ deps.log("[cmd] 拒绝 /qq 子命令:", shown, "配置 commands." + gateConfigKey(builtin));
299
+ await deps.reply(`/${builtin} 在 QQ 侧被禁用(配置 commands.${gateConfigKey(builtin)})。`);
300
+ return "handled";
301
+ }
302
+ if (takeover.needsIdle && deps.busy) {
303
+ await deps.reply(`当前有任务在跑,先发 /cancel 取消,再执行 /${builtin}。`);
304
+ return "handled";
305
+ }
306
+ deps.log("[cmd] 派发 /qq 桥接命令:", shown, "gate=", builtin);
307
+ if (takeover.selfDestroying) {
308
+ await deps.reply(`已请求 /${builtin}(${takeover.hint})…`);
309
+ }
310
+ deps.dispatch(parsed.raw);
311
+ return "dispatched";
312
+ }
313
+
252
314
  // 0. 输出只走 TUI 通道的扩展命令:桥接或明确回执(必须早于 passthrough)
253
315
  const uiOnly = UI_ONLY[name];
254
316
  if (uiOnly && deps.listPiCommands().some((c) => c.name.toLowerCase() === name)) {
@@ -275,7 +337,7 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
275
337
  } else if (result === "rejected") {
276
338
  await deps.reply(`队列已满,/${name} 没有排上。发 /queue 查看队列,或 /cancel 清空后重发。`);
277
339
  } else {
278
- await deps.reply(`已派发 /${name} 给 pi。`);
340
+ await deps.reply(`已派发 ${previewCommand(parsed.raw)} 给 pi。`);
279
341
  }
280
342
  return "dispatched";
281
343
  }
@@ -285,8 +347,7 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
285
347
  const takeover = TAKEOVER[name];
286
348
  if (takeover) {
287
349
  if (!allowed(deps.cfg, name)) {
288
- const gate = name === "reload" ? "allowReload" : name === "quit" ? "allowQuit" : "allowSession";
289
- await deps.reply(`/${name} 在 QQ 侧被禁用(配置 commands.${gate})。`);
350
+ await deps.reply(`/${name} 在 QQ 侧被禁用(配置 commands.${gateConfigKey(name)})。`);
290
351
  return "handled";
291
352
  }
292
353
  if (takeover.needsIdle && deps.busy) {
@@ -719,7 +780,7 @@ export async function runBridge(
719
780
  return;
720
781
  }
721
782
  const picker: PickerState = { kind, items, openid: store?.pickerOwner ?? null, createdAt: Date.now() };
722
- +
783
+
723
784
  reply(formatPicker(picker));
724
785
  return;
725
786
  }