@zxh126/pi-qqbot 2.0.1 → 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 +81 -0
- package/README.md +90 -2
- 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 +82 -2
- package/index.ts +232 -26
- 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
|
|
|
@@ -101,6 +123,64 @@ pi install ./pi-qqbot
|
|
|
101
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
|
任何以 `/` 开头的消息都会先走路由,再决定是否当成对话:
|
|
@@ -290,7 +370,7 @@ QQ 网关对同一 bot 只保留一个活跃连接。插件用 `proper-lockfile`
|
|
|
290
370
|
|
|
291
371
|
## 变更日志
|
|
292
372
|
|
|
293
|
-
见 [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**。
|
|
294
374
|
|
|
295
375
|
## License
|
|
296
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,6 +551,29 @@ 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
|
/** 取消/停止这类「打断当前任务」的表达 */
|
|
@@ -498,6 +591,70 @@ function isCancelIntent(text: string): boolean {
|
|
|
498
591
|
return RE_CANCEL.test(text.trim());
|
|
499
592
|
}
|
|
500
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
|
+
|
|
501
658
|
/** 派发失败时最多重试几次(通常是 pi 上一轮还没收尾) */
|
|
502
659
|
const MAX_DISPATCH_ATTEMPTS = 3;
|
|
503
660
|
|
|
@@ -733,6 +890,50 @@ export async function handleMessage(
|
|
|
733
890
|
}
|
|
734
891
|
state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
|
|
735
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
|
+
|
|
736
937
|
// 上一条最终答案没发出去(配额耗尽):先补发
|
|
737
938
|
if (state.undelivered && state.undelivered.openid === msg.openid) {
|
|
738
939
|
const pending = state.undelivered;
|
|
@@ -745,8 +946,8 @@ export async function handleMessage(
|
|
|
745
946
|
}
|
|
746
947
|
}
|
|
747
948
|
|
|
748
|
-
// 待选择的编号回复(/tree、/fork、/resume
|
|
749
|
-
if (state.picker) {
|
|
949
|
+
// 待选择的编号回复(/tree、/fork、/resume 之后);带附件时不当作编号选择
|
|
950
|
+
if (!hasAttachment && state.picker) {
|
|
750
951
|
if (pickerExpired(state.picker)) {
|
|
751
952
|
state.picker = null;
|
|
752
953
|
} else if (state.picker.openid === msg.openid) {
|
|
@@ -788,7 +989,9 @@ export async function handleMessage(
|
|
|
788
989
|
state.bridgeReply = (text: string) => {
|
|
789
990
|
void replyTo(api, state, msg.openid, text);
|
|
790
991
|
};
|
|
791
|
-
const routed =
|
|
992
|
+
const routed = hasAttachment
|
|
993
|
+
? ("text" as const)
|
|
994
|
+
: await routeSlash(
|
|
792
995
|
{
|
|
793
996
|
listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string; source?: string }[],
|
|
794
997
|
dispatch: (text, options) => {
|
|
@@ -821,8 +1024,8 @@ export async function handleMessage(
|
|
|
821
1024
|
);
|
|
822
1025
|
if (routed !== "text") return;
|
|
823
1026
|
|
|
824
|
-
//
|
|
825
|
-
const parsed = state.approver.parseReply(msg.content.trim());
|
|
1027
|
+
// 远程审批回复优先(带附件时不当作审批动作)
|
|
1028
|
+
const parsed = hasAttachment ? null : state.approver.parseReply(msg.content.trim());
|
|
826
1029
|
if (parsed) {
|
|
827
1030
|
if (parsed.decision === "terminate") {
|
|
828
1031
|
const n = state.approver.terminateAll();
|
|
@@ -874,8 +1077,8 @@ export async function handleMessage(
|
|
|
874
1077
|
log("[msg] 审批类文本但无待审批,按普通消息处理");
|
|
875
1078
|
}
|
|
876
1079
|
|
|
877
|
-
//
|
|
878
|
-
const cmd = parseCommand(msg.content);
|
|
1080
|
+
// 内置指令(带附件时绕过:附件消息一律当普通内容交给 pi)
|
|
1081
|
+
const cmd = hasAttachment ? null : parseCommand(msg.content);
|
|
879
1082
|
if (cmd?.name === "help") {
|
|
880
1083
|
await replyTo(
|
|
881
1084
|
api,
|
|
@@ -888,6 +1091,7 @@ export async function handleMessage(
|
|
|
888
1091
|
"/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
|
|
889
1092
|
"/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
|
|
890
1093
|
"/cancel(或「取消」「停止」「停」)— 中断正在运行的任务并清空队列",
|
|
1094
|
+
"/sendfile <路径> — 把本机文件发到你的 QQ(png/jpg/mp4/silk 直显,其余为文件卡片;外发需审批)",
|
|
891
1095
|
"只关进度推送:/progress off(或「别汇报了」「闭嘴」);「停」是中断任务,不是静音",
|
|
892
1096
|
"/help — 帮助",
|
|
893
1097
|
"pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
|
|
@@ -982,20 +1186,20 @@ export async function handleMessage(
|
|
|
982
1186
|
// 忙检查:单会话串行处理——忙碌时入队,当前任务结束后自动继续
|
|
983
1187
|
if (isBusy(state)) {
|
|
984
1188
|
// 忙碌时问「进度」不该排队,直接回当前进度并恢复自动推送
|
|
985
|
-
if (RE_PROGRESS_QUERY.test(msg.content.trim())) {
|
|
1189
|
+
if (!hasAttachment && RE_PROGRESS_QUERY.test(msg.content.trim())) {
|
|
986
1190
|
const snapshot = state.progress.summary();
|
|
987
1191
|
if (!state.progressMuted) state.progressPaused = false;
|
|
988
1192
|
await replyTo(api, state, msg.openid, snapshot || "任务刚启动,还没有可汇报的进度。");
|
|
989
1193
|
return;
|
|
990
1194
|
}
|
|
991
|
-
await enqueueJob(api, state, msg.openid,
|
|
1195
|
+
await enqueueJob(api, state, msg.openid, incoming);
|
|
992
1196
|
return;
|
|
993
1197
|
}
|
|
994
1198
|
|
|
995
1199
|
// ack
|
|
996
1200
|
await replyTo(api, state, msg.openid, "收到,正在处理");
|
|
997
1201
|
|
|
998
|
-
startJob(pi, state, state.queue.mint(msg.openid,
|
|
1202
|
+
startJob(pi, state, state.queue.mint(msg.openid, incoming));
|
|
999
1203
|
} catch (e) {
|
|
1000
1204
|
log("[msg] 处理失败:", String(e));
|
|
1001
1205
|
await replyTo(api, state, msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
|
|
@@ -1251,6 +1455,8 @@ function summarizeTool(event: any): string {
|
|
|
1251
1455
|
return `${event.toolName}: ${clip(input.path ?? input.file ?? input.filePath)}`;
|
|
1252
1456
|
case "grep":
|
|
1253
1457
|
return `${event.toolName}: ${clip(input.pattern)}`;
|
|
1458
|
+
case "qq_send_file":
|
|
1459
|
+
return `发送文件到 QQ: ${clip(input.path)}${input.name ? `(显示名 ${clip(input.name)})` : ""}`;
|
|
1254
1460
|
default:
|
|
1255
1461
|
return `${event.toolName}: ${clip(JSON.stringify(input))}`;
|
|
1256
1462
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zxh126/pi-qqbot",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Bridge the Pi coding agent into QQ private chats, with remote tool-call approval",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
]
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
|
-
"test": "node test/queue.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs && node test/config.test.mjs && node test/lock.test.mjs && node test/accounts.test.mjs && node test/auth.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/progress.test.mjs && node test/redact.test.mjs && node test/config.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"
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"*.ts",
|
|
@@ -43,10 +43,18 @@
|
|
|
43
43
|
"ws": "^8.18.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
46
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
47
|
+
"typebox": "*"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"typebox": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
47
53
|
},
|
|
48
54
|
"devDependencies": {
|
|
49
55
|
"@types/proper-lockfile": "^4.1.4",
|
|
50
|
-
"@types/ws": "^8.18.2"
|
|
56
|
+
"@types/ws": "^8.18.2",
|
|
57
|
+
"jiti": "^2.6.1",
|
|
58
|
+
"typebox": "^1.3.27"
|
|
51
59
|
}
|
|
52
60
|
}
|
package/qq/api.ts
CHANGED
|
@@ -11,6 +11,10 @@ export interface SendOptions {
|
|
|
11
11
|
format?: "text" | "markdown";
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export interface MediaSendOptions {
|
|
15
|
+
msgId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
14
18
|
export class QQApi {
|
|
15
19
|
private queue: Promise<void> = Promise.resolve();
|
|
16
20
|
private seqCounters = new Map<string, number>();
|
|
@@ -23,6 +27,13 @@ export class QQApi {
|
|
|
23
27
|
return task;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/** 发富媒体(msg_type=7):fileInfo 来自 `/v2/users/{openid}/files` 的上传结果 */
|
|
31
|
+
sendC2CMedia(openid: string, fileInfo: string, options: MediaSendOptions = {}): Promise<void> {
|
|
32
|
+
const task = this.queue.then(() => this.doMedia(openid, fileInfo, options));
|
|
33
|
+
this.queue = task.catch(() => {});
|
|
34
|
+
return task;
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
private nextSeq(msgId?: string): number | undefined {
|
|
27
38
|
if (!msgId) return undefined;
|
|
28
39
|
const used = this.seqCounters.get(msgId) ?? 0;
|
|
@@ -52,6 +63,21 @@ export class QQApi {
|
|
|
52
63
|
}
|
|
53
64
|
}
|
|
54
65
|
|
|
66
|
+
private async doMedia(openid: string, fileInfo: string, options: MediaSendOptions): Promise<void> {
|
|
67
|
+
let msgId = options.msgId;
|
|
68
|
+
let seq: number | undefined;
|
|
69
|
+
if (msgId) {
|
|
70
|
+
seq = this.nextSeq(msgId);
|
|
71
|
+
if (seq === undefined) msgId = undefined; // 被动额度用尽 → 主动消息
|
|
72
|
+
}
|
|
73
|
+
const body: any = { msg_type: 7, media: { file_info: fileInfo } };
|
|
74
|
+
if (msgId && seq !== undefined) {
|
|
75
|
+
body.msg_id = msgId;
|
|
76
|
+
body.msg_seq = seq;
|
|
77
|
+
}
|
|
78
|
+
await this.postWithRetry(openid, body);
|
|
79
|
+
}
|
|
80
|
+
|
|
55
81
|
private async doSend(openid: string, content: string, options: SendOptions): Promise<void> {
|
|
56
82
|
const format = options.format ?? "text";
|
|
57
83
|
let msgId = options.msgId;
|