feat(routing): add per-account-channel-peer session scope

Adds a new dmScope option that includes accountId in session keys,
enabling isolated sessions per channel account for multi-bot setups.

- Add 'per-account-channel-peer' to DmScope type
- Update session key generation to include accountId
- Pass accountId through routing chain
- Add tests for new routing behavior (13/13 passing)

Closes #3094

Co-authored-by: Sebastian Almeida <89653954+SebastianAlmeida@users.noreply.github.com>
This commit is contained in:
Jarvis Deploy
2026-01-27 21:51:23 -05:00
committed by Ayaan Zaidi
parent 93c2d65398
commit d499b14842
6 changed files with 63 additions and 4 deletions

View File

@@ -3,7 +3,7 @@ import type { NormalizedChatType } from "../channels/chat-type.js";
export type ReplyMode = "text" | "command";
export type TypingMode = "never" | "instant" | "thinking" | "message";
export type SessionScope = "per-sender" | "global";
export type DmScope = "main" | "per-peer" | "per-channel-peer";
export type DmScope = "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
export type ReplyToMode = "off" | "first" | "all";
export type GroupPolicy = "open" | "disabled" | "allowlist";
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";

View File

@@ -20,7 +20,12 @@ export const SessionSchema = z
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
dmScope: z
.union([z.literal("main"), z.literal("per-peer"), z.literal("per-channel-peer")])
.union([
z.literal("main"),
z.literal("per-peer"),
z.literal("per-channel-peer"),
z.literal("per-account-channel-peer"),
])
.optional(),
identityLinks: z.record(z.string(), z.array(z.string())).optional(),
resetTriggers: z.array(z.string()).optional(),

View File

@@ -103,11 +103,13 @@ function buildBaseSessionKey(params: {
cfg: MoltbotConfig;
agentId: string;
channel: ChannelId;
accountId?: string | null;
peer: RoutePeer;
}): string {
return buildAgentSessionKey({
agentId: params.agentId,
channel: params.channel,
accountId: params.accountId,
peer: params.peer,
dmScope: params.cfg.session?.dmScope ?? "main",
identityLinks: params.cfg.session?.identityLinks,
@@ -200,6 +202,7 @@ async function resolveSlackSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "slack",
accountId: params.accountId,
peer,
});
const threadId = normalizeThreadId(params.threadId ?? params.replyToId);
@@ -237,6 +240,7 @@ function resolveDiscordSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "discord",
accountId: params.accountId,
peer,
});
const explicitThreadId = normalizeThreadId(params.threadId);
@@ -285,6 +289,7 @@ function resolveTelegramSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "telegram",
accountId: params.accountId,
peer,
});
return {
@@ -312,6 +317,7 @@ function resolveWhatsAppSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "whatsapp",
accountId: params.accountId,
peer,
});
return {
@@ -337,6 +343,7 @@ function resolveSignalSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "signal",
accountId: params.accountId,
peer,
});
return {
@@ -371,6 +378,7 @@ function resolveSignalSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "signal",
accountId: params.accountId,
peer,
});
return {
@@ -395,6 +403,7 @@ function resolveIMessageSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "imessage",
accountId: params.accountId,
peer,
});
return {
@@ -419,6 +428,7 @@ function resolveIMessageSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "imessage",
accountId: params.accountId,
peer,
});
const toPrefix =
@@ -450,6 +460,7 @@ function resolveMatrixSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "matrix",
accountId: params.accountId,
peer,
});
return {
@@ -483,6 +494,7 @@ function resolveMSTeamsSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "msteams",
accountId: params.accountId,
peer,
});
return {
@@ -517,6 +529,7 @@ function resolveMattermostSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "mattermost",
accountId: params.accountId,
peer,
});
const threadId = normalizeThreadId(params.replyToId ?? params.threadId);
@@ -561,6 +574,7 @@ function resolveBlueBubblesSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "bluebubbles",
accountId: params.accountId,
peer,
});
return {
@@ -586,6 +600,7 @@ function resolveNextcloudTalkSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "nextcloud-talk",
accountId: params.accountId,
peer,
});
return {
@@ -612,6 +627,7 @@ function resolveZaloSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "zalo",
accountId: params.accountId,
peer,
});
return {
@@ -639,6 +655,7 @@ function resolveZalouserSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "zalouser",
accountId: params.accountId,
peer,
});
return {
@@ -661,6 +678,7 @@ function resolveNostrSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "nostr",
accountId: params.accountId,
peer,
});
return {
@@ -719,6 +737,7 @@ function resolveTlonSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "tlon",
accountId: params.accountId,
peer,
});
return {

View File

@@ -227,3 +227,29 @@ describe("resolveAgentRoute", () => {
expect(route.sessionKey).toBe("agent:home:main");
});
});
test("dmScope=per-account-channel-peer isolates DM sessions per account, channel and sender", () => {
const cfg: MoltbotConfig = {
session: { dmScope: "per-account-channel-peer" },
};
const route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId: "tasks",
peer: { kind: "dm", id: "7550356539" },
});
expect(route.sessionKey).toBe("agent:main:telegram:tasks:dm:7550356539");
});
test("dmScope=per-account-channel-peer uses default accountId when not provided", () => {
const cfg: MoltbotConfig = {
session: { dmScope: "per-account-channel-peer" },
};
const route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId: null,
peer: { kind: "dm", id: "7550356539" },
});
expect(route.sessionKey).toBe("agent:main:telegram:default:dm:7550356539");
});

View File

@@ -69,9 +69,10 @@ function matchesAccountId(match: string | undefined, actual: string): boolean {
export function buildAgentSessionKey(params: {
agentId: string;
channel: string;
accountId?: string | null;
peer?: RoutePeer | null;
/** DM session scope. */
dmScope?: "main" | "per-peer" | "per-channel-peer";
dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
identityLinks?: Record<string, string[]>;
}): string {
const channel = normalizeToken(params.channel) || "unknown";
@@ -80,6 +81,7 @@ export function buildAgentSessionKey(params: {
agentId: params.agentId,
mainKey: DEFAULT_MAIN_KEY,
channel,
accountId: params.accountId,
peerKind: peer?.kind ?? "dm",
peerId: peer ? normalizeId(peer.id) || "unknown" : null,
dmScope: params.dmScope,
@@ -160,6 +162,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
const sessionKey = buildAgentSessionKey({
agentId: resolvedAgentId,
channel,
accountId,
peer,
dmScope,
identityLinks,

View File

@@ -111,11 +111,12 @@ export function buildAgentPeerSessionKey(params: {
agentId: string;
mainKey?: string | undefined;
channel: string;
accountId?: string | null;
peerKind?: "dm" | "group" | "channel" | null;
peerId?: string | null;
identityLinks?: Record<string, string[]>;
/** DM session scope. */
dmScope?: "main" | "per-peer" | "per-channel-peer";
dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
}): string {
const peerKind = params.peerKind ?? "dm";
if (peerKind === "dm") {
@@ -131,6 +132,11 @@ export function buildAgentPeerSessionKey(params: {
});
if (linkedPeerId) peerId = linkedPeerId;
peerId = peerId.toLowerCase();
if (dmScope === "per-account-channel-peer" && peerId) {
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
const accountId = normalizeAccountId(params.accountId);
return `agent:${normalizeAgentId(params.agentId)}:${channel}:${accountId}:dm:${peerId}`;
}
if (dmScope === "per-channel-peer" && peerId) {
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`;