Scope media discovery to the current action

This commit is contained in:
Gustavo Madeira Santana
2026-04-14 12:05:59 -04:00
parent fe29ec5157
commit ff90b9d0d5
10 changed files with 160 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -111,10 +111,51 @@ type ResolvedChannelMessageActionDiscovery = {
mediaSourceParams: readonly string[];
};
type MessageToolMediaSourceParamMap = Partial<Record<ChannelMessageActionName, readonly string[]>>;
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<typeof getChannelPlugin>[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<typeof getChannelPlugin>[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<typeof getChannelPlugin>[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));

View File

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

View File

@@ -62,6 +62,10 @@ export type ChannelMessageToolSchemaContribution = {
visibility?: "current-channel" | "all-configured";
};
type ChannelMessageToolMediaSourceParams =
| readonly string[]
| Partial<Record<ChannelMessageActionName, readonly string[]>>;
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. */

View File

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

View File

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

View File

@@ -852,6 +852,7 @@ export async function runMessageAction(
});
const extraActionMediaSourceParamKeys = resolveExtraActionMediaSourceParamKeys({
cfg,
action,
channel,
accountId,
sessionKey: input.sessionKey,