@openclaw/qqbot 2026.7.1-beta.1 → 2026.7.1-beta.4

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,13 +1,17 @@
1
- import { A as getFileTypeName, D as downloadFile, E as checkFileSize, L as __exportAll, M as getMaxUploadSize, N as readFileAsync, O as fileExistsAsync, S as UploadDailyLimitExceededError, g as sendText$1, h as sendMedia$1, j as getImageMimeType, k as formatFileSize, t as accountToCreds, u as initApiConfig, w as UPLOAD_PREPARE_FALLBACK_CODE } from "./sender-BHWpE_yH.js";
1
+ import { A as getFileTypeName, D as downloadFile, E as checkFileSize, L as __exportAll, M as getMaxUploadSize, N as readFileAsync, O as fileExistsAsync, S as UploadDailyLimitExceededError, g as sendText$1, h as sendMedia$1, j as getImageMimeType, k as formatFileSize, t as accountToCreds, u as initApiConfig, w as UPLOAD_PREPARE_FALLBACK_CODE } from "./sender-CjDuU-uz.js";
2
2
  import { c as getPlatformAdapter, i as normalizeOptionalString, n as normalizeLowercaseStringOrEmpty, s as sanitizeFileName } from "./string-normalize-R_0cKO7Q.js";
3
- import { a as formatErrorMessage, n as debugLog, r as debugWarn, t as debugError } from "./log-SDfMMBWe.js";
3
+ import { a as formatErrorMessage, n as debugLog, r as debugWarn, t as debugError } from "./log-DEtcoDWe.js";
4
4
  import { r as parseTarget$1 } from "./target-parser-DagYRQkP.js";
5
- import * as fs$1 from "node:fs";
5
+ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
6
+ import * as fs$2 from "node:fs";
6
7
  import * as os$1 from "node:os";
8
+ import { randomUUID } from "node:crypto";
7
9
  import { pathExistsSync, resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime";
8
10
  import * as path$1 from "node:path";
9
11
  import path from "node:path";
10
- import { getFileExtension } from "openclaw/plugin-sdk/media-mime";
12
+ import { extensionForMime, getFileExtension } from "openclaw/plugin-sdk/media-mime";
13
+ import { mkdir, writeFile } from "node:fs/promises";
14
+ import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
11
15
  import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
12
16
  //#region extensions/qqbot/src/engine/utils/platform.ts
13
17
  /**
@@ -32,10 +36,10 @@ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
32
36
  function getHomeDir() {
33
37
  try {
34
38
  const home = os$1.homedir();
35
- if (home && fs$1.existsSync(home)) return home;
39
+ if (home && fs$2.existsSync(home)) return home;
36
40
  } catch {}
37
41
  const envHome = process.env.HOME || process.env.USERPROFILE;
38
- if (envHome && fs$1.existsSync(envHome)) return envHome;
42
+ if (envHome && fs$2.existsSync(envHome)) return envHome;
39
43
  return getPlatformAdapter().getTempDir();
40
44
  }
41
45
  /**
@@ -76,7 +80,7 @@ function getQQBotDataPath(...subPaths) {
76
80
  /** Return a path under `~/.openclaw/qqbot`, creating it on demand. */
77
81
  function getQQBotDataDir(...subPaths) {
78
82
  const dir = getQQBotDataPath(...subPaths);
79
- if (!fs$1.existsSync(dir)) fs$1.mkdirSync(dir, { recursive: true });
83
+ if (!fs$2.existsSync(dir)) fs$2.mkdirSync(dir, { recursive: true });
80
84
  return dir;
81
85
  }
82
86
  /**
@@ -94,7 +98,7 @@ function getQQBotMediaPath(...subPaths) {
94
98
  /** Return a path under `<openclaw-home>/.openclaw/media/qqbot`, creating it on demand. */
95
99
  function getQQBotMediaDir(...subPaths) {
96
100
  const dir = getQQBotMediaPath(...subPaths);
97
- if (!fs$1.existsSync(dir)) fs$1.mkdirSync(dir, { recursive: true });
101
+ if (!fs$2.existsSync(dir)) fs$2.mkdirSync(dir, { recursive: true });
98
102
  return dir;
99
103
  }
100
104
  /**
@@ -124,7 +128,7 @@ async function checkSilkWasmAvailable() {
124
128
  if (silkWasmAvailable !== null) return silkWasmAvailable;
125
129
  try {
126
130
  const { isSilk } = await import("silk-wasm");
127
- isSilk(new Uint8Array(0));
131
+ isSilk(/* @__PURE__ */ new Uint8Array(0));
128
132
  silkWasmAvailable = true;
129
133
  debugLog("[platform] silk-wasm: available");
130
134
  } catch (err) {
@@ -163,20 +167,20 @@ function isLocalPath(p) {
163
167
  if (p.startsWith(".\\") || p.startsWith("..\\")) return true;
164
168
  return false;
165
169
  }
166
- function isPathWithinRoot(candidate, root) {
170
+ function isPathWithinRoot$1(candidate, root) {
167
171
  const relative = path$1.relative(root, candidate);
168
172
  return relative === "" || !relative.startsWith("..") && !path$1.isAbsolute(relative);
169
173
  }
170
174
  /** Remap legacy or hallucinated QQ Bot local media paths to real files when possible. */
171
175
  function resolveQQBotLocalMediaPath(p) {
172
176
  const normalized = normalizePath$1(p);
173
- if (!isLocalPath(normalized) || fs$1.existsSync(normalized)) return normalized;
177
+ if (!isLocalPath(normalized) || fs$2.existsSync(normalized)) return normalized;
174
178
  const osHomeDir = getHomeDir();
175
179
  const openclawHomeDir = resolveOpenClawHome();
176
180
  const mediaRoot = getQQBotMediaPath();
177
181
  const dataRoot = getQQBotDataPath();
178
182
  const candidateRoots = [
179
- ...Array.from(new Set([path$1.join(osHomeDir, ".openclaw", "workspace", "qqbot"), path$1.join(openclawHomeDir, ".openclaw", "workspace", "qqbot")])).map((from) => ({
183
+ ...Array.from(/* @__PURE__ */ new Set([path$1.join(osHomeDir, ".openclaw", "workspace", "qqbot"), path$1.join(openclawHomeDir, ".openclaw", "workspace", "qqbot")])).map((from) => ({
180
184
  from,
181
185
  to: mediaRoot
182
186
  })),
@@ -190,10 +194,10 @@ function resolveQQBotLocalMediaPath(p) {
190
194
  }
191
195
  ];
192
196
  for (const { from, to } of candidateRoots) {
193
- if (!isPathWithinRoot(normalized, from)) continue;
197
+ if (!isPathWithinRoot$1(normalized, from)) continue;
194
198
  const relative = path$1.relative(from, normalized);
195
199
  const candidate = path$1.join(to, relative);
196
- if (fs$1.existsSync(candidate)) {
200
+ if (fs$2.existsSync(candidate)) {
197
201
  debugWarn(`[platform] Remapped missing QQBot media path ${normalized} -> ${candidate}`);
198
202
  return candidate;
199
203
  }
@@ -208,12 +212,12 @@ function resolveQQBotPayloadLocalFilePath(p) {
208
212
  const candidate = resolveQQBotLocalMediaPath(p);
209
213
  if (!candidate.trim()) return null;
210
214
  const resolvedCandidate = path$1.resolve(candidate);
211
- if (!fs$1.existsSync(resolvedCandidate)) return null;
212
- const canonicalCandidate = fs$1.realpathSync(resolvedCandidate);
215
+ if (!fs$2.existsSync(resolvedCandidate)) return null;
216
+ const canonicalCandidate = fs$2.realpathSync(resolvedCandidate);
213
217
  const allowedRoots = [getOpenClawMediaDir(), getQQBotMediaPath()];
214
218
  for (const root of allowedRoots) {
215
219
  const resolvedRoot = path$1.resolve(root);
216
- if (isPathWithinRoot(canonicalCandidate, fs$1.existsSync(resolvedRoot) ? fs$1.realpathSync(resolvedRoot) : resolvedRoot)) return canonicalCandidate;
220
+ if (isPathWithinRoot$1(canonicalCandidate, fs$2.existsSync(resolvedRoot) ? fs$2.realpathSync(resolvedRoot) : resolvedRoot)) return canonicalCandidate;
217
221
  }
218
222
  return null;
219
223
  }
@@ -406,6 +410,46 @@ function buildFileTooLargeResult(fileType, fileSize) {
406
410
  };
407
411
  }
408
412
  //#endregion
413
+ //#region extensions/qqbot/src/engine/messaging/outbound-media-path.ts
414
+ function mergeMediaLocalRoots(...groups) {
415
+ const roots = groups.flatMap((group) => group ?? []).map((root) => root.trim()).filter(Boolean);
416
+ return roots.length > 0 ? Array.from(new Set(roots)) : void 0;
417
+ }
418
+ function resolveOutboundMediaLocalRoots(ctx) {
419
+ return mergeMediaLocalRoots(ctx.mediaAccess?.localRoots, ctx.mediaLocalRoots);
420
+ }
421
+ function isPathWithinRoot(candidatePath, rootPath) {
422
+ const resolvedRoot = path.resolve(rootPath);
423
+ if (resolvedRoot === path.parse(resolvedRoot).root) return false;
424
+ const relative = path.relative(resolvedRoot, path.resolve(candidatePath));
425
+ return relative === "" || relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
426
+ }
427
+ function resolvePathInsideWorkspace(workspaceDir, pathWithinWorkspace) {
428
+ const mappedPath = path.resolve(workspaceDir, pathWithinWorkspace);
429
+ return isPathWithinRoot(mappedPath, workspaceDir) ? mappedPath : null;
430
+ }
431
+ function isVirtualWorkspacePath(normalizedPath) {
432
+ return normalizedPath === "/workspace" || normalizedPath.startsWith("/workspace/");
433
+ }
434
+ function resolveWorkspaceScopedLocalRoots(roots, workspaceDir) {
435
+ if (!roots?.length) return;
436
+ const scopedRoots = roots.map((root) => root.trim()).filter(Boolean).map((root) => workspaceDir && isVirtualWorkspacePath(root) ? resolveWorkspacePathCandidate(root, workspaceDir) : root).filter((root) => Boolean(root));
437
+ return scopedRoots.length > 0 ? Array.from(new Set(scopedRoots)) : void 0;
438
+ }
439
+ function resolveWorkspacePathCandidate(normalizedPath, workspaceDir) {
440
+ if (!workspaceDir) return isVirtualWorkspacePath(normalizedPath) ? null : normalizedPath;
441
+ if (normalizedPath === "/workspace") return workspaceDir;
442
+ if (normalizedPath.startsWith("/workspace/")) return resolvePathInsideWorkspace(workspaceDir, normalizedPath.slice(11));
443
+ if (path.isAbsolute(normalizedPath)) return normalizedPath;
444
+ return resolvePathInsideWorkspace(workspaceDir, normalizedPath);
445
+ }
446
+ function resolveWorkspacePathCandidates(normalizedPath, workspaceDir) {
447
+ const mappedPath = resolveWorkspacePathCandidate(normalizedPath, workspaceDir);
448
+ if (!mappedPath) return [];
449
+ if (mappedPath === normalizedPath) return [normalizedPath];
450
+ return path.isAbsolute(normalizedPath) && !isVirtualWorkspacePath(normalizedPath) ? [normalizedPath, mappedPath] : [mappedPath];
451
+ }
452
+ //#endregion
409
453
  //#region extensions/qqbot/src/engine/messaging/trusted-media-path.ts
410
454
  let cachedTrustedTmpRoot;
411
455
  function trustedOpenClawTmpRoot() {
@@ -462,11 +506,15 @@ function parseTarget(to) {
462
506
  /** Build a media target from a normal outbound context. */
463
507
  function buildMediaTarget(ctx) {
464
508
  const target = parseTarget(ctx.to);
509
+ const mediaLocalRoots = resolveOutboundMediaLocalRoots(ctx);
465
510
  return {
466
511
  targetType: target.type,
467
512
  targetId: target.id,
468
513
  account: ctx.account,
469
- replyToId: ctx.replyToId ?? void 0
514
+ replyToId: ctx.replyToId ?? void 0,
515
+ ...mediaLocalRoots ? { mediaLocalRoots } : {},
516
+ ...ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {},
517
+ ...ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}
470
518
  };
471
519
  }
472
520
  /** Return true when public URLs should be passed through directly. */
@@ -480,19 +528,28 @@ const qqBotMediaKindLabel = {
480
528
  file: "File",
481
529
  media: "Media"
482
530
  };
531
+ function isHttpUrl(pathValue) {
532
+ return pathValue.startsWith("http://") || pathValue.startsWith("https://");
533
+ }
534
+ function isDataUrl(pathValue) {
535
+ return pathValue.startsWith("data:");
536
+ }
483
537
  function isHttpOrDataSource(pathValue) {
484
- return pathValue.startsWith("http://") || pathValue.startsWith("https://") || pathValue.startsWith("data:");
538
+ return isHttpUrl(pathValue) || isDataUrl(pathValue);
485
539
  }
486
- function resolveMissingPathWithinMediaRoot(normalizedPath) {
540
+ function resolveMissingPathWithinRoots(normalizedPath, allowedRoots) {
487
541
  const resolvedCandidate = path.resolve(normalizedPath);
488
542
  if (pathExistsSync(resolvedCandidate)) return null;
489
543
  return resolveLocalPathFromRootsSync({
490
544
  filePath: resolvedCandidate,
491
- roots: [getQQBotMediaDir()],
492
- label: "QQ Bot media storage",
545
+ roots: allowedRoots,
546
+ label: "QQ Bot local roots",
493
547
  allowMissing: true
494
548
  })?.path ?? null;
495
549
  }
550
+ function isPathWithinAnyRoot(candidatePath, allowedRoots) {
551
+ return allowedRoots?.some((root) => root.trim() && isPathWithinRoot(candidatePath, root)) ?? false;
552
+ }
496
553
  function resolveExistingPathWithinRoots(normalizedPath, allowedRoots) {
497
554
  return resolveLocalPathFromRootsSync({
498
555
  filePath: normalizedPath,
@@ -500,30 +557,166 @@ function resolveExistingPathWithinRoots(normalizedPath, allowedRoots) {
500
557
  label: "QQ Bot local roots"
501
558
  })?.path ?? null;
502
559
  }
560
+ function resolveOutboundMediaReadFile(ctx) {
561
+ return ctx.mediaAccess?.readFile ?? ctx.mediaReadFile;
562
+ }
563
+ function resolveHostReadMediaAccess(ctx) {
564
+ const mediaLocalRoots = resolveWorkspaceScopedLocalRoots(resolveOutboundMediaLocalRoots(ctx), ctx.mediaAccess?.workspaceDir);
565
+ if (!ctx.mediaAccess && !mediaLocalRoots) return;
566
+ const { localRoots: _localRoots, ...mediaAccessWithoutRoots } = ctx.mediaAccess ?? {};
567
+ return {
568
+ ...mediaAccessWithoutRoots,
569
+ ...mediaLocalRoots ? { localRoots: mediaLocalRoots } : {}
570
+ };
571
+ }
572
+ function mediaFileTypeForKind(mediaKind) {
573
+ switch (mediaKind) {
574
+ case "image": return 1;
575
+ case "voice": return 3;
576
+ case "video": return 2;
577
+ default: return 4;
578
+ }
579
+ }
580
+ function senderKindForLoadedMedia(mediaKind, loadedKind) {
581
+ if (mediaKind === "image") return loadedKind === "image" ? "image" : null;
582
+ if (mediaKind === "video") return loadedKind === "video" ? "video" : null;
583
+ if (mediaKind === "file") return "file";
584
+ if (loadedKind === "image") return "image";
585
+ if (loadedKind === "video") return "video";
586
+ return "file";
587
+ }
588
+ function resolveHostReadMediaPath(ctx, mediaPath) {
589
+ const normalizedPath = normalizePath$1(mediaPath);
590
+ if (path.isAbsolute(normalizedPath)) {
591
+ if (normalizedPath === "/workspace" || normalizedPath.startsWith("/workspace/")) return ctx.mediaAccess?.workspaceDir ? resolveWorkspacePathCandidate(normalizedPath, ctx.mediaAccess.workspaceDir) : null;
592
+ if (isPathWithinAnyRoot(normalizedPath, resolveOutboundMediaLocalRoots(ctx))) return normalizedPath;
593
+ return null;
594
+ }
595
+ if (!ctx.mediaAccess?.workspaceDir) return null;
596
+ return resolveWorkspacePathCandidate(normalizedPath, ctx.mediaAccess.workspaceDir);
597
+ }
598
+ async function stageLoadedHostReadVoice(mediaPath, loaded) {
599
+ const stagedDir = getQQBotMediaDir("host-read", "voice");
600
+ await mkdir(stagedDir, { recursive: true });
601
+ const rawFileName = sanitizeFileName(loaded.fileName || path.basename(mediaPath) || "voice");
602
+ const ext = path.extname(rawFileName);
603
+ const inferredExt = extensionForMime(loaded.contentType);
604
+ const baseName = sanitizeFileName(path.basename(rawFileName, ext)) || "voice";
605
+ const stagedPath = path.join(stagedDir, `${baseName}-${randomUUID()}${ext || inferredExt || ".bin"}`);
606
+ await writeFile(stagedPath, loaded.buffer);
607
+ return stagedPath;
608
+ }
609
+ async function stageHostReadVoice(ctx, mediaPath) {
610
+ const mediaReadFile = resolveOutboundMediaReadFile(ctx);
611
+ if (!mediaReadFile || isHttpOrDataSource(mediaPath)) return null;
612
+ const hostReadMediaPath = resolveHostReadMediaPath(ctx, mediaPath);
613
+ if (!hostReadMediaPath) return null;
614
+ const mediaAccess = resolveHostReadMediaAccess(ctx);
615
+ const loaded = await loadOutboundMediaFromUrl(hostReadMediaPath, {
616
+ maxBytes: getMaxUploadSize(3),
617
+ mediaAccess,
618
+ mediaReadFile,
619
+ workspaceDir: mediaAccess?.workspaceDir
620
+ });
621
+ if (loaded.kind !== "audio") throw new Error(`Unsupported voice media type: ${loaded.kind ?? "unknown"}`);
622
+ return await stageLoadedHostReadVoice(mediaPath, loaded);
623
+ }
624
+ async function trySendViaHostRead(ctx, mediaPath, mediaKind) {
625
+ const mediaReadFile = resolveOutboundMediaReadFile(ctx);
626
+ if (!mediaReadFile || isHttpOrDataSource(mediaPath)) return null;
627
+ const hostReadMediaPath = resolveHostReadMediaPath(ctx, mediaPath);
628
+ if (!hostReadMediaPath) return null;
629
+ const mediaAccess = resolveHostReadMediaAccess(ctx);
630
+ try {
631
+ const loaded = await loadOutboundMediaFromUrl(hostReadMediaPath, {
632
+ maxBytes: getMaxUploadSize(mediaFileTypeForKind(mediaKind)),
633
+ mediaAccess,
634
+ mediaReadFile,
635
+ workspaceDir: mediaAccess?.workspaceDir
636
+ });
637
+ const kind = senderKindForLoadedMedia(mediaKind, loaded.kind);
638
+ if (!kind) return {
639
+ channel: "qqbot",
640
+ error: `Unsupported ${mediaKind} media type: ${loaded.kind ?? "unknown"}`
641
+ };
642
+ if (loaded.buffer.length === 0) return {
643
+ channel: "qqbot",
644
+ error: `File is empty: ${hostReadMediaPath}`
645
+ };
646
+ if (mediaKind === "media" && loaded.kind === "audio") {
647
+ const directUploadFormats = ctx.account.config?.audioFormatPolicy?.uploadDirectFormats ?? ctx.account.config?.voiceDirectUploadFormats;
648
+ const transcodeEnabled = ctx.account.config?.audioFormatPolicy?.transcodeEnabled !== false;
649
+ return await sendVoiceFromLocal(ctx, await stageLoadedHostReadVoice(mediaPath, loaded), directUploadFormats, transcodeEnabled);
650
+ }
651
+ const creds = accountToCreds(ctx.account);
652
+ const target = {
653
+ type: ctx.targetType,
654
+ id: ctx.targetId
655
+ };
656
+ if (target.type !== "c2c" && target.type !== "group") return {
657
+ channel: "qqbot",
658
+ error: `${qqBotMediaKindLabel[mediaKind]} not supported in channel`
659
+ };
660
+ const r = await sendMedia$1({
661
+ target,
662
+ creds,
663
+ kind,
664
+ source: {
665
+ buffer: loaded.buffer,
666
+ ...loaded.fileName ? { fileName: sanitizeFileName(loaded.fileName) } : {},
667
+ ...loaded.contentType ? { mime: loaded.contentType } : {}
668
+ },
669
+ msgId: ctx.replyToId,
670
+ ...kind === "file" && loaded.fileName ? { fileName: sanitizeFileName(loaded.fileName) } : {}
671
+ });
672
+ return {
673
+ channel: "qqbot",
674
+ messageId: r.id,
675
+ timestamp: r.timestamp
676
+ };
677
+ } catch (err) {
678
+ if (err instanceof UploadDailyLimitExceededError) return buildDailyLimitExceededResult(err.filePath === "<buffer>" ? new UploadDailyLimitExceededError(hostReadMediaPath, err.fileSize, err.message) : err);
679
+ return {
680
+ channel: "qqbot",
681
+ error: formatErrorMessage(err)
682
+ };
683
+ }
684
+ }
685
+ async function sendAutoDetectedMedia(ctx, mediaPath) {
686
+ const hostReadResult = await trySendViaHostRead(ctx, mediaPath, "media");
687
+ if (hostReadResult) return hostReadResult;
688
+ return await sendDocument(ctx, mediaPath);
689
+ }
503
690
  function resolveOutboundMediaPath(rawPath, mediaKind, options = {}) {
504
691
  const normalizedPath = normalizePath$1(rawPath);
505
692
  if (isHttpOrDataSource(normalizedPath)) return {
506
693
  ok: true,
507
694
  mediaPath: normalizedPath
508
695
  };
509
- const allowedPath = resolveTrustedOutboundMediaPath(normalizedPath, { allowMissing: options.allowMissingLocalPath });
510
- if (allowedPath) return {
511
- ok: true,
512
- mediaPath: allowedPath
513
- };
514
- if (options.extraLocalRoots && options.extraLocalRoots.length > 0) {
515
- const extraAllowedPath = resolveExistingPathWithinRoots(normalizedPath, options.extraLocalRoots);
516
- if (extraAllowedPath) return {
696
+ const candidatePaths = resolveWorkspacePathCandidates(normalizedPath, options.workspaceDir);
697
+ for (const candidatePath of candidatePaths) {
698
+ const allowedPath = resolveTrustedOutboundMediaPath(candidatePath, { allowMissing: options.allowMissingLocalPath });
699
+ if (allowedPath) return {
517
700
  ok: true,
518
- mediaPath: extraAllowedPath
701
+ mediaPath: allowedPath
519
702
  };
703
+ if (options.extraLocalRoots && options.extraLocalRoots.length > 0) {
704
+ const extraAllowedPath = resolveExistingPathWithinRoots(candidatePath, options.extraLocalRoots);
705
+ if (extraAllowedPath) return {
706
+ ok: true,
707
+ mediaPath: extraAllowedPath
708
+ };
709
+ }
520
710
  }
521
711
  if (options.allowMissingLocalPath) {
522
- const allowedMissingPath = resolveMissingPathWithinMediaRoot(normalizedPath);
523
- if (allowedMissingPath) return {
524
- ok: true,
525
- mediaPath: allowedMissingPath
526
- };
712
+ const missingRoots = mergeMediaLocalRoots([getQQBotMediaDir()], options.extraLocalRoots);
713
+ if (missingRoots) for (const candidatePath of candidatePaths) {
714
+ const allowedMissingPath = resolveMissingPathWithinRoots(candidatePath, missingRoots);
715
+ if (allowedMissingPath) return {
716
+ ok: true,
717
+ mediaPath: allowedMissingPath
718
+ };
719
+ }
527
720
  }
528
721
  debugWarn(`blocked local ${mediaKind} path outside QQ Bot media storage`);
529
722
  return {
@@ -535,28 +728,33 @@ function resolveOutboundMediaPath(rawPath, mediaKind, options = {}) {
535
728
  * Send a photo from a local file, public URL, or Base64 data URL.
536
729
  */
537
730
  async function sendPhoto(ctx, imagePath) {
538
- const resolvedMediaPath = resolveOutboundMediaPath(imagePath, "image");
731
+ const hostReadResult = await trySendViaHostRead(ctx, imagePath, "image");
732
+ if (hostReadResult) return hostReadResult;
733
+ const resolvedMediaPath = resolveOutboundMediaPath(imagePath, "image", {
734
+ extraLocalRoots: resolveOutboundMediaLocalRoots(ctx),
735
+ workspaceDir: ctx.mediaAccess?.workspaceDir
736
+ });
539
737
  if (!resolvedMediaPath.ok) return {
540
738
  channel: "qqbot",
541
739
  error: resolvedMediaPath.error
542
740
  };
543
741
  const mediaPath = resolvedMediaPath.mediaPath;
544
742
  const isLocal = isLocalPath(mediaPath);
545
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
546
- const isData = mediaPath.startsWith("data:");
743
+ const isHttp = isHttpUrl(mediaPath);
744
+ const isData = isDataUrl(mediaPath);
547
745
  if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
548
746
  debugLog(`sendPhoto: urlDirectUpload=false, downloading URL first...`);
549
747
  const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto");
550
748
  if (localFile) return await sendPhotoFromLocal(ctx, localFile);
551
749
  return {
552
750
  channel: "qqbot",
553
- error: `Failed to download image: ${mediaPath.slice(0, 80)}`
751
+ error: `Failed to download image: ${truncateUtf16Safe(mediaPath, 80)}`
554
752
  };
555
753
  }
556
754
  if (isLocal) return await sendPhotoFromLocal(ctx, mediaPath);
557
755
  if (!isHttp && !isData) return {
558
756
  channel: "qqbot",
559
- error: `Unsupported image source: ${mediaPath.slice(0, 50)}`
757
+ error: `Unsupported image source: ${truncateUtf16Safe(mediaPath, 50)}`
560
758
  };
561
759
  try {
562
760
  const creds = accountToCreds(ctx.account);
@@ -663,13 +861,29 @@ async function sendPhotoFromLocal(ctx, mediaPath) {
663
861
  * URL handling respects `urlDirectUpload`, and local files are transcoded when needed.
664
862
  */
665
863
  async function sendVoice(ctx, voicePath, directUploadFormats, transcodeEnabled = true) {
666
- const resolvedMediaPath = resolveOutboundMediaPath(voicePath, "voice", { allowMissingLocalPath: true });
864
+ let stagedHostReadVoice;
865
+ try {
866
+ stagedHostReadVoice = await stageHostReadVoice(ctx, voicePath);
867
+ } catch (err) {
868
+ return {
869
+ channel: "qqbot",
870
+ error: formatErrorMessage(err)
871
+ };
872
+ }
873
+ const resolvedMediaPath = stagedHostReadVoice ? {
874
+ ok: true,
875
+ mediaPath: stagedHostReadVoice
876
+ } : resolveOutboundMediaPath(voicePath, "voice", {
877
+ allowMissingLocalPath: true,
878
+ extraLocalRoots: resolveOutboundMediaLocalRoots(ctx),
879
+ workspaceDir: ctx.mediaAccess?.workspaceDir
880
+ });
667
881
  if (!resolvedMediaPath.ok) return {
668
882
  channel: "qqbot",
669
883
  error: resolvedMediaPath.error
670
884
  };
671
885
  const mediaPath = resolvedMediaPath.mediaPath;
672
- if (mediaPath.startsWith("http://") || mediaPath.startsWith("https://")) {
886
+ if (isHttpUrl(mediaPath)) {
673
887
  if (shouldDirectUploadUrl(ctx.account)) try {
674
888
  const creds = accountToCreds(ctx.account);
675
889
  const target = {
@@ -703,7 +917,7 @@ async function sendVoice(ctx, voicePath, directUploadFormats, transcodeEnabled =
703
917
  if (localFile) return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled);
704
918
  return {
705
919
  channel: "qqbot",
706
- error: `Failed to download audio: ${mediaPath.slice(0, 80)}`
920
+ error: `Failed to download audio: ${truncateUtf16Safe(mediaPath, 80)}`
707
921
  };
708
922
  }
709
923
  return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled);
@@ -716,7 +930,8 @@ async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcode
716
930
  error: "Voice generate failed"
717
931
  };
718
932
  if (fileSize > getMaxUploadSize(3)) return buildFileTooLargeResult(3, fileSize);
719
- const safeMediaPath = resolveTrustedOutboundMediaPath(mediaPath);
933
+ const extraLocalRoots = resolveOutboundMediaLocalRoots(ctx);
934
+ const safeMediaPath = resolveTrustedOutboundMediaPath(mediaPath) ?? (extraLocalRoots ? resolveExistingPathWithinRoots(mediaPath, extraLocalRoots) : null);
720
935
  if (!safeMediaPath) {
721
936
  debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`);
722
937
  return {
@@ -779,20 +994,25 @@ async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcode
779
994
  }
780
995
  /** Send video from either a public URL or a local file. */
781
996
  async function sendVideoMsg(ctx, videoPath) {
782
- const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video");
997
+ const hostReadResult = await trySendViaHostRead(ctx, videoPath, "video");
998
+ if (hostReadResult) return hostReadResult;
999
+ const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video", {
1000
+ extraLocalRoots: resolveOutboundMediaLocalRoots(ctx),
1001
+ workspaceDir: ctx.mediaAccess?.workspaceDir
1002
+ });
783
1003
  if (!resolvedMediaPath.ok) return {
784
1004
  channel: "qqbot",
785
1005
  error: resolvedMediaPath.error
786
1006
  };
787
1007
  const mediaPath = resolvedMediaPath.mediaPath;
788
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
1008
+ const isHttp = isHttpUrl(mediaPath);
789
1009
  if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
790
1010
  debugLog(`sendVideoMsg: urlDirectUpload=false, downloading URL first...`);
791
1011
  const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg");
792
1012
  if (localFile) return await sendVideoFromLocal(ctx, localFile);
793
1013
  return {
794
1014
  channel: "qqbot",
795
- error: `Failed to download video: ${mediaPath.slice(0, 80)}`
1015
+ error: `Failed to download video: ${truncateUtf16Safe(mediaPath, 80)}`
796
1016
  };
797
1017
  }
798
1018
  try {
@@ -887,13 +1107,18 @@ async function sendVideoFromLocal(ctx, mediaPath) {
887
1107
  }
888
1108
  /** Send a file from a local path or public URL. */
889
1109
  async function sendDocument(ctx, filePath, options = {}) {
890
- const resolvedMediaPath = resolveOutboundMediaPath(filePath, "file", { extraLocalRoots: options.allowQQBotDataDownloads ? [getQQBotDataDir("downloads")] : void 0 });
1110
+ const hostReadResult = await trySendViaHostRead(ctx, filePath, "file");
1111
+ if (hostReadResult) return hostReadResult;
1112
+ const resolvedMediaPath = resolveOutboundMediaPath(filePath, "file", {
1113
+ extraLocalRoots: mergeMediaLocalRoots(options.allowQQBotDataDownloads ? [getQQBotDataDir("downloads")] : void 0, resolveOutboundMediaLocalRoots(ctx)),
1114
+ workspaceDir: ctx.mediaAccess?.workspaceDir
1115
+ });
891
1116
  if (!resolvedMediaPath.ok) return {
892
1117
  channel: "qqbot",
893
1118
  error: resolvedMediaPath.error
894
1119
  };
895
1120
  const mediaPath = resolvedMediaPath.mediaPath;
896
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
1121
+ const isHttp = isHttpUrl(mediaPath);
897
1122
  const fileName = sanitizeFileName(path.basename(mediaPath));
898
1123
  if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
899
1124
  debugLog(`sendDocument: urlDirectUpload=false, downloading URL first...`);
@@ -901,7 +1126,7 @@ async function sendDocument(ctx, filePath, options = {}) {
901
1126
  if (localFile) return await sendDocumentFromLocal(ctx, localFile);
902
1127
  return {
903
1128
  channel: "qqbot",
904
- error: `Failed to download file: ${mediaPath.slice(0, 80)}`
1129
+ error: `Failed to download file: ${truncateUtf16Safe(mediaPath, 80)}`
905
1130
  };
906
1131
  }
907
1132
  try {
@@ -918,7 +1143,7 @@ async function sendDocument(ctx, filePath, options = {}) {
918
1143
  kind: "file",
919
1144
  source: { url: mediaPath },
920
1145
  msgId: ctx.replyToId,
921
- fileName
1146
+ ...fileName ? { fileName } : {}
922
1147
  });
923
1148
  return {
924
1149
  channel: "qqbot",
@@ -1006,7 +1231,7 @@ async function downloadToFallbackDir(httpUrl, caller) {
1006
1231
  try {
1007
1232
  const localFile = await downloadFile(httpUrl, getQQBotMediaDir("downloads", "url-fallback"));
1008
1233
  if (!localFile) {
1009
- debugError(`${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`);
1234
+ debugError(`${caller} fallback: download also failed for ${truncateUtf16Safe(httpUrl, 80)}`);
1010
1235
  return null;
1011
1236
  }
1012
1237
  debugLog(`${caller} fallback: downloaded → ${localFile}`);
@@ -1171,7 +1396,8 @@ function parseQQBotPayload(text) {
1171
1396
  /** Encode a cron reminder payload into the stored cron-message format. */
1172
1397
  function encodePayloadForCron(payload) {
1173
1398
  const jsonString = JSON.stringify(payload);
1174
- return `${CRON_PREFIX}${Buffer.from(jsonString, "utf-8").toString("base64")}`;
1399
+ const base64 = Buffer.from(jsonString, "utf-8").toString("base64");
1400
+ return `${CRON_PREFIX}${base64}`;
1175
1401
  }
1176
1402
  /** Decode a stored cron payload. */
1177
1403
  function decodeCronPayload(message) {
@@ -1288,7 +1514,7 @@ function decodeMediaPath(raw, log) {
1288
1514
  * These replace the inline `isImageFile` and `isVideoFile` helpers scattered
1289
1515
  * across `outbound.ts`. Centralizing them here keeps detection consistent.
1290
1516
  */
1291
- const IMAGE_EXTENSIONS = new Set([
1517
+ const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
1292
1518
  ".jpg",
1293
1519
  ".jpeg",
1294
1520
  ".png",
@@ -1296,7 +1522,7 @@ const IMAGE_EXTENSIONS = new Set([
1296
1522
  ".webp",
1297
1523
  ".bmp"
1298
1524
  ]);
1299
- const VIDEO_EXTENSIONS = new Set([
1525
+ const VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
1300
1526
  ".mp4",
1301
1527
  ".mov",
1302
1528
  ".avi",
@@ -1358,7 +1584,7 @@ async function sendText(ctx) {
1358
1584
  initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
1359
1585
  debugLog("[qqbot] sendText ctx:", JSON.stringify({
1360
1586
  to,
1361
- text: text?.slice(0, 50),
1587
+ text: truncateUtf16Safe(text, 50),
1362
1588
  replyToId,
1363
1589
  accountId: account.accountId
1364
1590
  }, null, 2));
@@ -1435,7 +1661,10 @@ async function sendText(ctx) {
1435
1661
  const mediaTarget = buildMediaTarget({
1436
1662
  to,
1437
1663
  account,
1438
- replyToId
1664
+ replyToId,
1665
+ mediaAccess: ctx.mediaAccess,
1666
+ mediaLocalRoots: ctx.mediaLocalRoots,
1667
+ mediaReadFile: ctx.mediaReadFile
1439
1668
  });
1440
1669
  let lastResult = { channel: "qqbot" };
1441
1670
  for (const item of sendQueue) try {
@@ -1453,7 +1682,7 @@ async function sendText(ctx) {
1453
1682
  timestamp: result.timestamp,
1454
1683
  refIdx: result.ext_info?.ref_idx
1455
1684
  };
1456
- debugLog(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
1685
+ debugLog(`[qqbot] sendText: Sent text part: ${truncateUtf16Safe(item.content, 30)}...`);
1457
1686
  } else if (item.type === "image") lastResult = await sendPhoto(mediaTarget, item.content);
1458
1687
  else if (item.type === "voice") lastResult = await sendVoice(mediaTarget, item.content, void 0, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
1459
1688
  else if (item.type === "video") lastResult = await sendVideoMsg(mediaTarget, item.content);
@@ -1464,7 +1693,10 @@ async function sendText(ctx) {
1464
1693
  mediaUrl: item.content,
1465
1694
  accountId: account.accountId,
1466
1695
  replyToId,
1467
- account
1696
+ account,
1697
+ mediaAccess: ctx.mediaAccess,
1698
+ mediaLocalRoots: ctx.mediaLocalRoots,
1699
+ mediaReadFile: ctx.mediaReadFile
1468
1700
  });
1469
1701
  } catch (err) {
1470
1702
  const errMsg = formatErrorMessage(err);
@@ -1526,17 +1758,27 @@ async function sendMedia(ctx) {
1526
1758
  channel: "qqbot",
1527
1759
  error: "mediaUrl is required for sendMedia"
1528
1760
  };
1529
- const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "media", { allowMissingLocalPath: true });
1761
+ const target = buildMediaTarget({
1762
+ to,
1763
+ account,
1764
+ replyToId,
1765
+ mediaAccess: ctx.mediaAccess,
1766
+ mediaLocalRoots: ctx.mediaLocalRoots,
1767
+ mediaReadFile: ctx.mediaReadFile
1768
+ });
1769
+ const resolvedMediaPath = !ctx.mediaAccess?.readFile && !ctx.mediaReadFile ? resolveOutboundMediaPath(ctx.mediaUrl, "media", {
1770
+ allowMissingLocalPath: true,
1771
+ extraLocalRoots: target.mediaLocalRoots ? [...target.mediaLocalRoots] : void 0,
1772
+ workspaceDir: target.mediaAccess?.workspaceDir
1773
+ }) : {
1774
+ ok: true,
1775
+ mediaPath: ctx.mediaUrl
1776
+ };
1530
1777
  if (!resolvedMediaPath.ok) return {
1531
1778
  channel: "qqbot",
1532
1779
  error: resolvedMediaPath.error
1533
1780
  };
1534
1781
  const mediaUrl = resolvedMediaPath.mediaPath;
1535
- const target = buildMediaTarget({
1536
- to,
1537
- account,
1538
- replyToId
1539
- });
1540
1782
  if (isAudioFile(mediaUrl, mimeType)) {
1541
1783
  const result = await sendVoice(target, mediaUrl, account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
1542
1784
  if (!result.error) {
@@ -1561,7 +1803,7 @@ async function sendMedia(ctx) {
1561
1803
  return result;
1562
1804
  }
1563
1805
  if (!isImageFile(mediaUrl, mimeType) && !isAudioFile(mediaUrl, mimeType) && !isVideoFile(mediaUrl, mimeType)) {
1564
- const result = await sendDocument(target, mediaUrl);
1806
+ const result = await sendAutoDetectedMedia(target, mediaUrl);
1565
1807
  if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
1566
1808
  return result;
1567
1809
  }
@@ -1622,4 +1864,4 @@ async function sendCronMessage(account, to, message) {
1622
1864
  });
1623
1865
  }
1624
1866
  //#endregion
1625
- export { getHomeDir as A, getMessageReplyConfig as C, OUTBOUND_ERROR_CODES as D, DEFAULT_MEDIA_SEND_ERROR as E, isLocalPath as F, isWindows as I, normalizePath$1 as L, getQQBotMediaDir as M, getQQBotMediaPath as N, setOutboundAudioPort as O, getTempDir as P, checkMessageReplyLimit as S, recordMessageReply as T, sendVideoMsg as _, sendText as a, resolveUserFacingMediaError as b, isCronReminderPayload as c, normalizeMediaTags as d, buildMediaTarget as f, sendPhoto as g, sendDocument as h, sendProactiveMessage as i, getQQBotDataDir as j, checkSilkWasmAvailable as k, isMediaPayload as l, resolveOutboundMediaPath as m, sendCronMessage as n, decodeMediaPath as o, parseTarget as p, sendMedia as r, encodePayloadForCron as s, outbound_exports as t, parseQQBotPayload as u, sendVoice as v, getMessageReplyStats as w, MESSAGE_REPLY_LIMIT as x, resolveTrustedOutboundMediaPath as y };
1867
+ export { OUTBOUND_ERROR_CODES as A, normalizePath$1 as B, resolveUserFacingMediaError as C, getMessageReplyStats as D, getMessageReplyConfig as E, getQQBotMediaDir as F, getQQBotMediaPath as I, getTempDir as L, checkSilkWasmAvailable as M, getHomeDir as N, recordMessageReply as O, getQQBotDataDir as P, isLocalPath as R, resolveWorkspaceScopedLocalRoots as S, checkMessageReplyLimit as T, sendVideoMsg as _, sendText as a, resolveOutboundMediaLocalRoots as b, isCronReminderPayload as c, normalizeMediaTags as d, buildMediaTarget as f, sendPhoto as g, sendDocument as h, sendProactiveMessage as i, setOutboundAudioPort as j, DEFAULT_MEDIA_SEND_ERROR as k, isMediaPayload as l, resolveOutboundMediaPath as m, sendCronMessage as n, decodeMediaPath as o, parseTarget as p, sendMedia as r, encodePayloadForCron as s, outbound_exports as t, parseQQBotPayload as u, sendVoice as v, MESSAGE_REPLY_LIMIT as w, resolveWorkspacePathCandidates as x, resolveTrustedOutboundMediaPath as y, isWindows as z };