@zxh126/pi-qqbot 2.2.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +370 -275
- package/README.md +518 -402
- package/approver.ts +174 -174
- package/attachment.ts +502 -502
- package/billing.ts +579 -0
- package/config.ts +281 -271
- package/constants.ts +51 -51
- package/docs/README.zh-CN.md +477 -377
- package/index.ts +1856 -1463
- package/package.json +60 -60
- package/qq/api.ts +107 -107
- package/qq/gateway.ts +265 -265
- package/qq/upload.ts +288 -288
- package/remote.ts +81 -0
- package/selfupdate.ts +212 -0
- package/slash.ts +862 -826
package/qq/upload.ts
CHANGED
|
@@ -1,288 +1,288 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 把本机文件发到 QQ(单聊富媒体上传)。
|
|
3
|
-
*
|
|
4
|
-
* 官方接口(2026-07 文档):
|
|
5
|
-
* 1) POST /v2/users/{openid}/upload_prepare → upload_id / block_size / parts[].presigned_url
|
|
6
|
-
* 2) PUT <presigned_url> → 直接推分片字节(不带 Authorization)
|
|
7
|
-
* 3) POST /v2/users/{openid}/upload_part_finish { upload_id, part_index, block_size, md5 }
|
|
8
|
-
* 4) POST /v2/users/{openid}/files { file_type, upload_id, file_name, srv_send_msg:false }
|
|
9
|
-
* → file_info / ttl
|
|
10
|
-
* 5) 再走发消息接口:msg_type=7 + media.file_info(见 qq/api.ts 的 sendC2CMedia)
|
|
11
|
-
*
|
|
12
|
-
* 只有 URL 上传和分片上传两种方式能突破 5MB;本地文件没有公网 URL,所以统一走分片。
|
|
13
|
-
* `file_type`:1=图片(png/jpg) 2=视频(mp4) 3=语音(silk) 4=文件;其余扩展名一律 4。
|
|
14
|
-
*/
|
|
15
|
-
import fs from "node:fs";
|
|
16
|
-
import path from "node:path";
|
|
17
|
-
import crypto from "node:crypto";
|
|
18
|
-
import {
|
|
19
|
-
DEFAULT_SEND_MAX_BYTES,
|
|
20
|
-
UPLOAD_PART_ATTEMPTS,
|
|
21
|
-
UPLOAD_PART_FALLBACK_BYTES,
|
|
22
|
-
UPLOAD_TIMEOUT_MS,
|
|
23
|
-
} from "../constants";
|
|
24
|
-
|
|
25
|
-
/** QQ 富媒体业务类型 */
|
|
26
|
-
export type MediaFileType = 1 | 2 | 3 | 4;
|
|
27
|
-
|
|
28
|
-
const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg"]);
|
|
29
|
-
const VIDEO_EXT = new Set([".mp4"]);
|
|
30
|
-
const VOICE_EXT = new Set([".silk"]);
|
|
31
|
-
/** QQ 侧硬上限 */
|
|
32
|
-
const HARD_LIMIT_BYTES = 200 * 1024 * 1024;
|
|
33
|
-
/** md5_10m 取文件前 10002432 字节(约 9.54MB,官方定义) */
|
|
34
|
-
const MD5_10M_BYTES = 10_002_432;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* 扩展名 → file_type。
|
|
38
|
-
* 注意:只有 png/jpg 算图片、mp4 算视频、silk 算语音;其它(gif/webp/mp3/pdf/zip…)
|
|
39
|
-
* 必须按「文件」发,否则服务端报 850019 不支持的文件格式。
|
|
40
|
-
*/
|
|
41
|
-
export function mediaFileTypeOf(filePath: string): MediaFileType {
|
|
42
|
-
const ext = path.extname(String(filePath ?? "")).toLowerCase();
|
|
43
|
-
if (IMAGE_EXT.has(ext)) return 1;
|
|
44
|
-
if (VIDEO_EXT.has(ext)) return 2;
|
|
45
|
-
if (VOICE_EXT.has(ext)) return 3;
|
|
46
|
-
return 4;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface RichMediaUploadOptions {
|
|
50
|
-
/** REST 基址(生产/沙箱) */
|
|
51
|
-
restBase: string;
|
|
52
|
-
/** 目标用户 openid */
|
|
53
|
-
openid: string;
|
|
54
|
-
getToken: () => Promise<string>;
|
|
55
|
-
/** 要发送的本机文件路径 */
|
|
56
|
-
filePath: string;
|
|
57
|
-
/** 在 QQ 里显示的文件名(默认取 basename) */
|
|
58
|
-
fileName?: string;
|
|
59
|
-
/** 扩展里的大小上限(默认 30MB,QQ 硬限 200MB) */
|
|
60
|
-
maxBytes?: number;
|
|
61
|
-
/** 覆盖自动判定的 file_type */
|
|
62
|
-
fileType?: MediaFileType;
|
|
63
|
-
onProgress?: (done: number, total: number) => void;
|
|
64
|
-
log?: (message: string, ...rest: unknown[]) => void;
|
|
65
|
-
fetchFn?: typeof fetch;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface UploadedMedia {
|
|
69
|
-
/** 发送消息时用的 media.file_info(原样透传,不要解析) */
|
|
70
|
-
fileInfo: string;
|
|
71
|
-
/** 有效期(秒),0 = 长期可用 */
|
|
72
|
-
ttl: number;
|
|
73
|
-
fileType: MediaFileType;
|
|
74
|
-
fileName: string;
|
|
75
|
-
size: number;
|
|
76
|
-
md5: string;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function formatBytes(bytes: number): string {
|
|
80
|
-
if (!Number.isFinite(bytes) || bytes < 0) return "?";
|
|
81
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
82
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
83
|
-
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function short(value: string, max = 200): string {
|
|
87
|
-
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
88
|
-
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function timeoutSignal(ms: number): AbortSignal | undefined {
|
|
92
|
-
const anyAbort = AbortSignal as any;
|
|
93
|
-
return typeof anyAbort?.timeout === "function" ? anyAbort.timeout(ms) : undefined;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function sanitizeFileName(name: string): string {
|
|
97
|
-
const base = String(name ?? "")
|
|
98
|
-
.replace(/[\\/]+/g, "_")
|
|
99
|
-
.replace(/[\u0000-\u001f<>:"|?*]/g, "_")
|
|
100
|
-
.trim();
|
|
101
|
-
const clipped = base.slice(0, 100);
|
|
102
|
-
return clipped || "file.bin";
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** md5 / sha1 / md5_10m(上传预检要这三个) */
|
|
106
|
-
export function hashFileBuffer(buffer: Buffer): { md5: string; sha1: string; md5_10m: string } {
|
|
107
|
-
return {
|
|
108
|
-
md5: crypto.createHash("md5").update(buffer).digest("hex"),
|
|
109
|
-
sha1: crypto.createHash("sha1").update(buffer).digest("hex"),
|
|
110
|
-
md5_10m: crypto.createHash("md5").update(buffer.subarray(0, MD5_10M_BYTES)).digest("hex"),
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** 把 QQ 的错误码翻成人话(用于回执) */
|
|
115
|
-
export function explainUploadError(error: unknown): string {
|
|
116
|
-
const text = String((error as any)?.message ?? error ?? "");
|
|
117
|
-
const hints: [RegExp, string][] = [
|
|
118
|
-
[/850031/, "文件超过 QQ 大小限制"],
|
|
119
|
-
[/850019/, "QQ 不支持该文件格式(图片只收 png/jpg,视频只收 mp4,语音只收 silk)"],
|
|
120
|
-
[/40093002/, "今天的发送文件容量已达上限(QQ 限制),明天再试或换小文件"],
|
|
121
|
-
[/850027/, "QQ 侧发送超时,稍后重试"],
|
|
122
|
-
[/850018/, "机器人被禁言或受频率限制"],
|
|
123
|
-
[/850026/, "QQ 下载原始文件失败"],
|
|
124
|
-
[/40093001/, "QQ 转存失败,请重试"],
|
|
125
|
-
];
|
|
126
|
-
for (const [pattern, hint] of hints) {
|
|
127
|
-
if (pattern.test(text)) return `${hint}${text ? `(${short(text)})` : ""}`;
|
|
128
|
-
}
|
|
129
|
-
return short(text) || "未知错误";
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async function withRetry<T>(task: () => Promise<T>, attempts: number, delayMs: number): Promise<T> {
|
|
133
|
-
let lastError: unknown;
|
|
134
|
-
for (let i = 0; i < Math.max(1, attempts); i += 1) {
|
|
135
|
-
try {
|
|
136
|
-
return await task();
|
|
137
|
-
} catch (e) {
|
|
138
|
-
lastError = e;
|
|
139
|
-
if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs));
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
throw lastError;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function makeClient(opts: RichMediaUploadOptions, token: string) {
|
|
146
|
-
const fetchFn = opts.fetchFn ?? fetch;
|
|
147
|
-
const timeoutMs = UPLOAD_TIMEOUT_MS;
|
|
148
|
-
const postJson = async (url: string, body: unknown): Promise<any> => {
|
|
149
|
-
const res = await fetchFn(url, {
|
|
150
|
-
method: "POST",
|
|
151
|
-
headers: { "Content-Type": "application/json", Authorization: `QQBot ${token}` },
|
|
152
|
-
body: JSON.stringify(body),
|
|
153
|
-
signal: timeoutSignal(timeoutMs),
|
|
154
|
-
});
|
|
155
|
-
const text = await res.text();
|
|
156
|
-
if (!res.ok) throw new Error(`HTTP ${res.status} ${short(text)}`);
|
|
157
|
-
try {
|
|
158
|
-
return text ? JSON.parse(text) : {};
|
|
159
|
-
} catch {
|
|
160
|
-
throw new Error(`响应不是 JSON:${short(text)}`);
|
|
161
|
-
}
|
|
162
|
-
};
|
|
163
|
-
return { fetchFn, postJson };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* 分片上传一个本机文件,返回可用于发消息的 file_info。
|
|
168
|
-
* 失败抛异常(调用方用 explainUploadError 转成回执文案)。
|
|
169
|
-
*/
|
|
170
|
-
export async function uploadRichMedia(opts: RichMediaUploadOptions): Promise<UploadedMedia> {
|
|
171
|
-
const filePath = path.resolve(String(opts.filePath ?? ""));
|
|
172
|
-
let stat: { isFile(): boolean; size: number };
|
|
173
|
-
try {
|
|
174
|
-
stat = await fs.promises.stat(filePath);
|
|
175
|
-
} catch {
|
|
176
|
-
throw new Error(`文件不存在:${filePath}`);
|
|
177
|
-
}
|
|
178
|
-
if (!stat.isFile()) throw new Error(`不是普通文件:${filePath}`);
|
|
179
|
-
if (stat.size === 0) throw new Error(`文件是空的:${filePath}`);
|
|
180
|
-
|
|
181
|
-
const maxBytes = Math.max(1, Number(opts.maxBytes ?? DEFAULT_SEND_MAX_BYTES));
|
|
182
|
-
if (stat.size > HARD_LIMIT_BYTES) {
|
|
183
|
-
throw new Error(`文件过大(${formatBytes(stat.size)}),QQ 硬上限 ${formatBytes(HARD_LIMIT_BYTES)}`);
|
|
184
|
-
}
|
|
185
|
-
if (stat.size > maxBytes) {
|
|
186
|
-
throw new Error(`文件过大(${formatBytes(stat.size)} > 上限 ${formatBytes(maxBytes)})`);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const buffer = await fs.promises.readFile(filePath);
|
|
190
|
-
const fileName = sanitizeFileName(opts.fileName || path.basename(filePath));
|
|
191
|
-
const fileType = opts.fileType ?? mediaFileTypeOf(filePath);
|
|
192
|
-
const hashes = hashFileBuffer(buffer);
|
|
193
|
-
const base = `${opts.restBase}/v2/users/${encodeURIComponent(opts.openid)}`;
|
|
194
|
-
const token = await opts.getToken();
|
|
195
|
-
const client = makeClient(opts, token);
|
|
196
|
-
|
|
197
|
-
// 1) 预上传
|
|
198
|
-
const prepared = await client.postJson(`${base}/upload_prepare`, {
|
|
199
|
-
file_type: fileType,
|
|
200
|
-
file_size: String(buffer.length),
|
|
201
|
-
file_name: fileName,
|
|
202
|
-
...hashes,
|
|
203
|
-
});
|
|
204
|
-
const uploadId = String(prepared?.upload_id ?? "");
|
|
205
|
-
if (!uploadId) throw new Error(`预上传失败:缺少 upload_id(${short(JSON.stringify(prepared))})`);
|
|
206
|
-
|
|
207
|
-
const parts: any[] = Array.isArray(prepared.parts) ? [...prepared.parts] : [];
|
|
208
|
-
parts.sort((a, b) => Number(a?.index ?? 0) - Number(b?.index ?? 0));
|
|
209
|
-
if (parts.length === 0) throw new Error("预上传失败:没有返回分片信息");
|
|
210
|
-
|
|
211
|
-
const fallbackPartSize = Number(prepared.block_size ?? 0) || UPLOAD_PART_FALLBACK_BYTES;
|
|
212
|
-
let offset = 0;
|
|
213
|
-
const jobs = parts.map((p) => {
|
|
214
|
-
const size = Number(p?.block_size ?? 0) || fallbackPartSize;
|
|
215
|
-
const slice = buffer.subarray(offset, offset + size);
|
|
216
|
-
offset += size;
|
|
217
|
-
return {
|
|
218
|
-
index: Number(p?.index ?? 0),
|
|
219
|
-
url: String(p?.presigned_url ?? ""),
|
|
220
|
-
slice,
|
|
221
|
-
size: slice.length,
|
|
222
|
-
md5: crypto.createHash("md5").update(slice).digest("hex"),
|
|
223
|
-
};
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
const uploadConfig = prepared?.upload_config ?? {};
|
|
227
|
-
const concurrency = Math.max(1, Math.min(8, Number(uploadConfig.concurrency ?? 1) || 1));
|
|
228
|
-
const retryDelayMs = Math.max(200, Number(uploadConfig.retry_delay ?? 1) * 1000 || 1000);
|
|
229
|
-
|
|
230
|
-
// 2)+3) 分片 PUT + 确认
|
|
231
|
-
let done = 0;
|
|
232
|
-
let cursor = 0;
|
|
233
|
-
const worker = async () => {
|
|
234
|
-
while (cursor < jobs.length) {
|
|
235
|
-
const job = jobs[cursor];
|
|
236
|
-
cursor += 1;
|
|
237
|
-
if (!job.url) throw new Error(`分片 ${job.index} 没有预签名地址`);
|
|
238
|
-
await withRetry(
|
|
239
|
-
async () => {
|
|
240
|
-
const res = await client.fetchFn(job.url, {
|
|
241
|
-
method: "PUT",
|
|
242
|
-
body: job.slice,
|
|
243
|
-
signal: timeoutSignal(UPLOAD_TIMEOUT_MS),
|
|
244
|
-
});
|
|
245
|
-
if (!res.ok) throw new Error(`分片上传失败 HTTP ${res.status} ${short(await res.text())}`);
|
|
246
|
-
await client.postJson(`${base}/upload_part_finish`, {
|
|
247
|
-
upload_id: uploadId,
|
|
248
|
-
part_index: job.index,
|
|
249
|
-
block_size: String(job.size),
|
|
250
|
-
md5: job.md5,
|
|
251
|
-
});
|
|
252
|
-
},
|
|
253
|
-
UPLOAD_PART_ATTEMPTS,
|
|
254
|
-
retryDelayMs,
|
|
255
|
-
);
|
|
256
|
-
done += 1;
|
|
257
|
-
opts.onProgress?.(done, jobs.length);
|
|
258
|
-
}
|
|
259
|
-
};
|
|
260
|
-
await Promise.all(Array.from({ length: Math.min(concurrency, jobs.length) }, () => worker()));
|
|
261
|
-
|
|
262
|
-
// 4) 合并,拿 file_info
|
|
263
|
-
const merged = await client.postJson(`${base}/files`, {
|
|
264
|
-
file_type: fileType,
|
|
265
|
-
upload_id: uploadId,
|
|
266
|
-
file_name: fileName,
|
|
267
|
-
srv_send_msg: false,
|
|
268
|
-
});
|
|
269
|
-
const fileInfo = String(merged?.file_info ?? "");
|
|
270
|
-
if (!fileInfo) throw new Error(`上传合并失败:${short(JSON.stringify(merged))}`);
|
|
271
|
-
|
|
272
|
-
opts.log?.(
|
|
273
|
-
"[send] 上传完成",
|
|
274
|
-
fileName,
|
|
275
|
-
formatBytes(buffer.length),
|
|
276
|
-
`file_type=${fileType}`,
|
|
277
|
-
`ttl=${merged?.ttl ?? 0}`,
|
|
278
|
-
);
|
|
279
|
-
return { fileInfo, ttl: Number(merged?.ttl ?? 0), fileType, fileName, size: buffer.length, md5: hashes.md5 };
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** 回执里用的类型说明 */
|
|
283
|
-
export function mediaKindLabel(fileType: MediaFileType): string {
|
|
284
|
-
if (fileType === 1) return "图片";
|
|
285
|
-
if (fileType === 2) return "视频";
|
|
286
|
-
if (fileType === 3) return "语音";
|
|
287
|
-
return "文件卡片";
|
|
288
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 把本机文件发到 QQ(单聊富媒体上传)。
|
|
3
|
+
*
|
|
4
|
+
* 官方接口(2026-07 文档):
|
|
5
|
+
* 1) POST /v2/users/{openid}/upload_prepare → upload_id / block_size / parts[].presigned_url
|
|
6
|
+
* 2) PUT <presigned_url> → 直接推分片字节(不带 Authorization)
|
|
7
|
+
* 3) POST /v2/users/{openid}/upload_part_finish { upload_id, part_index, block_size, md5 }
|
|
8
|
+
* 4) POST /v2/users/{openid}/files { file_type, upload_id, file_name, srv_send_msg:false }
|
|
9
|
+
* → file_info / ttl
|
|
10
|
+
* 5) 再走发消息接口:msg_type=7 + media.file_info(见 qq/api.ts 的 sendC2CMedia)
|
|
11
|
+
*
|
|
12
|
+
* 只有 URL 上传和分片上传两种方式能突破 5MB;本地文件没有公网 URL,所以统一走分片。
|
|
13
|
+
* `file_type`:1=图片(png/jpg) 2=视频(mp4) 3=语音(silk) 4=文件;其余扩展名一律 4。
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_SEND_MAX_BYTES,
|
|
20
|
+
UPLOAD_PART_ATTEMPTS,
|
|
21
|
+
UPLOAD_PART_FALLBACK_BYTES,
|
|
22
|
+
UPLOAD_TIMEOUT_MS,
|
|
23
|
+
} from "../constants";
|
|
24
|
+
|
|
25
|
+
/** QQ 富媒体业务类型 */
|
|
26
|
+
export type MediaFileType = 1 | 2 | 3 | 4;
|
|
27
|
+
|
|
28
|
+
const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg"]);
|
|
29
|
+
const VIDEO_EXT = new Set([".mp4"]);
|
|
30
|
+
const VOICE_EXT = new Set([".silk"]);
|
|
31
|
+
/** QQ 侧硬上限 */
|
|
32
|
+
const HARD_LIMIT_BYTES = 200 * 1024 * 1024;
|
|
33
|
+
/** md5_10m 取文件前 10002432 字节(约 9.54MB,官方定义) */
|
|
34
|
+
const MD5_10M_BYTES = 10_002_432;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 扩展名 → file_type。
|
|
38
|
+
* 注意:只有 png/jpg 算图片、mp4 算视频、silk 算语音;其它(gif/webp/mp3/pdf/zip…)
|
|
39
|
+
* 必须按「文件」发,否则服务端报 850019 不支持的文件格式。
|
|
40
|
+
*/
|
|
41
|
+
export function mediaFileTypeOf(filePath: string): MediaFileType {
|
|
42
|
+
const ext = path.extname(String(filePath ?? "")).toLowerCase();
|
|
43
|
+
if (IMAGE_EXT.has(ext)) return 1;
|
|
44
|
+
if (VIDEO_EXT.has(ext)) return 2;
|
|
45
|
+
if (VOICE_EXT.has(ext)) return 3;
|
|
46
|
+
return 4;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RichMediaUploadOptions {
|
|
50
|
+
/** REST 基址(生产/沙箱) */
|
|
51
|
+
restBase: string;
|
|
52
|
+
/** 目标用户 openid */
|
|
53
|
+
openid: string;
|
|
54
|
+
getToken: () => Promise<string>;
|
|
55
|
+
/** 要发送的本机文件路径 */
|
|
56
|
+
filePath: string;
|
|
57
|
+
/** 在 QQ 里显示的文件名(默认取 basename) */
|
|
58
|
+
fileName?: string;
|
|
59
|
+
/** 扩展里的大小上限(默认 30MB,QQ 硬限 200MB) */
|
|
60
|
+
maxBytes?: number;
|
|
61
|
+
/** 覆盖自动判定的 file_type */
|
|
62
|
+
fileType?: MediaFileType;
|
|
63
|
+
onProgress?: (done: number, total: number) => void;
|
|
64
|
+
log?: (message: string, ...rest: unknown[]) => void;
|
|
65
|
+
fetchFn?: typeof fetch;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface UploadedMedia {
|
|
69
|
+
/** 发送消息时用的 media.file_info(原样透传,不要解析) */
|
|
70
|
+
fileInfo: string;
|
|
71
|
+
/** 有效期(秒),0 = 长期可用 */
|
|
72
|
+
ttl: number;
|
|
73
|
+
fileType: MediaFileType;
|
|
74
|
+
fileName: string;
|
|
75
|
+
size: number;
|
|
76
|
+
md5: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatBytes(bytes: number): string {
|
|
80
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "?";
|
|
81
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
82
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
83
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function short(value: string, max = 200): string {
|
|
87
|
+
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
88
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function timeoutSignal(ms: number): AbortSignal | undefined {
|
|
92
|
+
const anyAbort = AbortSignal as any;
|
|
93
|
+
return typeof anyAbort?.timeout === "function" ? anyAbort.timeout(ms) : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sanitizeFileName(name: string): string {
|
|
97
|
+
const base = String(name ?? "")
|
|
98
|
+
.replace(/[\\/]+/g, "_")
|
|
99
|
+
.replace(/[\u0000-\u001f<>:"|?*]/g, "_")
|
|
100
|
+
.trim();
|
|
101
|
+
const clipped = base.slice(0, 100);
|
|
102
|
+
return clipped || "file.bin";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** md5 / sha1 / md5_10m(上传预检要这三个) */
|
|
106
|
+
export function hashFileBuffer(buffer: Buffer): { md5: string; sha1: string; md5_10m: string } {
|
|
107
|
+
return {
|
|
108
|
+
md5: crypto.createHash("md5").update(buffer).digest("hex"),
|
|
109
|
+
sha1: crypto.createHash("sha1").update(buffer).digest("hex"),
|
|
110
|
+
md5_10m: crypto.createHash("md5").update(buffer.subarray(0, MD5_10M_BYTES)).digest("hex"),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 把 QQ 的错误码翻成人话(用于回执) */
|
|
115
|
+
export function explainUploadError(error: unknown): string {
|
|
116
|
+
const text = String((error as any)?.message ?? error ?? "");
|
|
117
|
+
const hints: [RegExp, string][] = [
|
|
118
|
+
[/850031/, "文件超过 QQ 大小限制"],
|
|
119
|
+
[/850019/, "QQ 不支持该文件格式(图片只收 png/jpg,视频只收 mp4,语音只收 silk)"],
|
|
120
|
+
[/40093002/, "今天的发送文件容量已达上限(QQ 限制),明天再试或换小文件"],
|
|
121
|
+
[/850027/, "QQ 侧发送超时,稍后重试"],
|
|
122
|
+
[/850018/, "机器人被禁言或受频率限制"],
|
|
123
|
+
[/850026/, "QQ 下载原始文件失败"],
|
|
124
|
+
[/40093001/, "QQ 转存失败,请重试"],
|
|
125
|
+
];
|
|
126
|
+
for (const [pattern, hint] of hints) {
|
|
127
|
+
if (pattern.test(text)) return `${hint}${text ? `(${short(text)})` : ""}`;
|
|
128
|
+
}
|
|
129
|
+
return short(text) || "未知错误";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function withRetry<T>(task: () => Promise<T>, attempts: number, delayMs: number): Promise<T> {
|
|
133
|
+
let lastError: unknown;
|
|
134
|
+
for (let i = 0; i < Math.max(1, attempts); i += 1) {
|
|
135
|
+
try {
|
|
136
|
+
return await task();
|
|
137
|
+
} catch (e) {
|
|
138
|
+
lastError = e;
|
|
139
|
+
if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
throw lastError;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function makeClient(opts: RichMediaUploadOptions, token: string) {
|
|
146
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
147
|
+
const timeoutMs = UPLOAD_TIMEOUT_MS;
|
|
148
|
+
const postJson = async (url: string, body: unknown): Promise<any> => {
|
|
149
|
+
const res = await fetchFn(url, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { "Content-Type": "application/json", Authorization: `QQBot ${token}` },
|
|
152
|
+
body: JSON.stringify(body),
|
|
153
|
+
signal: timeoutSignal(timeoutMs),
|
|
154
|
+
});
|
|
155
|
+
const text = await res.text();
|
|
156
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${short(text)}`);
|
|
157
|
+
try {
|
|
158
|
+
return text ? JSON.parse(text) : {};
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error(`响应不是 JSON:${short(text)}`);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
return { fetchFn, postJson };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 分片上传一个本机文件,返回可用于发消息的 file_info。
|
|
168
|
+
* 失败抛异常(调用方用 explainUploadError 转成回执文案)。
|
|
169
|
+
*/
|
|
170
|
+
export async function uploadRichMedia(opts: RichMediaUploadOptions): Promise<UploadedMedia> {
|
|
171
|
+
const filePath = path.resolve(String(opts.filePath ?? ""));
|
|
172
|
+
let stat: { isFile(): boolean; size: number };
|
|
173
|
+
try {
|
|
174
|
+
stat = await fs.promises.stat(filePath);
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error(`文件不存在:${filePath}`);
|
|
177
|
+
}
|
|
178
|
+
if (!stat.isFile()) throw new Error(`不是普通文件:${filePath}`);
|
|
179
|
+
if (stat.size === 0) throw new Error(`文件是空的:${filePath}`);
|
|
180
|
+
|
|
181
|
+
const maxBytes = Math.max(1, Number(opts.maxBytes ?? DEFAULT_SEND_MAX_BYTES));
|
|
182
|
+
if (stat.size > HARD_LIMIT_BYTES) {
|
|
183
|
+
throw new Error(`文件过大(${formatBytes(stat.size)}),QQ 硬上限 ${formatBytes(HARD_LIMIT_BYTES)}`);
|
|
184
|
+
}
|
|
185
|
+
if (stat.size > maxBytes) {
|
|
186
|
+
throw new Error(`文件过大(${formatBytes(stat.size)} > 上限 ${formatBytes(maxBytes)})`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const buffer = await fs.promises.readFile(filePath);
|
|
190
|
+
const fileName = sanitizeFileName(opts.fileName || path.basename(filePath));
|
|
191
|
+
const fileType = opts.fileType ?? mediaFileTypeOf(filePath);
|
|
192
|
+
const hashes = hashFileBuffer(buffer);
|
|
193
|
+
const base = `${opts.restBase}/v2/users/${encodeURIComponent(opts.openid)}`;
|
|
194
|
+
const token = await opts.getToken();
|
|
195
|
+
const client = makeClient(opts, token);
|
|
196
|
+
|
|
197
|
+
// 1) 预上传
|
|
198
|
+
const prepared = await client.postJson(`${base}/upload_prepare`, {
|
|
199
|
+
file_type: fileType,
|
|
200
|
+
file_size: String(buffer.length),
|
|
201
|
+
file_name: fileName,
|
|
202
|
+
...hashes,
|
|
203
|
+
});
|
|
204
|
+
const uploadId = String(prepared?.upload_id ?? "");
|
|
205
|
+
if (!uploadId) throw new Error(`预上传失败:缺少 upload_id(${short(JSON.stringify(prepared))})`);
|
|
206
|
+
|
|
207
|
+
const parts: any[] = Array.isArray(prepared.parts) ? [...prepared.parts] : [];
|
|
208
|
+
parts.sort((a, b) => Number(a?.index ?? 0) - Number(b?.index ?? 0));
|
|
209
|
+
if (parts.length === 0) throw new Error("预上传失败:没有返回分片信息");
|
|
210
|
+
|
|
211
|
+
const fallbackPartSize = Number(prepared.block_size ?? 0) || UPLOAD_PART_FALLBACK_BYTES;
|
|
212
|
+
let offset = 0;
|
|
213
|
+
const jobs = parts.map((p) => {
|
|
214
|
+
const size = Number(p?.block_size ?? 0) || fallbackPartSize;
|
|
215
|
+
const slice = buffer.subarray(offset, offset + size);
|
|
216
|
+
offset += size;
|
|
217
|
+
return {
|
|
218
|
+
index: Number(p?.index ?? 0),
|
|
219
|
+
url: String(p?.presigned_url ?? ""),
|
|
220
|
+
slice,
|
|
221
|
+
size: slice.length,
|
|
222
|
+
md5: crypto.createHash("md5").update(slice).digest("hex"),
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const uploadConfig = prepared?.upload_config ?? {};
|
|
227
|
+
const concurrency = Math.max(1, Math.min(8, Number(uploadConfig.concurrency ?? 1) || 1));
|
|
228
|
+
const retryDelayMs = Math.max(200, Number(uploadConfig.retry_delay ?? 1) * 1000 || 1000);
|
|
229
|
+
|
|
230
|
+
// 2)+3) 分片 PUT + 确认
|
|
231
|
+
let done = 0;
|
|
232
|
+
let cursor = 0;
|
|
233
|
+
const worker = async () => {
|
|
234
|
+
while (cursor < jobs.length) {
|
|
235
|
+
const job = jobs[cursor];
|
|
236
|
+
cursor += 1;
|
|
237
|
+
if (!job.url) throw new Error(`分片 ${job.index} 没有预签名地址`);
|
|
238
|
+
await withRetry(
|
|
239
|
+
async () => {
|
|
240
|
+
const res = await client.fetchFn(job.url, {
|
|
241
|
+
method: "PUT",
|
|
242
|
+
body: job.slice,
|
|
243
|
+
signal: timeoutSignal(UPLOAD_TIMEOUT_MS),
|
|
244
|
+
});
|
|
245
|
+
if (!res.ok) throw new Error(`分片上传失败 HTTP ${res.status} ${short(await res.text())}`);
|
|
246
|
+
await client.postJson(`${base}/upload_part_finish`, {
|
|
247
|
+
upload_id: uploadId,
|
|
248
|
+
part_index: job.index,
|
|
249
|
+
block_size: String(job.size),
|
|
250
|
+
md5: job.md5,
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
UPLOAD_PART_ATTEMPTS,
|
|
254
|
+
retryDelayMs,
|
|
255
|
+
);
|
|
256
|
+
done += 1;
|
|
257
|
+
opts.onProgress?.(done, jobs.length);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, jobs.length) }, () => worker()));
|
|
261
|
+
|
|
262
|
+
// 4) 合并,拿 file_info
|
|
263
|
+
const merged = await client.postJson(`${base}/files`, {
|
|
264
|
+
file_type: fileType,
|
|
265
|
+
upload_id: uploadId,
|
|
266
|
+
file_name: fileName,
|
|
267
|
+
srv_send_msg: false,
|
|
268
|
+
});
|
|
269
|
+
const fileInfo = String(merged?.file_info ?? "");
|
|
270
|
+
if (!fileInfo) throw new Error(`上传合并失败:${short(JSON.stringify(merged))}`);
|
|
271
|
+
|
|
272
|
+
opts.log?.(
|
|
273
|
+
"[send] 上传完成",
|
|
274
|
+
fileName,
|
|
275
|
+
formatBytes(buffer.length),
|
|
276
|
+
`file_type=${fileType}`,
|
|
277
|
+
`ttl=${merged?.ttl ?? 0}`,
|
|
278
|
+
);
|
|
279
|
+
return { fileInfo, ttl: Number(merged?.ttl ?? 0), fileType, fileName, size: buffer.length, md5: hashes.md5 };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 回执里用的类型说明 */
|
|
283
|
+
export function mediaKindLabel(fileType: MediaFileType): string {
|
|
284
|
+
if (fileType === 1) return "图片";
|
|
285
|
+
if (fileType === 2) return "视频";
|
|
286
|
+
if (fileType === 3) return "语音";
|
|
287
|
+
return "文件卡片";
|
|
288
|
+
}
|
package/remote.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { QQBotConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「以下对话来自远程用户」提醒。
|
|
5
|
+
*
|
|
6
|
+
* pi 在锁屏/无人值守的机器上替 QQ 侧的远程用户干活:对方看不见屏幕,也没法点弹窗。
|
|
7
|
+
* 所以在每个会话第一次真正把 QQ 消息交给 pi 跑起来时,把一段说明写进系统提示的
|
|
8
|
+
* `qq-remote-context` section(排在用户消息之前、QQ 侧不可见),让它从一开始就按
|
|
9
|
+
* 「远程 + 可能锁屏」的前提工作(默认离屏渲染,而不是截桌面)。
|
|
10
|
+
*
|
|
11
|
+
* 注入时机由 index.ts 的 before_agent_start 控制:只有本扩展派发的 QQ 消息真的开跑
|
|
12
|
+
* 才注入;仅载入扩展、或本机 TUI 直接对话都不会触发。上下文压缩后由 session_compact 复位。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface RemoteConfig {
|
|
16
|
+
/** 每会话第一条消息注入一次提醒 */
|
|
17
|
+
notice: boolean;
|
|
18
|
+
/** 每次来自 QQ 的 run 在系统提示里追加几条规则 */
|
|
19
|
+
guidelines: boolean;
|
|
20
|
+
/** 追加在默认提醒末尾的自定义补充(留空 = 只用默认文案) */
|
|
21
|
+
extra: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_REMOTE: RemoteConfig = {
|
|
25
|
+
notice: true,
|
|
26
|
+
guidelines: true,
|
|
27
|
+
extra: "",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** 说明放在系统提示的哪个 section 里(排查会话文件时靠这个 tag 认出来) */
|
|
31
|
+
export const REMOTE_NOTICE_SECTION = "qq-remote-context";
|
|
32
|
+
|
|
33
|
+
/** 「远程前提作废」更正用的 section tag */
|
|
34
|
+
export const LOCAL_HANDOFF_SECTION = "qq-local-handoff";
|
|
35
|
+
|
|
36
|
+
/** 配置里的 `remote` 可能缺字段(老配置、测试里手搓的 cfg),这里补齐默认值 */
|
|
37
|
+
export function resolveRemoteConfig(cfg: Partial<Pick<QQBotConfig, "remote">> | null | undefined): RemoteConfig {
|
|
38
|
+
const raw = cfg?.remote ?? {};
|
|
39
|
+
return {
|
|
40
|
+
notice: raw.notice ?? DEFAULT_REMOTE.notice,
|
|
41
|
+
guidelines: raw.guidelines ?? DEFAULT_REMOTE.guidelines,
|
|
42
|
+
extra: String(raw.extra ?? DEFAULT_REMOTE.extra).trim(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 首条消息前的说明:说清来源、锁屏风险、离屏渲染要求 */
|
|
47
|
+
export function buildRemoteNotice(remote: RemoteConfig): string {
|
|
48
|
+
const lines = [
|
|
49
|
+
"[pi-qqbot 远程上下文 · 说明,不必回复本条]",
|
|
50
|
+
"",
|
|
51
|
+
"接下来的对话来自「远程 QQ 私聊用户」,不是本机终端用户。前提:",
|
|
52
|
+
"",
|
|
53
|
+
"1. 对方看不到本机屏幕、也收不到弹窗;回复应简明扼要。",
|
|
54
|
+
"2. 本机可能锁屏或无人值守。默认不要用桌面截屏、屏幕 OCR、UI 自动化、键鼠模拟、带界面的浏览器" +
|
|
55
|
+
"(例如 mcp__screenshot、mcp__winui 这类工具):锁屏下它们只会拍到锁屏画面或直接失败。",
|
|
56
|
+
"3. 需要图/界面效果时,按项目类型自行选择离屏渲染方案(headless 浏览器渲 HTML、SVG、matplotlib/Pillow 等)," +
|
|
57
|
+
"生成 PNG 后用 qq_send_file 发到 QQ。不要为了“确认”先截桌面。",
|
|
58
|
+
"4. 只有确实需要判断是否锁屏时,才允许截一次桌面看有没有内容;其余一律以离屏渲染为准。",
|
|
59
|
+
"5. 需要人工介入(登录、验证码、实体操作)时直接说明并给替代方案;" +
|
|
60
|
+
"危险工具会先发到 QQ 等审批,对方可能不在电脑旁。",
|
|
61
|
+
];
|
|
62
|
+
if (remote.extra) lines.push("", remote.extra);
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 本机接管后的更正:只在「上一轮来自 QQ」的第一次本机对话注入(只生效一轮) */
|
|
67
|
+
export function buildLocalHandoffNotice(): string {
|
|
68
|
+
return [
|
|
69
|
+
"现在是本机用户在终端直接对话(同一会话内接管),之前「远程 QQ 用户」的前提作废:",
|
|
70
|
+
"可以正常截屏 / 看屏幕,回复详略按本机需求来,不需要用 qq_send_file 交付。",
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 系统提示里追加的规则(比首条提醒更短,每次 QQ run 都带上) */
|
|
75
|
+
export function buildRemoteGuidelines(): string[] {
|
|
76
|
+
return [
|
|
77
|
+
"对话来自远程 QQ 用户:对方看不到本机屏幕,回复应简明扼要。",
|
|
78
|
+
"本机可能锁屏:默认禁用桌面截屏/屏幕 OCR/UI 自动化/键鼠模拟/带界面浏览器;要图就离屏渲染,再用 qq_send_file 发送。",
|
|
79
|
+
"需要本机人工操作时直接说明,不要等待或反复重试。",
|
|
80
|
+
];
|
|
81
|
+
}
|