Skip to content

Commit 65ec484

Browse files
committed
fix: tighten outbound channel/plugin resolution
1 parent a97e1e1 commit 65ec484

3 files changed

Lines changed: 79 additions & 5 deletions

File tree

src/infra/outbound/channel-selection.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const mocks = vi.hoisted(() => ({
44
listChannelPlugins: vi.fn(),
5+
resolveOutboundChannelPlugin: vi.fn(),
56
}));
67

78
vi.mock("../../channels/plugins/index.js", () => ({
89
listChannelPlugins: mocks.listChannelPlugins,
910
}));
1011

12+
vi.mock("./channel-resolution.js", () => ({
13+
resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin,
14+
}));
15+
1116
import {
1217
listConfiguredMessageChannels,
1318
resolveMessageChannelSelection,
@@ -36,6 +41,10 @@ describe("listConfiguredMessageChannels", () => {
3641
beforeEach(() => {
3742
mocks.listChannelPlugins.mockReset();
3843
mocks.listChannelPlugins.mockReturnValue([]);
44+
mocks.resolveOutboundChannelPlugin.mockReset();
45+
mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({
46+
id: channel,
47+
}));
3948
});
4049

4150
it("skips unknown plugin ids and plugins without accounts", async () => {
@@ -158,6 +167,35 @@ describe("resolveMessageChannelSelection", () => {
158167
).rejects.toThrow("Unknown channel: channel:c123");
159168
});
160169

170+
it("falls back when the explicit known channel is unavailable in the active plugin registry", async () => {
171+
mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) =>
172+
channel === "slack" ? { id: "slack" } : undefined,
173+
);
174+
175+
const selection = await resolveMessageChannelSelection({
176+
cfg: {} as never,
177+
channel: "discord",
178+
fallbackChannel: "slack",
179+
});
180+
181+
expect(selection).toEqual({
182+
channel: "slack",
183+
configured: [],
184+
source: "tool-context-fallback",
185+
});
186+
});
187+
188+
it("throws unavailable when a known channel has no active plugin", async () => {
189+
mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined);
190+
191+
await expect(
192+
resolveMessageChannelSelection({
193+
cfg: {} as never,
194+
channel: "discord",
195+
}),
196+
).rejects.toThrow("Channel is unavailable: discord");
197+
});
198+
161199
it("throws when no channel is provided and nothing is configured", async () => {
162200
await expect(
163201
resolveMessageChannelSelection({

src/infra/outbound/channel-selection.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
isDeliverableMessageChannel,
88
normalizeMessageChannel,
99
} from "../../utils/message-channel.js";
10+
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
1011

1112
export type MessageChannelId = DeliverableMessageChannel;
1213
export type MessageChannelSelectionSource =
@@ -34,6 +35,22 @@ function resolveKnownChannel(value?: string | null): MessageChannelId | undefine
3435
return normalized as MessageChannelId;
3536
}
3637

38+
function resolveAvailableKnownChannel(params: {
39+
cfg: OpenClawConfig;
40+
value?: string | null;
41+
}): MessageChannelId | undefined {
42+
const normalized = resolveKnownChannel(params.value);
43+
if (!normalized) {
44+
return undefined;
45+
}
46+
return resolveOutboundChannelPlugin({
47+
channel: normalized,
48+
cfg: params.cfg,
49+
})
50+
? normalized
51+
: undefined;
52+
}
53+
3754
function isAccountEnabled(account: unknown): boolean {
3855
if (!account || typeof account !== "object") {
3956
return true;
@@ -94,25 +111,38 @@ export async function resolveMessageChannelSelection(params: {
94111
}> {
95112
const normalized = normalizeMessageChannel(params.channel);
96113
if (normalized) {
97-
if (!isKnownChannel(normalized)) {
98-
const fallback = resolveKnownChannel(params.fallbackChannel);
114+
const availableExplicit = resolveAvailableKnownChannel({
115+
cfg: params.cfg,
116+
value: normalized,
117+
});
118+
if (!availableExplicit) {
119+
const fallback = resolveAvailableKnownChannel({
120+
cfg: params.cfg,
121+
value: params.fallbackChannel,
122+
});
99123
if (fallback) {
100124
return {
101125
channel: fallback,
102126
configured: await listConfiguredMessageChannels(params.cfg),
103127
source: "tool-context-fallback",
104128
};
105129
}
106-
throw new Error(`Unknown channel: ${String(normalized)}`);
130+
if (!isKnownChannel(normalized)) {
131+
throw new Error(`Unknown channel: ${String(normalized)}`);
132+
}
133+
throw new Error(`Channel is unavailable: ${String(normalized)}`);
107134
}
108135
return {
109-
channel: normalized as MessageChannelId,
136+
channel: availableExplicit,
110137
configured: await listConfiguredMessageChannels(params.cfg),
111138
source: "explicit",
112139
};
113140
}
114141

115-
const fallback = resolveKnownChannel(params.fallbackChannel);
142+
const fallback = resolveAvailableKnownChannel({
143+
cfg: params.cfg,
144+
value: params.fallbackChannel,
145+
});
116146
if (fallback) {
117147
return {
118148
channel: fallback,

src/infra/outbound/message-action-runner.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { buildChannelAccountBindings } from "../../routing/bindings.js";
2020
import { normalizeAgentId } from "../../routing/session-key.js";
2121
import { type GatewayClientMode, type GatewayClientName } from "../../utils/message-channel.js";
2222
import { throwIfAborted } from "./abort.js";
23+
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
2324
import {
2425
listConfiguredMessageChannels,
2526
resolveMessageChannelSelection,
@@ -670,6 +671,11 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
670671
};
671672
}
672673

674+
const plugin = resolveOutboundChannelPlugin({ channel, cfg });
675+
if (!plugin?.actions?.handleAction) {
676+
throw new Error(`Channel ${channel} is unavailable for message actions (plugin not loaded).`);
677+
}
678+
673679
const handled = await dispatchChannelMessageAction({
674680
channel,
675681
action,

0 commit comments

Comments
 (0)