mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-21 15:01:03 +00:00
Plugin SDK: normalize and harden message action discovery
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }]),
|
||||
|
||||
@@ -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(", ")}.`;
|
||||
}
|
||||
|
||||
46
src/channels/plugins/message-action-discovery.ts
Normal file
46
src/channels/plugins/message-action-discovery.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user