Merge branch 'main' into telegram/restore-types
This commit is contained in:
commit
2d41a275af
15
.github/workflows/auto-response.yml
vendored
15
.github/workflows/auto-response.yml
vendored
@ -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. We’re keeping the core lean on skills, so I’m closing this out.",
|
"Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. We’re keeping the core lean on skills, so I’m 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;
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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"]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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`,
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}`;
|
||||||
|
|||||||
@ -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.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user