test: decouple outbound target tests from bundled plugins

This commit is contained in:
Peter Steinberger
2026-04-20 23:03:30 +01:00
parent e0c01bf956
commit 72571f0d38
11 changed files with 548 additions and 531 deletions

View File

@@ -1,2 +1,2 @@
5e63409c91d1021a24496f498af2761cca644e59e26742b473eb53615d13154f plugin-sdk-api-baseline.json
61943ab581937f84635e9b46e0f05591bb1fabe606cb57c36e9aed7a1242c685 plugin-sdk-api-baseline.jsonl
4ec700ac180b7eca81ca48885bc7f645dbf5016e2438e44678f4c206eed4b643 plugin-sdk-api-baseline.json
ff0d1541e7220c67d97444304568285303e423770bd6af6227afdf470bf233cc plugin-sdk-api-baseline.jsonl

View File

@@ -707,6 +707,7 @@ export const telegramPlugin = createChatChannelPlugin({
resolveTelegramSessionConversation({ kind, rawId }),
parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType,
preserveHeartbeatThreadIdForGroupRoute: true,
formatTargetDisplay: ({ target, display, kind }) => {
const formatted = display?.trim();
if (formatted) {

View File

@@ -10,15 +10,15 @@ import {
resolveComparableTargetForLoadedChannel,
} from "./target-parsing.js";
function parseTelegramTargetForTest(raw: string): {
function parseThreadedTargetForTest(raw: string): {
to: string;
threadId?: number;
chatType?: "direct" | "group";
} {
const trimmed = raw
.trim()
.replace(/^telegram:/i, "")
.replace(/^tg:/i, "");
.replace(/^threaded:/i, "")
.replace(/^mock:/i, "");
const prefixedTopic = /^group:([^:]+):topic:(\d+)$/i.exec(trimmed);
if (prefixedTopic) {
return {
@@ -45,14 +45,14 @@ function setMinimalTargetParsingRegistry(): void {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
pluginId: "mock-threaded",
plugin: {
id: "telegram",
id: "mock-threaded",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
id: "mock-threaded",
label: "Mock Threaded",
selectionLabel: "Mock Threaded",
docsPath: "/channels/mock-threaded",
blurb: "test stub",
},
capabilities: { chatTypes: ["direct", "group"] },
@@ -61,7 +61,7 @@ function setMinimalTargetParsingRegistry(): void {
resolveAccount: () => ({}),
},
messaging: {
parseExplicitTarget: ({ raw }: { raw: string }) => parseTelegramTargetForTest(raw),
parseExplicitTarget: ({ raw }: { raw: string }) => parseThreadedTargetForTest(raw),
},
},
source: "test",
@@ -100,15 +100,17 @@ describe("parseExplicitTargetForChannel", () => {
setMinimalTargetParsingRegistry();
});
it("parses Telegram targets via the registered channel plugin contract", () => {
expect(parseExplicitTargetForChannel("telegram", "telegram:group:-100123:topic:77")).toEqual({
to: "-100123",
it("parses threaded targets via the registered channel plugin contract", () => {
expect(
parseExplicitTargetForChannel("mock-threaded", "threaded:group:room-a:topic:77"),
).toEqual({
to: "room-a",
threadId: 77,
chatType: "group",
});
expect(parseExplicitTargetForChannel("telegram", "-100123")).toEqual({
to: "-100123",
chatType: "group",
expect(parseExplicitTargetForChannel("mock-threaded", "room-a")).toEqual({
to: "room-a",
chatType: undefined,
});
});
@@ -126,23 +128,23 @@ describe("parseExplicitTargetForChannel", () => {
it("builds comparable targets from plugin-owned grammar", () => {
expect(
resolveComparableTargetForChannel({
channel: "telegram",
rawTarget: "telegram:group:-100123:topic:77",
channel: "mock-threaded",
rawTarget: "threaded:group:room-a:topic:77",
}),
).toEqual({
rawTo: "telegram:group:-100123:topic:77",
to: "-100123",
rawTo: "threaded:group:room-a:topic:77",
to: "room-a",
threadId: 77,
chatType: "group",
});
expect(
resolveComparableTargetForLoadedChannel({
channel: "telegram",
rawTarget: "telegram:group:-100123:topic:77",
channel: "mock-threaded",
rawTarget: "threaded:group:room-a:topic:77",
}),
).toEqual({
rawTo: "telegram:group:-100123:topic:77",
to: "-100123",
rawTo: "threaded:group:room-a:topic:77",
to: "room-a",
threadId: 77,
chatType: "group",
});
@@ -150,12 +152,12 @@ describe("parseExplicitTargetForChannel", () => {
it("matches comparable targets when only the plugin grammar differs", () => {
const topicTarget = resolveComparableTargetForChannel({
channel: "telegram",
rawTarget: "telegram:-100123:topic:77",
channel: "mock-threaded",
rawTarget: "threaded:room-a:topic:77",
});
const bareTarget = resolveComparableTargetForChannel({
channel: "telegram",
rawTarget: "-100123",
channel: "mock-threaded",
rawTarget: "room-a",
});
expect(

View File

@@ -515,6 +515,11 @@ export type ChannelMessagingAdapter = {
* steer peer-vs-group resolution without reimplementing host search flow.
*/
inferTargetChatType?: (params: { to: string }) => ChatType | undefined;
/**
* Preserve the session thread/topic id for heartbeat replies when that thread
* is part of the destination identity, not a transient reply thread.
*/
preserveHeartbeatThreadIdForGroupRoute?: boolean;
buildCrossContextComponents?: ChannelCrossContextComponentsFactory;
transformReplyPayload?: (params: {
payload: ReplyPayload;

View File

@@ -4,7 +4,6 @@ import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { ChannelOutboundAdapter, ChannelOutboundContext } from "../channels/plugins/types.js";
import type { CliDeps } from "../cli/deps.js";
import { resolveOutboundSendDep } from "../infra/outbound/send-deps.js";
import { createWhatsAppTestPlugin } from "../infra/outbound/targets.test-helpers.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js";
@@ -169,7 +168,12 @@ function createCliDelegatingOutbound(params: {
};
}
const whatsappResolveTarget = createWhatsAppTestPlugin().outbound?.resolveTarget;
const identityResolveTarget: ChannelOutboundAdapter["resolveTarget"] = ({ to }) => {
const trimmed = to?.trim();
return trimmed
? { ok: true, to: trimmed }
: { ok: false, error: new Error("target is required") };
};
describe("runCronIsolatedAgentTurn core-channel direct delivery", () => {
beforeEach(() => {
@@ -199,7 +203,7 @@ describe("runCronIsolatedAgentTurn core-channel direct delivery", () => {
outbound: createCliDelegatingOutbound({
channel: "whatsapp",
deliveryMode: "gateway",
resolveTarget: whatsappResolveTarget,
resolveTarget: identityResolveTarget,
}),
}),
source: "test",

View File

@@ -1,7 +1,7 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { telegramMessagingForTest } from "../../infra/outbound/targets.test-helpers.js";
import { forumMessagingForTest } from "../../infra/outbound/targets.test-helpers.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
@@ -20,7 +20,7 @@ vi.mock("../../config/sessions/store-load.js", () => ({
vi.mock("../../infra/outbound/channel-selection.runtime.js", () => ({
resolveMessageChannelSelection: vi
.fn()
.mockResolvedValue({ channel: "telegram", configured: ["telegram"] }),
.mockResolvedValue({ channel: "alpha", configured: ["alpha"] }),
}));
vi.mock("../../infra/outbound/target-id-resolution.js", () => ({
@@ -92,26 +92,26 @@ beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
pluginId: "forum",
plugin: createOutboundTestPlugin({
id: "telegram",
outbound: createStubOutbound("Telegram"),
messaging: telegramMessagingForTest,
id: "forum",
outbound: createStubOutbound("Forum"),
messaging: forumMessagingForTest,
}),
source: "test",
},
{
pluginId: "whatsapp",
pluginId: "alpha",
plugin: {
...createOutboundTestPlugin({
id: "whatsapp",
outbound: createAllowlistAwareStubOutbound("WhatsApp"),
id: "alpha",
outbound: createAllowlistAwareStubOutbound("Alpha"),
}),
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
resolveAllowFrom: ({ cfg }: { cfg: OpenClawConfig }) =>
(cfg.channels?.whatsapp as { allowFrom?: string[] } | undefined)?.allowFrom,
(cfg.channels?.alpha as { allowFrom?: string[] } | undefined)?.allowFrom,
},
},
source: "test",
@@ -132,12 +132,12 @@ function makeCfg(overrides?: Partial<OpenClawConfig>): OpenClawConfig {
} as OpenClawConfig;
}
function makeTelegramBoundCfg(accountId = "account-b"): OpenClawConfig {
function makeForumBoundCfg(accountId = "account-b"): OpenClawConfig {
return makeCfg({
bindings: [
{
agentId: AGENT_ID,
match: { channel: "telegram", accountId },
match: { channel: "forum", accountId },
},
],
});
@@ -145,8 +145,8 @@ function makeTelegramBoundCfg(accountId = "account-b"): OpenClawConfig {
const AGENT_ID = "agent-b";
const DEFAULT_TARGET = {
channel: "telegram" as const,
to: "123456",
channel: "forum" as const,
to: "room:default",
};
type SessionStore = ReturnType<typeof loadSessionStore>;
@@ -177,13 +177,13 @@ function setLastSessionEntry(params: {
});
}
function setStoredWhatsAppAllowFrom(allowFrom: string[]) {
function setStoredAlphaAllowFrom(allowFrom: string[]) {
vi.mocked(readChannelAllowFromStoreEntriesSync).mockReturnValue(allowFrom);
}
async function resolveForAgent(params: {
cfg: OpenClawConfig;
target?: { channel?: "last" | "telegram"; to?: string };
target?: { channel?: "last" | "forum" | "alpha"; to?: string };
}) {
const channel = params.target ? params.target.channel : DEFAULT_TARGET.channel;
const to = params.target && "to" in params.target ? params.target.to : DEFAULT_TARGET.to;
@@ -201,41 +201,41 @@ async function resolveLastTarget(cfg: OpenClawConfig) {
}
describe("resolveDeliveryTarget", () => {
it("reroutes implicit whatsapp delivery to authorized allowFrom recipient", async () => {
it("reroutes implicit delivery to an authorized allowFrom recipient", async () => {
setLastSessionEntry({
sessionId: "sess-w1",
lastChannel: "whatsapp",
lastTo: "+15550000099",
lastChannel: "alpha",
lastTo: "room-denied",
});
setStoredWhatsAppAllowFrom(["+15550000001"]);
setStoredAlphaAllowFrom(["room-allowed"]);
const cfg = makeCfg({ bindings: [], channels: { whatsapp: { allowFrom: [] } } });
const cfg = makeCfg({ bindings: [], channels: { alpha: { allowFrom: [] } } });
const result = await resolveLastTarget(cfg);
expect(result.channel).toBe("whatsapp");
expect(result.to).toBe("+15550000001");
expect(result.channel).toBe("alpha");
expect(result.to).toBe("room-allowed");
});
it("keeps explicit whatsapp target unchanged", async () => {
it("keeps explicit delivery target unchanged", async () => {
setLastSessionEntry({
sessionId: "sess-w2",
lastChannel: "whatsapp",
lastTo: "+15550000099",
lastChannel: "alpha",
lastTo: "room-denied",
});
setStoredWhatsAppAllowFrom(["+15550000001"]);
setStoredAlphaAllowFrom(["room-allowed"]);
const cfg = makeCfg({ bindings: [], channels: { whatsapp: { allowFrom: [] } } });
const cfg = makeCfg({ bindings: [], channels: { alpha: { allowFrom: [] } } });
const result = await resolveDeliveryTarget(cfg, AGENT_ID, {
channel: "whatsapp",
to: "+15550000099",
channel: "alpha",
to: "room-denied",
});
expect(result.to).toBe("+15550000099");
expect(result.to).toBe("room-denied");
});
it("falls back to bound accountId when session has no lastAccountId", async () => {
setMainSessionEntry(undefined);
const cfg = makeTelegramBoundCfg();
const cfg = makeForumBoundCfg();
const result = await resolveForAgent({ cfg });
expect(result.accountId).toBe("account-b");
@@ -248,14 +248,14 @@ describe("resolveDeliveryTarget", () => {
{
agentId: AGENT_ID,
match: {
channel: "telegram",
peer: { kind: "channel", id: "123456" },
channel: "forum",
peer: { kind: "channel", id: "room:default" },
accountId: "peer-first",
},
},
{
agentId: AGENT_ID,
match: { channel: "telegram", accountId: "channel-second" },
match: { channel: "forum", accountId: "channel-second" },
},
],
});
@@ -272,7 +272,7 @@ describe("resolveDeliveryTarget", () => {
{
agentId: AGENT_ID,
match: {
channel: "telegram",
channel: "forum",
guildId: "guild-1",
accountId: "tenant-account",
},
@@ -289,12 +289,12 @@ describe("resolveDeliveryTarget", () => {
setMainSessionEntry({
sessionId: "sess-1",
updatedAt: 1000,
lastChannel: "telegram",
lastTo: "123456",
lastChannel: "forum",
lastTo: "room:default",
lastAccountId: "session-account",
});
const cfg = makeTelegramBoundCfg();
const cfg = makeForumBoundCfg();
const result = await resolveForAgent({ cfg });
// Session-derived accountId should take precedence over binding
@@ -321,7 +321,7 @@ describe("resolveDeliveryTarget", () => {
});
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "telegram",
channel: "forum",
to: "123456789",
});
@@ -329,7 +329,7 @@ describe("resolveDeliveryTarget", () => {
expect(result.to).toBe("user:123456789");
expect(maybeResolveIdLikeTarget).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
channel: "forum",
input: "123456789",
}),
);
@@ -340,33 +340,33 @@ describe("resolveDeliveryTarget", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "whatsapp",
pluginId: "alpha",
plugin: createOutboundTestPlugin({
id: "whatsapp",
outbound: createStubOutbound("WhatsApp"),
id: "alpha",
outbound: createStubOutbound("Alpha"),
}),
source: "test",
},
]),
);
vi.mocked(resolveOutboundTarget).mockReturnValueOnce({ ok: true, to: "123456" });
vi.mocked(resolveOutboundTarget).mockReturnValueOnce({ ok: true, to: "room:default" });
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "telegram",
to: "123456",
channel: "forum",
to: "room:default",
});
expect(result).toEqual(
expect.objectContaining({
ok: true,
channel: "telegram",
to: "123456",
channel: "forum",
to: "room:default",
}),
);
expect(resolveOutboundTarget).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "123456",
channel: "forum",
to: "room:default",
}),
);
});
@@ -378,11 +378,11 @@ describe("resolveDeliveryTarget", () => {
bindings: [
{
agentId: "agent-a",
match: { channel: "telegram", accountId: "account-a" },
match: { channel: "forum", accountId: "account-a" },
},
{
agentId: "agent-b",
match: { channel: "telegram", accountId: "account-b" },
match: { channel: "forum", accountId: "account-b" },
},
],
});
@@ -399,7 +399,7 @@ describe("resolveDeliveryTarget", () => {
bindings: [
{
agentId: "agent-b",
match: { channel: "discord", accountId: "discord-account" },
match: { channel: "alpha", accountId: "alpha-account" },
},
],
});
@@ -412,8 +412,8 @@ describe("resolveDeliveryTarget", () => {
it("drops session threadId when destination does not match the previous recipient", async () => {
setLastSessionEntry({
sessionId: "sess-2",
lastChannel: "telegram",
lastTo: "999999",
lastChannel: "forum",
lastTo: "room:other",
lastThreadId: "thread-1",
});
@@ -424,8 +424,8 @@ describe("resolveDeliveryTarget", () => {
it("keeps session threadId when destination matches the previous recipient", async () => {
setLastSessionEntry({
sessionId: "sess-3",
lastChannel: "telegram",
lastTo: "123456",
lastChannel: "forum",
lastTo: "room:default",
lastThreadId: "thread-2",
});
@@ -437,7 +437,7 @@ describe("resolveDeliveryTarget", () => {
setMainSessionEntry(undefined);
const result = await resolveLastTarget(makeCfg({ bindings: [] }));
expect(result.channel).toBe("telegram");
expect(result.channel).toBe("alpha");
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected unresolved delivery target");
@@ -450,7 +450,7 @@ describe("resolveDeliveryTarget", () => {
it("returns an error when channel selection is ambiguous", async () => {
setMainSessionEntry(undefined);
vi.mocked(resolveMessageChannelSelection).mockRejectedValueOnce(
new Error("Channel is required when multiple channels are configured: telegram, slack"),
new Error("Channel is required when multiple channels are configured: alpha, forum"),
);
const result = await resolveLastTarget(makeCfg({ bindings: [] }));
@@ -468,13 +468,13 @@ describe("resolveDeliveryTarget", () => {
"agent:test:main": {
sessionId: "main-session",
updatedAt: 1000,
lastChannel: "telegram",
lastChannel: "forum",
lastTo: "main-chat",
},
"agent:test:thread:42": {
sessionId: "thread-session",
updatedAt: 2000,
lastChannel: "telegram",
lastChannel: "forum",
lastTo: "thread-chat",
lastThreadId: 42,
},
@@ -486,7 +486,7 @@ describe("resolveDeliveryTarget", () => {
to: undefined,
});
expect(result.channel).toBe("telegram");
expect(result.channel).toBe("forum");
expect(result.to).toBe("thread-chat");
expect(result.threadId).toBe(42);
});
@@ -496,7 +496,7 @@ describe("resolveDeliveryTarget", () => {
"agent:test:main": {
sessionId: "main-session",
updatedAt: 1000,
lastChannel: "telegram",
lastChannel: "forum",
lastTo: "main-chat",
},
} as SessionStore);
@@ -507,34 +507,34 @@ describe("resolveDeliveryTarget", () => {
to: undefined,
});
expect(result.channel).toBe("telegram");
expect(result.channel).toBe("forum");
expect(result.to).toBe("main-chat");
});
it("uses main session channel when channel=last and session route exists", async () => {
setLastSessionEntry({
sessionId: "sess-4",
lastChannel: "telegram",
lastTo: "987654",
lastChannel: "forum",
lastTo: "room:default",
});
const result = await resolveLastTarget(makeCfg({ bindings: [] }));
expect(result.channel).toBe("telegram");
expect(result.to).toBe("987654");
expect(result.channel).toBe("forum");
expect(result.to).toBe("room:default");
expect(result.ok).toBe(true);
});
it("parses explicit telegram topic targets into delivery threadId", async () => {
it("parses explicit plugin topic targets into delivery threadId", async () => {
setMainSessionEntry(undefined);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "telegram",
to: "63448508:topic:1008013",
channel: "forum",
to: "room:ops:topic:1008013",
});
expect(result.ok).toBe(true);
expect(result.to).toBe("63448508");
expect(result.to).toBe("room:ops");
expect(result.threadId).toBe(1008013);
});
@@ -542,27 +542,27 @@ describe("resolveDeliveryTarget", () => {
setMainSessionEntry(undefined);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "telegram",
to: "63448508",
channel: "forum",
to: "room:ops",
threadId: "1008013",
});
expect(result.ok).toBe(true);
expect(result.to).toBe("63448508");
expect(result.to).toBe("room:ops");
expect(result.threadId).toBe("1008013");
});
it("explicit delivery.accountId overrides session-derived accountId", async () => {
setLastSessionEntry({
sessionId: "sess-5",
lastChannel: "telegram",
lastTo: "chat-999",
lastChannel: "forum",
lastTo: "room:ops",
lastAccountId: "default",
});
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "telegram",
to: "chat-999",
channel: "forum",
to: "room:ops",
accountId: "bot-b",
});
@@ -573,12 +573,12 @@ describe("resolveDeliveryTarget", () => {
it("explicit delivery.accountId overrides bindings-derived accountId", async () => {
setMainSessionEntry(undefined);
const cfg = makeCfg({
bindings: [{ agentId: AGENT_ID, match: { channel: "telegram", accountId: "bound" } }],
bindings: [{ agentId: AGENT_ID, match: { channel: "forum", accountId: "bound" } }],
});
const result = await resolveDeliveryTarget(cfg, AGENT_ID, {
channel: "telegram",
to: "chat-777",
channel: "forum",
to: "room:ops",
accountId: "explicit",
});

View File

@@ -18,19 +18,20 @@ describe("tryResolveLoadedOutboundTarget", () => {
it("returns undefined when no loaded plugin exists", () => {
mocks.getLoadedChannelPlugin.mockReturnValue(undefined);
expect(tryResolveLoadedOutboundTarget({ channel: "telegram", to: "123" })).toBeUndefined();
expect(tryResolveLoadedOutboundTarget({ channel: "alpha", to: "room-one" })).toBeUndefined();
});
it("uses loaded plugin config defaultTo fallback", () => {
const cfg: OpenClawConfig = {
channels: { telegram: { defaultTo: "123456789" } },
channels: { alpha: { defaultTo: "room-one" } },
};
mocks.getLoadedChannelPlugin.mockReturnValue({
id: "telegram",
meta: { label: "Telegram" },
id: "alpha",
meta: { label: "Alpha" },
capabilities: {},
config: {
resolveDefaultTo: ({ cfg }: { cfg: OpenClawConfig }) => cfg.channels?.telegram?.defaultTo,
resolveDefaultTo: ({ cfg }: { cfg: OpenClawConfig }) =>
(cfg.channels?.alpha as { defaultTo?: string } | undefined)?.defaultTo,
},
outbound: {},
messaging: {},
@@ -38,17 +39,17 @@ describe("tryResolveLoadedOutboundTarget", () => {
expect(
tryResolveLoadedOutboundTarget({
channel: "telegram",
channel: "alpha",
to: "",
cfg,
mode: "implicit",
}),
).toEqual({ ok: true, to: "123456789" });
).toEqual({ ok: true, to: "room-one" });
});
it("trims channel ids before reading the loaded registry", () => {
tryResolveLoadedOutboundTarget({ channel: " telegram " as never, to: "123" });
tryResolveLoadedOutboundTarget({ channel: " alpha " as never, to: "room-one" });
expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("telegram");
expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("alpha");
});
});

View File

@@ -2,15 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { resolveOutboundTarget } from "./targets.js";
import {
createForumTargetTestPlugin,
createGenericTargetTestPlugin,
createTargetsTestRegistry,
createTelegramTestPlugin,
createWhatsAppTestPlugin,
createTestChannelPlugin,
} from "./targets.test-helpers.js";
export function installResolveOutboundTargetPluginRegistryHooks(): void {
beforeEach(() => {
setActivePluginRegistry(
createTargetsTestRegistry([createWhatsAppTestPlugin(), createTelegramTestPlugin()]),
createTargetsTestRegistry([
createGenericTargetTestPlugin("alpha", "Alpha"),
createGenericTargetTestPlugin("beta", "Beta"),
createForumTargetTestPlugin(),
]),
);
});
@@ -23,69 +28,50 @@ export function runResolveOutboundTargetCoreTests(): void {
describe("resolveOutboundTarget", () => {
installResolveOutboundTargetPluginRegistryHooks();
it("rejects whatsapp with empty target even when allowFrom configured", () => {
it("rejects empty targets through the loaded channel plugin", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+1555"] } },
channels: { alpha: { allowFrom: ["room-one"] } },
};
const res = resolveOutboundTarget({
channel: "whatsapp",
channel: "alpha",
to: "",
cfg,
mode: "explicit",
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("WhatsApp");
expect(res.error.message).toContain("Alpha");
}
});
it.each([
{
name: "normalizes whatsapp target when provided",
input: { channel: "whatsapp" as const, to: " (555) 123-4567 " },
expected: { ok: true as const, to: "+5551234567" },
name: "normalizes target through the loaded plugin",
input: { channel: "alpha" as const, to: " Alpha:Room One " },
expected: { ok: true as const, to: "room-one" },
},
{
name: "keeps whatsapp group targets",
input: { channel: "whatsapp" as const, to: "120363401234567890@g.us" },
expected: { ok: true as const, to: "120363401234567890@g.us" },
},
{
name: "normalizes prefixed/uppercase whatsapp group targets",
name: "uses channel defaultTo when no target was provided",
input: {
channel: "whatsapp" as const,
to: " WhatsApp:120363401234567890@G.US ",
},
expected: { ok: true as const, to: "120363401234567890@g.us" },
},
{
name: "rejects whatsapp with empty target and allowFrom (no silent fallback)",
input: { channel: "whatsapp" as const, to: "", allowFrom: ["+1555"] },
expectedErrorIncludes: "WhatsApp",
},
{
name: "rejects whatsapp with empty target and prefixed allowFrom (no silent fallback)",
input: {
channel: "whatsapp" as const,
channel: "beta" as const,
to: "",
allowFrom: ["whatsapp:(555) 123-4567"],
cfg: { channels: { beta: { defaultTo: "Beta:Default Room" } } },
},
expectedErrorIncludes: "WhatsApp",
expected: { ok: true as const, to: "default-room" },
},
{
name: "rejects invalid whatsapp target",
input: { channel: "whatsapp" as const, to: "wat" },
expectedErrorIncludes: "WhatsApp",
name: "passes explicit allowFrom without using it as an implicit target",
input: {
channel: "alpha" as const,
to: "",
allowFrom: ["alpha:room-one"],
},
expectedErrorIncludes: "Alpha",
},
{
name: "rejects whatsapp without to when allowFrom missing",
input: { channel: "whatsapp" as const, to: " " },
expectedErrorIncludes: "WhatsApp",
},
{
name: "rejects whatsapp allowFrom fallback when invalid",
input: { channel: "whatsapp" as const, to: "", allowFrom: ["wat"] },
expectedErrorIncludes: "WhatsApp",
name: "rejects plugin-specific invalid targets",
input: { channel: "alpha" as const, to: "invalid" },
expectedErrorIncludes: "Alpha",
},
])("$name", ({ input, expected, expectedErrorIncludes }) => {
const res = resolveOutboundTarget(input);
@@ -99,11 +85,28 @@ export function runResolveOutboundTargetCoreTests(): void {
}
});
it("rejects telegram with missing target", () => {
const res = resolveOutboundTarget({ channel: "telegram", to: " " });
it("uses the plugin hint when a channel has outbound support but no target resolver", () => {
setActivePluginRegistry(
createTargetsTestRegistry([
createForumTargetTestPlugin(),
createTestChannelPlugin({
id: "noresolver",
label: "NoResolver",
outbound: {
deliveryMode: "direct",
sendText: async () => ({ channel: "noresolver", messageId: "noresolver-msg" }),
},
messaging: {
targetResolver: { hint: "<test-target>" },
},
}),
]),
);
const res = resolveOutboundTarget({ channel: "noresolver", to: " " });
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("Telegram");
expect(res.error.message).toContain("NoResolver");
}
});

View File

@@ -4,9 +4,56 @@ import type {
ChannelPlugin,
} from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
function readTestDefaultTo(cfg: OpenClawConfig, channelId: string): string | undefined {
const channels = cfg.channels as Record<string, { defaultTo?: unknown }> | undefined;
const value = channels?.[channelId]?.defaultTo;
return typeof value === "string" ? value : undefined;
}
function stripTestPrefix(raw: string, channelId: string): string {
return raw.replace(new RegExp(`^${channelId}:`, "i"), "").trim();
}
function parseForumTargetForTest(raw: string): {
roomId: string;
threadId?: number;
chatType: "direct" | "group" | "unknown";
} {
const trimmed = stripTestPrefix(raw.trim(), "forum");
const topicMatch = /^(.*):topic:(\d+)$/i.exec(trimmed);
const roomId = topicMatch?.[1]?.trim() || trimmed;
const threadId = topicMatch?.[2] ? Number.parseInt(topicMatch[2], 10) : undefined;
const chatType = roomId.startsWith("dm:")
? "direct"
: roomId.startsWith("room:")
? "group"
: "unknown";
return { roomId, threadId, chatType };
}
function normalizeGenericTargetForTest(raw: string, channelId: string): string | null {
const normalized = stripTestPrefix(raw, channelId).toLowerCase().replace(/\s+/gu, "-");
if (!normalized || normalized === "invalid") {
return null;
}
return normalized;
}
function createGenericResolveTarget(
channelId: string,
label: string,
): ChannelOutboundAdapter["resolveTarget"] {
return ({ to }) => {
const normalized = to ? normalizeGenericTargetForTest(to, channelId) : null;
if (!normalized) {
return { ok: false, error: new Error(`${label} target is required`) };
}
return { ok: true, to: normalized };
};
}
function parseTelegramTargetForTest(raw: string): {
chatId: string;
messageThreadId?: number;
@@ -27,44 +74,6 @@ function parseTelegramTargetForTest(raw: string): {
return { chatId, messageThreadId, chatType };
}
function normalizeWhatsAppTargetForTest(raw: string): string | null {
const trimmed = raw
.trim()
.replace(/^whatsapp:/i, "")
.trim();
if (!trimmed) {
return null;
}
const lowered = normalizeLowercaseStringOrEmpty(trimmed);
if (lowered.endsWith("@g.us")) {
const normalized = lowered.replace(/\s+/gu, "");
return /^\d+@g\.us$/u.test(normalized) ? normalized : null;
}
const digits = trimmed.replace(/\D/gu, "");
const normalized = digits ? `+${digits}` : "";
return /^\+\d{7,15}$/u.test(normalized) ? normalized : null;
}
function createWhatsAppResolveTarget(label = "WhatsApp"): ChannelOutboundAdapter["resolveTarget"] {
return ({ to }) => {
const normalized = to ? normalizeWhatsAppTargetForTest(to) : null;
if (!normalized) {
return { ok: false, error: new Error(`${label} target is required`) };
}
return { ok: true, to: normalized };
};
}
function createTelegramResolveTarget(label = "Telegram"): ChannelOutboundAdapter["resolveTarget"] {
return ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return { ok: false, error: new Error(`${label} target is required`) };
}
return { ok: true, to: parseTelegramTargetForTest(trimmed).chatId };
};
}
export const telegramMessagingForTest: ChannelMessagingAdapter = {
parseExplicitTarget: ({ raw }) => {
const target = parseTelegramTargetForTest(raw);
@@ -80,17 +89,23 @@ export const telegramMessagingForTest: ChannelMessagingAdapter = {
},
};
export const whatsappMessagingForTest: ChannelMessagingAdapter = {
export const forumMessagingForTest: ChannelMessagingAdapter = {
parseExplicitTarget: ({ raw }) => {
const target = parseForumTargetForTest(raw);
return {
to: target.roomId,
threadId: target.threadId,
chatType: target.chatType === "unknown" ? undefined : target.chatType,
};
},
inferTargetChatType: ({ to }) => {
const normalized = normalizeWhatsAppTargetForTest(to);
if (!normalized) {
return undefined;
}
return normalized.endsWith("@g.us") ? "group" : "direct";
const target = parseForumTargetForTest(to);
return target.chatType === "unknown" ? undefined : target.chatType;
},
targetResolver: {
hint: "<E.164|group JID>",
hint: "<room|dm target>",
},
preserveHeartbeatThreadIdForGroupRoute: true,
};
export function createTestChannelPlugin(params: {
@@ -124,54 +139,42 @@ export function createTestChannelPlugin(params: {
};
}
export function createTelegramTestPlugin(): ChannelPlugin {
return createTestChannelPlugin({
id: "telegram",
label: "Telegram",
outbound: {
deliveryMode: "direct",
sendText: async () => ({ channel: "telegram", messageId: "telegram-msg" }),
resolveTarget: createTelegramResolveTarget(),
},
messaging: telegramMessagingForTest,
resolveDefaultTo: ({ cfg }) =>
typeof cfg.channels?.telegram?.defaultTo === "string"
? cfg.channels.telegram.defaultTo
: undefined,
});
}
export function createWhatsAppTestPlugin(): ChannelPlugin {
return createTestChannelPlugin({
id: "whatsapp",
label: "WhatsApp",
outbound: {
deliveryMode: "direct",
sendText: async () => ({ channel: "whatsapp", messageId: "whatsapp-msg" }),
resolveTarget: createWhatsAppResolveTarget(),
},
messaging: whatsappMessagingForTest,
resolveDefaultTo: ({ cfg }) =>
typeof cfg.channels?.whatsapp?.defaultTo === "string"
? cfg.channels.whatsapp.defaultTo
: undefined,
});
}
export function createNoopOutboundChannelPlugin(
id: "discord" | "imessage" | "slack",
export function createGenericTargetTestPlugin(
id: ChannelPlugin["id"],
label = String(id),
): ChannelPlugin {
return createTestChannelPlugin({
id,
label,
outbound: {
deliveryMode: "direct",
sendText: async () => ({ channel: id, messageId: `${id}-msg` }),
resolveTarget: createGenericResolveTarget(String(id), label),
},
resolveDefaultTo: ({ cfg }) => readTestDefaultTo(cfg, String(id)),
});
}
export function createForumTargetTestPlugin(): ChannelPlugin {
return createTestChannelPlugin({
id: "forum",
label: "Forum",
outbound: {
deliveryMode: "direct",
sendText: async () => ({ channel: "forum", messageId: "forum-msg" }),
resolveTarget: createGenericResolveTarget("forum", "Forum"),
},
messaging: forumMessagingForTest,
resolveDefaultTo: ({ cfg }) => readTestDefaultTo(cfg, "forum"),
});
}
export function createTargetsTestRegistry(
plugins: ChannelPlugin[] = [createWhatsAppTestPlugin(), createTelegramTestPlugin()],
plugins: ChannelPlugin[] = [
createGenericTargetTestPlugin("alpha", "Alpha"),
createGenericTargetTestPlugin("beta", "Beta"),
createForumTargetTestPlugin(),
],
) {
return createTestRegistry(
plugins.map((plugin) => ({

File diff suppressed because it is too large Load Diff

View File

@@ -220,7 +220,8 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
}
const inheritedHeartbeatThreadId = shouldReuseHeartbeatTelegramTopicThread({
const inheritedHeartbeatThreadId = shouldReuseHeartbeatRouteThreadId({
cfg,
target,
heartbeat,
turnSource: params.turnSource,
@@ -235,10 +236,8 @@ export function resolveHeartbeatDeliveryTarget(params: {
to: resolved.to,
reason,
accountId: effectiveAccountId,
// Heartbeats normally avoid inheriting session reply-thread IDs, but
// Telegram forum-topic sessions encode the topic as part of the
// destination identity. Preserve that topic routing when the heartbeat is
// still targeting the same group session.
// Heartbeats normally avoid inheriting session reply-thread IDs, but some
// plugins encode thread/topic ids as part of the destination identity.
threadId: resolvedTarget.threadId ?? inheritedHeartbeatThreadId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
@@ -299,20 +298,24 @@ function resolveHeartbeatDeliveryChatType(params: {
});
}
function shouldReuseHeartbeatTelegramTopicThread(params: {
function shouldReuseHeartbeatRouteThreadId(params: {
cfg: OpenClawConfig;
target: HeartbeatTarget;
heartbeat?: AgentDefaultsConfig["heartbeat"];
turnSource?: DeliveryContext;
entry?: SessionEntry;
resolvedTarget: SessionDeliveryTarget;
}): boolean {
const channel = params.resolvedTarget.channel;
const messaging =
channel && resolveOutboundChannelPlugin({ channel, cfg: params.cfg })?.messaging;
return (
messaging?.preserveHeartbeatThreadIdForGroupRoute === true &&
params.resolvedTarget.threadId == null &&
params.target === "last" &&
!params.heartbeat?.to &&
params.turnSource?.threadId == null &&
params.resolvedTarget.channel === "telegram" &&
params.resolvedTarget.lastChannel === "telegram" &&
params.resolvedTarget.channel === params.resolvedTarget.lastChannel &&
Boolean(params.resolvedTarget.to) &&
Boolean(params.resolvedTarget.lastTo) &&
params.resolvedTarget.to === params.resolvedTarget.lastTo &&