| import { describe, expect, it, vi } from "vitest"; |
| import type { OpenClawConfig } from "../../config/config.js"; |
|
|
| vi.mock("../../config/sessions.js", () => ({ |
| loadSessionStore: vi.fn().mockReturnValue({}), |
| resolveAgentMainSessionKey: vi.fn().mockReturnValue("agent:test:main"), |
| resolveStorePath: vi.fn().mockReturnValue("/tmp/test-store.json"), |
| })); |
|
|
| vi.mock("../../infra/outbound/channel-selection.js", () => ({ |
| resolveMessageChannelSelection: vi |
| .fn() |
| .mockResolvedValue({ channel: "telegram", configured: ["telegram"] }), |
| })); |
|
|
| vi.mock("../../infra/outbound/target-resolver.js", () => ({ |
| maybeResolveIdLikeTarget: vi.fn(), |
| })); |
|
|
| vi.mock("../../pairing/pairing-store.js", () => ({ |
| readChannelAllowFromStoreSync: vi.fn(() => []), |
| })); |
|
|
| vi.mock("../../web/accounts.js", () => ({ |
| resolveWhatsAppAccount: vi.fn(() => ({ allowFrom: [] })), |
| })); |
|
|
| import { loadSessionStore } from "../../config/sessions.js"; |
| import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; |
| import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js"; |
| import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; |
| import { resolveWhatsAppAccount } from "../../web/accounts.js"; |
| import { resolveDeliveryTarget } from "./delivery-target.js"; |
|
|
| function makeCfg(overrides?: Partial<OpenClawConfig>): OpenClawConfig { |
| return { |
| bindings: [], |
| channels: {}, |
| ...overrides, |
| } as OpenClawConfig; |
| } |
|
|
| function makeTelegramBoundCfg(accountId = "account-b"): OpenClawConfig { |
| return makeCfg({ |
| bindings: [ |
| { |
| agentId: AGENT_ID, |
| match: { channel: "telegram", accountId }, |
| }, |
| ], |
| }); |
| } |
|
|
| const AGENT_ID = "agent-b"; |
| const DEFAULT_TARGET = { |
| channel: "telegram" as const, |
| to: "123456", |
| }; |
|
|
| type SessionStore = ReturnType<typeof loadSessionStore>; |
|
|
| function setMainSessionEntry(entry?: SessionStore[string]) { |
| const store = entry ? ({ "agent:test:main": entry } as SessionStore) : ({} as SessionStore); |
| vi.mocked(loadSessionStore).mockReturnValue(store); |
| } |
|
|
| function setLastSessionEntry(params: { |
| sessionId: string; |
| lastChannel: string; |
| lastTo: string; |
| lastThreadId?: string; |
| lastAccountId?: string; |
| }) { |
| setMainSessionEntry({ |
| sessionId: params.sessionId, |
| updatedAt: 1000, |
| lastChannel: params.lastChannel, |
| lastTo: params.lastTo, |
| ...(params.lastThreadId ? { lastThreadId: params.lastThreadId } : {}), |
| ...(params.lastAccountId ? { lastAccountId: params.lastAccountId } : {}), |
| }); |
| } |
|
|
| function setWhatsAppAllowFrom(allowFrom: string[]) { |
| vi.mocked(resolveWhatsAppAccount).mockReturnValue({ |
| allowFrom, |
| } as unknown as ReturnType<typeof resolveWhatsAppAccount>); |
| } |
|
|
| function setStoredWhatsAppAllowFrom(allowFrom: string[]) { |
| vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(allowFrom); |
| } |
|
|
| async function resolveForAgent(params: { |
| cfg: OpenClawConfig; |
| target?: { channel?: "last" | "telegram"; 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; |
| return resolveDeliveryTarget(params.cfg, AGENT_ID, { |
| channel, |
| to, |
| }); |
| } |
|
|
| async function resolveLastTarget(cfg: OpenClawConfig) { |
| return resolveForAgent({ |
| cfg, |
| target: { channel: "last", to: undefined }, |
| }); |
| } |
|
|
| describe("resolveDeliveryTarget", () => { |
| it("reroutes implicit whatsapp delivery to authorized allowFrom recipient", async () => { |
| setLastSessionEntry({ |
| sessionId: "sess-w1", |
| lastChannel: "whatsapp", |
| lastTo: "+15550000099", |
| }); |
| setWhatsAppAllowFrom([]); |
| setStoredWhatsAppAllowFrom(["+15550000001"]); |
|
|
| const cfg = makeCfg({ bindings: [] }); |
| const result = await resolveLastTarget(cfg); |
|
|
| expect(result.channel).toBe("whatsapp"); |
| expect(result.to).toBe("+15550000001"); |
| }); |
|
|
| it("keeps explicit whatsapp target unchanged", async () => { |
| setLastSessionEntry({ |
| sessionId: "sess-w2", |
| lastChannel: "whatsapp", |
| lastTo: "+15550000099", |
| }); |
| setWhatsAppAllowFrom([]); |
| setStoredWhatsAppAllowFrom(["+15550000001"]); |
|
|
| const cfg = makeCfg({ bindings: [] }); |
| const result = await resolveDeliveryTarget(cfg, AGENT_ID, { |
| channel: "whatsapp", |
| to: "+15550000099", |
| }); |
|
|
| expect(result.to).toBe("+15550000099"); |
| }); |
|
|
| it("falls back to bound accountId when session has no lastAccountId", async () => { |
| setMainSessionEntry(undefined); |
| const cfg = makeTelegramBoundCfg(); |
| const result = await resolveForAgent({ cfg }); |
|
|
| expect(result.accountId).toBe("account-b"); |
| }); |
|
|
| it("preserves session lastAccountId when present", async () => { |
| setMainSessionEntry({ |
| sessionId: "sess-1", |
| updatedAt: 1000, |
| lastChannel: "telegram", |
| lastTo: "123456", |
| lastAccountId: "session-account", |
| }); |
|
|
| const cfg = makeTelegramBoundCfg(); |
| const result = await resolveForAgent({ cfg }); |
|
|
| |
| expect(result.accountId).toBe("session-account"); |
| }); |
|
|
| it("returns undefined accountId when no binding and no session", async () => { |
| setMainSessionEntry(undefined); |
|
|
| const cfg = makeCfg({ bindings: [] }); |
|
|
| const result = await resolveForAgent({ cfg }); |
|
|
| expect(result.accountId).toBeUndefined(); |
| }); |
|
|
| it("applies id-like target normalization before returning delivery targets", async () => { |
| setMainSessionEntry(undefined); |
| vi.mocked(maybeResolveIdLikeTarget).mockClear(); |
| vi.mocked(maybeResolveIdLikeTarget).mockResolvedValueOnce({ |
| to: "user:123456789", |
| kind: "user", |
| source: "directory", |
| }); |
|
|
| const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, { |
| channel: "telegram", |
| to: "123456789", |
| }); |
|
|
| expect(result.ok).toBe(true); |
| expect(result.to).toBe("user:123456789"); |
| expect(maybeResolveIdLikeTarget).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| channel: "telegram", |
| input: "123456789", |
| }), |
| ); |
| }); |
|
|
| it("selects correct binding when multiple agents have bindings", async () => { |
| setMainSessionEntry(undefined); |
|
|
| const cfg = makeCfg({ |
| bindings: [ |
| { |
| agentId: "agent-a", |
| match: { channel: "telegram", accountId: "account-a" }, |
| }, |
| { |
| agentId: "agent-b", |
| match: { channel: "telegram", accountId: "account-b" }, |
| }, |
| ], |
| }); |
|
|
| const result = await resolveForAgent({ cfg }); |
|
|
| expect(result.accountId).toBe("account-b"); |
| }); |
|
|
| it("ignores bindings for different channels", async () => { |
| setMainSessionEntry(undefined); |
|
|
| const cfg = makeCfg({ |
| bindings: [ |
| { |
| agentId: "agent-b", |
| match: { channel: "discord", accountId: "discord-account" }, |
| }, |
| ], |
| }); |
|
|
| const result = await resolveForAgent({ cfg }); |
|
|
| expect(result.accountId).toBeUndefined(); |
| }); |
|
|
| it("drops session threadId when destination does not match the previous recipient", async () => { |
| setLastSessionEntry({ |
| sessionId: "sess-2", |
| lastChannel: "telegram", |
| lastTo: "999999", |
| lastThreadId: "thread-1", |
| }); |
|
|
| const result = await resolveForAgent({ cfg: makeCfg({ bindings: [] }) }); |
| expect(result.threadId).toBeUndefined(); |
| }); |
|
|
| it("keeps session threadId when destination matches the previous recipient", async () => { |
| setLastSessionEntry({ |
| sessionId: "sess-3", |
| lastChannel: "telegram", |
| lastTo: "123456", |
| lastThreadId: "thread-2", |
| }); |
|
|
| const result = await resolveForAgent({ cfg: makeCfg({ bindings: [] }) }); |
| expect(result.threadId).toBe("thread-2"); |
| }); |
|
|
| it("uses single configured channel when neither explicit nor session channel exists", async () => { |
| setMainSessionEntry(undefined); |
|
|
| const result = await resolveLastTarget(makeCfg({ bindings: [] })); |
| expect(result.channel).toBe("telegram"); |
| expect(result.ok).toBe(false); |
| if (result.ok) { |
| throw new Error("expected unresolved delivery target"); |
| } |
| |
| |
| expect(result.error.message).toContain("requires target"); |
| }); |
|
|
| 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"), |
| ); |
|
|
| const result = await resolveLastTarget(makeCfg({ bindings: [] })); |
| expect(result.channel).toBeUndefined(); |
| expect(result.to).toBeUndefined(); |
| expect(result.ok).toBe(false); |
| if (result.ok) { |
| throw new Error("expected ambiguous channel selection error"); |
| } |
| expect(result.error.message).toContain("Channel is required"); |
| }); |
|
|
| it("uses sessionKey thread entry before main session entry", async () => { |
| vi.mocked(loadSessionStore).mockReturnValue({ |
| "agent:test:main": { |
| sessionId: "main-session", |
| updatedAt: 1000, |
| lastChannel: "telegram", |
| lastTo: "main-chat", |
| }, |
| "agent:test:thread:42": { |
| sessionId: "thread-session", |
| updatedAt: 2000, |
| lastChannel: "telegram", |
| lastTo: "thread-chat", |
| }, |
| } as SessionStore); |
|
|
| const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, { |
| channel: "last", |
| sessionKey: "agent:test:thread:42", |
| to: undefined, |
| }); |
|
|
| expect(result.channel).toBe("telegram"); |
| expect(result.to).toBe("thread-chat"); |
| }); |
|
|
| it("uses main session channel when channel=last and session route exists", async () => { |
| setLastSessionEntry({ |
| sessionId: "sess-4", |
| lastChannel: "telegram", |
| lastTo: "987654", |
| }); |
|
|
| const result = await resolveLastTarget(makeCfg({ bindings: [] })); |
|
|
| expect(result.channel).toBe("telegram"); |
| expect(result.to).toBe("987654"); |
| expect(result.ok).toBe(true); |
| }); |
|
|
| it("explicit delivery.accountId overrides session-derived accountId", async () => { |
| setLastSessionEntry({ |
| sessionId: "sess-5", |
| lastChannel: "telegram", |
| lastTo: "chat-999", |
| lastAccountId: "default", |
| }); |
|
|
| const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, { |
| channel: "telegram", |
| to: "chat-999", |
| accountId: "bot-b", |
| }); |
|
|
| expect(result.ok).toBe(true); |
| expect(result.accountId).toBe("bot-b"); |
| }); |
|
|
| it("explicit delivery.accountId overrides bindings-derived accountId", async () => { |
| setMainSessionEntry(undefined); |
| const cfg = makeCfg({ |
| bindings: [{ agentId: AGENT_ID, match: { channel: "telegram", accountId: "bound" } }], |
| }); |
|
|
| const result = await resolveDeliveryTarget(cfg, AGENT_ID, { |
| channel: "telegram", |
| to: "chat-777", |
| accountId: "explicit", |
| }); |
|
|
| expect(result.ok).toBe(true); |
| expect(result.accountId).toBe("explicit"); |
| }); |
| }); |
|
|