@openclaw/qqbot 2026.6.11 → 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,11 +1,226 @@
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-CRZ2af7P.js";
2
- import { i as normalizeOptionalString, n as normalizeLowercaseStringOrEmpty, s as sanitizeFileName } from "./string-normalize-R_0cKO7Q.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
+ 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
- import { c as getQQBotMediaDir, d as isLocalPath, m as resolveQQBotPayloadLocalFilePath, o as getQQBotDataDir, p as normalizePath$1, r as parseTarget$1 } from "./target-parser-BdCUmxK7.js";
4
+ import { r as parseTarget$1 } from "./target-parser-DagYRQkP.js";
5
+ import * as fs$2 from "node:fs";
6
+ import * as os$1 from "node:os";
7
+ import { randomUUID } from "node:crypto";
5
8
  import { pathExistsSync, resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime";
9
+ import * as path$1 from "node:path";
6
10
  import path from "node:path";
7
- 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";
8
14
  import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
15
+ //#region extensions/qqbot/src/engine/utils/platform.ts
16
+ /**
17
+ * Cross-platform path and detection helpers for core/ modules.
18
+ *
19
+ * Provides home/data/media directory helpers, platform detection,
20
+ * silk-wasm availability checks — all without importing `openclaw/plugin-sdk`.
21
+ * The temp-directory fallback is delegated to the PlatformAdapter.
22
+ */
23
+ /**
24
+ * Resolve the current user's OS home directory safely across platforms.
25
+ *
26
+ * Priority:
27
+ * 1. `os.homedir()`
28
+ * 2. `$HOME` or `%USERPROFILE%`
29
+ * 3. PlatformAdapter.getTempDir() as a last resort
30
+ *
31
+ * This is the *operating-system* home and intentionally ignores
32
+ * `OPENCLAW_HOME`. QQ Bot still checks this tree for legacy state imports and
33
+ * media-path remaps from older releases.
34
+ */
35
+ function getHomeDir() {
36
+ try {
37
+ const home = os$1.homedir();
38
+ if (home && fs$2.existsSync(home)) return home;
39
+ } catch {}
40
+ const envHome = process.env.HOME || process.env.USERPROFILE;
41
+ if (envHome && fs$2.existsSync(envHome)) return envHome;
42
+ return getPlatformAdapter().getTempDir();
43
+ }
44
+ /**
45
+ * Resolve the effective OpenClaw home directory.
46
+ *
47
+ * Mirrors the contract from core (`src/infra/home-dir.ts::resolveEffectiveHomeDir`)
48
+ * so QQ Bot media roots live under the same tree the rest of OpenClaw treats as
49
+ * `~`. The extension cannot import the core helper directly (it is a separate
50
+ * package with `openclaw` as a peer dependency), so this re-implements the
51
+ * minimal contract:
52
+ *
53
+ * 1. `OPENCLAW_HOME` when set (with `~` / `~/...` expanded against the OS home).
54
+ * 2. Otherwise fall back to {@link getHomeDir} so existing single-home
55
+ * deployments are unaffected.
56
+ *
57
+ * Empty / `"undefined"` / `"null"` strings are treated as unset to match how
58
+ * core normalizes the variable.
59
+ */
60
+ function resolveOpenClawHome() {
61
+ const raw = process.env.OPENCLAW_HOME?.trim();
62
+ if (!raw || raw === "undefined" || raw === "null") return getHomeDir();
63
+ if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
64
+ const osHome = getHomeDir();
65
+ if (raw === "~") return osHome;
66
+ return path$1.join(osHome, raw.slice(2));
67
+ }
68
+ return raw;
69
+ }
70
+ /**
71
+ * Return a legacy path under `~/.openclaw/qqbot` without creating it.
72
+ *
73
+ * Current QQ Bot runtime state lives in plugin SQLite KV. This path remains for
74
+ * legacy imports and media-path remaps from older releases.
75
+ */
76
+ function getQQBotDataPath(...subPaths) {
77
+ return path$1.join(getHomeDir(), ".openclaw", "qqbot", ...subPaths);
78
+ }
79
+ /** Return a path under `~/.openclaw/qqbot`, creating it on demand. */
80
+ function getQQBotDataDir(...subPaths) {
81
+ const dir = getQQBotDataPath(...subPaths);
82
+ if (!fs$2.existsSync(dir)) fs$2.mkdirSync(dir, { recursive: true });
83
+ return dir;
84
+ }
85
+ /**
86
+ * Return a path under `<openclaw-home>/.openclaw/media/qqbot` without creating it.
87
+ *
88
+ * Unlike `getQQBotDataPath`, this lives under OpenClaw's core media allowlist
89
+ * so downloaded images and audio can be accessed by framework media tooling.
90
+ * The base honors `OPENCLAW_HOME` (when set) so files written by agents into
91
+ * the OpenClaw-managed media tree are reachable by this plugin even when
92
+ * `HOME` and `OPENCLAW_HOME` differ (Docker, multi-user hosts). Fixes #83562.
93
+ */
94
+ function getQQBotMediaPath(...subPaths) {
95
+ return path$1.join(resolveOpenClawHome(), ".openclaw", "media", "qqbot", ...subPaths);
96
+ }
97
+ /** Return a path under `<openclaw-home>/.openclaw/media/qqbot`, creating it on demand. */
98
+ function getQQBotMediaDir(...subPaths) {
99
+ const dir = getQQBotMediaPath(...subPaths);
100
+ if (!fs$2.existsSync(dir)) fs$2.mkdirSync(dir, { recursive: true });
101
+ return dir;
102
+ }
103
+ /**
104
+ * Return `<openclaw-home>/.openclaw/media`, OpenClaw's shared media root.
105
+ *
106
+ * This mirrors the directory that core's `buildMediaLocalRoots` exposes as an
107
+ * allowlisted location (see `openclaw/src/media/local-roots.ts`). Using it as a
108
+ * QQ Bot payload root lets the plugin trust framework-produced files that live
109
+ * in sibling subdirectories such as `outbound/` (written by
110
+ * `saveMediaBuffer(..., "outbound", ...)`) or `inbound/`, while still keeping
111
+ * the check anchored to a single, well-known directory. Like
112
+ * {@link getQQBotMediaPath}, the base honors `OPENCLAW_HOME`.
113
+ */
114
+ function getOpenClawMediaDir() {
115
+ return path$1.join(resolveOpenClawHome(), ".openclaw", "media");
116
+ }
117
+ function isWindows() {
118
+ return process.platform === "win32";
119
+ }
120
+ /** Return the preferred temporary directory. */
121
+ function getTempDir() {
122
+ return getPlatformAdapter().getTempDir();
123
+ }
124
+ let silkWasmAvailable = null;
125
+ /** Check whether silk-wasm can run in the current environment. */
126
+ async function checkSilkWasmAvailable() {
127
+ if (silkWasmAvailable !== null) return silkWasmAvailable;
128
+ try {
129
+ const { isSilk } = await import("silk-wasm");
130
+ isSilk(new Uint8Array(0));
131
+ silkWasmAvailable = true;
132
+ debugLog("[platform] silk-wasm: available");
133
+ } catch (err) {
134
+ silkWasmAvailable = false;
135
+ debugWarn(`[platform] silk-wasm: NOT available (${formatErrorMessage(err)})`);
136
+ }
137
+ return silkWasmAvailable;
138
+ }
139
+ /** Expand `~` to the current user's home directory. */
140
+ function expandTilde$1(p) {
141
+ if (!p) return p;
142
+ if (p === "~") return getHomeDir();
143
+ if (p.startsWith("~/") || p.startsWith("~\\")) return path$1.join(getHomeDir(), p.slice(2));
144
+ return p;
145
+ }
146
+ /** Normalize a user-provided path by trimming, stripping `file://`, and expanding `~`. */
147
+ function normalizePath$1(p) {
148
+ let result = p.trim();
149
+ if (result.startsWith("file://")) {
150
+ result = result.slice(7);
151
+ try {
152
+ result = decodeURIComponent(result);
153
+ } catch {}
154
+ }
155
+ return expandTilde$1(result);
156
+ }
157
+ /** Return true when the string looks like a local filesystem path rather than a URL. */
158
+ function isLocalPath(p) {
159
+ if (!p) return false;
160
+ if (p.startsWith("file://")) return true;
161
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) return true;
162
+ if (p.startsWith("/")) return true;
163
+ if (/^[a-zA-Z]:[\\/]/.test(p)) return true;
164
+ if (p.startsWith("\\\\")) return true;
165
+ if (p.startsWith("./") || p.startsWith("../")) return true;
166
+ if (p.startsWith(".\\") || p.startsWith("..\\")) return true;
167
+ return false;
168
+ }
169
+ function isPathWithinRoot$1(candidate, root) {
170
+ const relative = path$1.relative(root, candidate);
171
+ return relative === "" || !relative.startsWith("..") && !path$1.isAbsolute(relative);
172
+ }
173
+ /** Remap legacy or hallucinated QQ Bot local media paths to real files when possible. */
174
+ function resolveQQBotLocalMediaPath(p) {
175
+ const normalized = normalizePath$1(p);
176
+ if (!isLocalPath(normalized) || fs$2.existsSync(normalized)) return normalized;
177
+ const osHomeDir = getHomeDir();
178
+ const openclawHomeDir = resolveOpenClawHome();
179
+ const mediaRoot = getQQBotMediaPath();
180
+ const dataRoot = getQQBotDataPath();
181
+ const candidateRoots = [
182
+ ...Array.from(new Set([path$1.join(osHomeDir, ".openclaw", "workspace", "qqbot"), path$1.join(openclawHomeDir, ".openclaw", "workspace", "qqbot")])).map((from) => ({
183
+ from,
184
+ to: mediaRoot
185
+ })),
186
+ {
187
+ from: dataRoot,
188
+ to: mediaRoot
189
+ },
190
+ {
191
+ from: mediaRoot,
192
+ to: dataRoot
193
+ }
194
+ ];
195
+ for (const { from, to } of candidateRoots) {
196
+ if (!isPathWithinRoot$1(normalized, from)) continue;
197
+ const relative = path$1.relative(from, normalized);
198
+ const candidate = path$1.join(to, relative);
199
+ if (fs$2.existsSync(candidate)) {
200
+ debugWarn(`[platform] Remapped missing QQBot media path ${normalized} -> ${candidate}`);
201
+ return candidate;
202
+ }
203
+ }
204
+ return normalized;
205
+ }
206
+ /**
207
+ * Resolve a structured-payload local file path and enforce that it stays within
208
+ * QQ Bot-owned storage roots.
209
+ */
210
+ function resolveQQBotPayloadLocalFilePath(p) {
211
+ const candidate = resolveQQBotLocalMediaPath(p);
212
+ if (!candidate.trim()) return null;
213
+ const resolvedCandidate = path$1.resolve(candidate);
214
+ if (!fs$2.existsSync(resolvedCandidate)) return null;
215
+ const canonicalCandidate = fs$2.realpathSync(resolvedCandidate);
216
+ const allowedRoots = [getOpenClawMediaDir(), getQQBotMediaPath()];
217
+ for (const root of allowedRoots) {
218
+ const resolvedRoot = path$1.resolve(root);
219
+ if (isPathWithinRoot$1(canonicalCandidate, fs$2.existsSync(resolvedRoot) ? fs$2.realpathSync(resolvedRoot) : resolvedRoot)) return canonicalCandidate;
220
+ }
221
+ return null;
222
+ }
223
+ //#endregion
9
224
  //#region extensions/qqbot/src/engine/messaging/outbound-audio-port.ts
10
225
  let outboundAudioPort = null;
11
226
  /**
@@ -194,6 +409,46 @@ function buildFileTooLargeResult(fileType, fileSize) {
194
409
  };
195
410
  }
196
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
197
452
  //#region extensions/qqbot/src/engine/messaging/trusted-media-path.ts
198
453
  let cachedTrustedTmpRoot;
199
454
  function trustedOpenClawTmpRoot() {
@@ -250,11 +505,15 @@ function parseTarget(to) {
250
505
  /** Build a media target from a normal outbound context. */
251
506
  function buildMediaTarget(ctx) {
252
507
  const target = parseTarget(ctx.to);
508
+ const mediaLocalRoots = resolveOutboundMediaLocalRoots(ctx);
253
509
  return {
254
510
  targetType: target.type,
255
511
  targetId: target.id,
256
512
  account: ctx.account,
257
- 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 } : {}
258
517
  };
259
518
  }
260
519
  /** Return true when public URLs should be passed through directly. */
@@ -268,19 +527,28 @@ const qqBotMediaKindLabel = {
268
527
  file: "File",
269
528
  media: "Media"
270
529
  };
530
+ function isHttpUrl(pathValue) {
531
+ return pathValue.startsWith("http://") || pathValue.startsWith("https://");
532
+ }
533
+ function isDataUrl(pathValue) {
534
+ return pathValue.startsWith("data:");
535
+ }
271
536
  function isHttpOrDataSource(pathValue) {
272
- return pathValue.startsWith("http://") || pathValue.startsWith("https://") || pathValue.startsWith("data:");
537
+ return isHttpUrl(pathValue) || isDataUrl(pathValue);
273
538
  }
274
- function resolveMissingPathWithinMediaRoot(normalizedPath) {
539
+ function resolveMissingPathWithinRoots(normalizedPath, allowedRoots) {
275
540
  const resolvedCandidate = path.resolve(normalizedPath);
276
541
  if (pathExistsSync(resolvedCandidate)) return null;
277
542
  return resolveLocalPathFromRootsSync({
278
543
  filePath: resolvedCandidate,
279
- roots: [getQQBotMediaDir()],
280
- label: "QQ Bot media storage",
544
+ roots: allowedRoots,
545
+ label: "QQ Bot local roots",
281
546
  allowMissing: true
282
547
  })?.path ?? null;
283
548
  }
549
+ function isPathWithinAnyRoot(candidatePath, allowedRoots) {
550
+ return allowedRoots?.some((root) => root.trim() && isPathWithinRoot(candidatePath, root)) ?? false;
551
+ }
284
552
  function resolveExistingPathWithinRoots(normalizedPath, allowedRoots) {
285
553
  return resolveLocalPathFromRootsSync({
286
554
  filePath: normalizedPath,
@@ -288,30 +556,166 @@ function resolveExistingPathWithinRoots(normalizedPath, allowedRoots) {
288
556
  label: "QQ Bot local roots"
289
557
  })?.path ?? null;
290
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
+ }
291
689
  function resolveOutboundMediaPath(rawPath, mediaKind, options = {}) {
292
690
  const normalizedPath = normalizePath$1(rawPath);
293
691
  if (isHttpOrDataSource(normalizedPath)) return {
294
692
  ok: true,
295
693
  mediaPath: normalizedPath
296
694
  };
297
- const allowedPath = resolveTrustedOutboundMediaPath(normalizedPath, { allowMissing: options.allowMissingLocalPath });
298
- if (allowedPath) return {
299
- ok: true,
300
- mediaPath: allowedPath
301
- };
302
- if (options.extraLocalRoots && options.extraLocalRoots.length > 0) {
303
- const extraAllowedPath = resolveExistingPathWithinRoots(normalizedPath, options.extraLocalRoots);
304
- 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 {
305
699
  ok: true,
306
- mediaPath: extraAllowedPath
700
+ mediaPath: allowedPath
307
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
+ }
308
709
  }
309
710
  if (options.allowMissingLocalPath) {
310
- const allowedMissingPath = resolveMissingPathWithinMediaRoot(normalizedPath);
311
- if (allowedMissingPath) return {
312
- ok: true,
313
- mediaPath: allowedMissingPath
314
- };
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
+ }
315
719
  }
316
720
  debugWarn(`blocked local ${mediaKind} path outside QQ Bot media storage`);
317
721
  return {
@@ -323,15 +727,20 @@ function resolveOutboundMediaPath(rawPath, mediaKind, options = {}) {
323
727
  * Send a photo from a local file, public URL, or Base64 data URL.
324
728
  */
325
729
  async function sendPhoto(ctx, imagePath) {
326
- 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
+ });
327
736
  if (!resolvedMediaPath.ok) return {
328
737
  channel: "qqbot",
329
738
  error: resolvedMediaPath.error
330
739
  };
331
740
  const mediaPath = resolvedMediaPath.mediaPath;
332
741
  const isLocal = isLocalPath(mediaPath);
333
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
334
- const isData = mediaPath.startsWith("data:");
742
+ const isHttp = isHttpUrl(mediaPath);
743
+ const isData = isDataUrl(mediaPath);
335
744
  if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
336
745
  debugLog(`sendPhoto: urlDirectUpload=false, downloading URL first...`);
337
746
  const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto");
@@ -451,13 +860,29 @@ async function sendPhotoFromLocal(ctx, mediaPath) {
451
860
  * URL handling respects `urlDirectUpload`, and local files are transcoded when needed.
452
861
  */
453
862
  async function sendVoice(ctx, voicePath, directUploadFormats, transcodeEnabled = true) {
454
- 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
+ });
455
880
  if (!resolvedMediaPath.ok) return {
456
881
  channel: "qqbot",
457
882
  error: resolvedMediaPath.error
458
883
  };
459
884
  const mediaPath = resolvedMediaPath.mediaPath;
460
- if (mediaPath.startsWith("http://") || mediaPath.startsWith("https://")) {
885
+ if (isHttpUrl(mediaPath)) {
461
886
  if (shouldDirectUploadUrl(ctx.account)) try {
462
887
  const creds = accountToCreds(ctx.account);
463
888
  const target = {
@@ -504,7 +929,8 @@ async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcode
504
929
  error: "Voice generate failed"
505
930
  };
506
931
  if (fileSize > getMaxUploadSize(3)) return buildFileTooLargeResult(3, fileSize);
507
- const safeMediaPath = resolveTrustedOutboundMediaPath(mediaPath);
932
+ const extraLocalRoots = resolveOutboundMediaLocalRoots(ctx);
933
+ const safeMediaPath = resolveTrustedOutboundMediaPath(mediaPath) ?? (extraLocalRoots ? resolveExistingPathWithinRoots(mediaPath, extraLocalRoots) : null);
508
934
  if (!safeMediaPath) {
509
935
  debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`);
510
936
  return {
@@ -567,13 +993,18 @@ async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcode
567
993
  }
568
994
  /** Send video from either a public URL or a local file. */
569
995
  async function sendVideoMsg(ctx, videoPath) {
570
- 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
+ });
571
1002
  if (!resolvedMediaPath.ok) return {
572
1003
  channel: "qqbot",
573
1004
  error: resolvedMediaPath.error
574
1005
  };
575
1006
  const mediaPath = resolvedMediaPath.mediaPath;
576
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
1007
+ const isHttp = isHttpUrl(mediaPath);
577
1008
  if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
578
1009
  debugLog(`sendVideoMsg: urlDirectUpload=false, downloading URL first...`);
579
1010
  const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg");
@@ -675,13 +1106,18 @@ async function sendVideoFromLocal(ctx, mediaPath) {
675
1106
  }
676
1107
  /** Send a file from a local path or public URL. */
677
1108
  async function sendDocument(ctx, filePath, options = {}) {
678
- 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
+ });
679
1115
  if (!resolvedMediaPath.ok) return {
680
1116
  channel: "qqbot",
681
1117
  error: resolvedMediaPath.error
682
1118
  };
683
1119
  const mediaPath = resolvedMediaPath.mediaPath;
684
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
1120
+ const isHttp = isHttpUrl(mediaPath);
685
1121
  const fileName = sanitizeFileName(path.basename(mediaPath));
686
1122
  if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
687
1123
  debugLog(`sendDocument: urlDirectUpload=false, downloading URL first...`);
@@ -706,7 +1142,7 @@ async function sendDocument(ctx, filePath, options = {}) {
706
1142
  kind: "file",
707
1143
  source: { url: mediaPath },
708
1144
  msgId: ctx.replyToId,
709
- fileName
1145
+ ...fileName ? { fileName } : {}
710
1146
  });
711
1147
  return {
712
1148
  channel: "qqbot",
@@ -1223,7 +1659,10 @@ async function sendText(ctx) {
1223
1659
  const mediaTarget = buildMediaTarget({
1224
1660
  to,
1225
1661
  account,
1226
- replyToId
1662
+ replyToId,
1663
+ mediaAccess: ctx.mediaAccess,
1664
+ mediaLocalRoots: ctx.mediaLocalRoots,
1665
+ mediaReadFile: ctx.mediaReadFile
1227
1666
  });
1228
1667
  let lastResult = { channel: "qqbot" };
1229
1668
  for (const item of sendQueue) try {
@@ -1252,7 +1691,10 @@ async function sendText(ctx) {
1252
1691
  mediaUrl: item.content,
1253
1692
  accountId: account.accountId,
1254
1693
  replyToId,
1255
- account
1694
+ account,
1695
+ mediaAccess: ctx.mediaAccess,
1696
+ mediaLocalRoots: ctx.mediaLocalRoots,
1697
+ mediaReadFile: ctx.mediaReadFile
1256
1698
  });
1257
1699
  } catch (err) {
1258
1700
  const errMsg = formatErrorMessage(err);
@@ -1314,17 +1756,27 @@ async function sendMedia(ctx) {
1314
1756
  channel: "qqbot",
1315
1757
  error: "mediaUrl is required for sendMedia"
1316
1758
  };
1317
- 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
+ };
1318
1775
  if (!resolvedMediaPath.ok) return {
1319
1776
  channel: "qqbot",
1320
1777
  error: resolvedMediaPath.error
1321
1778
  };
1322
1779
  const mediaUrl = resolvedMediaPath.mediaPath;
1323
- const target = buildMediaTarget({
1324
- to,
1325
- account,
1326
- replyToId
1327
- });
1328
1780
  if (isAudioFile(mediaUrl, mimeType)) {
1329
1781
  const result = await sendVoice(target, mediaUrl, account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
1330
1782
  if (!result.error) {
@@ -1349,7 +1801,7 @@ async function sendMedia(ctx) {
1349
1801
  return result;
1350
1802
  }
1351
1803
  if (!isImageFile(mediaUrl, mimeType) && !isAudioFile(mediaUrl, mimeType) && !isVideoFile(mediaUrl, mimeType)) {
1352
- const result = await sendDocument(target, mediaUrl);
1804
+ const result = await sendAutoDetectedMedia(target, mediaUrl);
1353
1805
  if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
1354
1806
  return result;
1355
1807
  }
@@ -1410,4 +1862,4 @@ async function sendCronMessage(account, to, message) {
1410
1862
  });
1411
1863
  }
1412
1864
  //#endregion
1413
- export { getMessageReplyConfig as C, OUTBOUND_ERROR_CODES as D, DEFAULT_MEDIA_SEND_ERROR as E, setOutboundAudioPort as O, 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, 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 };