@ryantest/openclaw-qqbot 0.0.3 → 1.6.6-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -15
- package/README.zh.md +3 -16
- package/dist/src/admin-resolver.d.ts +12 -6
- package/dist/src/admin-resolver.js +69 -34
- package/dist/src/api.d.ts +105 -1
- package/dist/src/api.js +164 -15
- package/dist/src/channel.js +13 -0
- package/dist/src/config.js +3 -10
- package/dist/src/deliver-debounce.d.ts +74 -0
- package/dist/src/deliver-debounce.js +174 -0
- package/dist/src/gateway.js +450 -248
- package/dist/src/image-server.d.ts +27 -8
- package/dist/src/image-server.js +179 -71
- package/dist/src/inbound-attachments.d.ts +3 -1
- package/dist/src/inbound-attachments.js +28 -14
- package/dist/src/outbound-deliver.js +77 -148
- package/dist/src/outbound.d.ts +6 -4
- package/dist/src/outbound.js +266 -442
- package/dist/src/reply-dispatcher.js +4 -4
- package/dist/src/request-context.d.ts +18 -0
- package/dist/src/request-context.js +30 -0
- package/dist/src/slash-commands.js +277 -32
- package/dist/src/startup-greeting.d.ts +5 -5
- package/dist/src/startup-greeting.js +32 -13
- package/dist/src/streaming.d.ts +244 -0
- package/dist/src/streaming.js +907 -0
- package/dist/src/tools/remind.js +11 -10
- package/dist/src/types.d.ts +101 -0
- package/dist/src/types.js +17 -1
- package/dist/src/update-checker.js +2 -8
- package/dist/src/utils/audio-convert.d.ts +9 -0
- package/dist/src/utils/audio-convert.js +51 -0
- package/dist/src/utils/chunked-upload.d.ts +59 -0
- package/dist/src/utils/chunked-upload.js +289 -0
- package/dist/src/utils/file-utils.d.ts +7 -1
- package/dist/src/utils/file-utils.js +24 -2
- package/dist/src/utils/media-send.d.ts +147 -0
- package/dist/src/utils/media-send.js +434 -0
- package/dist/src/utils/pkg-version.d.ts +5 -0
- package/dist/src/utils/pkg-version.js +51 -0
- package/dist/src/utils/ssrf-guard.d.ts +25 -0
- package/dist/src/utils/ssrf-guard.js +91 -0
- package/node_modules/ws/index.js +15 -6
- package/node_modules/ws/lib/permessage-deflate.js +6 -6
- package/node_modules/ws/lib/websocket-server.js +5 -5
- package/node_modules/ws/lib/websocket.js +6 -6
- package/node_modules/ws/package.json +4 -3
- package/node_modules/ws/wrapper.mjs +14 -1
- package/openclaw.plugin.json +1 -0
- package/package.json +11 -22
- package/scripts/postinstall-link-sdk.js +113 -0
- package/scripts/upgrade-via-npm.ps1 +161 -6
- package/scripts/upgrade-via-npm.sh +311 -104
- package/scripts/upgrade-via-source.sh +117 -0
- package/skills/qqbot-media/SKILL.md +9 -5
- package/skills/qqbot-remind/SKILL.md +3 -3
- package/src/admin-resolver.ts +76 -35
- package/src/api.ts +284 -12
- package/src/channel.ts +12 -0
- package/src/config.ts +3 -10
- package/src/deliver-debounce.ts +229 -0
- package/src/gateway.ts +277 -67
- package/src/image-server.ts +213 -77
- package/src/inbound-attachments.ts +32 -15
- package/src/outbound-deliver.ts +77 -157
- package/src/outbound.ts +304 -451
- package/src/reply-dispatcher.ts +4 -4
- package/src/request-context.ts +39 -0
- package/src/slash-commands.ts +303 -33
- package/src/startup-greeting.ts +35 -13
- package/src/streaming.ts +1096 -0
- package/src/tools/remind.ts +15 -11
- package/src/types.ts +111 -0
- package/src/update-checker.ts +2 -7
- package/src/utils/audio-convert.ts +56 -0
- package/src/utils/chunked-upload.ts +419 -0
- package/src/utils/file-utils.ts +28 -2
- package/src/utils/media-send.ts +563 -0
- package/src/utils/pkg-version.ts +54 -0
- package/src/utils/ssrf-guard.ts +102 -0
- package/clawdbot.plugin.json +0 -16
- package/dist/src/user-messages.d.ts +0 -8
- package/dist/src/user-messages.js +0 -8
- package/moltbot.plugin.json +0 -16
- package/scripts/upgrade-via-alt-pkg.sh +0 -307
- package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
- package/src/gateway.log +0 -43
- package/src/openclaw-2026-03-21.log +0 -3729
- package/src/user-messages.ts +0 -7
|
@@ -48,20 +48,39 @@ export declare function isImageServerRunning(): boolean;
|
|
|
48
48
|
* @returns 基础 URL,启动失败返回 null
|
|
49
49
|
*/
|
|
50
50
|
export declare function ensureImageServer(publicBaseUrl?: string): Promise<string | null>;
|
|
51
|
+
/** downloadFile 的返回结果 */
|
|
52
|
+
export interface DownloadResult {
|
|
53
|
+
/** 下载成功时的本地文件路径(位于系统临时目录,调用方用完后应删除) */
|
|
54
|
+
filePath: string | null;
|
|
55
|
+
/** 下载失败时的错误信息(用于兜底消息展示) */
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
51
58
|
/**
|
|
52
|
-
*
|
|
59
|
+
* 下载远程文件到系统临时目录。
|
|
60
|
+
*
|
|
61
|
+
* 文件名采用 UUID 保证不重名不覆盖,调用方用完后应自行删除。
|
|
62
|
+
*
|
|
63
|
+
* 安全措施:
|
|
64
|
+
* 1. SSRF 防护 — DNS 解析后校验 IP,拒绝私有/保留网段
|
|
65
|
+
* 2. Content-Type 黑名单 — 拦截 text/html(登录页/错误页/人机验证页)
|
|
66
|
+
* 3. 超时控制 — 默认 30 秒,传 0 表示不限时
|
|
67
|
+
* 4. 大小限制 — 可选,通过 Content-Length 预检 + 流式字节计数双重保护
|
|
68
|
+
*
|
|
53
69
|
* @param url 远程文件 URL
|
|
54
|
-
* @param
|
|
55
|
-
* @param originalFilename 原始文件名(可选,完整文件名包含扩展名)
|
|
70
|
+
* @param originalFilename 原始文件名(可选,仅用于推导扩展名)
|
|
56
71
|
* @param options 下载选项
|
|
57
|
-
* @returns
|
|
72
|
+
* @returns DownloadResult,filePath 为 null 表示失败,error 包含失败原因
|
|
58
73
|
*/
|
|
59
|
-
export declare function downloadFile(url: string,
|
|
60
|
-
/** 超时时间(毫秒),默认
|
|
74
|
+
export declare function downloadFile(url: string, originalFilename?: string, options?: {
|
|
75
|
+
/** 超时时间(毫秒),默认 30000(30 秒)。传 0 表示不限时 */
|
|
61
76
|
timeoutMs?: number;
|
|
62
|
-
/**
|
|
77
|
+
/** 指定下载目标目录。不传则使用系统临时目录(调用方用完后应删除) */
|
|
78
|
+
destDir?: string;
|
|
79
|
+
/** 下载大小上限(字节)。超过此值中断下载并返回错误。不传则不限制 */
|
|
63
80
|
maxSizeBytes?: number;
|
|
64
|
-
|
|
81
|
+
/** 网络错误时的最大重试次数,默认 2(即最多尝试 3 次) */
|
|
82
|
+
maxRetries?: number;
|
|
83
|
+
}): Promise<DownloadResult>;
|
|
65
84
|
/**
|
|
66
85
|
* 获取图床服务器配置
|
|
67
86
|
*/
|
package/dist/src/image-server.js
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import http from "node:http";
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { Readable } from "node:stream";
|
|
7
9
|
import path from "node:path";
|
|
8
10
|
import crypto from "node:crypto";
|
|
11
|
+
import { validateRemoteUrl } from "./utils/ssrf-guard.js";
|
|
12
|
+
import { getQQBotMediaDir } from "./utils/platform.js";
|
|
9
13
|
const DEFAULT_CONFIG = {
|
|
10
14
|
port: 18765,
|
|
11
15
|
storageDir: "./qqbot-images",
|
|
@@ -349,110 +353,214 @@ export async function ensureImageServer(publicBaseUrl) {
|
|
|
349
353
|
return null;
|
|
350
354
|
}
|
|
351
355
|
}
|
|
356
|
+
/** 默认下载目录:与入站附件统一放在 ~/.openclaw/media/qqbot/downloads/ */
|
|
357
|
+
const DEFAULT_DOWNLOAD_DIR = getQQBotMediaDir("downloads");
|
|
352
358
|
/**
|
|
353
|
-
*
|
|
359
|
+
* 下载远程文件到系统临时目录。
|
|
360
|
+
*
|
|
361
|
+
* 文件名采用 UUID 保证不重名不覆盖,调用方用完后应自行删除。
|
|
362
|
+
*
|
|
363
|
+
* 安全措施:
|
|
364
|
+
* 1. SSRF 防护 — DNS 解析后校验 IP,拒绝私有/保留网段
|
|
365
|
+
* 2. Content-Type 黑名单 — 拦截 text/html(登录页/错误页/人机验证页)
|
|
366
|
+
* 3. 超时控制 — 默认 30 秒,传 0 表示不限时
|
|
367
|
+
* 4. 大小限制 — 可选,通过 Content-Length 预检 + 流式字节计数双重保护
|
|
368
|
+
*
|
|
354
369
|
* @param url 远程文件 URL
|
|
355
|
-
* @param
|
|
356
|
-
* @param originalFilename 原始文件名(可选,完整文件名包含扩展名)
|
|
370
|
+
* @param originalFilename 原始文件名(可选,仅用于推导扩展名)
|
|
357
371
|
* @param options 下载选项
|
|
358
|
-
* @returns
|
|
372
|
+
* @returns DownloadResult,filePath 为 null 表示失败,error 包含失败原因
|
|
359
373
|
*/
|
|
360
|
-
export async function downloadFile(url,
|
|
361
|
-
const timeoutMs = options?.timeoutMs ??
|
|
362
|
-
const
|
|
363
|
-
const
|
|
364
|
-
const
|
|
374
|
+
export async function downloadFile(url, originalFilename, options) {
|
|
375
|
+
const timeoutMs = options?.timeoutMs ?? 30_000;
|
|
376
|
+
const destDir = options?.destDir ?? DEFAULT_DOWNLOAD_DIR;
|
|
377
|
+
const maxSizeBytes = options?.maxSizeBytes ?? 0; // 0 = 不限制
|
|
378
|
+
const maxRetries = options?.maxRetries ?? 2;
|
|
379
|
+
// ---- SSRF 防护(只做一次,不需要重试) ----
|
|
365
380
|
try {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
381
|
+
await validateRemoteUrl(url);
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
385
|
+
console.error(`[image-server] SSRF check failed: ${msg}`);
|
|
386
|
+
return { filePath: null, error: `URL 安全检查未通过: ${msg}` };
|
|
387
|
+
}
|
|
388
|
+
// 确保目标目录存在(只做一次)
|
|
389
|
+
if (!fs.existsSync(destDir)) {
|
|
390
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
391
|
+
}
|
|
392
|
+
let lastError = null;
|
|
393
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
394
|
+
if (attempt > 0) {
|
|
395
|
+
// 指数退避:1s, 2s
|
|
396
|
+
const delayMs = attempt * 1000;
|
|
397
|
+
console.log(`[image-server] Retry ${attempt}/${maxRetries} after ${delayMs}ms: ${url.slice(0, 120)}`);
|
|
398
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
369
399
|
}
|
|
400
|
+
const result = await downloadFileOnce(url, originalFilename, { timeoutMs, destDir, maxSizeBytes });
|
|
401
|
+
// 成功 或 不可重试的错误 → 直接返回
|
|
402
|
+
if (result.filePath || !result.retryable) {
|
|
403
|
+
return { filePath: result.filePath, error: result.error };
|
|
404
|
+
}
|
|
405
|
+
// 可重试的错误,记录后继续
|
|
406
|
+
lastError = { filePath: null, error: result.error };
|
|
407
|
+
console.error(`[image-server] Attempt ${attempt + 1}/${maxRetries + 1} failed (retryable): ${result.error}`);
|
|
408
|
+
}
|
|
409
|
+
// 所有重试用完
|
|
410
|
+
return lastError ?? { filePath: null, error: "下载失败(重试次数耗尽)" };
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* 执行一次下载尝试(无重试逻辑)。
|
|
414
|
+
*/
|
|
415
|
+
async function downloadFileOnce(url, originalFilename, opts) {
|
|
416
|
+
const { timeoutMs, destDir, maxSizeBytes } = opts;
|
|
417
|
+
const controller = new AbortController();
|
|
418
|
+
// timeoutMs > 0 时启用超时;为 0 表示不限时
|
|
419
|
+
const timeoutId = timeoutMs > 0
|
|
420
|
+
? setTimeout(() => controller.abort(), timeoutMs)
|
|
421
|
+
: null;
|
|
422
|
+
let tempPath = null;
|
|
423
|
+
try {
|
|
370
424
|
// 下载文件(带超时控制)
|
|
371
425
|
const response = await fetch(url, { signal: controller.signal });
|
|
372
426
|
if (!response.ok) {
|
|
373
|
-
|
|
374
|
-
|
|
427
|
+
const reason = `HTTP ${response.status} ${response.statusText}`;
|
|
428
|
+
console.error(`[image-server] Download failed: ${reason}`);
|
|
429
|
+
// 5xx 服务端错误可重试,4xx 不可重试
|
|
430
|
+
const retryable = response.status >= 500;
|
|
431
|
+
return { filePath: null, error: `下载失败 (${reason})`, retryable };
|
|
375
432
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const declaredSize = parseInt(contentLength, 10);
|
|
380
|
-
if (declaredSize > maxSizeBytes) {
|
|
381
|
-
console.error(`[image-server] Download rejected: Content-Length ${declaredSize} exceeds limit ${maxSizeBytes}`);
|
|
382
|
-
return null;
|
|
383
|
-
}
|
|
433
|
+
if (!response.body) {
|
|
434
|
+
console.error(`[image-server] Download failed: empty response body`);
|
|
435
|
+
return { filePath: null, error: `下载失败 (响应体为空)`, retryable: false };
|
|
384
436
|
}
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
437
|
+
// ---- 预检 Content-Length(如果服务端返回了该头) ----
|
|
438
|
+
if (maxSizeBytes > 0) {
|
|
439
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
440
|
+
if (contentLength > 0 && contentLength > maxSizeBytes) {
|
|
441
|
+
const sizeMB = (contentLength / (1024 * 1024)).toFixed(1);
|
|
442
|
+
const limitMB = Math.round(maxSizeBytes / (1024 * 1024));
|
|
443
|
+
console.error(`[image-server] File too large (Content-Length: ${sizeMB}MB, limit: ${limitMB}MB): ${url}`);
|
|
444
|
+
return { filePath: null, error: `文件过大(${sizeMB}MB),超过了${limitMB}M的下载限制`, retryable: false };
|
|
445
|
+
}
|
|
390
446
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
console.error(`[image-server] Download aborted: size ${totalSize} exceeds limit ${maxSizeBytes}`);
|
|
401
|
-
return null;
|
|
447
|
+
// 推导扩展名:originalFilename > Content-Disposition > Content-Type > .bin
|
|
448
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
449
|
+
let ext = "";
|
|
450
|
+
if (originalFilename) {
|
|
451
|
+
try {
|
|
452
|
+
ext = path.extname(decodeURIComponent(originalFilename));
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
ext = path.extname(originalFilename);
|
|
402
456
|
}
|
|
403
|
-
chunks.push(value);
|
|
404
457
|
}
|
|
405
|
-
|
|
406
|
-
// 从 Content-Disposition 解析文件名(如果没有提供 originalFilename)
|
|
407
|
-
if (!originalFilename) {
|
|
458
|
+
if (!ext) {
|
|
408
459
|
const disposition = response.headers.get("content-disposition");
|
|
409
460
|
if (disposition) {
|
|
410
|
-
const
|
|
411
|
-
if (
|
|
461
|
+
const m = disposition.match(/filename\*?=(?:UTF-8''|")?([^";]+)"?/i);
|
|
462
|
+
if (m?.[1]) {
|
|
412
463
|
try {
|
|
413
|
-
|
|
464
|
+
ext = path.extname(decodeURIComponent(m[1]));
|
|
414
465
|
}
|
|
415
|
-
catch { /* keep
|
|
466
|
+
catch { /* keep empty */ }
|
|
416
467
|
}
|
|
417
468
|
}
|
|
418
469
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
470
|
+
if (!ext) {
|
|
471
|
+
const mime = contentType.split(";")[0]?.trim() ?? "";
|
|
472
|
+
ext = mime ? (`.${getExtFromMime(mime) ?? "bin"}`) : ".bin";
|
|
473
|
+
}
|
|
474
|
+
// UUID 文件名,绝对不会重名
|
|
475
|
+
const uniqueName = `${crypto.randomUUID()}${ext}`;
|
|
476
|
+
const filePath = path.join(destDir, uniqueName);
|
|
477
|
+
tempPath = filePath + ".tmp";
|
|
478
|
+
// ---- 流式写入临时文件(内存占用恒定,不会 OOM) ----
|
|
479
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
480
|
+
// 如果设置了大小限制,包装一个 Transform 流来监控已写入字节数
|
|
481
|
+
if (maxSizeBytes > 0) {
|
|
482
|
+
const { Transform } = await import("node:stream");
|
|
483
|
+
let bytesWritten = 0;
|
|
484
|
+
const sizeGuard = new Transform({
|
|
485
|
+
transform(chunk, _encoding, callback) {
|
|
486
|
+
bytesWritten += chunk.length;
|
|
487
|
+
if (bytesWritten > maxSizeBytes) {
|
|
488
|
+
const sizeMB = (bytesWritten / (1024 * 1024)).toFixed(1);
|
|
489
|
+
const limitMB = Math.round(maxSizeBytes / (1024 * 1024));
|
|
490
|
+
callback(new Error(`DOWNLOAD_SIZE_EXCEEDED: ${sizeMB}MB > ${limitMB}MB`));
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
callback(null, chunk);
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
const writeStream = fs.createWriteStream(tempPath);
|
|
498
|
+
await pipeline(nodeStream, sizeGuard, writeStream);
|
|
431
499
|
}
|
|
432
500
|
else {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const ext = contentType ? (getExtFromMime(contentType.split(";")[0]?.trim() ?? "") ?? "bin") : "bin";
|
|
436
|
-
finalFilename = `${generateImageId()}.${ext}`;
|
|
501
|
+
const writeStream = fs.createWriteStream(tempPath);
|
|
502
|
+
await pipeline(nodeStream, writeStream);
|
|
437
503
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
fs.
|
|
441
|
-
|
|
442
|
-
|
|
504
|
+
// 流式写入完成,原子重命名为最终文件
|
|
505
|
+
const stat = await fs.promises.stat(tempPath);
|
|
506
|
+
fs.renameSync(tempPath, filePath);
|
|
507
|
+
tempPath = null; // 重命名成功,不再需要清理
|
|
508
|
+
console.log(`[image-server] Downloaded file: ${filePath} (${stat.size} bytes)`);
|
|
509
|
+
return { filePath };
|
|
443
510
|
}
|
|
444
511
|
catch (err) {
|
|
512
|
+
// 清理不完整的临时文件
|
|
513
|
+
if (tempPath) {
|
|
514
|
+
try {
|
|
515
|
+
fs.unlinkSync(tempPath);
|
|
516
|
+
}
|
|
517
|
+
catch { /* ignore cleanup error */ }
|
|
518
|
+
}
|
|
445
519
|
if (err instanceof Error && err.name === "AbortError") {
|
|
446
520
|
console.error(`[image-server] Download timeout after ${timeoutMs}ms: ${url}`);
|
|
521
|
+
return { filePath: null, error: `下载超时(${Math.round(timeoutMs / 1000)}秒)`, retryable: true };
|
|
447
522
|
}
|
|
448
|
-
|
|
449
|
-
|
|
523
|
+
// 大小超限错误 — 不可重试
|
|
524
|
+
if (err instanceof Error && err.message.startsWith("DOWNLOAD_SIZE_EXCEEDED:")) {
|
|
525
|
+
const limitMB = maxSizeBytes > 0 ? Math.round(maxSizeBytes / (1024 * 1024)) : 0;
|
|
526
|
+
console.error(`[image-server] Download size exceeded ${limitMB}MB: ${url}`);
|
|
527
|
+
return { filePath: null, error: `文件过大,超过了${limitMB}M的下载限制`, retryable: false };
|
|
450
528
|
}
|
|
451
|
-
|
|
529
|
+
// 网络层临时错误 — 可重试
|
|
530
|
+
const retryable = isRetryableNetworkError(err);
|
|
531
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
532
|
+
console.error(`[image-server] Download error (retryable=${retryable}):`, err);
|
|
533
|
+
return { filePath: null, error: `下载出错: ${msg}`, retryable };
|
|
452
534
|
}
|
|
453
535
|
finally {
|
|
454
|
-
|
|
536
|
+
if (timeoutId)
|
|
537
|
+
clearTimeout(timeoutId);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* 判断错误是否为可重试的网络临时错误。
|
|
542
|
+
*
|
|
543
|
+
* 覆盖常见的 TCP/DNS 层面临时故障:
|
|
544
|
+
* - ETIMEDOUT: TCP 连接超时
|
|
545
|
+
* - ECONNRESET: 连接被对端重置
|
|
546
|
+
* - ECONNREFUSED: 连接被拒绝
|
|
547
|
+
* - ENOTFOUND: DNS 解析失败
|
|
548
|
+
* - EAI_AGAIN: DNS 临时失败
|
|
549
|
+
* - UND_ERR_CONNECT_TIMEOUT: undici 连接超时
|
|
550
|
+
* - fetch failed: Node.js fetch 底层网络错误的通用消息
|
|
551
|
+
*/
|
|
552
|
+
function isRetryableNetworkError(err) {
|
|
553
|
+
if (!(err instanceof Error))
|
|
554
|
+
return false;
|
|
555
|
+
const code = err.code;
|
|
556
|
+
if (code && ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"].includes(code)) {
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
// Node.js fetch 抛出 TypeError: fetch failed 时,真正的网络错误在 cause 中
|
|
560
|
+
if (err.message === "fetch failed" && err.cause) {
|
|
561
|
+
return isRetryableNetworkError(err.cause);
|
|
455
562
|
}
|
|
563
|
+
return false;
|
|
456
564
|
}
|
|
457
565
|
/**
|
|
458
566
|
* 获取图床服务器配置
|
|
@@ -34,7 +34,9 @@ export interface ProcessedAttachments {
|
|
|
34
34
|
attachmentLocalPaths: Array<string | null>;
|
|
35
35
|
}
|
|
36
36
|
interface ProcessContext {
|
|
37
|
-
|
|
37
|
+
appId: string;
|
|
38
|
+
/** 对话 ID:群聊传 groupOpenid,私聊传 senderId(用于按群/用户隔离下载目录) */
|
|
39
|
+
peerId?: string;
|
|
38
40
|
cfg: unknown;
|
|
39
41
|
log?: {
|
|
40
42
|
info: (msg: string) => void;
|
|
@@ -32,9 +32,10 @@ const EMPTY_RESULT = {
|
|
|
32
32
|
export async function processAttachments(attachments, ctx) {
|
|
33
33
|
if (!attachments?.length)
|
|
34
34
|
return EMPTY_RESULT;
|
|
35
|
-
const {
|
|
36
|
-
const
|
|
37
|
-
const
|
|
35
|
+
const { appId, peerId, cfg, log } = ctx;
|
|
36
|
+
const subPaths = ["downloads", appId, ...(peerId ? [peerId] : [])];
|
|
37
|
+
const downloadDir = getQQBotMediaDir(...subPaths);
|
|
38
|
+
const prefix = `[qqbot:${appId}]`;
|
|
38
39
|
// 结果收集
|
|
39
40
|
const imageUrls = [];
|
|
40
41
|
const imageMediaTypes = [];
|
|
@@ -45,6 +46,8 @@ export async function processAttachments(attachments, ctx) {
|
|
|
45
46
|
const voiceTranscriptSources = [];
|
|
46
47
|
const attachmentLocalPaths = [];
|
|
47
48
|
const otherAttachments = [];
|
|
49
|
+
// 入站附件下载:限制 2 分钟,不限大小
|
|
50
|
+
const INBOUND_DOWNLOAD_TIMEOUT_MS = 2 * 60 * 1000; // 2 分钟
|
|
48
51
|
// Phase 1: 并行下载所有附件
|
|
49
52
|
const downloadTasks = attachments.map(async (att) => {
|
|
50
53
|
const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
|
|
@@ -54,25 +57,28 @@ export async function processAttachments(attachments, ctx) {
|
|
|
54
57
|
: "";
|
|
55
58
|
let localPath = null;
|
|
56
59
|
let audioPath = null;
|
|
60
|
+
let dlError;
|
|
57
61
|
if (isVoice && wavUrl) {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
localPath =
|
|
61
|
-
audioPath =
|
|
62
|
+
const wavResult = await downloadFile(wavUrl, undefined, { destDir: downloadDir, timeoutMs: INBOUND_DOWNLOAD_TIMEOUT_MS });
|
|
63
|
+
if (wavResult.filePath) {
|
|
64
|
+
localPath = wavResult.filePath;
|
|
65
|
+
audioPath = wavResult.filePath;
|
|
62
66
|
log?.info(`${prefix} Voice attachment: ${att.filename}, downloaded WAV directly (skip SILK→WAV)`);
|
|
63
67
|
}
|
|
64
68
|
else {
|
|
65
|
-
log?.error(`${prefix} Failed to download voice_wav_url, falling back to original URL`);
|
|
69
|
+
log?.error(`${prefix} Failed to download voice_wav_url (${wavResult.error}), falling back to original URL`);
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
if (!localPath) {
|
|
69
|
-
|
|
73
|
+
const dlResult = await downloadFile(attUrl, att.filename, { destDir: downloadDir, timeoutMs: INBOUND_DOWNLOAD_TIMEOUT_MS });
|
|
74
|
+
localPath = dlResult.filePath;
|
|
75
|
+
dlError = dlResult.error;
|
|
70
76
|
}
|
|
71
|
-
return { att, attUrl, isVoice, localPath, audioPath };
|
|
77
|
+
return { att, attUrl, isVoice, localPath, audioPath, dlError };
|
|
72
78
|
});
|
|
73
79
|
const downloadResults = await Promise.all(downloadTasks);
|
|
74
80
|
// Phase 2: 并行处理语音转换 + 转录(非语音附件同步归类)
|
|
75
|
-
const processTasks = downloadResults.map(async ({ att, attUrl, isVoice, localPath, audioPath }) => {
|
|
81
|
+
const processTasks = downloadResults.map(async ({ att, attUrl, isVoice, localPath, audioPath, dlError }) => {
|
|
76
82
|
const asrReferText = typeof att.asr_refer_text === "string" ? att.asr_refer_text.trim() : "";
|
|
77
83
|
const wavUrl = isVoice && att.voice_wav_url
|
|
78
84
|
? (att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url)
|
|
@@ -99,14 +105,14 @@ export async function processAttachments(attachments, ctx) {
|
|
|
99
105
|
else {
|
|
100
106
|
log?.error(`${prefix} Failed to download: ${attUrl}`);
|
|
101
107
|
if (att.content_type?.startsWith("image/")) {
|
|
102
|
-
return { localPath: null, type: "image-fallback", attUrl, contentType: att.content_type, meta };
|
|
108
|
+
return { localPath: null, type: "image-fallback", attUrl, contentType: att.content_type, dlError, meta };
|
|
103
109
|
}
|
|
104
110
|
else if (isVoice && asrReferText) {
|
|
105
111
|
log?.info(`${prefix} Voice attachment download failed, using asr_refer_text fallback`);
|
|
106
112
|
return { localPath: null, type: "voice-fallback", transcript: asrReferText, meta };
|
|
107
113
|
}
|
|
108
114
|
else {
|
|
109
|
-
return { localPath: null, type: "other-fallback", filename: att.filename ?? att.content_type, meta };
|
|
115
|
+
return { localPath: null, type: "other-fallback", filename: att.filename ?? att.content_type, dlError, meta };
|
|
110
116
|
}
|
|
111
117
|
}
|
|
112
118
|
});
|
|
@@ -136,6 +142,11 @@ export async function processAttachments(attachments, ctx) {
|
|
|
136
142
|
imageUrls.push(result.attUrl);
|
|
137
143
|
imageMediaTypes.push(result.contentType);
|
|
138
144
|
attachmentLocalPaths.push(null);
|
|
145
|
+
// 给模型一个明确的失败提示(和 other-fallback 对齐)
|
|
146
|
+
const hint = result.dlError?.includes("超时")
|
|
147
|
+
? "(图片下载超时)"
|
|
148
|
+
: "(图片下载失败)";
|
|
149
|
+
otherAttachments.push(`[图片] ${hint}`);
|
|
139
150
|
}
|
|
140
151
|
else if (result.type === "voice-fallback") {
|
|
141
152
|
voiceTranscripts.push(result.transcript);
|
|
@@ -143,7 +154,10 @@ export async function processAttachments(attachments, ctx) {
|
|
|
143
154
|
attachmentLocalPaths.push(null);
|
|
144
155
|
}
|
|
145
156
|
else if (result.type === "other-fallback") {
|
|
146
|
-
|
|
157
|
+
const hint = result.dlError?.includes("超时")
|
|
158
|
+
? "(下载超时)"
|
|
159
|
+
: "(下载失败)";
|
|
160
|
+
otherAttachments.push(`[附件: ${result.filename}] ${hint}`);
|
|
147
161
|
attachmentLocalPaths.push(null);
|
|
148
162
|
}
|
|
149
163
|
}
|