This commit is contained in:
Rory Roy Ulloa 2026-01-28 17:15:37 -06:00
commit f98a59cff6
54 changed files with 1181 additions and 94 deletions

View File

@ -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. Were keeping the core lean on skills, so Im closing this out.",
},
{
label: "r: support",
close: true,
message:
"Please use our support server https://molt.bot/discord and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.molt.bot/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
},
{
label: "r: third-party-extension",
close: true,
message:
"This would be better made as a third-party extension with our SDK that you maintain yourself. Docs: https://docs.molt.bot/plugin.",
},
];
const labelName = context.payload.label?.name;

View File

@ -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.

View File

@ -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.

View File

@ -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" }]
}
}
}

View File

@ -11,7 +11,8 @@ Use `session.dmScope` to control how **direct messages** are grouped:
- `main` (default): all DMs share the main session for continuity.
- `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:<agentId>:dm:<peerId>`.
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
- `per-account-channel-peer`: `agent:<agentId>:<channel>:<accountId>:dm:<peerId>` (accountId defaults to `default`).
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
@ -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"]
},

View File

@ -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).

View File

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

View File

@ -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",

View File

@ -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);
});
});

View File

@ -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<typeof getChannelPlugin>[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<string>();
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();
},
};

View File

@ -17,7 +17,7 @@ import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
type ModelsConfig = NonNullable<MoltbotConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[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,

View File

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

View File

@ -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<string, string>)?.Authorization)).toBe(
"Bearer minimax-test",

View File

@ -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 {

View File

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

View File

@ -65,12 +65,13 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
}
const LOBSTER_ASCII = [
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
" 🦞 FRESH DAILY 🦞",
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
"██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ",
" ",
];
export function formatCliBannerArt(options: BannerOptions = {}): string {

View File

@ -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);
},

View File

@ -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.`,
);
}
};

View File

@ -190,7 +190,7 @@ async function noteChannelPrimer(
"DM security: default is pairing; unknown DMs get a pairing code.",
`Approve with: ${formatCliCommand("moltbot pairing approve <channel> <code>")}`,
'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} <code>`)}`,
`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`,

View File

@ -64,12 +64,13 @@ export function randomToken(): string {
export function printWizardHeader(runtime: RuntimeEnv) {
const header = [
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
" 🦞 FRESH DAILY 🦞",
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
"██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ",
" ",
].join("\n");
runtime.log(header);
}

View File

@ -591,7 +591,7 @@ const FIELD_HELP: Record<string, string> = {
"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":

View File

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

View File

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

View File

@ -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<DiscordSendResult> {
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<DiscordSendResult> {
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);

View File

@ -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<DiscordRecipient> {
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) {

View File

@ -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");

View File

@ -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<MessagingTarget | undefined> {
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:<id>")
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;
}

View File

@ -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 `<workspace>/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

View File

@ -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");
});
});

View File

@ -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<string | null> {
async function getRecentSessionContent(
sessionFilePath: string,
messageCount: number = 15,
): Promise<string | null> {
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<string
? msg.content.find((c: any) => 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<string
}
}
return messages.join("\n");
// Then slice to get exactly messageCount messages
const recentMessages = allMessages.slice(-messageCount);
return recentMessages.join("\n");
} catch {
return null;
}
@ -93,12 +97,19 @@ const saveSessionToMemory: HookHandler = async (event) => {
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);

View File

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

View File

@ -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<Buffer> {
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();
});
});

View File

@ -13,7 +13,10 @@ const EXT_BY_MIME: Record<string, string> = {
"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",

View File

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

View File

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

View File

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

View File

@ -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.',
});
}
};

View File

@ -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<string, unknown>) =>
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);
});
});

View File

@ -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,

View File

@ -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([]),

View File

@ -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(

View File

@ -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(

View File

@ -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) })

View File

@ -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();

View File

@ -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;
}
/**

View File

@ -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;

View File

@ -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);

View File

@ -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",

View File

@ -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,

View File

@ -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}
</main>
${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)}
</div>
`;
}

View File

@ -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;

View File

@ -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<void>;
handleNostrProfileToggleAdvanced: () => void;
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
handleGatewayUrlConfirm: () => void;
handleGatewayUrlCancel: () => void;
handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>;
handleConfigApply: () => Promise<void>;

View File

@ -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<typeof applySettingsInternal>[0],
{ ...this.settings, gatewayUrl: nextGatewayUrl },
);
this.connect();
}
handleGatewayUrlCancel() {
this.pendingGatewayUrl = null;
}
// Sidebar handlers for tool output viewing
handleOpenSidebar(content: string) {
if (this.sidebarCloseTimer != null) {

View File

@ -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`
<div class="exec-approval-overlay" role="dialog" aria-modal="true" aria-live="polite">
<div class="exec-approval-card">
<div class="exec-approval-header">
<div>
<div class="exec-approval-title">Change Gateway URL</div>
<div class="exec-approval-sub">This will reconnect to a different gateway server</div>
</div>
</div>
<div class="exec-approval-command mono">${pendingGatewayUrl}</div>
<div class="callout danger" style="margin-top: 12px;">
Only confirm if you trust this URL. Malicious URLs can compromise your system.
</div>
<div class="exec-approval-actions">
<button
class="btn primary"
@click=${() => state.handleGatewayUrlConfirm()}
>
Confirm
</button>
<button
class="btn"
@click=${() => state.handleGatewayUrlCancel()}
>
Cancel
</button>
</div>
</div>
</div>
`;
}