mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
fix: route telegram cli sends through gateway
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -160,6 +160,7 @@ function describeTelegramMessageTool({
|
||||
|
||||
export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: describeTelegramMessageTool,
|
||||
resolveExecutionMode: () => "gateway",
|
||||
resolveCliActionRequest: ({ action, args }) => {
|
||||
if (action !== "thread-create") {
|
||||
return { action, args };
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user