From a32c7e16d20442d5205ffcfdde1459a38f625cef Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:55:00 +0000 Subject: [PATCH] Plugin SDK: normalize and harden message action discovery --- src/agents/channel-tools.test.ts | 27 +++++++++++ src/agents/channel-tools.ts | 43 ++++++----------- src/agents/tools/message-tool.test.ts | 24 ++++++++++ src/agents/tools/message-tool.ts | 47 ++++++++++--------- .../plugins/message-action-discovery.ts | 46 ++++++++++++++++++ src/channels/plugins/message-actions.test.ts | 45 ++++++++++++++---- src/channels/plugins/message-actions.ts | 38 +++++---------- 7 files changed, 186 insertions(+), 84 deletions(-) create mode 100644 src/channels/plugins/message-action-discovery.ts diff --git a/src/agents/channel-tools.test.ts b/src/agents/channel-tools.test.ts index 26552f81f9f..8e5e4266e10 100644 --- a/src/agents/channel-tools.test.ts +++ b/src/agents/channel-tools.test.ts @@ -84,4 +84,31 @@ describe("channel tools", () => { expect(listChannelSupportedActions({ cfg, channel: "polltest" })).toEqual([]); expect(listAllChannelSupportedActions({ cfg })).toEqual([]); }); + + it("normalizes channel aliases before listing supported actions", () => { + const plugin: ChannelPlugin = { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "telegram plugin", + aliases: ["tg"], + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + actions: { + listActions: () => ["react"], + }, + }; + + setActivePluginRegistry(createTestRegistry([{ pluginId: "telegram", source: "test", plugin }])); + + const cfg = {} as OpenClawConfig; + expect(listChannelSupportedActions({ cfg, channel: "tg" })).toEqual(["react"]); + }); }); diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 4e2d028e91a..49cbc5c0efe 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -1,4 +1,8 @@ import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; +import { + createMessageActionDiscoveryContext, + resolveMessageActionDiscoveryChannelId, +} from "../channels/plugins/message-action-discovery.js"; import type { ChannelAgentTool, ChannelMessageActionName, @@ -24,26 +28,15 @@ export function listChannelSupportedActions(params: { agentId?: string | null; requesterSenderId?: string | null; }): ChannelMessageActionName[] { - if (!params.channel) { + const channelId = resolveMessageActionDiscoveryChannelId(params.channel); + if (!channelId) { return []; } - const plugin = getChannelPlugin(params.channel as Parameters[0]); + const plugin = getChannelPlugin(channelId as Parameters[0]); if (!plugin?.actions?.listActions) { return []; } - const cfg = params.cfg ?? ({} as OpenClawConfig); - return runPluginListActions(plugin, { - cfg, - currentChannelId: params.currentChannelId, - currentChannelProvider: params.channel, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.accountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - }); + return runPluginListActions(plugin, createMessageActionDiscoveryContext(params)); } /** @@ -65,19 +58,13 @@ export function listAllChannelSupportedActions(params: { if (!plugin.actions?.listActions) { continue; } - const cfg = params.cfg ?? ({} as OpenClawConfig); - const channelActions = runPluginListActions(plugin, { - cfg, - currentChannelId: params.currentChannelId, - currentChannelProvider: plugin.id, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.accountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - }); + const channelActions = runPluginListActions( + plugin, + createMessageActionDiscoveryContext({ + ...params, + currentChannelProvider: plugin.id, + }), + ); for (const action of channelActions) { actions.add(action); } diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 2693e7fdf19..1e0965305d4 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -86,6 +86,7 @@ function createChannelPlugin(params: { label: string; docsPath: string; blurb: string; + aliases?: string[]; actions?: ChannelMessageActionName[]; listActions?: NonNullable["listActions"]>; capabilities?: readonly ChannelMessageCapability[]; @@ -101,6 +102,7 @@ function createChannelPlugin(params: { selectionLabel: params.label, docsPath: params.docsPath, blurb: params.blurb, + aliases: params.aliases, }, capabilities: { chatTypes: ["direct", "group"], media: true }, config: { @@ -641,6 +643,28 @@ describe("message tool description", () => { expect(tool.description).toContain("telegram (delete, edit, react, send, topic-create)"); }); + it("normalizes channel aliases before building the current channel description", () => { + const signalPlugin = createChannelPlugin({ + id: "signal", + label: "Signal", + docsPath: "/channels/signal", + blurb: "Signal test plugin.", + aliases: ["sig"], + actions: ["send", "react"], + }); + + setActivePluginRegistry( + createTestRegistry([{ pluginId: "signal", source: "test", plugin: signalPlugin }]), + ); + + const tool = createMessageTool({ + config: {} as never, + currentChannelProvider: "sig", + }); + + expect(tool.description).toContain("Current channel (signal) supports: react, send."); + }); + it("does not include 'Other configured channels' when only one channel is configured", () => { setActivePluginRegistry( createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: bluebubblesPlugin }]), diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index f5428519f81..bf4a4d4c8cf 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1,4 +1,4 @@ -import { Type } from "@sinclair/typebox"; +import { Type, type TSchema } from "@sinclair/typebox"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { channelSupportsMessageCapability, @@ -82,7 +82,7 @@ const interactiveMessageSchema = Type.Object( ); function buildSendSchema(options: { includeInteractive: boolean }) { - const props: Record = { + const props: Record = { message: Type.Optional(Type.String()), effectId: Type.Optional( Type.String({ @@ -167,7 +167,7 @@ function buildFetchSchema() { } function buildPollSchema() { - const props: Record = { + const props: Record = { pollId: Type.Optional(Type.String()), pollOptionId: Type.Optional( Type.String({ @@ -346,7 +346,7 @@ function buildChannelManagementSchema() { function buildMessageToolSchemaProps(options: { includeInteractive: boolean; - extraProperties?: Record; + extraProperties?: Record; }) { return { ...buildRoutingSchema(), @@ -370,7 +370,7 @@ function buildMessageToolSchemaFromActions( actions: readonly string[], options: { includeInteractive: boolean; - extraProperties?: Record; + extraProperties?: Record; }, ) { const props = buildMessageToolSchemaProps(options); @@ -547,34 +547,39 @@ function buildMessageToolDescription(options?: { requesterSenderId?: string; }): string { const baseDescription = "Send, delete, and manage messages via channel plugins."; + const resolvedOptions = options ?? {}; + const currentChannel = normalizeMessageChannel(resolvedOptions.currentChannel); // If we have a current channel, show its actions and list other configured channels - if (options?.currentChannel) { + if (currentChannel) { const channelActions = listChannelSupportedActions({ - cfg: options.config, - channel: options.currentChannel, - currentChannelId: options.currentChannelId, - currentThreadTs: options.currentThreadTs, - currentMessageId: options.currentMessageId, - accountId: options.currentAccountId, - sessionKey: options.sessionKey, - sessionId: options.sessionId, - agentId: options.agentId, - requesterSenderId: options.requesterSenderId, + cfg: resolvedOptions.config, + channel: currentChannel, + currentChannelId: resolvedOptions.currentChannelId, + currentThreadTs: resolvedOptions.currentThreadTs, + currentMessageId: resolvedOptions.currentMessageId, + accountId: resolvedOptions.currentAccountId, + sessionKey: resolvedOptions.sessionKey, + sessionId: resolvedOptions.sessionId, + agentId: resolvedOptions.agentId, + requesterSenderId: resolvedOptions.requesterSenderId, }); if (channelActions.length > 0) { // Always include "send" as a base action const allActions = new Set(["send", ...channelActions]); const actionList = Array.from(allActions).toSorted().join(", "); - let desc = `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`; + let desc = `${baseDescription} Current channel (${currentChannel}) supports: ${actionList}.`; // Include other configured channels so cron/isolated agents can discover them const otherChannels: string[] = []; for (const plugin of listChannelPlugins()) { - if (plugin.id === options.currentChannel) { + if (plugin.id === currentChannel) { continue; } - const actions = listChannelSupportedActions({ cfg: options.config, channel: plugin.id }); + const actions = listChannelSupportedActions({ + cfg: resolvedOptions.config, + channel: plugin.id, + }); if (actions.length > 0) { const all = new Set(["send", ...actions]); otherChannels.push(`${plugin.id} (${Array.from(all).toSorted().join(", ")})`); @@ -589,8 +594,8 @@ function buildMessageToolDescription(options?: { } // Fallback to generic description with all configured actions - if (options?.config) { - const actions = listChannelMessageActions(options.config); + if (resolvedOptions.config) { + const actions = listChannelMessageActions(resolvedOptions.config); if (actions.length > 0) { return `${baseDescription} Supports actions: ${actions.join(", ")}.`; } diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts new file mode 100644 index 00000000000..5825219f6dc --- /dev/null +++ b/src/channels/plugins/message-action-discovery.ts @@ -0,0 +1,46 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeAnyChannelId } from "../registry.js"; +import type { ChannelMessageActionDiscoveryContext } from "./types.js"; + +export type ChannelMessageActionDiscoveryInput = { + cfg?: OpenClawConfig; + channel?: string | null; + currentChannelProvider?: string | null; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}; + +export function resolveMessageActionDiscoveryChannelId(raw?: string | null): string | undefined { + const normalized = normalizeAnyChannelId(raw); + if (normalized) { + return normalized; + } + const trimmed = raw?.trim(); + return trimmed || undefined; +} + +export function createMessageActionDiscoveryContext( + params: ChannelMessageActionDiscoveryInput, +): ChannelMessageActionDiscoveryContext { + const currentChannelProvider = resolveMessageActionDiscoveryChannelId( + params.channel ?? params.currentChannelProvider, + ); + return { + cfg: params.cfg ?? ({} as OpenClawConfig), + currentChannelId: params.currentChannelId, + currentChannelProvider, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.accountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }; +} diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index 17fdf8fe193..bee94a28b0f 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -22,16 +22,22 @@ const emptyRegistry = createTestRegistry([]); function createMessageActionsPlugin(params: { id: "discord" | "telegram"; capabilities: readonly ChannelMessageCapability[]; + aliases?: string[]; }): ChannelPlugin { + const base = createChannelTestPluginBase({ + id: params.id, + label: params.id === "discord" ? "Discord" : "Telegram", + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + }, + }); return { - ...createChannelTestPluginBase({ - id: params.id, - label: params.id === "discord" ? "Discord" : "Telegram", - capabilities: { chatTypes: ["direct", "group"] }, - config: { - listAccountIds: () => ["default"], - }, - }), + ...base, + meta: { + ...base.meta, + ...(params.aliases ? { aliases: params.aliases } : {}), + }, actions: { listActions: () => ["send"], getCapabilities: () => params.capabilities, @@ -130,6 +136,29 @@ describe("message action capability checks", () => { ); }); + it("normalizes channel aliases for per-channel capability checks", () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: createMessageActionsPlugin({ + id: "telegram", + aliases: ["tg"], + capabilities: ["cards"], + }), + }, + ]), + ); + + expect( + listChannelMessageCapabilitiesForChannel({ + cfg: {} as OpenClawConfig, + channel: "tg", + }), + ).toEqual(["cards"]); + }); + it("skips crashing action/capability discovery paths and logs once", () => { const crashingPlugin: ChannelPlugin = { ...createChannelTestPluginBase({ diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 3a7cdad7e66..19f24d4f8d2 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -3,6 +3,10 @@ import type { TSchema } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { getChannelPlugin, listChannelPlugins } from "./index.js"; +import { + createMessageActionDiscoveryContext, + resolveMessageActionDiscoveryChannelId, +} from "./message-action-discovery.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; import type { ChannelMessageActionContext, @@ -124,27 +128,17 @@ export function listChannelMessageCapabilitiesForChannel(params: { agentId?: string | null; requesterSenderId?: string | null; }): ChannelMessageCapability[] { - if (!params.channel) { + const channelId = resolveMessageActionDiscoveryChannelId(params.channel); + if (!channelId) { return []; } - const plugin = getChannelPlugin(params.channel as Parameters[0]); + const plugin = getChannelPlugin(channelId as Parameters[0]); return plugin?.actions ? Array.from( listCapabilities({ pluginId: plugin.id, actions: plugin.actions, - context: { - cfg: params.cfg, - currentChannelProvider: params.channel, - currentChannelId: params.currentChannelId, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.accountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - }, + context: createMessageActionDiscoveryContext(params), }), ) : []; @@ -204,19 +198,9 @@ export function resolveChannelMessageToolSchemaProperties(params: { }): Record { const properties: Record = {}; const plugins = listChannelPlugins(); - const currentChannel = params.channel?.trim() || undefined; - const discoveryBase: ChannelMessageActionDiscoveryContext = { - cfg: params.cfg, - currentChannelId: params.currentChannelId, - currentChannelProvider: currentChannel, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.accountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - }; + const currentChannel = resolveMessageActionDiscoveryChannelId(params.channel); + const discoveryBase: ChannelMessageActionDiscoveryContext = + createMessageActionDiscoveryContext(params); for (const plugin of plugins) { const getToolSchema = plugin?.actions?.getToolSchema;