diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index 68acd303419..f98ac0c9ecc 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -25,6 +25,7 @@ export async function handleDiscordMessageAction( | "accountId" | "requesterSenderId" | "toolContext" + | "mediaAccess" | "mediaLocalRoots" | "mediaReadFile" >, @@ -32,6 +33,7 @@ export async function handleDiscordMessageAction( const { action, params, cfg } = ctx; const accountId = ctx.accountId ?? readStringParam(params, "accountId"); const actionOptions = { + mediaAccess: ctx.mediaAccess, mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile, } as const; diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 9e51cb5967a..ed6e5a99d74 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -172,6 +172,7 @@ export const discordOutbound: ChannelOutboundAdapter = { if (isFirst) { return await sendDiscordComponentMessage(target, componentSpec, { mediaUrl, + mediaAccess: ctx.mediaAccess, mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile, replyTo: ctx.replyToId ?? undefined, @@ -183,6 +184,7 @@ export const discordOutbound: ChannelOutboundAdapter = { return await send(target, text, { verbose: false, mediaUrl, + mediaAccess: ctx.mediaAccess, mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile, replyTo: ctx.replyToId ?? undefined, diff --git a/extensions/discord/src/runtime-api.ts b/extensions/discord/src/runtime-api.ts index d64de8d3168..1222b320c44 100644 --- a/extensions/discord/src/runtime-api.ts +++ b/extensions/discord/src/runtime-api.ts @@ -40,6 +40,7 @@ export { createAccountListHelpers, } from "openclaw/plugin-sdk/account-helpers"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/discord"; export { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; export type { ChannelMessageActionAdapter, diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index 7a2e821da23..319f59d8f36 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -7,7 +7,6 @@ import { import { ChannelType, Routes } from "discord-api-types/v10"; import { recordChannelActivity } from "openclaw/plugin-sdk/channel-runtime"; import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; import { registerDiscordComponentEntries } from "./components-registry.js"; import { @@ -17,6 +16,7 @@ import { type DiscordComponentBuildResult, type DiscordComponentMessageSpec, } from "./components.js"; +import { loadOutboundMediaFromUrl } from "./runtime-api.js"; import { buildDiscordSendError, createDiscordClient, @@ -51,6 +51,10 @@ type DiscordComponentSendOpts = { sessionKey?: string; agentId?: string; mediaUrl?: string; + mediaAccess?: { + localRoots?: readonly string[]; + readFile?: (filePath: string) => Promise; + }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; filename?: string; @@ -99,10 +103,10 @@ async function buildDiscordComponentPayload(params: { const expectedAttachmentName = uniqueAttachmentNames[0]; let files: MessagePayloadFile[] | undefined; if (params.opts.mediaUrl) { - const media = await loadWebMedia(params.opts.mediaUrl, { - localRoots: params.opts.mediaLocalRoots, - readFile: params.opts.mediaReadFile, - hostReadCapability: Boolean(params.opts.mediaReadFile), + const media = await loadOutboundMediaFromUrl(params.opts.mediaUrl, { + mediaAccess: params.opts.mediaAccess, + mediaLocalRoots: params.opts.mediaLocalRoots, + mediaReadFile: params.opts.mediaReadFile, }); const filenameOverride = params.opts.filename?.trim(); const fileName = filenameOverride || media.fileName || "upload"; diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index 27d466a5c61..352bed4a6ba 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -49,6 +49,10 @@ type DiscordSendOpts = { accountId?: string; mediaUrl?: string; filename?: string; + mediaAccess?: { + localRoots?: readonly string[]; + readFile?: (filePath: string) => Promise; + }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; verbose?: boolean; diff --git a/extensions/googlechat/src/actions.ts b/extensions/googlechat/src/actions.ts index 62cce73e510..647c344a106 100644 --- a/extensions/googlechat/src/actions.ts +++ b/extensions/googlechat/src/actions.ts @@ -7,6 +7,7 @@ import { createActionGate, extractToolSend, jsonResult, + loadOutboundMediaFromUrl, readNumberParam, readReactionParams, readStringParam, @@ -53,6 +54,10 @@ function resolveAppUserNames(account: { config: { botUser?: string | null } }) { async function loadGoogleChatActionMedia(params: { mediaUrl: string; maxBytes: number; + mediaAccess?: { + localRoots?: readonly string[]; + readFile?: (filePath: string) => Promise; + }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; }) { @@ -62,11 +67,11 @@ async function loadGoogleChatActionMedia(params: { url: params.mediaUrl, maxBytes: params.maxBytes, }) - : await runtime.media.loadWebMedia(params.mediaUrl, { + : await loadOutboundMediaFromUrl(params.mediaUrl, { maxBytes: params.maxBytes, - localRoots: params.mediaLocalRoots?.length ? params.mediaLocalRoots : undefined, - readFile: params.mediaReadFile, - hostReadCapability: Boolean(params.mediaReadFile), + mediaAccess: params.mediaAccess, + mediaLocalRoots: params.mediaLocalRoots, + mediaReadFile: params.mediaReadFile, }); } @@ -88,7 +93,15 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = { extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, - handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, mediaReadFile }) => { + handleAction: async ({ + action, + params, + cfg, + accountId, + mediaAccess, + mediaLocalRoots, + mediaReadFile, + }) => { const account = resolveGoogleChatAccount({ cfg: cfg, accountId, @@ -120,6 +133,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = { const loaded = await loadGoogleChatActionMedia({ mediaUrl, maxBytes, + mediaAccess, mediaLocalRoots, mediaReadFile, }); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 63bd8238203..54476d4fc5b 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -28,10 +28,10 @@ import { DEFAULT_ACCOUNT_ID, createAccountStatusSink, getChatChannelMeta, + loadOutboundMediaFromUrl, missingTargetError, PAIRING_APPROVED_MESSAGE, fetchRemoteMedia, - loadWebMedia, resolveChannelMediaMaxBytes, runPassiveAccountLifecycle, type ChannelMessageActionAdapter, @@ -401,6 +401,7 @@ export const googlechatPlugin = createChatChannelPlugin({ to, text, mediaUrl, + mediaAccess, mediaLocalRoots, mediaReadFile, accountId, @@ -433,11 +434,11 @@ export const googlechatPlugin = createChatChannelPlugin({ url: mediaUrl, maxBytes: effectiveMaxBytes, }) - : await loadWebMedia(mediaUrl, { + : await loadOutboundMediaFromUrl(mediaUrl, { maxBytes: effectiveMaxBytes, - localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, - readFile: mediaReadFile, - hostReadCapability: Boolean(mediaReadFile), + mediaAccess, + mediaLocalRoots, + mediaReadFile, }); const { sendGoogleChatMessage, uploadGoogleChatAttachment } = await loadGoogleChatChannelRuntime(); diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index e742f154558..bf14631ef96 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -1,4 +1,8 @@ -import type { MarkdownTableMode, PollInput } from "../runtime-api.js"; +import { + loadOutboundMediaFromUrl, + type MarkdownTableMode, + type PollInput, +} from "../runtime-api.js"; import { getMatrixRuntime } from "../runtime.js"; import type { CoreConfig } from "../types.js"; import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; @@ -163,11 +167,11 @@ export async function sendMessageMatrix( let lastMessageId = ""; if (opts.mediaUrl) { const maxBytes = resolveMediaMaxBytes(opts.accountId, cfg); - const media = await getCore().media.loadWebMedia(opts.mediaUrl, { + const media = await loadOutboundMediaFromUrl(opts.mediaUrl, { maxBytes, - localRoots: opts.mediaLocalRoots, - readFile: opts.mediaReadFile, - hostReadCapability: Boolean(opts.mediaReadFile), + mediaAccess: opts.mediaAccess, + mediaLocalRoots: opts.mediaLocalRoots, + mediaReadFile: opts.mediaReadFile, }); const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { contentType: media.contentType, diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index 46c79da6282..5d63d42dd93 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -88,6 +88,10 @@ export type MatrixSendOpts = { client?: import("../sdk.js").MatrixClient; cfg?: CoreConfig; mediaUrl?: string; + mediaAccess?: { + localRoots?: readonly string[]; + readFile?: (filePath: string) => Promise; + }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; accountId?: string; diff --git a/extensions/signal/src/send.ts b/extensions/signal/src/send.ts index 0fed9cee8c7..19777a0a269 100644 --- a/extensions/signal/src/send.ts +++ b/extensions/signal/src/send.ts @@ -13,6 +13,10 @@ export type SignalSendOpts = { account?: string; accountId?: string; mediaUrl?: string; + mediaAccess?: { + localRoots?: readonly string[]; + readFile?: (filePath: string) => Promise; + }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; maxBytes?: number; @@ -129,6 +133,7 @@ export async function sendMessageSignal( let attachments: string[] | undefined; if (opts.mediaUrl?.trim()) { const resolved = await resolveOutboundAttachmentFromUrl(opts.mediaUrl.trim(), maxBytes, { + mediaAccess: opts.mediaAccess, localRoots: opts.mediaLocalRoots, readFile: opts.mediaReadFile, }); diff --git a/extensions/slack/src/actions.ts b/extensions/slack/src/actions.ts index 4620319f696..72790b50cc8 100644 --- a/extensions/slack/src/actions.ts +++ b/extensions/slack/src/actions.ts @@ -159,6 +159,10 @@ export async function sendSlackMessage( content: string, opts: SlackActionClientOpts & { mediaUrl?: string; + mediaAccess?: { + localRoots?: readonly string[]; + readFile?: (filePath: string) => Promise; + }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; threadTs?: string; @@ -171,6 +175,7 @@ export async function sendSlackMessage( accountId: opts.accountId, token: opts.token, mediaUrl: opts.mediaUrl, + mediaAccess: opts.mediaAccess, mediaLocalRoots: opts.mediaLocalRoots, mediaReadFile: opts.mediaReadFile, client: opts.client, diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index 64f09b545a4..f27519b8c25 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -82,6 +82,10 @@ async function sendSlackOutboundMessage(params: { to: string; text: string; mediaUrl?: string; + mediaAccess?: { + localRoots?: readonly string[]; + readFile?: (filePath: string) => Promise; + }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; blocks?: NonNullable[2]>["blocks"]; @@ -118,6 +122,7 @@ async function sendSlackOutboundMessage(params: { ...(params.mediaUrl ? { mediaUrl: params.mediaUrl, + mediaAccess: params.mediaAccess, mediaLocalRoots: params.mediaLocalRoots, mediaReadFile: params.mediaReadFile, } @@ -188,6 +193,7 @@ export const slackOutbound: ChannelOutboundAdapter = { to: ctx.to, text, mediaUrl, + mediaAccess: ctx.mediaAccess, mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile, accountId: ctx.accountId, @@ -201,6 +207,7 @@ export const slackOutbound: ChannelOutboundAdapter = { cfg: ctx.cfg, to: ctx.to, text: payload.text ?? "", + mediaAccess: ctx.mediaAccess, mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile, blocks, @@ -231,6 +238,7 @@ export const slackOutbound: ChannelOutboundAdapter = { to, text, mediaUrl, + mediaAccess, mediaLocalRoots, mediaReadFile, accountId, @@ -244,6 +252,7 @@ export const slackOutbound: ChannelOutboundAdapter = { to, text, mediaUrl, + mediaAccess, mediaLocalRoots, mediaReadFile, accountId, diff --git a/extensions/slack/src/runtime-api.ts b/extensions/slack/src/runtime-api.ts index 23f3db26a83..6ad0e913310 100644 --- a/extensions/slack/src/runtime-api.ts +++ b/extensions/slack/src/runtime-api.ts @@ -5,6 +5,7 @@ export { resolveConfiguredFromRequiredCredentialStatuses, } from "openclaw/plugin-sdk/channel-status"; export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/slack"; export { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./targets.js"; export type { ChannelPlugin, OpenClawConfig, SlackAccountConfig } from "openclaw/plugin-sdk/slack"; export { diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index 48674cde158..6cbcf217fc8 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -11,7 +11,6 @@ import { import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; @@ -19,6 +18,7 @@ import { validateSlackBlocksArray } from "./blocks-input.js"; import { createSlackWebClient } from "./client.js"; import { markdownToSlackMrkdwnChunks } from "./format.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; +import { loadOutboundMediaFromUrl } from "./runtime-api.js"; import { parseSlackTarget } from "./targets.js"; import { resolveSlackBotToken } from "./token.js"; const SLACK_UPLOAD_SSRF_POLICY = { @@ -49,6 +49,10 @@ type SlackSendOpts = { token?: string; accountId?: string; mediaUrl?: string; + mediaAccess?: { + localRoots?: readonly string[]; + readFile?: (filePath: string) => Promise; + }; uploadFileName?: string; uploadTitle?: string; mediaLocalRoots?: readonly string[]; @@ -231,6 +235,10 @@ async function uploadSlackFile(params: { client: WebClient; channelId: string; mediaUrl: string; + mediaAccess?: { + localRoots?: readonly string[]; + readFile?: (filePath: string) => Promise; + }; uploadFileName?: string; uploadTitle?: string; mediaLocalRoots?: readonly string[]; @@ -239,11 +247,11 @@ async function uploadSlackFile(params: { threadTs?: string; maxBytes?: number; }): Promise { - const { buffer, contentType, fileName } = await loadWebMedia(params.mediaUrl, { + const { buffer, contentType, fileName } = await loadOutboundMediaFromUrl(params.mediaUrl, { maxBytes: params.maxBytes, - localRoots: params.mediaLocalRoots, - readFile: params.mediaReadFile, - hostReadCapability: Boolean(params.mediaReadFile), + mediaAccess: params.mediaAccess, + mediaLocalRoots: params.mediaLocalRoots, + mediaReadFile: params.mediaReadFile, }); const uploadFileName = params.uploadFileName ?? fileName ?? "upload"; const uploadTitle = params.uploadTitle ?? uploadFileName; @@ -373,6 +381,7 @@ export async function sendMessageSlack( client, channelId, mediaUrl: opts.mediaUrl, + mediaAccess: opts.mediaAccess, uploadFileName: opts.uploadFileName, uploadTitle: opts.uploadTitle, mediaLocalRoots: opts.mediaLocalRoots, diff --git a/extensions/whatsapp/src/runtime-api.ts b/extensions/whatsapp/src/runtime-api.ts index 7fe1dc6de8e..fb6b4e5bce6 100644 --- a/extensions/whatsapp/src/runtime-api.ts +++ b/extensions/whatsapp/src/runtime-api.ts @@ -29,6 +29,7 @@ export { type GroupPolicy, type WhatsAppAccountConfig, } from "openclaw/plugin-sdk/whatsapp-shared"; +export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/whatsapp"; export { isWhatsAppGroupJid, isWhatsAppUserTarget, diff --git a/extensions/whatsapp/src/send.ts b/extensions/whatsapp/src/send.ts index 729121cbe73..481db264345 100644 --- a/extensions/whatsapp/src/send.ts +++ b/extensions/whatsapp/src/send.ts @@ -10,7 +10,7 @@ import { markdownToWhatsApp } from "openclaw/plugin-sdk/text-runtime"; import { toWhatsappJid } from "openclaw/plugin-sdk/text-runtime"; import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "./accounts.js"; import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; -import { loadWebMedia } from "./media.js"; +import { loadOutboundMediaFromUrl } from "./runtime-api.js"; const outboundLog = createSubsystemLogger("gateway/channels/whatsapp").child("outbound"); @@ -21,6 +21,10 @@ export async function sendMessageWhatsApp( verbose: boolean; cfg?: OpenClawConfig; mediaUrl?: string; + mediaAccess?: { + localRoots?: readonly string[]; + readFile?: (filePath: string) => Promise; + }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; gifPlayback?: boolean; @@ -61,11 +65,11 @@ export async function sendMessageWhatsApp( let mediaType: string | undefined; let documentFileName: string | undefined; if (options.mediaUrl) { - const media = await loadWebMedia(options.mediaUrl, { + const media = await loadOutboundMediaFromUrl(options.mediaUrl, { maxBytes: resolveWhatsAppMediaMaxBytes(account), - localRoots: options.mediaLocalRoots, - readFile: options.mediaReadFile, - hostReadCapability: Boolean(options.mediaReadFile), + mediaAccess: options.mediaAccess, + mediaLocalRoots: options.mediaLocalRoots, + mediaReadFile: options.mediaReadFile, }); const caption = text || undefined; mediaBuffer = media.buffer; diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index 8824cfa1b50..64392671ea9 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -8,6 +8,7 @@ import { import { chunkText } from "../../../auto-reply/chunk.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; +import type { OutboundMediaAccess } from "../../../media/load-options.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js"; import type { ChannelOutboundAdapter } from "../types.js"; @@ -16,6 +17,7 @@ type DirectSendOptions = { accountId?: string | null; replyToId?: string | null; mediaUrl?: string; + mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; maxBytes?: number; @@ -80,8 +82,7 @@ export function createDirectTextMediaOutbound< deps?: OutboundSendDeps; replyToId?: string | null; mediaUrl?: string; - mediaLocalRoots?: readonly string[]; - mediaReadFile?: (filePath: string) => Promise; + mediaAccess?: OutboundMediaAccess; buildOptions: (params: DirectSendOptions) => TOpts; }) => { const send = params.resolveSender(sendParams.deps); @@ -95,8 +96,9 @@ export function createDirectTextMediaOutbound< sendParams.buildOptions({ cfg: sendParams.cfg, mediaUrl: sendParams.mediaUrl, - mediaLocalRoots: sendParams.mediaLocalRoots, - mediaReadFile: sendParams.mediaReadFile, + mediaAccess: sendParams.mediaAccess, + mediaLocalRoots: sendParams.mediaAccess?.localRoots, + mediaReadFile: sendParams.mediaAccess?.readFile, accountId: sendParams.accountId, replyToId: sendParams.replyToId, maxBytes, @@ -128,6 +130,7 @@ export function createDirectTextMediaOutbound< to, text, mediaUrl, + mediaAccess, mediaLocalRoots, mediaReadFile, accountId, @@ -139,8 +142,14 @@ export function createDirectTextMediaOutbound< to, text, mediaUrl, - mediaLocalRoots, - mediaReadFile, + mediaAccess: + mediaAccess ?? + (mediaLocalRoots || mediaReadFile + ? { + ...(mediaLocalRoots?.length ? { localRoots: mediaLocalRoots } : {}), + ...(mediaReadFile ? { readFile: mediaReadFile } : {}), + } + : undefined), accountId, deps, replyToId, diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 41ea098e988..191c1c54cd7 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -9,6 +9,7 @@ import type { PluginApprovalRequest, PluginApprovalResolved, } from "../../infra/plugin-approvals.js"; +import type { OutboundMediaAccess } from "../../media/load-options.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { ConfigWriteTarget } from "./config-writes.js"; @@ -137,6 +138,7 @@ export type ChannelOutboundContext = { text: string; mediaUrl?: string; audioAsVoice?: boolean; + mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; gifPlayback?: boolean; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 23d8609ca23..704ebca0387 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -3,6 +3,7 @@ import type { TSchema } from "@sinclair/typebox"; import type { MsgContext } from "../../auto-reply/templating.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { OutboundMediaAccess } from "../../media/load-options.js"; import type { PollInput } from "../../polls.js"; import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js"; import type { ChatType } from "../chat-type.js"; @@ -494,6 +495,7 @@ export type ChannelMessageActionContext = { action: ChannelMessageActionName; cfg: OpenClawConfig; params: Record; + mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; accountId?: string | null; diff --git a/src/channels/plugins/whatsapp-shared.ts b/src/channels/plugins/whatsapp-shared.ts index 7e896996693..362d97109ba 100644 --- a/src/channels/plugins/whatsapp-shared.ts +++ b/src/channels/plugins/whatsapp-shared.ts @@ -29,6 +29,10 @@ type WhatsAppSendMessage = ( verbose: boolean; cfg?: OpenClawConfig; mediaUrl?: string; + mediaAccess?: { + localRoots?: readonly string[]; + readFile?: (filePath: string) => Promise; + }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; gifPlayback?: boolean; @@ -99,6 +103,7 @@ export function createWhatsAppOutboundBase({ to, text, mediaUrl, + mediaAccess, mediaLocalRoots, mediaReadFile, accountId, @@ -111,6 +116,7 @@ export function createWhatsAppOutboundBase({ verbose: false, cfg, mediaUrl, + mediaAccess, mediaLocalRoots, mediaReadFile, accountId: accountId ?? undefined, diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index fc9ecae6f42..f37d15f3e5d 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -7,11 +7,7 @@ import type { CronJob } from "../../cron/types.js"; import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; -import { - normalizeHookDispatchSessionKey, - type HookAgentDispatchPayload, - type HooksConfigResolved, -} from "../hooks.js"; +import { type HookAgentDispatchPayload, type HooksConfigResolved } from "../hooks.js"; import { createHooksRequestHandler, type HookClientIpConfig } from "../server-http.js"; type SubsystemLogger = ReturnType; diff --git a/src/gateway/server/plugins-http.runtime-scopes.test.ts b/src/gateway/server/plugins-http.runtime-scopes.test.ts index f66ae294af2..db6d7334237 100644 --- a/src/gateway/server/plugins-http.runtime-scopes.test.ts +++ b/src/gateway/server/plugins-http.runtime-scopes.test.ts @@ -28,6 +28,7 @@ function createRoute(params: { } function createMockLogger(): SubsystemLogger { + const child = vi.fn<(name: string) => SubsystemLogger>(); const logger = { subsystem: "test/plugins-http-runtime-scopes", isEnabled: () => true, @@ -38,10 +39,10 @@ function createMockLogger(): SubsystemLogger { error: vi.fn(), fatal: vi.fn(), raw: vi.fn(), - child: vi.fn(), - } satisfies Omit & { child: ReturnType }; - logger.child.mockImplementation(() => logger); - return logger; + child, + } satisfies SubsystemLogger; + child.mockImplementation(() => logger); + return logger as SubsystemLogger; } function assertWriteHelperAllowed() { diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 31762d48640..0e6cf93d32b 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -29,8 +29,8 @@ import { } from "../../hooks/message-hook-mappers.js"; import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { getAgentScopedMediaLocalRootsForSources } from "../../media/local-roots.js"; -import { createAgentScopedHostMediaReadFile } from "../../media/read-capability.js"; +import type { OutboundMediaAccess } from "../../media/load-options.js"; +import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { throwIfAborted } from "./abort.js"; import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; @@ -130,8 +130,7 @@ type ChannelHandlerParams = { gifPlayback?: boolean; forceDocument?: boolean; silent?: boolean; - mediaLocalRoots?: readonly string[]; - mediaReadFile?: (filePath: string) => Promise; + mediaAccess?: OutboundMediaAccess; gatewayClientScopes?: readonly string[]; }; @@ -252,8 +251,9 @@ function createChannelOutboundContextBase( forceDocument: params.forceDocument, deps: params.deps, silent: params.silent, - mediaLocalRoots: params.mediaLocalRoots, - mediaReadFile: params.mediaReadFile, + mediaAccess: params.mediaAccess, + mediaLocalRoots: params.mediaAccess?.localRoots, + mediaReadFile: params.mediaAccess?.readFile, gatewayClientScopes: params.gatewayClientScopes, }; } @@ -564,15 +564,11 @@ async function deliverOutboundPayloadsCore( const accountId = params.accountId; const deps = params.deps; const abortSignal = params.abortSignal; - const mediaLocalRoots = getAgentScopedMediaLocalRootsForSources({ + const mediaAccess = resolveAgentScopedOutboundMediaAccess({ cfg, agentId: params.session?.agentId ?? params.mirror?.agentId, mediaSources: collectPayloadMediaSources(payloads), }); - const mediaReadFile = createAgentScopedHostMediaReadFile({ - cfg, - agentId: params.session?.agentId ?? params.mirror?.agentId, - }); const results: OutboundDeliveryResult[] = []; const handler = await createChannelHandler({ cfg, @@ -586,8 +582,7 @@ async function deliverOutboundPayloadsCore( gifPlayback: params.gifPlayback, forceDocument: params.forceDocument, silent: params.silent, - mediaLocalRoots, - mediaReadFile, + mediaAccess, gatewayClientScopes: params.gatewayClientScopes, }); const configuredTextLimit = handler.chunker diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index e40a9fe89a7..707f553f396 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -4,6 +4,12 @@ import type { ChannelId, ChannelMessageActionName } from "../../channels/plugins import type { OpenClawConfig } from "../../config/config.js"; import { createRootScopedReadFile } from "../../infra/fs-safe.js"; import { basenameFromMediaSource } from "../../infra/local-file-access.js"; +import { + buildOutboundMediaLoadOptions, + resolveOutboundMediaAccess, + type OutboundMediaAccess, + type OutboundMediaReadFile, +} from "../../media/load-options.js"; import { extensionForMime } from "../../media/mime.js"; import { loadWebMedia } from "../../media/web-media.js"; import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boolean-param.js"; @@ -101,14 +107,14 @@ export type AttachmentMediaPolicy = } | { mode: "host"; - localRoots?: readonly string[]; - readFile?: (filePath: string) => Promise; + mediaAccess?: OutboundMediaAccess; }; export function resolveAttachmentMediaPolicy(params: { sandboxRoot?: string; + mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[]; - mediaReadFile?: (filePath: string) => Promise; + mediaReadFile?: OutboundMediaReadFile; }): AttachmentMediaPolicy { const sandboxRoot = params.sandboxRoot?.trim(); if (sandboxRoot) { @@ -119,8 +125,11 @@ export function resolveAttachmentMediaPolicy(params: { } return { mode: "host", - localRoots: params.mediaLocalRoots, - readFile: params.mediaReadFile, + mediaAccess: resolveOutboundMediaAccess({ + mediaAccess: params.mediaAccess, + mediaLocalRoots: params.mediaLocalRoots, + mediaReadFile: params.mediaReadFile, + }), }; } @@ -136,7 +145,7 @@ function buildAttachmentMediaLoadOptions(params: { | { maxBytes?: number; localRoots?: readonly string[] | "any"; - readFile?: (filePath: string) => Promise; + readFile?: OutboundMediaReadFile; hostReadCapability?: boolean; } { if (params.policy.mode === "sandbox") { @@ -149,16 +158,10 @@ function buildAttachmentMediaLoadOptions(params: { readFile: readSandboxFile, }; } - return { + return buildOutboundMediaLoadOptions({ maxBytes: params.maxBytes, - ...(params.policy.readFile - ? { - localRoots: "any" as const, - readFile: params.policy.readFile, - hostReadCapability: true, - } - : { localRoots: params.policy.localRoots }), - }; + mediaAccess: params.policy.mediaAccess, + }); } async function hydrateAttachmentPayload(params: { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index a3481a8173d..1bd09942f93 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -15,11 +15,9 @@ import type { } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { hasInteractiveReplyBlocks, hasReplyPayloadContent } from "../../interactive/payload.js"; -import { - getAgentScopedMediaLocalRoots, - getAgentScopedMediaLocalRootsForSources, -} from "../../media/local-roots.js"; -import { createAgentScopedHostMediaReadFile } from "../../media/read-capability.js"; +import type { OutboundMediaAccess } from "../../media/load-options.js"; +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; +import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js"; import { hasPollCreationParams } from "../../poll-params.js"; import { resolvePollMaxSelections } from "../../polls.js"; import { buildChannelAccountBindings } from "../../routing/bindings.js"; @@ -273,8 +271,7 @@ type ResolvedActionContext = { cfg: OpenClawConfig; params: Record; channel: ChannelId; - mediaLocalRoots: readonly string[]; - mediaReadFile?: (filePath: string) => Promise; + mediaAccess: OutboundMediaAccess; accountId?: string | null; dryRun: boolean; gateway?: MessageActionRunnerGateway; @@ -520,7 +517,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise ({ - getDefaultMediaLocalRoots: vi.fn(() => []), - dispatchChannelMessageAction: vi.fn(), - sendMessage: vi.fn(), - sendPoll: vi.fn(), - getAgentScopedMediaLocalRootsForSources: vi.fn(() => ["/tmp/agent-roots"]), - createAgentScopedHostMediaReadFile: vi.fn(() => async () => Buffer.from("capability")), - appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), -})); +const getDefaultMediaLocalRootsMock = vi.hoisted(() => vi.fn(() => [])); +const dispatchChannelMessageActionMock = vi.hoisted(() => vi.fn()); +const sendMessageMock = vi.hoisted(() => vi.fn()); +const sendPollMock = vi.hoisted(() => vi.fn()); +const getAgentScopedMediaLocalRootsForSourcesMock = vi.hoisted(() => + vi.fn<(params: { cfg: unknown; agentId?: string; mediaSources?: readonly string[] }) => string[]>( + () => ["/tmp/agent-roots"], + ), +); +const createAgentScopedHostMediaReadFileMock = vi.hoisted(() => + vi.fn<(params: { cfg: unknown; agentId?: string }) => (filePath: string) => Promise>( + () => async () => Buffer.from("capability"), + ), +); +const resolveAgentScopedOutboundMediaAccessMock = vi.hoisted(() => + vi.fn< + (params: { cfg: unknown; agentId?: string; mediaSources?: readonly string[] }) => { + localRoots: string[]; + readFile: (filePath: string) => Promise; + } + >((params) => ({ + localRoots: getAgentScopedMediaLocalRootsForSourcesMock({ + cfg: params.cfg, + agentId: params.agentId, + mediaSources: params.mediaSources ?? [], + }), + readFile: createAgentScopedHostMediaReadFileMock({ + cfg: params.cfg, + agentId: params.agentId, + }), + })), +); +const appendAssistantMessageToSessionTranscriptMock = vi.hoisted(() => + vi.fn(async () => ({ ok: true, sessionFile: "x" })), +); + +const mocks = { + getDefaultMediaLocalRoots: getDefaultMediaLocalRootsMock, + dispatchChannelMessageAction: dispatchChannelMessageActionMock, + sendMessage: sendMessageMock, + sendPoll: sendPollMock, + getAgentScopedMediaLocalRootsForSources: getAgentScopedMediaLocalRootsForSourcesMock, + createAgentScopedHostMediaReadFile: createAgentScopedHostMediaReadFileMock, + resolveAgentScopedOutboundMediaAccess: resolveAgentScopedOutboundMediaAccessMock, + appendAssistantMessageToSessionTranscript: appendAssistantMessageToSessionTranscriptMock, +}; vi.mock("../../channels/plugins/message-action-dispatch.js", () => ({ dispatchChannelMessageAction: mocks.dispatchChannelMessageAction, @@ -22,6 +59,7 @@ vi.mock("./message.js", () => ({ vi.mock("../../media/read-capability.js", () => ({ createAgentScopedHostMediaReadFile: mocks.createAgentScopedHostMediaReadFile, + resolveAgentScopedOutboundMediaAccess: mocks.resolveAgentScopedOutboundMediaAccess, })); vi.mock("../../media/local-roots.js", async (importOriginal) => { @@ -105,6 +143,7 @@ describe("executeSendAction", () => { mocks.getDefaultMediaLocalRoots.mockClear(); mocks.getAgentScopedMediaLocalRootsForSources.mockClear(); mocks.createAgentScopedHostMediaReadFile.mockClear(); + mocks.resolveAgentScopedOutboundMediaAccess.mockClear(); mocks.appendAssistantMessageToSessionTranscript.mockClear(); }); diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index d6c953d5b67..9024a9caaee 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -3,8 +3,8 @@ import { dispatchChannelMessageAction } from "../../channels/plugins/message-act import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js"; -import { getAgentScopedMediaLocalRootsForSources } from "../../media/local-roots.js"; -import { createAgentScopedHostMediaReadFile } from "../../media/read-capability.js"; +import type { OutboundMediaAccess, OutboundMediaReadFile } from "../../media/load-options.js"; +import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js"; import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js"; import { throwIfAborted } from "./abort.js"; import type { OutboundSendDeps } from "./deliver.js"; @@ -28,7 +28,8 @@ export type OutboundSendContext = { params: Record; /** Active agent id for per-agent outbound media root scoping. */ agentId?: string; - mediaReadFile?: (filePath: string) => Promise; + mediaAccess?: OutboundMediaAccess; + mediaReadFile?: OutboundMediaReadFile; accountId?: string | null; gateway?: OutboundGatewayContext; toolContext?: ChannelThreadingToolContext; @@ -64,24 +65,21 @@ async function tryHandleWithPluginAction(params: { if (params.ctx.dryRun) { return null; } - const mediaLocalRoots = getAgentScopedMediaLocalRootsForSources({ + const mediaAccess = resolveAgentScopedOutboundMediaAccess({ cfg: params.ctx.cfg, agentId: params.ctx.agentId ?? params.ctx.mirror?.agentId, mediaSources: collectActionMediaSources(params.ctx.params), + mediaAccess: params.ctx.mediaAccess, + mediaReadFile: params.ctx.mediaReadFile, }); - const mediaReadFile = - params.ctx.mediaReadFile ?? - createAgentScopedHostMediaReadFile({ - cfg: params.ctx.cfg, - agentId: params.ctx.agentId ?? params.ctx.mirror?.agentId, - }); const handled = await dispatchChannelMessageAction({ channel: params.ctx.channel, action: params.action, cfg: params.ctx.cfg, params: params.ctx.params, - mediaLocalRoots, - mediaReadFile, + mediaAccess, + mediaLocalRoots: mediaAccess.localRoots, + mediaReadFile: mediaAccess.readFile, accountId: params.ctx.accountId ?? undefined, gateway: params.ctx.gateway, toolContext: params.ctx.toolContext, diff --git a/src/media/load-options.ts b/src/media/load-options.ts index 08cead669fc..ff30d7c3e4b 100644 --- a/src/media/load-options.ts +++ b/src/media/load-options.ts @@ -1,7 +1,15 @@ +export type OutboundMediaReadFile = (filePath: string) => Promise; + +export type OutboundMediaAccess = { + localRoots?: readonly string[]; + readFile?: OutboundMediaReadFile; +}; + export type OutboundMediaLoadParams = { maxBytes?: number; + mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[]; - mediaReadFile?: (filePath: string) => Promise; + mediaReadFile?: OutboundMediaReadFile; optimizeImages?: boolean; }; @@ -19,19 +27,40 @@ export function resolveOutboundMediaLocalRoots( return mediaLocalRoots && mediaLocalRoots.length > 0 ? mediaLocalRoots : undefined; } +export function resolveOutboundMediaAccess( + params: { + mediaAccess?: OutboundMediaAccess; + mediaLocalRoots?: readonly string[]; + mediaReadFile?: OutboundMediaReadFile; + } = {}, +): OutboundMediaAccess | undefined { + const localRoots = resolveOutboundMediaLocalRoots( + params.mediaAccess?.localRoots ?? params.mediaLocalRoots, + ); + const readFile = params.mediaAccess?.readFile ?? params.mediaReadFile; + if (!localRoots && !readFile) { + return undefined; + } + return { + ...(localRoots ? { localRoots } : {}), + ...(readFile ? { readFile } : {}), + }; +} + export function buildOutboundMediaLoadOptions( params: OutboundMediaLoadParams = {}, ): OutboundMediaLoadOptions { - if (params.mediaReadFile) { + const mediaAccess = resolveOutboundMediaAccess(params); + if (mediaAccess?.readFile) { return { ...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}), localRoots: "any", - readFile: params.mediaReadFile, + readFile: mediaAccess.readFile, hostReadCapability: true, ...(params.optimizeImages !== undefined ? { optimizeImages: params.optimizeImages } : {}), }; } - const localRoots = resolveOutboundMediaLocalRoots(params.mediaLocalRoots); + const localRoots = mediaAccess?.localRoots; return { ...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}), ...(localRoots ? { localRoots } : {}), diff --git a/src/media/outbound-attachment.ts b/src/media/outbound-attachment.ts index 1df02b29a9f..ac303b07d74 100644 --- a/src/media/outbound-attachment.ts +++ b/src/media/outbound-attachment.ts @@ -1,4 +1,4 @@ -import { buildOutboundMediaLoadOptions } from "./load-options.js"; +import { buildOutboundMediaLoadOptions, type OutboundMediaAccess } from "./load-options.js"; import { saveMediaBuffer } from "./store.js"; import { loadWebMedia } from "./web-media.js"; @@ -6,6 +6,7 @@ export async function resolveOutboundAttachmentFromUrl( mediaUrl: string, maxBytes: number, options?: { + mediaAccess?: OutboundMediaAccess; localRoots?: readonly string[]; readFile?: (filePath: string) => Promise; }, @@ -14,6 +15,7 @@ export async function resolveOutboundAttachmentFromUrl( mediaUrl, buildOutboundMediaLoadOptions({ maxBytes, + mediaAccess: options?.mediaAccess, mediaLocalRoots: options?.localRoots, mediaReadFile: options?.readFile, }), diff --git a/src/media/read-capability.ts b/src/media/read-capability.ts index 3ca74a5bd9b..4beacb19d54 100644 --- a/src/media/read-capability.ts +++ b/src/media/read-capability.ts @@ -4,12 +4,14 @@ import { resolveEffectiveToolFsRootExpansionAllowed } from "../agents/tool-fs-po import { resolveWorkspaceRoot } from "../agents/workspace-dir.js"; import type { OpenClawConfig } from "../config/config.js"; import { readLocalFileSafely } from "../infra/fs-safe.js"; +import type { OutboundMediaAccess, OutboundMediaReadFile } from "./load-options.js"; +import { getAgentScopedMediaLocalRootsForSources } from "./local-roots.js"; export function createAgentScopedHostMediaReadFile(params: { cfg: OpenClawConfig; agentId?: string; workspaceDir?: string; -}): ((filePath: string) => Promise) | undefined { +}): OutboundMediaReadFile | undefined { if ( !resolveEffectiveToolFsRootExpansionAllowed({ cfg: params.cfg, @@ -27,3 +29,32 @@ export function createAgentScopedHostMediaReadFile(params: { return (await readLocalFileSafely({ filePath: resolvedPath })).buffer; }; } + +export function resolveAgentScopedOutboundMediaAccess(params: { + cfg: OpenClawConfig; + agentId?: string; + mediaSources?: readonly string[]; + workspaceDir?: string; + mediaAccess?: OutboundMediaAccess; + mediaReadFile?: OutboundMediaReadFile; +}): OutboundMediaAccess { + const localRoots = + params.mediaAccess?.localRoots ?? + getAgentScopedMediaLocalRootsForSources({ + cfg: params.cfg, + agentId: params.agentId, + mediaSources: params.mediaSources, + }); + const readFile = + params.mediaAccess?.readFile ?? + params.mediaReadFile ?? + createAgentScopedHostMediaReadFile({ + cfg: params.cfg, + agentId: params.agentId, + workspaceDir: params.workspaceDir, + }); + return { + ...(localRoots?.length ? { localRoots } : {}), + ...(readFile ? { readFile } : {}), + }; +} diff --git a/src/plugin-sdk/discord-send.ts b/src/plugin-sdk/discord-send.ts index a01eeb6f8ad..c055a946363 100644 --- a/src/plugin-sdk/discord-send.ts +++ b/src/plugin-sdk/discord-send.ts @@ -1,3 +1,4 @@ +import type { OutboundMediaAccess } from "../media/load-options.js"; import { attachChannelToResult } from "./channel-send-result.js"; import type { DiscordSendResult } from "./discord.js"; @@ -9,6 +10,7 @@ type DiscordSendOptionInput = { type DiscordSendMediaOptionInput = DiscordSendOptionInput & { mediaUrl?: string; + mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; }; @@ -28,6 +30,7 @@ export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput) return { ...buildDiscordSendOptions(input), mediaUrl: input.mediaUrl, + mediaAccess: input.mediaAccess, mediaLocalRoots: input.mediaLocalRoots, mediaReadFile: input.mediaReadFile, }; diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index f0abd125f92..30490966b3e 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -76,6 +76,7 @@ export { resolveDiscordAccount, resolveDefaultDiscordAccountId, } from "./discord-surface.js"; +export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { inspectDiscordAccount } from "./discord-surface.js"; export { looksLikeDiscordTargetId, diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index 56b0f43919a..1e016d4fe6b 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -26,6 +26,7 @@ export { createAccountStatusSink, runPassiveAccountLifecycle } from "./channel-l export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { fetchRemoteMedia } from "../media/fetch.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; +export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { loadWebMedia } from "./web-media.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 13031180647..dbd84b23b84 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -88,6 +88,7 @@ export { } from "./matrix-thread-bindings.js"; export { createTypingCallbacks } from "../channels/typing.js"; export { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; +export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export type { OpenClawConfig } from "../config/config.js"; export { GROUP_POLICY_BLOCKED_LABEL, diff --git a/src/plugin-sdk/outbound-media.test.ts b/src/plugin-sdk/outbound-media.test.ts index 15b9562de8a..7dd21e6c217 100644 --- a/src/plugin-sdk/outbound-media.test.ts +++ b/src/plugin-sdk/outbound-media.test.ts @@ -1,20 +1,22 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const loadWebMediaMock = vi.hoisted(() => vi.fn()); -vi.mock("./web-media.js", () => ({ - loadWebMedia: loadWebMediaMock, -})); - type OutboundMediaModule = typeof import("./outbound-media.js"); let loadOutboundMediaFromUrl: OutboundMediaModule["loadOutboundMediaFromUrl"]; describe("loadOutboundMediaFromUrl", () => { beforeAll(async () => { + const webMedia = await import("./web-media.js"); + vi.spyOn(webMedia, "loadWebMedia").mockImplementation(loadWebMediaMock); ({ loadOutboundMediaFromUrl } = await import("./outbound-media.js")); }); + afterAll(() => { + vi.restoreAllMocks(); + }); + beforeEach(() => { loadWebMediaMock.mockReset(); }); @@ -46,10 +48,7 @@ describe("loadOutboundMediaFromUrl", () => { await loadOutboundMediaFromUrl("https://example.com/image.png"); - expect(loadWebMediaMock).toHaveBeenCalledWith("https://example.com/image.png", { - maxBytes: undefined, - localRoots: undefined, - }); + expect(loadWebMediaMock).toHaveBeenCalledWith("https://example.com/image.png", {}); }); it("prefers host read capability over local roots when provided", async () => { diff --git a/src/plugin-sdk/outbound-media.ts b/src/plugin-sdk/outbound-media.ts index 3e8a5a18405..580f96f4849 100644 --- a/src/plugin-sdk/outbound-media.ts +++ b/src/plugin-sdk/outbound-media.ts @@ -1,8 +1,9 @@ -import { buildOutboundMediaLoadOptions } from "../media/load-options.js"; +import { buildOutboundMediaLoadOptions, type OutboundMediaAccess } from "../media/load-options.js"; import { loadWebMedia } from "./web-media.js"; export type OutboundMediaLoadOptions = { maxBytes?: number; + mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; }; @@ -16,6 +17,7 @@ export async function loadOutboundMediaFromUrl( mediaUrl, buildOutboundMediaLoadOptions({ maxBytes: options.maxBytes, + mediaAccess: options.mediaAccess, mediaLocalRoots: options.mediaLocalRoots, mediaReadFile: options.mediaReadFile, }), diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index ba2696e56ba..7988c2754da 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -49,6 +49,7 @@ export { resolveDefaultSlackAccountId, resolveSlackReplyToMode, } from "./slack-surface.js"; +export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { isSlackInteractiveRepliesEnabled } from "./slack-surface.js"; export { inspectSlackAccount } from "./slack-surface.js"; export { parseSlackTarget, resolveSlackChannelId } from "./slack-targets.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 6e3b4ebac74..ccaf6f80b78 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -50,6 +50,7 @@ export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; +export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy,