From 56ee60d853d98d5c242051a8ff42726f76fc2a54 Mon Sep 17 00:00:00 2001 From: Kuzey Cimen Date: Thu, 29 Jan 2026 01:17:52 +0000 Subject: [PATCH 1/3] feat: Introduce multi-account support for Discord, ensuring session keys and RPC IDs are account-aware. --- src/discord/accounts.ts | 9 ++ .../monitor/message-handler.process.ts | 1 + src/discord/multi-account.test.ts | 99 +++++++++++++++++++ src/routing/session-key.ts | 19 +++- src/routing/shared-channel.test.ts | 59 +++++++++++ 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 src/discord/multi-account.test.ts create mode 100644 src/routing/shared-channel.test.ts diff --git a/src/discord/accounts.ts b/src/discord/accounts.ts index 7964a2a4e..a503631c9 100644 --- a/src/discord/accounts.ts +++ b/src/discord/accounts.ts @@ -20,6 +20,15 @@ function listConfiguredAccountIds(cfg: MoltbotConfig): string[] { export function listDiscordAccountIds(cfg: MoltbotConfig): 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..4020e072c --- /dev/null +++ b/src/discord/multi-account.test.ts @@ -0,0 +1,99 @@ +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: {}, // no token + }, + }, + }, + } as unknown as MoltbotConfig; + + const tokenA = resolveDiscordToken(config, { accountId: "bot_a" }); + // Current implementation might strictly require per-account token if it's not the default account. + // Let's verify behavior. + // Actually, looking at token.ts: + // const accountToken = ... + // if (accountToken) return ... + // const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + // const configToken = allowEnv ? ... : undefined; + + // So valid behavior: non-default accounts MUST have a specific token. + 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/session-key.ts b/src/routing/session-key.ts index 320ffeb83..9f6fa0295 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -139,11 +139,22 @@ 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 `agent:${normalizeAgentId(params.agentId)}:dm:${peerId}:${accountId}`; + } + return `agent:${normalizeAgentId(params.agentId)}:dm:${peerId}:${accountId}`; + } return buildAgentMainSessionKey({ agentId: params.agentId, mainKey: params.mainKey, @@ -151,7 +162,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..d94b5da09 --- /dev/null +++ b/src/routing/shared-channel.test.ts @@ -0,0 +1,59 @@ +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); + + // Should NOT have :default + expect(route.sessionKey).toBe("agent:main:discord:channel:channel_123"); + }); +}); From 58c92b63c6c970e478f138a5df0c8be135edf1c7 Mon Sep 17 00:00:00 2001 From: Kuzey Cimen Date: Thu, 29 Jan 2026 01:25:17 +0000 Subject: [PATCH 2/3] chore: remove outdated comments. --- src/discord/multi-account.test.ts | 10 +--------- src/routing/shared-channel.test.ts | 1 - 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/discord/multi-account.test.ts b/src/discord/multi-account.test.ts index 4020e072c..4c3cb066b 100644 --- a/src/discord/multi-account.test.ts +++ b/src/discord/multi-account.test.ts @@ -48,22 +48,14 @@ describe("discord multi-account logic", () => { discord: { token: "default_token", accounts: { - bot_a: {}, // no token + bot_a: {}, }, }, }, } as unknown as MoltbotConfig; const tokenA = resolveDiscordToken(config, { accountId: "bot_a" }); - // Current implementation might strictly require per-account token if it's not the default account. - // Let's verify behavior. - // Actually, looking at token.ts: - // const accountToken = ... - // if (accountToken) return ... - // const allowEnv = accountId === DEFAULT_ACCOUNT_ID; - // const configToken = allowEnv ? ... : undefined; - // So valid behavior: non-default accounts MUST have a specific token. expect(tokenA.token).toBe(""); }); diff --git a/src/routing/shared-channel.test.ts b/src/routing/shared-channel.test.ts index d94b5da09..a2e56f2b0 100644 --- a/src/routing/shared-channel.test.ts +++ b/src/routing/shared-channel.test.ts @@ -53,7 +53,6 @@ describe("resolveAgentRoute - Shared Channel", () => { const route = resolveAgentRoute(input); console.log("Channel Default:", route.sessionKey); - // Should NOT have :default expect(route.sessionKey).toBe("agent:main:discord:channel:channel_123"); }); }); From 6dbbae0d7e20b8420776e515316d1ee3aec62a65 Mon Sep 17 00:00:00 2001 From: Kuzey Cimen Date: Thu, 29 Jan 2026 11:11:05 +0000 Subject: [PATCH 3/3] fix: fix failing tests. --- src/routing/resolve-route.test.ts | 5 +++-- src/routing/session-key.ts | 7 ++++++- ...am-bot.routes-dms-by-telegram-accountid-binding.test.ts | 2 +- src/telegram/bot.test.ts | 2 +- src/web/auto-reply.partial-reply-gating.test.ts | 2 +- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index aed0fa755..b67389912 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 9f6fa0295..29927ce87 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -151,7 +151,12 @@ export function buildAgentPeerSessionKey(params: { const accountId = normalizeAccountId(params.accountId); if (accountId && accountId !== DEFAULT_ACCOUNT_ID) { if (dmScope === "main") { - return `agent:${normalizeAgentId(params.agentId)}:dm:${peerId}:${accountId}`; + return ( + buildAgentMainSessionKey({ + agentId: params.agentId, + mainKey: params.mainKey, + }) + `:${accountId}` + ); } return `agent:${normalizeAgentId(params.agentId)}:dm:${peerId}:${accountId}`; } 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 e854d1962..b78a8ab57 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 c075174fb..c08607bb5 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 43bb22db1..bb90a5e90 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 }, });