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 d7dd92c68..5909c9899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,17 +66,28 @@ 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. - macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee. - 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. +- Providers: update Moonshot Kimi model references to kimi-k2.5. (#2762) Thanks @MarvinCui. - Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. - TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. @@ -90,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/model-providers.md b/docs/concepts/model-providers.md index ef27fc9e3..9dbb984fc 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -130,9 +130,10 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: - Provider: `moonshot` - Auth: `MOONSHOT_API_KEY` -- Example model: `moonshot/kimi-k2-0905-preview` +- Example model: `moonshot/kimi-k2.5` - Kimi K2 model IDs: {/* moonshot-kimi-k2-model-refs:start */} + - `moonshot/kimi-k2.5` - `moonshot/kimi-k2-0905-preview` - `moonshot/kimi-k2-turbo-preview` - `moonshot/kimi-k2-thinking` @@ -141,7 +142,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: ```json5 { agents: { - defaults: { model: { primary: "moonshot/kimi-k2-0905-preview" } } + defaults: { model: { primary: "moonshot/kimi-k2.5" } } }, models: { mode: "merge", @@ -150,7 +151,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: baseUrl: "https://api.moonshot.ai/v1", apiKey: "${MOONSHOT_API_KEY}", api: "openai-completions", - models: [{ id: "kimi-k2-0905-preview", name: "Kimi K2 0905 Preview" }] + models: [{ id: "kimi-k2.5", name: "Kimi K2.5" }] } } } 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 f5438fb46..1d270974d 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2396,8 +2396,8 @@ Use Moonshot's OpenAI-compatible endpoint: env: { MOONSHOT_API_KEY: "sk-..." }, agents: { defaults: { - model: { primary: "moonshot/kimi-k2-0905-preview" }, - models: { "moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" } } + model: { primary: "moonshot/kimi-k2.5" }, + models: { "moonshot/kimi-k2.5": { alias: "Kimi K2.5" } } } }, models: { @@ -2409,8 +2409,8 @@ Use Moonshot's OpenAI-compatible endpoint: api: "openai-completions", models: [ { - id: "kimi-k2-0905-preview", - name: "Kimi K2 0905 Preview", + id: "kimi-k2.5", + name: "Kimi K2.5", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -2426,7 +2426,7 @@ Use Moonshot's OpenAI-compatible endpoint: Notes: - Set `MOONSHOT_API_KEY` in the environment or use `moltbot onboard --auth-choice moonshot-api-key`. -- Model ref: `moonshot/kimi-k2-0905-preview`. +- Model ref: `moonshot/kimi-k2.5`. - Use `https://api.moonshot.cn/v1` if you need the China endpoint. ### Kimi Code @@ -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/docs/providers/moonshot.md b/docs/providers/moonshot.md index 7e0723f7e..a1f2d18ad 100644 --- a/docs/providers/moonshot.md +++ b/docs/providers/moonshot.md @@ -9,11 +9,12 @@ read_when: # Moonshot AI (Kimi) Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the -provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use +provider and set the default model to `moonshot/kimi-k2.5`, or use Kimi Code with `kimi-code/kimi-for-coding`. Current Kimi K2 model IDs: {/* moonshot-kimi-k2-ids:start */} +- `kimi-k2.5` - `kimi-k2-0905-preview` - `kimi-k2-turbo-preview` - `kimi-k2-thinking` @@ -39,9 +40,10 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl env: { MOONSHOT_API_KEY: "sk-..." }, agents: { defaults: { - model: { primary: "moonshot/kimi-k2-0905-preview" }, + model: { primary: "moonshot/kimi-k2.5" }, models: { // moonshot-kimi-k2-aliases:start + "moonshot/kimi-k2.5": { alias: "Kimi K2.5" }, "moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" }, "moonshot/kimi-k2-turbo-preview": { alias: "Kimi K2 Turbo" }, "moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" }, @@ -59,6 +61,15 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl api: "openai-completions", models: [ // moonshot-kimi-k2-models:start + { + id: "kimi-k2.5", + name: "Kimi K2.5", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 256000, + maxTokens: 8192 + }, { id: "kimi-k2-0905-preview", name: "Kimi K2 0905 Preview", diff --git a/src/agents/channel-tools.test.ts b/src/agents/channel-tools.test.ts new file mode 100644 index 000000000..05ec460a7 --- /dev/null +++ b/src/agents/channel-tools.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { MoltbotConfig } from "../config/config.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { defaultRuntime } from "../runtime.js"; +import { __testing, listAllChannelSupportedActions } from "./channel-tools.js"; + +describe("channel tools", () => { + const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); + + beforeEach(() => { + const plugin: ChannelPlugin = { + id: "test", + meta: { + id: "test", + label: "Test", + selectionLabel: "Test", + docsPath: "/channels/test", + blurb: "test plugin", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + actions: { + listActions: () => { + throw new Error("boom"); + }, + }, + }; + + __testing.resetLoggedListActionErrors(); + errorSpy.mockClear(); + setActivePluginRegistry(createTestRegistry([{ pluginId: "test", source: "test", plugin }])); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + errorSpy.mockClear(); + }); + + it("skips crashing plugins and logs once", () => { + const cfg = {} as MoltbotConfig; + expect(listAllChannelSupportedActions({ cfg })).toEqual([]); + expect(errorSpy).toHaveBeenCalledTimes(1); + + expect(listAllChannelSupportedActions({ cfg })).toEqual([]); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 437d326cb..27af3c5f9 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -1,8 +1,13 @@ import { getChannelDock } from "../channels/dock.js"; import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; import { normalizeAnyChannelId } from "../channels/registry.js"; -import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js"; +import type { + ChannelAgentTool, + ChannelMessageActionName, + ChannelPlugin, +} from "../channels/plugins/types.js"; import type { MoltbotConfig } from "../config/config.js"; +import { defaultRuntime } from "../runtime.js"; /** * Get the list of supported message actions for a specific channel. @@ -16,7 +21,7 @@ export function listChannelSupportedActions(params: { const plugin = getChannelPlugin(params.channel as Parameters[0]); if (!plugin?.actions?.listActions) return []; const cfg = params.cfg ?? ({} as MoltbotConfig); - return plugin.actions.listActions({ cfg }); + return runPluginListActions(plugin, cfg); } /** @@ -29,7 +34,7 @@ export function listAllChannelSupportedActions(params: { for (const plugin of listChannelPlugins()) { if (!plugin.actions?.listActions) continue; const cfg = params.cfg ?? ({} as MoltbotConfig); - const channelActions = plugin.actions.listActions({ cfg }); + const channelActions = runPluginListActions(plugin, cfg); for (const action of channelActions) { actions.add(action); } @@ -64,3 +69,35 @@ export function resolveChannelMessageToolHints(params: { .map((entry) => entry.trim()) .filter(Boolean); } + +const loggedListActionErrors = new Set(); + +function runPluginListActions( + plugin: ChannelPlugin, + cfg: MoltbotConfig, +): ChannelMessageActionName[] { + if (!plugin.actions?.listActions) return []; + try { + const listed = plugin.actions.listActions({ cfg }); + return Array.isArray(listed) ? listed : []; + } catch (err) { + logListActionsError(plugin.id, err); + return []; + } +} + +function logListActionsError(pluginId: string, err: unknown) { + const message = err instanceof Error ? err.message : String(err); + const key = `${pluginId}:${message}`; + if (loggedListActionErrors.has(key)) return; + loggedListActionErrors.add(key); + const stack = err instanceof Error && err.stack ? err.stack : null; + const details = stack ?? message; + defaultRuntime.error?.(`[channel-tools] ${pluginId}.actions.listActions failed: ${details}`); +} + +export const __testing = { + resetLoggedListActionErrors() { + loggedListActionErrors.clear(); + }, +}; diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 76f1c3acd..a176dac8a 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -17,7 +17,7 @@ import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; -const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; +const MINIMAX_API_BASE_URL = "https://api.minimax.chat/v1"; const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.1"; const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; @@ -31,7 +31,7 @@ const MINIMAX_API_COST = { }; const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; -const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview"; +const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5"; const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; const MOONSHOT_DEFAULT_COST = { @@ -244,7 +244,7 @@ export function normalizeProviders(params: { function buildMinimaxProvider(): ProviderConfig { return { baseUrl: MINIMAX_API_BASE_URL, - api: "anthropic-messages", + api: "openai-completions", models: [ { id: MINIMAX_DEFAULT_MODEL_ID, @@ -275,7 +275,7 @@ function buildMoonshotProvider(): ProviderConfig { models: [ { id: MOONSHOT_DEFAULT_MODEL_ID, - name: "Kimi K2 0905 Preview", + name: "Kimi K2.5", reasoning: false, input: ["text"], cost: MOONSHOT_DEFAULT_COST, 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/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 3ea06ce88..9ae14d38f 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -51,7 +51,8 @@ function isOpenAiProvider(provider?: string | null): boolean { function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean { if (modelApi === "anthropic-messages") return true; const normalized = normalizeProviderId(provider ?? ""); - return normalized === "anthropic" || normalized === "minimax"; + // MiniMax now uses openai-completions API, not anthropic-messages + return normalized === "anthropic"; } function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean { 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/cli/banner.ts b/src/cli/banner.ts index 0d9c435c8..e19433e11 100644 --- a/src/cli/banner.ts +++ b/src/cli/banner.ts @@ -65,12 +65,13 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {} } const LOBSTER_ASCII = [ - "░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀", - "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░", - "█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░", - "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░", - "░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░", - " 🦞 FRESH DAILY 🦞", + "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄", + "██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██", + "██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████", + "██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████", + "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", + " 🦞 FRESH DAILY 🦞 ", + " ", ]; export function formatCliBannerArt(options: BannerOptions = {}): string { diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 97ca4508a..e5684fbea 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -168,6 +168,11 @@ const entries: SubCliEntry[] = [ name: "pairing", description: "Pairing helpers", register: async (program) => { + // Initialize plugins before registering pairing CLI. + // The pairing CLI calls listPairingChannels() at registration time, + // which requires the plugin registry to be populated with channel plugins. + const { registerPluginCliCommands } = await import("../../plugins/cli.js"); + registerPluginCliCommands(program, await loadConfig()); const mod = await import("../pairing-cli.js"); mod.registerPairingCli(program); }, 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/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 03fe77a27..376555a39 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -64,12 +64,13 @@ export function randomToken(): string { export function printWizardHeader(runtime: RuntimeEnv) { const header = [ - "░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀", - "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░", - "█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░", - "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░", - "░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░", - " 🦞 FRESH DAILY 🦞", + "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄", + "██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██", + "██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████", + "██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████", + "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", + " 🦞 FRESH DAILY 🦞 ", + " ", ].join("\n"); runtime.log(header); } 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.outbound.ts b/src/discord/send.outbound.ts index a47d0f4f1..22b402ae3 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -13,7 +13,7 @@ import { createDiscordClient, normalizeDiscordPollInput, normalizeStickerIds, - parseRecipient, + parseAndResolveRecipient, resolveChannelId, sendDiscordMedia, sendDiscordText, @@ -49,7 +49,7 @@ export async function sendMessageDiscord( const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId); const textWithTables = convertMarkdownTables(text ?? "", tableMode); const { token, rest, request } = createDiscordClient(opts, cfg); - const recipient = parseRecipient(to); + const recipient = await parseAndResolveRecipient(to, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); let result: { id: string; channel_id: string } | { id: string | null; channel_id: string }; try { @@ -104,7 +104,7 @@ export async function sendStickerDiscord( ): Promise { const cfg = loadConfig(); const { rest, request } = createDiscordClient(opts, cfg); - const recipient = parseRecipient(to); + const recipient = await parseAndResolveRecipient(to, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); const content = opts.content?.trim(); const stickers = normalizeStickerIds(stickerIds); @@ -131,7 +131,7 @@ export async function sendPollDiscord( ): Promise { const cfg = loadConfig(); const { rest, request } = createDiscordClient(opts, cfg); - const recipient = parseRecipient(to); + const recipient = await parseAndResolveRecipient(to, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); const content = opts.content?.trim(); const payload = normalizeDiscordPollInput(poll); diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index 4919be29d..e247300ee 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -13,7 +13,7 @@ import type { ChunkMode } from "../auto-reply/chunk.js"; import { chunkDiscordTextWithMode } from "./chunk.js"; import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js"; import { DiscordSendError } from "./send.types.js"; -import { parseDiscordTarget } from "./targets.js"; +import { parseDiscordTarget, resolveDiscordTarget } from "./targets.js"; import { normalizeDiscordToken } from "./token.js"; const DISCORD_TEXT_LIMIT = 2000; @@ -101,6 +101,51 @@ function parseRecipient(raw: string): DiscordRecipient { return { kind: target.kind, id: target.id }; } +/** + * Parse and resolve Discord recipient, including username lookup. + * This enables sending DMs by username (e.g., "john.doe") by querying + * the Discord directory to resolve usernames to user IDs. + * + * @param raw - The recipient string (username, ID, or known format) + * @param accountId - Discord account ID to use for directory lookup + * @returns Parsed DiscordRecipient with resolved user ID if applicable + */ +export async function parseAndResolveRecipient( + raw: string, + accountId?: string, +): Promise { + const cfg = loadConfig(); + const accountInfo = resolveDiscordAccount({ cfg, accountId }); + + // First try to resolve using directory lookup (handles usernames) + 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, parseOptions); + + if (!parsed) { + throw new Error("Recipient is required for Discord sends"); + } + + return { kind: parsed.kind, id: parsed.id }; +} + function normalizeStickerIds(raw: string[]) { const ids = raw.map((entry) => entry.trim()).filter(Boolean); if (ids.length === 0) { diff --git a/src/discord/targets.test.ts b/src/discord/targets.test.ts index 3eee1eb1e..7ac39450b 100644 --- a/src/discord/targets.test.ts +++ b/src/discord/targets.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; import { normalizeDiscordMessagingTarget } from "../channels/plugins/normalize/discord.js"; -import { parseDiscordTarget, resolveDiscordChannelId } from "./targets.js"; +import { listDiscordDirectoryPeersLive } from "./directory-live.js"; +import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js"; + +vi.mock("./directory-live.js", () => ({ + listDiscordDirectoryPeersLive: vi.fn(), +})); describe("parseDiscordTarget", () => { it("parses user mention and prefixes", () => { @@ -68,6 +74,38 @@ describe("resolveDiscordChannelId", () => { }); }); +describe("resolveDiscordTarget", () => { + const cfg = { channels: { discord: {} } } as ClawdbotConfig; + const listPeers = vi.mocked(listDiscordDirectoryPeersLive); + + beforeEach(() => { + listPeers.mockReset(); + }); + + it("returns a resolved user for usernames", async () => { + listPeers.mockResolvedValueOnce([{ kind: "user", id: "user:999", name: "Jane" } as const]); + + await expect( + resolveDiscordTarget("jane", { cfg, accountId: "default" }), + ).resolves.toMatchObject({ kind: "user", id: "999", normalized: "user:999" }); + }); + + it("falls back to parsing when lookup misses", async () => { + listPeers.mockResolvedValueOnce([]); + await expect( + resolveDiscordTarget("general", { cfg, accountId: "default" }), + ).resolves.toMatchObject({ kind: "channel", id: "general" }); + }); + + it("does not call directory lookup for explicit user ids", async () => { + listPeers.mockResolvedValueOnce([]); + await expect( + resolveDiscordTarget("user:123", { cfg, accountId: "default" }), + ).resolves.toMatchObject({ kind: "user", id: "123" }); + expect(listPeers).not.toHaveBeenCalled(); + }); +}); + describe("normalizeDiscordMessagingTarget", () => { it("defaults raw numeric ids to channels", () => { expect(normalizeDiscordMessagingTarget("123")).toBe("channel:123"); diff --git a/src/discord/targets.ts b/src/discord/targets.ts index 3a3c93ec8..5ea6f5b1b 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -7,6 +7,10 @@ import { type MessagingTargetParseOptions, } from "../channels/targets.js"; +import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; + +import { listDiscordDirectoryPeersLive } from "./directory-live.js"; + export type DiscordTargetKind = MessagingTargetKind; export type DiscordTarget = MessagingTarget; @@ -60,3 +64,93 @@ export function resolveDiscordChannelId(raw: string): string { const target = parseDiscordTarget(raw, { defaultKind: "channel" }); return requireTargetKind({ platform: "Discord", target, kind: "channel" }); } + +/** + * Resolve a Discord username to user ID using the directory lookup. + * This enables sending DMs by username instead of requiring explicit user IDs. + * + * @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; + + 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 { + const directoryEntries = await listDiscordDirectoryPeersLive({ + ...options, + query: trimmed, + limit: 1, + }); + + const match = directoryEntries[0]; + if (match && match.kind === "user") { + // Extract user ID from the directory entry (format: "user:") + const userId = match.id.replace(/^user:/, ""); + return buildMessagingTarget("user", userId, trimmed); + } + } 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, 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; +} + +/** + * 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; +} diff --git a/src/hooks/bundled/session-memory/HOOK.md b/src/hooks/bundled/session-memory/HOOK.md index 2a635a645..41223eb05 100644 --- a/src/hooks/bundled/session-memory/HOOK.md +++ b/src/hooks/bundled/session-memory/HOOK.md @@ -23,7 +23,7 @@ Automatically saves session context to your workspace memory when you issue the When you run `/new` to start a fresh session: 1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript -2. **Extracts conversation** - Reads the last 15 lines of conversation from the session +2. **Extracts conversation** - Reads the last N user/assistant messages from the session (default: 15, configurable) 3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content 4. **Saves to memory** - Creates a new file at `/memory/YYYY-MM-DD-slug.md` 5. **Sends confirmation** - Notifies you with the file path @@ -57,7 +57,30 @@ The hook uses your configured LLM provider to generate slugs, so it works with a ## Configuration -No additional configuration required. The hook automatically: +The hook supports optional configuration: + +| Option | Type | Default | Description | +| ---------- | ------ | ------- | --------------------------------------------------------------- | +| `messages` | number | 15 | Number of user/assistant messages to include in the memory file | + +Example configuration: + +```json +{ + "hooks": { + "internal": { + "entries": { + "session-memory": { + "enabled": true, + "messages": 25 + } + } + } + } +} +``` + +The hook automatically: - Uses your workspace directory (`~/clawd` by default) - Uses your configured LLM for slug generation diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts new file mode 100644 index 000000000..525e21059 --- /dev/null +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -0,0 +1,379 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import handler from "./handler.js"; +import { createHookEvent } from "../../hooks.js"; +import type { ClawdbotConfig } from "../../../config/config.js"; +import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js"; + +/** + * Create a mock session JSONL file with various entry types + */ +function createMockSessionContent( + entries: Array<{ role: string; content: string } | { type: string }>, +): string { + return entries + .map((entry) => { + if ("role" in entry) { + return JSON.stringify({ + type: "message", + message: { + role: entry.role, + content: entry.content, + }, + }); + } + // Non-message entry (tool call, system, etc.) + return JSON.stringify(entry); + }) + .join("\n"); +} + +describe("session-memory hook", () => { + it("skips non-command events", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + + const event = createHookEvent("agent", "bootstrap", "agent:main:main", { + workspaceDir: tempDir, + }); + + await handler(event); + + // Memory directory should not be created for non-command events + const memoryDir = path.join(tempDir, "memory"); + await expect(fs.access(memoryDir)).rejects.toThrow(); + }); + + it("skips commands other than new", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + + const event = createHookEvent("command", "help", "agent:main:main", { + workspaceDir: tempDir, + }); + + await handler(event); + + // Memory directory should not be created for other commands + const memoryDir = path.join(tempDir, "memory"); + await expect(fs.access(memoryDir)).rejects.toThrow(); + }); + + it("creates memory file with session content on /new command", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Create a mock session file with user/assistant messages + const sessionContent = createMockSessionContent([ + { role: "user", content: "Hello there" }, + { role: "assistant", content: "Hi! How can I help?" }, + { role: "user", content: "What is 2+2?" }, + { role: "assistant", content: "2+2 equals 4" }, + ]); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + // Memory file should be created + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + expect(files.length).toBe(1); + + // Read the memory file and verify content + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + expect(memoryContent).toContain("user: Hello there"); + expect(memoryContent).toContain("assistant: Hi! How can I help?"); + expect(memoryContent).toContain("user: What is 2+2?"); + expect(memoryContent).toContain("assistant: 2+2 equals 4"); + }); + + it("filters out non-message entries (tool calls, system)", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Create session with mixed entry types + const sessionContent = createMockSessionContent([ + { role: "user", content: "Hello" }, + { type: "tool_use", tool: "search", input: "test" }, + { role: "assistant", content: "World" }, + { type: "tool_result", result: "found it" }, + { role: "user", content: "Thanks" }, + ]); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + + // Only user/assistant messages should be present + expect(memoryContent).toContain("user: Hello"); + expect(memoryContent).toContain("assistant: World"); + expect(memoryContent).toContain("user: Thanks"); + // Tool entries should not appear + expect(memoryContent).not.toContain("tool_use"); + expect(memoryContent).not.toContain("tool_result"); + expect(memoryContent).not.toContain("search"); + }); + + it("filters out command messages starting with /", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const sessionContent = createMockSessionContent([ + { role: "user", content: "/help" }, + { role: "assistant", content: "Here is help info" }, + { role: "user", content: "Normal message" }, + { role: "user", content: "/new" }, + ]); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + + // Command messages should be filtered out + expect(memoryContent).not.toContain("/help"); + expect(memoryContent).not.toContain("/new"); + // Normal messages should be present + expect(memoryContent).toContain("assistant: Here is help info"); + expect(memoryContent).toContain("user: Normal message"); + }); + + it("respects custom messages config (limits to N messages)", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Create 10 messages + const entries = []; + for (let i = 1; i <= 10; i++) { + entries.push({ role: "user", content: `Message ${i}` }); + } + const sessionContent = createMockSessionContent(entries); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + // Configure to only include last 3 messages + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + hooks: { + internal: { + entries: { + "session-memory": { enabled: true, messages: 3 }, + }, + }, + }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + + // Only last 3 messages should be present + expect(memoryContent).not.toContain("user: Message 1\n"); + expect(memoryContent).not.toContain("user: Message 7\n"); + expect(memoryContent).toContain("user: Message 8"); + expect(memoryContent).toContain("user: Message 9"); + expect(memoryContent).toContain("user: Message 10"); + }); + + it("filters messages before slicing (fix for #2681)", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Create session with many tool entries interspersed with messages + // This tests that we filter FIRST, then slice - not the other way around + const entries = [ + { role: "user", content: "First message" }, + { type: "tool_use", tool: "test1" }, + { type: "tool_result", result: "result1" }, + { role: "assistant", content: "Second message" }, + { type: "tool_use", tool: "test2" }, + { type: "tool_result", result: "result2" }, + { role: "user", content: "Third message" }, + { type: "tool_use", tool: "test3" }, + { type: "tool_result", result: "result3" }, + { role: "assistant", content: "Fourth message" }, + ]; + const sessionContent = createMockSessionContent(entries); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + // Request 3 messages - if we sliced first, we'd only get 1-2 messages + // because the last 3 lines include tool entries + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + hooks: { + internal: { + entries: { + "session-memory": { enabled: true, messages: 3 }, + }, + }, + }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + + // Should have exactly 3 user/assistant messages (the last 3) + expect(memoryContent).not.toContain("First message"); + expect(memoryContent).toContain("user: Third message"); + expect(memoryContent).toContain("assistant: Second message"); + expect(memoryContent).toContain("assistant: Fourth message"); + }); + + it("handles empty session files gracefully", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: "", + }); + + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + // Should not throw + await handler(event); + + // Memory file should still be created with metadata + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + expect(files.length).toBe(1); + }); + + it("handles session files with fewer messages than requested", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Only 2 messages but requesting 15 (default) + const sessionContent = createMockSessionContent([ + { role: "user", content: "Only message 1" }, + { role: "assistant", content: "Only message 2" }, + ]); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + + // Both messages should be included + expect(memoryContent).toContain("user: Only message 1"); + expect(memoryContent).toContain("assistant: Only message 2"); + }); +}); diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index c087d73e8..5b5a69c9c 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -8,25 +8,27 @@ import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; +import { fileURLToPath } from "node:url"; import type { MoltbotConfig } from "../../../config/config.js"; import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; +import { resolveHookConfig } from "../../config.js"; import type { HookHandler } from "../../hooks.js"; /** * Read recent messages from session file for slug generation */ -async function getRecentSessionContent(sessionFilePath: string): Promise { +async function getRecentSessionContent( + sessionFilePath: string, + messageCount: number = 15, +): Promise { try { const content = await fs.readFile(sessionFilePath, "utf-8"); const lines = content.trim().split("\n"); - // Get last 15 lines (recent conversation) - const recentLines = lines.slice(-15); - - // Parse JSONL and extract messages - const messages: string[] = []; - for (const line of recentLines) { + // Parse JSONL and extract user/assistant messages first + const allMessages: string[] = []; + for (const line of lines) { try { const entry = JSON.parse(line); // Session files have entries with type="message" containing a nested message object @@ -39,7 +41,7 @@ async function getRecentSessionContent(sessionFilePath: string): Promise c.type === "text")?.text : msg.content; if (text && !text.startsWith("/")) { - messages.push(`${role}: ${text}`); + allMessages.push(`${role}: ${text}`); } } } @@ -48,7 +50,9 @@ async function getRecentSessionContent(sessionFilePath: string): Promise { const sessionFile = currentSessionFile || undefined; + // Read message count from hook config (default: 15) + const hookConfig = resolveHookConfig(cfg, "session-memory"); + const messageCount = + typeof hookConfig?.messages === "number" && hookConfig.messages > 0 + ? hookConfig.messages + : 15; + let slug: string | null = null; let sessionContent: string | null = null; if (sessionFile) { // Get recent conversation content - sessionContent = await getRecentSessionContent(sessionFile); + sessionContent = await getRecentSessionContent(sessionFile, messageCount); console.log("[session-memory] sessionContent length:", sessionContent?.length || 0); if (sessionContent && cfg) { @@ -106,10 +117,7 @@ const saveSessionToMemory: HookHandler = async (event) => { // Dynamically import the LLM slug generator (avoids module caching issues) // When compiled, handler is at dist/hooks/bundled/session-memory/handler.js // Going up ../.. puts us at dist/hooks/, so just add llm-slug-generator.js - const moltbotRoot = path.resolve( - path.dirname(import.meta.url.replace("file://", "")), - "../..", - ); + const moltbotRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const slugGenPath = path.join(moltbotRoot, "llm-slug-generator.js"); const { generateSlugViaLLM } = await import(slugGenPath); 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/media/mime.test.ts b/src/media/mime.test.ts index a3c2a35d8..92325a62e 100644 --- a/src/media/mime.test.ts +++ b/src/media/mime.test.ts @@ -1,7 +1,7 @@ import JSZip from "jszip"; import { describe, expect, it } from "vitest"; -import { detectMime, imageMimeFromFormat } from "./mime.js"; +import { detectMime, extensionForMime, imageMimeFromFormat } from "./mime.js"; async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promise { const zip = new JSZip(); @@ -53,3 +53,47 @@ describe("mime detection", () => { expect(mime).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); }); }); + +describe("extensionForMime", () => { + it("maps image MIME types to extensions", () => { + expect(extensionForMime("image/jpeg")).toBe(".jpg"); + expect(extensionForMime("image/png")).toBe(".png"); + expect(extensionForMime("image/webp")).toBe(".webp"); + expect(extensionForMime("image/gif")).toBe(".gif"); + expect(extensionForMime("image/heic")).toBe(".heic"); + }); + + it("maps audio MIME types to extensions", () => { + expect(extensionForMime("audio/mpeg")).toBe(".mp3"); + expect(extensionForMime("audio/ogg")).toBe(".ogg"); + expect(extensionForMime("audio/x-m4a")).toBe(".m4a"); + expect(extensionForMime("audio/mp4")).toBe(".m4a"); + }); + + it("maps video MIME types to extensions", () => { + expect(extensionForMime("video/mp4")).toBe(".mp4"); + expect(extensionForMime("video/quicktime")).toBe(".mov"); + }); + + it("maps document MIME types to extensions", () => { + expect(extensionForMime("application/pdf")).toBe(".pdf"); + expect(extensionForMime("text/plain")).toBe(".txt"); + expect(extensionForMime("text/markdown")).toBe(".md"); + }); + + it("handles case insensitivity", () => { + expect(extensionForMime("IMAGE/JPEG")).toBe(".jpg"); + expect(extensionForMime("Audio/X-M4A")).toBe(".m4a"); + expect(extensionForMime("Video/QuickTime")).toBe(".mov"); + }); + + it("returns undefined for unknown MIME types", () => { + expect(extensionForMime("video/unknown")).toBeUndefined(); + expect(extensionForMime("application/x-custom")).toBeUndefined(); + }); + + it("returns undefined for null or undefined input", () => { + expect(extensionForMime(null)).toBeUndefined(); + expect(extensionForMime(undefined)).toBeUndefined(); + }); +}); diff --git a/src/media/mime.ts b/src/media/mime.ts index 79677b1cb..c50e9152c 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -13,7 +13,10 @@ const EXT_BY_MIME: Record = { "image/gif": ".gif", "audio/ogg": ".ogg", "audio/mpeg": ".mp3", + "audio/x-m4a": ".m4a", + "audio/mp4": ".m4a", "video/mp4": ".mp4", + "video/quicktime": ".mov", "application/pdf": ".pdf", "application/json": ".json", "application/zip": ".zip", 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 4cca71d14..3415ea927 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -322,7 +322,7 @@ export const registerTelegramNativeCommands = ({ ]; if (allCommands.length > 0) { - void withTelegramApiErrorLogging({ + withTelegramApiErrorLogging({ operation: "setMyCommands", runtime, fn: () => bot.api.setMyCommands(allCommands), @@ -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, @@ -576,7 +581,7 @@ export const registerTelegramNativeCommands = ({ } } } else if (nativeDisabledExplicit) { - void withTelegramApiErrorLogging({ + withTelegramApiErrorLogging({ operation: "setMyCommands", runtime, fn: () => bot.api.setMyCommands([]), 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/telegram/monitor.ts b/src/telegram/monitor.ts index 59df7098d..c3b3a5a2f 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -74,6 +74,23 @@ const isGetUpdatesConflict = (err: unknown) => { return haystack.includes("getupdates"); }; +const NETWORK_ERROR_SNIPPETS = [ + "fetch failed", + "network", + "timeout", + "socket", + "econnreset", + "econnrefused", + "undici", +]; + +const isNetworkRelatedError = (err: unknown) => { + if (!err) return false; + const message = formatErrorMessage(err).toLowerCase(); + if (!message) return false; + return NETWORK_ERROR_SNIPPETS.some((snippet) => message.includes(snippet)); +}; + export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const cfg = opts.config ?? loadConfig(); const account = resolveTelegramAccount({ @@ -158,7 +175,8 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { } const isConflict = isGetUpdatesConflict(err); const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" }); - if (!isConflict && !isRecoverable) { + const isNetworkError = isNetworkRelatedError(err); + if (!isConflict && !isRecoverable && !isNetworkError) { throw err; } restartAttempts += 1; diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts index ae42cbb97..db582355f 100644 --- a/src/telegram/network-errors.test.ts +++ b/src/telegram/network-errors.test.ts @@ -8,6 +8,13 @@ describe("isRecoverableTelegramNetworkError", () => { expect(isRecoverableTelegramNetworkError(err)).toBe(true); }); + it("detects additional recoverable error codes", () => { + const aborted = Object.assign(new Error("aborted"), { code: "ECONNABORTED" }); + const network = Object.assign(new Error("network"), { code: "ERR_NETWORK" }); + expect(isRecoverableTelegramNetworkError(aborted)).toBe(true); + expect(isRecoverableTelegramNetworkError(network)).toBe(true); + }); + it("detects AbortError names", () => { const err = Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); expect(isRecoverableTelegramNetworkError(err)).toBe(true); @@ -19,6 +26,11 @@ describe("isRecoverableTelegramNetworkError", () => { expect(isRecoverableTelegramNetworkError(err)).toBe(true); }); + it("detects expanded message patterns", () => { + expect(isRecoverableTelegramNetworkError(new Error("TypeError: fetch failed"))).toBe(true); + expect(isRecoverableTelegramNetworkError(new Error("Undici: socket failure"))).toBe(true); + }); + it("skips message matches for send context", () => { const err = new TypeError("fetch failed"); expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false); diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts index 70cd81994..bb3432432 100644 --- a/src/telegram/network-errors.ts +++ b/src/telegram/network-errors.ts @@ -15,6 +15,8 @@ const RECOVERABLE_ERROR_CODES = new Set([ "UND_ERR_BODY_TIMEOUT", "UND_ERR_SOCKET", "UND_ERR_ABORTED", + "ECONNABORTED", + "ERR_NETWORK", ]); const RECOVERABLE_ERROR_NAMES = new Set([ @@ -27,6 +29,8 @@ const RECOVERABLE_ERROR_NAMES = new Set([ const RECOVERABLE_MESSAGE_SNIPPETS = [ "fetch failed", + "typeerror: fetch failed", + "undici", "network error", "network request", "client network socket disconnected", 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, diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a088c33ff..422af6863 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -42,6 +42,7 @@ import { renderNodes } from "./views/nodes"; import { renderOverview } from "./views/overview"; import { renderSessions } from "./views/sessions"; import { renderExecApprovalPrompt } from "./views/exec-approval"; +import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation"; import { approveDevicePairing, loadDevices, @@ -578,6 +579,7 @@ export function renderApp(state: AppViewState) { : nothing} ${renderExecApprovalPrompt(state)} + ${renderGatewayUrlConfirmation(state)} `; } diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index e269742b2..7e3ab29cf 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -33,6 +33,7 @@ type SettingsHost = { basePath: string; themeMedia: MediaQueryList | null; themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; + pendingGatewayUrl?: string | null; }; export function applySettings(host: SettingsHost, next: UiSettings) { @@ -98,7 +99,7 @@ export function applySettingsFromUrl(host: SettingsHost) { if (gatewayUrlRaw != null) { const gatewayUrl = gatewayUrlRaw.trim(); if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) { - applySettings(host, { ...host.settings, gatewayUrl }); + host.pendingGatewayUrl = gatewayUrl; } params.delete("gatewayUrl"); shouldCleanUrl = true; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 069465e32..f58656bfb 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -73,6 +73,7 @@ export type AppViewState = { execApprovalQueue: ExecApprovalRequest[]; execApprovalBusy: boolean; execApprovalError: string | null; + pendingGatewayUrl: string | null; configLoading: boolean; configRaw: string; configRawOriginal: string; @@ -165,6 +166,8 @@ export type AppViewState = { handleNostrProfileImport: () => Promise; handleNostrProfileToggleAdvanced: () => void; handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise; + handleGatewayUrlConfirm: () => void; + handleGatewayUrlCancel: () => void; handleConfigLoad: () => Promise; handleConfigSave: () => Promise; handleConfigApply: () => Promise; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index d23e543cd..26f4a5836 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -152,6 +152,7 @@ export class MoltbotApp extends LitElement { @state() execApprovalQueue: ExecApprovalRequest[] = []; @state() execApprovalBusy = false; @state() execApprovalError: string | null = null; + @state() pendingGatewayUrl: string | null = null; @state() configLoading = false; @state() configRaw = "{\n}\n"; @@ -448,6 +449,21 @@ export class MoltbotApp extends LitElement { } } + handleGatewayUrlConfirm() { + const nextGatewayUrl = this.pendingGatewayUrl; + if (!nextGatewayUrl) return; + this.pendingGatewayUrl = null; + applySettingsInternal( + this as unknown as Parameters[0], + { ...this.settings, gatewayUrl: nextGatewayUrl }, + ); + this.connect(); + } + + handleGatewayUrlCancel() { + this.pendingGatewayUrl = null; + } + // Sidebar handlers for tool output viewing handleOpenSidebar(content: string) { if (this.sidebarCloseTimer != null) { diff --git a/ui/src/ui/views/gateway-url-confirmation.ts b/ui/src/ui/views/gateway-url-confirmation.ts new file mode 100644 index 000000000..7d48c4367 --- /dev/null +++ b/ui/src/ui/views/gateway-url-confirmation.ts @@ -0,0 +1,39 @@ +import { html, nothing } from "lit"; + +import type { AppViewState } from "../app-view-state"; + +export function renderGatewayUrlConfirmation(state: AppViewState) { + const { pendingGatewayUrl } = state; + if (!pendingGatewayUrl) return nothing; + + return html` + + `; +}