@zxh126/pi-qqbot 2.0.0 → 2.2.1
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 +104 -0
- package/README.md +103 -3
- package/approver.ts +33 -12
- package/attachment.ts +502 -0
- package/config.ts +53 -0
- package/constants.ts +24 -0
- package/docs/README.zh-CN.md +95 -3
- package/index.ts +303 -38
- package/package.json +12 -4
- package/qq/api.ts +26 -0
- package/qq/gateway.ts +41 -11
- package/qq/upload.ts +288 -0
- package/slash.ts +17 -1
package/docs/README.zh-CN.md
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
| **进度汇报** | 任务运行期间按 `progress.intervalMs`(默认 **15 秒**)推一条进度摘要(轮次、工具调用、正在跑的工具、token 用量)。随时发 `/progress`(别名 `/p`、「进度」)可立即获取;`/progress off` 在本会话内暂停自动汇报,`/progress on` 恢复。推送失败后自动推送静默,直到你再次问询。 |
|
|
21
21
|
| **敏感信息脱敏** | 离开本机的文本先脱敏——进度摘要、审批提示、队列预览、日志文件:`Authorization` 头(含 QQ bot token)、`sk-…`/`ghp_…`/`npm_…`/`AKIA…`/JWT/PEM、`token=`/`password=`/`--api-key` 的值、URL 里的 `user:pass@`,以及超长高熵串。 |
|
|
22
22
|
| **多 bot 同机** | 一份配置里写多个 QQ bot(`qqbots: [...]`,详见 [accounts.md](./accounts.md)),同一台机器上每个 pi 进程按配置顺序接入第一个未上线的 bot;全部在线则待命、每 5 秒重扫,有人退出就接管。别人只是 `/reload` 临时释放的 bot 不会被抢(留 20 秒预约窗口)。接入/接管时会告知当前 bot 名称与工作目录。 |
|
|
23
|
-
|
|
|
23
|
+
| **图片与文件** | QQ 里发来的附件会先下载到临时文件,再以标记行交给 pi——图片是 `图片路径 <path>`,其余按类型是 `语音路径` / `视频路径` / `文档路径` / `压缩包路径` / `文件路径`;QQ 侧收到一条回执(数量、类型、路径)。pi 的 `read` 工具能直接看图片,`bash` 能处理文件。 |
|
|
24
|
+
| **把本机文件发到 QQ** | 让 agent 把机器上的文件发给你(「把 D:\docs\报告.pdf 发给我」)或发 `/sendfile <路径>`;机器人按 5MB 分片上传(QQ 富媒体),图片(png/jpg) / 视频(mp4) / 语音(silk) 直接展示,其余变成可下载的文件卡片。**外发必须先在 QQ 里审批。** |
|
|
25
|
+
| **内置指令** | `/status`、`/queue`、`/progress`、`/reset`、`/cancel`、`/sendfile`、`/help`。 |
|
|
24
26
|
|
|
25
27
|
## 安装
|
|
26
28
|
|
|
@@ -50,6 +52,18 @@ pi install ./pi-qqbot
|
|
|
50
52
|
"queue": {
|
|
51
53
|
"max": 20,
|
|
52
54
|
"ack": true
|
|
55
|
+
},
|
|
56
|
+
"attachments": {
|
|
57
|
+
"enabled": true,
|
|
58
|
+
"dir": "",
|
|
59
|
+
"maxBytes": 26214400,
|
|
60
|
+
"maxPerMessage": 10,
|
|
61
|
+
"retentionHours": 24,
|
|
62
|
+
"maxFiles": 200
|
|
63
|
+
},
|
|
64
|
+
"files": {
|
|
65
|
+
"enabled": true,
|
|
66
|
+
"maxBytes": 31457280
|
|
53
67
|
}
|
|
54
68
|
}
|
|
55
69
|
```
|
|
@@ -68,6 +82,14 @@ pi install ./pi-qqbot
|
|
|
68
82
|
| `queue.ack` | `true` | 排队消息开始执行时是否回执 QQ(入队回执总是会发) |
|
|
69
83
|
| `progress.enabled` | `true` | 任务运行期间是否定期推送进度摘要(会话启动时读取,改完需 `/new` 或重启;运行时用 `/progress off`) |
|
|
70
84
|
| `progress.intervalMs` | `15000` | 推送间隔(可用环境变量 `PI_QQBOT_PROGRESS_MS` 覆盖,便于测试) |
|
|
85
|
+
| `attachments.enabled` | `true` | 是否把附件下载到临时文件并把路径交给 pi;`false` = 只回一句忽略提示、只处理文字 |
|
|
86
|
+
| `attachments.dir` | `""` | 落盘目录;空 = 系统临时文件夹下的 `pi-qqbot/<bot>`(Windows:`%LOCALAPPDATA%\Temp\pi-qqbot\…`) |
|
|
87
|
+
| `attachments.maxBytes` | `26214400` | 单个附件上限(25 MB),超出的记为下载失败、不落盘 |
|
|
88
|
+
| `attachments.maxPerMessage` | `10` | 单条消息最多处理几个附件 |
|
|
89
|
+
| `attachments.retentionHours` | `24` | 超过此时长的临时文件在会话启动时清理 |
|
|
90
|
+
| `attachments.maxFiles` | `200` | 目录内最多保留多少个文件(超出删最旧) |
|
|
91
|
+
| `files.enabled` | `true` | 是否允许把本机文件发到 QQ(工具与 `/sendfile` 共用);`false` 则拒绕 |
|
|
92
|
+
| `files.maxBytes` | `31457280` | 发送单个文件的上限(30MB;QQ 硬限 200MB) |
|
|
71
93
|
|
|
72
94
|
也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件里的凭据(仅单 bot 场景)。
|
|
73
95
|
|
|
@@ -98,9 +120,67 @@ pi install ./pi-qqbot
|
|
|
98
120
|
| `/progress` | **立即获取当前进度摘要**(别名 `/p`、「进度」「当前进度」「进展」;忙碌时「到哪了」「怎么样了」这类问法同样生效)。手动暂停期间**问一次只回一次**,要重新打开定时汇报需发 `/progress on` 或「恢复汇报」;若只是配额静默,问一次即恢复 |
|
|
99
121
|
| `/progress off` | **在本会话内暂停自动进度汇报**(别名:`pause`、`stop`、`暂停`、「暂停汇报」、「关闭汇报」)。手动 `/progress` 仍可看一次,但不再解除暂停 |
|
|
100
122
|
| `/progress on` | **恢复自动进度汇报**(别名:`resume`、`start`、`恢复`、「恢复汇报」、「继续汇报」) |
|
|
101
|
-
| `/cancel` | **中断当前正在运行的任务**并清空队列(别名:`/stop`、`/abort
|
|
123
|
+
| `/cancel` | **中断当前正在运行的任务**并清空队列(别名:`/stop`、`/abort`、「取消」、「停止」、「中止」、「终止」、「算了」、「停」、「停下」、「停一下」、「别跑了」)。注意:单说「停」是中断**任务**,要关进度推送请说「别汇报了」「闭嘴」或 `/progress off` |
|
|
102
124
|
| `/help` | 帮助 |
|
|
103
125
|
|
|
126
|
+
### QQ 里发来的图片与文件
|
|
127
|
+
|
|
128
|
+
发一张图(或文档、压缩包、语音/视频),机器人会把它下载到临时文件,然后给 pi 一行路径:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
图片路径 C:\Users\you\AppData\Local\Temp\pi-qqbot\bot-340143\2026-02-11\msg-abc-1-103045.jpg
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
| 附件类型 | 交给 pi 的行 |
|
|
135
|
+
|---|---|
|
|
136
|
+
| 图片 | `图片路径 <path>` |
|
|
137
|
+
| 语音 / 音频 | `语音路径 <path>` |
|
|
138
|
+
| 视频 | `视频路径 <path>` |
|
|
139
|
+
| 文档(pdf、office、文本、代码…) | `文档路径 <path>` |
|
|
140
|
+
| 压缩包(zip、rar、7z、tar…) | `压缩包路径 <path>` |
|
|
141
|
+
| 其它 | `文件路径 <path>` |
|
|
142
|
+
|
|
143
|
+
你自己的文字保持在第一行:发「看下这个」+ 一张图,送给 pi 的就是
|
|
144
|
+
`看下这个\n图片路径 C:\…\a.jpg`——同一轮,路径已在里面。QQ 侧还会收到回执,例如
|
|
145
|
+
`已收到 2 个附件(图片 ×1、文档 ×1),已存为临时文件并交给 pi:` 后跟各条路径;下载失败的附件会列出原因,
|
|
146
|
+
文字部分照常执行。
|
|
147
|
+
|
|
148
|
+
注意:
|
|
149
|
+
|
|
150
|
+
- QQ 的下载地址带短期有效的 `rkey`,所以收到消息的当下就下载。
|
|
151
|
+
- 带附件的消息会**绕过**斜杠命令、审批解析与编号选择,一律当作普通内容交给 pi(附带 `/status` 不会被执行)。
|
|
152
|
+
- 临时文件由 `attachments.retentionHours` / `attachments.maxFiles` 在会话启动时清理;交给 pi 的路径在当前会话(通常一天内)都可读。
|
|
153
|
+
- 落盘文件名只保留 `[0-9a-zA-Z._-]`,并以 QQ 消息 id 作为前缀。
|
|
154
|
+
|
|
155
|
+
### 把本机文件发到 QQ
|
|
156
|
+
|
|
157
|
+
两种触发方式:
|
|
158
|
+
|
|
159
|
+
| 触发 | 用法 |
|
|
160
|
+
|---|---|
|
|
161
|
+
| 自然语言 | 「把 D:\docs\报告.pdf 发给我」——agent 会调 `qq_send_file` 工具 |
|
|
162
|
+
| 斜杠命令 | `/sendfile D:\docs\报告.pdf`(路径带空格就加引号;不开 run,直接执行) |
|
|
163
|
+
|
|
164
|
+
**审批是硬性的,而且每次都问**:`qq_send_file` 无论 `approve.tools` 怎么配都强制走审批,并且**不吃**会话级
|
|
165
|
+
「以后都同意」、也不吃「同意后记住」,因为外发文件等于数据出境,每个文件单独确认。提示里会写明:
|
|
166
|
+
`[审批 #1] 发送文件到 QQ: D:\…` + 「需单独确认」。
|
|
167
|
+
|
|
168
|
+
发送形态(QQ 富媒体分片上传):
|
|
169
|
+
|
|
170
|
+
| 文件 | 对方看到 |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `.png` / `.jpg` / `.jpeg` | 图片直接展示 |
|
|
173
|
+
| `.mp4` | 视频直接播放 |
|
|
174
|
+
| `.silk` | 语音条 |
|
|
175
|
+
| 其他(pdf / zip / mp3 / epub / exe…) | 可下载的文件卡片 |
|
|
176
|
+
|
|
177
|
+
注:
|
|
178
|
+
|
|
179
|
+
- 必须走分片上传(`upload_prepare` → `PUT` → `upload_part_finish` → 合并):URL 上传要求公网可访问,二进制直传只有 5MB 上限。
|
|
180
|
+
- 用被动回复(`srv_send_msg: false` + `msg_type: 7` 带 `msg_id`/`msg_seq`),不烧主动消息额度。
|
|
181
|
+
- QQ 侧还有 **200MB 硬上限**和**每天发送文件总容量上限**(错误码 `40093002`),都翻成中文回执。
|
|
182
|
+
- 成功后会回一行:`已发送「报告.pdf」(2.31 MB,文件卡片)`。
|
|
183
|
+
|
|
104
184
|
### QQ 里的斜杠命令
|
|
105
185
|
|
|
106
186
|
任何以 `/` 开头的消息都会先走路由,再决定是否当成对话:
|
|
@@ -213,6 +293,18 @@ pi 的会话一次只跑一轮 agent run,所以串行调度由 bot 自己做
|
|
|
213
293
|
- `agent_settled` / `/cancel` / 会话关闭时定时器都会清掉。
|
|
214
294
|
- 想彻底关掉(跨会话):`progress.enabled: false`(改完需 `/new` 或重启才生效)。
|
|
215
295
|
|
|
296
|
+
#### 口语说法(整句匹配,只在发整句时生效)
|
|
297
|
+
|
|
298
|
+
| 意图 | 可以说 |
|
|
299
|
+
|---|---|
|
|
300
|
+
| 暂停自动汇报 | 「暂停汇报进度」「暂停进度汇报」「别汇报了」「别说了」「闭嘴」「安静」「打住」「停止汇报」,或 `/progress off` |
|
|
301
|
+
| 恢复自动汇报 | 「恢复汇报」「恢复汇报进度」「接着说」「说下去」,或 `/progress on` |
|
|
302
|
+
| 看一次当前进度 | 「汇报一下当前进度」「看一下进度」「说说进度」「进度如何」「进度怎么样」,或 `/progress` |
|
|
303
|
+
| 中断当前任务 | 「停」「停下」「停一下」「停停」「停止」「取消」「别跑了」,或 `/cancel` |
|
|
304
|
+
|
|
305
|
+
单说「停」是中断**任务**;只有带名词的(「停止汇报」)或明确的闭嘴词(「别说了」「闭嘴」)才是暂停汇报。
|
|
306
|
+
「帮我加个进度条」这种任务句仍照常转给 pi,不会被当成口令。
|
|
307
|
+
|
|
216
308
|
### 取消正在运行的任务
|
|
217
309
|
|
|
218
310
|
从 QQ 发起的耗时任务(装依赖、编译、agent 跑飞了)不必回到机器上处理:
|
|
@@ -278,7 +370,7 @@ QQ 网关对同一 bot 只保留一个活跃连接。插件用 `proper-lockfile`
|
|
|
278
370
|
|
|
279
371
|
## 变更日志
|
|
280
372
|
|
|
281
|
-
见 [CHANGELOG.md](../CHANGELOG.md):每个版本改了什么、为什么改。进度汇报的暂停/恢复(`/progress off` / `on`)在 **1.5.0** 加入;多 bot 自动抢占在 **2.0.0**。
|
|
373
|
+
见 [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**。
|
|
282
374
|
|
|
283
375
|
## License
|
|
284
376
|
|
package/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
2
4
|
import { loadAccounts, pinnedAccountName, type QQBotAccount, type QQBotConfig } from "./config";
|
|
3
5
|
import {
|
|
4
6
|
REST_BASE_PROD,
|
|
@@ -8,7 +10,26 @@ import {
|
|
|
8
10
|
APPROVAL_TIMEOUT_MS,
|
|
9
11
|
DISPATCH_RETRY_MS,
|
|
10
12
|
CANCEL_SETTLE_GRACE_MS,
|
|
13
|
+
DEFAULT_ATTACHMENT_MAX_BYTES,
|
|
14
|
+
DEFAULT_ATTACHMENT_MAX_FILES,
|
|
15
|
+
DEFAULT_ATTACHMENT_MAX_PER_MESSAGE,
|
|
16
|
+
DEFAULT_ATTACHMENT_RETENTION_HOURS,
|
|
17
|
+
DEFAULT_SEND_MAX_BYTES,
|
|
11
18
|
} from "./constants";
|
|
19
|
+
import {
|
|
20
|
+
defaultAttachmentDir,
|
|
21
|
+
renderAttachmentAck,
|
|
22
|
+
renderAttachmentFailureLines,
|
|
23
|
+
renderAttachmentLines,
|
|
24
|
+
saveIncoming,
|
|
25
|
+
sweepAttachmentDir,
|
|
26
|
+
type AttachmentConfig,
|
|
27
|
+
} from "./attachment";
|
|
28
|
+
import {
|
|
29
|
+
explainUploadError,
|
|
30
|
+
mediaKindLabel,
|
|
31
|
+
uploadRichMedia,
|
|
32
|
+
} from "./qq/upload";
|
|
12
33
|
import { log, configureLog } from "./logger";
|
|
13
34
|
import { claimAny, startClaimRetry, reserveBeforeRelease, readOwner, type Claim } from "./lock";
|
|
14
35
|
import { AuthManager } from "./qq/auth";
|
|
@@ -22,6 +43,11 @@ import { redactSecrets } from "./redact";
|
|
|
22
43
|
import { ReplyBuffer } from "./stream-buffer";
|
|
23
44
|
import { splitText } from "./util/chunk";
|
|
24
45
|
|
|
46
|
+
/** 发送本机文件的工具名 */
|
|
47
|
+
const QQ_SEND_FILE_TOOL = "qq_send_file";
|
|
48
|
+
/** 外发文件必须审批:不受配置 approve.tools 影响,也不吃「以后都同意」/会话内记忆 */
|
|
49
|
+
const FORCE_APPROVAL_TOOLS = new Set<string>([QQ_SEND_FILE_TOOL]);
|
|
50
|
+
|
|
25
51
|
interface State {
|
|
26
52
|
cfg: QQBotConfig | null;
|
|
27
53
|
/** 本进程当前服务的 QQ bot(自动抢占的结果);未接入则为 null */
|
|
@@ -69,6 +95,12 @@ interface State {
|
|
|
69
95
|
progressMuted: boolean;
|
|
70
96
|
/** 最终答案发送失败时缓存,下一条消息时补发 */
|
|
71
97
|
undelivered: { openid: string; text: string } | null;
|
|
98
|
+
/** 附件下载 401/403 时重试用的凭据(接入后由 applyClaim 注入) */
|
|
99
|
+
getToken: (() => Promise<string>) | null;
|
|
100
|
+
/** 仅测试用:替换附件落盘时的 fetch */
|
|
101
|
+
attachmentFetch: typeof fetch | null;
|
|
102
|
+
/** 当前 bot 的 REST 基址(发文件用) */
|
|
103
|
+
restBase: string | null;
|
|
72
104
|
}
|
|
73
105
|
|
|
74
106
|
/** 扩展运行所需的状态(导出只为测试用) */
|
|
@@ -99,6 +131,9 @@ export function createInitialState(): State {
|
|
|
99
131
|
progressPaused: false,
|
|
100
132
|
progressMuted: false,
|
|
101
133
|
undelivered: null,
|
|
134
|
+
getToken: null,
|
|
135
|
+
attachmentFetch: null,
|
|
136
|
+
restBase: null,
|
|
102
137
|
account: null,
|
|
103
138
|
candidates: [],
|
|
104
139
|
claimNote: null,
|
|
@@ -201,24 +236,28 @@ export default function (
|
|
|
201
236
|
const toolName: string = ev.toolName ?? "tool";
|
|
202
237
|
const cfg = state.cfg;
|
|
203
238
|
|
|
204
|
-
// 判断是否需要审批:配置的 tools 列表 + destructiveHint 兑底
|
|
239
|
+
// 判断是否需要审批:配置的 tools 列表 + destructiveHint 兑底 + 强制审批工具(外发文件)
|
|
205
240
|
const allTools = (pi.getAllTools() ?? []) as any[];
|
|
206
241
|
const toolMeta = allTools.find((t) => t.name === toolName);
|
|
207
242
|
const destructive = toolMeta?.annotations?.destructiveHint === true;
|
|
208
|
-
const
|
|
243
|
+
const forced = FORCE_APPROVAL_TOOLS.has(toolName);
|
|
244
|
+
const needsApproval = forced || cfg.approve.tools.includes(toolName) || destructive;
|
|
209
245
|
if (!needsApproval) return; // 放行
|
|
210
246
|
|
|
211
|
-
//
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
247
|
+
// 强制审批的工具(发送文件)每次都问:不吃「以后都同意」,也不吃会话内记住的决策
|
|
248
|
+
if (!forced) {
|
|
249
|
+
// 会话级自动同意(用户回复过「以后都同意」):本会话内直接放行,不落盘、不跨会话
|
|
250
|
+
if (state.approver.autoAllowed) {
|
|
251
|
+
log("[approve] 自动同意放行:", toolName);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
216
254
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
255
|
+
// 会话内已记住的决策
|
|
256
|
+
const remembered = cfg.approve.remember ? state.approver.isRemembered(toolName) : undefined;
|
|
257
|
+
if (remembered === "allow") return; // 已记住放行
|
|
258
|
+
if (remembered === "block") return { block: true, reason: `QQ 用户已拒绝 ${toolName}` };
|
|
259
|
+
if (remembered === "terminate") return { block: true, terminate: true, reason: "已停止" };
|
|
260
|
+
}
|
|
222
261
|
|
|
223
262
|
const summary = summarizeTool(ev);
|
|
224
263
|
log("[approve] 工具需要审批:", summary);
|
|
@@ -226,11 +265,13 @@ export default function (
|
|
|
226
265
|
// 超时通知
|
|
227
266
|
state.api?.sendC2C(openid, `审批 #${seq} 已超时,已自动拒绝。`).catch(() => {});
|
|
228
267
|
});
|
|
229
|
-
await state.api
|
|
268
|
+
await state.api
|
|
269
|
+
.sendC2C(openid, state.approver.render(seq, { strict: forced }))
|
|
270
|
+
.catch(() => {});
|
|
230
271
|
const decision = await done;
|
|
231
272
|
|
|
232
273
|
if (decision === "allow") {
|
|
233
|
-
if (cfg.approve.remember) state.approver.remember(toolName, "allow");
|
|
274
|
+
if (!forced && cfg.approve.remember) state.approver.remember(toolName, "allow");
|
|
234
275
|
log("[approve] 用户批准 #", seq);
|
|
235
276
|
return;
|
|
236
277
|
}
|
|
@@ -244,7 +285,7 @@ export default function (
|
|
|
244
285
|
return { block: true, reason: `QQ 审批超时(#${seq}):${summary}` };
|
|
245
286
|
}
|
|
246
287
|
// block(用户显式拒绝)
|
|
247
|
-
if (cfg.approve.remember) state.approver.remember(toolName, "block");
|
|
288
|
+
if (!forced && cfg.approve.remember) state.approver.remember(toolName, "block");
|
|
248
289
|
log("[approve] 用户拒绝 #", seq);
|
|
249
290
|
return { block: true, reason: `QQ 用户拒绝了 ${summary}` };
|
|
250
291
|
});
|
|
@@ -338,6 +379,22 @@ export default function (
|
|
|
338
379
|
const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
|
|
339
380
|
const api = new QQApi({ restBase, getToken: () => auth.getToken() });
|
|
340
381
|
state.api = api;
|
|
382
|
+
state.getToken = () => auth.getToken();
|
|
383
|
+
state.restBase = restBase;
|
|
384
|
+
|
|
385
|
+
// 顺手清掉上一轮留下的临时附件(失败不影响启动)
|
|
386
|
+
const attCfg = attachmentConfig(cfg);
|
|
387
|
+
if (attCfg.enabled) {
|
|
388
|
+
const attDir = attCfg.dir || defaultAttachmentDir(cfg.appId);
|
|
389
|
+
void sweepAttachmentDir(attDir, {
|
|
390
|
+
retentionHours: attCfg.retentionHours,
|
|
391
|
+
maxFiles: attCfg.maxFiles,
|
|
392
|
+
})
|
|
393
|
+
.then((r) => {
|
|
394
|
+
if (r.removed > 0) log("[attachment] 清理临时附件", r.removed, "个", `(dir=${attDir})`);
|
|
395
|
+
})
|
|
396
|
+
.catch((e) => log("[attachment] 清理失败:", String(e).slice(0, 160)));
|
|
397
|
+
}
|
|
341
398
|
|
|
342
399
|
if (options?.disableGateway) {
|
|
343
400
|
log("[setup] 测试模式:跳过 QQ 网关");
|
|
@@ -458,6 +515,8 @@ export default function (
|
|
|
458
515
|
state.releaseLock = null;
|
|
459
516
|
state.gateway = null;
|
|
460
517
|
state.api = null;
|
|
518
|
+
state.getToken = null;
|
|
519
|
+
state.restBase = null;
|
|
461
520
|
state.account = null;
|
|
462
521
|
});
|
|
463
522
|
|
|
@@ -466,13 +525,24 @@ export default function (
|
|
|
466
525
|
// 而 reload()/newSession() 又必须拿到 ExtensionCommandContext。于是注册一个扩展
|
|
467
526
|
// 命令,QQ 侧收到 /reload 时改派 /qq reload,就能在合法的命令上下文里执行。
|
|
468
527
|
pi.registerCommand("qq", {
|
|
469
|
-
description:
|
|
528
|
+
description:
|
|
529
|
+
"pi-qqbot 内部桥接:/qq <reload|new|compact|quit|session|name|model|thinking|sendfile> [args]",
|
|
470
530
|
handler: async (args, ctx) => {
|
|
471
531
|
const trimmed = args.trim();
|
|
472
532
|
const space = trimmed.indexOf(" ");
|
|
473
533
|
const sub = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
|
|
474
534
|
const rest = (space === -1 ? "" : trimmed.slice(space + 1)).trim();
|
|
475
535
|
const reply = (text: string) => state.bridgeReply?.(text);
|
|
536
|
+
// 发本机文件不需要命令上下文,先于 runBridge 处理
|
|
537
|
+
if (sub === "sendfile") {
|
|
538
|
+
try {
|
|
539
|
+
reply(await sendFileToQQ(state, rest));
|
|
540
|
+
} catch (e) {
|
|
541
|
+
log("[/qq] sendfile 失败:", String(e));
|
|
542
|
+
reply(`发送失败:${explainUploadError(e)}`);
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
476
546
|
try {
|
|
477
547
|
await runBridge(sub, rest, ctx, pi, reply, state);
|
|
478
548
|
} catch (e) {
|
|
@@ -481,16 +551,110 @@ export default function (
|
|
|
481
551
|
}
|
|
482
552
|
},
|
|
483
553
|
});
|
|
554
|
+
|
|
555
|
+
// ---- 工具:把本机文件发到 QQ ----
|
|
556
|
+
// 外发文件属于数据出境,必须在 QQ 里审批(见 FORCE_APPROVAL_TOOLS)。
|
|
557
|
+
pi.registerTool(
|
|
558
|
+
defineTool({
|
|
559
|
+
name: QQ_SEND_FILE_TOOL,
|
|
560
|
+
label: "Send file to QQ",
|
|
561
|
+
description:
|
|
562
|
+
"把本机上已有的文件发送到当前 QQ 对话(对方收到可下载的文件卡片/图片/视频/语音)。" +
|
|
563
|
+
"只在用户明确要求把某个文件发给他时使用;path 必须是本机绝对路径。" +
|
|
564
|
+
"发送前会先在 QQ 里自动申请审批,用户同意后才会真正上传。",
|
|
565
|
+
parameters: Type.Object({
|
|
566
|
+
path: Type.String({ description: "本机文件的绝对路径,例如 D:\\docs\\report.pdf" }),
|
|
567
|
+
name: Type.Optional(
|
|
568
|
+
Type.String({ description: "在 QQ 里显示的文件名(含扩展名),默认用原文件名" }),
|
|
569
|
+
),
|
|
570
|
+
}),
|
|
571
|
+
async execute(_toolCallId: string, params: any) {
|
|
572
|
+
const text = await sendFileToQQ(state, String(params?.path ?? ""), params?.name ? String(params.name) : undefined);
|
|
573
|
+
return { content: [{ type: "text", text }], details: { tool: QQ_SEND_FILE_TOOL } };
|
|
574
|
+
},
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
484
577
|
}
|
|
485
578
|
|
|
486
579
|
/** 取消/停止这类「打断当前任务」的表达 */
|
|
580
|
+
/**
|
|
581
|
+
* 取消/停止这类「打断当前任务」的表达。
|
|
582
|
+
*
|
|
583
|
+
* 注意分工:「停」「停下」「停一下」属于**中断任务**(用户定的:停 = 停止干活);
|
|
584
|
+
* 想只关掉进度推送请用「别汇报了」「闭嘴」「/progress off」;
|
|
585
|
+
* 「停止汇报」因为带了名词,不算取消,走静音。
|
|
586
|
+
*/
|
|
487
587
|
const RE_CANCEL =
|
|
488
|
-
/^(\/cancel|\/stop|\/abort
|
|
588
|
+
/^(\/cancel|\/stop|\/abort|取消|停止|中止|终止|算了|停|停下|停一下|停停|停掉|别跑了|cancel|stop|abort)$/i;
|
|
489
589
|
|
|
490
590
|
function isCancelIntent(text: string): boolean {
|
|
491
591
|
return RE_CANCEL.test(text.trim());
|
|
492
592
|
}
|
|
493
593
|
|
|
594
|
+
/** files 配置兜底:老配置 / 测试里可能没这个键 */
|
|
595
|
+
function filesConfig(cfg: QQBotConfig): { enabled: boolean; maxBytes: number } {
|
|
596
|
+
const raw = (cfg as any)?.files;
|
|
597
|
+
return {
|
|
598
|
+
enabled: raw?.enabled ?? true,
|
|
599
|
+
maxBytes: Number(raw?.maxBytes ?? DEFAULT_SEND_MAX_BYTES),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* 把本机文件发到当前 QQ 对话。
|
|
605
|
+
* 工具(qq_send_file)与 `/sendfile` 命令共用;失败抛异常,文案由 explainUploadError 翻译。
|
|
606
|
+
*/
|
|
607
|
+
async function sendFileToQQ(state: State, filePath: string, displayName?: string): Promise<string> {
|
|
608
|
+
const cfg = state.cfg;
|
|
609
|
+
if (!cfg) throw new Error("还没有接入 QQ bot(会话未就绪)");
|
|
610
|
+
const files = filesConfig(cfg);
|
|
611
|
+
if (!files.enabled) throw new Error("发送文件已关闭(配置 files.enabled=false)");
|
|
612
|
+
const openid = state.currentOpenid ?? state.lastOpenid;
|
|
613
|
+
if (!openid || !state.api) throw new Error("没有 QQ 会话目标(先让用户从 QQ 发一条消息)");
|
|
614
|
+
if (!state.restBase || !state.getToken) throw new Error("QQ 凭据未就绪");
|
|
615
|
+
|
|
616
|
+
const raw = String(filePath ?? "")
|
|
617
|
+
.trim()
|
|
618
|
+
.replace(/^["']|["']$/g, "")
|
|
619
|
+
.trim();
|
|
620
|
+
if (!raw) throw new Error("缺少文件路径(用法:/sendfile <绝对路径>)");
|
|
621
|
+
|
|
622
|
+
const media = await uploadRichMedia({
|
|
623
|
+
restBase: state.restBase,
|
|
624
|
+
openid,
|
|
625
|
+
getToken: state.getToken,
|
|
626
|
+
filePath: raw,
|
|
627
|
+
fileName: displayName,
|
|
628
|
+
maxBytes: files.maxBytes,
|
|
629
|
+
log,
|
|
630
|
+
onProgress: (done, total) => log("[send] 分片", done, "/", total),
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const ref = state.passiveRefs.get(openid);
|
|
634
|
+
const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
|
|
635
|
+
await state.api.sendC2CMedia(openid, media.fileInfo, usePassive ? { msgId: ref.msgId } : {});
|
|
636
|
+
log("[send] 已发出", media.fileName, media.size, "bytes", `type=${media.fileType}`);
|
|
637
|
+
return `已发送「${media.fileName}」(${formatSize(media.size)},${mediaKindLabel(media.fileType)})`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function formatSize(bytes: number): string {
|
|
641
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "?";
|
|
642
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
643
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
644
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
645
|
+
}
|
|
646
|
+
function attachmentConfig(cfg: QQBotConfig): AttachmentConfig {
|
|
647
|
+
const raw = (cfg as any)?.attachments;
|
|
648
|
+
return {
|
|
649
|
+
enabled: raw?.enabled ?? true,
|
|
650
|
+
dir: String(raw?.dir ?? "").trim(),
|
|
651
|
+
maxBytes: Number(raw?.maxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES),
|
|
652
|
+
maxPerMessage: Number(raw?.maxPerMessage ?? DEFAULT_ATTACHMENT_MAX_PER_MESSAGE),
|
|
653
|
+
retentionHours: Number(raw?.retentionHours ?? DEFAULT_ATTACHMENT_RETENTION_HOURS),
|
|
654
|
+
maxFiles: Number(raw?.maxFiles ?? DEFAULT_ATTACHMENT_MAX_FILES),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
494
658
|
/** 派发失败时最多重试几次(通常是 pi 上一轮还没收尾) */
|
|
495
659
|
const MAX_DISPATCH_ATTEMPTS = 3;
|
|
496
660
|
|
|
@@ -726,6 +890,50 @@ export async function handleMessage(
|
|
|
726
890
|
}
|
|
727
891
|
state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
|
|
728
892
|
|
|
893
|
+
// ---- 附件落盘:图片 / 语音 / 视频 / 文档 / 压缩包 / 普通文件 ----
|
|
894
|
+
// 落盘后拼成「图片路径 <path>」这类标记行交给 pi;有附件时绕过 picker/指令/审批路由
|
|
895
|
+
const attachments = msg.attachments ?? [];
|
|
896
|
+
const hasAttachment = attachments.length > 0;
|
|
897
|
+
let incoming = msg.content;
|
|
898
|
+
if (hasAttachment) {
|
|
899
|
+
const acc = attachmentConfig(cfg);
|
|
900
|
+
if (!acc.enabled) {
|
|
901
|
+
const names = attachments.map((a, i) => a.filename || `附件 ${i + 1}`).join("、");
|
|
902
|
+
log("[attachment] 已被配置忽略", attachments.length, "个附件");
|
|
903
|
+
await replyTo(
|
|
904
|
+
api,
|
|
905
|
+
state,
|
|
906
|
+
msg.openid,
|
|
907
|
+
`已忽略 ${attachments.length} 个附件(配置 attachments.enabled=false):${names}。本次只处理文字内容。`,
|
|
908
|
+
);
|
|
909
|
+
if (!msg.content.trim()) return; // 纯附件 + 已忽略:不给 pi 送空消息
|
|
910
|
+
} else {
|
|
911
|
+
const dir = acc.dir || defaultAttachmentDir(cfg.appId);
|
|
912
|
+
const { saved, failed } = await saveIncoming(attachments, {
|
|
913
|
+
dir,
|
|
914
|
+
maxBytes: acc.maxBytes,
|
|
915
|
+
maxPerMessage: acc.maxPerMessage,
|
|
916
|
+
fetchFn: state.attachmentFetch ?? undefined,
|
|
917
|
+
credential: state.getToken,
|
|
918
|
+
prefix: msg.msgId,
|
|
919
|
+
});
|
|
920
|
+
log(
|
|
921
|
+
"[attachment] 落盘",
|
|
922
|
+
saved.length,
|
|
923
|
+
"个,失败",
|
|
924
|
+
failed.length,
|
|
925
|
+
"个 →",
|
|
926
|
+
saved.map((s) => `${s.kind}:${s.name}`).join(", ") || "(无)",
|
|
927
|
+
);
|
|
928
|
+
const ack = renderAttachmentAck(saved, failed);
|
|
929
|
+
if (ack) await replyTo(api, state, msg.openid, ack);
|
|
930
|
+
incoming = [msg.content.trim(), ...renderAttachmentLines(saved), ...renderAttachmentFailureLines(failed)]
|
|
931
|
+
.filter(Boolean)
|
|
932
|
+
.join("\n");
|
|
933
|
+
if (!incoming) return; // 全部失败且无正文:已经回执过,不再送空消息
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
729
937
|
// 上一条最终答案没发出去(配额耗尽):先补发
|
|
730
938
|
if (state.undelivered && state.undelivered.openid === msg.openid) {
|
|
731
939
|
const pending = state.undelivered;
|
|
@@ -738,8 +946,8 @@ export async function handleMessage(
|
|
|
738
946
|
}
|
|
739
947
|
}
|
|
740
948
|
|
|
741
|
-
// 待选择的编号回复(/tree、/fork、/resume
|
|
742
|
-
if (state.picker) {
|
|
949
|
+
// 待选择的编号回复(/tree、/fork、/resume 之后);带附件时不当作编号选择
|
|
950
|
+
if (!hasAttachment && state.picker) {
|
|
743
951
|
if (pickerExpired(state.picker)) {
|
|
744
952
|
state.picker = null;
|
|
745
953
|
} else if (state.picker.openid === msg.openid) {
|
|
@@ -781,7 +989,9 @@ export async function handleMessage(
|
|
|
781
989
|
state.bridgeReply = (text: string) => {
|
|
782
990
|
void replyTo(api, state, msg.openid, text);
|
|
783
991
|
};
|
|
784
|
-
const routed =
|
|
992
|
+
const routed = hasAttachment
|
|
993
|
+
? ("text" as const)
|
|
994
|
+
: await routeSlash(
|
|
785
995
|
{
|
|
786
996
|
listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string; source?: string }[],
|
|
787
997
|
dispatch: (text, options) => {
|
|
@@ -814,8 +1024,8 @@ export async function handleMessage(
|
|
|
814
1024
|
);
|
|
815
1025
|
if (routed !== "text") return;
|
|
816
1026
|
|
|
817
|
-
//
|
|
818
|
-
const parsed = state.approver.parseReply(msg.content.trim());
|
|
1027
|
+
// 远程审批回复优先(带附件时不当作审批动作)
|
|
1028
|
+
const parsed = hasAttachment ? null : state.approver.parseReply(msg.content.trim());
|
|
819
1029
|
if (parsed) {
|
|
820
1030
|
if (parsed.decision === "terminate") {
|
|
821
1031
|
const n = state.approver.terminateAll();
|
|
@@ -867,8 +1077,8 @@ export async function handleMessage(
|
|
|
867
1077
|
log("[msg] 审批类文本但无待审批,按普通消息处理");
|
|
868
1078
|
}
|
|
869
1079
|
|
|
870
|
-
//
|
|
871
|
-
const cmd = parseCommand(msg.content);
|
|
1080
|
+
// 内置指令(带附件时绕过:附件消息一律当普通内容交给 pi)
|
|
1081
|
+
const cmd = hasAttachment ? null : parseCommand(msg.content);
|
|
872
1082
|
if (cmd?.name === "help") {
|
|
873
1083
|
await replyTo(
|
|
874
1084
|
api,
|
|
@@ -880,7 +1090,9 @@ export async function handleMessage(
|
|
|
880
1090
|
"/status — 查看状态(当前 qqbot / 忙闲 / 队列 / 自动同意 / 待审批 / 自动汇报)",
|
|
881
1091
|
"/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
|
|
882
1092
|
"/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
|
|
883
|
-
"/cancel
|
|
1093
|
+
"/cancel(或「取消」「停止」「停」)— 中断正在运行的任务并清空队列",
|
|
1094
|
+
"/sendfile <路径> — 把本机文件发到你的 QQ(png/jpg/mp4/silk 直显,其余为文件卡片;外发需审批)",
|
|
1095
|
+
"只关进度推送:/progress off(或「别汇报了」「闭嘴」);「停」是中断任务,不是静音",
|
|
884
1096
|
"/help — 帮助",
|
|
885
1097
|
"pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
|
|
886
1098
|
"忙碌时发来的普通消息(含模板/skill 命令)会排队,当前任务结束后自动执行。",
|
|
@@ -974,20 +1186,20 @@ export async function handleMessage(
|
|
|
974
1186
|
// 忙检查:单会话串行处理——忙碌时入队,当前任务结束后自动继续
|
|
975
1187
|
if (isBusy(state)) {
|
|
976
1188
|
// 忙碌时问「进度」不该排队,直接回当前进度并恢复自动推送
|
|
977
|
-
if (RE_PROGRESS_QUERY.test(msg.content.trim())) {
|
|
1189
|
+
if (!hasAttachment && RE_PROGRESS_QUERY.test(msg.content.trim())) {
|
|
978
1190
|
const snapshot = state.progress.summary();
|
|
979
1191
|
if (!state.progressMuted) state.progressPaused = false;
|
|
980
1192
|
await replyTo(api, state, msg.openid, snapshot || "任务刚启动,还没有可汇报的进度。");
|
|
981
1193
|
return;
|
|
982
1194
|
}
|
|
983
|
-
await enqueueJob(api, state, msg.openid,
|
|
1195
|
+
await enqueueJob(api, state, msg.openid, incoming);
|
|
984
1196
|
return;
|
|
985
1197
|
}
|
|
986
1198
|
|
|
987
1199
|
// ack
|
|
988
1200
|
await replyTo(api, state, msg.openid, "收到,正在处理");
|
|
989
1201
|
|
|
990
|
-
startJob(pi, state, state.queue.mint(msg.openid,
|
|
1202
|
+
startJob(pi, state, state.queue.mint(msg.openid, incoming));
|
|
991
1203
|
} catch (e) {
|
|
992
1204
|
log("[msg] 处理失败:", String(e));
|
|
993
1205
|
await replyTo(api, state, msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
|
|
@@ -1109,14 +1321,65 @@ const UNMUTE_ARGS = new Set([
|
|
|
1109
1321
|
"继续汇报",
|
|
1110
1322
|
]);
|
|
1111
1323
|
|
|
1112
|
-
/**
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1324
|
+
/**
|
|
1325
|
+
* 不带斜杠的口令(整句匹配,避免误伤正常聊天)。
|
|
1326
|
+
*
|
|
1327
|
+
* 三类:
|
|
1328
|
+
* 1. 问进度:「汇报一下当前进度」「看一下进度」「进度如何」→ 回一次摘要
|
|
1329
|
+
* 2. 静音:「暂停汇报进度」「别汇报了」「别说了」「闭嘴」「停」→ 暂停自动汇报
|
|
1330
|
+
* 3. 恢复:「恢复汇报」「接着说」→ 重新打开自动汇报
|
|
1331
|
+
*
|
|
1332
|
+
* 注意:「停止/取消/中止」是**中断任务**(RE_CANCEL,更早判定),不是静音;
|
|
1333
|
+
* 「停止汇报」才走静音。
|
|
1334
|
+
*/
|
|
1335
|
+
const REPORT_LEAD = "(?:请|帮我|给我|麻烦|暂时|先)?\\s*";
|
|
1336
|
+
/** 汇报名词的两种语序(进度汇报 / 汇报进度) */
|
|
1337
|
+
const REPORT_NOUN =
|
|
1338
|
+
"(?:(?:进度|进展)(?:汇报|报告|推送|播报|上报)?|(?:汇报|报告|推送|播报|上报)(?:进度|进展)?)";
|
|
1339
|
+
const REPORT_TAIL = "\\s*(?:了|吧|哦|啊|呀|嘛|啦|呗)*";
|
|
1340
|
+
|
|
1341
|
+
/** 问进度(含「汇报一下…」这类祈使句) */
|
|
1342
|
+
const BARE_PROGRESS_RE = new RegExp(
|
|
1343
|
+
"^" +
|
|
1344
|
+
REPORT_LEAD +
|
|
1345
|
+
"(?:(?:汇报|报告|报|说说|讲讲|看|看看|查|查查|查看|显示|展示)(?:一下|下|个|一眼)?\\s*)?" +
|
|
1346
|
+
"(?:当前|现在|目前)?\\s*(?:的)?\\s*(?:progress|进度|进展)" +
|
|
1347
|
+
"(?:\\s*(?:如何|怎么样|怎样|呢|啦|了|吗))?" +
|
|
1348
|
+
"(?:\\s+([\\s\\S]*))?$",
|
|
1349
|
+
"i",
|
|
1350
|
+
);
|
|
1351
|
+
|
|
1352
|
+
/** 静音:带「汇报」名词的 */
|
|
1353
|
+
const RE_REPORTS_OFF = new RegExp(
|
|
1354
|
+
"^" +
|
|
1355
|
+
REPORT_LEAD +
|
|
1356
|
+
"(?:暂停|停止|停下|关闭|关掉|别再|别|不要|不用|甭|闭嘴|安静|打住)\\s*" +
|
|
1357
|
+
"(?:自动|定时|定期)?\\s*(?:的)?\\s*" +
|
|
1358
|
+
REPORT_NOUN +
|
|
1359
|
+
REPORT_TAIL +
|
|
1360
|
+
"$",
|
|
1361
|
+
);
|
|
1362
|
+
/** 静音:不提「汇报」的纯闭嘴类(停/停下 不在此列——它们属于中断任务) */
|
|
1363
|
+
const RE_SILENCE = new RegExp(
|
|
1364
|
+
"^" +
|
|
1365
|
+
REPORT_LEAD +
|
|
1366
|
+
"(?:闭嘴|安静|安静点|安静下|别说了|别讲了|别吵了?|别推了|别发了|别报了|打住)" +
|
|
1367
|
+
REPORT_TAIL +
|
|
1368
|
+
"$",
|
|
1369
|
+
);
|
|
1370
|
+
/** 恢复:说清楚要恢复汇报,或者「接着说」 */
|
|
1371
|
+
const RE_REPORTS_ON = new RegExp(
|
|
1372
|
+
"^" +
|
|
1373
|
+
REPORT_LEAD +
|
|
1374
|
+
"(?:恢复|继续|开启|打开|开始|接着说|接着说吧|说下去|接着报)\\s*" +
|
|
1375
|
+
"(?:自动|定时|定期)?\\s*(?:的)?\\s*" +
|
|
1376
|
+
REPORT_NOUN +
|
|
1377
|
+
REPORT_TAIL +
|
|
1378
|
+
"$",
|
|
1379
|
+
);
|
|
1380
|
+
const RE_RESUME_TALK = new RegExp("^" + REPORT_LEAD + "(?:接着说|说下去|接着报)" + REPORT_TAIL + "$");
|
|
1117
1381
|
|
|
1118
|
-
/**
|
|
1119
|
-
const BARE_PROGRESS_RE = /^(?:progress|(?:当前|现在|目前)?的?(?:进度|进展))(?:\s+([\s\S]*))?$/i;
|
|
1382
|
+
/** 不带斜杠的进度问询:「进度」「当前进度」「汇报一下当前进度」(可带子命令) */
|
|
1120
1383
|
|
|
1121
1384
|
function parseCommand(text: string): { name: "reset" | "status" | "help" | "queue" | "progress"; args: string } | null {
|
|
1122
1385
|
// 注意:/new 现在属于 pi 的内置命令(由 slash 路由接管,真的开新会话),
|
|
@@ -1132,11 +1395,11 @@ function parseCommand(text: string): { name: "reset" | "status" | "help" | "queu
|
|
|
1132
1395
|
args: (slash[2] ?? "").trim(),
|
|
1133
1396
|
};
|
|
1134
1397
|
}
|
|
1135
|
-
//
|
|
1398
|
+
// 裸文本:先认「问进度」,再认「静音/恢复」
|
|
1136
1399
|
const bare = BARE_PROGRESS_RE.exec(t);
|
|
1137
1400
|
if (bare) return { name: "progress", args: (bare[1] ?? "").trim() };
|
|
1138
|
-
if (RE_REPORTS_OFF.test(t)) return { name: "progress", args: "off" };
|
|
1139
|
-
if (RE_REPORTS_ON.test(t)) return { name: "progress", args: "on" };
|
|
1401
|
+
if (RE_REPORTS_OFF.test(t) || RE_SILENCE.test(t)) return { name: "progress", args: "off" };
|
|
1402
|
+
if (RE_REPORTS_ON.test(t) || RE_RESUME_TALK.test(t)) return { name: "progress", args: "on" };
|
|
1140
1403
|
return null;
|
|
1141
1404
|
}
|
|
1142
1405
|
|
|
@@ -1192,6 +1455,8 @@ function summarizeTool(event: any): string {
|
|
|
1192
1455
|
return `${event.toolName}: ${clip(input.path ?? input.file ?? input.filePath)}`;
|
|
1193
1456
|
case "grep":
|
|
1194
1457
|
return `${event.toolName}: ${clip(input.pattern)}`;
|
|
1458
|
+
case "qq_send_file":
|
|
1459
|
+
return `发送文件到 QQ: ${clip(input.path)}${input.name ? `(显示名 ${clip(input.name)})` : ""}`;
|
|
1195
1460
|
default:
|
|
1196
1461
|
return `${event.toolName}: ${clip(JSON.stringify(input))}`;
|
|
1197
1462
|
}
|