diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index b610e1718..6d9f55903 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -24,13 +24,26 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | + // Labels prefixed with "r:" are auto-response triggers. const rules = [ { - label: "skill-clawdhub", + label: "r: skill", close: true, 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.", }, + { + 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; diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a68c3769..5909c9899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,11 +66,14 @@ Status: beta. - Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999. - macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. - 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:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### 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: 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. @@ -78,6 +81,9 @@ Status: beta. - 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. - 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: fix directory config type import for target resolution. Thanks @thewilloftheshadow. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. - Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. - Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang. @@ -95,6 +101,7 @@ Status: beta. - Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai. - Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24. - Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. +- Telegram: ignore non-forum group message_thread_id while preserving DM thread sessions. (#2731) Thanks @dylanneve1. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. diff --git a/docs/cli/security.md b/docs/cli/security.md index 662181616..551debc99 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -20,5 +20,5 @@ moltbot security audit --deep 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. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 58ac57145..b15b1a1ea 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -11,7 +11,8 @@ Use `session.dmScope` to control how **direct messages** are grouped: - `main` (default): all DMs share the main session for continuity. - `per-peer`: isolate by sender id across channels. - `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 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. - `per-peer`: `agent::dm:`. - `per-channel-peer`: `agent:::dm:`. + - `per-account-channel-peer`: `agent::::dm:` (accountId defaults to `default`). - If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `` so the same person shares a session across channels. - Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`). - Telegram forum topics append `:topic:` to the group id for isolation. @@ -94,7 +96,7 @@ Send these as standalone messages so they register. { session: { 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: { alice: ["telegram:123456789", "discord:987654321012345678"] }, diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 15261c809..1d270974d 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2657,7 +2657,8 @@ Fields: - `main`: all DMs share the main session for continuity. - `per-peer`: isolate DMs by sender id across channels. - `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"]`. - `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). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index d29c3df48..a5d841c18 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -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 diff --git a/skills/bitwarden/SKILL.md b/skills/bitwarden/SKILL.md deleted file mode 100644 index 3e384597a..000000000 --- a/skills/bitwarden/SKILL.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -name: bitwarden -description: Manage passwords and credentials via Bitwarden CLI (bw). Use for storing, retrieving, creating, or updating logins, credit cards, secure notes, and identities. Trigger when automating authentication, filling payment forms, or managing secrets programmatically. ---- - -# Bitwarden CLI - -Full read/write vault access via `bw` command. - -## Prerequisites - -```bash -brew install bitwarden-cli -bw login # one-time, prompts for master password -``` - -## Session Management - -Bitwarden requires an unlocked session. Use the helper script: - -```bash -source scripts/bw-session.sh -# Sets BW_SESSION env var -``` - -Or manually: -```bash -export BW_SESSION=$(echo '' | bw unlock --raw) -bw sync # always sync after unlock -``` - -## Common Operations - -### Retrieve credentials -```bash -bw get password "Site Name" -bw get username "Site Name" -bw get item "Site Name" --pretty | jq '.login' -``` - -### Create login -```bash -bw get template item | jq ' - .type = 1 | - .name = "Site Name" | - .login.username = "user@email.com" | - .login.password = "secret123" | - .login.uris = [{uri: "https://example.com"}] -' | bw encode | bw create item -``` - -### Create credit card -```bash -bw get template item | jq ' - .type = 3 | - .name = "Card Name" | - .card.cardholderName = "John Doe" | - .card.brand = "Visa" | - .card.number = "4111111111111111" | - .card.expMonth = "12" | - .card.expYear = "2030" | - .card.code = "123" -' | bw encode | bw create item -``` - -### Get card for payment automation -```bash -bw get item "Card Name" | jq -r '.card | "\(.number) \(.expMonth)/\(.expYear) \(.code)"' -``` - -### List items -```bash -bw list items | jq -r '.[] | "\(.type)|\(.name)"' -# Types: 1=login, 2=note, 3=card, 4=identity -``` - -### Search -```bash -bw list items --search "vilaviniteca" | jq '.[0]' -``` - -## Item Types - -| Type | Value | Use | -|------|-------|-----| -| Login | 1 | Website credentials | -| Secure Note | 2 | Freeform text | -| Card | 3 | Credit/debit cards | -| Identity | 4 | Personal info | - -## References - -- [templates.md](references/templates.md) — Full jq templates for all item types -- [Bitwarden CLI docs](https://bitwarden.com/help/cli/) - -## Tips - -1. **Always sync** after creating/editing items: `bw sync` -2. **Session expires** — re-unlock if you get auth errors -3. **Delete sensitive messages** after receiving credentials -4. **Card numbers** may not import from other managers (security restriction) diff --git a/skills/bitwarden/references/templates.md b/skills/bitwarden/references/templates.md deleted file mode 100644 index a14e011e4..000000000 --- a/skills/bitwarden/references/templates.md +++ /dev/null @@ -1,116 +0,0 @@ -# Bitwarden Item Templates - -jq patterns for creating vault items via CLI. - -## Login (type=1) - -```bash -bw get template item | jq ' - .type = 1 | - .name = "Example Site" | - .notes = "Optional notes" | - .favorite = false | - .login.username = "user@example.com" | - .login.password = "secretPassword123" | - .login.totp = "otpauth://totp/..." | - .login.uris = [ - {uri: "https://example.com", match: null}, - {uri: "https://app.example.com", match: null} - ] -' | bw encode | bw create item -``` - -## Credit Card (type=3) - -```bash -bw get template item | jq ' - .type = 3 | - .name = "Visa ending 1234" | - .notes = "Primary card" | - .card.cardholderName = "JOHN DOE" | - .card.brand = "Visa" | - .card.number = "4111111111111111" | - .card.expMonth = "12" | - .card.expYear = "2030" | - .card.code = "123" -' | bw encode | bw create item -``` - -**Brands:** Visa, Mastercard, Amex, Discover, Diners Club, JCB, Maestro, UnionPay, Other - -## Secure Note (type=2) - -```bash -bw get template item | jq ' - .type = 2 | - .name = "API Keys" | - .notes = "OPENAI_KEY=sk-xxx\nANTHROPIC_KEY=sk-ant-xxx" | - .secureNote.type = 0 -' | bw encode | bw create item -``` - -## Identity (type=4) - -```bash -bw get template item | jq ' - .type = 4 | - .name = "Personal Info" | - .identity.title = "Mr" | - .identity.firstName = "John" | - .identity.lastName = "Doe" | - .identity.email = "john@example.com" | - .identity.phone = "+34612345678" | - .identity.address1 = "123 Main St" | - .identity.city = "Barcelona" | - .identity.state = "Catalunya" | - .identity.postalCode = "08001" | - .identity.country = "ES" -' | bw encode | bw create item -``` - -## Edit Existing Item - -```bash -# Get item, modify, update -bw get item | jq '.login.password = "newPassword"' | bw encode | bw edit item -``` - -## Custom Fields - -```bash -bw get template item | jq ' - .type = 1 | - .name = "With Custom Fields" | - .fields = [ - {name: "Security Question", value: "Pet name", type: 0}, - {name: "PIN", value: "1234", type: 1} - ] -' | bw encode | bw create item -``` - -**Field types:** 0=text, 1=hidden, 2=boolean - -## Retrieve Patterns - -```bash -# Password only -bw get password "Site Name" - -# Username only -bw get username "Site Name" - -# Full login object -bw get item "Site Name" | jq '.login' - -# Card number -bw get item "Card Name" | jq -r '.card.number' - -# All card fields for form filling -bw get item "Card Name" | jq -r '.card | [.number, .expMonth, .expYear, .code] | @tsv' - -# Search by URL -bw list items --url "example.com" | jq '.[0].login' - -# List all cards -bw list items | jq '.[] | select(.type == 3) | .name' -``` diff --git a/skills/bitwarden/scripts/bw-session.sh b/skills/bitwarden/scripts/bw-session.sh deleted file mode 100755 index 1b353583e..000000000 --- a/skills/bitwarden/scripts/bw-session.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash -# Unlock Bitwarden vault and export session key -# Usage: source bw-session.sh -# Or: source bw-session.sh (prompts for password) - -set -e - -if [ -n "$1" ]; then - MASTER_PW="$1" -else - read -sp "Bitwarden master password: " MASTER_PW - echo -fi - -# Check if already logged in -if ! bw login --check &>/dev/null; then - echo "Not logged in. Run: bw login " - return 1 -fi - -# Unlock and get session -export BW_SESSION=$(echo "$MASTER_PW" | bw unlock --raw 2>/dev/null) - -if [ -z "$BW_SESSION" ]; then - echo "Failed to unlock vault" - return 1 -fi - -# Sync to get latest -bw sync &>/dev/null - -echo "✓ Vault unlocked and synced" -echo "Session valid for this shell" diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index 270b5fb02..fef8fa6a4 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -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"); const ids = parsed.providers.minimax?.models?.map((model) => model.id); expect(ids).toContain("MiniMax-M2.1"); diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 0e4579d6d..2b4e1aea1 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -275,7 +275,7 @@ describe("image tool MiniMax VLM routing", () => { expect(fetch).toHaveBeenCalledTimes(1); 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(String((init?.headers as Record)?.Authorization)).toBe( "Bearer minimax-test", diff --git a/src/channels/targets.ts b/src/channels/targets.ts index 77ab755b7..7c9d9cf60 100644 --- a/src/channels/targets.ts +++ b/src/channels/targets.ts @@ -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 MessagingTarget = { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index bf2c94da7..856b18bfb 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -124,7 +124,7 @@ export async function noteSecurityWarnings(cfg: MoltbotConfig) { if (dmScope === "main" && isMultiUserDm) { 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.`, ); } }; diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index e1f8dbe8e..27ec07de4 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -190,7 +190,7 @@ async function noteChannelPrimer( "DM security: default is pairing; unknown DMs get a pairing code.", `Approve with: ${formatCliCommand("moltbot pairing approve ")}`, '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")}`, "", ...channelLines, @@ -238,7 +238,7 @@ async function maybeConfigureDmPolicies(params: { `Approve: ${formatCliCommand(`moltbot pairing approve ${policy.channel} `)}`, `Allowlist DMs: ${policy.policyKey}="allowlist" + ${policy.allowFromKey} entries.`, `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")}`, ].join("\n"), `${policy.label} DM access`, diff --git a/src/config/schema.ts b/src/config/schema.ts index 9b5ad8be6..b4ec8723b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -591,7 +591,7 @@ const FIELD_HELP: Record = { "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", "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": "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", "channels.telegram.configWrites": diff --git a/src/config/types.base.ts b/src/config/types.base.ts index cc805e8ec..e7da1ecd8 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -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"; diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index b9e7b42cc..4412f5515 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -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(), diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index 1cf2a93a9..e247300ee 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -118,19 +118,26 @@ export async function parseAndResolveRecipient( const accountInfo = resolveDiscordAccount({ cfg, accountId }); // First try to resolve using directory lookup (handles usernames) - const resolved = await resolveDiscordTarget(raw, { - cfg, - accountId: accountInfo.accountId, - }); + const trimmed = raw.trim(); + const parseOptions = { + 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) { return { kind: resolved.kind, id: resolved.id }; } // Fallback to standard parsing (for channels, etc.) - const parsed = parseDiscordTarget(raw, { - ambiguousMessage: `Ambiguous Discord recipient "${raw.trim()}". Use "user:${raw.trim()}" for DMs or "channel:${raw.trim()}" for channel messages.`, - }); + const parsed = parseDiscordTarget(raw, parseOptions); if (!parsed) { throw new Error("Recipient is required for Discord sends"); diff --git a/src/discord/targets.ts b/src/discord/targets.ts index 02ded763b..103f3ea0b 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -7,10 +7,8 @@ import { type MessagingTargetParseOptions, } from "../channels/targets.js"; import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; -import type { ChannelDirectoryEntry } from "../channels/plugins/types.core.js"; import { listDiscordDirectoryPeersLive } from "./directory-live.js"; -import { resolveDiscordAccount } from "./accounts.js"; export type DiscordTargetKind = MessagingTargetKind; @@ -72,20 +70,26 @@ export function resolveDiscordChannelId(raw: string): string { * * @param raw - The username or raw target string (e.g., "john.doe") * @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 */ export async function resolveDiscordTarget( raw: string, options: DirectoryConfigParams, + parseOptions: DiscordTargetParseOptions = {}, ): Promise { const trimmed = raw.trim(); if (!trimmed) return undefined; - // If already a known format, parse directly - const directParse = parseDiscordTarget(trimmed); - if (directParse && directParse.kind !== "channel" && !isLikelyUsername(trimmed)) { + const likelyUsername = isLikelyUsername(trimmed); + const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername; + const directParse = safeParseDiscordTarget(trimmed, parseOptions); + if (directParse && directParse.kind !== "channel" && !likelyUsername) { return directParse; } + if (!shouldLookup) { + return directParse ?? parseDiscordTarget(trimmed, parseOptions); + } // Try to resolve as a username via directory lookup try { @@ -101,13 +105,40 @@ export async function resolveDiscordTarget( const userId = match.id.replace(/^user:/, ""); return buildMessagingTarget("user", userId, trimmed); } - } catch (error) { + } catch { // Directory lookup failed - fall through to parse as-is // This preserves existing behavior for channel names } // Fallback to original parsing (for channels, etc.) - return parseDiscordTarget(trimmed); + return parseDiscordTarget(trimmed, parseOptions); +} + +function safeParseDiscordTarget( + input: string, + options: DiscordTargetParseOptions, +): MessagingTarget | undefined { + try { + return parseDiscordTarget(input, options); + } catch { + return undefined; + } +} + +function isExplicitUserLookup(input: string, options: DiscordTargetParseOptions): boolean { + if (/^<@!?(\d+)>$/.test(input)) { + return true; + } + if (/^(user:|discord:)/.test(input)) { + return true; + } + if (input.startsWith("@")) { + return true; + } + if (/^\d+$/.test(input)) { + return options.defaultKind === "user"; + } + return false; } /** diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index c74abc509..9c12fab96 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -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 { diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 6a3366e97..aed0fa755 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -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"); +}); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 473dc61f2..0c63f77c8 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -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 { 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, diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 7f9f209ed..320ffeb83 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -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; /** 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}`; diff --git a/src/security/audit.ts b/src/security/audit.ts index 7aebd6928..681d14c1d 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -519,7 +519,8 @@ async function collectChannelSecurityFindings(params: { title: `${input.label} DMs share the main session`, detail: "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.', }); } }; diff --git a/src/telegram/bot-message-context.dm-threads.test.ts b/src/telegram/bot-message-context.dm-threads.test.ts index ff6a8a837..d710e0b1b 100644 --- a/src/telegram/bot-message-context.dm-threads.test.ts +++ b/src/telegram/bot-message-context.dm-threads.test.ts @@ -70,3 +70,102 @@ describe("buildTelegramMessageContext dm thread sessions", () => { expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); }); }); + +describe("buildTelegramMessageContext group sessions without forum", () => { + const baseConfig = { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + } as never; + + const buildContext = async (message: Record) => + await buildTelegramMessageContext({ + primaryCtx: { + message, + me: { id: 7, username: "bot" }, + } as never, + allMedia: [], + storeAllowFrom: [], + options: { forceWasMentioned: true }, + bot: { + api: { + sendChatAction: vi.fn(), + setMessageReaction: vi.fn(), + }, + } as never, + cfg: baseConfig, + account: { accountId: "default" } as never, + historyLimit: 0, + groupHistories: new Map(), + dmPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + ackReactionScope: "off", + logger: { info: vi.fn() }, + resolveGroupActivation: () => true, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: undefined, + }), + }); + + it("ignores message_thread_id for regular groups (not forums)", async () => { + // When someone replies to a message in a non-forum group, Telegram sends + // message_thread_id but this should NOT create a separate session + const ctx = await buildContext({ + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 42, // This is a reply thread, NOT a forum topic + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctx).not.toBeNull(); + // Session key should NOT include :topic:42 + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890"); + // MessageThreadId should be undefined (not a forum) + expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined(); + }); + + it("keeps same session for regular group with and without message_thread_id", async () => { + const ctxWithThread = await buildContext({ + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 42, + from: { id: 42, first_name: "Alice" }, + }); + + const ctxWithoutThread = await buildContext({ + message_id: 2, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000001, + text: "@bot world", + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctxWithThread).not.toBeNull(); + expect(ctxWithoutThread).not.toBeNull(); + // Both messages should use the same session key + expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey); + }); + + it("uses topic session for forum groups with message_thread_id", async () => { + const ctx = await buildContext({ + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 99, + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctx).not.toBeNull(); + // Session key SHOULD include :topic:99 for forums + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99"); + expect(ctx?.ctxPayload?.MessageThreadId).toBe(99); + }); +}); diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index aa6dcd88b..832a4413d 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -173,7 +173,8 @@ export const buildTelegramMessageContext = async ({ }, }); const baseSessionKey = route.sessionKey; - const dmThreadId = !isGroup ? resolvedThreadId : undefined; + // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) + const dmThreadId = !isGroup ? messageThreadId : undefined; const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) @@ -601,7 +602,8 @@ export const buildTelegramMessageContext = async ({ Sticker: allMedia[0]?.stickerMetadata, ...(locationData ? toLocationContext(locationData) : undefined), CommandAuthorized: commandAuthorized, - MessageThreadId: resolvedThreadId, + // For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId + MessageThreadId: isGroup ? resolvedThreadId : messageThreadId, IsForum: isForum, // Originating channel for reply routing. OriginatingChannel: "telegram" as const, diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 0dd372c3e..3415ea927 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -360,6 +360,8 @@ export const registerTelegramNativeCommands = ({ topicConfig, commandAuthorized, } = auth; + const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; + const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId; const commandDefinition = findCommandByNativeName(command.name, "telegram"); const rawText = ctx.match?.trim() ?? ""; @@ -406,7 +408,7 @@ export const registerTelegramNativeCommands = ({ fn: () => bot.api.sendMessage(chatId, title, { ...(replyMarkup ? { reply_markup: replyMarkup } : {}), - ...(resolvedThreadId != null ? { message_thread_id: resolvedThreadId } : {}), + ...(threadIdForSend != null ? { message_thread_id: threadIdForSend } : {}), }), }); return; @@ -421,7 +423,8 @@ export const registerTelegramNativeCommands = ({ }, }); const baseSessionKey = route.sessionKey; - const dmThreadId = !isGroup ? resolvedThreadId : undefined; + // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) + const dmThreadId = !isGroup ? messageThreadId : undefined; const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) @@ -466,7 +469,7 @@ export const registerTelegramNativeCommands = ({ CommandSource: "native" as const, SessionKey: `telegram:slash:${senderId || chatId}`, CommandTargetSessionKey: sessionKey, - MessageThreadId: resolvedThreadId, + MessageThreadId: threadIdForSend, IsForum: isForum, // Originating context for sub-agent announce routing OriginatingChannel: "telegram" as const, @@ -493,7 +496,7 @@ export const registerTelegramNativeCommands = ({ bot, replyToMode, textLimit, - messageThreadId: resolvedThreadId, + messageThreadId: threadIdForSend, tableMode, chunkMode, linkPreview: telegramCfg.linkPreview, @@ -541,7 +544,9 @@ export const registerTelegramNativeCommands = ({ requireAuth: match.command.requireAuth !== false, }); if (!auth) return; - const { resolvedThreadId, senderId, commandAuthorized } = auth; + const { resolvedThreadId, senderId, commandAuthorized, isGroup } = auth; + const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; + const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId; const result = await executePluginCommand({ command: match.command, @@ -567,7 +572,7 @@ export const registerTelegramNativeCommands = ({ bot, replyToMode, textLimit, - messageThreadId: resolvedThreadId, + messageThreadId: threadIdForSend, tableMode, chunkMode, linkPreview: telegramCfg.linkPreview, diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index bf94e4f6f..c3844ac88 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -238,12 +238,17 @@ describe("createTelegramBot", () => { expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123"); expect( getTelegramSequentialKey({ - message: { chat: { id: 123 }, message_thread_id: 9 }, + message: { chat: { id: 123, type: "private" }, message_thread_id: 9 }, }), ).toBe("telegram:123:topic:9"); expect( getTelegramSequentialKey({ - message: { chat: { id: 123, is_forum: true } }, + message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 }, + }), + ).toBe("telegram:123"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123, type: "supergroup", is_forum: true } }, }), ).toBe("telegram:123:topic:1"); expect( diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 75dd32faf..c075174fb 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -340,12 +340,17 @@ describe("createTelegramBot", () => { expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123"); expect( getTelegramSequentialKey({ - message: { chat: { id: 123 }, message_thread_id: 9 }, + message: { chat: { id: 123, type: "private" }, message_thread_id: 9 }, }), ).toBe("telegram:123:topic:9"); expect( getTelegramSequentialKey({ - message: { chat: { id: 123, is_forum: true } }, + message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 }, + }), + ).toBe("telegram:123"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123, type: "supergroup", is_forum: true } }, }), ).toBe("telegram:123:topic:1"); expect( diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 655e1b427..ae21d10da 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -94,11 +94,12 @@ export function getTelegramSequentialKey(ctx: { if (typeof chatId === "number") return `telegram:${chatId}:control`; return "telegram:control"; } + const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup"; + const messageThreadId = msg?.message_thread_id; const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum; - const threadId = resolveTelegramForumThreadId({ - isForum, - messageThreadId: msg?.message_thread_id, - }); + const threadId = isGroup + ? resolveTelegramForumThreadId({ isForum, messageThreadId }) + : messageThreadId; if (typeof chatId === "number") { return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`; } @@ -427,7 +428,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { peer: { kind: isGroup ? "group" : "dm", id: peerId }, }); const baseSessionKey = route.sessionKey; - const dmThreadId = !isGroup ? resolvedThreadId : undefined; + // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) + const dmThreadId = !isGroup ? messageThreadId : undefined; const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) diff --git a/src/telegram/bot/helpers.test.ts b/src/telegram/bot/helpers.test.ts index 60fbba0dc..8e90bb520 100644 --- a/src/telegram/bot/helpers.test.ts +++ b/src/telegram/bot/helpers.test.ts @@ -3,8 +3,34 @@ import { buildTelegramThreadParams, buildTypingThreadParams, normalizeForwardedContext, + resolveTelegramForumThreadId, } from "./helpers.js"; +describe("resolveTelegramForumThreadId", () => { + it("returns undefined for non-forum groups even with messageThreadId", () => { + // Reply threads in regular groups should not create separate sessions + expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: 42 })).toBeUndefined(); + }); + + it("returns undefined for non-forum groups without messageThreadId", () => { + expect( + resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined }), + ).toBeUndefined(); + expect( + resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 }), + ).toBeUndefined(); + }); + + it("returns General topic (1) for forum groups without messageThreadId", () => { + expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: undefined })).toBe(1); + expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: null })).toBe(1); + }); + + it("returns the topic id for forum groups with messageThreadId", () => { + expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: 99 })).toBe(99); + }); +}); + describe("buildTelegramThreadParams", () => { it("omits General topic thread id for message sends", () => { expect(buildTelegramThreadParams(1)).toBeUndefined(); diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index 19b8e76c0..cd57392c0 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -13,14 +13,25 @@ import type { const TELEGRAM_GENERAL_TOPIC_ID = 1; +/** + * Resolve the thread ID for Telegram forum topics. + * For non-forum groups, returns undefined even if messageThreadId is present + * (reply threads in regular groups should not create separate sessions). + * For forum groups, returns the topic ID (or General topic ID=1 if unspecified). + */ export function resolveTelegramForumThreadId(params: { isForum?: boolean; messageThreadId?: number | null; }) { - if (params.isForum && params.messageThreadId == null) { + // Non-forum groups: ignore message_thread_id (reply threads are not real topics) + if (!params.isForum) { + return undefined; + } + // Forum groups: use the topic ID, defaulting to General topic + if (params.messageThreadId == null) { return TELEGRAM_GENERAL_TOPIC_ID; } - return params.messageThreadId ?? undefined; + return params.messageThreadId; } /** diff --git a/src/web/auto-reply/monitor/broadcast.ts b/src/web/auto-reply/monitor/broadcast.ts index ef76ce3b0..c8f84a048 100644 --- a/src/web/auto-reply/monitor/broadcast.ts +++ b/src/web/auto-reply/monitor/broadcast.ts @@ -54,11 +54,13 @@ export async function maybeBroadcastMessage(params: { sessionKey: buildAgentSessionKey({ agentId: normalizedAgentId, channel: "whatsapp", + accountId: params.route.accountId, peer: { kind: params.msg.chatType === "group" ? "group" : "dm", id: params.peerId, }, dmScope: params.cfg.session?.dmScope, + identityLinks: params.cfg.session?.identityLinks, }), mainSessionKey: buildAgentMainSessionKey({ agentId: normalizedAgentId,