diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts new file mode 100644 index 0000000000..f5627fc3b5 --- /dev/null +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -0,0 +1,131 @@ +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" }), +})); + +import { loadSessionStore } from "../../config/sessions.js"; +import { resolveDeliveryTarget } from "./delivery-target.js"; + +function makeCfg(overrides?: Partial): OpenClawConfig { + return { + bindings: [], + channels: {}, + ...overrides, + } as OpenClawConfig; +} + +describe("resolveDeliveryTarget", () => { + it("falls back to bound accountId when session has no lastAccountId", async () => { + vi.mocked(loadSessionStore).mockReturnValue({}); + + const cfg = makeCfg({ + bindings: [ + { + agentId: "agent-b", + match: { channel: "telegram", accountId: "account-b" }, + }, + ], + }); + + const result = await resolveDeliveryTarget(cfg, "agent-b", { + channel: "telegram", + to: "123456", + }); + + expect(result.accountId).toBe("account-b"); + }); + + it("preserves session lastAccountId when present", async () => { + vi.mocked(loadSessionStore).mockReturnValue({ + "agent:test:main": { + sessionId: "sess-1", + updatedAt: 1000, + lastChannel: "telegram", + lastTo: "123456", + lastAccountId: "session-account", + }, + }); + + const cfg = makeCfg({ + bindings: [ + { + agentId: "agent-b", + match: { channel: "telegram", accountId: "account-b" }, + }, + ], + }); + + const result = await resolveDeliveryTarget(cfg, "agent-b", { + channel: "telegram", + to: "123456", + }); + + // Session-derived accountId should take precedence over binding + expect(result.accountId).toBe("session-account"); + }); + + it("returns undefined accountId when no binding and no session", async () => { + vi.mocked(loadSessionStore).mockReturnValue({}); + + const cfg = makeCfg({ bindings: [] }); + + const result = await resolveDeliveryTarget(cfg, "agent-b", { + channel: "telegram", + to: "123456", + }); + + expect(result.accountId).toBeUndefined(); + }); + + it("selects correct binding when multiple agents have bindings", async () => { + vi.mocked(loadSessionStore).mockReturnValue({}); + + 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 resolveDeliveryTarget(cfg, "agent-b", { + channel: "telegram", + to: "123456", + }); + + expect(result.accountId).toBe("account-b"); + }); + + it("ignores bindings for different channels", async () => { + vi.mocked(loadSessionStore).mockReturnValue({}); + + const cfg = makeCfg({ + bindings: [ + { + agentId: "agent-b", + match: { channel: "discord", accountId: "discord-account" }, + }, + ], + }); + + const result = await resolveDeliveryTarget(cfg, "agent-b", { + channel: "telegram", + to: "123456", + }); + + expect(result.accountId).toBeUndefined(); + }); +}); diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 4c6fe7681e..cad157d027 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -12,6 +12,8 @@ import { resolveOutboundTarget, resolveSessionDeliveryTarget, } from "../../infra/outbound/targets.js"; +import { buildChannelAccountBindings } from "../../routing/bindings.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; export async function resolveDeliveryTarget( cfg: OpenClawConfig, @@ -70,6 +72,20 @@ export async function resolveDeliveryTarget( const mode = resolved.mode as "explicit" | "implicit"; const toCandidate = resolved.to; + // When the session has no lastAccountId (e.g. first-run isolated cron + // session), fall back to the agent's bound account from bindings config. + // This ensures the message tool in isolated sessions resolves the correct + // bot token for multi-account setups. + let accountId = resolved.accountId; + if (!accountId && channel) { + const bindings = buildChannelAccountBindings(cfg); + const byAgent = bindings.get(channel); + const boundAccounts = byAgent?.get(normalizeAgentId(agentId)); + if (boundAccounts && boundAccounts.length > 0) { + accountId = boundAccounts[0]; + } + } + // Only carry threadId when delivering to the same recipient as the session's // last conversation. This prevents stale thread IDs (e.g. from a Telegram // supergroup topic) from being sent to a different target (e.g. a private @@ -83,7 +99,7 @@ export async function resolveDeliveryTarget( return { channel, to: undefined, - accountId: resolved.accountId, + accountId, threadId, mode, }; @@ -93,13 +109,13 @@ export async function resolveDeliveryTarget( channel, to: toCandidate, cfg, - accountId: resolved.accountId, + accountId, mode, }); return { channel, to: docked.ok ? docked.to : undefined, - accountId: resolved.accountId, + accountId, threadId, mode, error: docked.ok ? undefined : docked.error,