Merge branch 'main' into nanogpt
This commit is contained in:
642 files changed
+2988
-979
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"). */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -49,6 +49,7 @@ export type MsgContext = {
|
||||
ReplyToIdFull?: string;
|
||||
ReplyToBody?: string;
|
||||
ReplyToSender?: string;
|
||||
ReplyToIsQuote?: boolean;
|
||||
ForwardedFrom?: string;
|
||||
ForwardedFromType?: string;
|
||||
ForwardedFromId?: string;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
b6d3dea7c656c8a480059c32e954c4d39053ff79c4e9c69b38f4c04e3f0280d4
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -65,12 +65,12 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
|
||||
}
|
||||
|
||||
const LOBSTER_ASCII = [
|
||||
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
|
||||
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
|
||||
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
|
||||
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
|
||||
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
|
||||
" 🦞 FRESH DAILY 🦞",
|
||||
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
|
||||
"██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
|
||||
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
|
||||
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
|
||||
" 🦞 FRESH DAILY 🦞 ",
|
||||
];
|
||||
|
||||
export function formatCliBannerArt(options: BannerOptions = {}): string {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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))}`);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -64,12 +64,12 @@ export function randomToken(): string {
|
||||
|
||||
export function printWizardHeader(runtime: RuntimeEnv) {
|
||||
const header = [
|
||||
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
|
||||
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
|
||||
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
|
||||
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
|
||||
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
|
||||
" 🦞 FRESH DAILY 🦞",
|
||||
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
|
||||
"██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
|
||||
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
|
||||
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
|
||||
" 🦞 FRESH DAILY 🦞 ",
|
||||
].join("\n");
|
||||
runtime.log(header);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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) })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user