@openclaw/qqbot 2026.5.7 → 2026.5.9-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.
- package/dist/api.js +5 -5
- package/dist/{channel-CC2YO9fj.js → channel-nsJeL4qp.js} +128 -58
- package/dist/channel-plugin-api.js +1 -1
- package/dist/{channel.setup-ByCRAe5H.js → channel.setup-DS8qSqfn.js} +1 -1
- package/dist/{config-schema-DFcjQw73.js → config-schema-BUrvFkQH.js} +1 -1
- package/dist/{gateway-Cs3-_on9.js → gateway-Dcs6Ip8b.js} +74 -34
- package/dist/{handler-runtime-Bqm6N0WG.js → handler-runtime-DraqODp_.js} +1 -1
- package/dist/{outbound-BJfhwrPg.js → outbound-CqRmSFJZ.js} +18 -57
- package/dist/{request-context-Cjwx7OwW.js → request-context-DVsN666m.js} +26 -7
- package/dist/{runtime-BJAS3eXW.js → runtime-C3Qpr4Ph.js} +1 -1
- package/dist/runtime-api.js +1 -1
- package/dist/{sender-p-B14eLG.js → sender-CgdXGY2H.js} +241 -268
- package/dist/setup-plugin-api.js +1 -1
- package/dist/{target-parser-Y0prnrXD.js → target-parser-DQ6J3Qe-.js} +1 -1
- package/package.json +5 -6
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { c as getPlatformAdapter, i as normalizeOptionalString,
|
|
1
|
+
import { c as getPlatformAdapter, i as normalizeOptionalString, s as sanitizeFileName } from "./string-normalize-Ci6NM5DE.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
|
|
263
|
-
if (
|
|
264
|
-
|
|
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
|
|
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
|
|
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 =
|
|
332
|
+
const ext = path$1.extname(filePath).toLowerCase();
|
|
349
333
|
if (!IMAGE_EXTENSIONS.has(ext)) return null;
|
|
350
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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:
|
|
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
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
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 =
|
|
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
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
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,
|
|
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 };
|
package/dist/setup-plugin-api.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as qqbotSetupPlugin } from "./channel.setup-
|
|
1
|
+
import { t as qqbotSetupPlugin } from "./channel.setup-DS8qSqfn.js";
|
|
2
2
|
export { qqbotSetupPlugin };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { c as getPlatformAdapter } from "./string-normalize-Ci6NM5DE.js";
|
|
2
|
-
import { C as
|
|
2
|
+
import { C as debugWarn, L as formatErrorMessage, S as debugLog } from "./sender-CgdXGY2H.js";
|
|
3
3
|
import * as fs$1 from "node:fs";
|
|
4
4
|
import * as os$1 from "node:os";
|
|
5
5
|
import * as path$1 from "node:path";
|