diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index a90ad8914653..588da2c358b2 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -450,6 +450,8 @@ describe("resolveDeliveryTarget", () => { channel: "forum", input: "123456789", accountId: undefined, + plugin: expect.objectContaining({ id: "forum" }), + preferredKind: undefined, }); }); @@ -923,6 +925,8 @@ describe("resolveDeliveryTarget", () => { channel: "forum", input: "123456789", accountId: undefined, + plugin: expect.objectContaining({ id: "forum" }), + preferredKind: undefined, }); }); diff --git a/src/infra/outbound/channel-resolution.test.ts b/src/infra/outbound/channel-resolution.test.ts index 6d3ede5dc7cb..97d9725fec43 100644 --- a/src/infra/outbound/channel-resolution.test.ts +++ b/src/infra/outbound/channel-resolution.test.ts @@ -44,6 +44,7 @@ vi.mock("../../plugins/runtime.js", () => ({ })); vi.mock("../../utils/message-channel.js", () => ({ + INTERNAL_MESSAGE_CHANNEL: "webchat", normalizeMessageChannel: (...args: unknown[]) => normalizeMessageChannelMock(...args), isDeliverableMessageChannel: (...args: unknown[]) => isDeliverableMessageChannelMock(...args), })); @@ -231,6 +232,86 @@ describe("outbound channel resolution", () => { }); }); + it("bootstraps an external channel before strict deliverability validation", async () => { + const plugin = { id: "external-channel", outbound: { sendText: vi.fn() } }; + isDeliverableMessageChannelMock.mockImplementation( + (value?: string) => + value === "external-channel" && resolveRuntimePluginRegistryMock.mock.calls.length > 0, + ); + getLoadedChannelPluginMock.mockImplementation(() => + resolveRuntimePluginRegistryMock.mock.calls.length > 0 ? plugin : undefined, + ); + const channelResolution = await importChannelResolution("bootstrap-external-channel"); + + expect( + channelResolution.resolveOutboundChannelPlugin({ + channel: "external-channel", + cfg: { channels: {} } as never, + allowBootstrap: true, + }), + ).toBe(plugin); + expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1); + }); + + it("resolves a bootstrapped external channel from the active registry when the pin is stale", async () => { + const plugin = { id: "external-channel", outbound: { sendText: vi.fn() } }; + isDeliverableMessageChannelMock.mockReturnValue(false); + getLoadedChannelPluginMock.mockReturnValue(undefined); + getChannelPluginMock.mockReturnValue(undefined); + getActivePluginChannelRegistryMock.mockReturnValue({ + channels: [{ plugin: { id: "other-channel" } }], + }); + getActivePluginRegistryMock.mockReturnValue({ channels: [{ plugin }] }); + const channelResolution = await importChannelResolution("bootstrap-external-active-registry"); + + expect( + channelResolution.resolveOutboundChannelPlugin({ + channel: "external-channel", + cfg: { channels: {} } as never, + allowBootstrap: true, + }), + ).toBe(plugin); + expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + }); + + it("keeps a bootstrapped external alias available to normal runtime lookups", async () => { + const message = { send: { text: vi.fn() } }; + const plugin = { + id: "external-channel", + meta: { aliases: ["external"] }, + message, + }; + isDeliverableMessageChannelMock.mockReturnValue(false); + getLoadedChannelPluginMock.mockReturnValue(undefined); + getChannelPluginMock.mockReturnValue(undefined); + getActivePluginChannelRegistryMock.mockReturnValue({ channels: [] }); + getActivePluginRegistryMock.mockImplementation(() => + resolveRuntimePluginRegistryMock.mock.calls.length > 0 ? { channels: [{ plugin }] } : null, + ); + const channelResolution = await importChannelResolution("bootstrap-external-alias"); + + expect( + channelResolution.resolveOutboundChannelPlugin({ + channel: "external", + cfg: { channels: {} } as never, + allowBootstrap: true, + }), + ).toBe(plugin); + expect( + channelResolution.resolveOutboundChannelPlugin({ + channel: "external", + cfg: { channels: {} } as never, + }), + ).toBe(plugin); + expect( + channelResolution.resolveOutboundChannelMessageAdapter({ + channel: "external", + cfg: { channels: {} } as never, + }), + ).toBe(message); + expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1); + }); + it("bootstraps instead of returning a pinned setup shell as the outbound plugin", async () => { const setupPlugin = { id: "alpha" }; const runtimePlugin = { id: "alpha", outbound: { sendText: vi.fn() } }; @@ -252,6 +333,31 @@ describe("outbound channel resolution", () => { expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1); }); + it("bootstraps instead of returning direct outbound metadata from a setup shell", async () => { + const setupPlugin = { id: "alpha", outbound: { deliveryMode: "direct" } }; + const runtimePlugin = { id: "alpha", outbound: { deliveryMode: "direct", sendText: vi.fn() } }; + getLoadedChannelPluginMock.mockReturnValue(setupPlugin); + getChannelPluginMock.mockReturnValue(undefined); + getActivePluginChannelRegistryMock.mockReturnValue({ + channels: [{ plugin: setupPlugin }], + }); + getActivePluginRegistryMock.mockImplementation(() => + resolveRuntimePluginRegistryMock.mock.calls.length > 0 + ? { channels: [{ plugin: runtimePlugin }] } + : { channels: [{ plugin: setupPlugin }] }, + ); + const channelResolution = await importChannelResolution("bootstrap-outbound-metadata-shell"); + + expect( + channelResolution.resolveOutboundChannelPlugin({ + channel: "alpha", + cfg: { channels: {} } as never, + allowBootstrap: true, + }), + ).toBe(runtimePlugin); + expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1); + }); + it("does not return a setup shell when bootstrap does not produce a runtime plugin", async () => { const setupPlugin = { id: "alpha" }; getLoadedChannelPluginMock.mockReturnValue(setupPlugin); @@ -463,6 +569,28 @@ describe("outbound channel resolution", () => { expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1); }); + it("bootstraps an external channel before resolving its message adapter", async () => { + const message = { send: { text: vi.fn() } }; + const plugin = { id: "external-channel", message }; + isDeliverableMessageChannelMock.mockImplementation( + (value?: string) => + value === "external-channel" && resolveRuntimePluginRegistryMock.mock.calls.length > 0, + ); + getLoadedChannelPluginMock.mockImplementation(() => + resolveRuntimePluginRegistryMock.mock.calls.length > 0 ? plugin : undefined, + ); + const channelResolution = await importChannelResolution("message-adapter-external-channel"); + + expect( + channelResolution.resolveOutboundChannelMessageAdapter({ + channel: "external-channel", + cfg: { channels: {} } as never, + allowBootstrap: true, + }), + ).toBe(message); + expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1); + }); + it("does not bootstrap by default for outbound hot-path resolution", async () => { const plugin = { id: "alpha" }; getLoadedChannelPluginMock.mockReturnValue(undefined); diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index 3bcb6c5a7dc8..91edced51256 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -1,5 +1,6 @@ // Channel resolution exposes read-only outbound runtime facades and performs // optional bootstrap for deliverable channels that are not loaded yet. +import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import type { ChannelMessageAdapterShape } from "../../channels/message/types.js"; import { getChannelPlugin, getLoadedChannelPlugin } from "../../channels/plugins/index.js"; import { channelPluginHasNativeApprovalPromptUi } from "../../channels/plugins/native-approval-prompt.js"; @@ -23,6 +24,7 @@ import type { import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getActivePluginChannelRegistry, getActivePluginRegistry } from "../../plugins/runtime.js"; import { + INTERNAL_MESSAGE_CHANNEL, isDeliverableMessageChannel, normalizeMessageChannel, type DeliverableMessageChannel, @@ -111,6 +113,43 @@ function maybeBootstrapChannelPlugin(params: { bootstrapOutboundChannelPlugin(params); } +function normalizeOutboundChannelForResolution(params: { + channel: string; + cfg?: OpenClawConfig; + allowBootstrap?: boolean; +}): { channel?: DeliverableMessageChannel; didBootstrap: boolean } { + const normalized = normalizeMessageChannel(params.channel); + const deliverable = normalizeDeliverableOutboundChannel(normalized); + if (deliverable || !normalized || normalized === INTERNAL_MESSAGE_CHANNEL) { + return { channel: deliverable, didBootstrap: false }; + } + + const activeRuntimePlugin = resolveActivatedOutboundPluginFromRuntimeRegistries(normalized); + if (activeRuntimePlugin) { + return { + channel: activeRuntimePlugin.id as DeliverableMessageChannel, + didBootstrap: false, + }; + } + if (params.allowBootstrap !== true) { + return { channel: undefined, didBootstrap: false }; + } + + // External channel ids remain normalized before their runtime is registered. + // Bootstrap first, then let the runtime candidate lookup confirm sendability. + maybeBootstrapChannelPlugin({ + channel: normalized as DeliverableMessageChannel, + cfg: params.cfg, + }); + const bootstrappedRuntimePlugin = resolveActivatedOutboundPluginFromRuntimeRegistries(normalized); + return { + // The pinned channel registry may intentionally lag the active runtime + // registry, so strict registry validation here would hide a usable plugin. + channel: (bootstrappedRuntimePlugin?.id ?? normalized) as DeliverableMessageChannel, + didBootstrap: true, + }; +} + function resolveDirectFromRegistry( registry: ReturnType, channel: string, @@ -118,9 +157,18 @@ function resolveDirectFromRegistry( if (!registry) { return undefined; } + const normalizedChannel = normalizeOptionalLowercaseString(channel); + if (!normalizedChannel) { + return undefined; + } for (const entry of registry.channels) { const plugin = entry?.plugin; - if (plugin?.id === channel) { + if ( + normalizeOptionalLowercaseString(plugin?.id) === normalizedChannel || + plugin?.meta?.aliases?.some( + (alias) => normalizeOptionalLowercaseString(alias) === normalizedChannel, + ) + ) { return plugin; } } @@ -144,24 +192,40 @@ function channelPluginHasRuntimeOutboundSurface(plugin: ChannelPlugin | undefine return Boolean(plugin?.outbound ?? resolveSendCapableMessageAdapter(plugin)); } +function channelPluginHasActivatedOutboundSurface(plugin: ChannelPlugin | undefined): boolean { + return Boolean( + plugin?.outbound?.sendText || + plugin?.outbound?.deliveryMode === "gateway" || + resolveSendCapableMessageAdapter(plugin), + ); +} + function resolveRuntimeOutboundPlugin(plugin: ChannelPlugin): ChannelPlugin | undefined { return channelPluginHasRuntimeOutboundSurface(plugin) ? plugin : undefined; } +function resolveActivatedOutboundPlugin(plugin: ChannelPlugin): ChannelPlugin | undefined { + return channelPluginHasActivatedOutboundSurface(plugin) ? plugin : undefined; +} + function resolveRuntimeOutboundPluginCandidate(params: { loaded?: ChannelPlugin; runtime?: ChannelPlugin; setupFallback?: ChannelPlugin; bundled?: ChannelPlugin; allowSetupShell?: boolean; + requireActivatedRuntime?: boolean; }): ChannelPlugin | undefined { - if (channelPluginHasRuntimeOutboundSurface(params.loaded)) { + const hasRuntimeSurface = params.requireActivatedRuntime + ? channelPluginHasActivatedOutboundSurface + : channelPluginHasRuntimeOutboundSurface; + if (hasRuntimeSurface(params.loaded)) { return params.loaded; } - if (params.runtime) { + if (hasRuntimeSurface(params.runtime)) { return params.runtime; } - if (channelPluginHasRuntimeOutboundSurface(params.bundled)) { + if (hasRuntimeSurface(params.bundled)) { return params.bundled; } if (params.allowSetupShell) { @@ -202,6 +266,12 @@ function resolveRuntimeOutboundPluginFromRuntimeRegistries( return resolveValueFromRuntimeRegistries(channel, resolveRuntimeOutboundPlugin); } +function resolveActivatedOutboundPluginFromRuntimeRegistries( + channel: string, +): ChannelPlugin | undefined { + return resolveValueFromRuntimeRegistries(channel, resolveActivatedOutboundPlugin); +} + function toOutboundChannelRuntime(plugin: ChannelPlugin): OutboundChannelRuntime { return { id: plugin.id, @@ -260,7 +330,7 @@ export function resolveOutboundChannelPlugin(params: { cfg?: OpenClawConfig; allowBootstrap?: boolean; }): ChannelPlugin | undefined { - const normalized = normalizeDeliverableOutboundChannel(params.channel); + const { channel: normalized, didBootstrap } = normalizeOutboundChannelForResolution(params); if (!normalized) { return undefined; } @@ -268,7 +338,10 @@ export function resolveOutboundChannelPlugin(params: { const resolveLoaded = () => getLoadedChannelPlugin(normalized); const resolve = () => getChannelPlugin(normalized); const current = resolveLoaded(); - const runtimeCurrent = resolveRuntimeOutboundPluginFromRuntimeRegistries(normalized); + const requireActivatedRuntime = params.allowBootstrap === true; + const runtimeCurrent = requireActivatedRuntime + ? resolveActivatedOutboundPluginFromRuntimeRegistries(normalized) + : resolveRuntimeOutboundPluginFromRuntimeRegistries(normalized); const setupFallback = resolveDirectFromRuntimeRegistries(normalized); const bundledCurrent = resolve(); const candidate = resolveRuntimeOutboundPluginCandidate({ @@ -277,21 +350,23 @@ export function resolveOutboundChannelPlugin(params: { setupFallback, bundled: bundledCurrent, allowSetupShell: params.allowBootstrap !== true, + requireActivatedRuntime, }); if (candidate) { return candidate; } - if (params.allowBootstrap !== true) { + if (params.allowBootstrap !== true || didBootstrap) { return undefined; } maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg }); return resolveRuntimeOutboundPluginCandidate({ loaded: resolveLoaded(), - runtime: resolveRuntimeOutboundPluginFromRuntimeRegistries(normalized), + runtime: resolveActivatedOutboundPluginFromRuntimeRegistries(normalized), setupFallback: resolveDirectFromRuntimeRegistries(normalized), bundled: resolve(), + requireActivatedRuntime: true, }); } @@ -301,7 +376,7 @@ export function resolveOutboundChannelMessageAdapter(params: { cfg?: OpenClawConfig; allowBootstrap?: boolean; }): ChannelMessageAdapterShape | undefined { - const normalized = normalizeDeliverableOutboundChannel(params.channel); + const { channel: normalized, didBootstrap } = normalizeOutboundChannelForResolution(params); if (!normalized) { return undefined; } @@ -309,7 +384,7 @@ export function resolveOutboundChannelMessageAdapter(params: { resolveSendCapableMessageAdapter(getLoadedChannelPlugin(normalized)) ?? resolveValueFromRuntimeRegistries(normalized, resolveSendCapableMessageAdapter) ?? resolveSendCapableMessageAdapter(getChannelPlugin(normalized)); - if (current || params.allowBootstrap !== true) { + if (current || params.allowBootstrap !== true || didBootstrap) { return current; } maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg }); diff --git a/src/infra/outbound/message-action-runner.test-helpers.ts b/src/infra/outbound/message-action-runner.test-helpers.ts index 0b8d8ffa80d1..a22249ee09dd 100644 --- a/src/infra/outbound/message-action-runner.test-helpers.ts +++ b/src/infra/outbound/message-action-runner.test-helpers.ts @@ -30,7 +30,10 @@ export const directChatConfig = { }, } as OpenClawConfig; -export const directOutbound: ChannelOutboundAdapter = { deliveryMode: "direct" }; +export const directOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + sendText: async () => ({ channel: "test", messageId: "test" }), +}; // Test plugins model token-gated workspace sends without booting real channel runtimes. function hasChannelBotToken(channelConfig: unknown): boolean { diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts index 5a2a62ed7b4c..e703f463237c 100644 --- a/src/infra/outbound/message.test.ts +++ b/src/infra/outbound/message.test.ts @@ -145,7 +145,7 @@ describe("sendMessage", () => { mocks.resolveRuntimePluginRegistry.mockClear(); mocks.getChannelPlugin.mockReturnValue({ - outbound: { deliveryMode: "direct" }, + outbound: { deliveryMode: "direct", sendText: vi.fn() }, }); mocks.resolveOutboundTarget.mockImplementation(({ to }: { to: string }) => ({ ok: true, to })); mocks.deliverOutboundPayloads.mockResolvedValue([{ channel: "forum", messageId: "m1" }]); @@ -460,7 +460,7 @@ describe("sendMessage", () => { it("does not load registries while resolving outbound plugins", async () => { const forumPlugin = { - outbound: { deliveryMode: "direct" }, + outbound: { deliveryMode: "direct", sendText: vi.fn() }, }; mocks.getChannelPlugin .mockReturnValueOnce(undefined) diff --git a/src/infra/outbound/outbound-session.test.ts b/src/infra/outbound/outbound-session.test.ts index afa0956c9b5d..6f06ca220dd3 100644 --- a/src/infra/outbound/outbound-session.test.ts +++ b/src/infra/outbound/outbound-session.test.ts @@ -1,7 +1,9 @@ // Covers outbound session-route resolution through plugin hooks and fallback // target parsing, plus best-effort session metadata persistence. import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { createChannelTestPluginBase } from "../../test-utils/channel-plugins.js"; import { ensureOutboundSessionEntry, resolveOutboundSessionRoute } from "./outbound-session.js"; import { setMinimalOutboundSessionPluginRegistryForTests } from "./outbound-session.test-helpers.js"; @@ -64,6 +66,33 @@ describe("resolveOutboundSessionRoute", () => { }, } as OpenClawConfig; + it("uses a prepared runtime plugin for session-route resolution", async () => { + const plugin = { + ...createChannelTestPluginBase({ id: "external-channel" }), + messaging: { + resolveOutboundSessionRoute: ({ target }: { target: string }) => ({ + sessionKey: `agent:main:external-channel:direct:${target}`, + baseSessionKey: `agent:main:external-channel:direct:${target}`, + peer: { kind: "direct" as const, id: target }, + chatType: "direct" as const, + from: `external-channel:${target}`, + to: `user:${target}`, + }), + }, + } satisfies ChannelPlugin; + + const route = await resolveOutboundSessionRoute({ + cfg: baseConfig, + channel: "external-channel", + plugin, + agentId: "main", + target: "u123", + }); + + expect(route?.to).toBe("user:u123"); + expect(route?.chatType).toBe("direct"); + }); + async function expectResolvedRoute(params: { cfg: OpenClawConfig; channel: string; diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index 2d967f494f8c..d74f8a8b5281 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -5,6 +5,7 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization import type { MsgContext } from "../../auto-reply/templating.js"; import type { ChatType } from "../../channels/chat-type.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelId } from "../../channels/plugins/types.public.js"; import { recordSessionMetaFromInbound, @@ -31,6 +32,7 @@ export type OutboundSessionRoute = { export type ResolveOutboundSessionRouteParams = { cfg: OpenClawConfig; channel: ChannelId; + plugin?: ChannelPlugin; agentId: string; accountId?: string | null; target: string; @@ -110,6 +112,7 @@ function inferPeerKindFromFallbackPrefixes(targets: readonly string[]): ChatType function inferPeerKind(params: { channel: ChannelId; + plugin?: ChannelPlugin; target: string; resolvedTarget?: ResolvedMessagingTarget; }): ChatType { @@ -121,7 +124,7 @@ function inferPeerKind(params: { return "channel"; } if (resolvedKind === "group") { - const plugin = resolveOutboundChannelPlugin(params.channel); + const plugin = params.plugin ?? resolveOutboundChannelPlugin(params.channel); const chatTypes = plugin?.capabilities?.chatTypes ?? []; const supportsChannel = chatTypes.includes("channel"); const supportsGroup = chatTypes.includes("group"); @@ -130,7 +133,7 @@ function inferPeerKind(params: { } return "group"; } - const plugin = resolveOutboundChannelPlugin(params.channel); + const plugin = params.plugin ?? resolveOutboundChannelPlugin(params.channel); const strippedTarget = stripProviderPrefix(params.target, params.channel).trim(); const targets = uniqueStrings([params.target, strippedTarget].filter(Boolean)); return ( @@ -150,6 +153,7 @@ function resolveFallbackSession( } const peerKind = inferPeerKind({ channel: params.channel, + plugin: params.plugin, target: params.target, resolvedTarget: params.resolvedTarget, }); @@ -190,8 +194,8 @@ export async function resolveOutboundSessionRoute( return null; } const nextParams = { ...params, target }; - const resolver = resolveOutboundChannelPlugin(params.channel)?.messaging - ?.resolveOutboundSessionRoute; + const plugin = params.plugin ?? resolveOutboundChannelPlugin(params.channel); + const resolver = plugin?.messaging?.resolveOutboundSessionRoute; if (resolver) { // Channel plugins can provide richer route semantics than the generic target parser. return await resolver(nextParams); diff --git a/src/infra/outbound/target-id-resolution.ts b/src/infra/outbound/target-id-resolution.ts index 8432c8aaa15f..25a02dc59b9a 100644 --- a/src/infra/outbound/target-id-resolution.ts +++ b/src/infra/outbound/target-id-resolution.ts @@ -1,5 +1,6 @@ // Id-like target resolution gates plugin directory lookups to inputs that are // specific enough to avoid broad name searches. +import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelDirectoryEntryKind, ChannelId } from "../../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { maybeResolvePluginMessagingTarget } from "./target-normalization.js"; @@ -20,6 +21,7 @@ export async function maybeResolveIdLikeTarget(params: { input: string; accountId?: string | null; preferredKind?: ChannelDirectoryEntryKind | "channel"; + plugin?: ChannelPlugin; }): Promise { const target = await maybeResolvePluginMessagingTarget({ ...params, diff --git a/src/infra/outbound/target-normalization.test.ts b/src/infra/outbound/target-normalization.test.ts index 341168bb4606..0c0e16a8f42f 100644 --- a/src/infra/outbound/target-normalization.test.ts +++ b/src/infra/outbound/target-normalization.test.ts @@ -1,6 +1,7 @@ // Covers target input normalization, provider plugin normalizers, resolver // caching, and id-like lookup heuristics. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../../config/config.js"; const getLoadedChannelPluginMock = vi.hoisted(() => vi.fn()); @@ -328,4 +329,27 @@ describe("buildTargetResolverSignature", () => { expect(first).not.toBe(second); }); + + it("partitions prepared runtime plugins from pinned and replacement plugin cache entries", () => { + const firstPlugin = { + messaging: { + targetResolver: {}, + }, + } as ChannelPlugin; + const replacementPlugin = { + messaging: { + targetResolver: {}, + }, + } as ChannelPlugin; + getLoadedChannelPluginMock.mockReturnValue(firstPlugin); + + const pinned = buildTargetResolverSignature("workspace"); + const prepared = buildTargetResolverSignature("workspace", firstPlugin); + const samePrepared = buildTargetResolverSignature("workspace", firstPlugin); + const replacementPrepared = buildTargetResolverSignature("workspace", replacementPlugin); + + expect(prepared).not.toBe(pinned); + expect(samePrepared).toBe(prepared); + expect(replacementPrepared).not.toBe(prepared); + }); }); diff --git a/src/infra/outbound/target-normalization.ts b/src/infra/outbound/target-normalization.ts index fb18483b0afa..e3af121e89ca 100644 --- a/src/infra/outbound/target-normalization.ts +++ b/src/infra/outbound/target-normalization.ts @@ -25,6 +25,8 @@ type TargetNormalizerCacheEntry = { }; const targetNormalizerCacheByChannelId = new Map(); +const preparedPluginSignatureIds = new WeakMap(); +let nextPreparedPluginSignatureId = 1; function resolveChannelPluginForTargetRead(channelId: ChannelId): ChannelPlugin | undefined { return getLoadedChannelPluginForRead(channelId) ?? getChannelPlugin(channelId); @@ -38,7 +40,13 @@ export const testing = { resetTargetNormalizerCacheForTests, } as const; -function resolveTargetNormalizer(channelId: ChannelId): TargetNormalizer { +function resolveTargetNormalizer( + channelId: ChannelId, + preparedPlugin?: ChannelPlugin, +): TargetNormalizer { + if (preparedPlugin) { + return preparedPlugin.messaging?.normalizeTarget; + } const version = getActivePluginChannelRegistryVersion(); const cached = targetNormalizerCacheByChannelId.get(channelId); if (cached && cached.version === version) { @@ -54,10 +62,25 @@ function resolveTargetNormalizer(channelId: ChannelId): TargetNormalizer { return normalizer; } +function resolvePreparedPluginSignatureId(plugin: ChannelPlugin): number { + const existing = preparedPluginSignatureIds.get(plugin); + if (existing) { + return existing; + } + const id = nextPreparedPluginSignatureId; + nextPreparedPluginSignatureId += 1; + preparedPluginSignatureIds.set(plugin, id); + return id; +} + /** * Applies a channel plugin normalizer and falls back to trimmed input. */ -export function normalizeTargetForProvider(provider: string, raw?: string): string | undefined { +export function normalizeTargetForProvider( + provider: string, + raw?: string, + plugin?: ChannelPlugin, +): string | undefined { if (!raw) { return undefined; } @@ -66,7 +89,7 @@ export function normalizeTargetForProvider(provider: string, raw?: string): stri return undefined; } const providerId = normalizeOptionalLowercaseString(provider); - const normalizer = providerId ? resolveTargetNormalizer(providerId) : undefined; + const normalizer = providerId ? resolveTargetNormalizer(providerId, plugin) : undefined; return normalizeOptionalString(normalizer?.(raw) ?? fallback); } @@ -92,6 +115,7 @@ export type ResolvedPluginMessagingTarget = { export function resolveNormalizedTargetInput( provider: string, raw?: string, + plugin?: ChannelPlugin, ): { raw: string; normalized: string } | undefined { const trimmed = normalizeChannelTargetInput(raw ?? ""); if (!trimmed) { @@ -99,7 +123,7 @@ export function resolveNormalizedTargetInput( } return { raw: trimmed, - normalized: normalizeTargetForProvider(provider, trimmed) ?? trimmed, + normalized: normalizeTargetForProvider(provider, trimmed, plugin) ?? trimmed, }; } @@ -110,11 +134,12 @@ export function looksLikeTargetId(params: { channel: ChannelId; raw: string; normalized?: string; + plugin?: ChannelPlugin; }): boolean { const normalizedInput = - params.normalized ?? normalizeTargetForProvider(params.channel, params.raw); - const lookup = resolveChannelPluginForTargetRead(params.channel)?.messaging?.targetResolver - ?.looksLikeId; + params.normalized ?? normalizeTargetForProvider(params.channel, params.raw, params.plugin); + const lookup = (params.plugin ?? resolveChannelPluginForTargetRead(params.channel))?.messaging + ?.targetResolver?.looksLikeId; if (lookup) { // Plugin heuristics win so provider-specific ids do not fall through to // generic phone/mention checks. @@ -145,12 +170,14 @@ export async function maybeResolvePluginMessagingTarget(params: { accountId?: string | null; preferredKind?: TargetResolveKindLike; requireIdLike?: boolean; + plugin?: ChannelPlugin; }): Promise { - const normalizedInput = resolveNormalizedTargetInput(params.channel, params.input); + const normalizedInput = resolveNormalizedTargetInput(params.channel, params.input, params.plugin); if (!normalizedInput) { return undefined; } - const resolver = resolveChannelPluginForTargetRead(params.channel)?.messaging?.targetResolver; + const resolver = (params.plugin ?? resolveChannelPluginForTargetRead(params.channel))?.messaging + ?.targetResolver; if (!resolver?.resolveTarget) { return undefined; } @@ -160,6 +187,7 @@ export async function maybeResolvePluginMessagingTarget(params: { channel: params.channel, raw: normalizedInput.raw, normalized: normalizedInput.normalized, + plugin: params.plugin, }) ) { return undefined; @@ -186,14 +214,20 @@ export async function maybeResolvePluginMessagingTarget(params: { /** * Builds a cache signature for target-resolution behavior exposed by a channel plugin. */ -export function buildTargetResolverSignature(channel: ChannelId): string { - const plugin = resolveChannelPluginForTargetRead(channel); +export function buildTargetResolverSignature( + channel: ChannelId, + preparedPlugin?: ChannelPlugin, +): string { + const plugin = preparedPlugin ?? resolveChannelPluginForTargetRead(channel); + const registryScope = preparedPlugin + ? `prepared:${resolvePreparedPluginSignatureId(preparedPlugin)}` + : "pinned"; const resolver = plugin?.messaging?.targetResolver; const hint = resolver?.hint ?? ""; const looksLike = resolver?.looksLikeId; // Function source is only a cheap invalidation hint; resolver behavior still belongs to the plugin. const source = looksLike ? looksLike.toString() : ""; - return hashSignature(`${hint}|${source}`); + return hashSignature(`${registryScope}|${hint}|${source}`); } function hashSignature(value: string): string { diff --git a/src/infra/outbound/target-resolver.test.ts b/src/infra/outbound/target-resolver.test.ts index ad016c4d093d..28e1be1ca4a7 100644 --- a/src/infra/outbound/target-resolver.test.ts +++ b/src/infra/outbound/target-resolver.test.ts @@ -2,7 +2,9 @@ // ambiguity modes, display formatting, and plugin normalized fallbacks. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelDirectoryEntry } from "../../channels/plugins/types.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { createChannelTestPluginBase } from "../../test-utils/channel-plugins.js"; type TargetResolverModule = typeof import("./target-resolver.js"); let resetDirectoryCache: TargetResolverModule["resetDirectoryCache"]; @@ -128,6 +130,47 @@ describe("resolveMessagingTarget (directory fallback)", () => { expect(mocks.listGroupsLive).toHaveBeenCalledTimes(1); }); + it("does not reuse directory cache entries across prepared plugin runtimes", async () => { + const firstListGroups = vi + .fn() + .mockResolvedValue([ + { kind: "group", id: "first-id", name: "support" } satisfies ChannelDirectoryEntry, + ]); + const replacementListGroups = vi + .fn() + .mockResolvedValue([ + { kind: "group", id: "replacement-id", name: "support" } satisfies ChannelDirectoryEntry, + ]); + const firstPlugin = { + ...createChannelTestPluginBase({ id: "richchat" }), + directory: { listGroups: firstListGroups }, + messaging: { targetResolver: {} }, + } satisfies ChannelPlugin; + const replacementPlugin = { + ...createChannelTestPluginBase({ id: "richchat" }), + directory: { listGroups: replacementListGroups }, + messaging: { targetResolver: {} }, + } satisfies ChannelPlugin; + + const first = await expectOkResolution({ + cfg, + channel: "richchat", + input: "support", + plugin: firstPlugin, + }); + const replacement = await expectOkResolution({ + cfg, + channel: "richchat", + input: "support", + plugin: replacementPlugin, + }); + + expect(first.target.to).toBe("first-id"); + expect(replacement.target.to).toBe("replacement-id"); + expect(firstListGroups).toHaveBeenCalledOnce(); + expect(replacementListGroups).toHaveBeenCalledOnce(); + }); + it("skips directory lookup for direct ids", async () => { const result = await expectOkResolution({ cfg, diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 66f3fa824e88..30861105ea3b 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -2,6 +2,7 @@ // live fallback lookups, and normalized fallback targets. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { getChannelPlugin } from "../../channels/plugins/index.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelDirectoryEntry, ChannelDirectoryEntryKind, @@ -59,6 +60,7 @@ export async function resolveChannelTarget(params: { runtime?: RuntimeEnv; resolveAmbiguous?: ResolveAmbiguousMode; unknownTargetMode?: "error" | "normalized"; + plugin?: ChannelPlugin; }): Promise { return resolveMessagingTarget(params); } @@ -157,6 +159,7 @@ function detectTargetKind( channel: ChannelId, raw: string, preferred?: TargetResolveKind, + plugin?: ChannelPlugin, ): TargetResolveKind { if (preferred) { return preferred; @@ -165,7 +168,9 @@ function detectTargetKind( if (!trimmed) { return "group"; } - const inferredChatType = getChannelPlugin(channel)?.messaging?.inferTargetChatType?.({ to: raw }); + const inferredChatType = (plugin ?? getChannelPlugin(channel))?.messaging?.inferTargetChatType?.({ + to: raw, + }); if (inferredChatType === "direct") { return "user"; } @@ -186,8 +191,12 @@ function detectTargetKind( return "group"; } -function normalizeDirectoryEntryId(channel: ChannelId, entry: ChannelDirectoryEntry): string { - const normalized = normalizeTargetForProvider(channel, entry.id); +function normalizeDirectoryEntryId( + channel: ChannelId, + entry: ChannelDirectoryEntry, + plugin?: ChannelPlugin, +): string { + const normalized = normalizeTargetForProvider(channel, entry.id, plugin); return normalized ?? entry.id.trim(); } @@ -195,12 +204,15 @@ function matchesDirectoryEntry(params: { channel: ChannelId; entry: ChannelDirectoryEntry; query: string; + plugin?: ChannelPlugin; }): boolean { const query = normalizeQuery(params.query); if (!query) { return false; } - const id = stripTargetPrefixes(normalizeDirectoryEntryId(params.channel, params.entry)); + const id = stripTargetPrefixes( + normalizeDirectoryEntryId(params.channel, params.entry, params.plugin), + ); const name = params.entry.name ? stripTargetPrefixes(params.entry.name) : ""; const handle = params.entry.handle ? stripTargetPrefixes(params.entry.handle) : ""; const candidates = [id, name, handle].map((value) => normalizeQuery(value)).filter(Boolean); @@ -211,9 +223,15 @@ function resolveMatch(params: { channel: ChannelId; entries: ChannelDirectoryEntry[]; query: string; + plugin?: ChannelPlugin; }) { const matches = params.entries.filter((entry) => - matchesDirectoryEntry({ channel: params.channel, entry, query: params.query }), + matchesDirectoryEntry({ + channel: params.channel, + entry, + query: params.query, + plugin: params.plugin, + }), ); if (matches.length === 0) { return { kind: "none" as const }; @@ -232,8 +250,9 @@ async function listDirectoryEntries(params: { runtime?: RuntimeEnv; query?: string; source: "cache" | "live"; + plugin?: ChannelPlugin; }): Promise { - const plugin = getChannelPlugin(params.channel); + const plugin = params.plugin ?? getChannelPlugin(params.channel); const directory = plugin?.directory; if (!directory) { return []; @@ -268,8 +287,9 @@ async function getDirectoryEntries(params: { query?: string; runtime?: RuntimeEnv; preferLiveOnMiss?: boolean; + plugin?: ChannelPlugin; }): Promise { - const signature = buildTargetResolverSignature(params.channel); + const signature = buildTargetResolverSignature(params.channel, params.plugin); const listParams = { cfg: params.cfg, channel: params.channel, @@ -277,6 +297,7 @@ async function getDirectoryEntries(params: { kind: params.kind, query: params.query, runtime: params.runtime, + plugin: params.plugin, }; const cacheKey = buildDirectoryCacheKey({ channel: params.channel, @@ -360,16 +381,17 @@ export async function resolveMessagingTarget(params: { runtime?: RuntimeEnv; resolveAmbiguous?: ResolveAmbiguousMode; unknownTargetMode?: "error" | "normalized"; + plugin?: ChannelPlugin; }): Promise { const raw = normalizeChannelTargetInput(params.input); if (!raw) { return { ok: false, error: new Error("Target is required") }; } - const plugin = getChannelPlugin(params.channel); + const plugin = params.plugin ?? getChannelPlugin(params.channel); const providerLabel = plugin?.meta?.label ?? params.channel; const hint = plugin?.messaging?.targetResolver?.hint; - const kind = detectTargetKind(params.channel, raw, params.preferredKind); - const normalizedInput = resolveNormalizedTargetInput(params.channel, raw); + const kind = detectTargetKind(params.channel, raw, params.preferredKind, plugin); + const normalizedInput = resolveNormalizedTargetInput(params.channel, raw, plugin); const normalized = normalizedInput?.normalized ?? raw; if ( normalizedInput && @@ -377,6 +399,7 @@ export async function resolveMessagingTarget(params: { channel: params.channel, raw: normalizedInput.raw, normalized, + plugin, }) ) { const resolvedIdLikeTarget = await maybeResolveIdLikeTarget({ @@ -385,6 +408,7 @@ export async function resolveMessagingTarget(params: { input: raw, accountId: params.accountId, preferredKind: params.preferredKind, + plugin, }); if (resolvedIdLikeTarget) { return { @@ -406,14 +430,15 @@ export async function resolveMessagingTarget(params: { query, runtime: params.runtime, preferLiveOnMiss: true, + plugin, }); - const match = resolveMatch({ channel: params.channel, entries, query }); + const match = resolveMatch({ channel: params.channel, entries, query, plugin }); if (match.kind === "single") { const entry = match.entry; return { ok: true, target: { - to: normalizeDirectoryEntryId(params.channel, entry), + to: normalizeDirectoryEntryId(params.channel, entry, plugin), kind, display: entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id), source: "directory", @@ -429,7 +454,7 @@ export async function resolveMessagingTarget(params: { return { ok: true, target: { - to: normalizeDirectoryEntryId(params.channel, best), + to: normalizeDirectoryEntryId(params.channel, best, plugin), kind, display: best.name ?? best.handle ?? stripTargetPrefixes(best.id), source: "directory", @@ -451,6 +476,7 @@ export async function resolveMessagingTarget(params: { input: raw, accountId: params.accountId, preferredKind: params.preferredKind, + plugin, }), ); if (resolvedFallbackTarget) { diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 8605505f2a58..305fcb11927d 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -766,6 +766,130 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.threadId).toBeUndefined(); }); + it("bootstraps plugin-channel heartbeat routes when the plugin registry is unavailable", () => { + const forum = createForumTargetTestPlugin(); + setActivePluginRegistry(createTargetsTestRegistry([])); + mocks.resolveOutboundChannelPlugin.mockImplementation( + ({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => + channel === "forum" && allowBootstrap === true ? forum : undefined, + ); + + const resolved = resolveHeartbeatDeliveryTarget({ + cfg: {}, + entry: { + sessionId: "sess-heartbeat-no-registry", + updatedAt: 1, + lastChannel: "forum", + lastTo: "room:ops", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:ops"); + expect(mocks.resolveOutboundChannelPlugin).toHaveBeenCalledWith({ + channel: "forum", + cfg: {}, + allowBootstrap: true, + }); + expect( + mocks.resolveOutboundChannelPlugin.mock.calls.filter( + ([params]) => params.allowBootstrap === true, + ), + ).toHaveLength(1); + }); + + it("does not bypass target policy when bootstrapping plugin-channel heartbeat routes", () => { + const forum = createForumTargetTestPlugin(); + setActivePluginRegistry(createTargetsTestRegistry([])); + mocks.resolveOutboundChannelPlugin.mockImplementation( + ({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => + channel === "forum" && allowBootstrap === true ? forum : undefined, + ); + + const resolved = resolveHeartbeatDeliveryTarget({ + cfg: {}, + entry: { + sessionId: "sess-heartbeat-no-registry-invalid-target", + updatedAt: 1, + lastChannel: "forum", + lastTo: "invalid", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("no-target"); + expect( + mocks.resolveOutboundChannelPlugin.mock.calls.filter( + ([params]) => params.allowBootstrap === true, + ), + ).toHaveLength(1); + }); + + it("does not bypass account validation when bootstrapping plugin-channel heartbeat routes", () => { + const forum = createForumTargetTestPlugin(); + const forumWithAccounts = { + ...forum, + config: { + ...forum.config, + listAccountIds: () => ["valid-account"], + }, + }; + setActivePluginRegistry(createTargetsTestRegistry([])); + mocks.resolveOutboundChannelPlugin.mockImplementation( + ({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => + channel === "forum" && allowBootstrap === true ? forumWithAccounts : undefined, + ); + + const resolved = resolveHeartbeatDeliveryTarget({ + cfg: {}, + entry: { + sessionId: "sess-heartbeat-no-registry-invalid-account", + updatedAt: 1, + lastChannel: "forum", + lastTo: "room:ops", + }, + heartbeat: { + target: "last", + accountId: "missing-account", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("unknown-account"); + expect( + mocks.resolveOutboundChannelPlugin.mock.calls.filter( + ([params]) => params.allowBootstrap === true, + ), + ).toHaveLength(1); + }); + + it("does not bootstrap plugin-channel heartbeat routes without a concrete target", () => { + setActivePluginRegistry(createTargetsTestRegistry([])); + + const resolved = resolveHeartbeatDeliveryTarget({ + cfg: {}, + entry: { + sessionId: "sess-heartbeat-no-target", + updatedAt: 1, + lastChannel: "forum", + }, + heartbeat: { + target: "last", + accountId: "configured-account", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("no-target"); + expect(mocks.resolveOutboundChannelPlugin).not.toHaveBeenCalled(); + }); + it("resolves explicit heartbeat plugin targets through the outbound session route", async () => { const cfg: OpenClawConfig = {}; const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({ @@ -782,25 +906,70 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.threadId).toBe(1008013); }); + it("bootstraps explicit external heartbeat targets before strict validation", () => { + const external = { + ...createForumTargetTestPlugin(), + id: "external-channel", + }; + mocks.resolveOutboundChannelPlugin.mockImplementation( + ({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => + channel === "external-channel" && allowBootstrap === true ? external : undefined, + ); + + const resolved = resolveHeartbeatDeliveryTarget({ + cfg: {}, + entry: { + sessionId: "sess-external-account", + updatedAt: 1, + lastChannel: "external-channel", + lastTo: "room:previous", + lastAccountId: "account-2", + }, + heartbeat: { + target: "external-channel", + to: "room:ops", + }, + }); + + expect(resolved.channel).toBe("external-channel"); + expect(resolved.to).toBe("room:ops"); + expect(resolved.accountId).toBe("account-2"); + expect(mocks.resolveOutboundChannelPlugin).toHaveBeenCalledWith({ + channel: "external-channel", + cfg: {}, + allowBootstrap: true, + }); + }); + it("blocks heartbeat targets that route to direct chats after canonicalization", async () => { const alpha = createGenericTargetTestPlugin("alpha", "Alpha"); - setActivePluginRegistry( - createTargetsTestRegistry([ - { - ...alpha, - messaging: { - ...alpha.messaging, - resolveOutboundSessionRoute: () => ({ - sessionKey: "main:alpha:user:u123", - baseSessionKey: "main:alpha:user:u123", - peer: { kind: "direct", id: "u123" }, - chatType: "direct", - from: "alpha:u123", - to: "user:u123", - }), - }, - }, - ]), + const routedAlpha = { + ...alpha, + messaging: { + ...alpha.messaging, + resolveOutboundSessionRoute: () => ({ + sessionKey: "main:alpha:user:u123", + baseSessionKey: "main:alpha:user:u123", + peer: { kind: "direct" as const, id: "u123" }, + chatType: "direct" as const, + from: "alpha:u123", + to: "user:u123", + }), + }, + }; + setActivePluginRegistry(createTargetsTestRegistry([])); + mocks.resolveOutboundChannelPlugin.mockImplementation( + ({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => { + if (channel !== "alpha") { + return undefined; + } + if (allowBootstrap === true) { + setActivePluginRegistry(createTargetsTestRegistry([routedAlpha])); + return routedAlpha; + } + return getActivePluginRegistry()?.channels.find((entry) => entry?.plugin?.id === channel) + ?.plugin; + }, ); const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({ @@ -876,6 +1045,155 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.chatType).toBe("group"); }); + it("uses an activation-aware external plugin when canonicalizing heartbeat routes", async () => { + const external = createTestChannelPlugin({ + id: "external-channel", + label: "External", + outbound: { + deliveryMode: "direct", + resolveTarget: ({ to }) => + to + ? { ok: true as const, to: to.trim() } + : { ok: false as const, error: new Error("target required") }, + }, + messaging: { + targetResolver: { + resolveTarget: async ({ normalized }) => ({ + to: normalized, + kind: "user", + source: "directory", + }), + }, + resolveOutboundSessionRoute: ({ target, resolvedTarget }) => { + const isDirect = resolvedTarget?.kind === "user"; + return { + sessionKey: `main:external-channel:${isDirect ? "user" : "group"}:${target}`, + baseSessionKey: `main:external-channel:${isDirect ? "user" : "group"}:${target}`, + peer: { kind: isDirect ? "direct" : "group", id: target }, + chatType: isDirect ? "direct" : "group", + from: `external-channel:${target}`, + to: target, + }; + }, + }, + }); + const setupExternal = { ...external, messaging: undefined }; + setActivePluginRegistry(createTargetsTestRegistry([setupExternal])); + mocks.resolveOutboundChannelPlugin.mockImplementation( + ({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => { + if (channel !== "external-channel") { + return undefined; + } + if (allowBootstrap === true) { + return external; + } + return setupExternal; + }, + ); + + const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({ + cfg: {}, + agentId: "main", + heartbeat: { + target: "external-channel", + to: "person-123", + directPolicy: "block", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("blocks direct targets from prepared external target resolvers without route hooks", async () => { + const external = createTestChannelPlugin({ + id: "external-channel", + label: "External", + outbound: { + deliveryMode: "direct", + resolveTarget: ({ to }) => + to + ? { ok: true as const, to: to.trim() } + : { ok: false as const, error: new Error("target required") }, + }, + messaging: { + targetResolver: { + resolveTarget: async ({ normalized }) => ({ + to: normalized, + kind: "user", + source: "directory", + }), + }, + }, + }); + setActivePluginRegistry(createTargetsTestRegistry([])); + mocks.resolveOutboundChannelPlugin.mockImplementation( + ({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => { + if (channel !== "external-channel") { + return undefined; + } + if (allowBootstrap === true) { + setActivePluginRegistry(createTargetsTestRegistry([external])); + return external; + } + return getActivePluginRegistry()?.channels.find((entry) => entry?.plugin?.id === channel) + ?.plugin; + }, + ); + + const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({ + cfg: {}, + agentId: "main", + heartbeat: { + target: "external-channel", + to: "person-123", + directPolicy: "block", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("uses an activation-aware infer-only plugin for heartbeat direct policy", () => { + const external = createTestChannelPlugin({ + id: "external-channel", + label: "External", + outbound: { + deliveryMode: "direct", + sendText: vi.fn(), + resolveTarget: ({ to }) => + to + ? { ok: true as const, to: to.trim() } + : { ok: false as const, error: new Error("target required") }, + }, + messaging: { + inferTargetChatType: () => "direct", + }, + }); + const setupExternal = { ...external, messaging: undefined }; + mocks.resolveOutboundChannelPlugin.mockImplementation( + ({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => { + if (channel !== "external-channel") { + return undefined; + } + return allowBootstrap === true ? external : setupExternal; + }, + ); + + const resolved = resolveHeartbeatDeliveryTarget({ + cfg: {}, + heartbeat: { + target: "external-channel", + to: "person-123", + directPolicy: "block", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + it("keeps heartbeat route canonicalization best-effort when target resolution fails", async () => { setActivePluginRegistry( createTargetsTestRegistry([ diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index b494f71b507e..df2ed21a75dd 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -3,6 +3,7 @@ import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.core.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelId } from "../../channels/plugins/types.public.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; @@ -104,12 +105,27 @@ export function resolveHeartbeatDeliveryTarget(params: { const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat; const rawTarget = heartbeat?.target; let target: HeartbeatTarget = "none"; + let preparedExplicitPlugin: ChannelPlugin | undefined; + let preparedExplicitTo: string | undefined; if (rawTarget === "none" || rawTarget === "last") { target = rawTarget; } else if (typeof rawTarget === "string") { const normalized = normalizeDeliverableOutboundChannel(rawTarget); if (normalized) { target = normalized; + } else { + const explicitTo = heartbeat?.to?.trim(); + if (explicitTo) { + preparedExplicitPlugin = resolveOutboundChannelPlugin({ + channel: rawTarget, + cfg, + allowBootstrap: true, + }); + if (preparedExplicitPlugin) { + target = preparedExplicitPlugin.id as HeartbeatTarget; + preparedExplicitTo = explicitTo; + } + } } } else if ( rawTarget === undefined && @@ -139,34 +155,57 @@ export function resolveHeartbeatDeliveryTarget(params: { ? mergeDeliveryContext(params.turnSource, deliveryContextFromSession(entry)) : undefined; - const resolvedTarget = resolveSessionDeliveryTarget({ - entry, - requestedChannel: target === "last" ? "last" : target, - explicitTo: heartbeat?.to, - mode: "heartbeat", - turnSourceChannel: - resolvedTurnSource?.channel && isDeliverableMessageChannel(resolvedTurnSource.channel) - ? resolvedTurnSource.channel - : undefined, - turnSourceTo: resolvedTurnSource?.to, - turnSourceAccountId: resolvedTurnSource?.accountId, - // Only pass threadId from an explicit turn source (e.g., restart sentinel's - // delivery context). Do NOT fall back to session-stored threadId here — - // heartbeat mode intentionally drops inherited thread IDs to avoid replying - // in stale threads (e.g., Slack thread_ts). The sentinel's delivery context - // carries the correct topic/thread ID when present. - turnSourceThreadId: params.turnSource?.threadId, - }); + const resolvedTarget = + preparedExplicitPlugin && preparedExplicitTo + ? resolveSessionDeliveryTarget({ + entry, + requestedChannel: target, + explicitTo: preparedExplicitTo, + mode: "heartbeat", + }) + : resolveSessionDeliveryTarget({ + entry, + requestedChannel: target === "last" ? "last" : target, + explicitTo: heartbeat?.to, + mode: "heartbeat", + turnSourceChannel: + resolvedTurnSource?.channel && isDeliverableMessageChannel(resolvedTurnSource.channel) + ? resolvedTurnSource.channel + : undefined, + turnSourceTo: resolvedTurnSource?.to, + turnSourceAccountId: resolvedTurnSource?.accountId, + // Only pass threadId from an explicit turn source (e.g., restart sentinel's + // delivery context). Do NOT fall back to session-stored threadId here — + // heartbeat mode intentionally drops inherited thread IDs to avoid replying + // in stale threads (e.g., Slack thread_ts). The sentinel's delivery context + // carries the correct topic/thread ID when present. + turnSourceThreadId: params.turnSource?.threadId, + }); const heartbeatAccountId = heartbeat?.accountId?.trim(); // Use explicit accountId from heartbeat config if provided, otherwise fall back to session let effectiveAccountId = heartbeatAccountId || resolvedTarget.accountId; - if (heartbeatAccountId && resolvedTarget.channel) { - const plugin = resolveOutboundChannelPlugin({ + if (!resolvedTarget.channel || !resolvedTarget.to) { + return buildNoHeartbeatDeliveryTarget({ + reason: "no-target", + accountId: effectiveAccountId, + lastChannel: resolvedTarget.lastChannel, + lastAccountId: resolvedTarget.lastAccountId, + }); + } + + // Bootstrap once after a concrete route exists, then carry the prepared plugin + // through account validation, target policy, and allow-from comparison. + const plugin = + preparedExplicitPlugin ?? + resolveOutboundChannelPlugin({ channel: resolvedTarget.channel, cfg, + allowBootstrap: true, }); + + if (heartbeatAccountId) { const listAccountIds = plugin?.config.listAccountIds; const accountIds = listAccountIds ? listAccountIds(cfg) : []; if (accountIds.length > 0) { @@ -186,23 +225,17 @@ export function resolveHeartbeatDeliveryTarget(params: { } } - if (!resolvedTarget.channel || !resolvedTarget.to) { - return buildNoHeartbeatDeliveryTarget({ - reason: "no-target", + const resolved = resolveOutboundTargetWithPlugin({ + plugin, + target: { + channel: resolvedTarget.channel, + to: resolvedTarget.to, + cfg, accountId: effectiveAccountId, - lastChannel: resolvedTarget.lastChannel, - lastAccountId: resolvedTarget.lastAccountId, - }); - } - - const resolved = resolveOutboundTarget({ - channel: resolvedTarget.channel, - to: resolvedTarget.to, - cfg, - accountId: effectiveAccountId, - mode: "heartbeat", + mode: "heartbeat", + }, }); - if (!resolved.ok) { + if (!resolved?.ok) { return buildNoHeartbeatDeliveryTarget({ reason: "no-target", accountId: effectiveAccountId, @@ -217,6 +250,7 @@ export function resolveHeartbeatDeliveryTarget(params: { channel: resolvedTarget.channel, to: resolved.to, sessionChatType: sessionChatTypeHint, + plugin, }); if (deliveryChatType === "direct" && heartbeat?.directPolicy === "block") { return buildNoHeartbeatDeliveryTarget({ @@ -228,19 +262,18 @@ export function resolveHeartbeatDeliveryTarget(params: { } let reason: string | undefined; - const plugin = resolveOutboundChannelPlugin({ - channel: resolvedTarget.channel, - cfg, - }); if (plugin?.config.resolveAllowFrom) { - const explicit = resolveOutboundTarget({ - channel: resolvedTarget.channel, - to: resolvedTarget.to, - cfg, - accountId: effectiveAccountId, - mode: "explicit", + const explicit = resolveOutboundTargetWithPlugin({ + plugin, + target: { + channel: resolvedTarget.channel, + to: resolvedTarget.to, + cfg, + accountId: effectiveAccountId, + mode: "explicit", + }, }); - if (explicit.ok && explicit.to !== resolved.to) { + if (explicit?.ok && explicit.to !== resolved.to) { reason = "allowFrom-fallback"; } } @@ -252,6 +285,7 @@ export function resolveHeartbeatDeliveryTarget(params: { turnSource: params.turnSource, entry, resolvedTarget, + plugin, }) ? resolvedTarget.lastThreadId : undefined; @@ -303,8 +337,10 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: { const plugin = resolveOutboundChannelPlugin({ channel: delivery.channel, cfg: params.cfg, + allowBootstrap: true, }); - if (!plugin?.messaging?.resolveOutboundSessionRoute) { + const resolveSessionRoute = plugin?.messaging?.resolveOutboundSessionRoute; + if (!resolveSessionRoute && !plugin?.messaging?.targetResolver) { return delivery; } let routeResolvedTarget: ResolvedMessagingTarget | undefined; @@ -316,6 +352,7 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: { input: deliveryTo, accountId: delivery.accountId, unknownTargetMode: "normalized", + plugin, }); } catch { // Target normalization failure should not suppress an otherwise deliverable heartbeat. @@ -325,11 +362,23 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: { if (targetResolution?.ok) { routeResolvedTarget = targetResolution.target; } + if (routeResolvedTarget?.kind === "user" && heartbeat?.directPolicy === "block") { + return buildNoHeartbeatDeliveryTarget({ + reason: "dm-blocked", + accountId: delivery.accountId, + lastChannel: delivery.lastChannel, + lastAccountId: delivery.lastAccountId, + }); + } + if (!resolveSessionRoute) { + return delivery; + } const route = await (async () => { try { return await resolveOutboundSessionRoute({ cfg: params.cfg, channel: delivery.channel as ChannelId, + plugin, agentId: params.agentId, accountId: delivery.accountId, target: routeResolvedTarget?.to ?? deliveryTo, @@ -363,6 +412,7 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: { function inferChatTypeFromTarget(params: { channel: DeliverableMessageChannel; to: string; + plugin?: ChannelPlugin; }): ChatType | undefined { const to = params.to.trim(); if (!to) { @@ -378,17 +428,19 @@ function inferChatTypeFromTarget(params: { if (/^group:/i.test(to)) { return "group"; } - return ( + const plugin = + params.plugin ?? resolveOutboundChannelPlugin({ channel: params.channel, - })?.messaging?.inferTargetChatType?.({ to }) ?? undefined - ); + }); + return plugin?.messaging?.inferTargetChatType?.({ to }) ?? undefined; } function resolveHeartbeatDeliveryChatType(params: { channel: DeliverableMessageChannel; to: string; sessionChatType?: ChatType; + plugin?: ChannelPlugin; }): ChatType | undefined { if (params.sessionChatType) { return params.sessionChatType; @@ -396,6 +448,7 @@ function resolveHeartbeatDeliveryChatType(params: { return inferChatTypeFromTarget({ channel: params.channel, to: params.to, + plugin: params.plugin, }); } @@ -406,10 +459,12 @@ function shouldReuseHeartbeatRouteThreadId(params: { turnSource?: DeliveryContext; entry?: SessionEntry; resolvedTarget: SessionDeliveryTarget; + plugin?: ChannelPlugin; }): boolean { const channel = params.resolvedTarget.channel; - const messaging = - channel && resolveOutboundChannelPlugin({ channel, cfg: params.cfg })?.messaging; + const messaging = params.plugin + ? params.plugin.messaging + : channel && resolveOutboundChannelPlugin({ channel, cfg: params.cfg })?.messaging; return ( messaging?.preserveHeartbeatThreadIdForGroupRoute === true && params.resolvedTarget.threadId == null &&