@ryantest/openclaw-qqbot 0.0.3 → 1.6.6-alpha.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/README.md +2 -15
- package/README.zh.md +3 -16
- package/dist/src/admin-resolver.d.ts +12 -6
- package/dist/src/admin-resolver.js +69 -34
- package/dist/src/api.d.ts +105 -1
- package/dist/src/api.js +164 -15
- package/dist/src/channel.js +13 -0
- package/dist/src/config.js +3 -10
- package/dist/src/deliver-debounce.d.ts +74 -0
- package/dist/src/deliver-debounce.js +174 -0
- package/dist/src/gateway.js +450 -248
- package/dist/src/image-server.d.ts +27 -8
- package/dist/src/image-server.js +179 -71
- package/dist/src/inbound-attachments.d.ts +3 -1
- package/dist/src/inbound-attachments.js +28 -14
- package/dist/src/outbound-deliver.js +77 -148
- package/dist/src/outbound.d.ts +6 -4
- package/dist/src/outbound.js +266 -442
- package/dist/src/reply-dispatcher.js +4 -4
- package/dist/src/request-context.d.ts +18 -0
- package/dist/src/request-context.js +30 -0
- package/dist/src/slash-commands.js +277 -32
- package/dist/src/startup-greeting.d.ts +5 -5
- package/dist/src/startup-greeting.js +32 -13
- package/dist/src/streaming.d.ts +244 -0
- package/dist/src/streaming.js +907 -0
- package/dist/src/tools/remind.js +11 -10
- package/dist/src/types.d.ts +101 -0
- package/dist/src/types.js +17 -1
- package/dist/src/update-checker.js +2 -8
- package/dist/src/utils/audio-convert.d.ts +9 -0
- package/dist/src/utils/audio-convert.js +51 -0
- package/dist/src/utils/chunked-upload.d.ts +59 -0
- package/dist/src/utils/chunked-upload.js +289 -0
- package/dist/src/utils/file-utils.d.ts +7 -1
- package/dist/src/utils/file-utils.js +24 -2
- package/dist/src/utils/media-send.d.ts +147 -0
- package/dist/src/utils/media-send.js +434 -0
- package/dist/src/utils/pkg-version.d.ts +5 -0
- package/dist/src/utils/pkg-version.js +51 -0
- package/dist/src/utils/ssrf-guard.d.ts +25 -0
- package/dist/src/utils/ssrf-guard.js +91 -0
- package/node_modules/ws/index.js +15 -6
- package/node_modules/ws/lib/permessage-deflate.js +6 -6
- package/node_modules/ws/lib/websocket-server.js +5 -5
- package/node_modules/ws/lib/websocket.js +6 -6
- package/node_modules/ws/package.json +4 -3
- package/node_modules/ws/wrapper.mjs +14 -1
- package/openclaw.plugin.json +1 -0
- package/package.json +11 -22
- package/scripts/postinstall-link-sdk.js +113 -0
- package/scripts/upgrade-via-npm.ps1 +161 -6
- package/scripts/upgrade-via-npm.sh +311 -104
- package/scripts/upgrade-via-source.sh +117 -0
- package/skills/qqbot-media/SKILL.md +9 -5
- package/skills/qqbot-remind/SKILL.md +3 -3
- package/src/admin-resolver.ts +76 -35
- package/src/api.ts +284 -12
- package/src/channel.ts +12 -0
- package/src/config.ts +3 -10
- package/src/deliver-debounce.ts +229 -0
- package/src/gateway.ts +277 -67
- package/src/image-server.ts +213 -77
- package/src/inbound-attachments.ts +32 -15
- package/src/outbound-deliver.ts +77 -157
- package/src/outbound.ts +304 -451
- package/src/reply-dispatcher.ts +4 -4
- package/src/request-context.ts +39 -0
- package/src/slash-commands.ts +303 -33
- package/src/startup-greeting.ts +35 -13
- package/src/streaming.ts +1096 -0
- package/src/tools/remind.ts +15 -11
- package/src/types.ts +111 -0
- package/src/update-checker.ts +2 -7
- package/src/utils/audio-convert.ts +56 -0
- package/src/utils/chunked-upload.ts +419 -0
- package/src/utils/file-utils.ts +28 -2
- package/src/utils/media-send.ts +563 -0
- package/src/utils/pkg-version.ts +54 -0
- package/src/utils/ssrf-guard.ts +102 -0
- package/clawdbot.plugin.json +0 -16
- package/dist/src/user-messages.d.ts +0 -8
- package/dist/src/user-messages.js +0 -8
- package/moltbot.plugin.json +0 -16
- package/scripts/upgrade-via-alt-pkg.sh +0 -307
- package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
- package/src/gateway.log +0 -43
- package/src/openclaw-2026-03-21.log +0 -3729
- package/src/user-messages.ts +0 -7
package/dist/src/tools/remind.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getRequestTarget } from "../request-context.js";
|
|
1
2
|
// ========== JSON Schema ==========
|
|
2
3
|
const RemindSchema = {
|
|
3
4
|
type: "object",
|
|
@@ -13,8 +14,8 @@ const RemindSchema = {
|
|
|
13
14
|
},
|
|
14
15
|
to: {
|
|
15
16
|
type: "string",
|
|
16
|
-
description: "
|
|
17
|
-
"私聊格式:user_openid,群聊格式:group:group_openid。
|
|
17
|
+
description: "投递目标地址(可选)。系统会自动从当前会话获取,通常无需手动填写。" +
|
|
18
|
+
"私聊格式:qqbot:c2c:user_openid,群聊格式:qqbot:group:group_openid。",
|
|
18
19
|
},
|
|
19
20
|
time: {
|
|
20
21
|
type: "string",
|
|
@@ -99,9 +100,8 @@ function generateJobName(content) {
|
|
|
99
100
|
/**
|
|
100
101
|
* 构建一次性提醒的 cron 工具参数
|
|
101
102
|
*/
|
|
102
|
-
function buildOnceJob(params, delayMs) {
|
|
103
|
+
function buildOnceJob(params, delayMs, to) {
|
|
103
104
|
const atMs = Date.now() + delayMs;
|
|
104
|
-
const to = params.to;
|
|
105
105
|
const content = params.content;
|
|
106
106
|
const name = params.name || generateJobName(content);
|
|
107
107
|
return {
|
|
@@ -125,8 +125,7 @@ function buildOnceJob(params, delayMs) {
|
|
|
125
125
|
/**
|
|
126
126
|
* 构建周期提醒的 cron 工具参数
|
|
127
127
|
*/
|
|
128
|
-
function buildCronJob(params) {
|
|
129
|
-
const to = params.to;
|
|
128
|
+
function buildCronJob(params, to) {
|
|
130
129
|
const content = params.content;
|
|
131
130
|
const name = params.name || generateJobName(content);
|
|
132
131
|
const tz = params.timezone || "Asia/Shanghai";
|
|
@@ -207,8 +206,10 @@ export function registerRemindTool(api) {
|
|
|
207
206
|
if (!p.content) {
|
|
208
207
|
return json({ error: "action=add 时 content(提醒内容)为必填参数" });
|
|
209
208
|
}
|
|
210
|
-
|
|
211
|
-
|
|
209
|
+
// 优先使用 AI 传入的 to,否则自动从请求级上下文获取(AsyncLocalStorage)
|
|
210
|
+
const resolvedTo = p.to || getRequestTarget();
|
|
211
|
+
if (!resolvedTo) {
|
|
212
|
+
return json({ error: "action=add 时无法确定投递目标地址。请稍后重试。" });
|
|
212
213
|
}
|
|
213
214
|
if (!p.time) {
|
|
214
215
|
return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
|
|
@@ -216,7 +217,7 @@ export function registerRemindTool(api) {
|
|
|
216
217
|
// 判断是 cron 表达式还是相对时间
|
|
217
218
|
if (isCronExpression(p.time)) {
|
|
218
219
|
// 周期提醒
|
|
219
|
-
const cronJob = buildCronJob(p);
|
|
220
|
+
const cronJob = buildCronJob(p, resolvedTo);
|
|
220
221
|
return json({
|
|
221
222
|
_instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
|
|
222
223
|
cronParams: cronJob,
|
|
@@ -235,7 +236,7 @@ export function registerRemindTool(api) {
|
|
|
235
236
|
if (delayMs < 30_000) {
|
|
236
237
|
return json({ error: "提醒时间不能少于 30 秒" });
|
|
237
238
|
}
|
|
238
|
-
const onceJob = buildOnceJob(p, delayMs);
|
|
239
|
+
const onceJob = buildOnceJob(p, delayMs, resolvedTo);
|
|
239
240
|
return json({
|
|
240
241
|
_instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
|
|
241
242
|
cronParams: onceJob,
|
package/dist/src/types.d.ts
CHANGED
|
@@ -68,6 +68,45 @@ export interface QQBotAccountConfig {
|
|
|
68
68
|
* - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新
|
|
69
69
|
*/
|
|
70
70
|
upgradeMode?: "doc" | "hot-reload";
|
|
71
|
+
/**
|
|
72
|
+
* 出站消息合并回复(debounce)配置
|
|
73
|
+
* 当短时间内收到多次 deliver 时,将文本合并为一条消息发送,避免消息轰炸
|
|
74
|
+
*/
|
|
75
|
+
deliverDebounce?: DeliverDebounceConfig;
|
|
76
|
+
/**
|
|
77
|
+
* 是否启用流式消息(默认 false)
|
|
78
|
+
* 启用后,AI 的回复会以流式形式逐步显示在 QQ 聊天中,
|
|
79
|
+
* 用户可以看到文字逐字出现的打字机效果。
|
|
80
|
+
* 设置为 true 可开启流式消息。
|
|
81
|
+
*
|
|
82
|
+
* 注意:仅 C2C(私聊)支持流式消息 API。
|
|
83
|
+
*/
|
|
84
|
+
streaming?: boolean;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 出站消息合并回复配置
|
|
88
|
+
*/
|
|
89
|
+
export interface DeliverDebounceConfig {
|
|
90
|
+
/**
|
|
91
|
+
* 是否启用合并回复(默认 true)
|
|
92
|
+
*/
|
|
93
|
+
enabled?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* 合并窗口时长(毫秒),在此时间内的连续 deliver 会被合并
|
|
96
|
+
* 默认 1500ms
|
|
97
|
+
*/
|
|
98
|
+
windowMs?: number;
|
|
99
|
+
/**
|
|
100
|
+
* 最大等待时长(毫秒),从第一条 deliver 开始计算,超过此时间强制发送
|
|
101
|
+
* 防止持续有新 deliver 导致一直不发送
|
|
102
|
+
* 默认 8000ms
|
|
103
|
+
*/
|
|
104
|
+
maxWaitMs?: number;
|
|
105
|
+
/**
|
|
106
|
+
* 合并文本之间的分隔符
|
|
107
|
+
* 默认 "\n\n---\n\n"
|
|
108
|
+
*/
|
|
109
|
+
separator?: string;
|
|
71
110
|
}
|
|
72
111
|
/**
|
|
73
112
|
* 音频格式策略:控制哪些格式可跳过转换
|
|
@@ -173,3 +212,65 @@ export interface WSPayload {
|
|
|
173
212
|
s?: number;
|
|
174
213
|
t?: string;
|
|
175
214
|
}
|
|
215
|
+
/** 流式消息输入模式 */
|
|
216
|
+
export declare const StreamInputMode: {
|
|
217
|
+
/** 每次发送的 content_raw 替换整条消息内容 */
|
|
218
|
+
readonly REPLACE: "replace";
|
|
219
|
+
};
|
|
220
|
+
export type StreamInputMode = (typeof StreamInputMode)[keyof typeof StreamInputMode];
|
|
221
|
+
/** 流式消息输入状态 */
|
|
222
|
+
export declare const StreamInputState: {
|
|
223
|
+
/** 正文生成中 */
|
|
224
|
+
readonly GENERATING: 1;
|
|
225
|
+
/** 正文生成结束(终结状态) */
|
|
226
|
+
readonly DONE: 10;
|
|
227
|
+
};
|
|
228
|
+
export type StreamInputState = (typeof StreamInputState)[keyof typeof StreamInputState];
|
|
229
|
+
/** 流式消息内容类型 */
|
|
230
|
+
export declare const StreamContentType: {
|
|
231
|
+
readonly MARKDOWN: "markdown";
|
|
232
|
+
};
|
|
233
|
+
export type StreamContentType = (typeof StreamContentType)[keyof typeof StreamContentType];
|
|
234
|
+
/**
|
|
235
|
+
* 流式消息请求体
|
|
236
|
+
* 对应 StreamReq proto
|
|
237
|
+
*/
|
|
238
|
+
export interface StreamMessageRequest {
|
|
239
|
+
/** 输入模式 */
|
|
240
|
+
input_mode: StreamInputMode;
|
|
241
|
+
/** 输入状态 */
|
|
242
|
+
input_state: StreamInputState;
|
|
243
|
+
/** 内容类型 */
|
|
244
|
+
content_type: StreamContentType;
|
|
245
|
+
/** markdown 内容 */
|
|
246
|
+
content_raw: string;
|
|
247
|
+
/** 事件 ID */
|
|
248
|
+
event_id: string;
|
|
249
|
+
/** 原始消息 ID */
|
|
250
|
+
msg_id: string;
|
|
251
|
+
/** 流式消息 ID,首次发送后返回,后续分片需携带 */
|
|
252
|
+
stream_msg_id?: string;
|
|
253
|
+
/** 递增序号 */
|
|
254
|
+
msg_seq: number;
|
|
255
|
+
/** 同一条流式会话内的发送索引,从 0 开始,每次发送前递增;新流式会话重新从 0 开始 */
|
|
256
|
+
index: number;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* 流式消息响应体
|
|
260
|
+
* 对应 StreamRsp proto
|
|
261
|
+
*
|
|
262
|
+
* 成功时返回:{ id, timestamp, extInfo }(无 code/message)
|
|
263
|
+
* 失败时返回:{ code, message }(code > 0)
|
|
264
|
+
*/
|
|
265
|
+
export interface StreamMessageResponse {
|
|
266
|
+
/** 错误码,仅失败时存在(> 0 表示失败);成功时不存在 */
|
|
267
|
+
code?: number;
|
|
268
|
+
/** 错误信息,仅失败时存在 */
|
|
269
|
+
message?: string;
|
|
270
|
+
/** 流式消息 ID */
|
|
271
|
+
id?: string;
|
|
272
|
+
/** 时间戳 */
|
|
273
|
+
timestamp?: string;
|
|
274
|
+
/** 扩展信息 */
|
|
275
|
+
extInfo?: Record<string, unknown>;
|
|
276
|
+
}
|
package/dist/src/types.js
CHANGED
|
@@ -1 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
// ---- 流式消息常量 ----
|
|
2
|
+
/** 流式消息输入模式 */
|
|
3
|
+
export const StreamInputMode = {
|
|
4
|
+
/** 每次发送的 content_raw 替换整条消息内容 */
|
|
5
|
+
REPLACE: "replace",
|
|
6
|
+
};
|
|
7
|
+
/** 流式消息输入状态 */
|
|
8
|
+
export const StreamInputState = {
|
|
9
|
+
/** 正文生成中 */
|
|
10
|
+
GENERATING: 1,
|
|
11
|
+
/** 正文生成结束(终结状态) */
|
|
12
|
+
DONE: 10,
|
|
13
|
+
};
|
|
14
|
+
/** 流式消息内容类型 */
|
|
15
|
+
export const StreamContentType = {
|
|
16
|
+
MARKDOWN: "markdown",
|
|
17
|
+
};
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import https from "node:https";
|
|
12
|
+
import { getPackageVersion } from "./utils/pkg-version.js";
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const PKG_NAME = "@tencent-connect/openclaw-qqbot";
|
|
14
15
|
const ENCODED_PKG = encodeURIComponent(PKG_NAME);
|
|
@@ -16,14 +17,7 @@ const REGISTRIES = [
|
|
|
16
17
|
`https://registry.npmjs.org/${ENCODED_PKG}`,
|
|
17
18
|
`https://registry.npmmirror.com/${ENCODED_PKG}`,
|
|
18
19
|
];
|
|
19
|
-
let CURRENT_VERSION =
|
|
20
|
-
try {
|
|
21
|
-
const pkg = require("../package.json");
|
|
22
|
-
CURRENT_VERSION = pkg.version ?? "unknown";
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
// fallback
|
|
26
|
-
}
|
|
20
|
+
let CURRENT_VERSION = getPackageVersion(import.meta.url);
|
|
27
21
|
let _log;
|
|
28
22
|
function fetchJson(url, timeoutMs) {
|
|
29
23
|
return new Promise((resolve, reject) => {
|
|
@@ -72,6 +72,15 @@ export declare function textToSilk(text: string, ttsCfg: TTSConfig, outputDir: s
|
|
|
72
72
|
* @param directUploadFormats - 自定义直传格式列表,覆盖默认值。传 undefined 使用 QQ_NATIVE_UPLOAD_FORMATS
|
|
73
73
|
*/
|
|
74
74
|
export declare function audioFileToSilkBase64(filePath: string, directUploadFormats?: string[]): Promise<string | null>;
|
|
75
|
+
/**
|
|
76
|
+
* 将音频文件转码为 SILK,**输出到临时文件**(供分片上传使用)。
|
|
77
|
+
*
|
|
78
|
+
* 如果文件已经是 QQ 原生格式(WAV/MP3/SILK)或已经是 SILK 编码,
|
|
79
|
+
* 则直接返回原文件路径(不需要转码)。
|
|
80
|
+
*
|
|
81
|
+
* @returns 转码后的文件路径,或 null 表示转码失败
|
|
82
|
+
*/
|
|
83
|
+
export declare function audioFileToSilkFile(filePath: string, directUploadFormats?: string[]): Promise<string | null>;
|
|
75
84
|
/**
|
|
76
85
|
* 等待文件就绪(轮询直到文件出现且大小稳定)
|
|
77
86
|
* 用于 TTS 生成后等待文件写入完成
|
|
@@ -434,6 +434,57 @@ export async function audioFileToSilkBase64(filePath, directUploadFormats) {
|
|
|
434
434
|
console.error(`[audio-convert] unsupported format: ${ext} (no ffmpeg available). ${installHint}`);
|
|
435
435
|
return null;
|
|
436
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* 将音频文件转码为 SILK,**输出到临时文件**(供分片上传使用)。
|
|
439
|
+
*
|
|
440
|
+
* 如果文件已经是 QQ 原生格式(WAV/MP3/SILK)或已经是 SILK 编码,
|
|
441
|
+
* 则直接返回原文件路径(不需要转码)。
|
|
442
|
+
*
|
|
443
|
+
* @returns 转码后的文件路径,或 null 表示转码失败
|
|
444
|
+
*/
|
|
445
|
+
export async function audioFileToSilkFile(filePath, directUploadFormats) {
|
|
446
|
+
if (!fs.existsSync(filePath))
|
|
447
|
+
return null;
|
|
448
|
+
const buf = fs.readFileSync(filePath);
|
|
449
|
+
if (buf.length === 0) {
|
|
450
|
+
console.error(`[audio-convert] file is empty: ${filePath}`);
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
454
|
+
// 0. 直传格式 → 直接返回原文件
|
|
455
|
+
const uploadFormats = directUploadFormats ? normalizeFormats(directUploadFormats) : QQ_NATIVE_UPLOAD_FORMATS;
|
|
456
|
+
if (uploadFormats.includes(ext)) {
|
|
457
|
+
console.log(`[audio-convert] direct upload (QQ native format): ${ext} (${buf.length} bytes)`);
|
|
458
|
+
return filePath;
|
|
459
|
+
}
|
|
460
|
+
// 1. 已经是 SILK 编码 → 直接返回原文件
|
|
461
|
+
if ([".slk", ".slac"].includes(ext)) {
|
|
462
|
+
const stripped = stripAmrHeader(buf);
|
|
463
|
+
const raw = new Uint8Array(stripped.buffer, stripped.byteOffset, stripped.byteLength);
|
|
464
|
+
if (isSilk(raw)) {
|
|
465
|
+
console.log(`[audio-convert] SILK file, direct use: ${filePath} (${buf.length} bytes)`);
|
|
466
|
+
return filePath;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const rawCheck = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
470
|
+
const strippedCheck = stripAmrHeader(buf);
|
|
471
|
+
const strippedRaw = new Uint8Array(strippedCheck.buffer, strippedCheck.byteOffset, strippedCheck.byteLength);
|
|
472
|
+
if (isSilk(rawCheck) || isSilk(strippedRaw)) {
|
|
473
|
+
console.log(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`);
|
|
474
|
+
return filePath;
|
|
475
|
+
}
|
|
476
|
+
// 需要转码 → 调用 audioFileToSilkBase64 获取结果,写入临时文件
|
|
477
|
+
const silkBase64 = await audioFileToSilkBase64(filePath, directUploadFormats);
|
|
478
|
+
if (!silkBase64)
|
|
479
|
+
return null;
|
|
480
|
+
const silkBuffer = Buffer.from(silkBase64, "base64");
|
|
481
|
+
const os = await import("node:os");
|
|
482
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "voice-silk-"));
|
|
483
|
+
const tmpFile = path.join(tmpDir, `voice${Date.now()}.silk`);
|
|
484
|
+
fs.writeFileSync(tmpFile, silkBuffer);
|
|
485
|
+
console.log(`[audio-convert] SILK written to temp file: ${tmpFile} (${silkBuffer.length} bytes)`);
|
|
486
|
+
return tmpFile;
|
|
487
|
+
}
|
|
437
488
|
/**
|
|
438
489
|
* 等待文件就绪(轮询直到文件出现且大小稳定)
|
|
439
490
|
* 用于 TTS 生成后等待文件写入完成
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 大文件分片上传模块
|
|
3
|
+
*
|
|
4
|
+
* 流程(对照序列图):
|
|
5
|
+
* 1. 申请上传 (upload_prepare) → 获取 upload_id + block_size + 分片预签名链接
|
|
6
|
+
* 2. 并行上传所有分片:
|
|
7
|
+
* 对于每个分片 i(并行执行,但分片内部串行):
|
|
8
|
+
* a. 读取文件的第 i 块数据
|
|
9
|
+
* b. PUT 到预签名 URL (COS)
|
|
10
|
+
* c. 调用 upload_part_finish 通知开放平台分片 i 已完成
|
|
11
|
+
* 3. 所有分片完成后,调用完成文件上传接口 → 获取 file_info
|
|
12
|
+
*
|
|
13
|
+
* 注意:N 个分片之间是并行的,但每个分片的"上传 + 完成"是串行的。
|
|
14
|
+
*/
|
|
15
|
+
import { type MediaFileType, type MediaUploadResponse } from "../api.js";
|
|
16
|
+
/** 分片上传进度回调 */
|
|
17
|
+
export interface ChunkedUploadProgress {
|
|
18
|
+
/** 当前已完成分片数 */
|
|
19
|
+
completedParts: number;
|
|
20
|
+
/** 总分片数 */
|
|
21
|
+
totalParts: number;
|
|
22
|
+
/** 已上传字节数 */
|
|
23
|
+
uploadedBytes: number;
|
|
24
|
+
/** 总字节数 */
|
|
25
|
+
totalBytes: number;
|
|
26
|
+
}
|
|
27
|
+
/** 分片上传选项 */
|
|
28
|
+
export interface ChunkedUploadOptions {
|
|
29
|
+
/** 进度回调 */
|
|
30
|
+
onProgress?: (progress: ChunkedUploadProgress) => void;
|
|
31
|
+
/** 最大并发数(默认 5) */
|
|
32
|
+
maxConcurrent?: number;
|
|
33
|
+
/** 日志前缀 */
|
|
34
|
+
logPrefix?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* C2C 大文件分片上传
|
|
38
|
+
*
|
|
39
|
+
* @param appId - 应用 ID
|
|
40
|
+
* @param clientSecret - 应用密钥
|
|
41
|
+
* @param userId - 用户 openid
|
|
42
|
+
* @param filePath - 本地文件路径
|
|
43
|
+
* @param fileType - 文件类型(1=图片, 2=视频, 3=语音, 4=文件)
|
|
44
|
+
* @param options - 上传选项
|
|
45
|
+
* @returns 上传结果(包含 file_info 可直接用于发送消息)
|
|
46
|
+
*/
|
|
47
|
+
export declare function chunkedUploadC2C(appId: string, clientSecret: string, userId: string, filePath: string, fileType: MediaFileType, options?: ChunkedUploadOptions): Promise<MediaUploadResponse>;
|
|
48
|
+
/**
|
|
49
|
+
* Group 大文件分片上传
|
|
50
|
+
*
|
|
51
|
+
* @param appId - 应用 ID
|
|
52
|
+
* @param clientSecret - 应用密钥
|
|
53
|
+
* @param groupId - 群 openid
|
|
54
|
+
* @param filePath - 本地文件路径
|
|
55
|
+
* @param fileType - 文件类型(1=图片, 2=视频, 3=语音, 4=文件)
|
|
56
|
+
* @param options - 上传选项
|
|
57
|
+
* @returns 上传结果(包含 file_info 可直接用于发送消息)
|
|
58
|
+
*/
|
|
59
|
+
export declare function chunkedUploadGroup(appId: string, clientSecret: string, groupId: string, filePath: string, fileType: MediaFileType, options?: ChunkedUploadOptions): Promise<MediaUploadResponse>;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 大文件分片上传模块
|
|
3
|
+
*
|
|
4
|
+
* 流程(对照序列图):
|
|
5
|
+
* 1. 申请上传 (upload_prepare) → 获取 upload_id + block_size + 分片预签名链接
|
|
6
|
+
* 2. 并行上传所有分片:
|
|
7
|
+
* 对于每个分片 i(并行执行,但分片内部串行):
|
|
8
|
+
* a. 读取文件的第 i 块数据
|
|
9
|
+
* b. PUT 到预签名 URL (COS)
|
|
10
|
+
* c. 调用 upload_part_finish 通知开放平台分片 i 已完成
|
|
11
|
+
* 3. 所有分片完成后,调用完成文件上传接口 → 获取 file_info
|
|
12
|
+
*
|
|
13
|
+
* 注意:N 个分片之间是并行的,但每个分片的"上传 + 完成"是串行的。
|
|
14
|
+
*/
|
|
15
|
+
import * as crypto from "node:crypto";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import { c2cUploadPrepare, c2cUploadPartFinish, c2cCompleteUpload, groupUploadPrepare, groupUploadPartFinish, groupCompleteUpload, getAccessToken, } from "../api.js";
|
|
18
|
+
import { formatFileSize } from "./file-utils.js";
|
|
19
|
+
/** 分片上传并发控制:最多同时上传 N 个分片 */
|
|
20
|
+
const MAX_CONCURRENT_PARTS = 5;
|
|
21
|
+
/** 单个分片上传超时(毫秒)— 5 分钟,兼容低带宽场景 */
|
|
22
|
+
const PART_UPLOAD_TIMEOUT = 300_000;
|
|
23
|
+
/** 单个分片上传最大重试次数 */
|
|
24
|
+
const PART_UPLOAD_MAX_RETRIES = 2;
|
|
25
|
+
/**
|
|
26
|
+
* C2C 大文件分片上传
|
|
27
|
+
*
|
|
28
|
+
* @param appId - 应用 ID
|
|
29
|
+
* @param clientSecret - 应用密钥
|
|
30
|
+
* @param userId - 用户 openid
|
|
31
|
+
* @param filePath - 本地文件路径
|
|
32
|
+
* @param fileType - 文件类型(1=图片, 2=视频, 3=语音, 4=文件)
|
|
33
|
+
* @param options - 上传选项
|
|
34
|
+
* @returns 上传结果(包含 file_info 可直接用于发送消息)
|
|
35
|
+
*/
|
|
36
|
+
export async function chunkedUploadC2C(appId, clientSecret, userId, filePath, fileType, options) {
|
|
37
|
+
const prefix = options?.logPrefix ?? "[chunked-upload]";
|
|
38
|
+
const maxConcurrent = options?.maxConcurrent ?? MAX_CONCURRENT_PARTS;
|
|
39
|
+
// 1. 读取文件信息
|
|
40
|
+
const stat = await fs.promises.stat(filePath);
|
|
41
|
+
const fileSize = stat.size;
|
|
42
|
+
const fileName = filePath.split(/[/\\]/).pop() ?? "file";
|
|
43
|
+
console.log(`${prefix} Starting chunked upload: file=${fileName}, size=${formatFileSize(fileSize)}, type=${fileType}`);
|
|
44
|
+
// 2. 计算文件哈希(md5, sha1, md5_10m)
|
|
45
|
+
console.log(`${prefix} Computing file hashes...`);
|
|
46
|
+
const hashes = await computeFileHashes(filePath, fileSize);
|
|
47
|
+
console.log(`${prefix} File hashes: md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m}`);
|
|
48
|
+
// 3. 申请上传 → 获取 upload_id + block_size + 预签名链接
|
|
49
|
+
const accessToken = await getAccessToken(appId, clientSecret);
|
|
50
|
+
console.log(`${prefix} >>> Calling c2cUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
|
|
51
|
+
const prepareResp = await c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes);
|
|
52
|
+
console.log(`${prefix} <<< c2cUploadPrepare response:`, JSON.stringify(prepareResp));
|
|
53
|
+
const { upload_id, parts } = prepareResp;
|
|
54
|
+
// QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
|
|
55
|
+
const block_size = Number(prepareResp.block_size);
|
|
56
|
+
console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}`);
|
|
57
|
+
// 4. 并行上传所有分片(带并发控制)
|
|
58
|
+
let completedParts = 0;
|
|
59
|
+
let uploadedBytes = 0;
|
|
60
|
+
const uploadPart = async (part) => {
|
|
61
|
+
const partIndex = part.index; // API 返回的 1-based index
|
|
62
|
+
const partNum = partIndex; // 显示用序号(与 API 一致)
|
|
63
|
+
// 计算本分片在文件中的偏移和长度(index 是 1-based,需要减 1)
|
|
64
|
+
const offset = (partIndex - 1) * block_size;
|
|
65
|
+
const length = Math.min(block_size, fileSize - offset);
|
|
66
|
+
// 读取分片数据
|
|
67
|
+
const partBuffer = await readFileChunk(filePath, offset, length);
|
|
68
|
+
// 计算 MD5
|
|
69
|
+
const md5Hex = crypto.createHash("md5").update(partBuffer).digest("hex");
|
|
70
|
+
console.log(`${prefix} Part ${partNum}/${parts.length}: uploading ${formatFileSize(length)} (offset=${offset}, md5=${md5Hex})`);
|
|
71
|
+
// a. PUT 到预签名 URL(带重试)
|
|
72
|
+
await putToPresignedUrl(part.presigned_url, partBuffer, prefix, partNum, parts.length);
|
|
73
|
+
// b. 通知开放平台分片上传完成(需要重新获取 token,避免长时间上传后 token 过期)
|
|
74
|
+
const token = await getAccessToken(appId, clientSecret);
|
|
75
|
+
console.log(`${prefix} >>> Calling c2cUploadPartFinish(upload_id=${upload_id}, partIndex=${partIndex}, blockSize=${length}, md5=${md5Hex})`);
|
|
76
|
+
await c2cUploadPartFinish(token, userId, upload_id, partIndex, length, md5Hex);
|
|
77
|
+
console.log(`${prefix} <<< c2cUploadPartFinish(partIndex=${partIndex}) done`);
|
|
78
|
+
// 更新进度
|
|
79
|
+
completedParts++;
|
|
80
|
+
uploadedBytes += length;
|
|
81
|
+
console.log(`${prefix} Part ${partNum}/${parts.length}: completed (${completedParts}/${parts.length})`);
|
|
82
|
+
if (options?.onProgress) {
|
|
83
|
+
options.onProgress({
|
|
84
|
+
completedParts,
|
|
85
|
+
totalParts: parts.length,
|
|
86
|
+
uploadedBytes,
|
|
87
|
+
totalBytes: fileSize,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
// 并发控制:同时最多执行 maxConcurrent 个分片上传
|
|
92
|
+
await runWithConcurrency(parts.map(part => () => uploadPart(part)), maxConcurrent);
|
|
93
|
+
console.log(`${prefix} All ${parts.length} parts uploaded successfully, completing upload...`);
|
|
94
|
+
// 5. 完成文件上传
|
|
95
|
+
const finalToken = await getAccessToken(appId, clientSecret);
|
|
96
|
+
console.log(`${prefix} >>> Calling c2cCompleteUpload(upload_id=${upload_id})`);
|
|
97
|
+
const result = await c2cCompleteUpload(finalToken, userId, upload_id);
|
|
98
|
+
console.log(`${prefix} <<< c2cCompleteUpload response:`, JSON.stringify(result));
|
|
99
|
+
console.log(`${prefix} Upload completed: file_uuid=${result.file_uuid}, ttl=${result.ttl}s`);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Group 大文件分片上传
|
|
104
|
+
*
|
|
105
|
+
* @param appId - 应用 ID
|
|
106
|
+
* @param clientSecret - 应用密钥
|
|
107
|
+
* @param groupId - 群 openid
|
|
108
|
+
* @param filePath - 本地文件路径
|
|
109
|
+
* @param fileType - 文件类型(1=图片, 2=视频, 3=语音, 4=文件)
|
|
110
|
+
* @param options - 上传选项
|
|
111
|
+
* @returns 上传结果(包含 file_info 可直接用于发送消息)
|
|
112
|
+
*/
|
|
113
|
+
export async function chunkedUploadGroup(appId, clientSecret, groupId, filePath, fileType, options) {
|
|
114
|
+
const prefix = options?.logPrefix ?? "[chunked-upload]";
|
|
115
|
+
const maxConcurrent = options?.maxConcurrent ?? MAX_CONCURRENT_PARTS;
|
|
116
|
+
// 1. 读取文件信息
|
|
117
|
+
const stat = await fs.promises.stat(filePath);
|
|
118
|
+
const fileSize = stat.size;
|
|
119
|
+
const fileName = filePath.split(/[/\\]/).pop() ?? "file";
|
|
120
|
+
console.log(`${prefix} Starting chunked upload (group): file=${fileName}, size=${formatFileSize(fileSize)}, type=${fileType}`);
|
|
121
|
+
// 2. 计算文件哈希(md5, sha1, md5_10m)
|
|
122
|
+
console.log(`${prefix} Computing file hashes...`);
|
|
123
|
+
const hashes = await computeFileHashes(filePath, fileSize);
|
|
124
|
+
console.log(`${prefix} File hashes: md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m}`);
|
|
125
|
+
// 3. 申请上传
|
|
126
|
+
const accessToken = await getAccessToken(appId, clientSecret);
|
|
127
|
+
console.log(`${prefix} >>> Calling groupUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
|
|
128
|
+
const prepareResp = await groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes);
|
|
129
|
+
console.log(`${prefix} <<< groupUploadPrepare response:`, JSON.stringify(prepareResp));
|
|
130
|
+
const { upload_id, parts } = prepareResp;
|
|
131
|
+
// QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
|
|
132
|
+
const block_size = Number(prepareResp.block_size);
|
|
133
|
+
console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}`);
|
|
134
|
+
// 4. 并行上传所有分片(带并发控制)
|
|
135
|
+
let completedParts = 0;
|
|
136
|
+
let uploadedBytes = 0;
|
|
137
|
+
const uploadPart = async (part) => {
|
|
138
|
+
const partIndex = part.index; // API 返回的 1-based index
|
|
139
|
+
const partNum = partIndex; // 显示用序号(与 API 一致)
|
|
140
|
+
const offset = (partIndex - 1) * block_size;
|
|
141
|
+
const length = Math.min(block_size, fileSize - offset);
|
|
142
|
+
const partBuffer = await readFileChunk(filePath, offset, length);
|
|
143
|
+
const md5Hex = crypto.createHash("md5").update(partBuffer).digest("hex");
|
|
144
|
+
console.log(`${prefix} Part ${partNum}/${parts.length}: uploading ${formatFileSize(length)} (offset=${offset}, md5=${md5Hex})`);
|
|
145
|
+
await putToPresignedUrl(part.presigned_url, partBuffer, prefix, partNum, parts.length);
|
|
146
|
+
const token = await getAccessToken(appId, clientSecret);
|
|
147
|
+
console.log(`${prefix} >>> Calling groupUploadPartFinish(upload_id=${upload_id}, partIndex=${partIndex}, blockSize=${length}, md5=${md5Hex})`);
|
|
148
|
+
await groupUploadPartFinish(token, groupId, upload_id, partIndex, length, md5Hex);
|
|
149
|
+
console.log(`${prefix} <<< groupUploadPartFinish(partIndex=${partIndex}) done`);
|
|
150
|
+
completedParts++;
|
|
151
|
+
uploadedBytes += length;
|
|
152
|
+
console.log(`${prefix} Part ${partNum}/${parts.length}: completed (${completedParts}/${parts.length})`);
|
|
153
|
+
if (options?.onProgress) {
|
|
154
|
+
options.onProgress({
|
|
155
|
+
completedParts,
|
|
156
|
+
totalParts: parts.length,
|
|
157
|
+
uploadedBytes,
|
|
158
|
+
totalBytes: fileSize,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
await runWithConcurrency(parts.map(part => () => uploadPart(part)), maxConcurrent);
|
|
163
|
+
console.log(`${prefix} All ${parts.length} parts uploaded successfully, completing upload...`);
|
|
164
|
+
// 5. 完成文件上传
|
|
165
|
+
const finalToken = await getAccessToken(appId, clientSecret);
|
|
166
|
+
console.log(`${prefix} >>> Calling groupCompleteUpload(upload_id=${upload_id})`);
|
|
167
|
+
const result = await groupCompleteUpload(finalToken, groupId, upload_id);
|
|
168
|
+
console.log(`${prefix} <<< groupCompleteUpload response:`, JSON.stringify(result));
|
|
169
|
+
console.log(`${prefix} Upload completed: file_uuid=${result.file_uuid}, ttl=${result.ttl}s`);
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 读取文件的指定区间(分片)
|
|
174
|
+
*/
|
|
175
|
+
async function readFileChunk(filePath, offset, length) {
|
|
176
|
+
const fd = await fs.promises.open(filePath, "r");
|
|
177
|
+
try {
|
|
178
|
+
const buffer = Buffer.alloc(length);
|
|
179
|
+
const { bytesRead } = await fd.read(buffer, 0, length, offset);
|
|
180
|
+
if (bytesRead < length) {
|
|
181
|
+
// 文件末尾,返回实际读取的部分
|
|
182
|
+
return buffer.subarray(0, bytesRead);
|
|
183
|
+
}
|
|
184
|
+
return buffer;
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
await fd.close();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* PUT 分片数据到预签名 URL(带重试)
|
|
192
|
+
*/
|
|
193
|
+
async function putToPresignedUrl(presignedUrl, data, prefix, partIndex, totalParts) {
|
|
194
|
+
let lastError = null;
|
|
195
|
+
for (let attempt = 0; attempt <= PART_UPLOAD_MAX_RETRIES; attempt++) {
|
|
196
|
+
try {
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const timeoutId = setTimeout(() => controller.abort(), PART_UPLOAD_TIMEOUT);
|
|
199
|
+
try {
|
|
200
|
+
// 将 Buffer 转为标准 ArrayBuffer 再包装为 Blob,兼容 bun-types 类型定义
|
|
201
|
+
const ab = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
202
|
+
const attemptLabel = attempt > 0 ? ` (retry ${attempt})` : "";
|
|
203
|
+
console.log(`${prefix} >>> PUT Part ${partIndex}/${totalParts}${attemptLabel}: url=${presignedUrl}, size=${data.length}`);
|
|
204
|
+
const startTime = Date.now();
|
|
205
|
+
const response = await fetch(presignedUrl, {
|
|
206
|
+
method: "PUT",
|
|
207
|
+
body: new Blob([ab]),
|
|
208
|
+
headers: {
|
|
209
|
+
"Content-Length": String(data.length),
|
|
210
|
+
},
|
|
211
|
+
signal: controller.signal,
|
|
212
|
+
});
|
|
213
|
+
const elapsed = Date.now() - startTime;
|
|
214
|
+
const etag = response.headers.get("ETag") ?? "-";
|
|
215
|
+
const requestId = response.headers.get("x-cos-request-id") ?? "-";
|
|
216
|
+
if (!response.ok) {
|
|
217
|
+
const body = await response.text().catch(() => "");
|
|
218
|
+
console.error(`${prefix} <<< PUT Part ${partIndex}/${totalParts}: FAILED ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${body}`);
|
|
219
|
+
throw new Error(`COS PUT failed: ${response.status} ${response.statusText} - ${body}`);
|
|
220
|
+
}
|
|
221
|
+
console.log(`${prefix} <<< PUT Part ${partIndex}/${totalParts}: ${response.status} OK (${elapsed}ms, ETag=${etag}, requestId=${requestId})`);
|
|
222
|
+
return; // 成功
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
clearTimeout(timeoutId);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
230
|
+
if (lastError.name === "AbortError") {
|
|
231
|
+
lastError = new Error(`Part ${partIndex}/${totalParts} upload timeout after ${PART_UPLOAD_TIMEOUT}ms`);
|
|
232
|
+
}
|
|
233
|
+
if (attempt < PART_UPLOAD_MAX_RETRIES) {
|
|
234
|
+
const delay = 1000 * Math.pow(2, attempt);
|
|
235
|
+
console.warn(`${prefix} Part ${partIndex}/${totalParts}: attempt ${attempt + 1} failed (${lastError.message}), retrying in ${delay}ms...`);
|
|
236
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
throw lastError;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* 带并发限制的异步任务执行器(批次模式)
|
|
244
|
+
* 每批最多执行 maxConcurrent 个任务,等全部完成后再启动下一批
|
|
245
|
+
*/
|
|
246
|
+
async function runWithConcurrency(tasks, maxConcurrent) {
|
|
247
|
+
for (let i = 0; i < tasks.length; i += maxConcurrent) {
|
|
248
|
+
const batch = tasks.slice(i, i + maxConcurrent);
|
|
249
|
+
await Promise.all(batch.map(task => task()));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// ============ 文件哈希计算 ============
|
|
253
|
+
/** 文件前 N 字节用于计算 md5_10m(与协议定义一致:10002432 Bytes) */
|
|
254
|
+
const MD5_10M_SIZE = 10002432;
|
|
255
|
+
/**
|
|
256
|
+
* 流式计算文件的 MD5、SHA1、md5_10m(前 10002432 Bytes 的 MD5)
|
|
257
|
+
* 只遍历文件一次,内存友好
|
|
258
|
+
*/
|
|
259
|
+
async function computeFileHashes(filePath, fileSize) {
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
const md5Hash = crypto.createHash("md5");
|
|
262
|
+
const sha1Hash = crypto.createHash("sha1");
|
|
263
|
+
const md5_10mHash = crypto.createHash("md5");
|
|
264
|
+
let bytesRead = 0;
|
|
265
|
+
const need10m = fileSize > MD5_10M_SIZE; // 文件超过阈值才需要单独计算 md5_10m
|
|
266
|
+
const stream = fs.createReadStream(filePath);
|
|
267
|
+
stream.on("data", (chunk) => {
|
|
268
|
+
// ReadStream 默认 encoding=null,chunk 一定是 Buffer,但类型声明要求兼容 string
|
|
269
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
270
|
+
md5Hash.update(buf);
|
|
271
|
+
sha1Hash.update(buf);
|
|
272
|
+
if (need10m) {
|
|
273
|
+
const remaining = MD5_10M_SIZE - bytesRead;
|
|
274
|
+
if (remaining > 0) {
|
|
275
|
+
md5_10mHash.update(remaining >= buf.length ? buf : buf.subarray(0, remaining));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
bytesRead += buf.length;
|
|
279
|
+
});
|
|
280
|
+
stream.on("end", () => {
|
|
281
|
+
const md5 = md5Hash.digest("hex");
|
|
282
|
+
const sha1 = sha1Hash.digest("hex");
|
|
283
|
+
// 文件不足 MD5_10M_SIZE 时,md5_10m 等于整文件 MD5
|
|
284
|
+
const md5_10m = need10m ? md5_10mHash.digest("hex") : md5;
|
|
285
|
+
resolve({ md5, sha1, md5_10m });
|
|
286
|
+
});
|
|
287
|
+
stream.on("error", reject);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 文件操作工具 — 异步读取 + 大小校验 + 进度提示
|
|
3
3
|
*/
|
|
4
|
-
/** QQ Bot API
|
|
4
|
+
/** QQ Bot API 各类型文件上传大小限制(QQ 机器人上行) */
|
|
5
|
+
export declare const UPLOAD_SIZE_LIMITS: Record<number, number>;
|
|
6
|
+
/** 获取文件类型的中文名称;未知类型返回 "文件" */
|
|
7
|
+
export declare function getFileTypeName(fileType: number): string;
|
|
8
|
+
/** 获取指定文件类型的上传大小限制;未知类型默认 200MB */
|
|
9
|
+
export declare function getMaxUploadSize(fileType: number): number;
|
|
10
|
+
/** @deprecated 使用 getMaxUploadSize(fileType) 代替 */
|
|
5
11
|
export declare const MAX_UPLOAD_SIZE: number;
|
|
6
12
|
/** 大文件阈值(超过此值发送进度提示):5MB */
|
|
7
13
|
export declare const LARGE_FILE_THRESHOLD: number;
|