From ff90b9d0d5979a9e067f16125274217dbb3fdc6d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 14 Apr 2026 12:05:59 -0400 Subject: [PATCH] Scope media discovery to the current action --- docs/plugins/architecture.md | 3 + docs/plugins/sdk-channel-plugins.md | 4 + extensions/matrix/src/actions.test.ts | 9 +- extensions/matrix/src/actions.ts | 4 +- .../plugins/message-action-discovery.ts | 86 +++++++++++++------ src/channels/plugins/message-actions.test.ts | 46 +++++++++- src/channels/plugins/types.core.ts | 9 +- src/infra/outbound/message-action-params.ts | 2 + .../message-action-runner.media.test.ts | 37 +++++++- src/infra/outbound/message-action-runner.ts | 1 + 10 files changed, 160 insertions(+), 41 deletions(-) diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 61146f53fb4..c336f92787c 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -178,6 +178,9 @@ local path or remote media URL, the plugin should also return `mediaSourceParams` from `describeMessageTool(...)`. Core uses that explicit list to apply sandbox path normalization and outbound media-access hints without hardcoding plugin-owned param names. +Prefer action-scoped maps there, not one channel-wide flat list, so a +profile-only media param does not get normalized on unrelated actions like +`send`. Core passes runtime scope into that discovery step. Important fields include: diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 957c8d7027d..5e51b8b6e4a 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -40,6 +40,10 @@ param names through `describeMessageTool(...).mediaSourceParams`. Core uses that explicit list for sandbox path normalization and outbound media-access policy, so plugins do not need shared-core special cases for provider-specific avatar, attachment, or cover-image params. +Prefer returning an action-keyed map such as +`{ "set-profile": ["avatarUrl", "avatarPath"] }` so unrelated actions do not +inherit another action's media args. A flat array still works for params that +are intentionally shared across every exposed action. If your platform stores extra scope inside conversation ids, keep that parsing in the plugin with `messaging.resolveSessionConversation(...)`. That is the diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts index 25cf00edc5e..70cff604395 100644 --- a/extensions/matrix/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -92,12 +92,9 @@ describe("matrixMessageActions", () => { expect(actions).toContain(profileAction); expect(supportsAction({ action: profileAction } as never)).toBe(true); - expect(discovery.mediaSourceParams).toEqual([ - "avatarUrl", - "avatar_url", - "avatarPath", - "avatar_path", - ]); + expect(discovery.mediaSourceParams).toEqual({ + "set-profile": ["avatarUrl", "avatar_url", "avatarPath", "avatar_path"], + }); expect(properties.displayName).toBeDefined(); expect(properties.avatarUrl).toBeDefined(); expect(properties.avatarPath).toBeDefined(); diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index d0e6ebda21d..6797786b23b 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -140,8 +140,8 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { capabilities: [], schema: listedActions.includes("set-profile") ? buildMatrixProfileToolSchema() : null, mediaSourceParams: listedActions.includes("set-profile") - ? MATRIX_PROFILE_MEDIA_SOURCE_PARAMS - : [], + ? { "set-profile": MATRIX_PROFILE_MEDIA_SOURCE_PARAMS } + : null, }; }, supportsAction: ({ action }) => MATRIX_PLUGIN_HANDLED_ACTIONS.has(action), diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts index 6e328b07c02..c07fe442085 100644 --- a/src/channels/plugins/message-action-discovery.ts +++ b/src/channels/plugins/message-action-discovery.ts @@ -111,10 +111,51 @@ type ResolvedChannelMessageActionDiscovery = { mediaSourceParams: readonly string[]; }; +type MessageToolMediaSourceParamMap = Partial>; + +function normalizeMessageToolMediaSourceParams( + mediaSourceParams: ChannelMessageToolDiscovery["mediaSourceParams"], + action?: ChannelMessageActionName, +): readonly string[] { + if (Array.isArray(mediaSourceParams)) { + return mediaSourceParams; + } + if (!mediaSourceParams || typeof mediaSourceParams !== "object") { + return []; + } + const scopedMediaSourceParams = mediaSourceParams as MessageToolMediaSourceParamMap; + if (action) { + const scoped = scopedMediaSourceParams[action]; + return Array.isArray(scoped) ? scoped : []; + } + return Object.values(scopedMediaSourceParams).flatMap((scoped) => + Array.isArray(scoped) ? scoped : [], + ); +} + +function resolveCurrentChannelPluginActions(channel?: string): { + pluginId: string; + actions: ChannelActions; +} | null { + const channelId = resolveMessageActionDiscoveryChannelId(channel); + if (!channelId) { + return null; + } + const plugin = getChannelPlugin(channelId as Parameters[0]); + if (!plugin?.actions) { + return null; + } + return { + pluginId: plugin.id, + actions: plugin.actions, + }; +} + export function resolveMessageActionDiscoveryForPlugin(params: { pluginId: string; actions?: ChannelActions; context: ChannelMessageActionDiscoveryContext; + action?: ChannelMessageActionName; includeActions?: boolean; includeCapabilities?: boolean; includeSchema?: boolean; @@ -144,9 +185,10 @@ export function resolveMessageActionDiscoveryForPlugin(params: { schemaContributions: params.includeSchema ? normalizeToolSchemaContributions(described?.schema) : [], - mediaSourceParams: Array.isArray(described?.mediaSourceParams) - ? described.mediaSourceParams - : [], + mediaSourceParams: normalizeMessageToolMediaSourceParams( + described?.mediaSourceParams, + params.action, + ), }; } @@ -193,21 +235,18 @@ export function listChannelMessageCapabilitiesForChannel(params: { requesterSenderId?: string | null; senderIsOwner?: boolean; }): ChannelMessageCapability[] { - const channelId = resolveMessageActionDiscoveryChannelId(params.channel); - if (!channelId) { + const pluginActions = resolveCurrentChannelPluginActions(params.channel); + if (!pluginActions) { return []; } - const plugin = getChannelPlugin(channelId as Parameters[0]); - return plugin?.actions - ? Array.from( - resolveMessageActionDiscoveryForPlugin({ - pluginId: plugin.id, - actions: plugin.actions, - context: createMessageActionDiscoveryContext(params), - includeCapabilities: true, - }).capabilities, - ) - : []; + return Array.from( + resolveMessageActionDiscoveryForPlugin({ + pluginId: pluginActions.pluginId, + actions: pluginActions.actions, + context: createMessageActionDiscoveryContext(params), + includeCapabilities: true, + }).capabilities, + ); } function mergeToolSchemaProperties( @@ -267,6 +306,7 @@ export function resolveChannelMessageToolSchemaProperties(params: { export function resolveChannelMessageToolMediaSourceParamKeys(params: { cfg: OpenClawConfig; + action?: ChannelMessageActionName; channel?: string; currentChannelId?: string | null; currentThreadTs?: string | null; @@ -278,19 +318,15 @@ export function resolveChannelMessageToolMediaSourceParamKeys(params: { requesterSenderId?: string | null; senderIsOwner?: boolean; }): string[] { - const channelId = resolveMessageActionDiscoveryChannelId(params.channel); - if (!channelId) { + const pluginActions = resolveCurrentChannelPluginActions(params.channel); + if (!pluginActions) { return []; } - const plugin = getChannelPlugin(channelId as Parameters[0]); - if (!plugin?.actions) { - return []; - } - const described = resolveMessageActionDiscoveryForPlugin({ - pluginId: plugin.id, - actions: plugin.actions, + pluginId: pluginActions.pluginId, + actions: pluginActions.actions, context: createMessageActionDiscoveryContext(params), + action: params.action, includeSchema: false, }); return Array.from(new Set(described.mediaSourceParams)); diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index 1ee43f90f2f..58858be9a44 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -200,7 +200,7 @@ describe("message action capability checks", () => { ).toHaveProperty("components"); }); - it("derives plugin-owned media-source params from message-tool discovery", () => { + it("derives plugin-owned media-source params for the current action", () => { const mediaPlugin: ChannelPlugin = { ...createChannelTestPluginBase({ id: "demo-media", @@ -212,8 +212,10 @@ describe("message action capability checks", () => { }), actions: { describeMessageTool: () => ({ - actions: ["set-profile"], - mediaSourceParams: ["avatarUrl", "avatarPath"], + actions: ["send", "set-profile"], + mediaSourceParams: { + "set-profile": ["avatarUrl", "avatarPath"], + }, schema: { properties: { avatarUrl: Type.Optional(Type.String({ description: "Remote avatar URL" })), @@ -231,9 +233,47 @@ describe("message action capability checks", () => { expect( resolveChannelMessageToolMediaSourceParamKeys({ cfg: {} as OpenClawConfig, + action: "set-profile", channel: "demo-media", }), ).toEqual(["avatarUrl", "avatarPath"]); + expect( + resolveChannelMessageToolMediaSourceParamKeys({ + cfg: {} as OpenClawConfig, + action: "send", + channel: "demo-media", + }), + ).toEqual([]); + }); + + it("keeps flat media-source param discovery for backward compatibility", () => { + const mediaPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "demo-media-flat", + label: "Demo Media Flat", + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + }, + }), + actions: { + describeMessageTool: () => ({ + actions: ["set-profile"], + mediaSourceParams: ["avatarUrl", "avatarPath"], + }), + }, + }; + setActivePluginRegistry( + createTestRegistry([{ pluginId: "demo-media-flat", source: "test", plugin: mediaPlugin }]), + ); + + expect( + resolveChannelMessageToolMediaSourceParamKeys({ + cfg: {} as OpenClawConfig, + action: "set-profile", + channel: "demo-media-flat", + }), + ).toEqual(["avatarUrl", "avatarPath"]); }); it("skips crashing action/capability discovery paths and logs once", () => { diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 70bbf5f6156..00c4c044094 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -62,6 +62,10 @@ export type ChannelMessageToolSchemaContribution = { visibility?: "current-channel" | "all-configured"; }; +type ChannelMessageToolMediaSourceParams = + | readonly string[] + | Partial>; + export type ChannelMessageToolDiscovery = { actions?: readonly ChannelMessageActionName[] | null; capabilities?: readonly ChannelMessageCapability[] | null; @@ -69,9 +73,10 @@ export type ChannelMessageToolDiscovery = { /** * Plugin-owned message-tool params that carry media sources. * Core uses this to derive sandbox path normalization and host media-access - * hints without hardcoding plugin-specific param names. + * hints without hardcoding plugin-specific param names. Prefer scoping keys + * by action so unrelated actions do not inherit another action's media args. */ - mediaSourceParams?: readonly string[] | null; + mediaSourceParams?: ChannelMessageToolMediaSourceParams | null; }; /** Shared setup input bag used by CLI, onboarding, and setup adapters. */ diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index ff4a37f3b48..ed9559113fa 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -39,6 +39,7 @@ function buildActionMediaSourceParamKeys(extraParamKeys?: readonly string[]): st export function resolveExtraActionMediaSourceParamKeys(params: { cfg: OpenClawConfig; + action?: ChannelMessageActionName; channel?: string; accountId?: string | null; sessionKey?: string | null; @@ -49,6 +50,7 @@ export function resolveExtraActionMediaSourceParamKeys(params: { }): string[] { return resolveChannelMessageToolMediaSourceParamKeys({ cfg: params.cfg, + action: params.action, channel: params.channel, accountId: params.accountId, sessionKey: params.sessionKey, diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index c5825080e1f..82fd485d520 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -536,10 +536,18 @@ describe("runMessageAction media behavior", () => { isConfigured: () => true, }, }), + outbound: { + deliveryMode: "direct", + resolveTarget: ({ to }) => ({ ok: true, to: to?.trim() ?? "profile-demo-target" }), + sendText: async () => ({ channel: "profile-demo", messageId: "msg-test" }), + sendMedia: async () => ({ channel: "profile-demo", messageId: "msg-test" }), + }, actions: { describeMessageTool: () => ({ - actions: ["set-profile"], - mediaSourceParams: ["avatarPath", "avatarUrl"], + actions: ["send", "set-profile"], + mediaSourceParams: { + "set-profile": ["avatarPath", "avatarUrl"], + }, schema: { properties: { avatarPath: Type.Optional(Type.String({ description: "Local avatar path" })), @@ -548,7 +556,7 @@ describe("runMessageAction media behavior", () => { }, }, }), - supportsAction: ({ action }) => action === "set-profile", + supportsAction: ({ action }) => action === "set-profile" || action === "send", handleAction: async ({ params, mediaLocalRoots }) => jsonResult({ ok: true, @@ -622,6 +630,29 @@ describe("runMessageAction media behavior", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("does not apply set-profile media params to send actions", async () => { + await withSandbox(async (sandboxDir) => { + const avatarUrl = "data:text/plain;base64,SGVsbG8="; + const result = await runMessageAction({ + cfg: {} as OpenClawConfig, + action: "send", + dryRun: true, + params: { + channel: "profile-demo", + target: "@profile-demo", + message: "hi", + avatarUrl, + }, + sandboxRoot: sandboxDir, + }); + + expect(result.kind).toBe("send"); + expect(result.sendResult).toMatchObject({ + channel: "profile-demo", + }); + }); + }); }); describe("sandboxed media validation", () => { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 03c2080c341..5f7737a0b5b 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -852,6 +852,7 @@ export async function runMessageAction( }); const extraActionMediaSourceParamKeys = resolveExtraActionMediaSourceParamKeys({ cfg, + action, channel, accountId, sessionKey: input.sessionKey,