@tencent-connect/openclaw-qqbot 1.6.6-alpha.1 → 1.6.6-alpha.3
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 -3
- package/README.zh.md +2 -3
- package/dist/src/api.d.ts +26 -4
- package/dist/src/api.js +99 -9
- package/dist/src/outbound-deliver.js +4 -3
- package/dist/src/outbound.js +13 -1
- package/dist/src/utils/chunked-upload.d.ts +11 -2
- package/dist/src/utils/chunked-upload.js +63 -11
- package/dist/src/utils/media-send.d.ts +1 -0
- package/dist/src/utils/media-send.js +22 -0
- package/package.json +3 -2
- package/scripts/prebuild-stub.cjs +172 -0
- package/src/api.ts +115 -4
- package/src/outbound-deliver.ts +4 -3
- package/src/outbound.ts +13 -1
- package/src/utils/chunked-upload.ts +76 -12
- package/src/utils/media-send.ts +22 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
**Connect your AI assistant to QQ — private chat, group chat, and rich media, all in one plugin.**
|
|
12
12
|
|
|
13
|
-
### 🚀 Current Version: `v1.6.
|
|
13
|
+
### 🚀 Current Version: `v1.6.5`
|
|
14
14
|
|
|
15
15
|
[](./LICENSE)
|
|
16
16
|
[](https://bot.q.qq.com/wiki/)
|
|
@@ -46,7 +46,6 @@ Scan to join the QQ group chat
|
|
|
46
46
|
| 📝 **Markdown** | Full Markdown formatting support |
|
|
47
47
|
| 🛠️ **Commands** | Native OpenClaw command integration |
|
|
48
48
|
| 💬 **Quoted Context** | Resolve QQ `REFIDX_*` quoted messages and inject quote body into AI context |
|
|
49
|
-
| 📦 **Large File Support** | Auto chunked upload for large files (parallel upload with retry), up to 100 MB |
|
|
50
49
|
|
|
51
50
|
---
|
|
52
51
|
|
|
@@ -130,7 +129,7 @@ This capability depends on OpenClaw cron scheduling and proactive messaging. If
|
|
|
130
129
|
>
|
|
131
130
|
> **QQBot**: *(sends a .txt file)*
|
|
132
131
|
|
|
133
|
-
AI can send files directly. Any format, up to
|
|
132
|
+
AI can send files directly. Any format, up to 20MB.
|
|
134
133
|
|
|
135
134
|
<img width="360" src="docs/images/17cada70df90185d45a2d6dd36e92f2f_720.jpg" alt="File Sending Demo" />
|
|
136
135
|
|
package/README.zh.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
**让你的 AI 助手接入 QQ — 私聊、群聊、富媒体,一个插件全搞定。**
|
|
11
11
|
|
|
12
|
-
### 🚀 当前版本: `v1.6.
|
|
12
|
+
### 🚀 当前版本: `v1.6.5`
|
|
13
13
|
|
|
14
14
|
[](./LICENSE)
|
|
15
15
|
[](https://bot.q.qq.com/wiki/)
|
|
@@ -41,7 +41,6 @@
|
|
|
41
41
|
| 📝 **Markdown** | 完整支持 Markdown 格式消息 |
|
|
42
42
|
| 🛠️ **原生命令** | 支持 OpenClaw 原生命令 |
|
|
43
43
|
| 💬 **引用上下文** | 解析 QQ `REFIDX_*` 引用消息,并将引用内容注入 AI 上下文 |
|
|
44
|
-
| 📦 **大文件支持** | 大文件自动分片并行上传,最大支持 100 MB |
|
|
45
44
|
|
|
46
45
|
---
|
|
47
46
|
|
|
@@ -125,7 +124,7 @@ AI 可直接发送语音消息。格式:mp3/wav/silk/ogg,无需安装 ffmpeg
|
|
|
125
124
|
>
|
|
126
125
|
> **QQBot**:*(发送 .txt 文件)*
|
|
127
126
|
|
|
128
|
-
AI 可直接发送文件。任意格式,最大
|
|
127
|
+
AI 可直接发送文件。任意格式,最大 20MB。
|
|
129
128
|
|
|
130
129
|
<img width="360" src="docs/images/17cada70df90185d45a2d6dd36e92f2f_720.jpg" alt="发文件演示" />
|
|
131
130
|
|
package/dist/src/api.d.ts
CHANGED
|
@@ -2,11 +2,19 @@
|
|
|
2
2
|
* QQ Bot API 鉴权和请求封装
|
|
3
3
|
* [修复版] 已重构为支持多实例并发,消除全局变量冲突
|
|
4
4
|
*/
|
|
5
|
-
/** API 请求错误,携带 HTTP status code */
|
|
5
|
+
/** API 请求错误,携带 HTTP status code 和业务错误码 */
|
|
6
6
|
export declare class ApiError extends Error {
|
|
7
7
|
readonly status: number;
|
|
8
8
|
readonly path: string;
|
|
9
|
-
|
|
9
|
+
/** 业务错误码(回包中的 code / err_code 字段),不一定存在 */
|
|
10
|
+
readonly bizCode?: number | undefined;
|
|
11
|
+
/** 回包中的原始 message 字段(用于向用户展示兜底文案) */
|
|
12
|
+
readonly bizMessage?: string | undefined;
|
|
13
|
+
constructor(message: string, status: number, path: string,
|
|
14
|
+
/** 业务错误码(回包中的 code / err_code 字段),不一定存在 */
|
|
15
|
+
bizCode?: number | undefined,
|
|
16
|
+
/** 回包中的原始 message 字段(用于向用户展示兜底文案) */
|
|
17
|
+
bizMessage?: string | undefined);
|
|
10
18
|
}
|
|
11
19
|
export declare const PLUGIN_USER_AGENT: string;
|
|
12
20
|
/** 出站消息元信息(结构化存储,不做预格式化) */
|
|
@@ -69,6 +77,16 @@ export declare function getNextMsgSeq(_msgId: string): number;
|
|
|
69
77
|
* API 请求封装
|
|
70
78
|
*/
|
|
71
79
|
export declare function apiRequest<T = unknown>(accessToken: string, method: string, path: string, body?: unknown, timeoutMs?: number): Promise<T>;
|
|
80
|
+
/**
|
|
81
|
+
* 需要持续重试的业务错误码集合
|
|
82
|
+
* 当 upload_part_finish 返回这些错误码时,会以固定 1s 间隔持续重试直到成功或超时
|
|
83
|
+
*/
|
|
84
|
+
export declare const PART_FINISH_RETRYABLE_CODES: Set<number>;
|
|
85
|
+
/**
|
|
86
|
+
* upload_prepare 接口命中此错误码时,使用回包中的 message 字段作为兜底文案发送给用户
|
|
87
|
+
* 而非走通用的"文件发送失败,请稍后重试"
|
|
88
|
+
*/
|
|
89
|
+
export declare const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
|
|
72
90
|
export declare function getGatewayUrl(accessToken: string): Promise<string>;
|
|
73
91
|
export interface MessageResponse {
|
|
74
92
|
id: string;
|
|
@@ -128,6 +146,10 @@ export interface UploadPrepareResponse {
|
|
|
128
146
|
block_size: number;
|
|
129
147
|
/** 分片列表(含预签名链接) */
|
|
130
148
|
parts: UploadPart[];
|
|
149
|
+
/** 上传并发数(由服务端控制,可选,不返回时使用客户端默认值) */
|
|
150
|
+
concurrency?: number;
|
|
151
|
+
/** upload_part_finish 特定错误码的重试超时时间(秒),由服务端控制,客户端上限 10 分钟 */
|
|
152
|
+
retry_timeout?: number;
|
|
131
153
|
}
|
|
132
154
|
/** 完成文件上传响应(与 UploadMediaResponse 一致) */
|
|
133
155
|
export interface MediaUploadResponse {
|
|
@@ -171,7 +193,7 @@ export declare function c2cUploadPrepare(accessToken: string, userId: string, fi
|
|
|
171
193
|
* @param blockSize - 分块大小(字节)
|
|
172
194
|
* @param md5 - 分片数据的 MD5(十六进制)
|
|
173
195
|
*/
|
|
174
|
-
export declare function c2cUploadPartFinish(accessToken: string, userId: string, uploadId: string, partIndex: number, blockSize: number, md5: string): Promise<void>;
|
|
196
|
+
export declare function c2cUploadPartFinish(accessToken: string, userId: string, uploadId: string, partIndex: number, blockSize: number, md5: string, retryTimeoutMs?: number): Promise<void>;
|
|
175
197
|
/**
|
|
176
198
|
* 完成文件上传(C2C)
|
|
177
199
|
* POST /v2/users/{user_id}/files
|
|
@@ -191,7 +213,7 @@ export declare function groupUploadPrepare(accessToken: string, groupId: string,
|
|
|
191
213
|
* 完成分片上传(Group)
|
|
192
214
|
* POST /v2/groups/{group_id}/upload_part_finish
|
|
193
215
|
*/
|
|
194
|
-
export declare function groupUploadPartFinish(accessToken: string, groupId: string, uploadId: string, partIndex: number, blockSize: number, md5: string): Promise<void>;
|
|
216
|
+
export declare function groupUploadPartFinish(accessToken: string, groupId: string, uploadId: string, partIndex: number, blockSize: number, md5: string, retryTimeoutMs?: number): Promise<void>;
|
|
195
217
|
/**
|
|
196
218
|
* 完成文件上传(Group)
|
|
197
219
|
* POST /v2/groups/{group_id}/files
|
package/dist/src/api.js
CHANGED
|
@@ -6,14 +6,22 @@ import os from "node:os";
|
|
|
6
6
|
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
|
|
7
7
|
import { sanitizeFileName } from "./utils/platform.js";
|
|
8
8
|
// ============ 自定义错误 ============
|
|
9
|
-
/** API 请求错误,携带 HTTP status code */
|
|
9
|
+
/** API 请求错误,携带 HTTP status code 和业务错误码 */
|
|
10
10
|
export class ApiError extends Error {
|
|
11
11
|
status;
|
|
12
12
|
path;
|
|
13
|
-
|
|
13
|
+
bizCode;
|
|
14
|
+
bizMessage;
|
|
15
|
+
constructor(message, status, path,
|
|
16
|
+
/** 业务错误码(回包中的 code / err_code 字段),不一定存在 */
|
|
17
|
+
bizCode,
|
|
18
|
+
/** 回包中的原始 message 字段(用于向用户展示兜底文案) */
|
|
19
|
+
bizMessage) {
|
|
14
20
|
super(message);
|
|
15
21
|
this.status = status;
|
|
16
22
|
this.path = path;
|
|
23
|
+
this.bizCode = bizCode;
|
|
24
|
+
this.bizMessage = bizMessage;
|
|
17
25
|
this.name = "ApiError";
|
|
18
26
|
}
|
|
19
27
|
}
|
|
@@ -264,7 +272,8 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
|
|
|
264
272
|
// JSON 错误响应
|
|
265
273
|
try {
|
|
266
274
|
const error = JSON.parse(rawBody);
|
|
267
|
-
|
|
275
|
+
const bizCode = error.code ?? error.err_code;
|
|
276
|
+
throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path, bizCode, error.message);
|
|
268
277
|
}
|
|
269
278
|
catch (parseErr) {
|
|
270
279
|
if (parseErr instanceof ApiError)
|
|
@@ -332,10 +341,50 @@ async function completeUploadWithRetry(accessToken, method, path, body) {
|
|
|
332
341
|
}
|
|
333
342
|
throw lastError;
|
|
334
343
|
}
|
|
335
|
-
// ============
|
|
344
|
+
// ============ 分片完成重试 ============
|
|
345
|
+
/** 普通错误最大重试次数 */
|
|
336
346
|
const PART_FINISH_MAX_RETRIES = 2;
|
|
337
347
|
const PART_FINISH_BASE_DELAY_MS = 1000;
|
|
338
|
-
|
|
348
|
+
/**
|
|
349
|
+
* 需要持续重试的业务错误码集合
|
|
350
|
+
* 当 upload_part_finish 返回这些错误码时,会以固定 1s 间隔持续重试直到成功或超时
|
|
351
|
+
*/
|
|
352
|
+
export const PART_FINISH_RETRYABLE_CODES = new Set([
|
|
353
|
+
40093001,
|
|
354
|
+
]);
|
|
355
|
+
/**
|
|
356
|
+
* upload_prepare 接口命中此错误码时,使用回包中的 message 字段作为兜底文案发送给用户
|
|
357
|
+
* 而非走通用的"文件发送失败,请稍后重试"
|
|
358
|
+
*/
|
|
359
|
+
export const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
|
|
360
|
+
/** 特定错误码持续重试的默认超时(服务端未返回 retry_timeout 时的兜底) */
|
|
361
|
+
const PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000;
|
|
362
|
+
/** 特定错误码重试的固定间隔(1 秒) */
|
|
363
|
+
const PART_FINISH_RETRYABLE_INTERVAL_MS = 1000;
|
|
364
|
+
/**
|
|
365
|
+
* 判断错误是否命中"需要持续重试"的业务错误码
|
|
366
|
+
*/
|
|
367
|
+
function isRetryableBizCode(err) {
|
|
368
|
+
if (PART_FINISH_RETRYABLE_CODES.size === 0)
|
|
369
|
+
return false;
|
|
370
|
+
if (err instanceof ApiError && err.bizCode !== undefined) {
|
|
371
|
+
return PART_FINISH_RETRYABLE_CODES.has(err.bizCode);
|
|
372
|
+
}
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* 分片完成接口重试策略:
|
|
377
|
+
*
|
|
378
|
+
* 1. 命中 PART_FINISH_RETRYABLE_CODES 的错误码 → 每 1s 重试一次,直到成功或超时
|
|
379
|
+
* 超时时间 = min(API 返回的 retry_timeout, 10 分钟)
|
|
380
|
+
* 2. 其他错误 → 最多重试 PART_FINISH_MAX_RETRIES 次(与之前逻辑一致)
|
|
381
|
+
*
|
|
382
|
+
* 若持续重试超时或普通重试耗尽,抛出错误,调用方(chunkedUpload)
|
|
383
|
+
* 可据此中止后续分片上传。
|
|
384
|
+
*
|
|
385
|
+
* @param retryTimeoutMs - 持续重试的超时时间(毫秒),由 upload_prepare 返回的 retry_timeout 计算得出
|
|
386
|
+
*/
|
|
387
|
+
async function partFinishWithRetry(accessToken, method, path, body, retryTimeoutMs) {
|
|
339
388
|
let lastError = null;
|
|
340
389
|
for (let attempt = 0; attempt <= PART_FINISH_MAX_RETRIES; attempt++) {
|
|
341
390
|
try {
|
|
@@ -344,6 +393,13 @@ async function partFinishWithRetry(accessToken, method, path, body) {
|
|
|
344
393
|
}
|
|
345
394
|
catch (err) {
|
|
346
395
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
396
|
+
// 命中特定错误码 → 进入持续重试模式
|
|
397
|
+
if (isRetryableBizCode(err)) {
|
|
398
|
+
const timeoutMs = retryTimeoutMs ?? PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS;
|
|
399
|
+
console.warn(`[qqbot-api] PartFinish hit retryable bizCode=${err.bizCode}, entering persistent retry (timeout=${timeoutMs / 1000}s, interval=1s)...`);
|
|
400
|
+
await partFinishPersistentRetry(accessToken, method, path, body, timeoutMs);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
347
403
|
if (attempt < PART_FINISH_MAX_RETRIES) {
|
|
348
404
|
const delay = PART_FINISH_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
349
405
|
console.warn(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
|
|
@@ -353,6 +409,40 @@ async function partFinishWithRetry(accessToken, method, path, body) {
|
|
|
353
409
|
}
|
|
354
410
|
throw lastError;
|
|
355
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* 特定错误码的持续重试模式
|
|
414
|
+
* 不限次数,仅受总超时时间约束,固定每 1 秒重试一次
|
|
415
|
+
*/
|
|
416
|
+
async function partFinishPersistentRetry(accessToken, method, path, body, timeoutMs) {
|
|
417
|
+
const deadline = Date.now() + timeoutMs;
|
|
418
|
+
let attempt = 0;
|
|
419
|
+
let lastError = null;
|
|
420
|
+
while (Date.now() < deadline) {
|
|
421
|
+
try {
|
|
422
|
+
await apiRequest(accessToken, method, path, body);
|
|
423
|
+
console.log(`[qqbot-api] PartFinish persistent retry succeeded after ${attempt} retries`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
428
|
+
// 如果不再是可重试的错误码,直接抛出(可能是其他类型的错误)
|
|
429
|
+
if (!isRetryableBizCode(err)) {
|
|
430
|
+
console.error(`[qqbot-api] PartFinish persistent retry: error is no longer retryable (bizCode=${err.bizCode ?? "N/A"}), aborting`);
|
|
431
|
+
throw lastError;
|
|
432
|
+
}
|
|
433
|
+
attempt++;
|
|
434
|
+
const remaining = deadline - Date.now();
|
|
435
|
+
if (remaining <= 0)
|
|
436
|
+
break;
|
|
437
|
+
const actualDelay = Math.min(PART_FINISH_RETRYABLE_INTERVAL_MS, remaining);
|
|
438
|
+
console.warn(`[qqbot-api] PartFinish persistent retry #${attempt}: bizCode=${err.bizCode}, retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`);
|
|
439
|
+
await new Promise(resolve => setTimeout(resolve, actualDelay));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// 超时
|
|
443
|
+
console.error(`[qqbot-api] PartFinish persistent retry timed out after ${timeoutMs / 1000}s (${attempt} attempts)`);
|
|
444
|
+
throw new Error(`upload_part_finish 持续重试超时(${timeoutMs / 1000}s, ${attempt} 次重试),中止上传`);
|
|
445
|
+
}
|
|
356
446
|
export async function getGatewayUrl(accessToken) {
|
|
357
447
|
const data = await apiRequest(accessToken, "GET", "/gateway");
|
|
358
448
|
return data.url;
|
|
@@ -487,8 +577,8 @@ export async function c2cUploadPrepare(accessToken, userId, fileType, fileName,
|
|
|
487
577
|
* @param blockSize - 分块大小(字节)
|
|
488
578
|
* @param md5 - 分片数据的 MD5(十六进制)
|
|
489
579
|
*/
|
|
490
|
-
export async function c2cUploadPartFinish(accessToken, userId, uploadId, partIndex, blockSize, md5) {
|
|
491
|
-
await partFinishWithRetry(accessToken, "POST", `/v2/users/${userId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 });
|
|
580
|
+
export async function c2cUploadPartFinish(accessToken, userId, uploadId, partIndex, blockSize, md5, retryTimeoutMs) {
|
|
581
|
+
await partFinishWithRetry(accessToken, "POST", `/v2/users/${userId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 }, retryTimeoutMs);
|
|
492
582
|
}
|
|
493
583
|
/**
|
|
494
584
|
* 完成文件上传(C2C)
|
|
@@ -513,8 +603,8 @@ export async function groupUploadPrepare(accessToken, groupId, fileType, fileNam
|
|
|
513
603
|
* 完成分片上传(Group)
|
|
514
604
|
* POST /v2/groups/{group_id}/upload_part_finish
|
|
515
605
|
*/
|
|
516
|
-
export async function groupUploadPartFinish(accessToken, groupId, uploadId, partIndex, blockSize, md5) {
|
|
517
|
-
await partFinishWithRetry(accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 });
|
|
606
|
+
export async function groupUploadPartFinish(accessToken, groupId, uploadId, partIndex, blockSize, md5, retryTimeoutMs) {
|
|
607
|
+
await partFinishWithRetry(accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 }, retryTimeoutMs);
|
|
518
608
|
}
|
|
519
609
|
/**
|
|
520
610
|
* 完成文件上传(Group)
|
|
@@ -154,7 +154,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
|
|
|
154
154
|
});
|
|
155
155
|
if (result.error) {
|
|
156
156
|
log?.error(`${prefix} sendMedia(auto) error for ${mediaPath}: ${result.error}`);
|
|
157
|
-
await sendTextChunks(
|
|
157
|
+
await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
|
|
158
158
|
}
|
|
159
159
|
else {
|
|
160
160
|
log?.info(`${prefix} Sent local media: ${mediaPath}`);
|
|
@@ -162,7 +162,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
|
|
|
162
162
|
}
|
|
163
163
|
catch (err) {
|
|
164
164
|
log?.error(`${prefix} sendMedia(auto) failed for ${mediaPath}: ${err}`);
|
|
165
|
-
await sendTextChunks(
|
|
165
|
+
await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
}
|
|
@@ -183,7 +183,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
|
|
|
183
183
|
});
|
|
184
184
|
if (result.error) {
|
|
185
185
|
log?.error(`${prefix} Tool media forward error: ${result.error}`);
|
|
186
|
-
await sendTextChunks(
|
|
186
|
+
await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
|
|
187
187
|
}
|
|
188
188
|
else {
|
|
189
189
|
log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
|
|
@@ -191,6 +191,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
|
|
|
191
191
|
}
|
|
192
192
|
catch (err) {
|
|
193
193
|
log?.error(`${prefix} Tool media forward failed: ${err}`);
|
|
194
|
+
await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
|
|
194
195
|
}
|
|
195
196
|
}
|
|
196
197
|
}
|
package/dist/src/outbound.js
CHANGED
|
@@ -8,7 +8,7 @@ import { decodeCronPayload } from "./utils/payload.js";
|
|
|
8
8
|
import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, sendProactiveC2CMessage, sendProactiveGroupMessage, sendC2CMediaMessage, sendGroupMediaMessage, MediaFileType, } from "./api.js";
|
|
9
9
|
import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
|
|
10
10
|
import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
|
|
11
|
-
import { chunkedUploadC2C, chunkedUploadGroup } from "./utils/chunked-upload.js";
|
|
11
|
+
import { chunkedUploadC2C, chunkedUploadGroup, UploadPrepareFallbackError } from "./utils/chunked-upload.js";
|
|
12
12
|
import { isLocalPath as isLocalFilePath, normalizePath, getQQBotMediaDir } from "./utils/platform.js";
|
|
13
13
|
import { downloadFile } from "./image-server.js";
|
|
14
14
|
import { parseMediaTagsToSendQueue, executeSendQueue } from "./utils/media-send.js";
|
|
@@ -393,6 +393,12 @@ sendMeta) {
|
|
|
393
393
|
catch (err) {
|
|
394
394
|
const msg = err instanceof Error ? err.message : String(err);
|
|
395
395
|
console.error(`${prefix} ${callerName}: c2c chunked upload failed: ${msg}`);
|
|
396
|
+
if (err instanceof UploadPrepareFallbackError) {
|
|
397
|
+
const dir = path.dirname(err.filePath);
|
|
398
|
+
const name = path.basename(err.filePath);
|
|
399
|
+
const size = formatFileSize(err.fileSize);
|
|
400
|
+
return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
|
|
401
|
+
}
|
|
396
402
|
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
397
403
|
}
|
|
398
404
|
}
|
|
@@ -412,6 +418,12 @@ sendMeta) {
|
|
|
412
418
|
catch (err) {
|
|
413
419
|
const msg = err instanceof Error ? err.message : String(err);
|
|
414
420
|
console.error(`${prefix} ${callerName}: group chunked upload failed: ${msg}`);
|
|
421
|
+
if (err instanceof UploadPrepareFallbackError) {
|
|
422
|
+
const dir = path.dirname(err.filePath);
|
|
423
|
+
const name = path.basename(err.filePath);
|
|
424
|
+
const size = formatFileSize(err.fileSize);
|
|
425
|
+
return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
|
|
426
|
+
}
|
|
415
427
|
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
416
428
|
}
|
|
417
429
|
}
|
|
@@ -13,6 +13,17 @@
|
|
|
13
13
|
* 注意:N 个分片之间是并行的,但每个分片的"上传 + 完成"是串行的。
|
|
14
14
|
*/
|
|
15
15
|
import { type MediaFileType, type MediaUploadResponse } from "../api.js";
|
|
16
|
+
/**
|
|
17
|
+
* upload_prepare 返回特定错误码时抛出的错误
|
|
18
|
+
* 调用方根据携带的文件信息构造兜底文案发送给用户
|
|
19
|
+
*/
|
|
20
|
+
export declare class UploadPrepareFallbackError extends Error {
|
|
21
|
+
/** 触发错误的本地文件路径 */
|
|
22
|
+
readonly filePath: string;
|
|
23
|
+
/** 文件大小(字节) */
|
|
24
|
+
readonly fileSize: number;
|
|
25
|
+
constructor(filePath: string, fileSize: number, originalMessage: string);
|
|
26
|
+
}
|
|
16
27
|
/** 分片上传进度回调 */
|
|
17
28
|
export interface ChunkedUploadProgress {
|
|
18
29
|
/** 当前已完成分片数 */
|
|
@@ -28,8 +39,6 @@ export interface ChunkedUploadProgress {
|
|
|
28
39
|
export interface ChunkedUploadOptions {
|
|
29
40
|
/** 进度回调 */
|
|
30
41
|
onProgress?: (progress: ChunkedUploadProgress) => void;
|
|
31
|
-
/** 最大并发数(默认 2) */
|
|
32
|
-
maxConcurrent?: number;
|
|
33
42
|
/** 日志前缀 */
|
|
34
43
|
logPrefix?: string;
|
|
35
44
|
}
|
|
@@ -14,10 +14,30 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import * as crypto from "node:crypto";
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
|
-
import { c2cUploadPrepare, c2cUploadPartFinish, c2cCompleteUpload, groupUploadPrepare, groupUploadPartFinish, groupCompleteUpload, getAccessToken, } from "../api.js";
|
|
17
|
+
import { ApiError, UPLOAD_PREPARE_FALLBACK_CODE, c2cUploadPrepare, c2cUploadPartFinish, c2cCompleteUpload, groupUploadPrepare, groupUploadPartFinish, groupCompleteUpload, getAccessToken, } from "../api.js";
|
|
18
18
|
import { formatFileSize } from "./file-utils.js";
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* upload_prepare 返回特定错误码时抛出的错误
|
|
21
|
+
* 调用方根据携带的文件信息构造兜底文案发送给用户
|
|
22
|
+
*/
|
|
23
|
+
export class UploadPrepareFallbackError extends Error {
|
|
24
|
+
/** 触发错误的本地文件路径 */
|
|
25
|
+
filePath;
|
|
26
|
+
/** 文件大小(字节) */
|
|
27
|
+
fileSize;
|
|
28
|
+
constructor(filePath, fileSize, originalMessage) {
|
|
29
|
+
super(originalMessage);
|
|
30
|
+
this.name = "UploadPrepareFallbackError";
|
|
31
|
+
this.filePath = filePath;
|
|
32
|
+
this.fileSize = fileSize;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** 分片上传默认并发数(服务端未返回 concurrency 时的兜底) */
|
|
36
|
+
const DEFAULT_CONCURRENT_PARTS = 1;
|
|
37
|
+
/** 分片上传并发上限(即使服务端返回更大的值也不超过此限制) */
|
|
38
|
+
const MAX_CONCURRENT_PARTS = 10;
|
|
39
|
+
/** partFinish 特定错误码重试超时上限(10 分钟),即使服务端 retry_timeout 更大也不超过此值 */
|
|
40
|
+
const MAX_PART_FINISH_RETRY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
21
41
|
/** 单个分片上传超时(毫秒)— 5 分钟,兼容低带宽场景 */
|
|
22
42
|
const PART_UPLOAD_TIMEOUT = 300_000;
|
|
23
43
|
/** 单个分片上传最大重试次数 */
|
|
@@ -35,7 +55,6 @@ const PART_UPLOAD_MAX_RETRIES = 2;
|
|
|
35
55
|
*/
|
|
36
56
|
export async function chunkedUploadC2C(appId, clientSecret, userId, filePath, fileType, options) {
|
|
37
57
|
const prefix = options?.logPrefix ?? "[chunked-upload]";
|
|
38
|
-
const maxConcurrent = options?.maxConcurrent ?? MAX_CONCURRENT_PARTS;
|
|
39
58
|
// 1. 读取文件信息
|
|
40
59
|
const stat = await fs.promises.stat(filePath);
|
|
41
60
|
const fileSize = stat.size;
|
|
@@ -48,12 +67,29 @@ export async function chunkedUploadC2C(appId, clientSecret, userId, filePath, fi
|
|
|
48
67
|
// 3. 申请上传 → 获取 upload_id + block_size + 预签名链接
|
|
49
68
|
const accessToken = await getAccessToken(appId, clientSecret);
|
|
50
69
|
console.log(`${prefix} >>> Calling c2cUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
|
|
51
|
-
|
|
70
|
+
let prepareResp;
|
|
71
|
+
try {
|
|
72
|
+
prepareResp = await c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// 命中特定错误码 → 携带文件信息抛出,由上层构造兜底文案
|
|
76
|
+
if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
|
|
77
|
+
console.warn(`${prefix} c2cUploadPrepare hit fallback code ${UPLOAD_PREPARE_FALLBACK_CODE}`);
|
|
78
|
+
throw new UploadPrepareFallbackError(filePath, fileSize, err.message);
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
52
82
|
console.log(`${prefix} <<< c2cUploadPrepare response:`, JSON.stringify(prepareResp));
|
|
53
83
|
const { upload_id, parts } = prepareResp;
|
|
54
84
|
// QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
|
|
55
85
|
const block_size = Number(prepareResp.block_size);
|
|
56
|
-
|
|
86
|
+
// 并发数:使用 API 返回的 concurrency,未返回则用默认值,且不超过上限
|
|
87
|
+
const maxConcurrent = Math.min(prepareResp.concurrency ? Number(prepareResp.concurrency) : DEFAULT_CONCURRENT_PARTS, MAX_CONCURRENT_PARTS);
|
|
88
|
+
// partFinish 特定错误码的重试超时:使用 API 返回的 retry_timeout(秒),上限 10 分钟
|
|
89
|
+
const retryTimeoutMs = prepareResp.retry_timeout
|
|
90
|
+
? Math.min(Number(prepareResp.retry_timeout) * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
|
|
91
|
+
: undefined;
|
|
92
|
+
console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}, concurrency=${maxConcurrent}, retryTimeout=${retryTimeoutMs ? retryTimeoutMs / 1000 + 's' : 'default'}`);
|
|
57
93
|
// 4. 并行上传所有分片(带并发控制)
|
|
58
94
|
let completedParts = 0;
|
|
59
95
|
let uploadedBytes = 0;
|
|
@@ -73,7 +109,7 @@ export async function chunkedUploadC2C(appId, clientSecret, userId, filePath, fi
|
|
|
73
109
|
// b. 通知开放平台分片上传完成(需要重新获取 token,避免长时间上传后 token 过期)
|
|
74
110
|
const token = await getAccessToken(appId, clientSecret);
|
|
75
111
|
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);
|
|
112
|
+
await c2cUploadPartFinish(token, userId, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
|
|
77
113
|
console.log(`${prefix} <<< c2cUploadPartFinish(partIndex=${partIndex}) done`);
|
|
78
114
|
// 更新进度
|
|
79
115
|
completedParts++;
|
|
@@ -112,7 +148,6 @@ export async function chunkedUploadC2C(appId, clientSecret, userId, filePath, fi
|
|
|
112
148
|
*/
|
|
113
149
|
export async function chunkedUploadGroup(appId, clientSecret, groupId, filePath, fileType, options) {
|
|
114
150
|
const prefix = options?.logPrefix ?? "[chunked-upload]";
|
|
115
|
-
const maxConcurrent = options?.maxConcurrent ?? MAX_CONCURRENT_PARTS;
|
|
116
151
|
// 1. 读取文件信息
|
|
117
152
|
const stat = await fs.promises.stat(filePath);
|
|
118
153
|
const fileSize = stat.size;
|
|
@@ -125,12 +160,29 @@ export async function chunkedUploadGroup(appId, clientSecret, groupId, filePath,
|
|
|
125
160
|
// 3. 申请上传
|
|
126
161
|
const accessToken = await getAccessToken(appId, clientSecret);
|
|
127
162
|
console.log(`${prefix} >>> Calling groupUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
|
|
128
|
-
|
|
163
|
+
let prepareResp;
|
|
164
|
+
try {
|
|
165
|
+
prepareResp = await groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
// 命中特定错误码 → 携带文件信息抛出,由上层构造兜底文案
|
|
169
|
+
if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
|
|
170
|
+
console.warn(`${prefix} groupUploadPrepare hit fallback code ${UPLOAD_PREPARE_FALLBACK_CODE}`);
|
|
171
|
+
throw new UploadPrepareFallbackError(filePath, fileSize, err.message);
|
|
172
|
+
}
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
129
175
|
console.log(`${prefix} <<< groupUploadPrepare response:`, JSON.stringify(prepareResp));
|
|
130
176
|
const { upload_id, parts } = prepareResp;
|
|
131
177
|
// QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
|
|
132
178
|
const block_size = Number(prepareResp.block_size);
|
|
133
|
-
|
|
179
|
+
// 并发数:使用 API 返回的 concurrency,未返回则用默认值,且不超过上限
|
|
180
|
+
const maxConcurrent = Math.min(prepareResp.concurrency ? Number(prepareResp.concurrency) : DEFAULT_CONCURRENT_PARTS, MAX_CONCURRENT_PARTS);
|
|
181
|
+
// partFinish 特定错误码的重试超时:使用 API 返回的 retry_timeout(秒),上限 10 分钟
|
|
182
|
+
const retryTimeoutMs = prepareResp.retry_timeout
|
|
183
|
+
? Math.min(Number(prepareResp.retry_timeout) * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
|
|
184
|
+
: undefined;
|
|
185
|
+
console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}, concurrency=${maxConcurrent}, retryTimeout=${retryTimeoutMs ? retryTimeoutMs / 1000 + 's' : 'default'}`);
|
|
134
186
|
// 4. 并行上传所有分片(带并发控制)
|
|
135
187
|
let completedParts = 0;
|
|
136
188
|
let uploadedBytes = 0;
|
|
@@ -145,7 +197,7 @@ export async function chunkedUploadGroup(appId, clientSecret, groupId, filePath,
|
|
|
145
197
|
await putToPresignedUrl(part.presigned_url, partBuffer, prefix, partNum, parts.length);
|
|
146
198
|
const token = await getAccessToken(appId, clientSecret);
|
|
147
199
|
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);
|
|
200
|
+
await groupUploadPartFinish(token, groupId, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
|
|
149
201
|
console.log(`${prefix} <<< groupUploadPartFinish(partIndex=${partIndex}) done`);
|
|
150
202
|
completedParts++;
|
|
151
203
|
uploadedBytes += length;
|
|
@@ -125,6 +125,7 @@ export declare function parseMediaTagsToSendQueue(text: string, log?: {
|
|
|
125
125
|
*
|
|
126
126
|
* 遍历 sendQueue,按类型调用对应的发送函数。
|
|
127
127
|
* 文本项通过 onSendText 回调处理(不同场景的文本发送方式不同)。
|
|
128
|
+
* 媒体发送失败时,通过 onSendText 发送兜底文本通知用户。
|
|
128
129
|
*/
|
|
129
130
|
export declare function executeSendQueue(queue: SendQueueItem[], ctx: MediaSendContext, options?: {
|
|
130
131
|
/** 文本发送回调(每种场景的文本发送方式不同) */
|
|
@@ -221,11 +221,26 @@ export function parseMediaTagsToSendQueue(text, log) {
|
|
|
221
221
|
*
|
|
222
222
|
* 遍历 sendQueue,按类型调用对应的发送函数。
|
|
223
223
|
* 文本项通过 onSendText 回调处理(不同场景的文本发送方式不同)。
|
|
224
|
+
* 媒体发送失败时,通过 onSendText 发送兜底文本通知用户。
|
|
224
225
|
*/
|
|
225
226
|
export async function executeSendQueue(queue, ctx, options = {}) {
|
|
226
227
|
const { mediaTarget, qualifiedTarget, account, replyToId, log } = ctx;
|
|
227
228
|
const prefix = mediaTarget.logPrefix ?? `[qqbot:${account.accountId}]`;
|
|
229
|
+
/** 媒体发送失败时的兜底:通过 onSendText 发送错误文本给用户 */
|
|
230
|
+
const sendFallbackText = async (errorMsg) => {
|
|
231
|
+
if (!options.onSendText) {
|
|
232
|
+
log?.info(`${prefix} executeSendQueue: no onSendText handler, cannot send fallback text`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
await options.onSendText(errorMsg);
|
|
237
|
+
}
|
|
238
|
+
catch (fallbackErr) {
|
|
239
|
+
log?.error(`${prefix} executeSendQueue: fallback text send failed: ${fallbackErr}`);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
228
242
|
for (const item of queue) {
|
|
243
|
+
const FALLBACK_MSG = "发送失败,请稍后重试。";
|
|
229
244
|
try {
|
|
230
245
|
if (item.type === "text") {
|
|
231
246
|
if (options.skipInterTagText) {
|
|
@@ -245,6 +260,7 @@ export async function executeSendQueue(queue, ctx, options = {}) {
|
|
|
245
260
|
const result = await sendPhoto(mediaTarget, item.content);
|
|
246
261
|
if (result.error) {
|
|
247
262
|
log?.error(`${prefix} sendPhoto error: ${result.error}`);
|
|
263
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
248
264
|
}
|
|
249
265
|
}
|
|
250
266
|
else if (item.type === "voice") {
|
|
@@ -259,22 +275,26 @@ export async function executeSendQueue(queue, ctx, options = {}) {
|
|
|
259
275
|
]);
|
|
260
276
|
if (result.error) {
|
|
261
277
|
log?.error(`${prefix} sendVoice error: ${result.error}`);
|
|
278
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
262
279
|
}
|
|
263
280
|
}
|
|
264
281
|
catch (err) {
|
|
265
282
|
log?.error(`${prefix} sendVoice unexpected error: ${err}`);
|
|
283
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
266
284
|
}
|
|
267
285
|
}
|
|
268
286
|
else if (item.type === "video") {
|
|
269
287
|
const result = await sendVideoMsg(mediaTarget, item.content);
|
|
270
288
|
if (result.error) {
|
|
271
289
|
log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
|
|
290
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
272
291
|
}
|
|
273
292
|
}
|
|
274
293
|
else if (item.type === "file") {
|
|
275
294
|
const result = await sendDocument(mediaTarget, item.content);
|
|
276
295
|
if (result.error) {
|
|
277
296
|
log?.error(`${prefix} sendDocument error: ${result.error}`);
|
|
297
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
278
298
|
}
|
|
279
299
|
}
|
|
280
300
|
else if (item.type === "media") {
|
|
@@ -288,11 +308,13 @@ export async function executeSendQueue(queue, ctx, options = {}) {
|
|
|
288
308
|
});
|
|
289
309
|
if (result.error) {
|
|
290
310
|
log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
|
|
311
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
291
312
|
}
|
|
292
313
|
}
|
|
293
314
|
}
|
|
294
315
|
catch (err) {
|
|
295
316
|
log?.error(`${prefix} executeSendQueue: failed to send ${item.type}: ${err}`);
|
|
317
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
296
318
|
}
|
|
297
319
|
}
|
|
298
320
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tencent-connect/openclaw-qqbot",
|
|
3
|
-
"version": "1.6.6-alpha.
|
|
3
|
+
"version": "1.6.6-alpha.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"
|
|
33
|
+
"prebuild": "node scripts/prebuild-stub.cjs",
|
|
34
|
+
"build": "tsc",
|
|
34
35
|
"postbuild": "node -e \"const fs=require('fs'),p=require('path'),ext=p.join(require('os').homedir(),'.openclaw/extensions/openclaw-qqbot');if(fs.existsSync(ext)&&!fs.lstatSync(ext).isSymbolicLink()){const d=p.join(ext,'dist'),pr=p.join(ext,'preload.cjs');fs.cpSync('dist',d,{recursive:true});fs.copyFileSync('preload.cjs',pr);console.log('[postbuild] synced to',ext)}\"",
|
|
35
36
|
"dev": "tsc --watch",
|
|
36
37
|
"prepack": "npm install --omit=dev",
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* prebuild-stub.cjs
|
|
5
|
+
*
|
|
6
|
+
* 在 openclaw peer 依赖未安装时(如独立开发环境),自动生成最小 stub 模块,
|
|
7
|
+
* 使 tsc 能正常编译。运行时 openclaw 会由宿主环境提供,stub 不会被使用。
|
|
8
|
+
*
|
|
9
|
+
* 如果 openclaw 已安装(如通过 postinstall-link-sdk 建立了 symlink),则跳过。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
|
|
15
|
+
const pluginRoot = path.resolve(__dirname, "..");
|
|
16
|
+
const openclawDir = path.join(pluginRoot, "node_modules", "openclaw");
|
|
17
|
+
|
|
18
|
+
// 如果 openclaw 已存在(真实安装或 symlink),则不覆盖
|
|
19
|
+
if (fs.existsSync(path.join(openclawDir, "package.json"))) {
|
|
20
|
+
console.log("[prebuild-stub] openclaw already available, skipping stub generation");
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log("[prebuild-stub] openclaw not found, generating type stubs...");
|
|
25
|
+
|
|
26
|
+
// --- openclaw/package.json ---
|
|
27
|
+
const pkgJson = {
|
|
28
|
+
name: "openclaw",
|
|
29
|
+
version: "0.0.0-stub",
|
|
30
|
+
description: "Auto-generated stub for build-time type checking",
|
|
31
|
+
main: "plugin-sdk/index.js",
|
|
32
|
+
types: "plugin-sdk/index.d.ts",
|
|
33
|
+
exports: {
|
|
34
|
+
"./plugin-sdk": {
|
|
35
|
+
types: "./plugin-sdk/index.d.ts",
|
|
36
|
+
default: "./plugin-sdk/index.js",
|
|
37
|
+
},
|
|
38
|
+
"./plugin-sdk/core": {
|
|
39
|
+
types: "./plugin-sdk/core.d.ts",
|
|
40
|
+
default: "./plugin-sdk/core.js",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// --- openclaw/plugin-sdk/index.d.ts ---
|
|
46
|
+
const pluginSdkDts = `\
|
|
47
|
+
// Auto-generated stub — provides minimal types for build-time compilation.
|
|
48
|
+
// At runtime, the real openclaw package is used.
|
|
49
|
+
|
|
50
|
+
export interface OpenClawPluginApi {
|
|
51
|
+
runtime: PluginRuntime;
|
|
52
|
+
registerChannel(opts: { plugin: any }): void;
|
|
53
|
+
registerTool(definition: any, options?: any): void;
|
|
54
|
+
[key: string]: any;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PluginRuntime {
|
|
58
|
+
channel: { text: { chunkMarkdownText(text: string, limit: number): string[] } };
|
|
59
|
+
config: any;
|
|
60
|
+
[key: string]: any;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface OpenClawConfig {
|
|
64
|
+
channels?: { qqbot?: any; [key: string]: any };
|
|
65
|
+
[key: string]: any;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ChannelPlugin<T = any> {
|
|
69
|
+
id: string;
|
|
70
|
+
meta?: any;
|
|
71
|
+
capabilities?: any;
|
|
72
|
+
reload?: any;
|
|
73
|
+
config?: {
|
|
74
|
+
listAccountIds?: (cfg: OpenClawConfig) => string[];
|
|
75
|
+
resolveAccount?: (cfg: OpenClawConfig, accountId?: string) => T;
|
|
76
|
+
defaultAccountId?: (cfg: OpenClawConfig) => string;
|
|
77
|
+
setAccountEnabled?: (opts: { cfg: OpenClawConfig; accountId: string; enabled: boolean }) => OpenClawConfig;
|
|
78
|
+
deleteAccount?: (opts: { cfg: OpenClawConfig; accountId: string }) => OpenClawConfig;
|
|
79
|
+
isConfigured?: (account: T) => boolean;
|
|
80
|
+
describeAccount?: (account: T) => any;
|
|
81
|
+
resolveAllowFrom?: (opts: { cfg: OpenClawConfig; accountId?: string | null }) => (string | number)[];
|
|
82
|
+
formatAllowFrom?: (opts: { allowFrom: Array<string | number> }) => string[];
|
|
83
|
+
[key: string]: any;
|
|
84
|
+
};
|
|
85
|
+
setup?: {
|
|
86
|
+
resolveAccountId?: (opts: { accountId?: string }) => string;
|
|
87
|
+
applyAccountName?: (opts: { cfg: OpenClawConfig; accountId: string; name: string }) => OpenClawConfig;
|
|
88
|
+
validateInput?: (opts: { input: any }) => string | null;
|
|
89
|
+
applyAccountConfig?: (opts: { cfg: OpenClawConfig; accountId: string; input: any }) => OpenClawConfig;
|
|
90
|
+
[key: string]: any;
|
|
91
|
+
};
|
|
92
|
+
messaging?: any;
|
|
93
|
+
outbound?: {
|
|
94
|
+
deliveryMode?: string;
|
|
95
|
+
chunker?: (text: string, limit: number) => string[];
|
|
96
|
+
chunkerMode?: string;
|
|
97
|
+
textChunkLimit?: number;
|
|
98
|
+
sendText?: (opts: { to: string; text: string; accountId?: string | null; replyToId?: string | null; cfg: OpenClawConfig }) => Promise<any>;
|
|
99
|
+
sendMedia?: (opts: { to: string; text?: string; mediaUrl?: string; accountId?: string | null; replyToId?: string | null; cfg: OpenClawConfig }) => Promise<any>;
|
|
100
|
+
[key: string]: any;
|
|
101
|
+
};
|
|
102
|
+
gateway?: {
|
|
103
|
+
startAccount?: (ctx: { account: T; abortSignal: AbortSignal; log: any; cfg: OpenClawConfig; setStatus: (s: any) => void; getStatus: () => any; [key: string]: any }) => Promise<void>;
|
|
104
|
+
logoutAccount?: (opts: { accountId: string; cfg: OpenClawConfig }) => Promise<any>;
|
|
105
|
+
[key: string]: any;
|
|
106
|
+
};
|
|
107
|
+
status?: {
|
|
108
|
+
defaultRuntime?: any;
|
|
109
|
+
buildChannelSummary?: (opts: { snapshot: any }) => any;
|
|
110
|
+
buildAccountSnapshot?: (opts: { account: any; runtime: any }) => any;
|
|
111
|
+
[key: string]: any;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ChannelOnboardingAdapter {
|
|
116
|
+
[key: string]: any;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ChannelOnboardingStatus {
|
|
120
|
+
[key: string]: any;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ChannelOnboardingStatusContext {
|
|
124
|
+
[key: string]: any;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ChannelOnboardingConfigureContext {
|
|
128
|
+
[key: string]: any;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ChannelOnboardingResult {
|
|
132
|
+
[key: string]: any;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function emptyPluginConfigSchema(): any;
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
// --- openclaw/plugin-sdk/index.js ---
|
|
139
|
+
const pluginSdkJs = `\
|
|
140
|
+
// Auto-generated stub
|
|
141
|
+
export function emptyPluginConfigSchema() { return {}; }
|
|
142
|
+
`;
|
|
143
|
+
|
|
144
|
+
// --- openclaw/plugin-sdk/core.d.ts ---
|
|
145
|
+
const coreDts = `\
|
|
146
|
+
// Auto-generated stub
|
|
147
|
+
export { OpenClawConfig, ChannelPlugin } from "./index.js";
|
|
148
|
+
|
|
149
|
+
export function applyAccountNameToChannelSection(opts: any): any;
|
|
150
|
+
export function deleteAccountFromConfigSection(opts: any): any;
|
|
151
|
+
export function setAccountEnabledInConfigSection(opts: any): any;
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
// --- openclaw/plugin-sdk/core.js ---
|
|
155
|
+
const coreJs = `\
|
|
156
|
+
// Auto-generated stub
|
|
157
|
+
export function applyAccountNameToChannelSection(opts) { return opts.cfg; }
|
|
158
|
+
export function deleteAccountFromConfigSection(opts) { return opts.cfg; }
|
|
159
|
+
export function setAccountEnabledInConfigSection(opts) { return opts.cfg; }
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
// Write files
|
|
163
|
+
const sdkDir = path.join(openclawDir, "plugin-sdk");
|
|
164
|
+
fs.mkdirSync(sdkDir, { recursive: true });
|
|
165
|
+
|
|
166
|
+
fs.writeFileSync(path.join(openclawDir, "package.json"), JSON.stringify(pkgJson, null, 2));
|
|
167
|
+
fs.writeFileSync(path.join(sdkDir, "index.d.ts"), pluginSdkDts);
|
|
168
|
+
fs.writeFileSync(path.join(sdkDir, "index.js"), pluginSdkJs);
|
|
169
|
+
fs.writeFileSync(path.join(sdkDir, "core.d.ts"), coreDts);
|
|
170
|
+
fs.writeFileSync(path.join(sdkDir, "core.js"), coreJs);
|
|
171
|
+
|
|
172
|
+
console.log("[prebuild-stub] stub generated at node_modules/openclaw/");
|
package/src/api.ts
CHANGED
|
@@ -9,12 +9,16 @@ import { sanitizeFileName } from "./utils/platform.js";
|
|
|
9
9
|
|
|
10
10
|
// ============ 自定义错误 ============
|
|
11
11
|
|
|
12
|
-
/** API 请求错误,携带 HTTP status code */
|
|
12
|
+
/** API 请求错误,携带 HTTP status code 和业务错误码 */
|
|
13
13
|
export class ApiError extends Error {
|
|
14
14
|
constructor(
|
|
15
15
|
message: string,
|
|
16
16
|
public readonly status: number,
|
|
17
17
|
public readonly path: string,
|
|
18
|
+
/** 业务错误码(回包中的 code / err_code 字段),不一定存在 */
|
|
19
|
+
public readonly bizCode?: number,
|
|
20
|
+
/** 回包中的原始 message 字段(用于向用户展示兜底文案) */
|
|
21
|
+
public readonly bizMessage?: string,
|
|
18
22
|
) {
|
|
19
23
|
super(message);
|
|
20
24
|
this.name = "ApiError";
|
|
@@ -320,8 +324,9 @@ export async function apiRequest<T = unknown>(
|
|
|
320
324
|
}
|
|
321
325
|
// JSON 错误响应
|
|
322
326
|
try {
|
|
323
|
-
const error = JSON.parse(rawBody) as { message?: string; code?: number };
|
|
324
|
-
|
|
327
|
+
const error = JSON.parse(rawBody) as { message?: string; code?: number; err_code?: number };
|
|
328
|
+
const bizCode = error.code ?? error.err_code;
|
|
329
|
+
throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path, bizCode, error.message);
|
|
325
330
|
} catch (parseErr) {
|
|
326
331
|
if (parseErr instanceof ApiError) throw parseErr;
|
|
327
332
|
throw new ApiError(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`, res.status, path);
|
|
@@ -413,16 +418,61 @@ async function completeUploadWithRetry(
|
|
|
413
418
|
throw lastError!;
|
|
414
419
|
}
|
|
415
420
|
|
|
416
|
-
// ============
|
|
421
|
+
// ============ 分片完成重试 ============
|
|
417
422
|
|
|
423
|
+
/** 普通错误最大重试次数 */
|
|
418
424
|
const PART_FINISH_MAX_RETRIES = 2;
|
|
419
425
|
const PART_FINISH_BASE_DELAY_MS = 1000;
|
|
420
426
|
|
|
427
|
+
/**
|
|
428
|
+
* 需要持续重试的业务错误码集合
|
|
429
|
+
* 当 upload_part_finish 返回这些错误码时,会以固定 1s 间隔持续重试直到成功或超时
|
|
430
|
+
*/
|
|
431
|
+
export const PART_FINISH_RETRYABLE_CODES: Set<number> = new Set([
|
|
432
|
+
40093001,
|
|
433
|
+
]);
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* upload_prepare 接口命中此错误码时,使用回包中的 message 字段作为兜底文案发送给用户
|
|
437
|
+
* 而非走通用的"文件发送失败,请稍后重试"
|
|
438
|
+
*/
|
|
439
|
+
export const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
|
|
440
|
+
|
|
441
|
+
/** 特定错误码持续重试的默认超时(服务端未返回 retry_timeout 时的兜底) */
|
|
442
|
+
const PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000;
|
|
443
|
+
|
|
444
|
+
/** 特定错误码重试的固定间隔(1 秒) */
|
|
445
|
+
const PART_FINISH_RETRYABLE_INTERVAL_MS = 1000;
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* 判断错误是否命中"需要持续重试"的业务错误码
|
|
449
|
+
*/
|
|
450
|
+
function isRetryableBizCode(err: unknown): boolean {
|
|
451
|
+
if (PART_FINISH_RETRYABLE_CODES.size === 0) return false;
|
|
452
|
+
if (err instanceof ApiError && err.bizCode !== undefined) {
|
|
453
|
+
return PART_FINISH_RETRYABLE_CODES.has(err.bizCode);
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 分片完成接口重试策略:
|
|
460
|
+
*
|
|
461
|
+
* 1. 命中 PART_FINISH_RETRYABLE_CODES 的错误码 → 每 1s 重试一次,直到成功或超时
|
|
462
|
+
* 超时时间 = min(API 返回的 retry_timeout, 10 分钟)
|
|
463
|
+
* 2. 其他错误 → 最多重试 PART_FINISH_MAX_RETRIES 次(与之前逻辑一致)
|
|
464
|
+
*
|
|
465
|
+
* 若持续重试超时或普通重试耗尽,抛出错误,调用方(chunkedUpload)
|
|
466
|
+
* 可据此中止后续分片上传。
|
|
467
|
+
*
|
|
468
|
+
* @param retryTimeoutMs - 持续重试的超时时间(毫秒),由 upload_prepare 返回的 retry_timeout 计算得出
|
|
469
|
+
*/
|
|
421
470
|
async function partFinishWithRetry(
|
|
422
471
|
accessToken: string,
|
|
423
472
|
method: string,
|
|
424
473
|
path: string,
|
|
425
474
|
body?: unknown,
|
|
475
|
+
retryTimeoutMs?: number,
|
|
426
476
|
): Promise<void> {
|
|
427
477
|
let lastError: Error | null = null;
|
|
428
478
|
|
|
@@ -433,6 +483,14 @@ async function partFinishWithRetry(
|
|
|
433
483
|
} catch (err) {
|
|
434
484
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
435
485
|
|
|
486
|
+
// 命中特定错误码 → 进入持续重试模式
|
|
487
|
+
if (isRetryableBizCode(err)) {
|
|
488
|
+
const timeoutMs = retryTimeoutMs ?? PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS;
|
|
489
|
+
console.warn(`[qqbot-api] PartFinish hit retryable bizCode=${(err as ApiError).bizCode}, entering persistent retry (timeout=${timeoutMs / 1000}s, interval=1s)...`);
|
|
490
|
+
await partFinishPersistentRetry(accessToken, method, path, body, timeoutMs);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
436
494
|
if (attempt < PART_FINISH_MAX_RETRIES) {
|
|
437
495
|
const delay = PART_FINISH_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
438
496
|
console.warn(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
|
|
@@ -444,6 +502,51 @@ async function partFinishWithRetry(
|
|
|
444
502
|
throw lastError!;
|
|
445
503
|
}
|
|
446
504
|
|
|
505
|
+
/**
|
|
506
|
+
* 特定错误码的持续重试模式
|
|
507
|
+
* 不限次数,仅受总超时时间约束,固定每 1 秒重试一次
|
|
508
|
+
*/
|
|
509
|
+
async function partFinishPersistentRetry(
|
|
510
|
+
accessToken: string,
|
|
511
|
+
method: string,
|
|
512
|
+
path: string,
|
|
513
|
+
body: unknown,
|
|
514
|
+
timeoutMs: number,
|
|
515
|
+
): Promise<void> {
|
|
516
|
+
const deadline = Date.now() + timeoutMs;
|
|
517
|
+
let attempt = 0;
|
|
518
|
+
let lastError: Error | null = null;
|
|
519
|
+
|
|
520
|
+
while (Date.now() < deadline) {
|
|
521
|
+
try {
|
|
522
|
+
await apiRequest<Record<string, unknown>>(accessToken, method, path, body);
|
|
523
|
+
console.log(`[qqbot-api] PartFinish persistent retry succeeded after ${attempt} retries`);
|
|
524
|
+
return;
|
|
525
|
+
} catch (err) {
|
|
526
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
527
|
+
|
|
528
|
+
// 如果不再是可重试的错误码,直接抛出(可能是其他类型的错误)
|
|
529
|
+
if (!isRetryableBizCode(err)) {
|
|
530
|
+
console.error(`[qqbot-api] PartFinish persistent retry: error is no longer retryable (bizCode=${(err as ApiError).bizCode ?? "N/A"}), aborting`);
|
|
531
|
+
throw lastError;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
attempt++;
|
|
535
|
+
const remaining = deadline - Date.now();
|
|
536
|
+
|
|
537
|
+
if (remaining <= 0) break;
|
|
538
|
+
|
|
539
|
+
const actualDelay = Math.min(PART_FINISH_RETRYABLE_INTERVAL_MS, remaining);
|
|
540
|
+
console.warn(`[qqbot-api] PartFinish persistent retry #${attempt}: bizCode=${(err as ApiError).bizCode}, retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`);
|
|
541
|
+
await new Promise(resolve => setTimeout(resolve, actualDelay));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 超时
|
|
546
|
+
console.error(`[qqbot-api] PartFinish persistent retry timed out after ${timeoutMs / 1000}s (${attempt} attempts)`);
|
|
547
|
+
throw new Error(`upload_part_finish 持续重试超时(${timeoutMs / 1000}s, ${attempt} 次重试),中止上传`);
|
|
548
|
+
}
|
|
549
|
+
|
|
447
550
|
export async function getGatewayUrl(accessToken: string): Promise<string> {
|
|
448
551
|
const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway");
|
|
449
552
|
return data.url;
|
|
@@ -644,6 +747,10 @@ export interface UploadPrepareResponse {
|
|
|
644
747
|
block_size: number;
|
|
645
748
|
/** 分片列表(含预签名链接) */
|
|
646
749
|
parts: UploadPart[];
|
|
750
|
+
/** 上传并发数(由服务端控制,可选,不返回时使用客户端默认值) */
|
|
751
|
+
concurrency?: number;
|
|
752
|
+
/** upload_part_finish 特定错误码的重试超时时间(秒),由服务端控制,客户端上限 10 分钟 */
|
|
753
|
+
retry_timeout?: number;
|
|
647
754
|
}
|
|
648
755
|
|
|
649
756
|
/** 完成文件上传响应(与 UploadMediaResponse 一致) */
|
|
@@ -710,10 +817,12 @@ export async function c2cUploadPartFinish(
|
|
|
710
817
|
partIndex: number,
|
|
711
818
|
blockSize: number,
|
|
712
819
|
md5: string,
|
|
820
|
+
retryTimeoutMs?: number,
|
|
713
821
|
): Promise<void> {
|
|
714
822
|
await partFinishWithRetry(
|
|
715
823
|
accessToken, "POST", `/v2/users/${userId}/upload_part_finish`,
|
|
716
824
|
{ upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 },
|
|
825
|
+
retryTimeoutMs,
|
|
717
826
|
);
|
|
718
827
|
}
|
|
719
828
|
|
|
@@ -766,10 +875,12 @@ export async function groupUploadPartFinish(
|
|
|
766
875
|
partIndex: number,
|
|
767
876
|
blockSize: number,
|
|
768
877
|
md5: string,
|
|
878
|
+
retryTimeoutMs?: number,
|
|
769
879
|
): Promise<void> {
|
|
770
880
|
await partFinishWithRetry(
|
|
771
881
|
accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`,
|
|
772
882
|
{ upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 },
|
|
883
|
+
retryTimeoutMs,
|
|
773
884
|
);
|
|
774
885
|
}
|
|
775
886
|
|
package/src/outbound-deliver.ts
CHANGED
|
@@ -217,13 +217,13 @@ export async function sendPlainReply(
|
|
|
217
217
|
});
|
|
218
218
|
if (result.error) {
|
|
219
219
|
log?.error(`${prefix} sendMedia(auto) error for ${mediaPath}: ${result.error}`);
|
|
220
|
-
await sendTextChunks(
|
|
220
|
+
await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
|
|
221
221
|
} else {
|
|
222
222
|
log?.info(`${prefix} Sent local media: ${mediaPath}`);
|
|
223
223
|
}
|
|
224
224
|
} catch (err) {
|
|
225
225
|
log?.error(`${prefix} sendMedia(auto) failed for ${mediaPath}: ${err}`);
|
|
226
|
-
await sendTextChunks(
|
|
226
|
+
await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
}
|
|
@@ -245,12 +245,13 @@ export async function sendPlainReply(
|
|
|
245
245
|
});
|
|
246
246
|
if (result.error) {
|
|
247
247
|
log?.error(`${prefix} Tool media forward error: ${result.error}`);
|
|
248
|
-
await sendTextChunks(
|
|
248
|
+
await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
|
|
249
249
|
} else {
|
|
250
250
|
log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
|
|
251
251
|
}
|
|
252
252
|
} catch (err) {
|
|
253
253
|
log?.error(`${prefix} Tool media forward failed: ${err}`);
|
|
254
|
+
await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
|
|
254
255
|
}
|
|
255
256
|
}
|
|
256
257
|
}
|
package/src/outbound.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from "./api.js";
|
|
21
21
|
import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
|
|
22
22
|
import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
|
|
23
|
-
import { chunkedUploadC2C, chunkedUploadGroup } from "./utils/chunked-upload.js";
|
|
23
|
+
import { chunkedUploadC2C, chunkedUploadGroup, UploadPrepareFallbackError } from "./utils/chunked-upload.js";
|
|
24
24
|
import { isLocalPath as isLocalFilePath, normalizePath, getQQBotMediaDir } from "./utils/platform.js";
|
|
25
25
|
import { downloadFile } from "./image-server.js";
|
|
26
26
|
import { parseMediaTagsToSendQueue, executeSendQueue, type MediaSendContext } from "./utils/media-send.js";
|
|
@@ -533,6 +533,12 @@ async function chunkedUploadAndSend(
|
|
|
533
533
|
} catch (err) {
|
|
534
534
|
const msg = err instanceof Error ? err.message : String(err);
|
|
535
535
|
console.error(`${prefix} ${callerName}: c2c chunked upload failed: ${msg}`);
|
|
536
|
+
if (err instanceof UploadPrepareFallbackError) {
|
|
537
|
+
const dir = path.dirname(err.filePath);
|
|
538
|
+
const name = path.basename(err.filePath);
|
|
539
|
+
const size = formatFileSize(err.fileSize);
|
|
540
|
+
return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
|
|
541
|
+
}
|
|
536
542
|
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
537
543
|
}
|
|
538
544
|
}
|
|
@@ -556,6 +562,12 @@ async function chunkedUploadAndSend(
|
|
|
556
562
|
} catch (err) {
|
|
557
563
|
const msg = err instanceof Error ? err.message : String(err);
|
|
558
564
|
console.error(`${prefix} ${callerName}: group chunked upload failed: ${msg}`);
|
|
565
|
+
if (err instanceof UploadPrepareFallbackError) {
|
|
566
|
+
const dir = path.dirname(err.filePath);
|
|
567
|
+
const name = path.basename(err.filePath);
|
|
568
|
+
const size = formatFileSize(err.fileSize);
|
|
569
|
+
return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
|
|
570
|
+
}
|
|
559
571
|
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
560
572
|
}
|
|
561
573
|
}
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
type UploadPrepareResponse,
|
|
21
21
|
type UploadPrepareHashes,
|
|
22
22
|
type MediaUploadResponse,
|
|
23
|
+
ApiError,
|
|
24
|
+
UPLOAD_PREPARE_FALLBACK_CODE,
|
|
23
25
|
c2cUploadPrepare,
|
|
24
26
|
c2cUploadPartFinish,
|
|
25
27
|
c2cCompleteUpload,
|
|
@@ -30,8 +32,32 @@ import {
|
|
|
30
32
|
} from "../api.js";
|
|
31
33
|
import { formatFileSize } from "./file-utils.js";
|
|
32
34
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
+
/**
|
|
36
|
+
* upload_prepare 返回特定错误码时抛出的错误
|
|
37
|
+
* 调用方根据携带的文件信息构造兜底文案发送给用户
|
|
38
|
+
*/
|
|
39
|
+
export class UploadPrepareFallbackError extends Error {
|
|
40
|
+
/** 触发错误的本地文件路径 */
|
|
41
|
+
public readonly filePath: string;
|
|
42
|
+
/** 文件大小(字节) */
|
|
43
|
+
public readonly fileSize: number;
|
|
44
|
+
|
|
45
|
+
constructor(filePath: string, fileSize: number, originalMessage: string) {
|
|
46
|
+
super(originalMessage);
|
|
47
|
+
this.name = "UploadPrepareFallbackError";
|
|
48
|
+
this.filePath = filePath;
|
|
49
|
+
this.fileSize = fileSize;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 分片上传默认并发数(服务端未返回 concurrency 时的兜底) */
|
|
54
|
+
const DEFAULT_CONCURRENT_PARTS = 1;
|
|
55
|
+
|
|
56
|
+
/** 分片上传并发上限(即使服务端返回更大的值也不超过此限制) */
|
|
57
|
+
const MAX_CONCURRENT_PARTS = 10;
|
|
58
|
+
|
|
59
|
+
/** partFinish 特定错误码重试超时上限(10 分钟),即使服务端 retry_timeout 更大也不超过此值 */
|
|
60
|
+
const MAX_PART_FINISH_RETRY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
35
61
|
|
|
36
62
|
/** 单个分片上传超时(毫秒)— 5 分钟,兼容低带宽场景 */
|
|
37
63
|
const PART_UPLOAD_TIMEOUT = 300_000;
|
|
@@ -55,8 +81,6 @@ export interface ChunkedUploadProgress {
|
|
|
55
81
|
export interface ChunkedUploadOptions {
|
|
56
82
|
/** 进度回调 */
|
|
57
83
|
onProgress?: (progress: ChunkedUploadProgress) => void;
|
|
58
|
-
/** 最大并发数(默认 2) */
|
|
59
|
-
maxConcurrent?: number;
|
|
60
84
|
/** 日志前缀 */
|
|
61
85
|
logPrefix?: string;
|
|
62
86
|
}
|
|
@@ -81,7 +105,6 @@ export async function chunkedUploadC2C(
|
|
|
81
105
|
options?: ChunkedUploadOptions,
|
|
82
106
|
): Promise<MediaUploadResponse> {
|
|
83
107
|
const prefix = options?.logPrefix ?? "[chunked-upload]";
|
|
84
|
-
const maxConcurrent = options?.maxConcurrent ?? MAX_CONCURRENT_PARTS;
|
|
85
108
|
|
|
86
109
|
// 1. 读取文件信息
|
|
87
110
|
const stat = await fs.promises.stat(filePath);
|
|
@@ -98,13 +121,34 @@ export async function chunkedUploadC2C(
|
|
|
98
121
|
// 3. 申请上传 → 获取 upload_id + block_size + 预签名链接
|
|
99
122
|
const accessToken = await getAccessToken(appId, clientSecret);
|
|
100
123
|
console.log(`${prefix} >>> Calling c2cUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
|
|
101
|
-
|
|
124
|
+
let prepareResp: UploadPrepareResponse;
|
|
125
|
+
try {
|
|
126
|
+
prepareResp = await c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// 命中特定错误码 → 携带文件信息抛出,由上层构造兜底文案
|
|
129
|
+
if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
|
|
130
|
+
console.warn(`${prefix} c2cUploadPrepare hit fallback code ${UPLOAD_PREPARE_FALLBACK_CODE}`);
|
|
131
|
+
throw new UploadPrepareFallbackError(filePath, fileSize, err.message);
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
102
135
|
console.log(`${prefix} <<< c2cUploadPrepare response:`, JSON.stringify(prepareResp));
|
|
103
136
|
const { upload_id, parts } = prepareResp;
|
|
104
137
|
// QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
|
|
105
138
|
const block_size = Number(prepareResp.block_size);
|
|
106
139
|
|
|
107
|
-
|
|
140
|
+
// 并发数:使用 API 返回的 concurrency,未返回则用默认值,且不超过上限
|
|
141
|
+
const maxConcurrent = Math.min(
|
|
142
|
+
prepareResp.concurrency ? Number(prepareResp.concurrency) : DEFAULT_CONCURRENT_PARTS,
|
|
143
|
+
MAX_CONCURRENT_PARTS,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// partFinish 特定错误码的重试超时:使用 API 返回的 retry_timeout(秒),上限 10 分钟
|
|
147
|
+
const retryTimeoutMs = prepareResp.retry_timeout
|
|
148
|
+
? Math.min(Number(prepareResp.retry_timeout) * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
|
|
149
|
+
: undefined;
|
|
150
|
+
|
|
151
|
+
console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}, concurrency=${maxConcurrent}, retryTimeout=${retryTimeoutMs ? retryTimeoutMs / 1000 + 's' : 'default'}`);
|
|
108
152
|
|
|
109
153
|
// 4. 并行上传所有分片(带并发控制)
|
|
110
154
|
let completedParts = 0;
|
|
@@ -131,7 +175,7 @@ export async function chunkedUploadC2C(
|
|
|
131
175
|
// b. 通知开放平台分片上传完成(需要重新获取 token,避免长时间上传后 token 过期)
|
|
132
176
|
const token = await getAccessToken(appId, clientSecret);
|
|
133
177
|
console.log(`${prefix} >>> Calling c2cUploadPartFinish(upload_id=${upload_id}, partIndex=${partIndex}, blockSize=${length}, md5=${md5Hex})`);
|
|
134
|
-
await c2cUploadPartFinish(token, userId, upload_id, partIndex, length, md5Hex);
|
|
178
|
+
await c2cUploadPartFinish(token, userId, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
|
|
135
179
|
console.log(`${prefix} <<< c2cUploadPartFinish(partIndex=${partIndex}) done`);
|
|
136
180
|
|
|
137
181
|
// 更新进度
|
|
@@ -188,7 +232,6 @@ export async function chunkedUploadGroup(
|
|
|
188
232
|
options?: ChunkedUploadOptions,
|
|
189
233
|
): Promise<MediaUploadResponse> {
|
|
190
234
|
const prefix = options?.logPrefix ?? "[chunked-upload]";
|
|
191
|
-
const maxConcurrent = options?.maxConcurrent ?? MAX_CONCURRENT_PARTS;
|
|
192
235
|
|
|
193
236
|
// 1. 读取文件信息
|
|
194
237
|
const stat = await fs.promises.stat(filePath);
|
|
@@ -205,13 +248,34 @@ export async function chunkedUploadGroup(
|
|
|
205
248
|
// 3. 申请上传
|
|
206
249
|
const accessToken = await getAccessToken(appId, clientSecret);
|
|
207
250
|
console.log(`${prefix} >>> Calling groupUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
|
|
208
|
-
|
|
251
|
+
let prepareResp: UploadPrepareResponse;
|
|
252
|
+
try {
|
|
253
|
+
prepareResp = await groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// 命中特定错误码 → 携带文件信息抛出,由上层构造兜底文案
|
|
256
|
+
if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
|
|
257
|
+
console.warn(`${prefix} groupUploadPrepare hit fallback code ${UPLOAD_PREPARE_FALLBACK_CODE}`);
|
|
258
|
+
throw new UploadPrepareFallbackError(filePath, fileSize, err.message);
|
|
259
|
+
}
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
209
262
|
console.log(`${prefix} <<< groupUploadPrepare response:`, JSON.stringify(prepareResp));
|
|
210
263
|
const { upload_id, parts } = prepareResp;
|
|
211
264
|
// QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
|
|
212
265
|
const block_size = Number(prepareResp.block_size);
|
|
213
266
|
|
|
214
|
-
|
|
267
|
+
// 并发数:使用 API 返回的 concurrency,未返回则用默认值,且不超过上限
|
|
268
|
+
const maxConcurrent = Math.min(
|
|
269
|
+
prepareResp.concurrency ? Number(prepareResp.concurrency) : DEFAULT_CONCURRENT_PARTS,
|
|
270
|
+
MAX_CONCURRENT_PARTS,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// partFinish 特定错误码的重试超时:使用 API 返回的 retry_timeout(秒),上限 10 分钟
|
|
274
|
+
const retryTimeoutMs = prepareResp.retry_timeout
|
|
275
|
+
? Math.min(Number(prepareResp.retry_timeout) * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
|
|
276
|
+
: undefined;
|
|
277
|
+
|
|
278
|
+
console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}, concurrency=${maxConcurrent}, retryTimeout=${retryTimeoutMs ? retryTimeoutMs / 1000 + 's' : 'default'}`);
|
|
215
279
|
|
|
216
280
|
// 4. 并行上传所有分片(带并发控制)
|
|
217
281
|
let completedParts = 0;
|
|
@@ -232,7 +296,7 @@ export async function chunkedUploadGroup(
|
|
|
232
296
|
|
|
233
297
|
const token = await getAccessToken(appId, clientSecret);
|
|
234
298
|
console.log(`${prefix} >>> Calling groupUploadPartFinish(upload_id=${upload_id}, partIndex=${partIndex}, blockSize=${length}, md5=${md5Hex})`);
|
|
235
|
-
await groupUploadPartFinish(token, groupId, upload_id, partIndex, length, md5Hex);
|
|
299
|
+
await groupUploadPartFinish(token, groupId, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
|
|
236
300
|
console.log(`${prefix} <<< groupUploadPartFinish(partIndex=${partIndex}) done`);
|
|
237
301
|
|
|
238
302
|
completedParts++;
|
package/src/utils/media-send.ts
CHANGED
|
@@ -335,6 +335,7 @@ export function parseMediaTagsToSendQueue(
|
|
|
335
335
|
*
|
|
336
336
|
* 遍历 sendQueue,按类型调用对应的发送函数。
|
|
337
337
|
* 文本项通过 onSendText 回调处理(不同场景的文本发送方式不同)。
|
|
338
|
+
* 媒体发送失败时,通过 onSendText 发送兜底文本通知用户。
|
|
338
339
|
*/
|
|
339
340
|
export async function executeSendQueue(
|
|
340
341
|
queue: SendQueueItem[],
|
|
@@ -349,7 +350,21 @@ export async function executeSendQueue(
|
|
|
349
350
|
const { mediaTarget, qualifiedTarget, account, replyToId, log } = ctx;
|
|
350
351
|
const prefix = mediaTarget.logPrefix ?? `[qqbot:${account.accountId}]`;
|
|
351
352
|
|
|
353
|
+
/** 媒体发送失败时的兜底:通过 onSendText 发送错误文本给用户 */
|
|
354
|
+
const sendFallbackText = async (errorMsg: string): Promise<void> => {
|
|
355
|
+
if (!options.onSendText) {
|
|
356
|
+
log?.info(`${prefix} executeSendQueue: no onSendText handler, cannot send fallback text`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
await options.onSendText(errorMsg);
|
|
361
|
+
} catch (fallbackErr) {
|
|
362
|
+
log?.error(`${prefix} executeSendQueue: fallback text send failed: ${fallbackErr}`);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
352
366
|
for (const item of queue) {
|
|
367
|
+
const FALLBACK_MSG = "发送失败,请稍后重试。";
|
|
353
368
|
try {
|
|
354
369
|
if (item.type === "text") {
|
|
355
370
|
if (options.skipInterTagText) {
|
|
@@ -370,6 +385,7 @@ export async function executeSendQueue(
|
|
|
370
385
|
const result = await sendPhoto(mediaTarget, item.content);
|
|
371
386
|
if (result.error) {
|
|
372
387
|
log?.error(`${prefix} sendPhoto error: ${result.error}`);
|
|
388
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
373
389
|
}
|
|
374
390
|
} else if (item.type === "voice") {
|
|
375
391
|
const uploadFormats =
|
|
@@ -390,19 +406,23 @@ export async function executeSendQueue(
|
|
|
390
406
|
]);
|
|
391
407
|
if (result.error) {
|
|
392
408
|
log?.error(`${prefix} sendVoice error: ${result.error}`);
|
|
409
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
393
410
|
}
|
|
394
411
|
} catch (err) {
|
|
395
412
|
log?.error(`${prefix} sendVoice unexpected error: ${err}`);
|
|
413
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
396
414
|
}
|
|
397
415
|
} else if (item.type === "video") {
|
|
398
416
|
const result = await sendVideoMsg(mediaTarget, item.content);
|
|
399
417
|
if (result.error) {
|
|
400
418
|
log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
|
|
419
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
401
420
|
}
|
|
402
421
|
} else if (item.type === "file") {
|
|
403
422
|
const result = await sendDocument(mediaTarget, item.content);
|
|
404
423
|
if (result.error) {
|
|
405
424
|
log?.error(`${prefix} sendDocument error: ${result.error}`);
|
|
425
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
406
426
|
}
|
|
407
427
|
} else if (item.type === "media") {
|
|
408
428
|
const result = await sendMediaAuto({
|
|
@@ -415,10 +435,12 @@ export async function executeSendQueue(
|
|
|
415
435
|
});
|
|
416
436
|
if (result.error) {
|
|
417
437
|
log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
|
|
438
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
418
439
|
}
|
|
419
440
|
}
|
|
420
441
|
} catch (err) {
|
|
421
442
|
log?.error(`${prefix} executeSendQueue: failed to send ${item.type}: ${err}`);
|
|
443
|
+
await sendFallbackText(FALLBACK_MSG);
|
|
422
444
|
}
|
|
423
445
|
}
|
|
424
446
|
}
|