Plugin SDK: normalize and harden message action discovery

This commit is contained in:
Gustavo Madeira Santana
2026-03-17 23:55:00 +00:00
parent df284fec27
commit a32c7e16d2
7 changed files with 186 additions and 84 deletions

View File

@@ -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"]);
});
});

View File

@@ -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<typeof getChannelPlugin>[0]);
const plugin = getChannelPlugin(channelId as Parameters<typeof getChannelPlugin>[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);
}

View File

@@ -86,6 +86,7 @@ function createChannelPlugin(params: {
label: string;
docsPath: string;
blurb: string;
aliases?: string[];
actions?: ChannelMessageActionName[];
listActions?: NonNullable<NonNullable<ChannelPlugin["actions"]>["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 }]),

View File

@@ -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<string, unknown> = {
const props: Record<string, TSchema> = {
message: Type.Optional(Type.String()),
effectId: Type.Optional(
Type.String({
@@ -167,7 +167,7 @@ function buildFetchSchema() {
}
function buildPollSchema() {
const props: Record<string, unknown> = {
const props: Record<string, TSchema> = {
pollId: Type.Optional(Type.String()),
pollOptionId: Type.Optional(
Type.String({
@@ -346,7 +346,7 @@ function buildChannelManagementSchema() {
function buildMessageToolSchemaProps(options: {
includeInteractive: boolean;
extraProperties?: Record<string, unknown>;
extraProperties?: Record<string, TSchema>;
}) {
return {
...buildRoutingSchema(),
@@ -370,7 +370,7 @@ function buildMessageToolSchemaFromActions(
actions: readonly string[],
options: {
includeInteractive: boolean;
extraProperties?: Record<string, unknown>;
extraProperties?: Record<string, TSchema>;
},
) {
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(", ")}.`;
}

View File

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

View File

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

View File

@@ -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<typeof getChannelPlugin>[0]);
const plugin = getChannelPlugin(channelId as Parameters<typeof getChannelPlugin>[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<string, TSchema> {
const properties: Record<string, TSchema> = {};
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;