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

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,7 +1,7 @@
1
1
  import { t as asOptionalObjectRecord } from "./string-normalize-R_0cKO7Q.js";
2
2
  import { d as resolveAccountBase } from "./config-C1qZbh0K.js";
3
3
  import { n as debugLog } from "./log-SDfMMBWe.js";
4
- import { A as getHomeDir, I as isWindows, N as getQQBotMediaPath, j as getQQBotDataDir } from "./outbound-Dfa2uOEp.js";
4
+ import { I as getQQBotMediaPath, N as getHomeDir, P as getQQBotDataDir, z as isWindows } from "./outbound-BZHwMFUX.js";
5
5
  import { asBoolean, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
@@ -1,6 +1,6 @@
1
- import { c as getMessageApi, t as accountToCreds } from "./sender-BHWpE_yH.js";
2
- import { o as ensurePlatformAdapter, s as getBridgeLogger } from "./config-schema-gMW9zKvQ.js";
3
- import { c as resolveQQBotExecApprovalConfig, d as buildExecApprovalText, f as buildPluginApprovalText, l as shouldHandleQQBotExecApprovalRequest, m as resolveApprovalTarget, o as isQQBotExecApprovalClientEnabled, s as matchesQQBotApprovalAccount, u as buildApprovalKeyboard } from "./channel-Bh0MrYvU.js";
1
+ import { c as getMessageApi, t as accountToCreds } from "./sender-BfVLJJYp.js";
2
+ import { o as ensurePlatformAdapter, s as getBridgeLogger } from "./config-schema-JZEf1dvB.js";
3
+ import { c as resolveQQBotExecApprovalConfig, d as buildExecApprovalText, f as buildPluginApprovalText, l as shouldHandleQQBotExecApprovalRequest, m as resolveApprovalTarget, o as isQQBotExecApprovalClientEnabled, s as matchesQQBotApprovalAccount, u as buildApprovalKeyboard } from "./channel-BQM6pDlD.js";
4
4
  import { a as resolveQQBotAccount } from "./config-C1qZbh0K.js";
5
5
  import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime";
6
6
  import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
@@ -1,13 +1,16 @@
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-BfVLJJYp.js";
2
2
  import { c as getPlatformAdapter, i as normalizeOptionalString, n as normalizeLowercaseStringOrEmpty, s as sanitizeFileName } from "./string-normalize-R_0cKO7Q.js";
3
3
  import { a as formatErrorMessage, n as debugLog, r as debugWarn, t as debugError } from "./log-SDfMMBWe.js";
4
4
  import { r as parseTarget$1 } from "./target-parser-DagYRQkP.js";
5
- import * as fs$1 from "node:fs";
5
+ import * as fs$2 from "node:fs";
6
6
  import * as os$1 from "node:os";
7
+ import { randomUUID } from "node:crypto";
7
8
  import { pathExistsSync, resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime";
8
9
  import * as path$1 from "node:path";
9
10
  import path from "node:path";
10
- import { getFileExtension } from "openclaw/plugin-sdk/media-mime";
11
+ import { extensionForMime, getFileExtension } from "openclaw/plugin-sdk/media-mime";
12
+ import { mkdir, writeFile } from "node:fs/promises";
13
+ import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
11
14
  import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
12
15
  //#region extensions/qqbot/src/engine/utils/platform.ts
13
16
  /**
@@ -32,10 +35,10 @@ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
32
35
  function getHomeDir() {
33
36
  try {
34
37
  const home = os$1.homedir();
35
- if (home && fs$1.existsSync(home)) return home;
38
+ if (home && fs$2.existsSync(home)) return home;
36
39
  } catch {}
37
40
  const envHome = process.env.HOME || process.env.USERPROFILE;
38
- if (envHome && fs$1.existsSync(envHome)) return envHome;
41
+ if (envHome && fs$2.existsSync(envHome)) return envHome;
39
42
  return getPlatformAdapter().getTempDir();
40
43
  }
41
44
  /**
@@ -76,7 +79,7 @@ function getQQBotDataPath(...subPaths) {
76
79
  /** Return a path under `~/.openclaw/qqbot`, creating it on demand. */
77
80
  function getQQBotDataDir(...subPaths) {
78
81
  const dir = getQQBotDataPath(...subPaths);
79
- if (!fs$1.existsSync(dir)) fs$1.mkdirSync(dir, { recursive: true });
82
+ if (!fs$2.existsSync(dir)) fs$2.mkdirSync(dir, { recursive: true });
80
83
  return dir;
81
84
  }
82
85
  /**
@@ -94,7 +97,7 @@ function getQQBotMediaPath(...subPaths) {
94
97
  /** Return a path under `<openclaw-home>/.openclaw/media/qqbot`, creating it on demand. */
95
98
  function getQQBotMediaDir(...subPaths) {
96
99
  const dir = getQQBotMediaPath(...subPaths);
97
- if (!fs$1.existsSync(dir)) fs$1.mkdirSync(dir, { recursive: true });
100
+ if (!fs$2.existsSync(dir)) fs$2.mkdirSync(dir, { recursive: true });
98
101
  return dir;
99
102
  }
100
103
  /**
@@ -163,14 +166,14 @@ function isLocalPath(p) {
163
166
  if (p.startsWith(".\\") || p.startsWith("..\\")) return true;
164
167
  return false;
165
168
  }
166
- function isPathWithinRoot(candidate, root) {
169
+ function isPathWithinRoot$1(candidate, root) {
167
170
  const relative = path$1.relative(root, candidate);
168
171
  return relative === "" || !relative.startsWith("..") && !path$1.isAbsolute(relative);
169
172
  }
170
173
  /** Remap legacy or hallucinated QQ Bot local media paths to real files when possible. */
171
174
  function resolveQQBotLocalMediaPath(p) {
172
175
  const normalized = normalizePath$1(p);
173
- if (!isLocalPath(normalized) || fs$1.existsSync(normalized)) return normalized;
176
+ if (!isLocalPath(normalized) || fs$2.existsSync(normalized)) return normalized;
174
177
  const osHomeDir = getHomeDir();
175
178
  const openclawHomeDir = resolveOpenClawHome();
176
179
  const mediaRoot = getQQBotMediaPath();
@@ -190,10 +193,10 @@ function resolveQQBotLocalMediaPath(p) {
190
193
  }
191
194
  ];
192
195
  for (const { from, to } of candidateRoots) {
193
- if (!isPathWithinRoot(normalized, from)) continue;
196
+ if (!isPathWithinRoot$1(normalized, from)) continue;
194
197
  const relative = path$1.relative(from, normalized);
195
198
  const candidate = path$1.join(to, relative);
196
- if (fs$1.existsSync(candidate)) {
199
+ if (fs$2.existsSync(candidate)) {
197
200
  debugWarn(`[platform] Remapped missing QQBot media path ${normalized} -> ${candidate}`);
198
201
  return candidate;
199
202
  }
@@ -208,12 +211,12 @@ function resolveQQBotPayloadLocalFilePath(p) {
208
211
  const candidate = resolveQQBotLocalMediaPath(p);
209
212
  if (!candidate.trim()) return null;
210
213
  const resolvedCandidate = path$1.resolve(candidate);
211
- if (!fs$1.existsSync(resolvedCandidate)) return null;
212
- const canonicalCandidate = fs$1.realpathSync(resolvedCandidate);
214
+ if (!fs$2.existsSync(resolvedCandidate)) return null;
215
+ const canonicalCandidate = fs$2.realpathSync(resolvedCandidate);
213
216
  const allowedRoots = [getOpenClawMediaDir(), getQQBotMediaPath()];
214
217
  for (const root of allowedRoots) {
215
218
  const resolvedRoot = path$1.resolve(root);
216
- if (isPathWithinRoot(canonicalCandidate, fs$1.existsSync(resolvedRoot) ? fs$1.realpathSync(resolvedRoot) : resolvedRoot)) return canonicalCandidate;
219
+ if (isPathWithinRoot$1(canonicalCandidate, fs$2.existsSync(resolvedRoot) ? fs$2.realpathSync(resolvedRoot) : resolvedRoot)) return canonicalCandidate;
217
220
  }
218
221
  return null;
219
222
  }
@@ -406,6 +409,46 @@ function buildFileTooLargeResult(fileType, fileSize) {
406
409
  };
407
410
  }
408
411
  //#endregion
412
+ //#region extensions/qqbot/src/engine/messaging/outbound-media-path.ts
413
+ function mergeMediaLocalRoots(...groups) {
414
+ const roots = groups.flatMap((group) => group ?? []).map((root) => root.trim()).filter(Boolean);
415
+ return roots.length > 0 ? Array.from(new Set(roots)) : void 0;
416
+ }
417
+ function resolveOutboundMediaLocalRoots(ctx) {
418
+ return mergeMediaLocalRoots(ctx.mediaAccess?.localRoots, ctx.mediaLocalRoots);
419
+ }
420
+ function isPathWithinRoot(candidatePath, rootPath) {
421
+ const resolvedRoot = path.resolve(rootPath);
422
+ if (resolvedRoot === path.parse(resolvedRoot).root) return false;
423
+ const relative = path.relative(resolvedRoot, path.resolve(candidatePath));
424
+ return relative === "" || relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
425
+ }
426
+ function resolvePathInsideWorkspace(workspaceDir, pathWithinWorkspace) {
427
+ const mappedPath = path.resolve(workspaceDir, pathWithinWorkspace);
428
+ return isPathWithinRoot(mappedPath, workspaceDir) ? mappedPath : null;
429
+ }
430
+ function isVirtualWorkspacePath(normalizedPath) {
431
+ return normalizedPath === "/workspace" || normalizedPath.startsWith("/workspace/");
432
+ }
433
+ function resolveWorkspaceScopedLocalRoots(roots, workspaceDir) {
434
+ if (!roots?.length) return;
435
+ const scopedRoots = roots.map((root) => root.trim()).filter(Boolean).map((root) => workspaceDir && isVirtualWorkspacePath(root) ? resolveWorkspacePathCandidate(root, workspaceDir) : root).filter((root) => Boolean(root));
436
+ return scopedRoots.length > 0 ? Array.from(new Set(scopedRoots)) : void 0;
437
+ }
438
+ function resolveWorkspacePathCandidate(normalizedPath, workspaceDir) {
439
+ if (!workspaceDir) return isVirtualWorkspacePath(normalizedPath) ? null : normalizedPath;
440
+ if (normalizedPath === "/workspace") return workspaceDir;
441
+ if (normalizedPath.startsWith("/workspace/")) return resolvePathInsideWorkspace(workspaceDir, normalizedPath.slice(11));
442
+ if (path.isAbsolute(normalizedPath)) return normalizedPath;
443
+ return resolvePathInsideWorkspace(workspaceDir, normalizedPath);
444
+ }
445
+ function resolveWorkspacePathCandidates(normalizedPath, workspaceDir) {
446
+ const mappedPath = resolveWorkspacePathCandidate(normalizedPath, workspaceDir);
447
+ if (!mappedPath) return [];
448
+ if (mappedPath === normalizedPath) return [normalizedPath];
449
+ return path.isAbsolute(normalizedPath) && !isVirtualWorkspacePath(normalizedPath) ? [normalizedPath, mappedPath] : [mappedPath];
450
+ }
451
+ //#endregion
409
452
  //#region extensions/qqbot/src/engine/messaging/trusted-media-path.ts
410
453
  let cachedTrustedTmpRoot;
411
454
  function trustedOpenClawTmpRoot() {
@@ -462,11 +505,15 @@ function parseTarget(to) {
462
505
  /** Build a media target from a normal outbound context. */
463
506
  function buildMediaTarget(ctx) {
464
507
  const target = parseTarget(ctx.to);
508
+ const mediaLocalRoots = resolveOutboundMediaLocalRoots(ctx);
465
509
  return {
466
510
  targetType: target.type,
467
511
  targetId: target.id,
468
512
  account: ctx.account,
469
- replyToId: ctx.replyToId ?? void 0
513
+ replyToId: ctx.replyToId ?? void 0,
514
+ ...mediaLocalRoots ? { mediaLocalRoots } : {},
515
+ ...ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {},
516
+ ...ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}
470
517
  };
471
518
  }
472
519
  /** Return true when public URLs should be passed through directly. */
@@ -480,19 +527,28 @@ const qqBotMediaKindLabel = {
480
527
  file: "File",
481
528
  media: "Media"
482
529
  };
530
+ function isHttpUrl(pathValue) {
531
+ return pathValue.startsWith("http://") || pathValue.startsWith("https://");
532
+ }
533
+ function isDataUrl(pathValue) {
534
+ return pathValue.startsWith("data:");
535
+ }
483
536
  function isHttpOrDataSource(pathValue) {
484
- return pathValue.startsWith("http://") || pathValue.startsWith("https://") || pathValue.startsWith("data:");
537
+ return isHttpUrl(pathValue) || isDataUrl(pathValue);
485
538
  }
486
- function resolveMissingPathWithinMediaRoot(normalizedPath) {
539
+ function resolveMissingPathWithinRoots(normalizedPath, allowedRoots) {
487
540
  const resolvedCandidate = path.resolve(normalizedPath);
488
541
  if (pathExistsSync(resolvedCandidate)) return null;
489
542
  return resolveLocalPathFromRootsSync({
490
543
  filePath: resolvedCandidate,
491
- roots: [getQQBotMediaDir()],
492
- label: "QQ Bot media storage",
544
+ roots: allowedRoots,
545
+ label: "QQ Bot local roots",
493
546
  allowMissing: true
494
547
  })?.path ?? null;
495
548
  }
549
+ function isPathWithinAnyRoot(candidatePath, allowedRoots) {
550
+ return allowedRoots?.some((root) => root.trim() && isPathWithinRoot(candidatePath, root)) ?? false;
551
+ }
496
552
  function resolveExistingPathWithinRoots(normalizedPath, allowedRoots) {
497
553
  return resolveLocalPathFromRootsSync({
498
554
  filePath: normalizedPath,
@@ -500,30 +556,166 @@ function resolveExistingPathWithinRoots(normalizedPath, allowedRoots) {
500
556
  label: "QQ Bot local roots"
501
557
  })?.path ?? null;
502
558
  }
559
+ function resolveOutboundMediaReadFile(ctx) {
560
+ return ctx.mediaAccess?.readFile ?? ctx.mediaReadFile;
561
+ }
562
+ function resolveHostReadMediaAccess(ctx) {
563
+ const mediaLocalRoots = resolveWorkspaceScopedLocalRoots(resolveOutboundMediaLocalRoots(ctx), ctx.mediaAccess?.workspaceDir);
564
+ if (!ctx.mediaAccess && !mediaLocalRoots) return;
565
+ const { localRoots: _localRoots, ...mediaAccessWithoutRoots } = ctx.mediaAccess ?? {};
566
+ return {
567
+ ...mediaAccessWithoutRoots,
568
+ ...mediaLocalRoots ? { localRoots: mediaLocalRoots } : {}
569
+ };
570
+ }
571
+ function mediaFileTypeForKind(mediaKind) {
572
+ switch (mediaKind) {
573
+ case "image": return 1;
574
+ case "voice": return 3;
575
+ case "video": return 2;
576
+ default: return 4;
577
+ }
578
+ }
579
+ function senderKindForLoadedMedia(mediaKind, loadedKind) {
580
+ if (mediaKind === "image") return loadedKind === "image" ? "image" : null;
581
+ if (mediaKind === "video") return loadedKind === "video" ? "video" : null;
582
+ if (mediaKind === "file") return "file";
583
+ if (loadedKind === "image") return "image";
584
+ if (loadedKind === "video") return "video";
585
+ return "file";
586
+ }
587
+ function resolveHostReadMediaPath(ctx, mediaPath) {
588
+ const normalizedPath = normalizePath$1(mediaPath);
589
+ if (path.isAbsolute(normalizedPath)) {
590
+ if (normalizedPath === "/workspace" || normalizedPath.startsWith("/workspace/")) return ctx.mediaAccess?.workspaceDir ? resolveWorkspacePathCandidate(normalizedPath, ctx.mediaAccess.workspaceDir) : null;
591
+ if (isPathWithinAnyRoot(normalizedPath, resolveOutboundMediaLocalRoots(ctx))) return normalizedPath;
592
+ return null;
593
+ }
594
+ if (!ctx.mediaAccess?.workspaceDir) return null;
595
+ return resolveWorkspacePathCandidate(normalizedPath, ctx.mediaAccess.workspaceDir);
596
+ }
597
+ async function stageLoadedHostReadVoice(mediaPath, loaded) {
598
+ const stagedDir = getQQBotMediaDir("host-read", "voice");
599
+ await mkdir(stagedDir, { recursive: true });
600
+ const rawFileName = sanitizeFileName(loaded.fileName || path.basename(mediaPath) || "voice");
601
+ const ext = path.extname(rawFileName);
602
+ const inferredExt = extensionForMime(loaded.contentType);
603
+ const baseName = sanitizeFileName(path.basename(rawFileName, ext)) || "voice";
604
+ const stagedPath = path.join(stagedDir, `${baseName}-${randomUUID()}${ext || inferredExt || ".bin"}`);
605
+ await writeFile(stagedPath, loaded.buffer);
606
+ return stagedPath;
607
+ }
608
+ async function stageHostReadVoice(ctx, mediaPath) {
609
+ const mediaReadFile = resolveOutboundMediaReadFile(ctx);
610
+ if (!mediaReadFile || isHttpOrDataSource(mediaPath)) return null;
611
+ const hostReadMediaPath = resolveHostReadMediaPath(ctx, mediaPath);
612
+ if (!hostReadMediaPath) return null;
613
+ const mediaAccess = resolveHostReadMediaAccess(ctx);
614
+ const loaded = await loadOutboundMediaFromUrl(hostReadMediaPath, {
615
+ maxBytes: getMaxUploadSize(3),
616
+ mediaAccess,
617
+ mediaReadFile,
618
+ workspaceDir: mediaAccess?.workspaceDir
619
+ });
620
+ if (loaded.kind !== "audio") throw new Error(`Unsupported voice media type: ${loaded.kind ?? "unknown"}`);
621
+ return await stageLoadedHostReadVoice(mediaPath, loaded);
622
+ }
623
+ async function trySendViaHostRead(ctx, mediaPath, mediaKind) {
624
+ const mediaReadFile = resolveOutboundMediaReadFile(ctx);
625
+ if (!mediaReadFile || isHttpOrDataSource(mediaPath)) return null;
626
+ const hostReadMediaPath = resolveHostReadMediaPath(ctx, mediaPath);
627
+ if (!hostReadMediaPath) return null;
628
+ const mediaAccess = resolveHostReadMediaAccess(ctx);
629
+ try {
630
+ const loaded = await loadOutboundMediaFromUrl(hostReadMediaPath, {
631
+ maxBytes: getMaxUploadSize(mediaFileTypeForKind(mediaKind)),
632
+ mediaAccess,
633
+ mediaReadFile,
634
+ workspaceDir: mediaAccess?.workspaceDir
635
+ });
636
+ const kind = senderKindForLoadedMedia(mediaKind, loaded.kind);
637
+ if (!kind) return {
638
+ channel: "qqbot",
639
+ error: `Unsupported ${mediaKind} media type: ${loaded.kind ?? "unknown"}`
640
+ };
641
+ if (loaded.buffer.length === 0) return {
642
+ channel: "qqbot",
643
+ error: `File is empty: ${hostReadMediaPath}`
644
+ };
645
+ if (mediaKind === "media" && loaded.kind === "audio") {
646
+ const directUploadFormats = ctx.account.config?.audioFormatPolicy?.uploadDirectFormats ?? ctx.account.config?.voiceDirectUploadFormats;
647
+ const transcodeEnabled = ctx.account.config?.audioFormatPolicy?.transcodeEnabled !== false;
648
+ return await sendVoiceFromLocal(ctx, await stageLoadedHostReadVoice(mediaPath, loaded), directUploadFormats, transcodeEnabled);
649
+ }
650
+ const creds = accountToCreds(ctx.account);
651
+ const target = {
652
+ type: ctx.targetType,
653
+ id: ctx.targetId
654
+ };
655
+ if (target.type !== "c2c" && target.type !== "group") return {
656
+ channel: "qqbot",
657
+ error: `${qqBotMediaKindLabel[mediaKind]} not supported in channel`
658
+ };
659
+ const r = await sendMedia$1({
660
+ target,
661
+ creds,
662
+ kind,
663
+ source: {
664
+ buffer: loaded.buffer,
665
+ ...loaded.fileName ? { fileName: sanitizeFileName(loaded.fileName) } : {},
666
+ ...loaded.contentType ? { mime: loaded.contentType } : {}
667
+ },
668
+ msgId: ctx.replyToId,
669
+ ...kind === "file" && loaded.fileName ? { fileName: sanitizeFileName(loaded.fileName) } : {}
670
+ });
671
+ return {
672
+ channel: "qqbot",
673
+ messageId: r.id,
674
+ timestamp: r.timestamp
675
+ };
676
+ } catch (err) {
677
+ if (err instanceof UploadDailyLimitExceededError) return buildDailyLimitExceededResult(err.filePath === "<buffer>" ? new UploadDailyLimitExceededError(hostReadMediaPath, err.fileSize, err.message) : err);
678
+ return {
679
+ channel: "qqbot",
680
+ error: formatErrorMessage(err)
681
+ };
682
+ }
683
+ }
684
+ async function sendAutoDetectedMedia(ctx, mediaPath) {
685
+ const hostReadResult = await trySendViaHostRead(ctx, mediaPath, "media");
686
+ if (hostReadResult) return hostReadResult;
687
+ return await sendDocument(ctx, mediaPath);
688
+ }
503
689
  function resolveOutboundMediaPath(rawPath, mediaKind, options = {}) {
504
690
  const normalizedPath = normalizePath$1(rawPath);
505
691
  if (isHttpOrDataSource(normalizedPath)) return {
506
692
  ok: true,
507
693
  mediaPath: normalizedPath
508
694
  };
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 {
695
+ const candidatePaths = resolveWorkspacePathCandidates(normalizedPath, options.workspaceDir);
696
+ for (const candidatePath of candidatePaths) {
697
+ const allowedPath = resolveTrustedOutboundMediaPath(candidatePath, { allowMissing: options.allowMissingLocalPath });
698
+ if (allowedPath) return {
517
699
  ok: true,
518
- mediaPath: extraAllowedPath
700
+ mediaPath: allowedPath
519
701
  };
702
+ if (options.extraLocalRoots && options.extraLocalRoots.length > 0) {
703
+ const extraAllowedPath = resolveExistingPathWithinRoots(candidatePath, options.extraLocalRoots);
704
+ if (extraAllowedPath) return {
705
+ ok: true,
706
+ mediaPath: extraAllowedPath
707
+ };
708
+ }
520
709
  }
521
710
  if (options.allowMissingLocalPath) {
522
- const allowedMissingPath = resolveMissingPathWithinMediaRoot(normalizedPath);
523
- if (allowedMissingPath) return {
524
- ok: true,
525
- mediaPath: allowedMissingPath
526
- };
711
+ const missingRoots = mergeMediaLocalRoots([getQQBotMediaDir()], options.extraLocalRoots);
712
+ if (missingRoots) for (const candidatePath of candidatePaths) {
713
+ const allowedMissingPath = resolveMissingPathWithinRoots(candidatePath, missingRoots);
714
+ if (allowedMissingPath) return {
715
+ ok: true,
716
+ mediaPath: allowedMissingPath
717
+ };
718
+ }
527
719
  }
528
720
  debugWarn(`blocked local ${mediaKind} path outside QQ Bot media storage`);
529
721
  return {
@@ -535,15 +727,20 @@ function resolveOutboundMediaPath(rawPath, mediaKind, options = {}) {
535
727
  * Send a photo from a local file, public URL, or Base64 data URL.
536
728
  */
537
729
  async function sendPhoto(ctx, imagePath) {
538
- const resolvedMediaPath = resolveOutboundMediaPath(imagePath, "image");
730
+ const hostReadResult = await trySendViaHostRead(ctx, imagePath, "image");
731
+ if (hostReadResult) return hostReadResult;
732
+ const resolvedMediaPath = resolveOutboundMediaPath(imagePath, "image", {
733
+ extraLocalRoots: resolveOutboundMediaLocalRoots(ctx),
734
+ workspaceDir: ctx.mediaAccess?.workspaceDir
735
+ });
539
736
  if (!resolvedMediaPath.ok) return {
540
737
  channel: "qqbot",
541
738
  error: resolvedMediaPath.error
542
739
  };
543
740
  const mediaPath = resolvedMediaPath.mediaPath;
544
741
  const isLocal = isLocalPath(mediaPath);
545
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
546
- const isData = mediaPath.startsWith("data:");
742
+ const isHttp = isHttpUrl(mediaPath);
743
+ const isData = isDataUrl(mediaPath);
547
744
  if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
548
745
  debugLog(`sendPhoto: urlDirectUpload=false, downloading URL first...`);
549
746
  const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto");
@@ -663,13 +860,29 @@ async function sendPhotoFromLocal(ctx, mediaPath) {
663
860
  * URL handling respects `urlDirectUpload`, and local files are transcoded when needed.
664
861
  */
665
862
  async function sendVoice(ctx, voicePath, directUploadFormats, transcodeEnabled = true) {
666
- const resolvedMediaPath = resolveOutboundMediaPath(voicePath, "voice", { allowMissingLocalPath: true });
863
+ let stagedHostReadVoice;
864
+ try {
865
+ stagedHostReadVoice = await stageHostReadVoice(ctx, voicePath);
866
+ } catch (err) {
867
+ return {
868
+ channel: "qqbot",
869
+ error: formatErrorMessage(err)
870
+ };
871
+ }
872
+ const resolvedMediaPath = stagedHostReadVoice ? {
873
+ ok: true,
874
+ mediaPath: stagedHostReadVoice
875
+ } : resolveOutboundMediaPath(voicePath, "voice", {
876
+ allowMissingLocalPath: true,
877
+ extraLocalRoots: resolveOutboundMediaLocalRoots(ctx),
878
+ workspaceDir: ctx.mediaAccess?.workspaceDir
879
+ });
667
880
  if (!resolvedMediaPath.ok) return {
668
881
  channel: "qqbot",
669
882
  error: resolvedMediaPath.error
670
883
  };
671
884
  const mediaPath = resolvedMediaPath.mediaPath;
672
- if (mediaPath.startsWith("http://") || mediaPath.startsWith("https://")) {
885
+ if (isHttpUrl(mediaPath)) {
673
886
  if (shouldDirectUploadUrl(ctx.account)) try {
674
887
  const creds = accountToCreds(ctx.account);
675
888
  const target = {
@@ -716,7 +929,8 @@ async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcode
716
929
  error: "Voice generate failed"
717
930
  };
718
931
  if (fileSize > getMaxUploadSize(3)) return buildFileTooLargeResult(3, fileSize);
719
- const safeMediaPath = resolveTrustedOutboundMediaPath(mediaPath);
932
+ const extraLocalRoots = resolveOutboundMediaLocalRoots(ctx);
933
+ const safeMediaPath = resolveTrustedOutboundMediaPath(mediaPath) ?? (extraLocalRoots ? resolveExistingPathWithinRoots(mediaPath, extraLocalRoots) : null);
720
934
  if (!safeMediaPath) {
721
935
  debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`);
722
936
  return {
@@ -779,13 +993,18 @@ async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcode
779
993
  }
780
994
  /** Send video from either a public URL or a local file. */
781
995
  async function sendVideoMsg(ctx, videoPath) {
782
- const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video");
996
+ const hostReadResult = await trySendViaHostRead(ctx, videoPath, "video");
997
+ if (hostReadResult) return hostReadResult;
998
+ const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video", {
999
+ extraLocalRoots: resolveOutboundMediaLocalRoots(ctx),
1000
+ workspaceDir: ctx.mediaAccess?.workspaceDir
1001
+ });
783
1002
  if (!resolvedMediaPath.ok) return {
784
1003
  channel: "qqbot",
785
1004
  error: resolvedMediaPath.error
786
1005
  };
787
1006
  const mediaPath = resolvedMediaPath.mediaPath;
788
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
1007
+ const isHttp = isHttpUrl(mediaPath);
789
1008
  if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
790
1009
  debugLog(`sendVideoMsg: urlDirectUpload=false, downloading URL first...`);
791
1010
  const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg");
@@ -887,13 +1106,18 @@ async function sendVideoFromLocal(ctx, mediaPath) {
887
1106
  }
888
1107
  /** Send a file from a local path or public URL. */
889
1108
  async function sendDocument(ctx, filePath, options = {}) {
890
- const resolvedMediaPath = resolveOutboundMediaPath(filePath, "file", { extraLocalRoots: options.allowQQBotDataDownloads ? [getQQBotDataDir("downloads")] : void 0 });
1109
+ const hostReadResult = await trySendViaHostRead(ctx, filePath, "file");
1110
+ if (hostReadResult) return hostReadResult;
1111
+ const resolvedMediaPath = resolveOutboundMediaPath(filePath, "file", {
1112
+ extraLocalRoots: mergeMediaLocalRoots(options.allowQQBotDataDownloads ? [getQQBotDataDir("downloads")] : void 0, resolveOutboundMediaLocalRoots(ctx)),
1113
+ workspaceDir: ctx.mediaAccess?.workspaceDir
1114
+ });
891
1115
  if (!resolvedMediaPath.ok) return {
892
1116
  channel: "qqbot",
893
1117
  error: resolvedMediaPath.error
894
1118
  };
895
1119
  const mediaPath = resolvedMediaPath.mediaPath;
896
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
1120
+ const isHttp = isHttpUrl(mediaPath);
897
1121
  const fileName = sanitizeFileName(path.basename(mediaPath));
898
1122
  if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
899
1123
  debugLog(`sendDocument: urlDirectUpload=false, downloading URL first...`);
@@ -918,7 +1142,7 @@ async function sendDocument(ctx, filePath, options = {}) {
918
1142
  kind: "file",
919
1143
  source: { url: mediaPath },
920
1144
  msgId: ctx.replyToId,
921
- fileName
1145
+ ...fileName ? { fileName } : {}
922
1146
  });
923
1147
  return {
924
1148
  channel: "qqbot",
@@ -1435,7 +1659,10 @@ async function sendText(ctx) {
1435
1659
  const mediaTarget = buildMediaTarget({
1436
1660
  to,
1437
1661
  account,
1438
- replyToId
1662
+ replyToId,
1663
+ mediaAccess: ctx.mediaAccess,
1664
+ mediaLocalRoots: ctx.mediaLocalRoots,
1665
+ mediaReadFile: ctx.mediaReadFile
1439
1666
  });
1440
1667
  let lastResult = { channel: "qqbot" };
1441
1668
  for (const item of sendQueue) try {
@@ -1464,7 +1691,10 @@ async function sendText(ctx) {
1464
1691
  mediaUrl: item.content,
1465
1692
  accountId: account.accountId,
1466
1693
  replyToId,
1467
- account
1694
+ account,
1695
+ mediaAccess: ctx.mediaAccess,
1696
+ mediaLocalRoots: ctx.mediaLocalRoots,
1697
+ mediaReadFile: ctx.mediaReadFile
1468
1698
  });
1469
1699
  } catch (err) {
1470
1700
  const errMsg = formatErrorMessage(err);
@@ -1526,17 +1756,27 @@ async function sendMedia(ctx) {
1526
1756
  channel: "qqbot",
1527
1757
  error: "mediaUrl is required for sendMedia"
1528
1758
  };
1529
- const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "media", { allowMissingLocalPath: true });
1759
+ const target = buildMediaTarget({
1760
+ to,
1761
+ account,
1762
+ replyToId,
1763
+ mediaAccess: ctx.mediaAccess,
1764
+ mediaLocalRoots: ctx.mediaLocalRoots,
1765
+ mediaReadFile: ctx.mediaReadFile
1766
+ });
1767
+ const resolvedMediaPath = !ctx.mediaAccess?.readFile && !ctx.mediaReadFile ? resolveOutboundMediaPath(ctx.mediaUrl, "media", {
1768
+ allowMissingLocalPath: true,
1769
+ extraLocalRoots: target.mediaLocalRoots ? [...target.mediaLocalRoots] : void 0,
1770
+ workspaceDir: target.mediaAccess?.workspaceDir
1771
+ }) : {
1772
+ ok: true,
1773
+ mediaPath: ctx.mediaUrl
1774
+ };
1530
1775
  if (!resolvedMediaPath.ok) return {
1531
1776
  channel: "qqbot",
1532
1777
  error: resolvedMediaPath.error
1533
1778
  };
1534
1779
  const mediaUrl = resolvedMediaPath.mediaPath;
1535
- const target = buildMediaTarget({
1536
- to,
1537
- account,
1538
- replyToId
1539
- });
1540
1780
  if (isAudioFile(mediaUrl, mimeType)) {
1541
1781
  const result = await sendVoice(target, mediaUrl, account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
1542
1782
  if (!result.error) {
@@ -1561,7 +1801,7 @@ async function sendMedia(ctx) {
1561
1801
  return result;
1562
1802
  }
1563
1803
  if (!isImageFile(mediaUrl, mimeType) && !isAudioFile(mediaUrl, mimeType) && !isVideoFile(mediaUrl, mimeType)) {
1564
- const result = await sendDocument(target, mediaUrl);
1804
+ const result = await sendAutoDetectedMedia(target, mediaUrl);
1565
1805
  if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
1566
1806
  return result;
1567
1807
  }
@@ -1622,4 +1862,4 @@ async function sendCronMessage(account, to, message) {
1622
1862
  });
1623
1863
  }
1624
1864
  //#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 };
1865
+ 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 };
@@ -1,4 +1,4 @@
1
- import { v as setOpenClawVersion } from "./sender-BHWpE_yH.js";
1
+ import { v as setOpenClawVersion } from "./sender-BfVLJJYp.js";
2
2
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
3
3
  //#region extensions/qqbot/src/bridge/runtime.ts
4
4
  const { setRuntime: _setRuntime, clearRuntime: resetQQBotRuntimeForTest, getRuntime: getQQBotRuntime } = createPluginRuntimeStore({
@@ -1,2 +1,2 @@
1
- import { r as setQQBotRuntime, t as getQQBotRuntime } from "./runtime-BL0xZzWe.js";
1
+ import { r as setQQBotRuntime, t as getQQBotRuntime } from "./runtime-B4HWCOa-.js";
2
2
  export { getQQBotRuntime, setQQBotRuntime };
@@ -6,6 +6,7 @@ import { readProviderTextResponse, readResponseTextLimited } from "openclaw/plug
6
6
  import { fetchWithSsrFGuard, isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
7
7
  import * as crypto$1 from "node:crypto";
8
8
  import crypto from "node:crypto";
9
+ import { sleep } from "openclaw/plugin-sdk/runtime-env";
9
10
  import { FsSafeError, openLocalFileSafely, readRegularFile, statRegularFileSync } from "openclaw/plugin-sdk/security-runtime";
10
11
  import * as path$1 from "node:path";
11
12
  import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
@@ -462,6 +463,17 @@ async function normalizeSource(raw, opts = {}) {
462
463
  //#endregion
463
464
  //#region extensions/qqbot/src/engine/api/retry.ts
464
465
  /**
466
+ * Generic retry engine for QQ Bot API requests.
467
+ *
468
+ * Replaces the three separate retry implementations in the old `api.ts`:
469
+ * - `apiRequestWithRetry` (upload retry with exponential backoff)
470
+ * - `partFinishWithRetry` (part-finish retry + persistent retry on specific biz codes)
471
+ * - `completeUploadWithRetry` (unconditional retry for complete-upload)
472
+ *
473
+ * All three patterns are expressed as a single `withRetry` function
474
+ * parameterized by `RetryPolicy` and optional `PersistentRetryPolicy`.
475
+ */
476
+ /**
465
477
  * Execute an async operation with configurable retry semantics.
466
478
  *
467
479
  * @param fn - The async operation to retry.
@@ -484,7 +496,7 @@ async function withRetry(fn, policy, persistentPolicy, logger) {
484
496
  if (attempt < policy.maxRetries) {
485
497
  const delay = policy.backoff === "exponential" ? policy.baseDelayMs * 2 ** attempt : policy.baseDelayMs;
486
498
  logger?.debug?.(`[qqbot:retry] Attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 100)}`);
487
- await sleep$1(delay);
499
+ await sleep(delay);
488
500
  }
489
501
  }
490
502
  throw lastError;
@@ -514,16 +526,11 @@ async function persistentRetryLoop(fn, policy, logger) {
514
526
  if (remaining <= 0) break;
515
527
  const actualDelay = Math.min(policy.intervalMs, remaining);
516
528
  (logger?.warn ?? logger?.error)?.(`[qqbot:retry] Persistent retry #${attempt}: retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1e3)}s)`);
517
- await sleep$1(actualDelay);
529
+ await sleep(actualDelay);
518
530
  }
519
531
  logger?.error?.(`[qqbot:retry] Persistent retry timed out after ${policy.timeoutMs / 1e3}s (${attempt} attempts)`);
520
532
  throw lastError ?? /* @__PURE__ */ new Error(`Persistent retry timed out (${policy.timeoutMs / 1e3}s)`);
521
533
  }
522
- function sleep$1(ms) {
523
- return new Promise((resolve) => {
524
- setTimeout(resolve, ms);
525
- });
526
- }
527
534
  /** Standard upload retry: exponential backoff, skip 400/401/timeout errors. */
528
535
  const UPLOAD_RETRY_POLICY = {
529
536
  maxRetries: 2,
@@ -967,11 +974,6 @@ async function runWithConcurrency(tasks, maxConcurrent) {
967
974
  await Promise.all(batch.map((task) => task()));
968
975
  }
969
976
  }
970
- function sleep(ms) {
971
- return new Promise((resolve) => {
972
- setTimeout(resolve, ms);
973
- });
974
- }
975
977
  //#endregion
976
978
  //#region extensions/qqbot/src/engine/api/token.ts
977
979
  /**
@@ -1,2 +1,2 @@
1
- import { t as qqbotSetupPlugin } from "./channel.setup-CldhUlTO.js";
1
+ import { t as qqbotSetupPlugin } from "./channel.setup-2ItDYKhz.js";
2
2
  export { qqbotSetupPlugin };