From 72571f0d387650194eb374bcce0120f3548129ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 23:03:30 +0100 Subject: [PATCH] test: decouple outbound target tests from bundled plugins --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- extensions/telegram/src/channel.ts | 1 + src/channels/plugins/target-parsing.test.ts | 58 +- src/channels/plugins/types.core.ts | 5 + ...gent.direct-delivery-core-channels.test.ts | 10 +- .../isolated-agent/delivery-target.test.ts | 166 +++--- src/infra/outbound/targets-loaded.test.ts | 19 +- src/infra/outbound/targets.shared-test.ts | 95 ++-- src/infra/outbound/targets.test-helpers.ts | 169 +++--- src/infra/outbound/targets.test.ts | 533 +++++++++--------- src/infra/outbound/targets.ts | 19 +- 11 files changed, 548 insertions(+), 531 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 07ad4437b18..e086e690a94 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 0610815bd72..a98d95698df 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -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) { diff --git a/src/channels/plugins/target-parsing.test.ts b/src/channels/plugins/target-parsing.test.ts index 966932e5dc9..3d8a6714402 100644 --- a/src/channels/plugins/target-parsing.test.ts +++ b/src/channels/plugins/target-parsing.test.ts @@ -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( diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 8e78f9e3617..03392626b04 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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; diff --git a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts index c66a78b6472..623e358c851 100644 --- a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts +++ b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts @@ -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", diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index fff5b8d8abf..1c9deee084f 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -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 { } 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; @@ -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", }); diff --git a/src/infra/outbound/targets-loaded.test.ts b/src/infra/outbound/targets-loaded.test.ts index be5a00def71..cd84151bbc6 100644 --- a/src/infra/outbound/targets-loaded.test.ts +++ b/src/infra/outbound/targets-loaded.test.ts @@ -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"); }); }); diff --git a/src/infra/outbound/targets.shared-test.ts b/src/infra/outbound/targets.shared-test.ts index feddfa8e173..410917dc729 100644 --- a/src/infra/outbound/targets.shared-test.ts +++ b/src/infra/outbound/targets.shared-test.ts @@ -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: "" }, + }, + }), + ]), + ); + + 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"); } }); diff --git a/src/infra/outbound/targets.test-helpers.ts b/src/infra/outbound/targets.test-helpers.ts index 01fd1d06e4b..583e8e48d6e 100644 --- a/src/infra/outbound/targets.test-helpers.ts +++ b/src/infra/outbound/targets.test-helpers.ts @@ -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 | 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: "", + hint: "", }, + 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) => ({ diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 5a875c7c508..bd7896e7700 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -13,10 +13,9 @@ import { runResolveOutboundTargetCoreTests, } from "./targets.shared-test.js"; import { - createNoopOutboundChannelPlugin, + createForumTargetTestPlugin, + createGenericTargetTestPlugin, createTargetsTestRegistry, - createTelegramTestPlugin, - createWhatsAppTestPlugin, } from "./targets.test-helpers.js"; const mocks = vi.hoisted(() => ({ @@ -35,9 +34,7 @@ beforeEach(() => { mocks.normalizeDeliverableOutboundChannel.mockReset(); mocks.normalizeDeliverableOutboundChannel.mockImplementation((value?: string | null) => { const normalized = typeof value === "string" ? value.trim().toLowerCase() : undefined; - return ["discord", "imessage", "slack", "telegram", "whatsapp"].includes(String(normalized)) - ? normalized - : undefined; + return ["alpha", "beta", "forum"].includes(String(normalized)) ? normalized : undefined; }); mocks.resolveOutboundChannelPlugin.mockReset(); mocks.resolveOutboundChannelPlugin.mockImplementation( @@ -46,60 +43,58 @@ beforeEach(() => { ); setActivePluginRegistry( createTargetsTestRegistry([ - createNoopOutboundChannelPlugin("discord"), - createNoopOutboundChannelPlugin("imessage"), - createNoopOutboundChannelPlugin("slack"), - createTelegramTestPlugin(), - createWhatsAppTestPlugin(), + createGenericTargetTestPlugin("alpha", "Alpha"), + createGenericTargetTestPlugin("beta", "Beta"), + createForumTargetTestPlugin(), ]), ); }); describe("resolveOutboundTarget defaultTo config fallback", () => { installResolveOutboundTargetPluginRegistryHooks(); - const whatsappDefaultCfg: OpenClawConfig = { - channels: { whatsapp: { defaultTo: "+15551234567", allowFrom: ["*"] } }, + const alphaDefaultCfg: OpenClawConfig = { + channels: { alpha: { defaultTo: "Alpha:Room One", allowFrom: ["*"] } }, }; - it("uses whatsapp defaultTo when no explicit target is provided", () => { + it("uses plugin defaultTo when no explicit target is provided", () => { const res = resolveOutboundTarget({ - channel: "whatsapp", + channel: "alpha", to: undefined, - cfg: whatsappDefaultCfg, + cfg: alphaDefaultCfg, mode: "implicit", }); - expect(res).toEqual({ ok: true, to: "+15551234567" }); + expect(res).toEqual({ ok: true, to: "room-one" }); }); - it("uses telegram defaultTo when no explicit target is provided", () => { + it("uses a second plugin defaultTo when no explicit target is provided", () => { const cfg: OpenClawConfig = { - channels: { telegram: { defaultTo: "123456789" } }, + channels: { beta: { defaultTo: "Beta:Default Room" } }, }; const res = resolveOutboundTarget({ - channel: "telegram", + channel: "beta", to: "", cfg, mode: "implicit", }); - expect(res).toEqual({ ok: true, to: "123456789" }); + expect(res).toEqual({ ok: true, to: "default-room" }); }); it("explicit --reply-to overrides defaultTo", () => { const res = resolveOutboundTarget({ - channel: "whatsapp", - to: "+15559999999", - cfg: whatsappDefaultCfg, + channel: "alpha", + to: "Alpha:Override Room", + cfg: alphaDefaultCfg, mode: "explicit", }); - expect(res).toEqual({ ok: true, to: "+15559999999" }); + expect(res).toEqual({ ok: true, to: "override-room" }); }); it("still errors when no defaultTo and no explicit target", () => { const cfg: OpenClawConfig = { - channels: { whatsapp: { allowFrom: ["+1555"] } }, + channels: { alpha: { allowFrom: ["room-one"] } }, }; const res = resolveOutboundTarget({ - channel: "whatsapp", + channel: "alpha", to: "", cfg, mode: "implicit", @@ -112,19 +107,19 @@ describe("resolveOutboundTarget defaultTo config fallback", () => { setActivePluginRegistry(registry, "stale-registry-test"); // Warm the cached channel map before mutating the registry in place. - expect(resolveOutboundTarget({ channel: "telegram", to: "123", mode: "explicit" }).ok).toBe( + expect(resolveOutboundTarget({ channel: "alpha", to: "room-one", mode: "explicit" }).ok).toBe( false, ); registry.channels.push({ - pluginId: "telegram", - plugin: createTelegramTestPlugin(), + pluginId: "alpha", + plugin: createGenericTargetTestPlugin("alpha", "Alpha"), source: "test", }); - expect(resolveOutboundTarget({ channel: "telegram", to: "123", mode: "explicit" })).toEqual({ + expect(resolveOutboundTarget({ channel: "alpha", to: "room-one", mode: "explicit" })).toEqual({ ok: true, - to: "123", + to: "room-one", }); }); }); @@ -159,9 +154,9 @@ describe("resolveSessionDeliveryTarget", () => { const resolved = resolveSessionDeliveryTarget({ entry, requestedChannel: "last", - explicitTo: "63448508:topic:1008013", + explicitTo: "room:ops:topic:1008013", }); - expect(resolved.to).toBe("63448508"); + expect(resolved.to).toBe("room:ops"); expect(resolved.threadId).toBe(1008013); }; @@ -170,22 +165,22 @@ describe("resolveSessionDeliveryTarget", () => { entry: { sessionId: "sess-1", updatedAt: 1, - lastChannel: " whatsapp ", - lastTo: " +1555 ", + lastChannel: " alpha ", + lastTo: " Room One ", lastAccountId: " acct-1 ", }, requestedChannel: "last", }); expect(resolved).toEqual({ - channel: "whatsapp", - to: "+1555", + channel: "alpha", + to: "Room One", accountId: "acct-1", threadId: undefined, threadIdExplicit: false, mode: "implicit", - lastChannel: "whatsapp", - lastTo: "+1555", + lastChannel: "alpha", + lastTo: "Room One", lastAccountId: "acct-1", lastThreadId: undefined, }); @@ -196,17 +191,17 @@ describe("resolveSessionDeliveryTarget", () => { entry: { sessionId: "sess-2", updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+1555", + lastChannel: "alpha", + lastTo: "room-one", }, - requestedChannel: "telegram", + requestedChannel: "beta", }); expectImplicitRoute(resolved, { - channel: "telegram", + channel: "beta", to: undefined, - lastChannel: "whatsapp", - lastTo: "+1555", + lastChannel: "alpha", + lastTo: "room-one", }); }); @@ -215,18 +210,18 @@ describe("resolveSessionDeliveryTarget", () => { entry: { sessionId: "sess-3", updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+1555", + lastChannel: "alpha", + lastTo: "room-one", }, - requestedChannel: "telegram", + requestedChannel: "beta", allowMismatchedLastTo: true, }); expectImplicitRoute(resolved, { - channel: "telegram", - to: "+1555", - lastChannel: "whatsapp", - lastTo: "+1555", + channel: "beta", + to: "room-one", + lastChannel: "alpha", + lastTo: "room-one", }); }); @@ -235,8 +230,8 @@ describe("resolveSessionDeliveryTarget", () => { entry: { sessionId: "sess-thread", updatedAt: 1, - lastChannel: "telegram", - lastTo: "-100123", + lastChannel: "forum", + lastTo: "room:ops", lastThreadId: 999, }, requestedChannel: "last", @@ -244,8 +239,8 @@ describe("resolveSessionDeliveryTarget", () => { }); expect(resolved.threadId).toBe(42); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-100123"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:ops"); }); it("uses session lastThreadId when no explicitThreadId", () => { @@ -253,8 +248,8 @@ describe("resolveSessionDeliveryTarget", () => { entry: { sessionId: "sess-thread-2", updatedAt: 1, - lastChannel: "telegram", - lastTo: "-100123", + lastChannel: "forum", + lastTo: "room:ops", lastThreadId: 999, }, requestedChannel: "last", @@ -268,9 +263,9 @@ describe("resolveSessionDeliveryTarget", () => { entry: { sessionId: "sess-heartbeat-thread", updatedAt: 1, - lastChannel: "slack", - lastTo: "user:U123", - lastThreadId: "1739142736.000100", + lastChannel: "alpha", + lastTo: "room-one", + lastThreadId: "thread-1", }, requestedChannel: "last", mode: "heartbeat", @@ -284,85 +279,85 @@ describe("resolveSessionDeliveryTarget", () => { entry: { sessionId: "sess-4", updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+1555", + lastChannel: "alpha", + lastTo: "room-one", }, requestedChannel: "webchat", - fallbackChannel: "slack", + fallbackChannel: "beta", }); expectImplicitRoute(resolved, { - channel: "slack", + channel: "beta", to: undefined, - lastChannel: "whatsapp", - lastTo: "+1555", + lastChannel: "alpha", + lastTo: "room-one", }); }); - it("parses :topic:NNN from explicitTo into threadId", () => { + it("parses plugin-owned explicit targets into threadId", () => { expectTopicParsedFromExplicitTo({ sessionId: "sess-topic", updatedAt: 1, - lastChannel: "telegram", - lastTo: "63448508", + lastChannel: "forum", + lastTo: "room:ops", }); }); - it("parses :topic:NNN even when lastTo is absent", () => { + it("parses plugin-owned explicit targets even when lastTo is absent", () => { expectTopicParsedFromExplicitTo({ sessionId: "sess-no-last", updatedAt: 1, - lastChannel: "telegram", + lastChannel: "forum", }); }); - it("skips :topic: parsing for non-telegram channels", () => { + it("skips plugin-owned target parsing for other channels", () => { const resolved = resolveSessionDeliveryTarget({ entry: { - sessionId: "sess-slack", + sessionId: "sess-alpha", updatedAt: 1, - lastChannel: "slack", - lastTo: "C12345", + lastChannel: "alpha", + lastTo: "room-one", }, requestedChannel: "last", - explicitTo: "C12345:topic:999", + explicitTo: "room-one:topic:999", }); - expect(resolved.to).toBe("C12345:topic:999"); + expect(resolved.to).toBe("room-one:topic:999"); expect(resolved.threadId).toBeUndefined(); }); - it("skips :topic: parsing when channel is explicitly non-telegram even if lastChannel was telegram", () => { + it("skips plugin-owned target parsing when the requested channel differs from lastChannel", () => { const resolved = resolveSessionDeliveryTarget({ entry: { sessionId: "sess-cross", updatedAt: 1, - lastChannel: "telegram", - lastTo: "63448508", + lastChannel: "forum", + lastTo: "room:ops", }, - requestedChannel: "slack", - explicitTo: "C12345:topic:999", + requestedChannel: "alpha", + explicitTo: "room-one:topic:999", }); - expect(resolved.to).toBe("C12345:topic:999"); + expect(resolved.to).toBe("room-one:topic:999"); expect(resolved.threadId).toBeUndefined(); }); - it("keeps raw :topic: targets when the telegram plugin registry is unavailable", () => { + it("keeps raw plugin-owned targets when the plugin registry is unavailable", () => { setActivePluginRegistry(createTargetsTestRegistry([])); const resolved = resolveSessionDeliveryTarget({ entry: { sessionId: "sess-no-registry", updatedAt: 1, - lastChannel: "telegram", - lastTo: "63448508", + lastChannel: "forum", + lastTo: "room:ops", }, requestedChannel: "last", - explicitTo: "63448508:topic:1008013", + explicitTo: "room:ops:topic:1008013", }); - expect(resolved.to).toBe("63448508:topic:1008013"); + expect(resolved.to).toBe("room:ops:topic:1008013"); expect(resolved.threadId).toBeUndefined(); }); @@ -371,16 +366,16 @@ describe("resolveSessionDeliveryTarget", () => { entry: { sessionId: "sess-priority", updatedAt: 1, - lastChannel: "telegram", - lastTo: "63448508", + lastChannel: "forum", + lastTo: "room:ops", }, requestedChannel: "last", - explicitTo: "63448508:topic:1008013", + explicitTo: "room:ops:topic:1008013", explicitThreadId: 42, }); expect(resolved.threadId).toBe(42); - expect(resolved.to).toBe("63448508"); + expect(resolved.to).toBe("room:ops"); }); const resolveHeartbeatTarget = (entry: SessionEntry, directPolicy?: "allow" | "block") => @@ -411,104 +406,105 @@ describe("resolveSessionDeliveryTarget", () => { it.each([ { - name: "allows heartbeat delivery to Slack DMs by default and drops inherited thread ids", + name: "allows heartbeat delivery to direct targets by default and drops inherited thread ids", entry: { - sessionId: "sess-heartbeat-slack-direct", + sessionId: "sess-heartbeat-alpha-direct", updatedAt: 1, - lastChannel: "slack", - lastTo: "user:U123", - lastThreadId: "1739142736.000100", + lastChannel: "alpha", + lastTo: "user:one", + lastThreadId: "thread-1", }, - expectedChannel: "slack", - expectedTo: "user:U123", + expectedChannel: "alpha", + expectedTo: "user:one", }, { - name: "blocks heartbeat delivery to Slack DMs when directPolicy is block", + name: "blocks heartbeat delivery to direct targets when directPolicy is block", entry: { - sessionId: "sess-heartbeat-slack-direct-blocked", + sessionId: "sess-heartbeat-alpha-direct-blocked", updatedAt: 1, - lastChannel: "slack", - lastTo: "user:U123", - lastThreadId: "1739142736.000100", + lastChannel: "alpha", + lastTo: "user:one", + lastThreadId: "thread-1", }, directPolicy: "block" as const, expectedChannel: "none", expectedReason: "dm-blocked", }, { - name: "allows heartbeat delivery to Telegram direct chats by default", + name: "allows heartbeat delivery to plugin-classified direct chats by default", entry: { - sessionId: "sess-heartbeat-telegram-direct", + sessionId: "sess-heartbeat-forum-direct", updatedAt: 1, - lastChannel: "telegram", - lastTo: "5232990709", + lastChannel: "forum", + lastTo: "dm:one", }, - expectedChannel: "telegram", - expectedTo: "5232990709", + expectedChannel: "forum", + expectedTo: "dm:one", }, { - name: "blocks heartbeat delivery to Telegram direct chats when directPolicy is block", + name: "blocks heartbeat delivery to plugin-classified direct chats when directPolicy is block", entry: { - sessionId: "sess-heartbeat-telegram-direct-blocked", + sessionId: "sess-heartbeat-forum-direct-blocked", updatedAt: 1, - lastChannel: "telegram", - lastTo: "5232990709", + lastChannel: "forum", + lastTo: "dm:one", }, directPolicy: "block" as const, expectedChannel: "none", expectedReason: "dm-blocked", }, { - name: "keeps heartbeat delivery to Telegram groups", + name: "keeps heartbeat delivery to plugin-classified groups", entry: { - sessionId: "sess-heartbeat-telegram-group", + sessionId: "sess-heartbeat-forum-group", updatedAt: 1, - lastChannel: "telegram", - lastTo: "-1001234567890", + lastChannel: "forum", + lastTo: "room:ops", }, - expectedChannel: "telegram", - expectedTo: "-1001234567890", + expectedChannel: "forum", + expectedTo: "room:ops", }, { - name: "allows heartbeat delivery to WhatsApp direct chats by default", + name: "allows heartbeat delivery to unknown-shape targets when session chatType is direct", entry: { - sessionId: "sess-heartbeat-whatsapp-direct", + sessionId: "sess-heartbeat-beta-direct", updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+15551234567", + lastChannel: "beta", + lastTo: "unknown-shape", + chatType: "direct", }, - expectedChannel: "whatsapp", - expectedTo: "+15551234567", + expectedChannel: "beta", + expectedTo: "unknown-shape", }, { - name: "keeps heartbeat delivery to WhatsApp groups", + name: "keeps heartbeat delivery to generic group targets", entry: { - sessionId: "sess-heartbeat-whatsapp-group", + sessionId: "sess-heartbeat-alpha-group", updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "120363140186826074@g.us", + lastChannel: "alpha", + lastTo: "group:ops", }, - expectedChannel: "whatsapp", - expectedTo: "120363140186826074@g.us", + expectedChannel: "alpha", + expectedTo: "group:ops", }, { name: "uses session chatType hints when target parsing cannot classify a direct chat", entry: { - sessionId: "sess-heartbeat-imessage-direct", + sessionId: "sess-heartbeat-alpha-unknown-direct", updatedAt: 1, - lastChannel: "imessage", + lastChannel: "alpha", lastTo: "chat-guid-unknown-shape", chatType: "direct", }, - expectedChannel: "imessage", + expectedChannel: "alpha", expectedTo: "chat-guid-unknown-shape", }, { name: "blocks session chatType direct hints when directPolicy is block", entry: { - sessionId: "sess-heartbeat-imessage-direct-blocked", + sessionId: "sess-heartbeat-alpha-unknown-direct-blocked", updatedAt: 1, - lastChannel: "imessage", + lastChannel: "alpha", lastTo: "chat-guid-unknown-shape", chatType: "direct", }, @@ -534,14 +530,14 @@ describe("resolveSessionDeliveryTarget", () => { }); }); - it("allows heartbeat delivery to Discord DMs by default", () => { + it("allows heartbeat delivery to core direct target prefixes by default", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, entry: { - sessionId: "sess-heartbeat-discord-dm", + sessionId: "sess-heartbeat-core-direct-prefix", updatedAt: 1, - lastChannel: "discord", + lastChannel: "alpha", lastTo: "user:12345", }, heartbeat: { @@ -549,18 +545,18 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("discord"); + expect(resolved.channel).toBe("alpha"); expect(resolved.to).toBe("user:12345"); }); - it("keeps heartbeat delivery to Discord channels", () => { + it("keeps heartbeat delivery to core channel target prefixes", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, entry: { - sessionId: "sess-heartbeat-discord-channel", + sessionId: "sess-heartbeat-core-channel-prefix", updatedAt: 1, - lastChannel: "discord", + lastChannel: "alpha", lastTo: "channel:999", }, heartbeat: { @@ -568,7 +564,7 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("discord"); + expect(resolved.channel).toBe("alpha"); expect(resolved.to).toBe("channel:999"); }); @@ -577,8 +573,8 @@ describe("resolveSessionDeliveryTarget", () => { entry: { sessionId: "sess-heartbeat-explicit-thread", updatedAt: 1, - lastChannel: "telegram", - lastTo: "-100123", + lastChannel: "forum", + lastTo: "room:ops", lastThreadId: 999, }, requestedChannel: "last", @@ -586,36 +582,36 @@ describe("resolveSessionDeliveryTarget", () => { explicitThreadId: 42, }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-100123"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:ops"); expect(resolved.threadId).toBe(42); expect(resolved.threadIdExplicit).toBe(true); }); - it("parses explicit heartbeat topic targets into threadId", () => { + it("parses explicit heartbeat plugin targets into threadId", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, heartbeat: { - target: "telegram", - to: "-10063448508:topic:1008013", + target: "forum", + to: "room:ops:topic:1008013", }, }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-10063448508"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:ops"); expect(resolved.threadId).toBe(1008013); }); - it("preserves Telegram topic threadId for heartbeat target=last on topic-bound group sessions", () => { + it("preserves route threadId for heartbeat target=last on plugin-owned group sessions", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, entry: { - sessionId: "sess-heartbeat-telegram-topic", + sessionId: "sess-heartbeat-forum-topic", updatedAt: 1, - lastChannel: "telegram", - lastTo: "-1001234567890", + lastChannel: "forum", + lastTo: "room:ops", lastThreadId: 1122, chatType: "group", }, @@ -624,21 +620,21 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-1001234567890"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:ops"); expect(resolved.threadId).toBe(1122); }); - it("reuses Telegram topic routing when only deliveryContext carries the topic threadId", () => { + it("reuses route threadId when only deliveryContext carries it", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, entry: { - sessionId: "sess-heartbeat-telegram-topic-context-only", + sessionId: "sess-heartbeat-forum-topic-context-only", updatedAt: 1, deliveryContext: { - channel: "telegram", - to: "-1001234567890", + channel: "forum", + to: "room:ops", threadId: 1122, }, chatType: "group", @@ -648,20 +644,20 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-1001234567890"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:ops"); expect(resolved.threadId).toBe(1122); }); - it("does not inherit stale Telegram threadId for direct-chat heartbeat routes", () => { + it("does not inherit stale threadId for direct-chat heartbeat routes", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, entry: { - sessionId: "sess-heartbeat-telegram-direct-stale-thread", + sessionId: "sess-heartbeat-forum-direct-stale-thread", updatedAt: 1, - lastChannel: "telegram", - lastTo: "5232990709", + lastChannel: "forum", + lastTo: "dm:one", lastThreadId: 1122, chatType: "direct", }, @@ -670,8 +666,8 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("5232990709"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("dm:one"); expect(resolved.threadId).toBeUndefined(); }); @@ -681,21 +677,21 @@ describe("resolveSessionDeliveryTarget", () => { entry: { sessionId: "sess-heartbeat-turn-source", updatedAt: 1, - lastChannel: "slack", - lastTo: "U_WRONG", + lastChannel: "alpha", + lastTo: "wrong-room", }, heartbeat: { target: "last", }, turnSource: { - channel: "telegram", - to: "-100123", + channel: "forum", + to: "room:ops", threadId: 42, }, }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-100123"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:ops"); expect(resolved.threadId).toBe(42); }); @@ -705,8 +701,8 @@ describe("resolveSessionDeliveryTarget", () => { entry: { sessionId: "sess-heartbeat-turn-source-partial", updatedAt: 1, - lastChannel: "telegram", - lastTo: "-100123", + lastChannel: "forum", + lastTo: "room:ops", }, heartbeat: { target: "last", @@ -716,30 +712,30 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-100123"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:ops"); expect(resolved.threadId).toBe(42); }); }); describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", () => { it("uses turnSourceChannel over session lastChannel when provided", () => { - // Simulate: WhatsApp message originated the turn, but a Slack message - // arrived concurrently and updated lastChannel to "slack" + // Simulate: one channel originated the turn, but another channel + // concurrently updated the shared session route. const resolved = resolveSessionDeliveryTarget({ entry: { sessionId: "sess-shared", updatedAt: 1, - lastChannel: "slack", // <- concurrently overwritten - lastTo: "U0AEMECNCBV", // <- Slack user (wrong target) + lastChannel: "beta", + lastTo: "wrong-room", }, requestedChannel: "last", - turnSourceChannel: "whatsapp", // <- originated from WhatsApp - turnSourceTo: "+66972796305", // <- WhatsApp user (correct target) + turnSourceChannel: "alpha", + turnSourceTo: "room-one", }); - expect(resolved.channel).toBe("whatsapp"); - expect(resolved.to).toBe("+66972796305"); + expect(resolved.channel).toBe("alpha"); + expect(resolved.to).toBe("room-one"); }); it("falls back to session lastChannel when turnSourceChannel is not set", () => { @@ -747,14 +743,14 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", entry: { sessionId: "sess-normal", updatedAt: 1, - lastChannel: "telegram", - lastTo: "8587265585", + lastChannel: "alpha", + lastTo: "room-one", }, requestedChannel: "last", }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("8587265585"); + expect(resolved.channel).toBe("alpha"); + expect(resolved.to).toBe("room-one"); }); it("respects explicit requestedChannel over turnSourceChannel", () => { @@ -762,17 +758,17 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", entry: { sessionId: "sess-explicit", updatedAt: 1, - lastChannel: "slack", - lastTo: "U12345", + lastChannel: "beta", + lastTo: "wrong-room", }, - requestedChannel: "telegram", - explicitTo: "8587265585", - turnSourceChannel: "whatsapp", - turnSourceTo: "+66972796305", + requestedChannel: "forum", + explicitTo: "room:ops", + turnSourceChannel: "alpha", + turnSourceTo: "room-one", }); - // Explicit requestedChannel "telegram" is not "last", so it takes priority - expect(resolved.channel).toBe("telegram"); + // Explicit requestedChannel is not "last", so it takes priority. + expect(resolved.channel).toBe("forum"); }); it("preserves turnSourceAccountId and turnSourceThreadId", () => { @@ -780,19 +776,19 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", entry: { sessionId: "sess-meta", updatedAt: 1, - lastChannel: "slack", - lastTo: "U_WRONG", + lastChannel: "beta", + lastTo: "wrong-room", lastAccountId: "wrong-account", }, requestedChannel: "last", - turnSourceChannel: "telegram", - turnSourceTo: "8587265585", + turnSourceChannel: "forum", + turnSourceTo: "room:ops", turnSourceAccountId: "bot-123", turnSourceThreadId: 42, }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("8587265585"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:ops"); expect(resolved.accountId).toBe("bot-123"); expect(resolved.threadId).toBe(42); }); @@ -802,16 +798,16 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", entry: { sessionId: "sess-no-fallback", updatedAt: 1, - lastChannel: "slack", - lastTo: "U_WRONG", + lastChannel: "beta", + lastTo: "wrong-room", lastAccountId: "wrong-account", - lastThreadId: "1739142736.000100", + lastThreadId: "thread-1", }, requestedChannel: "last", - turnSourceChannel: "whatsapp", + turnSourceChannel: "alpha", }); - expect(resolved.channel).toBe("whatsapp"); + expect(resolved.channel).toBe("alpha"); expect(resolved.to).toBeUndefined(); expect(resolved.accountId).toBeUndefined(); expect(resolved.threadId).toBeUndefined(); @@ -821,62 +817,61 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", }); it("falls back to session lastThreadId when turnSourceChannel matches session channel and no explicit turnSourceThreadId", () => { - // Regression: Telegram forum topic replies were landing in the root chat instead of the topic - // thread because turnSourceThreadId was undefined (not explicitly passed), causing lastThreadId - // to be undefined even though the session had the correct lastThreadId from the inbound message. + // Regression: topic replies were landing in the root chat instead of the topic + // because turnSourceThreadId was undefined even though the session had it. const resolved = resolveSessionDeliveryTarget({ entry: { sessionId: "sess-forum-topic", updatedAt: 1, - lastChannel: "telegram", - lastTo: "-1001234567890", + lastChannel: "forum", + lastTo: "room:ops", lastThreadId: 1122, }, requestedChannel: "last", - turnSourceChannel: "telegram", - turnSourceTo: "-1001234567890", + turnSourceChannel: "forum", + turnSourceTo: "room:ops", }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-1001234567890"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:ops"); expect(resolved.threadId).toBe(1122); }); - it("keeps Telegram topic thread routing when turnSourceTo uses the plugin-owned topic target", () => { + it("keeps topic thread routing when turnSourceTo uses the plugin-owned topic target", () => { const resolved = resolveSessionDeliveryTarget({ entry: { sessionId: "sess-forum-topic-scoped", updatedAt: 1, - lastChannel: "telegram", - lastTo: "telegram:-1001234567890:topic:1122", + lastChannel: "forum", + lastTo: "forum:room:ops:topic:1122", lastThreadId: 1122, }, requestedChannel: "last", - turnSourceChannel: "telegram", - turnSourceTo: "telegram:-1001234567890:topic:1122", + turnSourceChannel: "forum", + turnSourceTo: "forum:room:ops:topic:1122", }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("telegram:-1001234567890:topic:1122"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("forum:room:ops:topic:1122"); expect(resolved.threadId).toBe(1122); }); - it("matches bare stored Telegram routes against topic-scoped turn routes via plugin grammar", () => { + it("matches bare stored routes against topic-scoped turn routes via plugin grammar", () => { const resolved = resolveSessionDeliveryTarget({ entry: { sessionId: "sess-forum-topic-mixed-shape", updatedAt: 1, - lastChannel: "telegram", - lastTo: "-1001234567890", + lastChannel: "forum", + lastTo: "room:ops", lastThreadId: 1122, }, requestedChannel: "last", - turnSourceChannel: "telegram", - turnSourceTo: "telegram:-1001234567890:topic:1122", + turnSourceChannel: "forum", + turnSourceTo: "forum:room:ops:topic:1122", }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("telegram:-1001234567890:topic:1122"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("forum:room:ops:topic:1122"); expect(resolved.threadId).toBe(1122); }); @@ -885,16 +880,16 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", entry: { sessionId: "sess-cross-channel-no-thread", updatedAt: 1, - lastChannel: "slack", - lastTo: "U_SLACK", - lastThreadId: "1739142736.000100", + lastChannel: "alpha", + lastTo: "room-one", + lastThreadId: "thread-1", }, requestedChannel: "last", - turnSourceChannel: "telegram", - turnSourceTo: "-1001234567890", + turnSourceChannel: "forum", + turnSourceTo: "room:ops", }); - expect(resolved.channel).toBe("telegram"); + expect(resolved.channel).toBe("forum"); expect(resolved.threadId).toBeUndefined(); }); @@ -903,18 +898,18 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", entry: { sessionId: "sess-explicit-thread-override", updatedAt: 1, - lastChannel: "telegram", - lastTo: "-1001234567890", + lastChannel: "forum", + lastTo: "room:ops", lastThreadId: 1122, }, requestedChannel: "last", - turnSourceChannel: "telegram", - turnSourceTo: "-1001234567890", + turnSourceChannel: "forum", + turnSourceTo: "room:ops", turnSourceThreadId: 9999, }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-1001234567890"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:ops"); expect(resolved.threadId).toBe(9999); }); @@ -923,17 +918,17 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", entry: { sessionId: "sess-shared-race", updatedAt: 1, - lastChannel: "telegram", - lastTo: "-1001234567890", + lastChannel: "forum", + lastTo: "room:ops", lastThreadId: 1122, }, requestedChannel: "last", - turnSourceChannel: "telegram", - turnSourceTo: "-1009999999999", + turnSourceChannel: "forum", + turnSourceTo: "room:other", }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-1009999999999"); + expect(resolved.channel).toBe("forum"); + expect(resolved.to).toBe("room:other"); expect(resolved.threadId).toBeUndefined(); }); @@ -942,16 +937,16 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", entry: { sessionId: "sess-explicit-to", updatedAt: 1, - lastChannel: "slack", - lastTo: "U_WRONG", + lastChannel: "beta", + lastTo: "wrong-room", }, requestedChannel: "last", - explicitTo: "+15551234567", - turnSourceChannel: "whatsapp", + explicitTo: "room-one", + turnSourceChannel: "alpha", }); - expect(resolved.channel).toBe("whatsapp"); - expect(resolved.to).toBe("+15551234567"); + expect(resolved.channel).toBe("alpha"); + expect(resolved.to).toBe("room-one"); }); it("still allows mismatched lastTo only from turn-scoped metadata", () => { @@ -959,16 +954,16 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", entry: { sessionId: "sess-mismatch-turn", updatedAt: 1, - lastChannel: "slack", - lastTo: "U_WRONG", + lastChannel: "alpha", + lastTo: "wrong-room", }, - requestedChannel: "telegram", + requestedChannel: "beta", allowMismatchedLastTo: true, - turnSourceChannel: "whatsapp", - turnSourceTo: "+15550000000", + turnSourceChannel: "alpha", + turnSourceTo: "room-one", }); - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("+15550000000"); + expect(resolved.channel).toBe("beta"); + expect(resolved.to).toBe("room-one"); }); }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 33006fd42aa..d24b8daf481 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -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 &&