feat: Introduce multi-account support for Discord, ensuring session keys and RPC IDs are account-aware.

This commit is contained in:
Kuzey Cimen 2026-01-29 01:17:52 +00:00
parent fdcac0ccf4
commit 56ee60d853
5 changed files with 186 additions and 1 deletions

View File

@ -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));
}

View File

@ -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,

View File

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

View File

@ -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: {

View File

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