@openclaw/qqbot 2026.5.7 → 2026.5.10-beta.1

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.
@@ -1,9 +1,12 @@
1
- import { c as getPlatformAdapter, i as normalizeOptionalString, n as normalizeLowercaseStringOrEmpty, s as sanitizeFileName } from "./string-normalize-Ci6NM5DE.js";
1
+ import { c as getPlatformAdapter, i as normalizeOptionalString, s as sanitizeFileName } from "./string-normalize-BzzzPOeb.js";
2
2
  import * as fs$1 from "node:fs";
3
3
  import os from "node:os";
4
4
  import * as crypto$1 from "node:crypto";
5
5
  import crypto from "node:crypto";
6
+ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
7
+ import { FsSafeError, openLocalFileSafely, readRegularFile, statRegularFileSync } from "openclaw/plugin-sdk/security-runtime";
6
8
  import * as path$1 from "node:path";
9
+ import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
7
10
  //#region extensions/qqbot/src/engine/types.ts
8
11
  /**
9
12
  * Core API layer public types.
@@ -259,19 +262,20 @@ const QQBOT_MEDIA_SSRF_POLICY = {
259
262
  /** Validate that a file is within the allowed upload size. */
260
263
  function checkFileSize(filePath, maxSize = MAX_UPLOAD_SIZE) {
261
264
  try {
262
- const stat = fs$1.statSync(filePath);
263
- if (stat.size > maxSize) {
264
- const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
265
+ const result = statRegularFileSync(filePath);
266
+ if (result.missing) throw Object.assign(/* @__PURE__ */ new Error(`File not found: ${filePath}`), { code: "ENOENT" });
267
+ if (result.stat.size > maxSize) {
268
+ const sizeMB = (result.stat.size / (1024 * 1024)).toFixed(1);
265
269
  const limitMB = (maxSize / (1024 * 1024)).toFixed(0);
266
270
  return {
267
271
  ok: false,
268
- size: stat.size,
272
+ size: result.stat.size,
269
273
  error: `File is too large (${sizeMB}MB); QQ Bot API limit is ${limitMB}MB`
270
274
  };
271
275
  }
272
276
  return {
273
277
  ok: true,
274
- size: stat.size
278
+ size: result.stat.size
275
279
  };
276
280
  } catch (err) {
277
281
  return {
@@ -283,15 +287,18 @@ function checkFileSize(filePath, maxSize = MAX_UPLOAD_SIZE) {
283
287
  }
284
288
  /** Read file contents asynchronously. */
285
289
  async function readFileAsync(filePath) {
286
- return fs$1.promises.readFile(filePath);
290
+ return (await readRegularFile({ filePath })).buffer;
287
291
  }
288
292
  /** Check file readability asynchronously. */
289
293
  async function fileExistsAsync(filePath) {
294
+ const opened = await openLocalFileSafely({ filePath }).catch(() => null);
295
+ if (!opened) return false;
290
296
  try {
291
- await fs$1.promises.access(filePath, fs$1.constants.R_OK);
292
297
  return true;
293
298
  } catch {
294
299
  return false;
300
+ } finally {
301
+ await opened.handle.close().catch(() => void 0);
295
302
  }
296
303
  }
297
304
  /** Format a byte count into a human-readable size string. */
@@ -302,31 +309,8 @@ function formatFileSize(bytes) {
302
309
  }
303
310
  /** Infer a MIME type from the file extension. */
304
311
  function getMimeType(filePath) {
305
- return MIME_TYPES[normalizeLowercaseStringOrEmpty(path$1.extname(filePath))] ?? "application/octet-stream";
306
- }
307
- /** Canonical ext → MIME table. Single source of truth. */
308
- const MIME_TYPES = {
309
- ".jpg": "image/jpeg",
310
- ".jpeg": "image/jpeg",
311
- ".png": "image/png",
312
- ".gif": "image/gif",
313
- ".webp": "image/webp",
314
- ".bmp": "image/bmp",
315
- ".mp4": "video/mp4",
316
- ".mov": "video/quicktime",
317
- ".avi": "video/x-msvideo",
318
- ".mkv": "video/x-matroska",
319
- ".webm": "video/webm",
320
- ".pdf": "application/pdf",
321
- ".doc": "application/msword",
322
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
323
- ".xls": "application/vnd.ms-excel",
324
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
325
- ".zip": "application/zip",
326
- ".tar": "application/x-tar",
327
- ".gz": "application/gzip",
328
- ".txt": "text/plain"
329
- };
312
+ return mimeTypeFromFilePath(filePath) ?? "application/octet-stream";
313
+ }
330
314
  /** Extensions accepted as image uploads by the QQ Bot media pipeline. */
331
315
  const IMAGE_EXTENSIONS = new Set([
332
316
  ".jpg",
@@ -345,9 +329,10 @@ const IMAGE_EXTENSIONS = new Set([
345
329
  * `data:image/...;base64,` URL).
346
330
  */
347
331
  function getImageMimeType(filePath) {
348
- const ext = normalizeLowercaseStringOrEmpty(path$1.extname(filePath));
332
+ const ext = path$1.extname(filePath).toLowerCase();
349
333
  if (!IMAGE_EXTENSIONS.has(ext)) return null;
350
- return MIME_TYPES[ext] ?? null;
334
+ const mime = mimeTypeFromFilePath(filePath);
335
+ return mime?.startsWith("image/") ? mime : null;
351
336
  }
352
337
  /** Download a remote file into a local directory. */
353
338
  async function downloadFile(url, destDir, originalFilename) {
@@ -381,6 +366,117 @@ async function downloadFile(url, destDir, originalFilename) {
381
366
  }
382
367
  }
383
368
  //#endregion
369
+ //#region extensions/qqbot/src/engine/messaging/media-source.ts
370
+ const DATA_URL_RE = /^data:([^;,]+);base64,(.+)$/i;
371
+ /**
372
+ * Parse a `data:<mime>;base64,<payload>` URL.
373
+ *
374
+ * Returns `null` when the string is not a data URL or does not declare
375
+ * base64 encoding. Non-base64 data URLs are intentionally rejected because
376
+ * the QQ upload API ingests raw base64, not arbitrary URL-encoded payloads.
377
+ */
378
+ function tryParseDataUrl(value) {
379
+ if (!value.startsWith("data:")) return null;
380
+ const m = value.match(DATA_URL_RE);
381
+ if (!m) return null;
382
+ return {
383
+ mime: m[1],
384
+ data: m[2]
385
+ };
386
+ }
387
+ /**
388
+ * Open a local file for upload with defense-in-depth:
389
+ *
390
+ * 1. `O_NOFOLLOW` refuses to traverse symlinks (prevents post-whitelist
391
+ * symlink swaps / TOCTOU attacks).
392
+ * 2. `fstat` on the opened descriptor — NOT `fs.stat` on the path —
393
+ * so the size check applies to the exact byte stream we will read.
394
+ * 3. Rejects non-regular files (sockets / devices / directories).
395
+ * 4. Enforces a caller-specified `maxSize` (default {@link MAX_UPLOAD_SIZE})
396
+ * at open time, so oversized files fail fast without allocating a
397
+ * full buffer. Chunked upload callers should pass a larger ceiling
398
+ * (e.g. `CHUNKED_UPLOAD_MAX_SIZE` from `utils/file-utils.js`).
399
+ *
400
+ * The caller receives the open handle plus validated size and is expected
401
+ * to either {@link OpenedLocalFile.handle.readFile} (one-shot path) or
402
+ * stream via `fs.createReadStream` (chunked path).
403
+ */
404
+ async function openLocalFile(filePath, opts = {}) {
405
+ const maxSize = opts.maxSize ?? 20971520;
406
+ const opened = await openLocalFileSafely({ filePath }).catch((err) => {
407
+ if (err instanceof FsSafeError && err.code === "not-file") throw new Error("Path is not a regular file", { cause: err });
408
+ throw err;
409
+ });
410
+ try {
411
+ if (opened.stat.size > maxSize) throw new Error(`File is too large (${formatFileSize(opened.stat.size)}); QQ Bot API limit is ${formatFileSize(maxSize)}`);
412
+ return {
413
+ handle: opened.handle,
414
+ size: opened.stat.size,
415
+ close: () => opened.handle.close()
416
+ };
417
+ } catch (err) {
418
+ await opened.handle.close().catch(() => void 0);
419
+ throw err;
420
+ }
421
+ }
422
+ /**
423
+ * Normalize a {@link RawMediaSource} into a {@link MediaSource}.
424
+ *
425
+ * - Strings passed via `{ url }` that start with `data:` are auto-resolved
426
+ * to a `base64` branch (this is the unified `data:` URL support that was
427
+ * previously only implemented in `sendImage`).
428
+ * - `localPath` branches open the file with {@link openLocalFile} and carry
429
+ * that descriptor to the uploader, so later reads use the exact file that
430
+ * passed regular-file / O_NOFOLLOW / size validation.
431
+ * - `buffer` branches enforce the same ceiling inline.
432
+ *
433
+ * `maxSize` defaults to {@link MAX_UPLOAD_SIZE} (20MB, one-shot upload limit).
434
+ * Callers that dispatch to the chunked uploader should pass a larger ceiling
435
+ * (e.g. `CHUNKED_UPLOAD_MAX_SIZE`, or a value derived from
436
+ * `getMaxUploadSize(fileType)`).
437
+ *
438
+ * NOTE: Root-whitelist validation (i.e. "this path must live under the
439
+ * allowed QQ Bot media directory") is a caller concern. This function
440
+ * assumes the path has already passed such checks.
441
+ */
442
+ async function normalizeSource(raw, opts = {}) {
443
+ const maxSize = opts.maxSize ?? 20971520;
444
+ if ("url" in raw) {
445
+ const parsed = tryParseDataUrl(raw.url);
446
+ if (parsed) return {
447
+ kind: "base64",
448
+ data: parsed.data,
449
+ mime: parsed.mime
450
+ };
451
+ return {
452
+ kind: "url",
453
+ url: raw.url
454
+ };
455
+ }
456
+ if ("base64" in raw) return {
457
+ kind: "base64",
458
+ data: raw.base64,
459
+ mime: raw.mime
460
+ };
461
+ if ("localPath" in raw) {
462
+ const opened = await openLocalFile(raw.localPath, { maxSize });
463
+ return {
464
+ kind: "localPath",
465
+ path: raw.localPath,
466
+ size: opened.size,
467
+ mime: getMimeType(raw.localPath),
468
+ opened
469
+ };
470
+ }
471
+ if (raw.buffer.length > maxSize) throw new Error(`Buffer is too large (${formatFileSize(raw.buffer.length)}); QQ Bot API limit is ${formatFileSize(maxSize)}`);
472
+ return {
473
+ kind: "buffer",
474
+ buffer: raw.buffer,
475
+ fileName: raw.fileName,
476
+ mime: raw.mime
477
+ };
478
+ }
479
+ //#endregion
384
480
  //#region extensions/qqbot/src/engine/api/retry.ts
385
481
  /**
386
482
  * Execute an async operation with configurable retry semantics.
@@ -650,60 +746,62 @@ var ChunkedMediaApi = class {
650
746
  */
651
747
  async uploadChunked(opts) {
652
748
  const prefix = opts.logPrefix ?? "[qqbot:chunked-upload]";
653
- const input = resolveSource(opts.source, opts.fileName);
654
- const displayName = input.fileName;
655
- const fileSize = input.size;
656
- const pathLabel = input.kind === "localPath" ? input.path : "<buffer>";
657
- this.logger?.info?.(`${prefix} Start: file=${displayName} size=${formatFileSize(fileSize)} type=${opts.fileType}`);
658
- const hashes = await computeHashes(input);
659
- this.logger?.debug?.(`${prefix} hashes: md5=${hashes.md5} sha1=${hashes.sha1} md5_10m=${hashes.md5_10m}`);
660
- if (this.cache) {
661
- const cached = this.cache.get(hashes.md5, opts.scope, opts.targetId, opts.fileType);
662
- if (cached) {
663
- this.logger?.info?.(`${prefix} cache HIT (md5=${hashes.md5.slice(0, 8)}) — skipping chunked upload`);
664
- return {
665
- file_uuid: "",
666
- file_info: cached,
667
- ttl: 0
668
- };
669
- }
670
- }
671
- const fileNameForPrepare = opts.fileType === 4 ? this.sanitize(displayName) : displayName;
672
- const prepareResp = await this.callUploadPrepare(opts, fileNameForPrepare, fileSize, hashes, pathLabel);
673
- const { upload_id, parts } = prepareResp;
674
- const block_size = prepareResp.block_size;
675
- const maxConcurrent = Math.min(prepareResp.concurrency ? prepareResp.concurrency : DEFAULT_CONCURRENT_PARTS, MAX_CONCURRENT_PARTS);
676
- const retryTimeoutMs = prepareResp.retry_timeout ? Math.min(prepareResp.retry_timeout * 1e3, MAX_PART_FINISH_RETRY_TIMEOUT_MS) : void 0;
677
- this.logger?.info?.(`${prefix} prepared: upload_id=${upload_id} block=${formatFileSize(block_size)} parts=${parts.length} concurrency=${maxConcurrent}`);
678
- let completedParts = 0;
679
- let uploadedBytes = 0;
680
- const uploadPart = async (part) => {
681
- const partIndex = part.index;
682
- const offset = (partIndex - 1) * block_size;
683
- const length = Math.min(block_size, fileSize - offset);
684
- const partBuffer = await readPart(input, offset, length);
685
- const md5Hex = crypto$1.createHash("md5").update(partBuffer).digest("hex");
686
- this.logger?.debug?.(`${prefix} part ${partIndex}/${parts.length}: ${formatFileSize(length)} offset=${offset} md5=${md5Hex}`);
687
- await putToPresignedUrl(part.presigned_url, partBuffer, partIndex, parts.length, this.logger, prefix);
688
- await this.callUploadPartFinish(opts, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
689
- completedParts++;
690
- uploadedBytes += length;
691
- this.logger?.info?.(`${prefix} part ${partIndex}/${parts.length} done (${completedParts}/${parts.length})`);
692
- opts.onProgress?.({
693
- completedParts,
694
- totalParts: parts.length,
695
- uploadedBytes,
696
- totalBytes: fileSize
697
- });
698
- };
749
+ const input = await resolveSource(opts.source, opts.fileName);
699
750
  try {
751
+ const displayName = input.fileName;
752
+ const fileSize = input.size;
753
+ const pathLabel = input.kind === "localPath" ? input.path : "<buffer>";
754
+ this.logger?.info?.(`${prefix} Start: file=${displayName} size=${formatFileSize(fileSize)} type=${opts.fileType}`);
755
+ const hashes = await computeHashes(input);
756
+ this.logger?.debug?.(`${prefix} hashes: md5=${hashes.md5} sha1=${hashes.sha1} md5_10m=${hashes.md5_10m}`);
757
+ if (this.cache) {
758
+ const cached = this.cache.get(hashes.md5, opts.scope, opts.targetId, opts.fileType);
759
+ if (cached) {
760
+ this.logger?.info?.(`${prefix} cache HIT (md5=${hashes.md5.slice(0, 8)}) — skipping chunked upload`);
761
+ return {
762
+ file_uuid: "",
763
+ file_info: cached,
764
+ ttl: 0
765
+ };
766
+ }
767
+ }
768
+ const fileNameForPrepare = opts.fileType === 4 ? this.sanitize(displayName) : displayName;
769
+ const prepareResp = await this.callUploadPrepare(opts, fileNameForPrepare, fileSize, hashes, pathLabel);
770
+ const { upload_id, parts } = prepareResp;
771
+ const block_size = prepareResp.block_size;
772
+ const maxConcurrent = Math.min(prepareResp.concurrency ? prepareResp.concurrency : DEFAULT_CONCURRENT_PARTS, MAX_CONCURRENT_PARTS);
773
+ const retryTimeoutMs = prepareResp.retry_timeout ? Math.min(prepareResp.retry_timeout * 1e3, MAX_PART_FINISH_RETRY_TIMEOUT_MS) : void 0;
774
+ this.logger?.info?.(`${prefix} prepared: upload_id=${upload_id} block=${formatFileSize(block_size)} parts=${parts.length} concurrency=${maxConcurrent}`);
775
+ let completedParts = 0;
776
+ let uploadedBytes = 0;
777
+ const uploadPart = async (part) => {
778
+ const partIndex = part.index;
779
+ const offset = (partIndex - 1) * block_size;
780
+ const length = Math.min(block_size, fileSize - offset);
781
+ const partBuffer = await readPart(input, offset, length);
782
+ const md5Hex = crypto$1.createHash("md5").update(partBuffer).digest("hex");
783
+ this.logger?.debug?.(`${prefix} part ${partIndex}/${parts.length}: ${formatFileSize(length)} offset=${offset} md5=${md5Hex}`);
784
+ await putToPresignedUrl(part.presigned_url, partBuffer, partIndex, parts.length, this.logger, prefix);
785
+ await this.callUploadPartFinish(opts, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
786
+ completedParts++;
787
+ uploadedBytes += length;
788
+ this.logger?.info?.(`${prefix} part ${partIndex}/${parts.length} done (${completedParts}/${parts.length})`);
789
+ opts.onProgress?.({
790
+ completedParts,
791
+ totalParts: parts.length,
792
+ uploadedBytes,
793
+ totalBytes: fileSize
794
+ });
795
+ };
700
796
  await runWithConcurrency(parts.map((part) => () => uploadPart(part)), maxConcurrent);
701
- } finally {}
702
- this.logger?.info?.(`${prefix} all parts uploaded, completing...`);
703
- const result = await this.callCompleteUpload(opts, upload_id);
704
- this.logger?.info?.(`${prefix} completed: file_uuid=${result.file_uuid} ttl=${result.ttl}s`);
705
- if (this.cache && result.file_info && result.ttl > 0) this.cache.set(hashes.md5, opts.scope, opts.targetId, opts.fileType, result.file_info, result.file_uuid, result.ttl);
706
- return result;
797
+ this.logger?.info?.(`${prefix} all parts uploaded, completing...`);
798
+ const result = await this.callCompleteUpload(opts, upload_id);
799
+ this.logger?.info?.(`${prefix} completed: file_uuid=${result.file_uuid} ttl=${result.ttl}s`);
800
+ if (this.cache && result.file_info && result.ttl > 0) this.cache.set(hashes.md5, opts.scope, opts.targetId, opts.fileType, result.file_info, result.file_uuid, result.ttl);
801
+ return result;
802
+ } finally {
803
+ if (input.kind === "localPath" && input.closeWhenDone) await input.opened.close().catch(() => void 0);
804
+ }
707
805
  }
708
806
  async callUploadPrepare(opts, fileName, fileSize, hashes, pathLabel) {
709
807
  const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
@@ -743,14 +841,17 @@ var ChunkedMediaApi = class {
743
841
  }, COMPLETE_UPLOAD_RETRY_POLICY, void 0, this.logger);
744
842
  }
745
843
  };
746
- function resolveSource(source, fileNameOverride) {
844
+ async function resolveSource(source, fileNameOverride) {
747
845
  if (source.kind === "localPath") {
748
846
  const inferredName = source.path.split(/[/\\]/).pop() || "file";
847
+ const opened = source.opened ?? await openLocalFile(source.path, { maxSize: Number.MAX_SAFE_INTEGER });
749
848
  return {
750
849
  kind: "localPath",
751
850
  path: source.path,
752
- size: source.size,
753
- fileName: fileNameOverride ?? inferredName
851
+ size: opened.size,
852
+ fileName: fileNameOverride ?? inferredName,
853
+ opened,
854
+ closeWhenDone: source.opened === void 0
754
855
  };
755
856
  }
756
857
  if (source.kind === "buffer") return {
@@ -763,21 +864,16 @@ function resolveSource(source, fileNameOverride) {
763
864
  }
764
865
  async function readPart(input, offset, length) {
765
866
  if (input.kind === "buffer") return input.buffer.subarray(offset, offset + length);
766
- const handle = await fs$1.promises.open(input.path, "r");
767
- try {
768
- const buf = Buffer.alloc(length);
769
- const { bytesRead } = await handle.read(buf, 0, length, offset);
770
- return bytesRead < length ? buf.subarray(0, bytesRead) : buf;
771
- } finally {
772
- await handle.close();
773
- }
867
+ const buf = Buffer.alloc(length);
868
+ const { bytesRead } = await input.opened.handle.read(buf, 0, length, offset);
869
+ return bytesRead < length ? buf.subarray(0, bytesRead) : buf;
774
870
  }
775
871
  /**
776
872
  * Stream the source once to compute md5 + sha1 + md5_10m.
777
873
  *
778
874
  * For buffer inputs the three hashes are computed in a single pass over
779
- * the existing memory. For localPath inputs a ReadStream drives the
780
- * hashers so memory use stays constant.
875
+ * the existing memory. For localPath inputs the verified descriptor drives
876
+ * the hashers so memory use stays constant.
781
877
  */
782
878
  async function computeHashes(input) {
783
879
  if (input.kind === "buffer") {
@@ -794,7 +890,7 @@ async function computeHashes(input) {
794
890
  const md5_10m = crypto$1.createHash("md5");
795
891
  let consumed = 0;
796
892
  const needsMd5_10m = input.size > MD5_10M_SIZE;
797
- const stream = fs$1.createReadStream(input.path);
893
+ const stream = createReadStreamFromHandle(input.opened.handle);
798
894
  stream.on("data", (chunk) => {
799
895
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
800
896
  md5.update(buf);
@@ -816,6 +912,12 @@ async function computeHashes(input) {
816
912
  stream.on("error", reject);
817
913
  });
818
914
  }
915
+ function createReadStreamFromHandle(handle) {
916
+ return handle.createReadStream({
917
+ autoClose: false,
918
+ start: 0
919
+ });
920
+ }
819
921
  /** Per-part retry budget for the COS PUT call (exponential backoff). */
820
922
  const PART_UPLOAD_MAX_RETRIES = 2;
821
923
  async function putToPresignedUrl(presignedUrl, data, partIndex, totalParts, logger, prefix) {
@@ -826,22 +928,30 @@ async function putToPresignedUrl(presignedUrl, data, partIndex, totalParts, logg
826
928
  try {
827
929
  const ab = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
828
930
  const startTime = Date.now();
829
- const response = await fetch(presignedUrl, {
830
- method: "PUT",
831
- body: new Blob([ab]),
832
- headers: { "Content-Length": String(data.length) },
931
+ const { response, release } = await fetchWithSsrFGuard({
932
+ url: presignedUrl,
933
+ auditContext: "qqbot-media-part-upload",
934
+ init: {
935
+ method: "PUT",
936
+ body: new Blob([ab]),
937
+ headers: { "Content-Length": String(data.length) }
938
+ },
833
939
  signal: controller.signal
834
940
  });
835
- const elapsed = Date.now() - startTime;
836
- const requestId = response.headers.get("x-cos-request-id") ?? "-";
837
- const etag = response.headers.get("ETag") ?? "-";
838
- if (!response.ok) {
839
- const body = await response.text().catch(() => "");
840
- logger?.error?.(`${prefix} PUT part ${partIndex}/${totalParts}: HTTP ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${body.slice(0, 160)}`);
841
- throw new Error(`COS PUT failed: ${response.status} ${response.statusText} - ${body.slice(0, 120)}`);
941
+ try {
942
+ const elapsed = Date.now() - startTime;
943
+ const requestId = response.headers.get("x-cos-request-id") ?? "-";
944
+ const etag = response.headers.get("ETag") ?? "-";
945
+ if (!response.ok) {
946
+ const body = await response.text().catch(() => "");
947
+ logger?.error?.(`${prefix} PUT part ${partIndex}/${totalParts}: HTTP ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${body.slice(0, 160)}`);
948
+ throw new Error(`COS PUT failed: ${response.status} ${response.statusText} - ${body.slice(0, 120)}`);
949
+ }
950
+ logger?.debug?.(`${prefix} PUT part ${partIndex}/${totalParts} OK (${elapsed}ms ETag=${etag} requestId=${requestId})`);
951
+ return;
952
+ } finally {
953
+ await release();
842
954
  }
843
- logger?.debug?.(`${prefix} PUT part ${partIndex}/${totalParts} OK (${elapsed}ms ETag=${etag} requestId=${requestId})`);
844
- return;
845
955
  } catch (err) {
846
956
  lastError = err instanceof Error ? err : new Error(String(err));
847
957
  if (lastError.name === "AbortError") lastError = /* @__PURE__ */ new Error(`Part ${partIndex}/${totalParts} upload timeout after ${PART_UPLOAD_TIMEOUT_MS}ms`);
@@ -1401,151 +1511,6 @@ function setCachedFileInfo(contentHash, scope, targetId, fileType, fileInfo, fil
1401
1511
  debugLog(`[upload-cache] Cache SET: key=${key.slice(0, 40)}..., ttl=${effectiveTtl}s, uuid=${fileUuid}`);
1402
1512
  }
1403
1513
  //#endregion
1404
- //#region extensions/qqbot/src/engine/messaging/media-source.ts
1405
- /**
1406
- * Unified media-source abstraction for the QQ Bot upload pipeline.
1407
- *
1408
- * All rich-media entry points (sender.ts#sendMedia, outbound.ts#send*,
1409
- * reply-dispatcher.ts#handle*Payload) funnel through {@link normalizeSource}
1410
- * before reaching the low-level {@link MediaApi}.
1411
- *
1412
- * ## Why four branches?
1413
- *
1414
- * - `url` — remote http(s) URL that the QQ server can fetch directly.
1415
- * - `base64` — in-memory base64 string (typically from a `data:` URL).
1416
- * - `localPath` — on-disk file; kept as a path so a future chunked-upload
1417
- * implementation can stream it via `fs.createReadStream` without the 4/3×
1418
- * base64 memory overhead.
1419
- * - `buffer` — in-memory raw bytes (e.g. TTS output, downloaded url-fallback).
1420
- *
1421
- * ## Security baseline (localPath branch)
1422
- *
1423
- * `openLocalFile` is the single canonical implementation of "safely open a
1424
- * local file for upload" across the plugin. It merges the previously
1425
- * inconsistent strategies from `reply-dispatcher.ts` (O_NOFOLLOW + size check)
1426
- * and `outbound.ts` (realpath + root containment). Callers are still
1427
- * responsible for *root-whitelist* validation (via
1428
- * `resolveQQBotPayloadLocalFilePath` / `resolveOutboundMediaPath`) before
1429
- * passing the path in; this function enforces *file-level* safety only.
1430
- *
1431
- * Chunked upload is not implemented in this PR, but the contract here already
1432
- * returns `size` metadata so `sendMediaInternal` can route by size without
1433
- * reading the whole file first.
1434
- */
1435
- const DATA_URL_RE = /^data:([^;,]+);base64,(.+)$/i;
1436
- /**
1437
- * Parse a `data:<mime>;base64,<payload>` URL.
1438
- *
1439
- * Returns `null` when the string is not a data URL or does not declare
1440
- * base64 encoding. Non-base64 data URLs are intentionally rejected because
1441
- * the QQ upload API ingests raw base64, not arbitrary URL-encoded payloads.
1442
- */
1443
- function tryParseDataUrl(value) {
1444
- if (!value.startsWith("data:")) return null;
1445
- const m = value.match(DATA_URL_RE);
1446
- if (!m) return null;
1447
- return {
1448
- mime: m[1],
1449
- data: m[2]
1450
- };
1451
- }
1452
- /**
1453
- * Open a local file for upload with defense-in-depth:
1454
- *
1455
- * 1. `O_NOFOLLOW` refuses to traverse symlinks (prevents post-whitelist
1456
- * symlink swaps / TOCTOU attacks).
1457
- * 2. `fstat` on the opened descriptor — NOT `fs.stat` on the path —
1458
- * so the size check applies to the exact byte stream we will read.
1459
- * 3. Rejects non-regular files (sockets / devices / directories).
1460
- * 4. Enforces a caller-specified `maxSize` (default {@link MAX_UPLOAD_SIZE})
1461
- * at open time, so oversized files fail fast without allocating a
1462
- * full buffer. Chunked upload callers should pass a larger ceiling
1463
- * (e.g. `CHUNKED_UPLOAD_MAX_SIZE` from `utils/file-utils.js`).
1464
- *
1465
- * The caller receives the open handle plus validated size and is expected
1466
- * to either {@link OpenedLocalFile.handle.readFile} (one-shot path) or
1467
- * stream via `fs.createReadStream` (chunked path).
1468
- */
1469
- async function openLocalFile(filePath, opts = {}) {
1470
- const maxSize = opts.maxSize ?? 20971520;
1471
- const openFlags = fs$1.constants.O_RDONLY | ("O_NOFOLLOW" in fs$1.constants ? fs$1.constants.O_NOFOLLOW : 0);
1472
- const handle = await fs$1.promises.open(filePath, openFlags);
1473
- try {
1474
- const stat = await handle.stat();
1475
- if (!stat.isFile()) throw new Error("Path is not a regular file");
1476
- if (stat.size > maxSize) throw new Error(`File is too large (${formatFileSize(stat.size)}); QQ Bot API limit is ${formatFileSize(maxSize)}`);
1477
- return {
1478
- handle,
1479
- size: stat.size,
1480
- close: () => handle.close()
1481
- };
1482
- } catch (err) {
1483
- await handle.close().catch(() => void 0);
1484
- throw err;
1485
- }
1486
- }
1487
- /**
1488
- * Normalize a {@link RawMediaSource} into a {@link MediaSource}.
1489
- *
1490
- * - Strings passed via `{ url }` that start with `data:` are auto-resolved
1491
- * to a `base64` branch (this is the unified `data:` URL support that was
1492
- * previously only implemented in `sendImage`).
1493
- * - `localPath` branches open the file with {@link openLocalFile} solely to
1494
- * validate size / regular-file / O_NOFOLLOW invariants. The handle is
1495
- * closed immediately — actual reading is deferred to the uploader so
1496
- * the chunked path can stream without double-reading.
1497
- * - `buffer` branches enforce the same ceiling inline.
1498
- *
1499
- * `maxSize` defaults to {@link MAX_UPLOAD_SIZE} (20MB, one-shot upload limit).
1500
- * Callers that dispatch to the chunked uploader should pass a larger ceiling
1501
- * (e.g. `CHUNKED_UPLOAD_MAX_SIZE`, or a value derived from
1502
- * `getMaxUploadSize(fileType)`).
1503
- *
1504
- * NOTE: Root-whitelist validation (i.e. "this path must live under the
1505
- * allowed QQ Bot media directory") is a caller concern. This function
1506
- * assumes the path has already passed such checks.
1507
- */
1508
- async function normalizeSource(raw, opts = {}) {
1509
- const maxSize = opts.maxSize ?? 20971520;
1510
- if ("url" in raw) {
1511
- const parsed = tryParseDataUrl(raw.url);
1512
- if (parsed) return {
1513
- kind: "base64",
1514
- data: parsed.data,
1515
- mime: parsed.mime
1516
- };
1517
- return {
1518
- kind: "url",
1519
- url: raw.url
1520
- };
1521
- }
1522
- if ("base64" in raw) return {
1523
- kind: "base64",
1524
- data: raw.base64,
1525
- mime: raw.mime
1526
- };
1527
- if ("localPath" in raw) {
1528
- const opened = await openLocalFile(raw.localPath, { maxSize });
1529
- try {
1530
- return {
1531
- kind: "localPath",
1532
- path: raw.localPath,
1533
- size: opened.size,
1534
- mime: getMimeType(raw.localPath)
1535
- };
1536
- } finally {
1537
- await opened.close();
1538
- }
1539
- }
1540
- if (raw.buffer.length > maxSize) throw new Error(`Buffer is too large (${formatFileSize(raw.buffer.length)}); QQ Bot API limit is ${formatFileSize(maxSize)}`);
1541
- return {
1542
- kind: "buffer",
1543
- buffer: raw.buffer,
1544
- fileName: raw.fileName,
1545
- mime: raw.mime
1546
- };
1547
- }
1548
- //#endregion
1549
1514
  //#region extensions/qqbot/src/engine/messaging/sender.ts
1550
1515
  /**
1551
1516
  * Unified message sender — per-account resource management + business function layer.
@@ -1891,14 +1856,18 @@ async function sendMediaInternal(ctx, opts) {
1891
1856
  clientSecret: opts.creds.clientSecret
1892
1857
  };
1893
1858
  const source = await normalizeSource(opts.source, { maxSize: Number.MAX_SAFE_INTEGER });
1894
- const uploadResult = await dispatchUpload(ctx, scope, opts.target.id, KIND_TO_FILE_TYPE[opts.kind], source, c, opts.fileName);
1895
- const msgContent = opts.kind === "image" || opts.kind === "video" ? opts.content : void 0;
1896
- const result = await ctx.mediaApi.sendMediaMessage(scope, opts.target.id, uploadResult.file_info, c, {
1897
- msgId: opts.msgId,
1898
- content: msgContent
1899
- });
1900
- notifyMediaHook(opts.creds.appId, result, buildOutboundMeta(opts, source));
1901
- return result;
1859
+ try {
1860
+ const uploadResult = await dispatchUpload(ctx, scope, opts.target.id, KIND_TO_FILE_TYPE[opts.kind], source, c, opts.fileName);
1861
+ const msgContent = opts.kind === "image" || opts.kind === "video" ? opts.content : void 0;
1862
+ const result = await ctx.mediaApi.sendMediaMessage(scope, opts.target.id, uploadResult.file_info, c, {
1863
+ msgId: opts.msgId,
1864
+ content: msgContent
1865
+ });
1866
+ notifyMediaHook(opts.creds.appId, result, buildOutboundMeta(opts, source));
1867
+ return result;
1868
+ } finally {
1869
+ if (source.kind === "localPath") await source.opened?.close().catch(() => void 0);
1870
+ }
1902
1871
  }
1903
1872
  /**
1904
1873
  * Upload a {@link MediaSource} via the one-shot or chunked path, chosen by
@@ -1931,6 +1900,10 @@ async function dispatchUpload(ctx, scope, targetId, fileType, source, creds, fil
1931
1900
  creds,
1932
1901
  fileName
1933
1902
  });
1903
+ if (source.opened) return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
1904
+ buffer: await source.opened.handle.readFile(),
1905
+ fileName
1906
+ });
1934
1907
  return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
1935
1908
  localPath: source.path,
1936
1909
  fileName
@@ -1984,4 +1957,4 @@ function supportsRichMedia(targetType) {
1984
1957
  return targetType === "c2c" || targetType === "group";
1985
1958
  }
1986
1959
  //#endregion
1987
- export { fileExistsAsync as A, StreamInputState as B, debugLog as C, UPLOAD_PREPARE_FALLBACK_CODE as D, getNextMsgSeq as E, readFileAsync as F, formatDuration as I, formatErrorMessage as L, getFileTypeName as M, getImageMimeType as N, checkFileSize as O, getMaxUploadSize as P, StreamContentType as R, debugError as S, UploadDailyLimitExceededError as T, setOpenClawVersion as _, createRawInputNotifyFn as a, withTokenRetry as b, getMessageApi as c, initSender as d, onMessageSent as f, sendText as g, sendMedia as h, clearTokenCache as i, formatFileSize as j, downloadFile as k, getPluginUserAgent as l, sendInputNotify as m, acknowledgeInteraction as n, getAccessToken as o, registerAccount as p, buildDeliveryTarget as r, getGatewayUrl as s, accountToCreds as t, initApiConfig as u, startBackgroundTokenRefresh as v, debugWarn as w, openLocalFile as x, stopBackgroundTokenRefresh as y, StreamInputMode as z };
1960
+ export { fileExistsAsync as A, StreamInputState as B, debugWarn as C, openLocalFile as D, UPLOAD_PREPARE_FALLBACK_CODE as E, readFileAsync as F, formatDuration as I, formatErrorMessage as L, getFileTypeName as M, getImageMimeType as N, checkFileSize as O, getMaxUploadSize as P, StreamContentType as R, debugLog as S, getNextMsgSeq as T, setOpenClawVersion as _, createRawInputNotifyFn as a, withTokenRetry as b, getMessageApi as c, initSender as d, onMessageSent as f, sendText as g, sendMedia as h, clearTokenCache as i, formatFileSize as j, downloadFile as k, getPluginUserAgent as l, sendInputNotify as m, acknowledgeInteraction as n, getAccessToken as o, registerAccount as p, buildDeliveryTarget as r, getGatewayUrl as s, accountToCreds as t, initApiConfig as u, startBackgroundTokenRefresh as v, UploadDailyLimitExceededError as w, debugError as x, stopBackgroundTokenRefresh as y, StreamInputMode as z };
@@ -1,2 +1,2 @@
1
- import { t as qqbotSetupPlugin } from "./channel.setup-ByCRAe5H.js";
1
+ import { t as qqbotSetupPlugin } from "./channel.setup-BDM8y3aD.js";
2
2
  export { qqbotSetupPlugin };