@ryantest/openclaw-qqbot 0.0.3 → 1.6.6-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +2 -15
  2. package/README.zh.md +3 -16
  3. package/dist/src/admin-resolver.d.ts +12 -6
  4. package/dist/src/admin-resolver.js +69 -34
  5. package/dist/src/api.d.ts +105 -1
  6. package/dist/src/api.js +164 -15
  7. package/dist/src/channel.js +13 -0
  8. package/dist/src/config.js +3 -10
  9. package/dist/src/deliver-debounce.d.ts +74 -0
  10. package/dist/src/deliver-debounce.js +174 -0
  11. package/dist/src/gateway.js +450 -248
  12. package/dist/src/image-server.d.ts +27 -8
  13. package/dist/src/image-server.js +179 -71
  14. package/dist/src/inbound-attachments.d.ts +3 -1
  15. package/dist/src/inbound-attachments.js +28 -14
  16. package/dist/src/outbound-deliver.js +77 -148
  17. package/dist/src/outbound.d.ts +6 -4
  18. package/dist/src/outbound.js +266 -442
  19. package/dist/src/reply-dispatcher.js +4 -4
  20. package/dist/src/request-context.d.ts +18 -0
  21. package/dist/src/request-context.js +30 -0
  22. package/dist/src/slash-commands.js +277 -32
  23. package/dist/src/startup-greeting.d.ts +5 -5
  24. package/dist/src/startup-greeting.js +32 -13
  25. package/dist/src/streaming.d.ts +244 -0
  26. package/dist/src/streaming.js +907 -0
  27. package/dist/src/tools/remind.js +11 -10
  28. package/dist/src/types.d.ts +101 -0
  29. package/dist/src/types.js +17 -1
  30. package/dist/src/update-checker.js +2 -8
  31. package/dist/src/utils/audio-convert.d.ts +9 -0
  32. package/dist/src/utils/audio-convert.js +51 -0
  33. package/dist/src/utils/chunked-upload.d.ts +59 -0
  34. package/dist/src/utils/chunked-upload.js +289 -0
  35. package/dist/src/utils/file-utils.d.ts +7 -1
  36. package/dist/src/utils/file-utils.js +24 -2
  37. package/dist/src/utils/media-send.d.ts +147 -0
  38. package/dist/src/utils/media-send.js +434 -0
  39. package/dist/src/utils/pkg-version.d.ts +5 -0
  40. package/dist/src/utils/pkg-version.js +51 -0
  41. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  42. package/dist/src/utils/ssrf-guard.js +91 -0
  43. package/node_modules/ws/index.js +15 -6
  44. package/node_modules/ws/lib/permessage-deflate.js +6 -6
  45. package/node_modules/ws/lib/websocket-server.js +5 -5
  46. package/node_modules/ws/lib/websocket.js +6 -6
  47. package/node_modules/ws/package.json +4 -3
  48. package/node_modules/ws/wrapper.mjs +14 -1
  49. package/openclaw.plugin.json +1 -0
  50. package/package.json +11 -22
  51. package/scripts/postinstall-link-sdk.js +113 -0
  52. package/scripts/upgrade-via-npm.ps1 +161 -6
  53. package/scripts/upgrade-via-npm.sh +311 -104
  54. package/scripts/upgrade-via-source.sh +117 -0
  55. package/skills/qqbot-media/SKILL.md +9 -5
  56. package/skills/qqbot-remind/SKILL.md +3 -3
  57. package/src/admin-resolver.ts +76 -35
  58. package/src/api.ts +284 -12
  59. package/src/channel.ts +12 -0
  60. package/src/config.ts +3 -10
  61. package/src/deliver-debounce.ts +229 -0
  62. package/src/gateway.ts +277 -67
  63. package/src/image-server.ts +213 -77
  64. package/src/inbound-attachments.ts +32 -15
  65. package/src/outbound-deliver.ts +77 -157
  66. package/src/outbound.ts +304 -451
  67. package/src/reply-dispatcher.ts +4 -4
  68. package/src/request-context.ts +39 -0
  69. package/src/slash-commands.ts +303 -33
  70. package/src/startup-greeting.ts +35 -13
  71. package/src/streaming.ts +1096 -0
  72. package/src/tools/remind.ts +15 -11
  73. package/src/types.ts +111 -0
  74. package/src/update-checker.ts +2 -7
  75. package/src/utils/audio-convert.ts +56 -0
  76. package/src/utils/chunked-upload.ts +419 -0
  77. package/src/utils/file-utils.ts +28 -2
  78. package/src/utils/media-send.ts +563 -0
  79. package/src/utils/pkg-version.ts +54 -0
  80. package/src/utils/ssrf-guard.ts +102 -0
  81. package/clawdbot.plugin.json +0 -16
  82. package/dist/src/user-messages.d.ts +0 -8
  83. package/dist/src/user-messages.js +0 -8
  84. package/moltbot.plugin.json +0 -16
  85. package/scripts/upgrade-via-alt-pkg.sh +0 -307
  86. package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
  87. package/src/gateway.log +0 -43
  88. package/src/openclaw-2026-03-21.log +0 -3729
  89. package/src/user-messages.ts +0 -7
@@ -5,8 +5,12 @@
5
5
 
6
6
  import http from "node:http";
7
7
  import fs from "node:fs";
8
+ import { pipeline } from "node:stream/promises";
9
+ import { Readable } from "node:stream";
8
10
  import path from "node:path";
9
11
  import crypto from "node:crypto";
12
+ import { validateRemoteUrl } from "./utils/ssrf-guard.js";
13
+ import { getQQBotMediaDir } from "./utils/platform.js";
10
14
 
11
15
  export interface ImageServerConfig {
12
16
  /** 监听端口 */
@@ -413,124 +417,256 @@ export async function ensureImageServer(publicBaseUrl?: string): Promise<string
413
417
  }
414
418
  }
415
419
 
420
+ /** downloadFile 的返回结果 */
421
+ export interface DownloadResult {
422
+ /** 下载成功时的本地文件路径(位于系统临时目录,调用方用完后应删除) */
423
+ filePath: string | null;
424
+ /** 下载失败时的错误信息(用于兜底消息展示) */
425
+ error?: string;
426
+ }
427
+
428
+ /** 默认下载目录:与入站附件统一放在 ~/.openclaw/media/qqbot/downloads/ */
429
+ const DEFAULT_DOWNLOAD_DIR = getQQBotMediaDir("downloads");
430
+
416
431
  /**
417
- * 下载远程文件并保存到本地
432
+ * 下载远程文件到系统临时目录。
433
+ *
434
+ * 文件名采用 UUID 保证不重名不覆盖,调用方用完后应自行删除。
435
+ *
436
+ * 安全措施:
437
+ * 1. SSRF 防护 — DNS 解析后校验 IP,拒绝私有/保留网段
438
+ * 2. Content-Type 黑名单 — 拦截 text/html(登录页/错误页/人机验证页)
439
+ * 3. 超时控制 — 默认 30 秒,传 0 表示不限时
440
+ * 4. 大小限制 — 可选,通过 Content-Length 预检 + 流式字节计数双重保护
441
+ *
418
442
  * @param url 远程文件 URL
419
- * @param destDir 目标目录
420
- * @param originalFilename 原始文件名(可选,完整文件名包含扩展名)
443
+ * @param originalFilename 原始文件名(可选,仅用于推导扩展名)
421
444
  * @param options 下载选项
422
- * @returns 本地文件路径,失败返回 null
445
+ * @returns DownloadResult,filePath null 表示失败,error 包含失败原因
423
446
  */
424
447
  export async function downloadFile(
425
448
  url: string,
426
- destDir: string,
427
449
  originalFilename?: string,
428
450
  options?: {
429
- /** 超时时间(毫秒),默认 1200002分钟) */
451
+ /** 超时时间(毫秒),默认 3000030 秒)。传 0 表示不限时 */
430
452
  timeoutMs?: number;
431
- /** 最大文件大小(字节),默认 50MB */
453
+ /** 指定下载目标目录。不传则使用系统临时目录(调用方用完后应删除) */
454
+ destDir?: string;
455
+ /** 下载大小上限(字节)。超过此值中断下载并返回错误。不传则不限制 */
432
456
  maxSizeBytes?: number;
457
+ /** 网络错误时的最大重试次数,默认 2(即最多尝试 3 次) */
458
+ maxRetries?: number;
433
459
  },
434
- ): Promise<string | null> {
435
- const timeoutMs = options?.timeoutMs ?? 120000;
436
- const maxSizeBytes = options?.maxSizeBytes ?? 50 * 1024 * 1024;
437
-
438
- const controller = new AbortController();
439
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
460
+ ): Promise<DownloadResult> {
461
+ const timeoutMs = options?.timeoutMs ?? 30_000;
462
+ const destDir = options?.destDir ?? DEFAULT_DOWNLOAD_DIR;
463
+ const maxSizeBytes = options?.maxSizeBytes ?? 0; // 0 = 不限制
464
+ const maxRetries = options?.maxRetries ?? 2;
440
465
 
466
+ // ---- SSRF 防护(只做一次,不需要重试) ----
441
467
  try {
442
- // 确保目录存在
443
- if (!fs.existsSync(destDir)) {
444
- fs.mkdirSync(destDir, { recursive: true });
468
+ await validateRemoteUrl(url);
469
+ } catch (err) {
470
+ const msg = err instanceof Error ? err.message : String(err);
471
+ console.error(`[image-server] SSRF check failed: ${msg}`);
472
+ return { filePath: null, error: `URL 安全检查未通过: ${msg}` };
473
+ }
474
+
475
+ // 确保目标目录存在(只做一次)
476
+ if (!fs.existsSync(destDir)) {
477
+ fs.mkdirSync(destDir, { recursive: true });
478
+ }
479
+
480
+ let lastError: DownloadResult | null = null;
481
+
482
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
483
+ if (attempt > 0) {
484
+ // 指数退避:1s, 2s
485
+ const delayMs = attempt * 1000;
486
+ console.log(`[image-server] Retry ${attempt}/${maxRetries} after ${delayMs}ms: ${url.slice(0, 120)}`);
487
+ await new Promise(r => setTimeout(r, delayMs));
488
+ }
489
+
490
+ const result = await downloadFileOnce(url, originalFilename, { timeoutMs, destDir, maxSizeBytes });
491
+
492
+ // 成功 或 不可重试的错误 → 直接返回
493
+ if (result.filePath || !result.retryable) {
494
+ return { filePath: result.filePath, error: result.error };
445
495
  }
446
496
 
497
+ // 可重试的错误,记录后继续
498
+ lastError = { filePath: null, error: result.error };
499
+ console.error(`[image-server] Attempt ${attempt + 1}/${maxRetries + 1} failed (retryable): ${result.error}`);
500
+ }
501
+
502
+ // 所有重试用完
503
+ return lastError ?? { filePath: null, error: "下载失败(重试次数耗尽)" };
504
+ }
505
+
506
+ /** downloadFileOnce 内部返回类型,增加 retryable 标记 */
507
+ interface DownloadOnceResult {
508
+ filePath: string | null;
509
+ error?: string;
510
+ /** 是否可重试(网络层临时错误 = true,业务错误如文件过大 = false) */
511
+ retryable?: boolean;
512
+ }
513
+
514
+ /**
515
+ * 执行一次下载尝试(无重试逻辑)。
516
+ */
517
+ async function downloadFileOnce(
518
+ url: string,
519
+ originalFilename: string | undefined,
520
+ opts: { timeoutMs: number; destDir: string; maxSizeBytes: number },
521
+ ): Promise<DownloadOnceResult> {
522
+ const { timeoutMs, destDir, maxSizeBytes } = opts;
523
+
524
+ const controller = new AbortController();
525
+ // timeoutMs > 0 时启用超时;为 0 表示不限时
526
+ const timeoutId = timeoutMs > 0
527
+ ? setTimeout(() => controller.abort(), timeoutMs)
528
+ : null;
529
+
530
+ let tempPath: string | null = null;
531
+
532
+ try {
447
533
  // 下载文件(带超时控制)
448
534
  const response = await fetch(url, { signal: controller.signal });
449
535
  if (!response.ok) {
450
- console.error(`[image-server] Download failed: ${response.status} ${response.statusText}`);
451
- return null;
536
+ const reason = `HTTP ${response.status} ${response.statusText}`;
537
+ console.error(`[image-server] Download failed: ${reason}`);
538
+ // 5xx 服务端错误可重试,4xx 不可重试
539
+ const retryable = response.status >= 500;
540
+ return { filePath: null, error: `下载失败 (${reason})`, retryable };
452
541
  }
453
542
 
454
- // Content-Length 预检
455
- const contentLength = response.headers.get("content-length");
456
- if (contentLength) {
457
- const declaredSize = parseInt(contentLength, 10);
458
- if (declaredSize > maxSizeBytes) {
459
- console.error(`[image-server] Download rejected: Content-Length ${declaredSize} exceeds limit ${maxSizeBytes}`);
460
- return null;
461
- }
543
+ if (!response.body) {
544
+ console.error(`[image-server] Download failed: empty response body`);
545
+ return { filePath: null, error: `下载失败 (响应体为空)`, retryable: false };
462
546
  }
463
547
 
464
- // 流式下载,实时监控大小
465
- const reader = response.body?.getReader();
466
- if (!reader) {
467
- console.error(`[image-server] Download failed: no response body`);
468
- return null;
469
- }
470
-
471
- const chunks: Uint8Array[] = [];
472
- let totalSize = 0;
473
-
474
- while (true) {
475
- const { done, value } = await reader.read();
476
- if (done) break;
477
- totalSize += value.byteLength;
478
- if (totalSize > maxSizeBytes) {
479
- reader.cancel();
480
- console.error(`[image-server] Download aborted: size ${totalSize} exceeds limit ${maxSizeBytes}`);
481
- return null;
548
+ // ---- 预检 Content-Length(如果服务端返回了该头) ----
549
+ if (maxSizeBytes > 0) {
550
+ const contentLength = Number(response.headers.get("content-length"));
551
+ if (contentLength > 0 && contentLength > maxSizeBytes) {
552
+ const sizeMB = (contentLength / (1024 * 1024)).toFixed(1);
553
+ const limitMB = Math.round(maxSizeBytes / (1024 * 1024));
554
+ console.error(`[image-server] File too large (Content-Length: ${sizeMB}MB, limit: ${limitMB}MB): ${url}`);
555
+ return { filePath: null, error: `文件过大(${sizeMB}MB),超过了${limitMB}M的下载限制`, retryable: false };
482
556
  }
483
- chunks.push(value);
484
557
  }
485
558
 
486
- const buffer = Buffer.concat(chunks);
487
-
488
- // Content-Disposition 解析文件名(如果没有提供 originalFilename)
489
- if (!originalFilename) {
559
+ // 推导扩展名:originalFilename > Content-Disposition > Content-Type > .bin
560
+ const contentType = response.headers.get("content-type") ?? "";
561
+ let ext = "";
562
+ if (originalFilename) {
563
+ try { ext = path.extname(decodeURIComponent(originalFilename)); } catch { ext = path.extname(originalFilename); }
564
+ }
565
+ if (!ext) {
490
566
  const disposition = response.headers.get("content-disposition");
491
567
  if (disposition) {
492
- const filenameMatch = disposition.match(/filename\*?=(?:UTF-8''|")?([^";]+)"?/i);
493
- if (filenameMatch?.[1]) {
494
- try { originalFilename = decodeURIComponent(filenameMatch[1]); } catch { /* keep undefined */ }
568
+ const m = disposition.match(/filename\*?=(?:UTF-8''|")?([^";]+)"?/i);
569
+ if (m?.[1]) {
570
+ try { ext = path.extname(decodeURIComponent(m[1])); } catch { /* keep empty */ }
495
571
  }
496
572
  }
497
573
  }
498
-
499
- // 确定文件名
500
- let finalFilename: string;
501
- if (originalFilename) {
502
- let decodedFilename = originalFilename;
503
- try { decodedFilename = decodeURIComponent(originalFilename); } catch { /* keep original */ }
504
- const ext = path.extname(decodedFilename);
505
- const baseName = path.basename(decodedFilename, ext);
506
- const timestamp = Date.now();
507
- finalFilename = `${baseName}_${timestamp}${ext}`;
574
+ if (!ext) {
575
+ const mime = contentType.split(";")[0]?.trim() ?? "";
576
+ ext = mime ? (`.${getExtFromMime(mime) ?? "bin"}`) : ".bin";
577
+ }
578
+
579
+ // UUID 文件名,绝对不会重名
580
+ const uniqueName = `${crypto.randomUUID()}${ext}`;
581
+ const filePath = path.join(destDir, uniqueName);
582
+ tempPath = filePath + ".tmp";
583
+
584
+ // ---- 流式写入临时文件(内存占用恒定,不会 OOM) ----
585
+ const nodeStream = Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream);
586
+
587
+
588
+ // 如果设置了大小限制,包装一个 Transform 流来监控已写入字节数
589
+ if (maxSizeBytes > 0) {
590
+ const { Transform } = await import("node:stream");
591
+ let bytesWritten = 0;
592
+ const sizeGuard = new Transform({
593
+ transform(chunk, _encoding, callback) {
594
+ bytesWritten += chunk.length;
595
+ if (bytesWritten > maxSizeBytes) {
596
+ const sizeMB = (bytesWritten / (1024 * 1024)).toFixed(1);
597
+ const limitMB = Math.round(maxSizeBytes / (1024 * 1024));
598
+ callback(new Error(`DOWNLOAD_SIZE_EXCEEDED: ${sizeMB}MB > ${limitMB}MB`));
599
+ } else {
600
+ callback(null, chunk);
601
+ }
602
+ },
603
+ });
604
+ const writeStream = fs.createWriteStream(tempPath);
605
+ await pipeline(nodeStream, sizeGuard, writeStream);
508
606
  } else {
509
- // 没有原始文件名,尝试从 Content-Type 推导扩展名
510
- const contentType = response.headers.get("content-type");
511
- const ext = contentType ? (getExtFromMime(contentType.split(";")[0]?.trim() ?? "") ?? "bin") : "bin";
512
- finalFilename = `${generateImageId()}.${ext}`;
607
+ const writeStream = fs.createWriteStream(tempPath);
608
+ await pipeline(nodeStream, writeStream);
513
609
  }
514
-
515
- const filePath = path.join(destDir, finalFilename);
516
610
 
517
- // 保存文件
518
- fs.writeFileSync(filePath, buffer);
519
- console.log(`[image-server] Downloaded file: ${filePath} (${buffer.length} bytes, ${Date.now()}ms)`);
520
-
521
- return filePath;
611
+ // 流式写入完成,原子重命名为最终文件
612
+ const stat = await fs.promises.stat(tempPath);
613
+ fs.renameSync(tempPath, filePath);
614
+ tempPath = null; // 重命名成功,不再需要清理
615
+
616
+ console.log(`[image-server] Downloaded file: ${filePath} (${stat.size} bytes)`);
617
+ return { filePath };
522
618
  } catch (err) {
619
+ // 清理不完整的临时文件
620
+ if (tempPath) {
621
+ try { fs.unlinkSync(tempPath); } catch { /* ignore cleanup error */ }
622
+ }
623
+
523
624
  if (err instanceof Error && err.name === "AbortError") {
524
625
  console.error(`[image-server] Download timeout after ${timeoutMs}ms: ${url}`);
525
- } else {
526
- console.error(`[image-server] Download error:`, err);
626
+ return { filePath: null, error: `下载超时(${Math.round(timeoutMs / 1000)}秒)`, retryable: true };
527
627
  }
528
- return null;
628
+ // 大小超限错误 — 不可重试
629
+ if (err instanceof Error && err.message.startsWith("DOWNLOAD_SIZE_EXCEEDED:")) {
630
+ const limitMB = maxSizeBytes > 0 ? Math.round(maxSizeBytes / (1024 * 1024)) : 0;
631
+ console.error(`[image-server] Download size exceeded ${limitMB}MB: ${url}`);
632
+ return { filePath: null, error: `文件过大,超过了${limitMB}M的下载限制`, retryable: false };
633
+ }
634
+ // 网络层临时错误 — 可重试
635
+ const retryable = isRetryableNetworkError(err);
636
+ const msg = err instanceof Error ? err.message : String(err);
637
+ console.error(`[image-server] Download error (retryable=${retryable}):`, err);
638
+ return { filePath: null, error: `下载出错: ${msg}`, retryable };
529
639
  } finally {
530
- clearTimeout(timeoutId);
640
+ if (timeoutId) clearTimeout(timeoutId);
531
641
  }
532
642
  }
533
643
 
644
+ /**
645
+ * 判断错误是否为可重试的网络临时错误。
646
+ *
647
+ * 覆盖常见的 TCP/DNS 层面临时故障:
648
+ * - ETIMEDOUT: TCP 连接超时
649
+ * - ECONNRESET: 连接被对端重置
650
+ * - ECONNREFUSED: 连接被拒绝
651
+ * - ENOTFOUND: DNS 解析失败
652
+ * - EAI_AGAIN: DNS 临时失败
653
+ * - UND_ERR_CONNECT_TIMEOUT: undici 连接超时
654
+ * - fetch failed: Node.js fetch 底层网络错误的通用消息
655
+ */
656
+ function isRetryableNetworkError(err: unknown): boolean {
657
+ if (!(err instanceof Error)) return false;
658
+ const code = (err as NodeJS.ErrnoException).code;
659
+ if (code && ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"].includes(code)) {
660
+ return true;
661
+ }
662
+ // Node.js fetch 抛出 TypeError: fetch failed 时,真正的网络错误在 cause 中
663
+ if (err.message === "fetch failed" && err.cause) {
664
+ return isRetryableNetworkError(err.cause);
665
+ }
666
+ return false;
667
+ }
668
+
669
+
534
670
  /**
535
671
  * 获取图床服务器配置
536
672
  */
@@ -45,7 +45,9 @@ export interface ProcessedAttachments {
45
45
  }
46
46
 
47
47
  interface ProcessContext {
48
- accountId: string;
48
+ appId: string;
49
+ /** 对话 ID:群聊传 groupOpenid,私聊传 senderId(用于按群/用户隔离下载目录) */
50
+ peerId?: string;
49
51
  cfg: unknown;
50
52
  log?: {
51
53
  info: (msg: string) => void;
@@ -84,9 +86,10 @@ export async function processAttachments(
84
86
  ): Promise<ProcessedAttachments> {
85
87
  if (!attachments?.length) return EMPTY_RESULT;
86
88
 
87
- const { accountId, cfg, log } = ctx;
88
- const downloadDir = getQQBotMediaDir("downloads");
89
- const prefix = `[qqbot:${accountId}]`;
89
+ const { appId, peerId, cfg, log } = ctx;
90
+ const subPaths = ["downloads", appId, ...(peerId ? [peerId] : [])];
91
+ const downloadDir = getQQBotMediaDir(...subPaths);
92
+ const prefix = `[qqbot:${appId}]`;
90
93
 
91
94
  // 结果收集
92
95
  const imageUrls: string[] = [];
@@ -99,6 +102,9 @@ export async function processAttachments(
99
102
  const attachmentLocalPaths: Array<string | null> = [];
100
103
  const otherAttachments: string[] = [];
101
104
 
105
+ // 入站附件下载:限制 2 分钟,不限大小
106
+ const INBOUND_DOWNLOAD_TIMEOUT_MS = 2 * 60 * 1000; // 2 分钟
107
+
102
108
  // Phase 1: 并行下载所有附件
103
109
  const downloadTasks = attachments.map(async (att) => {
104
110
  const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
@@ -109,29 +115,32 @@ export async function processAttachments(
109
115
 
110
116
  let localPath: string | null = null;
111
117
  let audioPath: string | null = null;
118
+ let dlError: string | undefined;
112
119
 
113
120
  if (isVoice && wavUrl) {
114
- const wavLocalPath = await downloadFile(wavUrl, downloadDir);
115
- if (wavLocalPath) {
116
- localPath = wavLocalPath;
117
- audioPath = wavLocalPath;
121
+ const wavResult = await downloadFile(wavUrl, undefined, { destDir: downloadDir, timeoutMs: INBOUND_DOWNLOAD_TIMEOUT_MS });
122
+ if (wavResult.filePath) {
123
+ localPath = wavResult.filePath;
124
+ audioPath = wavResult.filePath;
118
125
  log?.info(`${prefix} Voice attachment: ${att.filename}, downloaded WAV directly (skip SILK→WAV)`);
119
126
  } else {
120
- log?.error(`${prefix} Failed to download voice_wav_url, falling back to original URL`);
127
+ log?.error(`${prefix} Failed to download voice_wav_url (${wavResult.error}), falling back to original URL`);
121
128
  }
122
129
  }
123
130
 
124
131
  if (!localPath) {
125
- localPath = await downloadFile(attUrl, downloadDir, att.filename);
132
+ const dlResult = await downloadFile(attUrl, att.filename, { destDir: downloadDir, timeoutMs: INBOUND_DOWNLOAD_TIMEOUT_MS });
133
+ localPath = dlResult.filePath;
134
+ dlError = dlResult.error;
126
135
  }
127
136
 
128
- return { att, attUrl, isVoice, localPath, audioPath };
137
+ return { att, attUrl, isVoice, localPath, audioPath, dlError };
129
138
  });
130
139
 
131
140
  const downloadResults = await Promise.all(downloadTasks);
132
141
 
133
142
  // Phase 2: 并行处理语音转换 + 转录(非语音附件同步归类)
134
- const processTasks = downloadResults.map(async ({ att, attUrl, isVoice, localPath, audioPath }) => {
143
+ const processTasks = downloadResults.map(async ({ att, attUrl, isVoice, localPath, audioPath, dlError }) => {
135
144
  const asrReferText = typeof att.asr_refer_text === "string" ? att.asr_refer_text.trim() : "";
136
145
  const wavUrl = isVoice && att.voice_wav_url
137
146
  ? (att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url)
@@ -157,12 +166,12 @@ export async function processAttachments(
157
166
  } else {
158
167
  log?.error(`${prefix} Failed to download: ${attUrl}`);
159
168
  if (att.content_type?.startsWith("image/")) {
160
- return { localPath: null, type: "image-fallback" as const, attUrl, contentType: att.content_type, meta };
169
+ return { localPath: null, type: "image-fallback" as const, attUrl, contentType: att.content_type, dlError, meta };
161
170
  } else if (isVoice && asrReferText) {
162
171
  log?.info(`${prefix} Voice attachment download failed, using asr_refer_text fallback`);
163
172
  return { localPath: null, type: "voice-fallback" as const, transcript: asrReferText, meta };
164
173
  } else {
165
- return { localPath: null, type: "other-fallback" as const, filename: att.filename ?? att.content_type, meta };
174
+ return { localPath: null, type: "other-fallback" as const, filename: att.filename ?? att.content_type, dlError, meta };
166
175
  }
167
176
  }
168
177
  });
@@ -190,12 +199,20 @@ export async function processAttachments(
190
199
  imageUrls.push(result.attUrl);
191
200
  imageMediaTypes.push(result.contentType);
192
201
  attachmentLocalPaths.push(null);
202
+ // 给模型一个明确的失败提示(和 other-fallback 对齐)
203
+ const hint = result.dlError?.includes("超时")
204
+ ? "(图片下载超时)"
205
+ : "(图片下载失败)";
206
+ otherAttachments.push(`[图片] ${hint}`);
193
207
  } else if (result.type === "voice-fallback") {
194
208
  voiceTranscripts.push(result.transcript);
195
209
  voiceTranscriptSources.push("asr");
196
210
  attachmentLocalPaths.push(null);
197
211
  } else if (result.type === "other-fallback") {
198
- otherAttachments.push(`[附件: ${result.filename}] (下载失败)`);
212
+ const hint = result.dlError?.includes("超时")
213
+ ? "(下载超时)"
214
+ : "(下载失败)";
215
+ otherAttachments.push(`[附件: ${result.filename}] ${hint}`);
199
216
  attachmentLocalPaths.push(null);
200
217
  }
201
218
  }