diff --git a/src/discord/accounts.ts b/src/discord/accounts.ts index e4bf6ad31..60c5e80c7 100644 --- a/src/discord/accounts.ts +++ b/src/discord/accounts.ts @@ -20,6 +20,15 @@ function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { export function listDiscordAccountIds(cfg: OpenClawConfig): string[] { const ids = listConfiguredAccountIds(cfg); + const explicitDefault = ids.includes(DEFAULT_ACCOUNT_ID); + + if (!explicitDefault) { + const defaultResolution = resolveDiscordToken(cfg, { accountId: DEFAULT_ACCOUNT_ID }); + if (defaultResolution.source !== "none") { + ids.push(DEFAULT_ACCOUNT_ID); + } + } + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; return ids.sort((a, b) => a.localeCompare(b)); } diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 6d502be21..8baa44870 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -284,6 +284,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) Surface: "discord" as const, WasMentioned: effectiveWasMentioned, MessageSid: message.id, + RpcId: `discord:${route.accountId}:${message.id}`, ParentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey, ThreadStarterBody: threadStarterBody, ThreadLabel: threadLabel, diff --git a/src/discord/multi-account.test.ts b/src/discord/multi-account.test.ts new file mode 100644 index 000000000..4c3cb066b --- /dev/null +++ b/src/discord/multi-account.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { type MoltbotConfig } from "../config/config.js"; +import { listDiscordAccountIds } from "./accounts.js"; +import { resolveDiscordToken } from "./token.js"; + +describe("discord multi-account logic", () => { + it("should list all configured accounts", () => { + const config = { + channels: { + discord: { + accounts: { + bot_a: { token: "token_a", enabled: true }, + bot_b: { token: "token_b", enabled: true }, + bot_c: { token: "token_c", enabled: false }, + }, + }, + }, + } as unknown as MoltbotConfig; + + const ids = listDiscordAccountIds(config); + expect(ids).toEqual(["bot_a", "bot_b", "bot_c"]); + }); + + it("should resolve specific account tokens", () => { + const config = { + channels: { + discord: { + accounts: { + bot_a: { token: "token_a" }, + bot_b: { token: "token_b" }, + }, + }, + }, + } as unknown as MoltbotConfig; + + const tokenA = resolveDiscordToken(config, { accountId: "bot_a" }); + const tokenB = resolveDiscordToken(config, { accountId: "bot_b" }); + const tokenUnknown = resolveDiscordToken(config, { accountId: "bot_unknown" }); + + expect(tokenA.token).toBe("token_a"); + expect(tokenB.token).toBe("token_b"); + expect(tokenUnknown.token).toBe(""); + }); + + it("should fall back to default token if no account token specified", () => { + const config = { + channels: { + discord: { + token: "default_token", + accounts: { + bot_a: {}, + }, + }, + }, + } as unknown as MoltbotConfig; + + const tokenA = resolveDiscordToken(config, { accountId: "bot_a" }); + + expect(tokenA.token).toBe(""); + }); + + it("should include default account if top-level token is present", () => { + const config = { + channels: { + discord: { + token: "default_token", + accounts: { + bot_a: { token: "token_a" }, + }, + }, + }, + } as unknown as MoltbotConfig; + + const ids = listDiscordAccountIds(config); + expect(ids).toContain("default"); + expect(ids).toContain("bot_a"); + }); + + it("should resolve default account correctly", () => { + const config = { + channels: { + discord: { + token: "default_token", + }, + }, + } as unknown as MoltbotConfig; + + const token = resolveDiscordToken(config, { accountId: "default" }); + expect(token.token).toBe("default_token"); + }); +}); diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 51094e125..53d538052 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -104,7 +104,7 @@ describe("resolveAgentRoute", () => { peer: { kind: "dm", id: "+1000" }, }); expect(route.agentId).toBe("a"); - expect(route.sessionKey).toBe("agent:a:main"); + expect(route.sessionKey).toBe("agent:a:main:biz"); expect(route.matchedBy).toBe("binding.peer"); }); @@ -224,7 +224,8 @@ describe("resolveAgentRoute", () => { peer: { kind: "dm", id: "+1000" }, }); expect(route.agentId).toBe("home"); - expect(route.sessionKey).toBe("agent:home:main"); + // Updated: Non-default accounts get distinct session keys + expect(route.sessionKey).toBe("agent:home:main:biz"); }); }); diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 320ffeb83..29927ce87 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -139,11 +139,27 @@ export function buildAgentPeerSessionKey(params: { } if (dmScope === "per-channel-peer" && peerId) { const channel = (params.channel ?? "").trim().toLowerCase() || "unknown"; + const accountId = normalizeAccountId(params.accountId); + if (accountId !== DEFAULT_ACCOUNT_ID) { + return `agent:${normalizeAgentId(params.agentId)}:${channel}:${accountId}:dm:${peerId}`; + } return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`; } if (dmScope === "per-peer" && peerId) { return `agent:${normalizeAgentId(params.agentId)}:dm:${peerId}`; } + const accountId = normalizeAccountId(params.accountId); + if (accountId && accountId !== DEFAULT_ACCOUNT_ID) { + if (dmScope === "main") { + return ( + buildAgentMainSessionKey({ + agentId: params.agentId, + mainKey: params.mainKey, + }) + `:${accountId}` + ); + } + return `agent:${normalizeAgentId(params.agentId)}:dm:${peerId}:${accountId}`; + } return buildAgentMainSessionKey({ agentId: params.agentId, mainKey: params.mainKey, @@ -151,7 +167,13 @@ export function buildAgentPeerSessionKey(params: { } const channel = (params.channel ?? "").trim().toLowerCase() || "unknown"; const peerId = ((params.peerId ?? "").trim() || "unknown").toLowerCase(); - return `agent:${normalizeAgentId(params.agentId)}:${channel}:${peerKind}:${peerId}`; + const accountId = normalizeAccountId(params.accountId); + const baseKey = `agent:${normalizeAgentId(params.agentId)}:${channel}:${peerKind}:${peerId}`; + + if (accountId && accountId !== DEFAULT_ACCOUNT_ID) { + return `${baseKey}:${accountId}`; + } + return baseKey; } function resolveLinkedPeerId(params: { diff --git a/src/routing/shared-channel.test.ts b/src/routing/shared-channel.test.ts new file mode 100644 index 000000000..a2e56f2b0 --- /dev/null +++ b/src/routing/shared-channel.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { resolveAgentRoute, type ResolveAgentRouteInput } from "./resolve-route.js"; +import type { MoltbotConfig } from "../config/config.js"; + +const mockConfig: MoltbotConfig = { + agents: { list: [{ id: "main" }] }, + channels: { + discord: { + enabled: true, + accounts: { + bot_a: { token: "taken_a" }, + bot_b: { token: "token_b" }, + }, + }, + }, +}; + +describe("resolveAgentRoute - Shared Channel", () => { + it("should generate distinct keys for SAME channel on DIFFERENT bots", () => { + const inputA: ResolveAgentRouteInput = { + cfg: mockConfig, + channel: "discord", + accountId: "bot_a", + peer: { kind: "channel", id: "channel_123" }, + }; + + const inputB: ResolveAgentRouteInput = { + cfg: mockConfig, + channel: "discord", + accountId: "bot_b", + peer: { kind: "channel", id: "channel_123" }, + }; + + const routeA = resolveAgentRoute(inputA); + const routeB = resolveAgentRoute(inputB); + + console.log("Channel RouteA:", routeA.sessionKey); + console.log("Channel RouteB:", routeB.sessionKey); + + expect(routeA.sessionKey).not.toEqual(routeB.sessionKey); + expect(routeA.sessionKey).toContain(":bot_a"); + expect(routeB.sessionKey).toContain(":bot_b"); + }); + + it("should preserve backward compatibility for default bot in channel", () => { + const input: ResolveAgentRouteInput = { + cfg: mockConfig, + channel: "discord", + accountId: "default", + peer: { kind: "channel", id: "channel_123" }, + }; + + const route = resolveAgentRoute(input); + console.log("Channel Default:", route.sessionKey); + + expect(route.sessionKey).toBe("agent:main:discord:channel:channel_123"); + }); +}); diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts index 0e0774379..a65b95418 100644 --- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts +++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts @@ -200,7 +200,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.AccountId).toBe("opie"); - expect(payload.SessionKey).toBe("agent:opie:main"); + expect(payload.SessionKey).toBe("agent:opie:main:opie"); }); it("allows per-group requireMention override", async () => { onSpy.mockReset(); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 8129adc7a..d512c7295 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -1260,7 +1260,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.AccountId).toBe("opie"); - expect(payload.SessionKey).toBe("agent:opie:main"); + expect(payload.SessionKey).toBe("agent:opie:main:opie"); }); it("allows per-group requireMention override", async () => { diff --git a/src/web/auto-reply.partial-reply-gating.test.ts b/src/web/auto-reply.partial-reply-gating.test.ts index d4ebc5870..87a20fffe 100644 --- a/src/web/auto-reply.partial-reply-gating.test.ts +++ b/src/web/auto-reply.partial-reply-gating.test.ts @@ -242,7 +242,7 @@ describe("partial reply gating", () => { }); it("updates last-route for group chats with account id", async () => { const now = Date.now(); - const groupSessionKey = "agent:main:whatsapp:group:123@g.us"; + const groupSessionKey = "agent:main:whatsapp:group:123@g.us:work"; const store = await makeSessionStore({ [groupSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, });