refactor(media): centralize outbound access plumbing

This commit is contained in:
Peter Steinberger 2026-04-01 00:31:43 +09:00
parent c416527df6
commit 43ef8a5a86
No known key found for this signature in database
38 changed files with 316 additions and 134 deletions

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
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";

View File

@ -49,6 +49,10 @@ type DiscordSendOpts = {
accountId?: string;
mediaUrl?: string;
filename?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
verbose?: boolean;

View File

@ -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<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
}) {
@ -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,
});

View File

@ -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();

View File

@ -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,

View File

@ -88,6 +88,10 @@ export type MatrixSendOpts = {
client?: import("../sdk.js").MatrixClient;
cfg?: CoreConfig;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string;

View File

@ -13,6 +13,10 @@ export type SignalSendOpts = {
account?: string;
accountId?: string;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
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,
});

View File

@ -159,6 +159,10 @@ export async function sendSlackMessage(
content: string,
opts: SlackActionClientOpts & {
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
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,

View File

@ -82,6 +82,10 @@ async function sendSlackOutboundMessage(params: {
to: string;
text: string;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
blocks?: NonNullable<Parameters<typeof sendMessageSlack>[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,

View File

@ -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 {

View File

@ -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<Buffer>;
};
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<Buffer>;
};
uploadFileName?: string;
uploadTitle?: string;
mediaLocalRoots?: readonly string[];
@ -239,11 +247,11 @@ async function uploadSlackFile(params: {
threadTs?: string;
maxBytes?: number;
}): Promise<string> {
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,

View File

@ -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,

View File

@ -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<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
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;

View File

@ -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<Buffer>;
maxBytes?: number;
@ -80,8 +82,7 @@ export function createDirectTextMediaOutbound<
deps?: OutboundSendDeps;
replyToId?: string | null;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
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,

View File

@ -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<Buffer>;
gifPlayback?: boolean;

View File

@ -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<string, unknown>;
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string | null;

View File

@ -29,6 +29,10 @@ type WhatsAppSendMessage = (
verbose: boolean;
cfg?: OpenClawConfig;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
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,

View File

@ -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<typeof createSubsystemLogger>;

View File

@ -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<SubsystemLogger, "child"> & { child: ReturnType<typeof vi.fn> };
logger.child.mockImplementation(() => logger);
return logger;
child,
} satisfies SubsystemLogger;
child.mockImplementation(() => logger);
return logger as SubsystemLogger;
}
function assertWriteHelperAllowed() {

View File

@ -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<Buffer>;
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

View File

@ -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<Buffer>;
mediaAccess?: OutboundMediaAccess;
};
export function resolveAttachmentMediaPolicy(params: {
sandboxRoot?: string;
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
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<Buffer>;
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: {

View File

@ -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<string, unknown>;
channel: ChannelId;
mediaLocalRoots: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
mediaAccess: OutboundMediaAccess;
accountId?: string | null;
dryRun: boolean;
gateway?: MessageActionRunnerGateway;
@ -520,7 +517,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
channel,
params,
agentId,
mediaReadFile: ctx.mediaReadFile,
mediaAccess: ctx.mediaAccess,
accountId: accountId ?? undefined,
gateway,
toolContext: input.toolContext,
@ -644,7 +641,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
cfg,
params,
channel,
mediaLocalRoots,
mediaAccess,
accountId,
dryRun,
gateway,
@ -675,8 +672,9 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
action,
cfg,
params,
mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
mediaAccess,
mediaLocalRoots: mediaAccess.localRoots,
mediaReadFile: mediaAccess.readFile,
accountId: accountId ?? undefined,
requesterSenderId: input.requesterSenderId ?? undefined,
sessionKey: input.sessionKey,
@ -748,19 +746,14 @@ export async function runMessageAction(
mediaPolicy: normalizationPolicy,
});
const mediaLocalRoots = getAgentScopedMediaLocalRootsForSources({
const mediaAccess = resolveAgentScopedOutboundMediaAccess({
cfg,
agentId: resolvedAgentId,
mediaSources: collectActionMediaSourceHints(params),
});
const mediaReadFile = createAgentScopedHostMediaReadFile({
cfg,
agentId: resolvedAgentId,
});
const mediaPolicy = resolveAttachmentMediaPolicy({
sandboxRoot: input.sandboxRoot,
mediaLocalRoots,
mediaReadFile,
mediaAccess,
});
await hydrateAttachmentParamsForAction({
@ -800,8 +793,7 @@ export async function runMessageAction(
cfg,
params,
channel,
mediaLocalRoots,
mediaReadFile,
mediaAccess,
accountId,
dryRun,
gateway,
@ -817,8 +809,7 @@ export async function runMessageAction(
cfg,
params,
channel,
mediaLocalRoots,
mediaReadFile,
mediaAccess,
accountId,
dryRun,
gateway,
@ -831,8 +822,7 @@ export async function runMessageAction(
cfg,
params,
channel,
mediaLocalRoots,
mediaReadFile,
mediaAccess,
accountId,
dryRun,
gateway,

View File

@ -1,15 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
const mocks = vi.hoisted(() => ({
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<Buffer>>(
() => async () => Buffer.from("capability"),
),
);
const resolveAgentScopedOutboundMediaAccessMock = vi.hoisted(() =>
vi.fn<
(params: { cfg: unknown; agentId?: string; mediaSources?: readonly string[] }) => {
localRoots: string[];
readFile: (filePath: string) => Promise<Buffer>;
}
>((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();
});

View File

@ -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<string, unknown>;
/** Active agent id for per-agent outbound media root scoping. */
agentId?: string;
mediaReadFile?: (filePath: string) => Promise<Buffer>;
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,

View File

@ -1,7 +1,15 @@
export type OutboundMediaReadFile = (filePath: string) => Promise<Buffer>;
export type OutboundMediaAccess = {
localRoots?: readonly string[];
readFile?: OutboundMediaReadFile;
};
export type OutboundMediaLoadParams = {
maxBytes?: number;
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
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 } : {}),

View File

@ -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<Buffer>;
},
@ -14,6 +15,7 @@ export async function resolveOutboundAttachmentFromUrl(
mediaUrl,
buildOutboundMediaLoadOptions({
maxBytes,
mediaAccess: options?.mediaAccess,
mediaLocalRoots: options?.localRoots,
mediaReadFile: options?.readFile,
}),

View File

@ -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<Buffer>) | 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 } : {}),
};
}

View File

@ -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<Buffer>;
};
@ -28,6 +30,7 @@ export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput)
return {
...buildDiscordSendOptions(input),
mediaUrl: input.mediaUrl,
mediaAccess: input.mediaAccess,
mediaLocalRoots: input.mediaLocalRoots,
mediaReadFile: input.mediaReadFile,
};

View File

@ -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,

View File

@ -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 {

View File

@ -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,

View File

@ -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 () => {

View File

@ -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<Buffer>;
};
@ -16,6 +17,7 @@ export async function loadOutboundMediaFromUrl(
mediaUrl,
buildOutboundMediaLoadOptions({
maxBytes: options.maxBytes,
mediaAccess: options.mediaAccess,
mediaLocalRoots: options.mediaLocalRoots,
mediaReadFile: options.mediaReadFile,
}),

View File

@ -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";

View File

@ -50,6 +50,7 @@ export {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
} from "../config/runtime-group-policy.js";
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
export {
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupToolPolicy,