fix: route telegram cli sends through gateway

This commit is contained in:
Peter Steinberger
2026-04-28 03:00:48 +01:00
parent 662d5de746
commit 0835f9409a
5 changed files with 281 additions and 21 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/memory: skip eager context-window warmup for `openclaw memory` commands so memory search does not race unrelated model metadata discovery. Fixes #73123. Thanks @oalansilva and @neeravmakwana.
- CLI/Telegram: route Telegram `message send` and poll actions through the running Gateway when available, so packaged installs use the staged `grammy` runtime deps and CLI sends return instead of hanging after the Telegram channel is active. Fixes #73140. Thanks @oalansilva.
- Cron/providers: preflight local Ollama and OpenAI-compatible provider endpoints before isolated cron agent turns, record unreachable local providers as skipped runs, and cache dead-endpoint probes so many jobs do not hammer the same stopped local server. Fixes #58584. Thanks @jpeghead.
- Gateway/config: let config reload continue in degraded mode when invalidity is scoped to plugin entries, so incompatible plugin configs can be skipped and the Gateway restart can still pick up the rest of the config after rollbacks. Fixes #73131. Thanks @Adam-Researchh.
- Doctor/channels: suppress disabled bundled-plugin blocker warnings when a trusted external plugin owns the configured channel, so Lark/Feishu installs no longer get Feishu repair noise after switching to `openclaw-lark`. Fixes #56794. Thanks @wuji-tech-dev.

View File

@@ -20,6 +20,12 @@ describe("telegramMessageActions", () => {
telegramMessageActionRuntime.handleTelegramAction = originalHandleTelegramAction;
});
it("executes message actions in the gateway when a gateway is available", () => {
for (const action of ["send", "poll", "react", "delete", "edit"] as const) {
expect(telegramMessageActions.resolveExecutionMode?.({ action })).toBe("gateway");
}
});
it("allows interactive-only sends", async () => {
await telegramMessageActions.handleAction!({
action: "send",

View File

@@ -160,6 +160,7 @@ function describeTelegramMessageTool({
export const telegramMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: describeTelegramMessageTool,
resolveExecutionMode: () => "gateway",
resolveCliActionRequest: ({ action, args }) => {
if (action !== "thread-create") {
return { action, args };

View File

@@ -412,6 +412,94 @@ describe("runMessageAction plugin dispatch", () => {
});
});
it("routes gateway-executed plugin sends through gateway RPC instead of local dispatch", async () => {
const handleAction = vi.fn(async () => jsonResult({ ok: true, local: true }));
const gatewayPlugin: ChannelPlugin = {
id: "gatewaychat",
meta: {
id: "gatewaychat",
label: "Gateway Chat",
selectionLabel: "Gateway Chat",
docsPath: "/channels/gatewaychat",
blurb: "Gateway Chat send test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
messaging: {
targetResolver: {
looksLikeId: () => true,
},
},
actions: {
describeMessageTool: () => ({ actions: ["send"] }),
supportsAction: ({ action }) => action === "send",
resolveExecutionMode: ({ action }) => (action === "send" ? "gateway" : "local"),
handleAction,
},
};
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "gatewaychat",
source: "test",
plugin: gatewayPlugin,
},
]),
);
mocks.callGatewayLeastPrivilege.mockResolvedValue({
ok: true,
messageId: "gw-send-1",
});
const result = await runMessageAction({
cfg: {
channels: {
gatewaychat: {
enabled: true,
},
},
} as OpenClawConfig,
action: "send",
params: {
channel: "gatewaychat",
target: "user-123",
message: "hello from cli",
},
gateway: {
clientName: "cli",
mode: "cli",
},
dryRun: false,
});
expect(mocks.callGatewayLeastPrivilege).toHaveBeenCalledWith(
expect.objectContaining({
method: "message.action",
params: expect.objectContaining({
channel: "gatewaychat",
action: "send",
params: expect.objectContaining({
to: "user-123",
message: "hello from cli",
}),
idempotencyKey: "idem-gateway-action",
}),
}),
);
expect(mocks.executeSendAction).not.toHaveBeenCalled();
expect(handleAction).not.toHaveBeenCalled();
expect(result).toMatchObject({
kind: "send",
channel: "gatewaychat",
action: "send",
handledBy: "plugin",
payload: {
ok: true,
messageId: "gw-send-1",
},
});
});
it("uses requester session channel policy for host-media reads", async () => {
const handlePolicyCheckedAction = vi.fn(async ({ mediaAccess }) =>
jsonResult({
@@ -912,6 +1000,86 @@ describe("runMessageAction plugin dispatch", () => {
},
});
});
it("routes gateway-executed plugin polls through gateway RPC instead of local dispatch", async () => {
const handleAction = vi.fn(async () => jsonResult({ ok: true, local: true }));
const pollGatewayPlugin = createPollForwardingPlugin({
pluginId: "pollchat",
label: "Poll Chat",
blurb: "Poll chat gateway forwarding test plugin.",
handleAction,
});
const baseActions = pollGatewayPlugin.actions!;
pollGatewayPlugin.actions = {
describeMessageTool: baseActions.describeMessageTool,
supportsAction: baseActions.supportsAction,
handleAction: baseActions.handleAction,
resolveExecutionMode: ({ action }) => (action === "poll" ? "gateway" : "local"),
};
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "pollchat",
source: "test",
plugin: pollGatewayPlugin,
},
]),
);
mocks.callGatewayLeastPrivilege.mockResolvedValue({
ok: true,
pollId: "gw-poll-1",
});
const result = await runMessageAction({
cfg: {
channels: {
pollchat: {
botToken: "tok",
},
},
} as OpenClawConfig,
action: "poll",
params: {
channel: "pollchat",
target: "pollchat:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
},
gateway: {
clientName: "cli",
mode: "cli",
},
dryRun: false,
});
expect(mocks.callGatewayLeastPrivilege).toHaveBeenCalledWith(
expect.objectContaining({
method: "message.action",
params: expect.objectContaining({
channel: "pollchat",
action: "poll",
params: expect.objectContaining({
to: "pollchat:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
}),
idempotencyKey: "idem-gateway-action",
}),
}),
);
expect(mocks.executePollAction).not.toHaveBeenCalled();
expect(handleAction).not.toHaveBeenCalled();
expect(result).toMatchObject({
kind: "poll",
channel: "pollchat",
action: "poll",
handledBy: "plugin",
payload: {
ok: true,
pollId: "gw-poll-1",
},
});
});
});
describe("plugin-owned poll semantics", () => {

View File

@@ -365,6 +365,51 @@ type ResolvedActionContext = {
resolvedTarget?: ResolvedMessagingTarget;
abortSignal?: AbortSignal;
};
async function maybeCallGatewayPluginMessageAction(params: {
cfg: OpenClawConfig;
params: Record<string, unknown>;
channel: ChannelId;
action: ChannelMessageActionName;
accountId?: string | null;
dryRun: boolean;
gateway?: MessageActionRunnerGateway;
input: RunMessageActionParams;
agentId?: string;
}): Promise<{ payload: unknown } | null> {
if (params.dryRun || !params.gateway) {
return null;
}
const plugin = resolveOutboundChannelPlugin({ channel: params.channel, cfg: params.cfg });
if (!plugin?.actions?.handleAction) {
return null;
}
const executionMode = plugin.actions.resolveExecutionMode?.({ action: params.action }) ?? "local";
if (executionMode !== "gateway") {
return null;
}
return {
payload: await callGatewayMessageAction<unknown>({
gateway: params.gateway,
actionParams: {
channel: params.channel,
action: params.action,
params: params.params,
accountId: params.accountId ?? undefined,
requesterSenderId: params.input.requesterSenderId ?? undefined,
senderIsOwner: params.input.senderIsOwner,
sessionKey: params.input.sessionKey,
sessionId: params.input.sessionId,
agentId: params.agentId,
toolContext: params.input.toolContext,
idempotencyKey: await resolveGatewayActionIdempotencyKey(
normalizeOptionalString(params.params.idempotencyKey),
),
},
}),
};
}
function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined {
if (!input.gateway) {
return undefined;
@@ -590,6 +635,30 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const mirrorMediaUrls =
mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined;
throwIfAborted(abortSignal);
const gatewayPluginAction = await maybeCallGatewayPluginMessageAction({
cfg,
params,
channel,
action,
accountId,
dryRun,
gateway,
input,
agentId,
});
if (gatewayPluginAction) {
return {
kind: "send",
channel,
action,
to,
handledBy: "plugin",
payload: gatewayPluginAction.payload,
dryRun,
};
}
const send = await executeSendAction({
ctx: {
cfg,
@@ -674,6 +743,29 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
preferPresentation: false,
});
const gatewayPluginAction = await maybeCallGatewayPluginMessageAction({
cfg,
params,
channel,
action,
accountId,
dryRun,
gateway,
input,
agentId,
});
if (gatewayPluginAction) {
return {
kind: "poll",
channel,
action,
to,
handledBy: "plugin",
payload: gatewayPluginAction.payload,
dryRun,
};
}
const poll = await executePollAction({
ctx: {
cfg,
@@ -758,33 +850,25 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
if (!plugin?.actions?.handleAction) {
throw new Error(`Channel ${channel} is unavailable for message actions (plugin not loaded).`);
}
const executionMode = plugin.actions.resolveExecutionMode?.({ action }) ?? "local";
if (executionMode === "gateway" && gateway) {
const gatewayPluginAction = await maybeCallGatewayPluginMessageAction({
cfg,
params,
channel,
action,
accountId,
dryRun,
gateway,
input,
agentId,
});
if (gatewayPluginAction) {
// Gateway-owned actions must execute where the live channel runtime exists.
const payload = await callGatewayMessageAction<unknown>({
gateway,
actionParams: {
channel,
action,
params,
accountId: accountId ?? undefined,
requesterSenderId: input.requesterSenderId ?? undefined,
senderIsOwner: input.senderIsOwner,
sessionKey: input.sessionKey,
sessionId: input.sessionId,
agentId,
toolContext: input.toolContext,
idempotencyKey: await resolveGatewayActionIdempotencyKey(
normalizeOptionalString(params.idempotencyKey),
),
},
});
return {
kind: "action",
channel,
action,
handledBy: "plugin",
payload,
payload: gatewayPluginAction.payload,
dryRun,
};
}