mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(heartbeat): bootstrap plugin session targets (#93630)
* fix(heartbeat): bootstrap plugin session targets * fix(heartbeat): reuse bootstrapped route plugin * fix(heartbeat): preserve active external route plugins * fix(heartbeat): carry prepared plugin through routing * fix(heartbeat): canonicalize with prepared route plugin * fix(heartbeat): preserve explicit route account context * fix(heartbeat): enforce prepared plugin route policy * fix(outbound): partition prepared plugin target cache * test(cron): expect prepared delivery plugin * test(outbound): use complete plugin fixtures * fix(outbound): bootstrap direct metadata shells * fix(outbound): scope external runtime activation * test(outbound): model activated direct plugins * fix(heartbeat): keep route policy activation-aware * fix(heartbeat): preserve prepared plugin policy --------- Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
co-authored by
Vincent Koc
parent
617f97d4b9
commit
6470bb7625
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof getActivePluginRegistry>,
|
||||
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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ResolvedIdLikeTarget | undefined> {
|
||||
const target = await maybeResolvePluginMessagingTarget({
|
||||
...params,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ type TargetNormalizerCacheEntry = {
|
||||
};
|
||||
|
||||
const targetNormalizerCacheByChannelId = new Map<string, TargetNormalizerCacheEntry>();
|
||||
const preparedPluginSignatureIds = new WeakMap<ChannelPlugin, number>();
|
||||
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<ResolvedPluginMessagingTarget | undefined> {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ResolveMessagingTargetResult> {
|
||||
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<ChannelDirectoryEntry[]> {
|
||||
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<ChannelDirectoryEntry[]> {
|
||||
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<ResolveMessagingTargetResult> {
|
||||
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) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
+107
-52
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user