Skip to content

Commit 1a23627

Browse files
committed
refactor: split delivery target runtime seams
1 parent c2e93c7 commit 1a23627

7 files changed

Lines changed: 375 additions & 185 deletions

File tree

src/cron/isolated-agent/delivery-target.test.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import type { OpenClawConfig } from "../../config/config.js";
44
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
55
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
66

7+
const whatsappAccountMocks = vi.hoisted(() => ({
8+
resolveWhatsAppAccount: vi.fn<() => { allowFrom: string[] }>(() => ({ allowFrom: [] })),
9+
}));
10+
711
vi.mock("../../config/sessions/main-session.js", () => ({
812
resolveAgentMainSessionKey: vi.fn().mockReturnValue("agent:test:main"),
913
}));
@@ -16,7 +20,7 @@ vi.mock("../../config/sessions/store-load.js", () => ({
1620
loadSessionStore: vi.fn().mockReturnValue({}),
1721
}));
1822

19-
vi.mock("../../infra/outbound/channel-selection.js", () => ({
23+
vi.mock("../../infra/outbound/channel-selection.runtime.js", () => ({
2024
resolveMessageChannelSelection: vi
2125
.fn()
2226
.mockResolvedValue({ channel: "telegram", configured: ["telegram"] }),
@@ -30,18 +34,28 @@ vi.mock("../../pairing/pairing-store.js", () => ({
3034
readChannelAllowFromStoreSync: vi.fn(() => []),
3135
}));
3236

37+
vi.mock("../../plugin-sdk/whatsapp-surface.js", () => ({
38+
resolveWhatsAppAccount: whatsappAccountMocks.resolveWhatsAppAccount,
39+
}));
40+
41+
vi.mock("../../infra/outbound/targets.runtime.js", () => ({
42+
resolveOutboundTarget: vi.fn(),
43+
}));
3344
const mockedModuleIds = [
3445
"../../config/sessions/main-session.js",
3546
"../../config/sessions/paths.js",
3647
"../../config/sessions/store-load.js",
37-
"../../infra/outbound/channel-selection.js",
48+
"../../infra/outbound/channel-selection.runtime.js",
49+
"../../infra/outbound/targets.runtime.js",
3850
"../../infra/outbound/target-resolver.js",
3951
"../../pairing/pairing-store.js",
52+
"../../plugin-sdk/whatsapp-surface.js",
4053
];
4154

4255
import { loadSessionStore } from "../../config/sessions/store-load.js";
43-
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
56+
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.runtime.js";
4457
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
58+
import { resolveOutboundTarget } from "../../infra/outbound/targets.runtime.js";
4559
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
4660
import { resolveDeliveryTarget } from "./delivery-target.js";
4761

@@ -82,6 +96,7 @@ function createAllowlistAwareStubOutbound(label: string): ChannelOutboundAdapter
8296

8397
beforeEach(() => {
8498
resetPluginRuntimeStateForTest();
99+
vi.mocked(resolveOutboundTarget).mockReset();
85100
setActivePluginRegistry(
86101
createTestRegistry([
87102
{
@@ -279,6 +294,42 @@ describe("resolveDeliveryTarget", () => {
279294
);
280295
});
281296

297+
it("falls back to the runtime target resolver when the channel plugin is not already loaded", async () => {
298+
setMainSessionEntry(undefined);
299+
setActivePluginRegistry(
300+
createTestRegistry([
301+
{
302+
pluginId: "whatsapp",
303+
plugin: createOutboundTestPlugin({
304+
id: "whatsapp",
305+
outbound: createStubOutbound("WhatsApp"),
306+
}),
307+
source: "test",
308+
},
309+
]),
310+
);
311+
vi.mocked(resolveOutboundTarget).mockReturnValueOnce({ ok: true, to: "123456" });
312+
313+
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
314+
channel: "telegram",
315+
to: "123456",
316+
});
317+
318+
expect(result).toEqual(
319+
expect.objectContaining({
320+
ok: true,
321+
channel: "telegram",
322+
to: "123456",
323+
}),
324+
);
325+
expect(resolveOutboundTarget).toHaveBeenCalledWith(
326+
expect.objectContaining({
327+
channel: "telegram",
328+
to: "123456",
329+
}),
330+
);
331+
});
332+
282333
it("selects correct binding when multiple agents have bindings", async () => {
283334
setMainSessionEntry(undefined);
284335

src/cron/isolated-agent/delivery-target.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,10 @@ import type { OpenClawConfig } from "../../config/config.js";
44
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
55
import { resolveStorePath } from "../../config/sessions/paths.js";
66
import { loadSessionStore } from "../../config/sessions/store-load.js";
7-
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
87
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
8+
import { tryResolveLoadedOutboundTarget } from "../../infra/outbound/targets-loaded.js";
9+
import { resolveSessionDeliveryTarget } from "../../infra/outbound/targets-session.js";
910
import type { OutboundChannel } from "../../infra/outbound/targets.js";
10-
import {
11-
resolveOutboundTarget,
12-
resolveSessionDeliveryTarget,
13-
} from "../../infra/outbound/targets.js";
1411
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
1512
import { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js";
1613
import { buildChannelAccountBindings } from "../../routing/bindings.js";
@@ -35,6 +32,24 @@ export type DeliveryTargetResolution =
3532
error: Error;
3633
};
3734

35+
let targetsRuntimePromise:
36+
| Promise<typeof import("../../infra/outbound/targets.runtime.js")>
37+
| undefined;
38+
39+
async function loadTargetsRuntime() {
40+
targetsRuntimePromise ??= import("../../infra/outbound/targets.runtime.js");
41+
return await targetsRuntimePromise;
42+
}
43+
44+
let channelSelectionRuntimePromise:
45+
| Promise<typeof import("../../infra/outbound/channel-selection.runtime.js")>
46+
| undefined;
47+
48+
async function loadChannelSelectionRuntime() {
49+
channelSelectionRuntimePromise ??= import("../../infra/outbound/channel-selection.runtime.js");
50+
return await channelSelectionRuntimePromise;
51+
}
52+
3853
export async function resolveDeliveryTarget(
3954
cfg: OpenClawConfig,
4055
agentId: string,
@@ -77,6 +92,7 @@ export async function resolveDeliveryTarget(
7792
fallbackChannel = preliminary.lastChannel;
7893
} else {
7994
try {
95+
const { resolveMessageChannelSelection } = await loadChannelSelectionRuntime();
8096
const selection = await resolveMessageChannelSelection({ cfg });
8197
fallbackChannel = selection.channel;
8298
} catch (err) {
@@ -166,27 +182,49 @@ export async function resolveDeliveryTarget(
166182
const effectiveAllowFrom = mode === "implicit" ? allowFromOverride : undefined;
167183

168184
if (toCandidate && mode === "implicit" && allowFromOverride.length > 0) {
169-
const currentTargetResolution = resolveOutboundTarget({
185+
let currentTargetResolution = tryResolveLoadedOutboundTarget({
170186
channel,
171187
to: toCandidate,
172188
cfg,
173189
accountId,
174190
mode,
175191
allowFrom: effectiveAllowFrom,
176192
});
193+
if (!currentTargetResolution) {
194+
const { resolveOutboundTarget } = await loadTargetsRuntime();
195+
currentTargetResolution = resolveOutboundTarget({
196+
channel,
197+
to: toCandidate,
198+
cfg,
199+
accountId,
200+
mode,
201+
allowFrom: effectiveAllowFrom,
202+
});
203+
}
177204
if (!currentTargetResolution.ok) {
178205
toCandidate = allowFromOverride[0];
179206
}
180207
}
181208

182-
const docked = resolveOutboundTarget({
209+
let docked = tryResolveLoadedOutboundTarget({
183210
channel,
184211
to: toCandidate,
185212
cfg,
186213
accountId,
187214
mode,
188215
allowFrom: effectiveAllowFrom,
189216
});
217+
if (!docked) {
218+
const { resolveOutboundTarget } = await loadTargetsRuntime();
219+
docked = resolveOutboundTarget({
220+
channel,
221+
to: toCandidate,
222+
cfg,
223+
accountId,
224+
mode,
225+
allowFrom: effectiveAllowFrom,
226+
});
227+
}
190228
if (!docked.ok) {
191229
return {
192230
ok: false,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { resolveMessageChannelSelection } from "./channel-selection.js";
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
2+
import { getChannelPlugin } from "../../channels/plugins/index.js";
3+
import type { ChannelOutboundTargetMode, ChannelPlugin } from "../../channels/plugins/types.js";
4+
import { formatCliCommand } from "../../cli/command-format.js";
5+
import type { OpenClawConfig } from "../../config/config.js";
6+
import { getActivePluginRegistry } from "../../plugins/runtime.js";
7+
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
8+
import {
9+
INTERNAL_MESSAGE_CHANNEL,
10+
isDeliverableMessageChannel,
11+
normalizeMessageChannel,
12+
} from "../../utils/message-channel.js";
13+
import { missingTargetError } from "./target-errors.js";
14+
import type { OutboundTargetResolution } from "./targets.js";
15+
16+
function resolveLoadedOutboundChannelPlugin(channel: string): ChannelPlugin | undefined {
17+
const normalized = normalizeMessageChannel(channel);
18+
if (!normalized || !isDeliverableMessageChannel(normalized)) {
19+
return undefined;
20+
}
21+
22+
const current = getChannelPlugin(normalized);
23+
if (current) {
24+
return current;
25+
}
26+
27+
const activeRegistry = getActivePluginRegistry();
28+
if (!activeRegistry) {
29+
return undefined;
30+
}
31+
for (const entry of activeRegistry.channels) {
32+
const plugin = entry?.plugin;
33+
if (plugin?.id === normalized) {
34+
return plugin;
35+
}
36+
}
37+
return undefined;
38+
}
39+
40+
export function tryResolveLoadedOutboundTarget(params: {
41+
channel: GatewayMessageChannel;
42+
to?: string;
43+
allowFrom?: string[];
44+
cfg?: OpenClawConfig;
45+
accountId?: string | null;
46+
mode?: ChannelOutboundTargetMode;
47+
}): OutboundTargetResolution | undefined {
48+
if (params.channel === INTERNAL_MESSAGE_CHANNEL) {
49+
return {
50+
ok: false,
51+
error: new Error(
52+
`Delivering to WebChat is not supported via \`${formatCliCommand("openclaw agent")}\`; use WhatsApp/Telegram or run with --deliver=false.`,
53+
),
54+
};
55+
}
56+
57+
const plugin = resolveLoadedOutboundChannelPlugin(params.channel);
58+
if (!plugin) {
59+
return undefined;
60+
}
61+
62+
const allowFromRaw =
63+
params.allowFrom ??
64+
(params.cfg && plugin.config.resolveAllowFrom
65+
? plugin.config.resolveAllowFrom({
66+
cfg: params.cfg,
67+
accountId: params.accountId ?? undefined,
68+
})
69+
: undefined);
70+
const allowFrom = allowFromRaw ? mapAllowFromEntries(allowFromRaw) : undefined;
71+
72+
const effectiveTo =
73+
params.to?.trim() ||
74+
(params.cfg && plugin.config.resolveDefaultTo
75+
? plugin.config.resolveDefaultTo({
76+
cfg: params.cfg,
77+
accountId: params.accountId ?? undefined,
78+
})
79+
: undefined);
80+
81+
const resolveTarget = plugin.outbound?.resolveTarget;
82+
if (resolveTarget) {
83+
return resolveTarget({
84+
cfg: params.cfg,
85+
to: effectiveTo,
86+
allowFrom,
87+
accountId: params.accountId ?? undefined,
88+
mode: params.mode ?? "explicit",
89+
});
90+
}
91+
92+
if (effectiveTo) {
93+
return { ok: true, to: effectiveTo };
94+
}
95+
const hint = plugin.messaging?.targetResolver?.hint;
96+
return {
97+
ok: false,
98+
error: missingTargetError(plugin.meta.label ?? params.channel, hint),
99+
};
100+
}

0 commit comments

Comments
 (0)