diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index da605dcdb63..9448b919312 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -2,12 +2,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), + resolveOutboundChannelPlugin: vi.fn(), })); vi.mock("../../channels/plugins/index.js", () => ({ listChannelPlugins: mocks.listChannelPlugins, })); +vi.mock("./channel-resolution.js", () => ({ + resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin, +})); + import { listConfiguredMessageChannels, resolveMessageChannelSelection, @@ -36,6 +41,10 @@ describe("listConfiguredMessageChannels", () => { beforeEach(() => { mocks.listChannelPlugins.mockReset(); mocks.listChannelPlugins.mockReturnValue([]); + mocks.resolveOutboundChannelPlugin.mockReset(); + mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({ + id: channel, + })); }); it("skips unknown plugin ids and plugins without accounts", async () => { @@ -158,6 +167,35 @@ describe("resolveMessageChannelSelection", () => { ).rejects.toThrow("Unknown channel: channel:c123"); }); + it("falls back when the explicit known channel is unavailable in the active plugin registry", async () => { + mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => + channel === "slack" ? { id: "slack" } : undefined, + ); + + const selection = await resolveMessageChannelSelection({ + cfg: {} as never, + channel: "discord", + fallbackChannel: "slack", + }); + + expect(selection).toEqual({ + channel: "slack", + configured: [], + source: "tool-context-fallback", + }); + }); + + it("throws unavailable when a known channel has no active plugin", async () => { + mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined); + + await expect( + resolveMessageChannelSelection({ + cfg: {} as never, + channel: "discord", + }), + ).rejects.toThrow("Channel is unavailable: discord"); + }); + it("throws when no channel is provided and nothing is configured", async () => { await expect( resolveMessageChannelSelection({ diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index 9fbd592a589..024fc2273f6 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -7,6 +7,7 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; export type MessageChannelId = DeliverableMessageChannel; export type MessageChannelSelectionSource = @@ -34,6 +35,22 @@ function resolveKnownChannel(value?: string | null): MessageChannelId | undefine return normalized as MessageChannelId; } +function resolveAvailableKnownChannel(params: { + cfg: OpenClawConfig; + value?: string | null; +}): MessageChannelId | undefined { + const normalized = resolveKnownChannel(params.value); + if (!normalized) { + return undefined; + } + return resolveOutboundChannelPlugin({ + channel: normalized, + cfg: params.cfg, + }) + ? normalized + : undefined; +} + function isAccountEnabled(account: unknown): boolean { if (!account || typeof account !== "object") { return true; @@ -94,8 +111,15 @@ export async function resolveMessageChannelSelection(params: { }> { const normalized = normalizeMessageChannel(params.channel); if (normalized) { - if (!isKnownChannel(normalized)) { - const fallback = resolveKnownChannel(params.fallbackChannel); + const availableExplicit = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: normalized, + }); + if (!availableExplicit) { + const fallback = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: params.fallbackChannel, + }); if (fallback) { return { channel: fallback, @@ -103,16 +127,22 @@ export async function resolveMessageChannelSelection(params: { source: "tool-context-fallback", }; } - throw new Error(`Unknown channel: ${String(normalized)}`); + if (!isKnownChannel(normalized)) { + throw new Error(`Unknown channel: ${String(normalized)}`); + } + throw new Error(`Channel is unavailable: ${String(normalized)}`); } return { - channel: normalized as MessageChannelId, + channel: availableExplicit, configured: await listConfiguredMessageChannels(params.cfg), source: "explicit", }; } - const fallback = resolveKnownChannel(params.fallbackChannel); + const fallback = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: params.fallbackChannel, + }); if (fallback) { return { channel: fallback, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 0b6ad1ba16e..088baf75c22 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -20,6 +20,7 @@ import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { type GatewayClientMode, type GatewayClientName } from "../../utils/message-channel.js"; import { throwIfAborted } from "./abort.js"; +import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; import { listConfiguredMessageChannels, resolveMessageChannelSelection, @@ -670,6 +671,11 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise