mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
fix(cli): avoid local preload for gateway-owned message actions (#76898)
* fix(cli): avoid Telegram send preload * fix(cli): include telegram target prefixes * fix(cli): generalize gateway message preload skip * refactor(cli): clarify message preload planning * fix(cli): keep broadcast plugin preload
This commit is contained in:
committed by
GitHub
parent
9f2f75ff02
commit
6529cad499
@@ -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.
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
await expect(runMessageAction("send", { ...baseSendOptions, ...opts })).rejects.toThrow("exit");
|
||||
}
|
||||
|
||||
function mockChannelExecutionModes(modes: Record<string, "gateway" | "local"> = {}) {
|
||||
getChannelPluginMock.mockImplementation((id: string) => ({
|
||||
actions: {
|
||||
resolveExecutionMode: () => modes[id] ?? "local",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function expectNoAccountFieldInPassedOptions() {
|
||||
const passedOpts = (
|
||||
messageCommandMock.mock.calls as unknown as Array<[Record<string, unknown>]>
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string>(CHANNEL_MESSAGE_ACTION_NAMES);
|
||||
|
||||
type MessagePluginLoadOptions = { scope: PluginRegistryScope; onlyChannelIds?: string[] };
|
||||
type MessagePluginPreloadPlan =
|
||||
| { preload: true; loadOptions: MessagePluginLoadOptions }
|
||||
| { preload: false };
|
||||
|
||||
function normalizeMessageOptions(opts: Record<string, unknown>): Record<string, unknown> {
|
||||
const { account, ...rest } = opts;
|
||||
@@ -49,18 +61,48 @@ async function runPluginStopHooks(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMessagePluginLoadOptions(
|
||||
opts: Record<string, unknown>,
|
||||
): { scope: PluginRegistryScope; onlyChannelIds?: string[] } | undefined {
|
||||
const scopedChannel = resolveMessageSecretScope({
|
||||
function resolveScopedMessageChannel(opts: Record<string, unknown>): 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<string, unknown>,
|
||||
): 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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user