diff --git a/CHANGELOG.md b/CHANGELOG.md index cc32df92827..d6774cb35cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - Plugins/Anthropic: expose Claude thinking profiles from the bundled provider-policy artifact so non-runtime callers keep Opus 4.7 `adaptive`, `xhigh`, and `max` instead of downgrading to `high`. Fixes #76779. Thanks @tomascupr and @iAbhi001. - Discord/native commands: skip slash-command registration and cleanup REST calls when `channels.discord.commands.native=false`, letting low-power gateways start without waiting on disabled native-command lifecycle requests. Fixes #76202. Thanks @vincentkoc. - CLI/plugins: reject unowned command roots such as `openclaw foo` before managed proxy startup and full plugin CLI runtime loading while preserving manifest-owned and CLI-metadata-owned plugin commands. Fixes #75287. Thanks @neilofneils404. +- CLI/message: skip local configured-channel plugin preload for explicit gateway-owned message actions, letting normalized CLI delivery delegate to the gateway without initializing channel runtime in the short-lived CLI process. Fixes #75477. - Plugins/commands: normalize empty plugin command handler results and let Telegram native plugin commands send the empty-response fallback instead of throwing when a handler returns `undefined`. Fixes #74800. Thanks @vincentkoc. - Plugins/tools: cold-load selected plugin tool registries when the active registry only has partial tool coverage, so wildcard-expanded allowlists no longer hide installed plugin tools from `tools.effective`. Fixes #76780. Thanks @lilesjtu. - Plugins/tools: compare cached and runtime plugin tool name conflicts with normalized core tool names, so case variants of core tools are blocked instead of leaking duplicate tool registrations. Thanks @vincentkoc. diff --git a/src/cli/program/message/helpers.test.ts b/src/cli/program/message/helpers.test.ts index f5798617177..643821df03c 100644 --- a/src/cli/program/message/helpers.test.ts +++ b/src/cli/program/message/helpers.test.ts @@ -5,6 +5,11 @@ vi.mock("../../../commands/message.js", () => ({ messageCommand: messageCommandMock, })); +const getChannelPluginMock = vi.fn(); +vi.mock("../../../channels/plugins/index.js", () => ({ + getChannelPlugin: getChannelPluginMock, +})); + vi.mock("../../../globals.js", () => ({ danger: (s: string) => s, setVerbose: vi.fn(), @@ -70,6 +75,14 @@ async function runSendAction(opts: Record = {}) { await expect(runMessageAction("send", { ...baseSendOptions, ...opts })).rejects.toThrow("exit"); } +function mockChannelExecutionModes(modes: Record = {}) { + getChannelPluginMock.mockImplementation((id: string) => ({ + actions: { + resolveExecutionMode: () => modes[id] ?? "local", + }, + })); +} + function expectNoAccountFieldInPassedOptions() { const passedOpts = ( messageCommandMock.mock.calls as unknown as Array<[Record]> @@ -84,6 +97,8 @@ function expectNoAccountFieldInPassedOptions() { describe("runMessageAction", () => { beforeEach(() => { vi.clearAllMocks(); + getChannelPluginMock.mockReset(); + mockChannelExecutionModes({ telegram: "gateway" }); messageCommandMock.mockClear().mockResolvedValue(undefined); hasHooksMock.mockClear().mockReturnValue(false); runGatewayStopMock.mockClear().mockResolvedValue(undefined); @@ -113,12 +128,152 @@ describe("runMessageAction", () => { }); it("narrows plugin loading from a channel-prefixed target", async () => { - await runSendAction({ channel: undefined, target: "telegram:12345" }); + await runSendAction({ channel: undefined, target: "discord:channel:12345" }); + + expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + onlyChannelIds: ["discord"], + }); + }); + + it("skips local plugin preload for any gateway-owned scoped channel action", async () => { + mockChannelExecutionModes({ discord: "gateway" }); + + await runSendAction({ target: "channel:12345" }); + + expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + expect(messageCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "send", + channel: "discord", + target: "channel:12345", + message: "hi", + }), + expect.anything(), + expect.anything(), + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + it("keeps broadcast on the local preload path for same-channel prefixed targets", async () => { + const runMessageAction = createRunMessageAction(); + + await expect( + runMessageAction("broadcast", { + targets: ["telegram:1", "telegram:2"], + message: "hi", + }), + ).rejects.toThrow("exit"); expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "configured-channels", onlyChannelIds: ["telegram"], }); + expect(messageCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "broadcast", + targets: ["telegram:1", "telegram:2"], + message: "hi", + }), + expect.anything(), + expect.anything(), + ); + }); + + it("keeps unknown actions on the local preload path", async () => { + mockChannelExecutionModes({ discord: "gateway" }); + const runMessageAction = createRunMessageAction(); + + await expect( + runMessageAction("custom-action", { + ...baseSendOptions, + target: "channel:12345", + }), + ).rejects.toThrow("exit"); + + expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + onlyChannelIds: ["discord"], + }); + expect(messageCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "custom-action", + }), + expect.anything(), + expect.anything(), + ); + }); + + it("preloads when the scoped channel plugin is not cheaply available", async () => { + getChannelPluginMock.mockReturnValue(undefined); + + await runSendAction({ target: "channel:12345" }); + + expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + onlyChannelIds: ["discord"], + }); + }); + + it("keeps target-prefixed Telegram sends from local plugin preload", async () => { + await runSendAction({ channel: undefined, target: "telegram:12345" }); + + expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + expect(messageCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "send", + target: "telegram:12345", + message: "hi", + }), + expect.anything(), + expect.anything(), + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + it("keeps explicit Telegram sends on the normal command path without local plugin preload", async () => { + await runSendAction({ + channel: "telegram", + account: "default", + target: "@ops", + media: "./diagram.png", + presentation: '{"blocks":[{"type":"buttons","buttons":[{"label":"OK","value":"ok"}]}]}', + delivery: '{"pin":true}', + forceDocument: true, + }); + + expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + expect(messageCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "send", + channel: "telegram", + accountId: "default", + target: "@ops", + message: "hi", + media: "./diagram.png", + presentation: '{"blocks":[{"type":"buttons","buttons":[{"label":"OK","value":"ok"}]}]}', + delivery: '{"pin":true}', + forceDocument: true, + }), + expect.anything(), + expect.anything(), + ); + expectNoAccountFieldInPassedOptions(); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + it("keeps Telegram dry-runs on the local preload path for local validation", async () => { + await runSendAction({ + channel: "telegram", + target: "@ops", + dryRun: true, + }); + + expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + onlyChannelIds: ["telegram"], + }); + expect(messageCommandMock).toHaveBeenCalled(); }); it("loads configured channel plugins for mixed broadcast target prefixes", async () => { diff --git a/src/cli/program/message/helpers.ts b/src/cli/program/message/helpers.ts index 658bc8e5011..8f950ccff51 100644 --- a/src/cli/program/message/helpers.ts +++ b/src/cli/program/message/helpers.ts @@ -1,4 +1,9 @@ import type { Command } from "commander"; +import { getChannelPlugin } from "../../../channels/plugins/index.js"; +import { + CHANNEL_MESSAGE_ACTION_NAMES, + type ChannelMessageActionName, +} from "../../../channels/plugins/types.public.js"; import { resolveMessageSecretScope } from "../../../cli/message-secret-scope.js"; import { messageCommand } from "../../../commands/message.js"; import { danger, setVerbose } from "../../../globals.js"; @@ -18,6 +23,13 @@ export type MessageCliHelpers = { const GATEWAY_STOP_TIMEOUT_MS = 2500; const ACTIONS_WITHOUT_STOP_HOOKS = new Set(["read"]); +const ACTIONS_REQUIRING_CONFIGURED_CHANNEL_PRELOAD = new Set(["broadcast"]); +const CHANNEL_MESSAGE_ACTION_NAME_SET = new Set(CHANNEL_MESSAGE_ACTION_NAMES); + +type MessagePluginLoadOptions = { scope: PluginRegistryScope; onlyChannelIds?: string[] }; +type MessagePluginPreloadPlan = + | { preload: true; loadOptions: MessagePluginLoadOptions } + | { preload: false }; function normalizeMessageOptions(opts: Record): Record { const { account, ...rest } = opts; @@ -49,18 +61,48 @@ async function runPluginStopHooks(): Promise { } } -function resolveMessagePluginLoadOptions( - opts: Record, -): { scope: PluginRegistryScope; onlyChannelIds?: string[] } | undefined { - const scopedChannel = resolveMessageSecretScope({ +function resolveScopedMessageChannel(opts: Record): string | undefined { + return resolveMessageSecretScope({ channel: opts.channel, target: opts.target, targets: opts.targets, }).channel; - if (scopedChannel) { - return { scope: "configured-channels", onlyChannelIds: [scopedChannel] }; +} + +function asChannelMessageActionName(action: string): ChannelMessageActionName | undefined { + return CHANNEL_MESSAGE_ACTION_NAME_SET.has(action) + ? (action as ChannelMessageActionName) + : undefined; +} + +function isGatewayOwnedMessageAction(action: string, scopedChannel: string | undefined): boolean { + const messageAction = asChannelMessageActionName(action); + if (!messageAction || !scopedChannel) { + return false; } - return { scope: "configured-channels" }; + const plugin = getChannelPlugin(scopedChannel); + const executionMode = plugin?.actions?.resolveExecutionMode?.({ + action: messageAction, + }); + return executionMode === "gateway"; +} + +function resolveMessagePluginPreloadPlan( + action: string, + opts: Record, +): MessagePluginPreloadPlan { + const scopedChannel = resolveScopedMessageChannel(opts); + const loadOptions = scopedChannel + ? { scope: "configured-channels" as const, onlyChannelIds: [scopedChannel] } + : { scope: "configured-channels" as const }; + if ( + opts.dryRun === true || + ACTIONS_REQUIRING_CONFIGURED_CHANNEL_PRELOAD.has(action) || + !isGatewayOwnedMessageAction(action, scopedChannel) + ) { + return { preload: true, loadOptions }; + } + return { preload: false }; } export function createMessageCliHelpers( @@ -86,7 +128,10 @@ export function createMessageCliHelpers( await runCommandWithRuntime( defaultRuntime, async () => { - ensurePluginRegistryLoaded(resolveMessagePluginLoadOptions(opts)); + const preloadPlan = resolveMessagePluginPreloadPlan(action, opts); + if (preloadPlan.preload) { + ensurePluginRegistryLoaded(preloadPlan.loadOptions); + } const deps = createDefaultDeps(); await messageCommand( {