@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 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.6`
13
+ ### 🚀 Current Version: `v1.6.5`
14
14
 
15
15
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
16
16
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](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 100MB. Large files are automatically chunked and uploaded in parallel.
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.6`
12
+ ### 🚀 当前版本: `v1.6.5`
13
13
 
14
14
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
15
15
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](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 可直接发送文件。任意格式,最大 100MB。大文件自动分片并行上传。
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
- constructor(message: string, status: number, path: string);
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
- constructor(message, status, path) {
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
- throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path);
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
- // ============ 分片完成重试(无条件,与 completeUpload 策略一致) ============
344
+ // ============ 分片完成重试 ============
345
+ /** 普通错误最大重试次数 */
336
346
  const PART_FINISH_MAX_RETRIES = 2;
337
347
  const PART_FINISH_BASE_DELAY_MS = 1000;
338
- async function partFinishWithRetry(accessToken, method, path, body) {
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(result.error, event, actx, sendWithRetry, consumeQuoteRef);
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(`发送媒体失败:${err}`, event, actx, sendWithRetry, consumeQuoteRef);
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(result.error, event, actx, sendWithRetry, consumeQuoteRef);
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
  }
@@ -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
- /** 分片上传并发控制:最多同时上传 N 个分片 */
20
- const MAX_CONCURRENT_PARTS = 1;
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
- const prepareResp = await c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes);
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
- console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}`);
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
- const prepareResp = await groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes);
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
- console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}`);
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.1",
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
- "build": "tsc || true",
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
- throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path);
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
- // ============ 分片完成重试(无条件,与 completeUpload 策略一致) ============
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
 
@@ -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(result.error, event, actx, sendWithRetry, consumeQuoteRef);
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(`发送媒体失败:${err}`, event, actx, sendWithRetry, consumeQuoteRef);
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(result.error, event, actx, sendWithRetry, consumeQuoteRef);
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
- /** 分片上传并发控制:最多同时上传 N 个分片 */
34
- const MAX_CONCURRENT_PARTS = 1;
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
- const prepareResp = await c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes);
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
- console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}`);
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
- const prepareResp = await groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes);
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
- console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}`);
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++;
@@ -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
  }