Merge branch 'main' into telegram/restore-types

This commit is contained in:
Yash Goyal 2026-01-28 07:38:48 +01:00 committed by GitHub
commit 2d41a275af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 144 additions and 33 deletions

View File

@ -24,13 +24,26 @@ jobs:
with: with:
github-token: ${{ steps.app-token.outputs.token }} github-token: ${{ steps.app-token.outputs.token }}
script: | script: |
// Labels prefixed with "r:" are auto-response triggers.
const rules = [ const rules = [
{ {
label: "skill-clawdhub", label: "r: skill",
close: true, close: true,
message: message:
"Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. Were keeping the core lean on skills, so Im closing this out.", "Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. Were keeping the core lean on skills, so Im closing this out.",
}, },
{
label: "r: support",
close: true,
message:
"Please use our support server https://molt.bot/discord and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.molt.bot/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
},
{
label: "r: third-party-extension",
close: true,
message:
"This would be better made as a third-party extension with our SDK that you maintain yourself. Docs: https://docs.molt.bot/plugin.",
},
]; ];
const labelName = context.payload.label?.name; const labelName = context.payload.label?.name;

View File

@ -66,11 +66,14 @@ Status: beta.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999. - Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. - macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
- CLI: use Node's module compile cache for faster startup. (#2808) Thanks @pi0. - CLI: use Node's module compile cache for faster startup. (#2808) Thanks @pi0.
- Routing: add per-account DM session scope and document multi-account isolation. (#3095) Thanks @jarvis-sam.
### Breaking ### Breaking
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes ### Fixes
- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald.
- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.
- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma. - Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.
- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94. - Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355. - Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
@ -78,6 +81,7 @@ Status: beta.
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. - Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. - Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. - Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.
- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow.
- Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow. - Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow.
- Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow. - Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow.
- Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb.

View File

@ -20,5 +20,5 @@ moltbot security audit --deep
moltbot security audit --fix moltbot security audit --fix
``` ```
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes. The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.

View File

@ -11,7 +11,8 @@ Use `session.dmScope` to control how **direct messages** are grouped:
- `main` (default): all DMs share the main session for continuity. - `main` (default): all DMs share the main session for continuity.
- `per-peer`: isolate by sender id across channels. - `per-peer`: isolate by sender id across channels.
- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes). - `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`. - `per-account-channel-peer`: isolate by account + channel + sender (recommended for multi-account inboxes).
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
## Gateway is the source of truth ## Gateway is the source of truth
All session state is **owned by the gateway** (the “master” Moltbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files. All session state is **owned by the gateway** (the “master” Moltbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
@ -44,6 +45,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation. - Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
- `per-peer`: `agent:<agentId>:dm:<peerId>`. - `per-peer`: `agent:<agentId>:dm:<peerId>`.
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`. - `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
- `per-account-channel-peer`: `agent:<agentId>:<channel>:<accountId>:dm:<peerId>` (accountId defaults to `default`).
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels. - If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`). - Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation. - Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
@ -94,7 +96,7 @@ Send these as standalone messages so they register.
{ {
session: { session: {
scope: "per-sender", // keep group keys separate scope: "per-sender", // keep group keys separate
dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes) dmScope: "main", // DM continuity (set per-channel-peer/per-account-channel-peer for shared inboxes)
identityLinks: { identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"] alice: ["telegram:123456789", "discord:987654321012345678"]
}, },

View File

@ -2657,7 +2657,8 @@ Fields:
- `main`: all DMs share the main session for continuity. - `main`: all DMs share the main session for continuity.
- `per-peer`: isolate DMs by sender id across channels. - `per-peer`: isolate DMs by sender id across channels.
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes). - `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`. - `per-account-channel-peer`: isolate DMs per account + channel + sender (recommended for multi-account inboxes).
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`. - Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host. - `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
- `mode`: `daily` or `idle` (default: `daily` when `reset` is present). - `mode`: `daily` or `idle` (default: `daily` when `reset` is present).

View File

@ -199,7 +199,7 @@ By default, Moltbot routes **all DMs into the main session** so your assistant h
} }
``` ```
This prevents cross-user context leakage while keeping group chats isolated. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration). This prevents cross-user context leakage while keeping group chats isolated. If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
## Allowlists (DM + groups) — terminology ## Allowlists (DM + groups) — terminology

View File

@ -136,7 +136,7 @@ describe("models-config", () => {
} }
>; >;
}; };
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.chat/v1");
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id); const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-M2.1"); expect(ids).toContain("MiniMax-M2.1");

View File

@ -275,7 +275,7 @@ describe("image tool MiniMax VLM routing", () => {
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
const [url, init] = fetch.mock.calls[0]; const [url, init] = fetch.mock.calls[0];
expect(String(url)).toBe("https://api.minimax.io/v1/coding_plan/vlm"); expect(String(url)).toBe("https://api.minimax.chat/v1/coding_plan/vlm");
expect(init?.method).toBe("POST"); expect(init?.method).toBe("POST");
expect(String((init?.headers as Record<string, string>)?.Authorization)).toBe( expect(String((init?.headers as Record<string, string>)?.Authorization)).toBe(
"Bearer minimax-test", "Bearer minimax-test",

View File

@ -1,3 +1,6 @@
export type { DirectoryConfigParams } from "./plugins/directory-config.js";
export type { ChannelDirectoryEntry } from "./plugins/types.js";
export type MessagingTargetKind = "user" | "channel"; export type MessagingTargetKind = "user" | "channel";
export type MessagingTarget = { export type MessagingTarget = {

View File

@ -124,7 +124,7 @@ export async function noteSecurityWarnings(cfg: MoltbotConfig) {
if (dmScope === "main" && isMultiUserDm) { if (dmScope === "main" && isMultiUserDm) {
warnings.push( warnings.push(
`- ${params.label} DMs: multiple senders share the main session; set session.dmScope="per-channel-peer" to isolate sessions.`, `- ${params.label} DMs: multiple senders share the main session; set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.`,
); );
} }
}; };

View File

@ -190,7 +190,7 @@ async function noteChannelPrimer(
"DM security: default is pairing; unknown DMs get a pairing code.", "DM security: default is pairing; unknown DMs get a pairing code.",
`Approve with: ${formatCliCommand("moltbot pairing approve <channel> <code>")}`, `Approve with: ${formatCliCommand("moltbot pairing approve <channel> <code>")}`,
'Public DMs require dmPolicy="open" + allowFrom=["*"].', 'Public DMs require dmPolicy="open" + allowFrom=["*"].',
'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.', 'Multi-user DMs: set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.',
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`, `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
"", "",
...channelLines, ...channelLines,
@ -238,7 +238,7 @@ async function maybeConfigureDmPolicies(params: {
`Approve: ${formatCliCommand(`moltbot pairing approve ${policy.channel} <code>`)}`, `Approve: ${formatCliCommand(`moltbot pairing approve ${policy.channel} <code>`)}`,
`Allowlist DMs: ${policy.policyKey}="allowlist" + ${policy.allowFromKey} entries.`, `Allowlist DMs: ${policy.policyKey}="allowlist" + ${policy.allowFromKey} entries.`,
`Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`, `Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`,
'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.', 'Multi-user DMs: set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.',
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`, `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
].join("\n"), ].join("\n"),
`${policy.label} DM access`, `${policy.label} DM access`,

View File

@ -591,7 +591,7 @@ const FIELD_HELP: Record<string, string> = {
"commands.restart": "Allow /restart and gateway restart tool actions (default: false).", "commands.restart": "Allow /restart and gateway restart tool actions (default: false).",
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
"session.dmScope": "session.dmScope":
'DM session scoping: "main" keeps continuity; "per-peer" or "per-channel-peer" isolates DM history (recommended for shared inboxes).', 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).',
"session.identityLinks": "session.identityLinks":
"Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).",
"channels.telegram.configWrites": "channels.telegram.configWrites":

View File

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

View File

@ -20,7 +20,12 @@ export const SessionSchema = z
.object({ .object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
dmScope: z 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(), .optional(),
identityLinks: z.record(z.string(), z.array(z.string())).optional(), identityLinks: z.record(z.string(), z.array(z.string())).optional(),
resetTriggers: z.array(z.string()).optional(), resetTriggers: z.array(z.string()).optional(),

View File

@ -118,19 +118,26 @@ export async function parseAndResolveRecipient(
const accountInfo = resolveDiscordAccount({ cfg, accountId }); const accountInfo = resolveDiscordAccount({ cfg, accountId });
// First try to resolve using directory lookup (handles usernames) // First try to resolve using directory lookup (handles usernames)
const resolved = await resolveDiscordTarget(raw, { const trimmed = raw.trim();
cfg, const parseOptions = {
accountId: accountInfo.accountId, ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
}); };
const resolved = await resolveDiscordTarget(
raw,
{
cfg,
accountId: accountInfo.accountId,
},
parseOptions,
);
if (resolved) { if (resolved) {
return { kind: resolved.kind, id: resolved.id }; return { kind: resolved.kind, id: resolved.id };
} }
// Fallback to standard parsing (for channels, etc.) // Fallback to standard parsing (for channels, etc.)
const parsed = parseDiscordTarget(raw, { const parsed = parseDiscordTarget(raw, parseOptions);
ambiguousMessage: `Ambiguous Discord recipient "${raw.trim()}". Use "user:${raw.trim()}" for DMs or "channel:${raw.trim()}" for channel messages.`,
});
if (!parsed) { if (!parsed) {
throw new Error("Recipient is required for Discord sends"); throw new Error("Recipient is required for Discord sends");

View File

@ -10,7 +10,6 @@ import {
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
import { listDiscordDirectoryPeersLive } from "./directory-live.js"; import { listDiscordDirectoryPeersLive } from "./directory-live.js";
import { resolveDiscordAccount } from "./accounts.js";
export type DiscordTargetKind = MessagingTargetKind; export type DiscordTargetKind = MessagingTargetKind;
@ -72,22 +71,25 @@ export function resolveDiscordChannelId(raw: string): string {
* *
* @param raw - The username or raw target string (e.g., "john.doe") * @param raw - The username or raw target string (e.g., "john.doe")
* @param options - Directory configuration params (cfg, accountId, limit) * @param options - Directory configuration params (cfg, accountId, limit)
* @param parseOptions - Messaging target parsing options (defaults, ambiguity message)
* @returns Parsed MessagingTarget with user ID, or undefined if not found * @returns Parsed MessagingTarget with user ID, or undefined if not found
*/ */
export async function resolveDiscordTarget( export async function resolveDiscordTarget(
raw: string, raw: string,
options: DirectoryConfigParams, options: DirectoryConfigParams,
parseOptions: DiscordTargetParseOptions = {},
): Promise<MessagingTarget | undefined> { ): Promise<MessagingTarget | undefined> {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return undefined; if (!trimmed) return undefined;
const shouldLookup = isExplicitUserLookup(trimmed, options); const likelyUsername = isLikelyUsername(trimmed);
const directParse = safeParseDiscordTarget(trimmed, options); const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername;
if (directParse && directParse.kind !== "channel") { const directParse = safeParseDiscordTarget(trimmed, parseOptions);
if (directParse && directParse.kind !== "channel" && !likelyUsername) {
return directParse; return directParse;
} }
if (!shouldLookup) { if (!shouldLookup) {
return directParse ?? parseDiscordTarget(trimmed, options); return directParse ?? parseDiscordTarget(trimmed, parseOptions);
} }
// Try to resolve as a username via directory lookup // Try to resolve as a username via directory lookup
@ -104,13 +106,13 @@ export async function resolveDiscordTarget(
const userId = match.id.replace(/^user:/, ""); const userId = match.id.replace(/^user:/, "");
return buildMessagingTarget("user", userId, trimmed); return buildMessagingTarget("user", userId, trimmed);
} }
} catch (error) { } catch {
// Directory lookup failed - fall through to parse as-is // Directory lookup failed - fall through to parse as-is
// This preserves existing behavior for channel names // This preserves existing behavior for channel names
} }
// Fallback to original parsing (for channels, etc.) // Fallback to original parsing (for channels, etc.)
return parseDiscordTarget(trimmed, options); return parseDiscordTarget(trimmed, parseOptions);
} }
function safeParseDiscordTarget( function safeParseDiscordTarget(
@ -139,3 +141,16 @@ function isExplicitUserLookup(input: string, options: DiscordTargetParseOptions)
} }
return false; return false;
} }
/**
* Check if a string looks like a Discord username (not a mention, prefix, or ID).
* Usernames typically don't start with special characters except underscore.
*/
function isLikelyUsername(input: string): boolean {
// Skip if it's already a known format
if (/^(user:|channel:|discord:|@|<@!?)|[\d]+$/.test(input)) {
return false;
}
// Likely a username if it doesn't match known patterns
return true;
}

View File

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

View File

@ -227,3 +227,29 @@ describe("resolveAgentRoute", () => {
expect(route.sessionKey).toBe("agent:home:main"); 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: { export function buildAgentSessionKey(params: {
agentId: string; agentId: string;
channel: string; channel: string;
accountId?: string | null;
peer?: RoutePeer | null; peer?: RoutePeer | null;
/** DM session scope. */ /** 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[]>; identityLinks?: Record<string, string[]>;
}): string { }): string {
const channel = normalizeToken(params.channel) || "unknown"; const channel = normalizeToken(params.channel) || "unknown";
@ -80,6 +81,7 @@ export function buildAgentSessionKey(params: {
agentId: params.agentId, agentId: params.agentId,
mainKey: DEFAULT_MAIN_KEY, mainKey: DEFAULT_MAIN_KEY,
channel, channel,
accountId: params.accountId,
peerKind: peer?.kind ?? "dm", peerKind: peer?.kind ?? "dm",
peerId: peer ? normalizeId(peer.id) || "unknown" : null, peerId: peer ? normalizeId(peer.id) || "unknown" : null,
dmScope: params.dmScope, dmScope: params.dmScope,
@ -160,6 +162,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
const sessionKey = buildAgentSessionKey({ const sessionKey = buildAgentSessionKey({
agentId: resolvedAgentId, agentId: resolvedAgentId,
channel, channel,
accountId,
peer, peer,
dmScope, dmScope,
identityLinks, identityLinks,

View File

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

View File

@ -519,7 +519,8 @@ async function collectChannelSecurityFindings(params: {
title: `${input.label} DMs share the main session`, title: `${input.label} DMs share the main session`,
detail: detail:
"Multiple DM senders currently share the main session, which can leak context across users.", "Multiple DM senders currently share the main session, which can leak context across users.",
remediation: 'Set session.dmScope="per-channel-peer" to isolate DM sessions per sender.', remediation:
'Set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate DM sessions per sender.',
}); });
} }
}; };

View File

@ -13,8 +13,12 @@ describe("resolveTelegramForumThreadId", () => {
}); });
it("returns undefined for non-forum groups without messageThreadId", () => { it("returns undefined for non-forum groups without messageThreadId", () => {
expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined })).toBeUndefined(); expect(
expect(resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 })).toBeUndefined(); resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined }),
).toBeUndefined();
expect(
resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 }),
).toBeUndefined();
}); });
it("returns General topic (1) for forum groups without messageThreadId", () => { it("returns General topic (1) for forum groups without messageThreadId", () => {

View File

@ -54,11 +54,13 @@ export async function maybeBroadcastMessage(params: {
sessionKey: buildAgentSessionKey({ sessionKey: buildAgentSessionKey({
agentId: normalizedAgentId, agentId: normalizedAgentId,
channel: "whatsapp", channel: "whatsapp",
accountId: params.route.accountId,
peer: { peer: {
kind: params.msg.chatType === "group" ? "group" : "dm", kind: params.msg.chatType === "group" ? "group" : "dm",
id: params.peerId, id: params.peerId,
}, },
dmScope: params.cfg.session?.dmScope, dmScope: params.cfg.session?.dmScope,
identityLinks: params.cfg.session?.identityLinks,
}), }),
mainSessionKey: buildAgentMainSessionKey({ mainSessionKey: buildAgentMainSessionKey({
agentId: normalizedAgentId, agentId: normalizedAgentId,