diff --git a/extensions/slack/message-tool-api.ts b/extensions/slack/message-tool-api.ts new file mode 100644 index 00000000000..9610b751ec2 --- /dev/null +++ b/extensions/slack/message-tool-api.ts @@ -0,0 +1 @@ +export { describeSlackMessageTool as describeMessageTool } from "./src/message-tool-api.js"; diff --git a/extensions/slack/src/channel-actions.ts b/extensions/slack/src/channel-actions.ts index 647cbb2e008..2156ea6a4b7 100644 --- a/extensions/slack/src/channel-actions.ts +++ b/extensions/slack/src/channel-actions.ts @@ -1,14 +1,9 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { Type } from "@sinclair/typebox"; -import { - type ChannelMessageActionAdapter, - type ChannelMessageToolDiscovery, -} from "openclaw/plugin-sdk/channel-contract"; +import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract"; import type { SlackActionContext } from "./action-runtime.js"; -import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { handleSlackMessageAction } from "./message-action-dispatch.js"; -import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js"; -import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js"; +import { extractSlackToolSend } from "./message-actions.js"; +import { describeSlackMessageTool } from "./message-tool-api.js"; import { resolveSlackChannelId } from "./targets.js"; type SlackActionInvoke = ( @@ -28,35 +23,8 @@ export function createSlackActions( providerId: string, options?: { invoke?: SlackActionInvoke }, ): ChannelMessageActionAdapter { - function describeMessageTool({ - cfg, - accountId, - }: Parameters< - NonNullable - >[0]): ChannelMessageToolDiscovery { - const actions = listSlackMessageActions(cfg, accountId); - const capabilities = new Set<"blocks" | "interactive">(); - if (actions.includes("send")) { - capabilities.add("blocks"); - } - if (isSlackInteractiveRepliesEnabled({ cfg, accountId })) { - capabilities.add("interactive"); - } - return { - actions, - capabilities: Array.from(capabilities), - schema: actions.includes("send") - ? { - properties: { - blocks: Type.Optional(createSlackMessageToolBlocksSchema()), - }, - } - : null, - }; - } - return { - describeMessageTool, + describeMessageTool: describeSlackMessageTool, extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => { return await handleSlackMessageAction({ diff --git a/extensions/slack/src/message-tool-api.test.ts b/extensions/slack/src/message-tool-api.test.ts new file mode 100644 index 00000000000..d3c9c7b6c2f --- /dev/null +++ b/extensions/slack/src/message-tool-api.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { describeSlackMessageTool } from "./message-tool-api.js"; + +describe("Slack message tool public API", () => { + it("describes configured Slack message actions without loading channel runtime", () => { + expect( + describeSlackMessageTool({ + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + }, + }, + }, + }), + ).toMatchObject({ + actions: expect.arrayContaining(["send", "upload-file", "read"]), + capabilities: expect.arrayContaining(["blocks"]), + }); + }); + + it("honors account-scoped action gates", () => { + expect( + describeSlackMessageTool({ + cfg: { + channels: { + slack: { + botToken: "xoxb-default", + accounts: { + ops: { + botToken: "xoxb-ops", + actions: { + messages: false, + }, + }, + }, + }, + }, + }, + accountId: "ops", + }).actions, + ).not.toContain("upload-file"); + }); +}); diff --git a/extensions/slack/src/message-tool-api.ts b/extensions/slack/src/message-tool-api.ts new file mode 100644 index 00000000000..4fd5fb7f2eb --- /dev/null +++ b/extensions/slack/src/message-tool-api.ts @@ -0,0 +1,30 @@ +import { Type } from "@sinclair/typebox"; +import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract"; +import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; +import { listSlackMessageActions } from "./message-actions.js"; +import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js"; + +export function describeSlackMessageTool({ + cfg, + accountId, +}: Parameters>[0]) { + const actions = listSlackMessageActions(cfg, accountId); + const capabilities = new Set<"blocks" | "interactive">(); + if (actions.includes("send")) { + capabilities.add("blocks"); + } + if (isSlackInteractiveRepliesEnabled({ cfg, accountId })) { + capabilities.add("interactive"); + } + return { + actions, + capabilities: Array.from(capabilities), + schema: actions.includes("send") + ? { + properties: { + blocks: Type.Optional(createSlackMessageToolBlocksSchema()), + }, + } + : null, + }; +} diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 5273d8c6e5a..aad6d103f0c 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -3,6 +3,7 @@ import { createMessageActionDiscoveryContext, resolveMessageActionDiscoveryForPlugin, resolveMessageActionDiscoveryChannelId, + resolveCurrentChannelMessageToolDiscoveryAdapter, __testing as messageActionTesting, } from "../channels/plugins/message-action-discovery.js"; import type { @@ -50,13 +51,13 @@ export function listChannelSupportedActions(params: { if (!channelId) { return []; } - const plugin = getChannelPlugin(channelId as Parameters[0]); - if (!plugin?.actions) { + const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter(channelId); + if (!pluginActions?.actions) { return []; } return resolveMessageActionDiscoveryForPlugin({ - pluginId: plugin.id, - actions: plugin.actions, + pluginId: pluginActions.pluginId, + actions: pluginActions.actions, context: createMessageActionDiscoveryContext(params), includeActions: true, }).actions; diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts index c07fe442085..16bf55329a0 100644 --- a/src/channels/plugins/message-action-discovery.ts +++ b/src/channels/plugins/message-action-discovery.ts @@ -4,8 +4,12 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { normalizeAnyChannelId } from "../registry.js"; -import { getChannelPlugin, listChannelPlugins } from "./index.js"; +import { getChannelPlugin, getLoadedChannelPlugin, listChannelPlugins } from "./index.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; +import { + resolveBundledChannelMessageToolDiscoveryAdapter, + type ChannelMessageToolDiscoveryAdapter, +} from "./message-tool-api.js"; import type { ChannelMessageActionDiscoveryContext, ChannelMessageActionName, @@ -28,8 +32,6 @@ export type ChannelMessageActionDiscoveryInput = { senderIsOwner?: boolean; }; -type ChannelActions = NonNullable>["actions"]>; - const loggedMessageActionErrors = new Set(); export function resolveMessageActionDiscoveryChannelId(raw?: string | null): string | undefined { @@ -77,7 +79,7 @@ function logMessageActionError(params: { function describeMessageToolSafely(params: { pluginId: string; context: ChannelMessageActionDiscoveryContext; - describeMessageTool: NonNullable; + describeMessageTool: NonNullable; }): ChannelMessageToolDiscovery | null { try { return params.describeMessageTool(params.context) ?? null; @@ -133,14 +135,28 @@ function normalizeMessageToolMediaSourceParams( ); } -function resolveCurrentChannelPluginActions(channel?: string): { +export function resolveCurrentChannelMessageToolDiscoveryAdapter(channel?: string): { pluginId: string; - actions: ChannelActions; + actions: ChannelMessageToolDiscoveryAdapter; } | null { const channelId = resolveMessageActionDiscoveryChannelId(channel); if (!channelId) { return null; } + const loadedPlugin = getLoadedChannelPlugin(channelId as Parameters[0]); + if (loadedPlugin?.actions) { + return { + pluginId: loadedPlugin.id, + actions: loadedPlugin.actions, + }; + } + const bundledActions = resolveBundledChannelMessageToolDiscoveryAdapter(channelId); + if (bundledActions) { + return { + pluginId: channelId, + actions: bundledActions, + }; + } const plugin = getChannelPlugin(channelId as Parameters[0]); if (!plugin?.actions) { return null; @@ -153,7 +169,7 @@ function resolveCurrentChannelPluginActions(channel?: string): { export function resolveMessageActionDiscoveryForPlugin(params: { pluginId: string; - actions?: ChannelActions; + actions?: ChannelMessageToolDiscoveryAdapter; context: ChannelMessageActionDiscoveryContext; action?: ChannelMessageActionName; includeActions?: boolean; @@ -235,7 +251,7 @@ export function listChannelMessageCapabilitiesForChannel(params: { requesterSenderId?: string | null; senderIsOwner?: boolean; }): ChannelMessageCapability[] { - const pluginActions = resolveCurrentChannelPluginActions(params.channel); + const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter(params.channel); if (!pluginActions) { return []; } @@ -279,11 +295,13 @@ export function resolveChannelMessageToolSchemaProperties(params: { const properties: Record = {}; const currentChannel = resolveMessageActionDiscoveryChannelId(params.channel); const discoveryBase = createMessageActionDiscoveryContext(params); + const seenPluginIds = new Set(); for (const plugin of listChannelPlugins()) { if (!plugin.actions) { continue; } + seenPluginIds.add(plugin.id); for (const contribution of resolveMessageActionDiscoveryForPlugin({ pluginId: plugin.id, actions: plugin.actions, @@ -300,6 +318,22 @@ export function resolveChannelMessageToolSchemaProperties(params: { mergeToolSchemaProperties(properties, contribution.properties); } } + if (currentChannel && !seenPluginIds.has(currentChannel)) { + const currentActions = resolveCurrentChannelMessageToolDiscoveryAdapter(currentChannel); + if (currentActions?.actions) { + for (const contribution of resolveMessageActionDiscoveryForPlugin({ + pluginId: currentActions.pluginId, + actions: currentActions.actions, + context: discoveryBase, + includeSchema: true, + }).schemaContributions) { + const visibility = contribution.visibility ?? "current-channel"; + if (visibility === "all-configured" || currentActions.pluginId === currentChannel) { + mergeToolSchemaProperties(properties, contribution.properties); + } + } + } + } return properties; } @@ -318,7 +352,7 @@ export function resolveChannelMessageToolMediaSourceParamKeys(params: { requesterSenderId?: string | null; senderIsOwner?: boolean; }): string[] { - const pluginActions = resolveCurrentChannelPluginActions(params.channel); + const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter(params.channel); if (!pluginActions) { return []; } diff --git a/src/channels/plugins/message-tool-api.test.ts b/src/channels/plugins/message-tool-api.test.ts new file mode 100644 index 00000000000..30bd5dd9421 --- /dev/null +++ b/src/channels/plugins/message-tool-api.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({ + loadBundledPluginPublicArtifactModuleSyncMock: vi.fn( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + if (dirName === "slack" && artifactBasename === "message-tool-api.js") { + return { + describeMessageTool: () => ({ + actions: ["send", "upload-file"], + capabilities: ["blocks"], + schema: null, + }), + }; + } + if (dirName === "empty" && artifactBasename === "message-tool-api.js") { + return {}; + } + if (dirName === "broken" && artifactBasename === "message-tool-api.js") { + throw new Error("broken message tool artifact"); + } + throw new Error( + `Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`, + ); + }, + ), +})); + +vi.mock("../../plugins/public-surface-loader.js", () => ({ + loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, +})); + +import { + __testing, + describeBundledChannelMessageTool, + resolveBundledChannelMessageToolDiscoveryAdapter, +} from "./message-tool-api.js"; + +describe("bundled channel message tool fast path", () => { + beforeEach(() => { + __testing.clearMessageToolApiCache(); + loadBundledPluginPublicArtifactModuleSyncMock.mockClear(); + }); + + it("loads message tool discovery from the narrow artifact", () => { + const adapter = resolveBundledChannelMessageToolDiscoveryAdapter("slack"); + expect(adapter?.describeMessageTool?.({ cfg: {} })).toMatchObject({ + actions: ["send", "upload-file"], + capabilities: ["blocks"], + }); + expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ + dirName: "slack", + artifactBasename: "message-tool-api.js", + }); + }); + + it("describes message tools through the same artifact", () => { + expect( + describeBundledChannelMessageTool({ + channelId: "slack", + context: { cfg: {} }, + }), + ).toMatchObject({ + actions: ["send", "upload-file"], + capabilities: ["blocks"], + }); + }); + + it("treats missing artifacts as absent discovery", () => { + expect(resolveBundledChannelMessageToolDiscoveryAdapter("discord")).toBeUndefined(); + expect( + describeBundledChannelMessageTool({ + channelId: "discord", + context: { cfg: {} }, + }), + ).toBeUndefined(); + }); + + it("ignores present artifacts without discovery", () => { + expect(resolveBundledChannelMessageToolDiscoveryAdapter("empty")).toBeUndefined(); + }); + + it("surfaces errors from present message tool artifacts", () => { + expect(() => resolveBundledChannelMessageToolDiscoveryAdapter("broken")).toThrow( + "broken message tool artifact", + ); + }); +}); diff --git a/src/channels/plugins/message-tool-api.ts b/src/channels/plugins/message-tool-api.ts new file mode 100644 index 00000000000..b61852981ce --- /dev/null +++ b/src/channels/plugins/message-tool-api.ts @@ -0,0 +1,63 @@ +import { loadBundledPluginPublicArtifactModuleSync } from "../../plugins/public-surface-loader.js"; +import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery } from "./types.public.js"; + +export type ChannelMessageToolDiscoveryAdapter = Pick< + ChannelMessageActionAdapter, + "describeMessageTool" +>; + +type MessageToolApi = { + describeMessageTool?: ChannelMessageToolDiscoveryAdapter["describeMessageTool"]; +}; + +const MESSAGE_TOOL_API_ARTIFACT_BASENAME = "message-tool-api.js"; +const MISSING_PUBLIC_SURFACE_PREFIX = "Unable to resolve bundled plugin public surface "; +const messageToolApiCache = new Map(); + +function loadBundledChannelMessageToolApi(channelId: string): MessageToolApi | undefined { + const cacheKey = channelId.trim(); + if (messageToolApiCache.has(cacheKey)) { + return messageToolApiCache.get(cacheKey); + } + try { + const loaded = loadBundledPluginPublicArtifactModuleSync({ + dirName: cacheKey, + artifactBasename: MESSAGE_TOOL_API_ARTIFACT_BASENAME, + }); + messageToolApiCache.set(cacheKey, loaded); + return loaded; + } catch (error) { + if (error instanceof Error && error.message.startsWith(MISSING_PUBLIC_SURFACE_PREFIX)) { + messageToolApiCache.set(cacheKey, undefined); + return undefined; + } + throw error; + } +} + +export function resolveBundledChannelMessageToolDiscoveryAdapter( + channelId: string, +): ChannelMessageToolDiscoveryAdapter | undefined { + const describeMessageTool = loadBundledChannelMessageToolApi(channelId)?.describeMessageTool; + if (typeof describeMessageTool !== "function") { + return undefined; + } + return { describeMessageTool }; +} + +export function describeBundledChannelMessageTool(params: { + channelId: string; + context: Parameters>[0]; +}): ChannelMessageToolDiscovery | null | undefined { + const describeMessageTool = loadBundledChannelMessageToolApi( + params.channelId, + )?.describeMessageTool; + if (typeof describeMessageTool !== "function") { + return undefined; + } + return describeMessageTool(params.context) ?? null; +} + +export const __testing = { + clearMessageToolApiCache: () => messageToolApiCache.clear(), +};