Merge branch 'main' into nanogpt

This commit is contained in:
0xGingi authored and GitHub committed 2026-01-28 05:15:36 -05:00
commit 124e0038d6
642 files changed
+2988 -979

No files matched your search

+53
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);
});
});
+40 -3
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();
},
};
+4 -4
View File
@@ -22,7 +22,7 @@ import {
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;
@@ -36,7 +36,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 = {
@@ -249,7 +249,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,
@@ -280,7 +280,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,
@@ -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");
@@ -31,6 +31,7 @@ describe("classifyFailoverReason", () => {
"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels",
),
).toBeNull();
expect(classifyFailoverReason("image exceeds 5 MB maximum")).toBeNull();
});
it("classifies OpenAI usage limit errors as rate_limit", () => {
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { parseImageSizeError } from "./pi-embedded-helpers.js";
describe("parseImageSizeError", () => {
it("parses max MB values from error text", () => {
expect(parseImageSizeError("image exceeds 5 MB maximum")?.maxMb).toBe(5);
expect(parseImageSizeError("Image exceeds 5.5 MB limit")?.maxMb).toBe(5.5);
});
it("returns null for unrelated errors", () => {
expect(parseImageSizeError("context overflow")).toBeNull();
});
});
+2
View File
@@ -23,12 +23,14 @@ export {
isFailoverAssistantError,
isFailoverErrorMessage,
isImageDimensionErrorMessage,
isImageSizeError,
isOverloadedErrorMessage,
isRawApiErrorPayload,
isRateLimitAssistantError,
isRateLimitErrorMessage,
isTimeoutErrorMessage,
parseImageDimensionError,
parseImageSizeError,
} from "./pi-embedded-helpers/errors.js";
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
+21
View File
@@ -401,6 +401,7 @@ const ERROR_PATTERNS = {
const IMAGE_DIMENSION_ERROR_RE =
/image dimensions exceed max allowed size for many-image requests:\s*(\d+)\s*pixels/i;
const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i;
const IMAGE_SIZE_ERROR_RE = /image exceeds\s*(\d+(?:\.\d+)?)\s*mb/i;
function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
if (!raw) return false;
@@ -467,6 +468,25 @@ export function isImageDimensionErrorMessage(raw: string): boolean {
return Boolean(parseImageDimensionError(raw));
}
export function parseImageSizeError(raw: string): {
maxMb?: number;
raw: string;
} | null {
if (!raw) return null;
const lower = raw.toLowerCase();
if (!lower.includes("image exceeds") || !lower.includes("mb")) return null;
const match = raw.match(IMAGE_SIZE_ERROR_RE);
return {
maxMb: match?.[1] ? Number.parseFloat(match[1]) : undefined,
raw,
};
}
export function isImageSizeError(errorMessage?: string): boolean {
if (!errorMessage) return false;
return Boolean(parseImageSizeError(errorMessage));
}
export function isCloudCodeAssistFormatError(raw: string): boolean {
return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format);
}
@@ -478,6 +498,7 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean
export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isImageDimensionErrorMessage(raw)) return null;
if (isImageSizeError(raw)) return null;
if (isRateLimitErrorMessage(raw)) return "rate_limit";
if (isOverloadedErrorMessage(raw)) return "rate_limit";
if (isCloudCodeAssistFormatError(raw)) return "format";
+107 -6
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { buildInlineProviderModels } from "./model.js";
vi.mock("@mariozechner/pi-coding-agent", () => ({
discoverAuthStorage: vi.fn(() => ({ mocked: true })),
discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })),
}));
import type { MoltbotConfig } from "../../config/config.js";
import { buildInlineProviderModels, resolveModel } from "./model.js";
const makeModel = (id: string) => ({
id,
@@ -15,15 +21,110 @@ const makeModel = (id: string) => ({
describe("buildInlineProviderModels", () => {
it("attaches provider ids to inline models", () => {
const providers = {
" alpha ": { models: [makeModel("alpha-model")] },
beta: { models: [makeModel("beta-model")] },
" alpha ": { baseUrl: "http://alpha.local", models: [makeModel("alpha-model")] },
beta: { baseUrl: "http://beta.local", models: [makeModel("beta-model")] },
};
const result = buildInlineProviderModels(providers);
expect(result).toEqual([
{ ...makeModel("alpha-model"), provider: "alpha" },
{ ...makeModel("beta-model"), provider: "beta" },
{
...makeModel("alpha-model"),
provider: "alpha",
baseUrl: "http://alpha.local",
api: undefined,
},
{
...makeModel("beta-model"),
provider: "beta",
baseUrl: "http://beta.local",
api: undefined,
},
]);
});
it("inherits baseUrl from provider when model does not specify it", () => {
const providers = {
custom: {
baseUrl: "http://localhost:8000",
models: [makeModel("custom-model")],
},
};
const result = buildInlineProviderModels(providers);
expect(result).toHaveLength(1);
expect(result[0].baseUrl).toBe("http://localhost:8000");
});
it("inherits api from provider when model does not specify it", () => {
const providers = {
custom: {
baseUrl: "http://localhost:8000",
api: "anthropic-messages",
models: [makeModel("custom-model")],
},
};
const result = buildInlineProviderModels(providers);
expect(result).toHaveLength(1);
expect(result[0].api).toBe("anthropic-messages");
});
it("model-level api takes precedence over provider-level api", () => {
const providers = {
custom: {
baseUrl: "http://localhost:8000",
api: "openai-responses",
models: [{ ...makeModel("custom-model"), api: "anthropic-messages" as const }],
},
};
const result = buildInlineProviderModels(providers);
expect(result).toHaveLength(1);
expect(result[0].api).toBe("anthropic-messages");
});
it("inherits both baseUrl and api from provider config", () => {
const providers = {
custom: {
baseUrl: "http://localhost:10000",
api: "anthropic-messages",
models: [makeModel("claude-opus-4.5")],
},
};
const result = buildInlineProviderModels(providers);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
provider: "custom",
baseUrl: "http://localhost:10000",
api: "anthropic-messages",
name: "claude-opus-4.5",
});
});
});
describe("resolveModel", () => {
it("includes provider baseUrl in fallback model", () => {
const cfg = {
models: {
providers: {
custom: {
baseUrl: "http://localhost:9000",
models: [],
},
},
},
} as MoltbotConfig;
const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg);
expect(result.model?.baseUrl).toBe("http://localhost:9000");
expect(result.model?.provider).toBe("custom");
expect(result.model?.id).toBe("missing-model");
});
});
+14 -3
View File
@@ -8,15 +8,25 @@ import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { normalizeModelCompat } from "../model-compat.js";
import { normalizeProviderId } from "../model-selection.js";
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
type InlineModelEntry = ModelDefinitionConfig & { provider: string; baseUrl?: string };
type InlineProviderConfig = {
baseUrl?: string;
api?: ModelDefinitionConfig["api"];
models?: ModelDefinitionConfig[];
};
export function buildInlineProviderModels(
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
providers: Record<string, InlineProviderConfig>,
): InlineModelEntry[] {
return Object.entries(providers).flatMap(([providerId, entry]) => {
const trimmed = providerId.trim();
if (!trimmed) return [];
return (entry?.models ?? []).map((model) => ({ ...model, provider: trimmed }));
return (entry?.models ?? []).map((model) => ({
...model,
provider: trimmed,
baseUrl: entry?.baseUrl,
api: model.api ?? entry?.api,
}));
});
}
@@ -72,6 +82,7 @@ export function resolveModel(
name: modelId,
api: providerCfg?.api ?? "openai-responses",
provider,
baseUrl: providerCfg?.baseUrl,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+29
View File
@@ -34,6 +34,7 @@ import {
isContextOverflowError,
isFailoverAssistantError,
isFailoverErrorMessage,
parseImageSizeError,
parseImageDimensionError,
isRateLimitAssistantError,
isTimeoutErrorMessage,
@@ -440,6 +441,34 @@ export async function runEmbeddedPiAgent(
},
};
}
// Handle image size errors with a user-friendly message (no retry needed)
const imageSizeError = parseImageSizeError(errorText);
if (imageSizeError) {
const maxMb = imageSizeError.maxMb;
const maxMbLabel =
typeof maxMb === "number" && Number.isFinite(maxMb) ? `${maxMb}` : null;
const maxBytesHint = maxMbLabel ? ` (max ${maxMbLabel}MB)` : "";
return {
payloads: [
{
text:
`Image too large for the model${maxBytesHint}. ` +
"Please compress or resize the image and try again.",
isError: true,
},
],
meta: {
durationMs: Date.now() - started,
agentMeta: {
sessionId: sessionIdUsed,
provider,
model: model.id,
},
systemPromptReport: attempt.systemPromptReport,
error: { kind: "image_size", message: errorText },
},
};
}
const promptFailoverReason = classifyFailoverReason(errorText);
if (promptFailoverReason && promptFailoverReason !== "timeout" && lastProfileId) {
await markAuthProfileFailure({
+1 -1
View File
@@ -20,7 +20,7 @@ export type EmbeddedPiRunMeta = {
aborted?: boolean;
systemPromptReport?: SessionSystemPromptReport;
error?: {
kind: "context_overflow" | "compaction_failure" | "role_ordering";
kind: "context_overflow" | "compaction_failure" | "role_ordering" | "image_size";
message: string;
};
/** Stop reason for the agent run (e.g., "completed", "tool_calls"). */
+1 -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",
+3
View File
@@ -60,6 +60,9 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
threadId: Type.Optional(Type.String()),
asVoice: Type.Optional(Type.Boolean()),
silent: Type.Optional(Type.Boolean()),
quoteText: Type.Optional(
Type.String({ description: "Quote text for Telegram reply_parameters" }),
),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
buttons: Type.Optional(
+25
View File
@@ -261,6 +261,31 @@ describe("handleTelegramAction", () => {
);
});
it("passes quoteText when provided", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as MoltbotConfig;
await handleTelegramAction(
{
action: "sendMessage",
to: "123456",
content: "Replying now",
replyToMessageId: 144,
quoteText: "The text you want to quote",
},
cfg,
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"123456",
"Replying now",
expect.objectContaining({
token: "tok",
replyToMessageId: 144,
quoteText: "The text you want to quote",
}),
);
});
it("allows media-only messages without content", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
+2
View File
@@ -165,6 +165,7 @@ export async function handleTelegramAction(
const messageThreadId = readNumberParam(params, "messageThreadId", {
integer: true,
});
const quoteText = readStringParam(params, "quoteText");
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
@@ -178,6 +179,7 @@ export async function handleTelegramAction(
buttons,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
quoteText: quoteText ?? undefined,
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
silent: typeof params.silent === "boolean" ? params.silent : undefined,
});
+2 -1
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 {
+1
View File
@@ -49,6 +49,7 @@ export type MsgContext = {
ReplyToIdFull?: string;
ReplyToBody?: string;
ReplyToSender?: string;
ReplyToIsQuote?: boolean;
ForwardedFrom?: string;
ForwardedFromType?: string;
ForwardedFromId?: string;
-1
View File
@@ -1 +0,0 @@
b6d3dea7c656c8a480059c32e954c4d39053ff79c4e9c69b38f4c04e3f0280d4
+2
View File
@@ -23,6 +23,7 @@ function readTelegramSendParams(params: Record<string, unknown>) {
const buttons = params.buttons;
const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined;
const silent = typeof params.silent === "boolean" ? params.silent : undefined;
const quoteText = readStringParam(params, "quoteText");
return {
to,
content,
@@ -32,6 +33,7 @@ function readTelegramSendParams(params: Record<string, unknown>) {
buttons,
asVoice,
silent,
quoteText: quoteText ?? undefined,
};
}
+4 -1
View File
@@ -56,8 +56,10 @@ export const telegramOutbound: ChannelOutboundAdapter = {
const replyToMessageId = parseReplyToMessageId(replyToId);
const messageThreadId = parseThreadId(threadId);
const telegramData = payload.channelData?.telegram as
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
| { buttons?: Array<Array<{ text: string; callback_data: string }>>; quoteText?: string }
| undefined;
const quoteText =
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
const text = payload.text ?? "";
const mediaUrls = payload.mediaUrls?.length
? payload.mediaUrls
@@ -69,6 +71,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
textMode: "html" as const,
messageThreadId,
replyToMessageId,
quoteText,
accountId: accountId ?? undefined,
};
+3
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 = {
+6 -6
View File
@@ -65,12 +65,12 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
}
const LOBSTER_ASCII = [
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
" 🦞 FRESH DAILY 🦞",
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
"██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ",
];
export function formatCliBannerArt(options: BannerOptions = {}): string {
+1 -1
View File
@@ -145,7 +145,7 @@ describe("daemon-cli coverage", () => {
CLAWDBOT_CONFIG_PATH: "/tmp/moltbot-daemon-state/moltbot.json",
CLAWDBOT_GATEWAY_PORT: "19001",
},
sourcePath: "/tmp/com.clawdbot.gateway.plist",
sourcePath: "/tmp/bot.molt.gateway.plist",
});
const { registerDaemonCli } = await import("./daemon-cli.js");
+5 -3
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { handleReset } from "../../commands/onboard-helpers.js";
import { CONFIG_PATH, writeConfigFile } from "../../config/config.js";
import { createConfigIO, writeConfigFile } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveUserPath, shortenHomePath } from "../../utils.js";
@@ -89,7 +89,9 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
await handleReset("full", workspace, defaultRuntime);
}
const configExists = fs.existsSync(CONFIG_PATH);
const io = createConfigIO();
const configPath = io.configPath;
const configExists = fs.existsSync(configPath);
if (!opts.reset && configExists) return;
await writeConfigFile({
@@ -117,6 +119,6 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
},
});
await ensureDevWorkspace(workspace);
defaultRuntime.log(`Dev config ready: ${shortenHomePath(CONFIG_PATH)}`);
defaultRuntime.log(`Dev config ready: ${shortenHomePath(configPath)}`);
defaultRuntime.log(`Dev workspace ready: ${shortenHomePath(resolveUserPath(workspace))}`);
}
+2 -2
View File
@@ -157,7 +157,8 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
const passwordRaw = toOptionString(opts.password);
const tokenRaw = toOptionString(opts.token);
const configExists = fs.existsSync(CONFIG_PATH);
const snapshot = await readConfigFileSnapshot().catch(() => null);
const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH);
const mode = cfg.gateway?.mode;
if (!opts.allowUnconfigured && mode !== "local") {
if (!configExists) {
@@ -187,7 +188,6 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
return;
}
const snapshot = await readConfigFileSnapshot().catch(() => null);
const miskeys = extractGatewayMiskeys(snapshot?.parsed);
const authConfig = {
...cfg.gateway?.auth,
+14 -11
View File
@@ -66,20 +66,23 @@ export async function maybeInstallDaemon(params: {
if (shouldInstall) {
let installError: string | null = null;
if (!params.daemonRuntime) {
if (GATEWAY_DAEMON_RUNTIME_OPTIONS.length === 1) {
daemonRuntime = GATEWAY_DAEMON_RUNTIME_OPTIONS[0]?.value ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
} else {
daemonRuntime = guardCancel(
await select({
message: "Gateway service runtime",
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
}),
params.runtime,
) as GatewayDaemonRuntime;
}
}
await withProgress(
{ label: "Gateway service", indeterminate: true, delayMs: 0 },
async (progress) => {
if (!params.daemonRuntime) {
daemonRuntime = guardCancel(
await select({
message: "Gateway service runtime",
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
}),
params.runtime,
) as GatewayDaemonRuntime;
}
progress.setLabel("Preparing Gateway service…");
const cfg = loadConfig();
+42 -1
View File
@@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import type { ZodIssue } from "zod";
import type { MoltbotConfig } from "../config/config.js";
@@ -12,6 +14,7 @@ import { formatCliCommand } from "../cli/command-format.js";
import { note } from "../terminal/note.js";
import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js";
import type { DoctorOptions } from "./doctor-prompter.js";
import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -117,12 +120,50 @@ function noteOpencodeProviderOverrides(cfg: MoltbotConfig) {
note(lines.join("\n"), "OpenCode Zen");
}
function hasExplicitConfigPath(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.MOLTBOT_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim());
}
function moveLegacyConfigFile(legacyPath: string, canonicalPath: string) {
fs.mkdirSync(path.dirname(canonicalPath), { recursive: true, mode: 0o700 });
try {
fs.renameSync(legacyPath, canonicalPath);
} catch {
fs.copyFileSync(legacyPath, canonicalPath);
fs.chmodSync(canonicalPath, 0o600);
try {
fs.unlinkSync(legacyPath);
} catch {
// Best-effort cleanup; we'll warn later if both files exist.
}
}
}
export async function loadAndMaybeMigrateDoctorConfig(params: {
options: DoctorOptions;
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
}) {
const shouldRepair = params.options.repair === true || params.options.yes === true;
const snapshot = await readConfigFileSnapshot();
const stateDirResult = await autoMigrateLegacyStateDir({ env: process.env });
if (stateDirResult.changes.length > 0) {
note(stateDirResult.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
}
if (stateDirResult.warnings.length > 0) {
note(stateDirResult.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
}
let snapshot = await readConfigFileSnapshot();
if (!hasExplicitConfigPath(process.env) && snapshot.exists) {
const basename = path.basename(snapshot.path);
if (basename === "clawdbot.json") {
const canonicalPath = path.join(path.dirname(snapshot.path), "moltbot.json");
if (!fs.existsSync(canonicalPath)) {
moveLegacyConfigFile(snapshot.path, canonicalPath);
note(`- Config: ${snapshot.path} → ${canonicalPath}`, "Doctor changes");
snapshot = await readConfigFileSnapshot();
}
}
}
const baseCfg = snapshot.config ?? {};
let cfg: MoltbotConfig = baseCfg;
let candidate = structuredClone(baseCfg) as MoltbotConfig;
+1 -1
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.`,
);
}
};
@@ -6,8 +6,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { MoltbotConfig } from "../config/config.js";
import {
autoMigrateLegacyStateDir,
autoMigrateLegacyState,
detectLegacyStateMigrations,
resetAutoMigrateLegacyStateDirForTest,
resetAutoMigrateLegacyStateForTest,
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
@@ -22,6 +24,7 @@ async function makeTempRoot() {
afterEach(async () => {
resetAutoMigrateLegacyStateForTest();
resetAutoMigrateLegacyStateDirForTest();
if (!tempRoot) return;
await fs.promises.rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
@@ -323,4 +326,53 @@ describe("doctor legacy state migrations", () => {
expect(store["main"]).toBeUndefined();
expect(store["agent:main:main"]?.sessionId).toBe("legacy");
});
it("auto-migrates legacy state dir to ~/.moltbot", async () => {
const root = await makeTempRoot();
const legacyDir = path.join(root, ".clawdbot");
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, "foo.txt"), "legacy", "utf-8");
const result = await autoMigrateLegacyStateDir({
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
const targetDir = path.join(root, ".moltbot");
expect(fs.existsSync(path.join(targetDir, "foo.txt"))).toBe(true);
const legacyStat = fs.lstatSync(legacyDir);
expect(legacyStat.isSymbolicLink()).toBe(true);
expect(fs.realpathSync(legacyDir)).toBe(fs.realpathSync(targetDir));
expect(result.migrated).toBe(true);
});
it("skips state dir migration when target exists", async () => {
const root = await makeTempRoot();
const legacyDir = path.join(root, ".clawdbot");
const targetDir = path.join(root, ".moltbot");
fs.mkdirSync(legacyDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
const result = await autoMigrateLegacyStateDir({
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings.length).toBeGreaterThan(0);
});
it("skips state dir migration when env override is set", async () => {
const root = await makeTempRoot();
const legacyDir = path.join(root, ".clawdbot");
fs.mkdirSync(legacyDir, { recursive: true });
const result = await autoMigrateLegacyStateDir({
env: { MOLTBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.skipped).toBe(true);
expect(result.migrated).toBe(false);
});
});
+2
View File
@@ -1,9 +1,11 @@
export type { LegacyStateDetection } from "../infra/state-migrations.js";
export {
autoMigrateLegacyStateDir,
autoMigrateLegacyAgentDir,
autoMigrateLegacyState,
detectLegacyStateMigrations,
migrateLegacyAgentDir,
resetAutoMigrateLegacyStateDirForTest,
resetAutoMigrateLegacyAgentDirForTest,
resetAutoMigrateLegacyStateForTest,
runLegacyStateMigrations,
@@ -292,6 +292,12 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
changes: [],
warnings: [],
}),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
@@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
changes: [],
warnings: [],
}),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
@@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
changes: [],
warnings: [],
}),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
@@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
changes: [],
warnings: [],
}),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
@@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
changes: [],
warnings: [],
}),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
+2 -2
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`,
+6 -6
View File
@@ -64,12 +64,12 @@ export function randomToken(): string {
export function printWizardHeader(runtime: RuntimeEnv) {
const header = [
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
" 🦞 FRESH DAILY 🦞",
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
"██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ",
].join("\n");
runtime.log(header);
}
+9 -7
View File
@@ -3,19 +3,19 @@ import fs from "node:fs/promises";
import JSON5 from "json5";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import { type MoltbotConfig, CONFIG_PATH, writeConfigFile } from "../config/config.js";
import { type MoltbotConfig, createConfigIO, writeConfigFile } from "../config/config.js";
import { formatConfigPath, logConfigUpdated } from "../config/logging.js";
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
async function readConfigFileRaw(): Promise<{
async function readConfigFileRaw(configPath: string): Promise<{
exists: boolean;
parsed: MoltbotConfig;
}> {
try {
const raw = await fs.readFile(CONFIG_PATH, "utf-8");
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
return { exists: true, parsed: parsed as MoltbotConfig };
@@ -35,7 +35,9 @@ export async function setupCommand(
? opts.workspace.trim()
: undefined;
const existingRaw = await readConfigFileRaw();
const io = createConfigIO();
const configPath = io.configPath;
const existingRaw = await readConfigFileRaw(configPath);
const cfg = existingRaw.parsed;
const defaults = cfg.agents?.defaults ?? {};
@@ -55,12 +57,12 @@ export async function setupCommand(
if (!existingRaw.exists || defaults.workspace !== workspace) {
await writeConfigFile(next);
if (!existingRaw.exists) {
runtime.log(`Wrote ${formatConfigPath()}`);
runtime.log(`Wrote ${formatConfigPath(configPath)}`);
} else {
logConfigUpdated(runtime, { suffix: "(set agents.defaults.workspace)" });
logConfigUpdated(runtime, { path: configPath, suffix: "(set agents.defaults.workspace)" });
}
} else {
runtime.log(`Config OK: ${formatConfigPath()}`);
runtime.log(`Config OK: ${formatConfigPath(configPath)}`);
}
const ws = await ensureAgentWorkspace({
+2 -2
View File
@@ -255,7 +255,7 @@ vi.mock("../daemon/service.js", () => ({
readRuntime: async () => ({ status: "running", pid: 1234 }),
readCommand: async () => ({
programArguments: ["node", "dist/entry.js", "gateway"],
sourcePath: "/tmp/Library/LaunchAgents/com.clawdbot.gateway.plist",
sourcePath: "/tmp/Library/LaunchAgents/bot.molt.gateway.plist",
}),
}),
}));
@@ -268,7 +268,7 @@ vi.mock("../daemon/node-service.js", () => ({
readRuntime: async () => ({ status: "running", pid: 4321 }),
readCommand: async () => ({
programArguments: ["node", "dist/entry.js", "node-host"],
sourcePath: "/tmp/Library/LaunchAgents/com.clawdbot.node.plist",
sourcePath: "/tmp/Library/LaunchAgents/bot.molt.node.plist",
}),
}),
}));
+2
View File
@@ -7,3 +7,5 @@ export const LEGACY_PLUGIN_MANIFEST_FILENAME = `${LEGACY_PROJECT_NAME}.plugin.js
export const LEGACY_CANVAS_HANDLER_NAME = `${LEGACY_PROJECT_NAME}CanvasA2UIAction` as const;
export const LEGACY_MACOS_APP_SOURCES_DIR = "apps/macos/Sources/Clawdbot" as const;
export const MACOS_APP_SOURCES_DIR = "apps/macos/Sources/Moltbot" as const;
+36 -2
View File
@@ -14,10 +14,15 @@ async function withTempHome(run: (home: string) => Promise<void>): Promise<void>
}
}
async function writeConfig(home: string, dirname: ".moltbot" | ".clawdbot", port: number) {
async function writeConfig(
home: string,
dirname: ".moltbot" | ".clawdbot",
port: number,
filename: "moltbot.json" | "clawdbot.json" = "moltbot.json",
) {
const dir = path.join(home, dirname);
await fs.mkdir(dir, { recursive: true });
const configPath = path.join(dir, "moltbot.json");
const configPath = path.join(dir, filename);
await fs.writeFile(configPath, JSON.stringify({ gateway: { port } }, null, 2));
return configPath;
}
@@ -51,6 +56,35 @@ describe("config io compat (new + legacy folders)", () => {
});
});
it("falls back to ~/.clawdbot/clawdbot.json when only legacy filename exists", async () => {
await withTempHome(async (home) => {
const legacyConfigPath = await writeConfig(home, ".clawdbot", 20002, "clawdbot.json");
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
});
expect(io.configPath).toBe(legacyConfigPath);
expect(io.loadConfig().gateway?.port).toBe(20002);
});
});
it("prefers moltbot.json over legacy filename in the same dir", async () => {
await withTempHome(async (home) => {
const preferred = await writeConfig(home, ".clawdbot", 20003, "moltbot.json");
await writeConfig(home, ".clawdbot", 20004, "clawdbot.json");
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
});
expect(io.configPath).toBe(preferred);
expect(io.loadConfig().gateway?.port).toBe(20003);
});
});
it("honors explicit legacy config path env override", async () => {
await withTempHome(async (home) => {
const newConfigPath = await writeConfig(home, ".moltbot", 19002);
+5 -6
View File
@@ -555,7 +555,8 @@ function clearConfigCache(): void {
}
export function loadConfig(): MoltbotConfig {
const configPath = resolveConfigPath();
const io = createConfigIO();
const configPath = io.configPath;
const now = Date.now();
if (shouldUseConfigCache(process.env)) {
const cached = configCache;
@@ -563,7 +564,7 @@ export function loadConfig(): MoltbotConfig {
return cached.config;
}
}
const config = createConfigIO({ configPath }).loadConfig();
const config = io.loadConfig();
if (shouldUseConfigCache(process.env)) {
const cacheMs = resolveConfigCacheMs(process.env);
if (cacheMs > 0) {
@@ -578,12 +579,10 @@ export function loadConfig(): MoltbotConfig {
}
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
return await createConfigIO({
configPath: resolveConfigPath(),
}).readConfigFileSnapshot();
return await createConfigIO().readConfigFileSnapshot();
}
export async function writeConfigFile(cfg: MoltbotConfig): Promise<void> {
clearConfigCache();
await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg);
await createConfigIO().writeConfigFile(cfg);
}
+92 -2
View File
@@ -1,8 +1,11 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
resolveDefaultConfigCandidates,
resolveConfigPath,
resolveOAuthDir,
resolveOAuthPath,
resolveStateDir,
@@ -47,6 +50,93 @@ describe("state + config path candidates", () => {
const home = "/home/test";
const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home);
expect(candidates[0]).toBe(path.join(home, ".moltbot", "moltbot.json"));
expect(candidates[1]).toBe(path.join(home, ".clawdbot", "moltbot.json"));
expect(candidates[1]).toBe(path.join(home, ".moltbot", "clawdbot.json"));
expect(candidates[2]).toBe(path.join(home, ".clawdbot", "moltbot.json"));
expect(candidates[3]).toBe(path.join(home, ".clawdbot", "clawdbot.json"));
});
it("prefers ~/.moltbot when it exists and legacy dir is missing", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-state-"));
try {
const newDir = path.join(root, ".moltbot");
await fs.mkdir(newDir, { recursive: true });
const resolved = resolveStateDir({} as NodeJS.ProcessEnv, () => root);
expect(resolved).toBe(newDir);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("CONFIG_PATH prefers existing legacy filename when present", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-config-"));
const previousHome = process.env.HOME;
const previousUserProfile = process.env.USERPROFILE;
const previousHomeDrive = process.env.HOMEDRIVE;
const previousHomePath = process.env.HOMEPATH;
const previousMoltbotConfig = process.env.MOLTBOT_CONFIG_PATH;
const previousClawdbotConfig = process.env.CLAWDBOT_CONFIG_PATH;
const previousMoltbotState = process.env.MOLTBOT_STATE_DIR;
const previousClawdbotState = process.env.CLAWDBOT_STATE_DIR;
try {
const legacyDir = path.join(root, ".clawdbot");
await fs.mkdir(legacyDir, { recursive: true });
const legacyPath = path.join(legacyDir, "clawdbot.json");
await fs.writeFile(legacyPath, "{}", "utf-8");
process.env.HOME = root;
if (process.platform === "win32") {
process.env.USERPROFILE = root;
const parsed = path.win32.parse(root);
process.env.HOMEDRIVE = parsed.root.replace(/\\$/, "");
process.env.HOMEPATH = root.slice(parsed.root.length - 1);
}
delete process.env.MOLTBOT_CONFIG_PATH;
delete process.env.CLAWDBOT_CONFIG_PATH;
delete process.env.MOLTBOT_STATE_DIR;
delete process.env.CLAWDBOT_STATE_DIR;
vi.resetModules();
const { CONFIG_PATH } = await import("./paths.js");
expect(CONFIG_PATH).toBe(legacyPath);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = previousUserProfile;
if (previousHomeDrive === undefined) delete process.env.HOMEDRIVE;
else process.env.HOMEDRIVE = previousHomeDrive;
if (previousHomePath === undefined) delete process.env.HOMEPATH;
else process.env.HOMEPATH = previousHomePath;
if (previousMoltbotConfig === undefined) delete process.env.MOLTBOT_CONFIG_PATH;
else process.env.MOLTBOT_CONFIG_PATH = previousMoltbotConfig;
if (previousClawdbotConfig === undefined) delete process.env.CLAWDBOT_CONFIG_PATH;
else process.env.CLAWDBOT_CONFIG_PATH = previousClawdbotConfig;
if (previousMoltbotState === undefined) delete process.env.MOLTBOT_STATE_DIR;
else process.env.MOLTBOT_STATE_DIR = previousMoltbotState;
if (previousClawdbotState === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previousClawdbotState;
await fs.rm(root, { recursive: true, force: true });
vi.resetModules();
}
});
it("respects state dir overrides when config is missing", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-config-override-"));
try {
const legacyDir = path.join(root, ".clawdbot");
await fs.mkdir(legacyDir, { recursive: true });
const legacyConfig = path.join(legacyDir, "moltbot.json");
await fs.writeFile(legacyConfig, "{}", "utf-8");
const overrideDir = path.join(root, "override");
const env = { MOLTBOT_STATE_DIR: overrideDir } as NodeJS.ProcessEnv;
const resolved = resolveConfigPath(env, overrideDir, () => root);
expect(resolved).toBe(path.join(overrideDir, "moltbot.json"));
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
+74 -3
View File
@@ -1,3 +1,4 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { MoltbotConfig } from "./types.js";
@@ -18,6 +19,7 @@ export const isNixMode = resolveIsNixMode();
const LEGACY_STATE_DIRNAME = ".clawdbot";
const NEW_STATE_DIRNAME = ".moltbot";
const CONFIG_FILENAME = "moltbot.json";
const LEGACY_CONFIG_FILENAME = "clawdbot.json";
function legacyStateDir(homedir: () => string = os.homedir): string {
return path.join(homedir(), LEGACY_STATE_DIRNAME);
@@ -27,10 +29,19 @@ function newStateDir(homedir: () => string = os.homedir): string {
return path.join(homedir(), NEW_STATE_DIRNAME);
}
export function resolveLegacyStateDir(homedir: () => string = os.homedir): string {
return legacyStateDir(homedir);
}
export function resolveNewStateDir(homedir: () => string = os.homedir): string {
return newStateDir(homedir);
}
/**
* State directory for mutable data (sessions, logs, caches).
* Can be overridden via MOLTBOT_STATE_DIR (preferred) or CLAWDBOT_STATE_DIR (legacy).
* Default: ~/.clawdbot (legacy default for compatibility)
* If ~/.moltbot exists and ~/.clawdbot does not, prefer ~/.moltbot.
*/
export function resolveStateDir(
env: NodeJS.ProcessEnv = process.env,
@@ -38,7 +49,12 @@ export function resolveStateDir(
): string {
const override = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (override) return resolveUserPath(override);
return legacyStateDir(homedir);
const legacyDir = legacyStateDir(homedir);
const newDir = newStateDir(homedir);
const hasLegacy = fs.existsSync(legacyDir);
const hasNew = fs.existsSync(newDir);
if (!hasLegacy && hasNew) return newDir;
return legacyDir;
}
function resolveUserPath(input: string): string {
@@ -58,7 +74,7 @@ export const STATE_DIR = resolveStateDir();
* Can be overridden via MOLTBOT_CONFIG_PATH (preferred) or CLAWDBOT_CONFIG_PATH (legacy).
* Default: ~/.clawdbot/moltbot.json (or $*_STATE_DIR/moltbot.json)
*/
export function resolveConfigPath(
export function resolveCanonicalConfigPath(
env: NodeJS.ProcessEnv = process.env,
stateDir: string = resolveStateDir(env, os.homedir),
): string {
@@ -67,7 +83,58 @@ export function resolveConfigPath(
return path.join(stateDir, CONFIG_FILENAME);
}
export const CONFIG_PATH = resolveConfigPath();
/**
* Resolve the active config path by preferring existing config candidates
* (new/legacy filenames) before falling back to the canonical path.
*/
export function resolveConfigPathCandidate(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const candidates = resolveDefaultConfigCandidates(env, homedir);
const existing = candidates.find((candidate) => {
try {
return fs.existsSync(candidate);
} catch {
return false;
}
});
if (existing) return existing;
return resolveCanonicalConfigPath(env, resolveStateDir(env, homedir));
}
/**
* Active config path (prefers existing legacy/new config files).
*/
export function resolveConfigPath(
env: NodeJS.ProcessEnv = process.env,
stateDir: string = resolveStateDir(env, os.homedir),
homedir: () => string = os.homedir,
): string {
const override = env.MOLTBOT_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim();
if (override) return resolveUserPath(override);
const stateOverride = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
const candidates = [
path.join(stateDir, CONFIG_FILENAME),
path.join(stateDir, LEGACY_CONFIG_FILENAME),
];
const existing = candidates.find((candidate) => {
try {
return fs.existsSync(candidate);
} catch {
return false;
}
});
if (existing) return existing;
if (stateOverride) return path.join(stateDir, CONFIG_FILENAME);
const defaultStateDir = resolveStateDir(env, homedir);
if (path.resolve(stateDir) === path.resolve(defaultStateDir)) {
return resolveConfigPathCandidate(env, homedir);
}
return path.join(stateDir, CONFIG_FILENAME);
}
export const CONFIG_PATH = resolveConfigPathCandidate();
/**
* Resolve default config path candidates across new + legacy locations.
@@ -84,14 +151,18 @@ export function resolveDefaultConfigCandidates(
const moltbotStateDir = env.MOLTBOT_STATE_DIR?.trim();
if (moltbotStateDir) {
candidates.push(path.join(resolveUserPath(moltbotStateDir), CONFIG_FILENAME));
candidates.push(path.join(resolveUserPath(moltbotStateDir), LEGACY_CONFIG_FILENAME));
}
const legacyStateDirOverride = env.CLAWDBOT_STATE_DIR?.trim();
if (legacyStateDirOverride) {
candidates.push(path.join(resolveUserPath(legacyStateDirOverride), CONFIG_FILENAME));
candidates.push(path.join(resolveUserPath(legacyStateDirOverride), LEGACY_CONFIG_FILENAME));
}
candidates.push(path.join(newStateDir(homedir), CONFIG_FILENAME));
candidates.push(path.join(newStateDir(homedir), LEGACY_CONFIG_FILENAME));
candidates.push(path.join(legacyStateDir(homedir), CONFIG_FILENAME));
candidates.push(path.join(legacyStateDir(homedir), LEGACY_CONFIG_FILENAME));
return candidates;
}
+1 -1
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":
+1 -1
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";
+6 -1
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(),
+25 -4
View File
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { LEGACY_MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js";
import { LEGACY_MACOS_APP_SOURCES_DIR, MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js";
import { CronPayloadSchema } from "../gateway/protocol/schema.js";
type SchemaLike = {
@@ -30,7 +30,26 @@ function extractCronChannels(schema: SchemaLike): string[] {
const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"];
const SWIFT_FILES = [`${LEGACY_MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`];
const SWIFT_FILE_CANDIDATES = [
`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`,
`${LEGACY_MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`,
];
async function resolveSwiftFiles(cwd: string): Promise<string[]> {
const matches: string[] = [];
for (const relPath of SWIFT_FILE_CANDIDATES) {
try {
await fs.access(path.join(cwd, relPath));
matches.push(relPath);
} catch {
// ignore missing path
}
}
if (matches.length === 0) {
throw new Error(`Missing Swift cron definition. Tried: ${SWIFT_FILE_CANDIDATES.join(", ")}`);
}
return matches;
}
describe("cron protocol conformance", () => {
it("ui + swift include all cron providers from gateway schema", async () => {
@@ -45,7 +64,8 @@ describe("cron protocol conformance", () => {
}
}
for (const relPath of SWIFT_FILES) {
const swiftFiles = await resolveSwiftFiles(cwd);
for (const relPath of swiftFiles) {
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
for (const channel of channels) {
const pattern = new RegExp(`\\bcase\\s+${channel}\\b`);
@@ -61,7 +81,8 @@ describe("cron protocol conformance", () => {
expect(uiTypes.includes("jobs:")).toBe(true);
expect(uiTypes.includes("jobCount")).toBe(false);
const swiftPath = path.join(cwd, SWIFT_FILES[0]);
const [swiftRelPath] = await resolveSwiftFiles(cwd);
const swiftPath = path.join(cwd, swiftRelPath);
const swift = await fs.readFile(swiftPath, "utf-8");
expect(swift.includes("struct CronSchedulerStatus")).toBe(true);
expect(swift.includes("let jobs:")).toBe(true);
+4 -4
View File
@@ -14,7 +14,7 @@ describe("resolveGatewayLaunchAgentLabel", () => {
it("returns default label when no profile is set", () => {
const result = resolveGatewayLaunchAgentLabel();
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
expect(result).toBe("com.clawdbot.gateway");
expect(result).toBe("bot.molt.gateway");
});
it("returns default label when profile is undefined", () => {
@@ -34,17 +34,17 @@ describe("resolveGatewayLaunchAgentLabel", () => {
it("returns profile-specific label when profile is set", () => {
const result = resolveGatewayLaunchAgentLabel("dev");
expect(result).toBe("com.clawdbot.dev");
expect(result).toBe("bot.molt.dev");
});
it("returns profile-specific label for custom profile", () => {
const result = resolveGatewayLaunchAgentLabel("work");
expect(result).toBe("com.clawdbot.work");
expect(result).toBe("bot.molt.work");
});
it("trims whitespace from profile", () => {
const result = resolveGatewayLaunchAgentLabel(" staging ");
expect(result).toBe("com.clawdbot.staging");
expect(result).toBe("bot.molt.staging");
});
it("returns default label for empty string profile", () => {
+15 -4
View File
@@ -1,16 +1,19 @@
// Default service labels (for backward compatibility and when no profile specified)
export const GATEWAY_LAUNCH_AGENT_LABEL = "com.clawdbot.gateway";
export const GATEWAY_LAUNCH_AGENT_LABEL = "bot.molt.gateway";
export const GATEWAY_SYSTEMD_SERVICE_NAME = "moltbot-gateway";
export const GATEWAY_WINDOWS_TASK_NAME = "Moltbot Gateway";
export const GATEWAY_SERVICE_MARKER = "moltbot";
export const GATEWAY_SERVICE_KIND = "gateway";
export const NODE_LAUNCH_AGENT_LABEL = "com.clawdbot.node";
export const NODE_LAUNCH_AGENT_LABEL = "bot.molt.node";
export const NODE_SYSTEMD_SERVICE_NAME = "moltbot-node";
export const NODE_WINDOWS_TASK_NAME = "Moltbot Node";
export const NODE_SERVICE_MARKER = "moltbot";
export const NODE_SERVICE_KIND = "node";
export const NODE_WINDOWS_TASK_SCRIPT_NAME = "node.cmd";
export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = ["com.steipete.clawdbot.gateway"];
export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = [
"com.clawdbot.gateway",
"com.steipete.clawdbot.gateway",
];
export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = [];
export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES: string[] = [];
@@ -30,7 +33,15 @@ export function resolveGatewayLaunchAgentLabel(profile?: string): string {
if (!normalized) {
return GATEWAY_LAUNCH_AGENT_LABEL;
}
return `com.clawdbot.${normalized}`;
return `bot.molt.${normalized}`;
}
export function resolveLegacyGatewayLaunchAgentLabels(profile?: string): string[] {
const normalized = normalizeGatewayProfile(profile);
if (!normalized) {
return [...LEGACY_GATEWAY_LAUNCH_AGENT_LABELS];
}
return [...LEGACY_GATEWAY_LAUNCH_AGENT_LABELS, `com.clawdbot.${normalized}`];
}
export function resolveGatewaySystemdServiceName(profile?: string): string {
+4 -3
View File
@@ -6,12 +6,12 @@ import { promisify } from "node:util";
import {
GATEWAY_SERVICE_KIND,
GATEWAY_SERVICE_MARKER,
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
resolveLegacyGatewayLaunchAgentLabels,
} from "./constants.js";
export type ExtraGatewayService = {
@@ -78,7 +78,7 @@ function isMoltbotGatewayLaunchdService(label: string, contents: string): boolea
if (hasGatewayServiceMarker(contents)) return true;
const lowerContents = contents.toLowerCase();
if (!lowerContents.includes("gateway")) return false;
return label.startsWith("com.clawdbot.");
return label.startsWith("bot.molt.") || label.startsWith("com.clawdbot.");
}
function isMoltbotGatewaySystemdService(name: string, contents: string): boolean {
@@ -102,7 +102,8 @@ function tryExtractPlistLabel(contents: string): string | null {
function isIgnoredLaunchdLabel(label: string): boolean {
return (
label === resolveGatewayLaunchAgentLabel() || LEGACY_GATEWAY_LAUNCH_AGENT_LABELS.includes(label)
label === resolveGatewayLaunchAgentLabel() ||
resolveLegacyGatewayLaunchAgentLabels(process.env.CLAWDBOT_PROFILE).includes(label)
);
}
+10 -10
View File
@@ -107,7 +107,7 @@ describe("launchd runtime parsing", () => {
describe("launchctl list detection", () => {
it("detects the resolved label in launchctl list", async () => {
await withLaunchctlStub({ listOutput: "123 0 com.clawdbot.gateway\n" }, async ({ env }) => {
await withLaunchctlStub({ listOutput: "123 0 bot.molt.gateway\n" }, async ({ env }) => {
const listed = await isLaunchAgentListed({ env });
expect(listed).toBe(true);
});
@@ -133,7 +133,7 @@ describe("launchd bootstrap repair", () => {
.map((line) => JSON.parse(line) as string[]);
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "com.clawdbot.gateway";
const label = "bot.molt.gateway";
const plistPath = resolveLaunchAgentPlistPath(env);
expect(calls).toContainEqual(["bootstrap", domain, plistPath]);
@@ -201,7 +201,7 @@ describe("launchd install", () => {
.map((line) => JSON.parse(line) as string[]);
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "com.clawdbot.gateway";
const label = "bot.molt.gateway";
const plistPath = resolveLaunchAgentPlistPath(env);
const serviceId = `${domain}/${label}`;
@@ -231,21 +231,21 @@ describe("resolveLaunchAgentPlistPath", () => {
it("uses default label when CLAWDBOT_PROFILE is default", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "default" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
"/Users/test/Library/LaunchAgents/bot.molt.gateway.plist",
);
});
it("uses default label when CLAWDBOT_PROFILE is unset", () => {
const env = { HOME: "/Users/test" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
"/Users/test/Library/LaunchAgents/bot.molt.gateway.plist",
);
});
it("uses profile-specific label when CLAWDBOT_PROFILE is set to a custom value", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "jbphoenix" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.jbphoenix.plist",
"/Users/test/Library/LaunchAgents/bot.molt.jbphoenix.plist",
);
});
@@ -277,28 +277,28 @@ describe("resolveLaunchAgentPlistPath", () => {
CLAWDBOT_LAUNCHD_LABEL: " ",
};
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist",
"/Users/test/Library/LaunchAgents/bot.molt.myprofile.plist",
);
});
it("handles case-insensitive 'Default' profile", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "Default" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
"/Users/test/Library/LaunchAgents/bot.molt.gateway.plist",
);
});
it("handles case-insensitive 'DEFAULT' profile", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "DEFAULT" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
"/Users/test/Library/LaunchAgents/bot.molt.gateway.plist",
);
});
it("trims whitespace from CLAWDBOT_PROFILE", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: " myprofile " };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist",
"/Users/test/Library/LaunchAgents/bot.molt.myprofile.plist",
);
});
});
+3 -3
View File
@@ -7,8 +7,8 @@ import { colorize, isRich, theme } from "../terminal/theme.js";
import {
formatGatewayServiceDescription,
GATEWAY_LAUNCH_AGENT_LABEL,
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
resolveGatewayLaunchAgentLabel,
resolveLegacyGatewayLaunchAgentLabels,
} from "./constants.js";
import {
buildLaunchAgentPlist as buildLaunchAgentPlistImpl,
@@ -248,7 +248,7 @@ export async function findLegacyLaunchAgents(
): Promise<LegacyLaunchAgent[]> {
const domain = resolveGuiDomain();
const results: LegacyLaunchAgent[] = [];
for (const label of LEGACY_GATEWAY_LAUNCH_AGENT_LABELS) {
for (const label of resolveLegacyGatewayLaunchAgentLabels(env.CLAWDBOT_PROFILE)) {
const plistPath = resolveLaunchAgentPlistPathForLabel(env, label);
const res = await execLaunchctl(["print", `${domain}/${label}`]);
const loaded = res.code === 0;
@@ -384,7 +384,7 @@ export async function installLaunchAgent({
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel({ env });
for (const legacyLabel of LEGACY_GATEWAY_LAUNCH_AGENT_LABELS) {
for (const legacyLabel of resolveLegacyGatewayLaunchAgentLabels(env.CLAWDBOT_PROFILE)) {
const legacyPlistPath = resolveLaunchAgentPlistPathForLabel(env, legacyLabel);
await execLaunchctl(["bootout", domain, legacyPlistPath]);
await execLaunchctl(["unload", legacyPlistPath]);
+2 -2
View File
@@ -230,7 +230,7 @@ describe("buildServiceEnvironment", () => {
expect(typeof env.CLAWDBOT_SERVICE_VERSION).toBe("string");
expect(env.CLAWDBOT_SYSTEMD_UNIT).toBe("moltbot-gateway.service");
if (process.platform === "darwin") {
expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("com.clawdbot.gateway");
expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("bot.molt.gateway");
}
});
@@ -241,7 +241,7 @@ describe("buildServiceEnvironment", () => {
});
expect(env.CLAWDBOT_SYSTEMD_UNIT).toBe("moltbot-gateway-work.service");
if (process.platform === "darwin") {
expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("com.clawdbot.work");
expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("bot.molt.work");
}
});
});
+4 -4
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);
+46 -1
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) {
+40 -2
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");
+94
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;
}
+1
View File
@@ -72,6 +72,7 @@ function isGatewayArgv(args: string[]): boolean {
"dist/index.js",
"dist/index.mjs",
"dist/entry.js",
"moltbot.mjs",
"dist/entry.mjs",
"scripts/run-node.mjs",
"src/index.ts",
+19
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 {
+151 -5
View File
@@ -4,7 +4,12 @@ import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { MoltbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import {
resolveLegacyStateDir,
resolveNewStateDir,
resolveOAuthDir,
resolveStateDir,
} from "../config/paths.js";
import type { SessionEntry } from "../config/sessions.js";
import type { SessionScope } from "../config/sessions/types.js";
import { saveSessionStore } from "../config/sessions.js";
@@ -59,6 +64,7 @@ type MigrationLogger = {
};
let autoMigrateChecked = false;
let autoMigrateStateDirChecked = false;
function isSurfaceGroupKey(key: string): boolean {
return key.includes(":group:") || key.includes(":channel:");
@@ -267,6 +273,131 @@ export function resetAutoMigrateLegacyAgentDirForTest() {
resetAutoMigrateLegacyStateForTest();
}
export function resetAutoMigrateLegacyStateDirForTest() {
autoMigrateStateDirChecked = false;
}
type StateDirMigrationResult = {
migrated: boolean;
skipped: boolean;
changes: string[];
warnings: string[];
};
function resolveSymlinkTarget(linkPath: string): string | null {
try {
const target = fs.readlinkSync(linkPath);
return path.resolve(path.dirname(linkPath), target);
} catch {
return null;
}
}
function formatStateDirMigration(legacyDir: string, targetDir: string): string {
return `State dir: ${legacyDir} → ${targetDir} (legacy path now symlinked)`;
}
function isDirPath(filePath: string): boolean {
try {
return fs.statSync(filePath).isDirectory();
} catch {
return false;
}
}
export async function autoMigrateLegacyStateDir(params: {
env?: NodeJS.ProcessEnv;
homedir?: () => string;
log?: MigrationLogger;
}): Promise<StateDirMigrationResult> {
if (autoMigrateStateDirChecked) {
return { migrated: false, skipped: true, changes: [], warnings: [] };
}
autoMigrateStateDirChecked = true;
const env = params.env ?? process.env;
if (env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim()) {
return { migrated: false, skipped: true, changes: [], warnings: [] };
}
const homedir = params.homedir ?? os.homedir;
const legacyDir = resolveLegacyStateDir(homedir);
const targetDir = resolveNewStateDir(homedir);
const warnings: string[] = [];
const changes: string[] = [];
let legacyStat: fs.Stats | null = null;
try {
legacyStat = fs.lstatSync(legacyDir);
} catch {
legacyStat = null;
}
if (!legacyStat) {
return { migrated: false, skipped: false, changes, warnings };
}
if (!legacyStat.isDirectory() && !legacyStat.isSymbolicLink()) {
warnings.push(`Legacy state path is not a directory: ${legacyDir}`);
return { migrated: false, skipped: false, changes, warnings };
}
if (legacyStat.isSymbolicLink()) {
const legacyTarget = resolveSymlinkTarget(legacyDir);
if (legacyTarget && path.resolve(legacyTarget) === path.resolve(targetDir)) {
return { migrated: false, skipped: false, changes, warnings };
}
warnings.push(
`Legacy state dir is a symlink (${legacyDir} → ${legacyTarget ?? "unknown"}); skipping auto-migration.`,
);
return { migrated: false, skipped: false, changes, warnings };
}
if (isDirPath(targetDir)) {
warnings.push(
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
);
return { migrated: false, skipped: false, changes, warnings };
}
try {
fs.renameSync(legacyDir, targetDir);
} catch (err) {
warnings.push(`Failed to move legacy state dir (${legacyDir} → ${targetDir}): ${String(err)}`);
return { migrated: false, skipped: false, changes, warnings };
}
try {
fs.symlinkSync(targetDir, legacyDir, "dir");
changes.push(formatStateDirMigration(legacyDir, targetDir));
} catch (err) {
try {
if (process.platform === "win32") {
fs.symlinkSync(targetDir, legacyDir, "junction");
changes.push(formatStateDirMigration(legacyDir, targetDir));
} else {
throw err;
}
} catch (fallbackErr) {
try {
fs.renameSync(targetDir, legacyDir);
warnings.push(
`State dir migration rolled back (failed to link legacy path): ${String(fallbackErr)}`,
);
return { migrated: false, skipped: false, changes: [], warnings };
} catch (rollbackErr) {
warnings.push(
`State dir moved but failed to link legacy path (${legacyDir} → ${targetDir}): ${String(fallbackErr)}`,
);
warnings.push(
`Rollback failed; set MOLTBOT_STATE_DIR=${targetDir} to avoid split state: ${String(rollbackErr)}`,
);
changes.push(`State dir: ${legacyDir} → ${targetDir}`);
}
}
}
return { migrated: changes.length > 0, skipped: false, changes, warnings };
}
export async function detectLegacyStateMigrations(params: {
cfg: MoltbotConfig;
env?: NodeJS.ProcessEnv;
@@ -591,8 +722,18 @@ export async function autoMigrateLegacyState(params: {
autoMigrateChecked = true;
const env = params.env ?? process.env;
const stateDirResult = await autoMigrateLegacyStateDir({
env,
homedir: params.homedir,
log: params.log,
});
if (env.CLAWDBOT_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim()) {
return { migrated: false, skipped: true, changes: [], warnings: [] };
return {
migrated: stateDirResult.migrated,
skipped: true,
changes: stateDirResult.changes,
warnings: stateDirResult.warnings,
};
}
const detected = await detectLegacyStateMigrations({
@@ -601,14 +742,19 @@ export async function autoMigrateLegacyState(params: {
homedir: params.homedir,
});
if (!detected.sessions.hasLegacy && !detected.agentDir.hasLegacy) {
return { migrated: false, skipped: false, changes: [], warnings: [] };
return {
migrated: stateDirResult.migrated,
skipped: false,
changes: stateDirResult.changes,
warnings: stateDirResult.warnings,
};
}
const now = params.now ?? (() => Date.now());
const sessions = await migrateLegacySessions(detected, now);
const agentDir = await migrateLegacyAgentDir(detected, now);
const changes = [...sessions.changes, ...agentDir.changes];
const warnings = [...sessions.warnings, ...agentDir.warnings];
const changes = [...stateDirResult.changes, ...sessions.changes, ...agentDir.changes];
const warnings = [...stateDirResult.warnings, ...sessions.warnings, ...agentDir.warnings];
const logger = params.log ?? createSubsystemLogger("state-migrations");
if (changes.length > 0) {
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import process from "node:process";
import { installUnhandledRejectionHandler } from "./unhandled-rejections.js";
describe("installUnhandledRejectionHandler - fatal detection", () => {
let exitCalls: Array<string | number | null> = [];
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
let originalExit: typeof process.exit;
beforeAll(() => {
originalExit = process.exit.bind(process);
installUnhandledRejectionHandler();
});
beforeEach(() => {
exitCalls = [];
vi.spyOn(process, "exit").mockImplementation((code: string | number | null | undefined) => {
if (code !== undefined && code !== null) {
exitCalls.push(code);
}
});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.clearAllMocks();
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
});
afterAll(() => {
process.exit = originalExit;
});
describe("fatal errors", () => {
it("exits on ERR_OUT_OF_MEMORY", () => {
const oomErr = Object.assign(new Error("Out of memory"), {
code: "ERR_OUT_OF_MEMORY",
});
process.emit("unhandledRejection", oomErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[moltbot] FATAL unhandled rejection:",
expect.stringContaining("Out of memory"),
);
});
it("exits on ERR_SCRIPT_EXECUTION_TIMEOUT", () => {
const timeoutErr = Object.assign(new Error("Script execution timeout"), {
code: "ERR_SCRIPT_EXECUTION_TIMEOUT",
});
process.emit("unhandledRejection", timeoutErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
});
it("exits on ERR_WORKER_OUT_OF_MEMORY", () => {
const workerOomErr = Object.assign(new Error("Worker out of memory"), {
code: "ERR_WORKER_OUT_OF_MEMORY",
});
process.emit("unhandledRejection", workerOomErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
});
});
describe("configuration errors", () => {
it("exits on INVALID_CONFIG", () => {
const configErr = Object.assign(new Error("Invalid config"), {
code: "INVALID_CONFIG",
});
process.emit("unhandledRejection", configErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[moltbot] CONFIGURATION ERROR - requires fix:",
expect.stringContaining("Invalid config"),
);
});
it("exits on MISSING_API_KEY", () => {
const missingKeyErr = Object.assign(new Error("Missing API key"), {
code: "MISSING_API_KEY",
});
process.emit("unhandledRejection", missingKeyErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
});
});
describe("non-fatal errors", () => {
it("does NOT exit on undici fetch failures", () => {
const fetchErr = Object.assign(new TypeError("fetch failed"), {
cause: { code: "UND_ERR_CONNECT_TIMEOUT", syscall: "connect" },
});
process.emit("unhandledRejection", fetchErr, Promise.resolve());
expect(exitCalls).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
"[moltbot] Non-fatal unhandled rejection (continuing):",
expect.stringContaining("fetch failed"),
);
});
it("does NOT exit on DNS resolution failures", () => {
const dnsErr = Object.assign(new Error("DNS resolve failed"), {
code: "UND_ERR_DNS_RESOLVE_FAILED",
});
process.emit("unhandledRejection", dnsErr, Promise.resolve());
expect(exitCalls).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalled();
});
it("exits on generic errors without code", () => {
const genericErr = new Error("Something went wrong");
process.emit("unhandledRejection", genericErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[moltbot] Unhandled promise rejection:",
expect.stringContaining("Something went wrong"),
);
});
it("does NOT exit on connection reset errors", () => {
const connResetErr = Object.assign(new Error("Connection reset"), {
code: "ECONNRESET",
});
process.emit("unhandledRejection", connResetErr, Promise.resolve());
expect(exitCalls).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalled();
});
it("does NOT exit on timeout errors", () => {
const timeoutErr = Object.assign(new Error("Timeout"), {
code: "ETIMEDOUT",
});
process.emit("unhandledRejection", timeoutErr, Promise.resolve());
expect(exitCalls).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalled();
});
});
});
+65 -33
View File
@@ -1,11 +1,52 @@
import process from "node:process";
import { formatUncaughtError } from "./errors.js";
import { extractErrorCode, formatUncaughtError } from "./errors.js";
type UnhandledRejectionHandler = (reason: unknown) => boolean;
const handlers = new Set<UnhandledRejectionHandler>();
const FATAL_ERROR_CODES = new Set([
"ERR_OUT_OF_MEMORY",
"ERR_SCRIPT_EXECUTION_TIMEOUT",
"ERR_WORKER_OUT_OF_MEMORY",
"ERR_WORKER_UNCAUGHT_EXCEPTION",
"ERR_WORKER_INITIALIZATION_FAILED",
]);
const CONFIG_ERROR_CODES = new Set(["INVALID_CONFIG", "MISSING_API_KEY", "MISSING_CREDENTIALS"]);
// Network error codes that indicate transient failures (shouldn't crash the gateway)
const TRANSIENT_NETWORK_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"ENOTFOUND",
"ETIMEDOUT",
"ESOCKETTIMEDOUT",
"ECONNABORTED",
"EPIPE",
"EHOSTUNREACH",
"ENETUNREACH",
"EAI_AGAIN",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_DNS_RESOLVE_FAILED",
"UND_ERR_CONNECT",
"UND_ERR_SOCKET",
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_BODY_TIMEOUT",
]);
function getErrorCause(err: unknown): unknown {
if (!err || typeof err !== "object") return undefined;
return (err as { cause?: unknown }).cause;
}
function extractErrorCodeWithCause(err: unknown): string | undefined {
const direct = extractErrorCode(err);
if (direct) return direct;
return extractErrorCode(getErrorCause(err));
}
/**
* Checks if an error is an AbortError.
* These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash.
@@ -20,33 +61,14 @@ export function isAbortError(err: unknown): boolean {
return false;
}
// Network error codes that indicate transient failures (shouldn't crash the gateway)
const TRANSIENT_NETWORK_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"ENOTFOUND",
"ETIMEDOUT",
"ESOCKETTIMEDOUT",
"ECONNABORTED",
"EPIPE",
"EHOSTUNREACH",
"ENETUNREACH",
"EAI_AGAIN",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_SOCKET",
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_BODY_TIMEOUT",
]);
function getErrorCode(err: unknown): string | undefined {
if (!err || typeof err !== "object") return undefined;
const code = (err as { code?: unknown }).code;
return typeof code === "string" ? code : undefined;
function isFatalError(err: unknown): boolean {
const code = extractErrorCodeWithCause(err);
return code !== undefined && FATAL_ERROR_CODES.has(code);
}
function getErrorCause(err: unknown): unknown {
if (!err || typeof err !== "object") return undefined;
return (err as { cause?: unknown }).cause;
function isConfigError(err: unknown): boolean {
const code = extractErrorCodeWithCause(err);
return code !== undefined && CONFIG_ERROR_CODES.has(code);
}
/**
@@ -56,16 +78,13 @@ function getErrorCause(err: unknown): unknown {
export function isTransientNetworkError(err: unknown): boolean {
if (!err) return false;
// Check the error itself
const code = getErrorCode(err);
const code = extractErrorCodeWithCause(err);
if (code && TRANSIENT_NETWORK_CODES.has(code)) return true;
// "fetch failed" TypeError from undici (Node's native fetch)
if (err instanceof TypeError && err.message === "fetch failed") {
const cause = getErrorCause(err);
// The cause often contains the actual network error
if (cause) return isTransientNetworkError(cause);
// Even without a cause, "fetch failed" is typically a network issue
return true;
}
@@ -115,10 +134,23 @@ export function installUnhandledRejectionHandler(): void {
return;
}
// Transient network errors (fetch failed, connection reset, etc.) shouldn't crash
// These are temporary connectivity issues that will resolve on their own
if (isFatalError(reason)) {
console.error("[moltbot] FATAL unhandled rejection:", formatUncaughtError(reason));
process.exit(1);
return;
}
if (isConfigError(reason)) {
console.error("[moltbot] CONFIGURATION ERROR - requires fix:", formatUncaughtError(reason));
process.exit(1);
return;
}
if (isTransientNetworkError(reason)) {
console.error("[moltbot] Network error (non-fatal):", formatUncaughtError(reason));
console.warn(
"[moltbot] Non-fatal unhandled rejection (continuing):",
formatUncaughtError(reason),
);
return;
}
+26
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");
});
+4 -1
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,
+7 -1
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}`;
+4 -1
View File
@@ -280,7 +280,10 @@ function isClaudeModel(id: string): boolean {
}
function isClaude45OrHigher(id: string): boolean {
return /\bclaude-[^\s/]*?(?:-4-5\b|4\.5\b)/i.test(id);
// Match claude-*-4-5, claude-*-45, claude-*4.5, or opus-4-5/opus-45 variants
// Examples that should match:
// claude-opus-4-5, claude-opus-45, claude-4.5, venice/claude-opus-45
return /\bclaude-[^\s/]*?(?:-4-?5\b|4\.5\b)/i.test(id);
}
export function collectModelHygieneFindings(cfg: MoltbotConfig): SecurityAuditFinding[] {
+17
View File
@@ -730,6 +730,23 @@ describe("security audit", () => {
);
});
it("does not warn on Venice-style opus-45 model names", async () => {
// Venice uses "claude-opus-45" format (no dash between 4 and 5)
const cfg: ClawdbotConfig = {
agents: { defaults: { model: { primary: "venice/claude-opus-45" } } },
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
// Should NOT contain weak_tier warning for opus-45
const weakTierFinding = res.findings.find((f) => f.checkId === "models.weak_tier");
expect(weakTierFinding).toBeUndefined();
});
it("warns when hooks token looks short", async () => {
const cfg: MoltbotConfig = {
hooks: { enabled: true, token: "short" },
+2 -1
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.',
});
}
};
@@ -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);
});
});
+12 -5
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) })
@@ -480,9 +481,13 @@ export const buildTelegramMessageContext = async ({
const replyTarget = describeReplyTarget(msg);
const forwardOrigin = normalizeForwardedContext(msg);
const replySuffix = replyTarget
? `\n\n[Replying to ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n${replyTarget.body}\n[/Replying]`
? replyTarget.kind === "quote"
? `\n\n[Quoting ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n"${replyTarget.body}"\n[/Quoting]`
: `\n\n[Replying to ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n${replyTarget.body}\n[/Replying]`
: "";
const forwardPrefix = forwardOrigin
? `[Forwarded from ${forwardOrigin.from}${
@@ -565,6 +570,7 @@ export const buildTelegramMessageContext = async ({
ReplyToId: replyTarget?.id,
ReplyToBody: replyTarget?.body,
ReplyToSender: replyTarget?.sender,
ReplyToIsQuote: replyTarget?.kind === "quote" ? true : undefined,
ForwardedFrom: forwardOrigin?.from,
ForwardedFromType: forwardOrigin?.fromType,
ForwardedFromId: forwardOrigin?.fromId,
@@ -596,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,
+5
View File
@@ -210,6 +210,10 @@ export const dispatchTelegramMessage = async ({
draftStream?.stop();
}
const replyQuoteText =
ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody
? ctxPayload.ReplyToBody.trim() || undefined
: undefined;
await deliverReplies({
replies: [payload],
chatId: String(chatId),
@@ -223,6 +227,7 @@ export const dispatchTelegramMessage = async ({
chunkMode,
onVoiceRecording: sendRecordVoice,
linkPreview: telegramCfg.linkPreview,
replyQuoteText,
});
},
onError: (err, info) => {
+13 -8
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([]),
@@ -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(
+74 -2
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(
@@ -894,6 +899,73 @@ describe("createTelegramBot", () => {
expect(payload.ReplyToSender).toBe("Ada");
});
it("uses quote text when a Telegram partial reply is received", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "Sure, see below",
date: 1736380800,
reply_to_message: {
message_id: 9001,
text: "Can you summarize this?",
from: { first_name: "Ada" },
},
quote: {
text: "summarize this",
},
},
me: { username: "moltbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toContain("[Quoting Ada id:9001]");
expect(payload.Body).toContain('"summarize this"');
expect(payload.ReplyToId).toBe("9001");
expect(payload.ReplyToBody).toBe("summarize this");
expect(payload.ReplyToSender).toBe("Ada");
});
it("handles quote-only replies without reply metadata", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "Sure, see below",
date: 1736380800,
quote: {
text: "summarize this",
},
},
me: { username: "moltbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toContain("[Quoting unknown sender]");
expect(payload.Body).toContain('"summarize this"');
expect(payload.ReplyToId).toBeUndefined();
expect(payload.ReplyToBody).toBe("summarize this");
expect(payload.ReplyToSender).toBe("unknown sender");
});
it("sends replies without native reply threading", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
+7 -5
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) })
+31
View File
@@ -168,6 +168,37 @@ describe("deliverReplies", () => {
);
});
it("uses reply_parameters when quote text is provided", async () => {
const runtime = { error: vi.fn(), log: vi.fn() };
const sendMessage = vi.fn().mockResolvedValue({
message_id: 10,
chat: { id: "123" },
});
const bot = { api: { sendMessage } } as unknown as Bot;
await deliverReplies({
replies: [{ text: "Hello there", replyToId: "500" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "all",
textLimit: 4000,
replyQuoteText: "quoted text",
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.objectContaining({
reply_parameters: {
message_id: 500,
quote: "quoted text",
},
}),
);
});
it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => {
const runtime = { error: vi.fn(), log: vi.fn() };
const sendVoice = vi
+34 -8
View File
@@ -42,11 +42,21 @@ export async function deliverReplies(params: {
onVoiceRecording?: () => Promise<void> | void;
/** Controls whether link previews are shown. Default: true (previews enabled). */
linkPreview?: boolean;
/** Optional quote text for Telegram reply_parameters. */
replyQuoteText?: string;
}) {
const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId, linkPreview } =
params;
const {
replies,
chatId,
runtime,
bot,
replyToMode,
textLimit,
messageThreadId,
linkPreview,
replyQuoteText,
} = params;
const chunkMode = params.chunkMode ?? "length";
const threadParams = buildTelegramThreadParams(messageThreadId);
let hasReplied = false;
const chunkText = (markdown: string) => {
const markdownChunks =
@@ -97,6 +107,7 @@ export async function deliverReplies(params: {
await sendTelegramText(bot, chatId, chunk.html, runtime, {
replyToMessageId:
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
replyQuoteText,
messageThreadId,
textMode: "html",
plainText: chunk.text,
@@ -140,13 +151,14 @@ export async function deliverReplies(params: {
const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText;
const mediaParams: Record<string, unknown> = {
caption: htmlCaption,
reply_to_message_id: replyToMessageId,
...(htmlCaption ? { parse_mode: "HTML" } : {}),
...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}),
...buildTelegramSendParams({
replyToMessageId,
messageThreadId,
replyQuoteText,
}),
};
if (threadParams) {
mediaParams.message_thread_id = threadParams.message_thread_id;
}
if (isGif) {
await withTelegramApiErrorLogging({
operation: "sendAnimation",
@@ -207,6 +219,7 @@ export async function deliverReplies(params: {
messageThreadId,
linkPreview,
replyMarkup,
replyQuoteText,
});
// Skip this media item; continue with next.
continue;
@@ -391,6 +404,7 @@ async function sendTelegramVoiceFallbackText(opts: {
messageThreadId?: number;
linkPreview?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyQuoteText?: string;
}): Promise<boolean> {
const chunks = opts.chunkText(opts.text);
let hasReplied = opts.hasReplied;
@@ -399,6 +413,7 @@ async function sendTelegramVoiceFallbackText(opts: {
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
replyToMessageId:
opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined,
replyQuoteText: opts.replyQuoteText,
messageThreadId: opts.messageThreadId,
textMode: "html",
plainText: chunk.text,
@@ -415,11 +430,20 @@ async function sendTelegramVoiceFallbackText(opts: {
function buildTelegramSendParams(opts?: {
replyToMessageId?: number;
messageThreadId?: number;
replyQuoteText?: string;
}): Record<string, unknown> {
const threadParams = buildTelegramThreadParams(opts?.messageThreadId);
const params: Record<string, unknown> = {};
const quoteText = opts?.replyQuoteText?.trim();
if (opts?.replyToMessageId) {
params.reply_to_message_id = opts.replyToMessageId;
if (quoteText) {
params.reply_parameters = {
message_id: Math.trunc(opts.replyToMessageId),
quote: quoteText,
};
} else {
params.reply_to_message_id = opts.replyToMessageId;
}
}
if (threadParams) {
params.message_thread_id = threadParams.message_thread_id;
@@ -434,6 +458,7 @@ async function sendTelegramText(
runtime: RuntimeEnv,
opts?: {
replyToMessageId?: number;
replyQuoteText?: string;
messageThreadId?: number;
textMode?: "markdown" | "html";
plainText?: string;
@@ -443,6 +468,7 @@ async function sendTelegramText(
): Promise<number | undefined> {
const baseParams = buildTelegramSendParams({
replyToMessageId: opts?.replyToMessageId,
replyQuoteText: opts?.replyQuoteText,
messageThreadId: opts?.messageThreadId,
});
// Add link_preview_options when link preview is disabled.
+26
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();
+48 -16
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;
}
/**
@@ -150,28 +161,49 @@ export function resolveTelegramReplyId(raw?: string): number | undefined {
return parsed;
}
export function describeReplyTarget(msg: TelegramMessage) {
export type TelegramReplyTarget = {
id?: string;
sender: string;
body: string;
kind: "reply" | "quote";
};
export function describeReplyTarget(msg: TelegramMessage): TelegramReplyTarget | null {
const reply = msg.reply_to_message;
if (!reply) return null;
const replyBody = (reply.text ?? reply.caption ?? "").trim();
let body = replyBody;
if (!body) {
if (reply.photo) body = "<media:image>";
else if (reply.video) body = "<media:video>";
else if (reply.audio || reply.voice) body = "<media:audio>";
else if (reply.document) body = "<media:document>";
else {
const locationData = extractTelegramLocation(reply);
if (locationData) body = formatLocationText(locationData);
const quote = msg.quote;
let body = "";
let kind: TelegramReplyTarget["kind"] = "reply";
if (quote?.text) {
body = quote.text.trim();
if (body) {
kind = "quote";
}
}
if (!body && reply) {
const replyBody = (reply.text ?? reply.caption ?? "").trim();
body = replyBody;
if (!body) {
if (reply.photo) body = "<media:image>";
else if (reply.video) body = "<media:video>";
else if (reply.audio || reply.voice) body = "<media:audio>";
else if (reply.document) body = "<media:document>";
else {
const locationData = extractTelegramLocation(reply);
if (locationData) body = formatLocationText(locationData);
}
}
}
if (!body) return null;
const sender = buildSenderName(reply);
const sender = reply ? buildSenderName(reply) : undefined;
const senderLabel = sender ? `${sender}` : "unknown sender";
return {
id: reply.message_id ? String(reply.message_id) : undefined,
id: reply?.message_id ? String(reply.message_id) : undefined,
sender: senderLabel,
body,
kind,
};
}
+7 -1
View File
@@ -1,6 +1,12 @@
import type { Message } from "@grammyjs/types";
export type TelegramMessage = Message;
export type TelegramQuote = {
text?: string;
};
export type TelegramMessage = Message & {
quote?: TelegramQuote;
};
export type TelegramStreamMode = "off" | "partial" | "block";
+19 -1
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;
+12
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);
+4
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",
+12 -2
View File
@@ -46,6 +46,8 @@ type TelegramSendOpts = {
silent?: boolean;
/** Message ID to reply to (for threading) */
replyToMessageId?: number;
/** Quote text for Telegram reply_parameters. */
quoteText?: string;
/** Forum topic thread ID (for forum supergroups) */
messageThreadId?: number;
/** Inline keyboard buttons (reply markup). */
@@ -198,9 +200,17 @@ export async function sendMessageTelegram(
const messageThreadId =
opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId;
const threadIdParams = buildTelegramThreadParams(messageThreadId);
const threadParams: Record<string, number> = threadIdParams ? { ...threadIdParams } : {};
const threadParams: Record<string, unknown> = threadIdParams ? { ...threadIdParams } : {};
const quoteText = opts.quoteText?.trim();
if (opts.replyToMessageId != null) {
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
if (quoteText) {
threadParams.reply_parameters = {
message_id: Math.trunc(opts.replyToMessageId),
quote: quoteText,
};
} else {
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
}
}
const hasThreadParams = Object.keys(threadParams).length > 0;
const request = createTelegramRetryRunner({
+15
View File
@@ -9,6 +9,7 @@ import {
jidToE164,
normalizeE164,
normalizePath,
resolveConfigDir,
resolveJidToE164,
resolveUserPath,
sleep,
@@ -120,6 +121,20 @@ describe("jidToE164", () => {
});
});
describe("resolveConfigDir", () => {
it("prefers ~/.moltbot when legacy dir is missing", async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-config-dir-"));
try {
const newDir = path.join(root, ".moltbot");
await fs.promises.mkdir(newDir, { recursive: true });
const resolved = resolveConfigDir({} as NodeJS.ProcessEnv, () => root);
expect(resolved).toBe(newDir);
} finally {
await fs.promises.rm(root, { recursive: true, force: true });
}
});
});
describe("resolveJidToE164", () => {
it("resolves @lid via lidLookup when mapping file is missing", async () => {
const lidLookup = {
+11 -2
View File
@@ -215,9 +215,18 @@ export function resolveConfigDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const override = env.CLAWDBOT_STATE_DIR?.trim();
const override = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (override) return resolveUserPath(override);
return path.join(homedir(), ".clawdbot");
const legacyDir = path.join(homedir(), ".clawdbot");
const newDir = path.join(homedir(), ".moltbot");
try {
const hasLegacy = fs.existsSync(legacyDir);
const hasNew = fs.existsSync(newDir);
if (!hasLegacy && hasNew) return newDir;
} catch {
// best-effort
}
return legacyDir;
}
export function resolveHomeDir(): string | undefined {
+2
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,