@zxh126/pi-qqbot 2.3.0 → 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/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
+ }