@sunnoy/wecom 2.0.2 → 2.2.0

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.
@@ -0,0 +1,146 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { generateReqId } from "@wecom/aibot-node-sdk";
5
+ import { logger } from "../logger.js";
6
+
7
+ const MCP_GET_CONFIG_CMD = "aibot_get_mcp_config";
8
+ const MCP_CONFIG_FETCH_TIMEOUT_MS = 15_000;
9
+ const MCP_CONFIG_KEY = "doc";
10
+ const DEFAULT_MCP_TRANSPORT = "streamable-http";
11
+
12
+ let mcpConfigWriteQueue = Promise.resolve();
13
+
14
+ function withTimeout(promise, timeoutMs, message) {
15
+ if (!timeoutMs || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
16
+ return promise;
17
+ }
18
+
19
+ let timer = null;
20
+ const timeout = new Promise((_, reject) => {
21
+ timer = setTimeout(() => reject(new Error(message ?? `Timed out after ${timeoutMs}ms`)), timeoutMs);
22
+ });
23
+
24
+ promise.catch(() => {});
25
+
26
+ return Promise.race([promise, timeout]).finally(() => {
27
+ if (timer) {
28
+ clearTimeout(timer);
29
+ }
30
+ });
31
+ }
32
+
33
+ function getWecomConfigPath() {
34
+ return path.join(os.homedir(), ".openclaw", "wecomConfig", "config.json");
35
+ }
36
+
37
+ function resolveMcpTransport(body = {}) {
38
+ const candidate = String(
39
+ body.transport_type ??
40
+ body.transportType ??
41
+ body.config_type ??
42
+ body.configType ??
43
+ body.type ??
44
+ "",
45
+ )
46
+ .trim()
47
+ .toLowerCase();
48
+
49
+ return candidate || DEFAULT_MCP_TRANSPORT;
50
+ }
51
+
52
+ async function readJsonFile(filePath, fallback = {}) {
53
+ try {
54
+ const raw = await readFile(filePath, "utf8");
55
+ return JSON.parse(raw);
56
+ } catch (error) {
57
+ if (error?.code === "ENOENT") {
58
+ return fallback;
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ async function writeJsonFileAtomically(filePath, value) {
65
+ const dir = path.dirname(filePath);
66
+ await mkdir(dir, { recursive: true });
67
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
68
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
69
+ await rename(tempPath, filePath);
70
+ }
71
+
72
+ export async function fetchMcpConfig(wsClient) {
73
+ if (!wsClient || typeof wsClient.reply !== "function") {
74
+ throw new Error("WS client does not support MCP config requests");
75
+ }
76
+
77
+ const reqId = generateReqId("mcp_config");
78
+ const response = await withTimeout(
79
+ wsClient.reply({ headers: { req_id: reqId } }, { biz_type: MCP_CONFIG_KEY }, MCP_GET_CONFIG_CMD),
80
+ MCP_CONFIG_FETCH_TIMEOUT_MS,
81
+ `MCP config fetch timed out after ${MCP_CONFIG_FETCH_TIMEOUT_MS}ms`,
82
+ );
83
+
84
+ if (response?.errcode && response.errcode !== 0) {
85
+ throw new Error(`MCP config request failed: errcode=${response.errcode}, errmsg=${response.errmsg ?? "unknown"}`);
86
+ }
87
+
88
+ const body = response?.body;
89
+ if (!body?.url) {
90
+ throw new Error("MCP config response missing required 'url' field");
91
+ }
92
+
93
+ return {
94
+ key: MCP_CONFIG_KEY,
95
+ type: resolveMcpTransport(body),
96
+ url: body.url,
97
+ isAuthed: body.is_authed,
98
+ };
99
+ }
100
+
101
+ export async function saveMcpConfig(config, runtime) {
102
+ const configPath = getWecomConfigPath();
103
+
104
+ const saveTask = mcpConfigWriteQueue.then(async () => {
105
+ const current = await readJsonFile(configPath, {});
106
+ if (!current.mcpConfig || typeof current.mcpConfig !== "object") {
107
+ current.mcpConfig = {};
108
+ }
109
+
110
+ current.mcpConfig[config.key || MCP_CONFIG_KEY] = {
111
+ type: config.type,
112
+ url: config.url,
113
+ };
114
+
115
+ await writeJsonFileAtomically(configPath, current);
116
+ runtime?.log?.(`[WeCom] MCP config saved to ${configPath}`);
117
+ });
118
+
119
+ mcpConfigWriteQueue = saveTask.catch(() => {});
120
+ return saveTask;
121
+ }
122
+
123
+ export async function fetchAndSaveMcpConfig(wsClient, accountId, runtime) {
124
+ try {
125
+ runtime?.log?.(`[${accountId}] Fetching MCP config...`);
126
+ const config = await fetchMcpConfig(wsClient);
127
+ runtime?.log?.(
128
+ `[${accountId}] MCP config fetched: url=${config.url}, type=${config.type}, is_authed=${config.isAuthed ?? "N/A"}`,
129
+ );
130
+ await saveMcpConfig(config, runtime);
131
+ } catch (error) {
132
+ if (typeof wsClient?.reply !== "function") {
133
+ logger.debug?.(`[${accountId}] Skipping MCP config fetch because WS client has no reply() support`);
134
+ return;
135
+ }
136
+ runtime?.error?.(`[${accountId}] Failed to fetch/save MCP config: ${String(error)}`);
137
+ }
138
+ }
139
+
140
+ export const mcpConfigTesting = {
141
+ getWecomConfigPath,
142
+ resolveMcpTransport,
143
+ resetWriteQueue() {
144
+ mcpConfigWriteQueue = Promise.resolve();
145
+ },
146
+ };
@@ -0,0 +1,208 @@
1
+ import { basename } from "node:path";
2
+ import { logger } from "../logger.js";
3
+ import { loadOutboundMediaFromUrl, detectMime, getExtendedMediaLocalRoots } from "./openclaw-compat.js";
4
+ import {
5
+ IMAGE_MAX_BYTES,
6
+ VIDEO_MAX_BYTES,
7
+ VOICE_MAX_BYTES,
8
+ ABSOLUTE_MAX_BYTES,
9
+ } from "./constants.js";
10
+
11
+ const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
12
+
13
+ const MIME_TO_EXT = {
14
+ "image/jpeg": ".jpg",
15
+ "image/png": ".png",
16
+ "image/gif": ".gif",
17
+ "image/webp": ".webp",
18
+ "image/bmp": ".bmp",
19
+ "image/svg+xml": ".svg",
20
+ "video/mp4": ".mp4",
21
+ "video/quicktime": ".mov",
22
+ "video/x-msvideo": ".avi",
23
+ "video/webm": ".webm",
24
+ "audio/mpeg": ".mp3",
25
+ "audio/ogg": ".ogg",
26
+ "audio/wav": ".wav",
27
+ "audio/amr": ".amr",
28
+ "audio/aac": ".aac",
29
+ "application/pdf": ".pdf",
30
+ "application/zip": ".zip",
31
+ "application/msword": ".doc",
32
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
33
+ "application/vnd.ms-excel": ".xls",
34
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
35
+ "text/plain": ".txt",
36
+ };
37
+
38
+ export function detectWeComMediaType(mimeType) {
39
+ const mime = String(mimeType ?? "").toLowerCase();
40
+ if (mime.startsWith("image/")) return "image";
41
+ if (mime.startsWith("video/")) return "video";
42
+ if (mime.startsWith("audio/") || mime === "application/ogg") return "voice";
43
+ return "file";
44
+ }
45
+
46
+ export function mimeToExtension(mime) {
47
+ return MIME_TO_EXT[mime] || ".bin";
48
+ }
49
+
50
+ export function extractFileName(mediaUrl, providedFileName, contentType) {
51
+ if (providedFileName) return providedFileName;
52
+
53
+ try {
54
+ const urlObj = new URL(mediaUrl, "file://");
55
+ const lastPart = urlObj.pathname.split("/").pop();
56
+ if (lastPart?.includes(".")) return decodeURIComponent(lastPart);
57
+ } catch {
58
+ const lastPart = String(mediaUrl).split("/").pop();
59
+ if (lastPart?.includes(".")) return lastPart;
60
+ }
61
+
62
+ return `media_${Date.now()}${mimeToExtension(contentType || "application/octet-stream")}`;
63
+ }
64
+
65
+ export function applyFileSizeLimits(fileSize, detectedType, contentType) {
66
+ const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
67
+
68
+ if (fileSize > ABSOLUTE_MAX_BYTES) {
69
+ return {
70
+ finalType: detectedType,
71
+ shouldReject: true,
72
+ rejectReason: `文件大小 ${fileSizeMB}MB 超过了企业微信允许的最大限制 20MB,无法发送。请尝试压缩文件或减小文件大小。`,
73
+ downgraded: false,
74
+ };
75
+ }
76
+
77
+ switch (detectedType) {
78
+ case "image":
79
+ if (fileSize > IMAGE_MAX_BYTES) {
80
+ return {
81
+ finalType: "file",
82
+ shouldReject: false,
83
+ downgraded: true,
84
+ downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
85
+ };
86
+ }
87
+ break;
88
+ case "video":
89
+ if (fileSize > VIDEO_MAX_BYTES) {
90
+ return {
91
+ finalType: "file",
92
+ shouldReject: false,
93
+ downgraded: true,
94
+ downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
95
+ };
96
+ }
97
+ break;
98
+ case "voice":
99
+ if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
100
+ return {
101
+ finalType: "file",
102
+ shouldReject: false,
103
+ downgraded: true,
104
+ downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
105
+ };
106
+ }
107
+ if (fileSize > VOICE_MAX_BYTES) {
108
+ return {
109
+ finalType: "file",
110
+ shouldReject: false,
111
+ downgraded: true,
112
+ downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
113
+ };
114
+ }
115
+ break;
116
+ }
117
+
118
+ return { finalType: detectedType, shouldReject: false, downgraded: false };
119
+ }
120
+
121
+ async function resolveMediaFile(mediaUrl, mediaLocalRoots, includeDefaultMediaLocalRoots = true) {
122
+ const result = await loadOutboundMediaFromUrl(mediaUrl, {
123
+ maxBytes: ABSOLUTE_MAX_BYTES,
124
+ mediaLocalRoots,
125
+ includeDefaultMediaLocalRoots,
126
+ });
127
+
128
+ if (!result.buffer || result.buffer.length === 0) {
129
+ throw new Error(`Failed to load media from ${mediaUrl}: empty buffer`);
130
+ }
131
+
132
+ let contentType = result.contentType || "application/octet-stream";
133
+ if (contentType === "application/octet-stream" || contentType === "text/plain") {
134
+ const detected = await detectMime(result.buffer);
135
+ if (detected) contentType = detected;
136
+ }
137
+
138
+ return {
139
+ buffer: result.buffer,
140
+ contentType,
141
+ fileName: extractFileName(mediaUrl, result.fileName, contentType),
142
+ };
143
+ }
144
+
145
+ export function buildMediaErrorSummary(mediaUrl, result) {
146
+ if (result.error?.includes("LocalMediaAccessError")) {
147
+ return `文件发送失败:没有权限访问路径 ${mediaUrl}\n请在 openclaw.json 的 mediaLocalRoots 中添加该路径的父目录后重启生效。`;
148
+ }
149
+ if (result.rejectReason) {
150
+ return `文件发送失败:${result.rejectReason}`;
151
+ }
152
+ return `文件发送失败:无法处理文件 ${mediaUrl},请稍后再试。`;
153
+ }
154
+
155
+ export async function uploadAndSendMedia({
156
+ wsClient,
157
+ mediaUrl,
158
+ chatId,
159
+ mediaLocalRoots,
160
+ includeDefaultMediaLocalRoots = true,
161
+ log,
162
+ errorLog,
163
+ }) {
164
+ try {
165
+ log?.(`[wecom] Uploading media: url=${mediaUrl}`);
166
+ const media = await resolveMediaFile(mediaUrl, mediaLocalRoots, includeDefaultMediaLocalRoots);
167
+ const detectedType = detectWeComMediaType(media.contentType);
168
+ const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
169
+
170
+ if (sizeCheck.shouldReject) {
171
+ errorLog?.(`[wecom] Media rejected: ${sizeCheck.rejectReason}`);
172
+ return {
173
+ ok: false,
174
+ rejected: true,
175
+ rejectReason: sizeCheck.rejectReason,
176
+ finalType: sizeCheck.finalType,
177
+ };
178
+ }
179
+
180
+ const finalType = sizeCheck.finalType;
181
+ const uploadResult = await wsClient.uploadMedia(media.buffer, {
182
+ type: finalType,
183
+ filename: media.fileName,
184
+ });
185
+ log?.(`[wecom] Media uploaded: media_id=${uploadResult.media_id}, type=${finalType}`);
186
+
187
+ const result = await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id);
188
+ const messageId = result?.headers?.req_id ?? `wecom-media-${Date.now()}`;
189
+ log?.(`[wecom] Media sent via sendMediaMessage: chatId=${chatId}, type=${finalType}`);
190
+
191
+ return {
192
+ ok: true,
193
+ messageId,
194
+ finalType,
195
+ downgraded: sizeCheck.downgraded,
196
+ downgradeNote: sizeCheck.downgradeNote,
197
+ };
198
+ } catch (err) {
199
+ const errMsg = String(err);
200
+ errorLog?.(`[wecom] Failed to upload/send media: url=${mediaUrl}, error=${errMsg}`);
201
+ return { ok: false, error: errMsg };
202
+ }
203
+ }
204
+
205
+ export const mediaUploaderTesting = {
206
+ resolveMediaFile,
207
+ VOICE_SUPPORTED_MIMES,
208
+ };
@@ -0,0 +1,302 @@
1
+ import { basename, extname, join, parse, resolve } from "node:path";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { fileURLToPath } from "node:url";
4
+ import { readFile, realpath, stat } from "node:fs/promises";
5
+
6
+ const sdkReady = import("openclaw/plugin-sdk")
7
+ .then((sdk) => ({
8
+ loadOutboundMediaFromUrl:
9
+ typeof sdk.loadOutboundMediaFromUrl === "function" ? sdk.loadOutboundMediaFromUrl.bind(sdk) : undefined,
10
+ detectMime: typeof sdk.detectMime === "function" ? sdk.detectMime.bind(sdk) : undefined,
11
+ getDefaultMediaLocalRoots:
12
+ typeof sdk.getDefaultMediaLocalRoots === "function" ? sdk.getDefaultMediaLocalRoots.bind(sdk) : undefined,
13
+ }))
14
+ .catch(() => ({}));
15
+
16
+ const MIME_BY_EXT = {
17
+ ".aac": "audio/aac",
18
+ ".amr": "audio/amr",
19
+ ".avi": "video/x-msvideo",
20
+ ".bmp": "image/bmp",
21
+ ".csv": "text/csv",
22
+ ".doc": "application/msword",
23
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
24
+ ".gif": "image/gif",
25
+ ".gz": "application/gzip",
26
+ ".heic": "image/heic",
27
+ ".heif": "image/heif",
28
+ ".jpeg": "image/jpeg",
29
+ ".jpg": "image/jpeg",
30
+ ".json": "application/json",
31
+ ".m4a": "audio/x-m4a",
32
+ ".md": "text/markdown",
33
+ ".mov": "video/quicktime",
34
+ ".mp3": "audio/mpeg",
35
+ ".mp4": "video/mp4",
36
+ ".ogg": "audio/ogg",
37
+ ".pdf": "application/pdf",
38
+ ".png": "image/png",
39
+ ".ppt": "application/vnd.ms-powerpoint",
40
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
41
+ ".rar": "application/vnd.rar",
42
+ ".svg": "image/svg+xml",
43
+ ".tar": "application/x-tar",
44
+ ".txt": "text/plain",
45
+ ".wav": "audio/wav",
46
+ ".webm": "video/webm",
47
+ ".webp": "image/webp",
48
+ ".xls": "application/vnd.ms-excel",
49
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
50
+ ".zip": "application/zip",
51
+ ".7z": "application/x-7z-compressed",
52
+ };
53
+
54
+ function resolveUserPath(value) {
55
+ if (!value.startsWith("~")) {
56
+ return value;
57
+ }
58
+ return join(homedir(), value.slice(1));
59
+ }
60
+
61
+ function normalizeRootEntry(entry) {
62
+ const value = String(entry ?? "").trim();
63
+ if (!value) {
64
+ return null;
65
+ }
66
+ return resolve(resolveUserPath(value));
67
+ }
68
+
69
+ function normalizeMediaReference(mediaUrl) {
70
+ let value = String(mediaUrl ?? "").trim();
71
+ if (!value) {
72
+ return "";
73
+ }
74
+ value = value.replace(/^\s*(?:MEDIA|FILE)\s*:\s*/i, "");
75
+ if (value.startsWith("sandbox:")) {
76
+ value = value.replace(/^sandbox:\/{0,2}/, "");
77
+ if (!value.startsWith("/")) {
78
+ value = `/${value}`;
79
+ }
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function resolveStateDir() {
85
+ const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
86
+ if (override) {
87
+ return resolve(resolveUserPath(override));
88
+ }
89
+ if (process.env.VITEST || process.env.NODE_ENV === "test") {
90
+ return join(tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
91
+ }
92
+ return join(homedir(), ".openclaw");
93
+ }
94
+
95
+ async function sniffMimeFromBuffer(buffer) {
96
+ try {
97
+ const { fileTypeFromBuffer } = await import("file-type");
98
+ const type = await fileTypeFromBuffer(buffer);
99
+ return type?.mime ?? undefined;
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ async function detectMimeFallback(options) {
106
+ const ext = options.filePath ? extname(options.filePath).toLowerCase() : "";
107
+ const extMime = ext ? MIME_BY_EXT[ext] : undefined;
108
+ const sniffed = options.buffer ? await sniffMimeFromBuffer(options.buffer) : undefined;
109
+ const headerMime = options.headerMime?.split(";")?.[0]?.trim().toLowerCase();
110
+ const isGeneric = (value) => !value || value === "application/octet-stream" || value === "application/zip";
111
+
112
+ if (sniffed && (!isGeneric(sniffed) || !extMime)) {
113
+ return sniffed;
114
+ }
115
+ if (extMime) {
116
+ return extMime;
117
+ }
118
+ if (headerMime && !isGeneric(headerMime)) {
119
+ return headerMime;
120
+ }
121
+ return sniffed || headerMime || undefined;
122
+ }
123
+
124
+ function hasExplicitMediaRoots(options = {}) {
125
+ return Boolean(
126
+ (Array.isArray(options.mediaLocalRoots) && options.mediaLocalRoots.length > 0) ||
127
+ (Array.isArray(options.accountConfig?.mediaLocalRoots) && options.accountConfig.mediaLocalRoots.length > 0),
128
+ );
129
+ }
130
+
131
+ function isLocalMediaAccessError(error) {
132
+ const message = String(error?.message ?? error ?? "");
133
+ return /Local media path is not under an allowed directory|LocalMediaAccessError/i.test(message);
134
+ }
135
+
136
+ function shouldFallbackFromLocalAccessError(error, options) {
137
+ return isLocalMediaAccessError(error) && !hasExplicitMediaRoots(options);
138
+ }
139
+
140
+ async function readLocalMediaFile(filePath, { maxBytes } = {}) {
141
+ const info = await stat(filePath);
142
+ if (!info.isFile()) {
143
+ throw new Error(`Local media path is not a file: ${filePath}`);
144
+ }
145
+ const buffer = await readFile(filePath);
146
+ if (maxBytes && buffer.length > maxBytes) {
147
+ throw new Error(`Local media exceeds max size (${buffer.length} > ${maxBytes})`);
148
+ }
149
+ return {
150
+ buffer,
151
+ contentType: (await detectMimeFallback({ buffer, filePath })) || "",
152
+ fileName: basename(filePath) || "file",
153
+ };
154
+ }
155
+
156
+ async function fetchRemoteMedia(url, { maxBytes, fetchImpl } = {}) {
157
+ const response = await (fetchImpl ?? fetch)(url, { redirect: "follow" });
158
+ if (!response.ok) {
159
+ throw new Error(`failed to download media: ${response.status}`);
160
+ }
161
+ const buffer = Buffer.from(await response.arrayBuffer());
162
+ if (maxBytes && buffer.length > maxBytes) {
163
+ throw new Error(`Media from ${url} exceeds max size (${buffer.length} > ${maxBytes})`);
164
+ }
165
+ const disposition = response.headers.get("content-disposition");
166
+ let fileName = "";
167
+ if (disposition) {
168
+ const match = /filename\*?\s*=\s*(?:UTF-8''|")?([^";]+)/i.exec(disposition);
169
+ if (match?.[1]) {
170
+ try {
171
+ fileName = basename(decodeURIComponent(match[1].replace(/["']/g, "").trim()));
172
+ } catch {
173
+ fileName = basename(match[1].replace(/["']/g, "").trim());
174
+ }
175
+ }
176
+ }
177
+ if (!fileName) {
178
+ try {
179
+ fileName = basename(new URL(url).pathname) || "file";
180
+ } catch {
181
+ fileName = "file";
182
+ }
183
+ }
184
+ const headerMime = response.headers.get("content-type") || "";
185
+ return {
186
+ buffer,
187
+ contentType: (await detectMimeFallback({ buffer, headerMime, filePath: fileName || url })) || headerMime || "",
188
+ fileName,
189
+ };
190
+ }
191
+
192
+ function asLocalPath(mediaRef) {
193
+ if (!mediaRef) {
194
+ return "";
195
+ }
196
+ if (mediaRef.startsWith("file://")) {
197
+ return fileURLToPath(mediaRef);
198
+ }
199
+ if (mediaRef.startsWith("/") || mediaRef.startsWith("~")) {
200
+ return resolve(resolveUserPath(mediaRef));
201
+ }
202
+ return "";
203
+ }
204
+
205
+ export async function detectMime(bufferOrOptions) {
206
+ const sdk = await sdkReady;
207
+ const options = Buffer.isBuffer(bufferOrOptions) ? { buffer: bufferOrOptions } : bufferOrOptions;
208
+ if (sdk.detectMime) {
209
+ try {
210
+ return await sdk.detectMime(options);
211
+ } catch {}
212
+ }
213
+ return detectMimeFallback(options);
214
+ }
215
+
216
+ export async function getDefaultMediaLocalRoots() {
217
+ const sdk = await sdkReady;
218
+ if (sdk.getDefaultMediaLocalRoots) {
219
+ try {
220
+ return await sdk.getDefaultMediaLocalRoots();
221
+ } catch {}
222
+ }
223
+
224
+ const stateDir = resolveStateDir();
225
+ return [
226
+ join(stateDir, "media"),
227
+ join(stateDir, "agents"),
228
+ join(stateDir, "workspace"),
229
+ join(stateDir, "sandboxes"),
230
+ ];
231
+ }
232
+
233
+ export async function getExtendedMediaLocalRoots({
234
+ accountConfig,
235
+ mediaLocalRoots,
236
+ includeDefaultMediaLocalRoots = true,
237
+ } = {}) {
238
+ const defaults = includeDefaultMediaLocalRoots ? await getDefaultMediaLocalRoots() : [];
239
+ const roots = [
240
+ ...defaults,
241
+ ...(Array.isArray(accountConfig?.mediaLocalRoots) ? accountConfig.mediaLocalRoots : []),
242
+ ...(Array.isArray(mediaLocalRoots) ? mediaLocalRoots : []),
243
+ ]
244
+ .map(normalizeRootEntry)
245
+ .filter(Boolean);
246
+
247
+ return [...new Set(roots)];
248
+ }
249
+
250
+ export async function loadOutboundMediaFromUrl(mediaUrl, options = {}) {
251
+ const normalized = normalizeMediaReference(mediaUrl);
252
+ const filePath = asLocalPath(normalized);
253
+ const localRoots = await getExtendedMediaLocalRoots(options);
254
+ const sdk = await sdkReady;
255
+
256
+ if (filePath) {
257
+ if (typeof options.runtimeLoadMedia === "function" && localRoots.length > 0) {
258
+ try {
259
+ const loaded = await options.runtimeLoadMedia(filePath, { localRoots });
260
+ return {
261
+ buffer: loaded.buffer,
262
+ contentType: loaded.contentType || "",
263
+ fileName: loaded.fileName || basename(filePath) || "file",
264
+ };
265
+ } catch (error) {
266
+ if (!shouldFallbackFromLocalAccessError(error, options)) {
267
+ throw error;
268
+ }
269
+ }
270
+ }
271
+
272
+ if (sdk.loadOutboundMediaFromUrl) {
273
+ try {
274
+ return await sdk.loadOutboundMediaFromUrl(filePath, {
275
+ maxBytes: options.maxBytes,
276
+ mediaLocalRoots: localRoots,
277
+ });
278
+ } catch (error) {
279
+ if (!shouldFallbackFromLocalAccessError(error, options)) {
280
+ throw error;
281
+ }
282
+ }
283
+ }
284
+
285
+ return readLocalMediaFile(filePath, options);
286
+ }
287
+
288
+ if (sdk.loadOutboundMediaFromUrl && !options.fetchImpl) {
289
+ return sdk.loadOutboundMediaFromUrl(normalized, {
290
+ maxBytes: options.maxBytes,
291
+ mediaLocalRoots: localRoots,
292
+ });
293
+ }
294
+
295
+ return fetchRemoteMedia(normalized, options);
296
+ }
297
+
298
+ export { resolveStateDir };
299
+
300
+ export const openclawCompatTesting = {
301
+ normalizeMediaReference,
302
+ };