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"); + }); +});