Merge branch 'main' into fix/secondary-agent-oauth-fallback

This commit is contained in:
Shakker
2026-01-27 02:17:50 +00:00
committed by GitHub
148 changed files with 2602 additions and 450 deletions

View File

@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { acquireSessionWriteLock } from "./session-write-lock.js";
import { __testing, acquireSessionWriteLock } from "./session-write-lock.js";
describe("acquireSessionWriteLock", () => {
it("reuses locks across symlinked session paths", async () => {
@@ -31,4 +31,132 @@ describe("acquireSessionWriteLock", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
it("keeps the lock file until the last release", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
const lockA = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
const lockB = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
await expect(fs.access(lockPath)).resolves.toBeUndefined();
await lockA.release();
await expect(fs.access(lockPath)).resolves.toBeUndefined();
await lockB.release();
await expect(fs.access(lockPath)).rejects.toThrow();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("reclaims stale lock files", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await fs.writeFile(
lockPath,
JSON.stringify({ pid: 123456, createdAt: new Date(Date.now() - 60_000).toISOString() }),
"utf8",
);
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
const raw = await fs.readFile(lockPath, "utf8");
const payload = JSON.parse(raw) as { pid: number };
expect(payload.pid).toBe(process.pid);
await lock.release();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("removes held locks on termination signals", async () => {
const signals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
for (const signal of signals) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-cleanup-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
const keepAlive = () => {};
if (signal === "SIGINT") {
process.on(signal, keepAlive);
}
__testing.handleTerminationSignal(signal);
await expect(fs.stat(lockPath)).rejects.toThrow();
if (signal === "SIGINT") {
process.off(signal, keepAlive);
}
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
});
it("registers cleanup for SIGQUIT and SIGABRT", () => {
expect(__testing.cleanupSignals).toContain("SIGQUIT");
expect(__testing.cleanupSignals).toContain("SIGABRT");
});
it("cleans up locks on SIGINT without removing other handlers", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
const originalKill = process.kill.bind(process);
const killCalls: Array<NodeJS.Signals | undefined> = [];
let otherHandlerCalled = false;
process.kill = ((pid: number, signal?: NodeJS.Signals) => {
killCalls.push(signal);
return true;
}) as typeof process.kill;
const otherHandler = () => {
otherHandlerCalled = true;
};
process.on("SIGINT", otherHandler);
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
process.emit("SIGINT");
await expect(fs.access(lockPath)).rejects.toThrow();
expect(otherHandlerCalled).toBe(true);
expect(killCalls).toEqual([]);
} finally {
process.off("SIGINT", otherHandler);
process.kill = originalKill;
await fs.rm(root, { recursive: true, force: true });
}
});
it("cleans up locks on exit", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
process.emit("exit", 0);
await expect(fs.access(lockPath)).rejects.toThrow();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("keeps other signal listeners registered", () => {
const keepAlive = () => {};
process.on("SIGINT", keepAlive);
__testing.handleTerminationSignal("SIGINT");
expect(process.listeners("SIGINT")).toContain(keepAlive);
process.off("SIGINT", keepAlive);
});
});

View File

@@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
@@ -13,6 +14,9 @@ type HeldLock = {
};
const HELD_LOCKS = new Map<string, HeldLock>();
const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
type CleanupSignal = (typeof CLEANUP_SIGNALS)[number];
const cleanupHandlers = new Map<CleanupSignal, () => void>();
function isAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false;
@@ -24,6 +28,65 @@ function isAlive(pid: number): boolean {
}
}
/**
* Synchronously release all held locks.
* Used during process exit when async operations aren't reliable.
*/
function releaseAllLocksSync(): void {
for (const [sessionFile, held] of HELD_LOCKS) {
try {
if (typeof held.handle.fd === "number") {
fsSync.closeSync(held.handle.fd);
}
} catch {
// Ignore errors during cleanup - best effort
}
try {
fsSync.rmSync(held.lockPath, { force: true });
} catch {
// Ignore errors during cleanup - best effort
}
HELD_LOCKS.delete(sessionFile);
}
}
let cleanupRegistered = false;
function handleTerminationSignal(signal: CleanupSignal): void {
releaseAllLocksSync();
const shouldReraise = process.listenerCount(signal) === 1;
if (shouldReraise) {
const handler = cleanupHandlers.get(signal);
if (handler) process.off(signal, handler);
try {
process.kill(process.pid, signal);
} catch {
// Ignore errors during shutdown
}
}
}
function registerCleanupHandlers(): void {
if (cleanupRegistered) return;
cleanupRegistered = true;
// Cleanup on normal exit and process.exit() calls
process.on("exit", () => {
releaseAllLocksSync();
});
// Handle termination signals
for (const signal of CLEANUP_SIGNALS) {
try {
const handler = () => handleTerminationSignal(signal);
cleanupHandlers.set(signal, handler);
process.on(signal, handler);
} catch {
// Ignore unsupported signals on this platform.
}
}
}
async function readLockPayload(lockPath: string): Promise<LockFilePayload | null> {
try {
const raw = await fs.readFile(lockPath, "utf8");
@@ -43,6 +106,7 @@ export async function acquireSessionWriteLock(params: {
}): Promise<{
release: () => Promise<void>;
}> {
registerCleanupHandlers();
const timeoutMs = params.timeoutMs ?? 10_000;
const staleMs = params.staleMs ?? 30 * 60 * 1000;
const sessionFile = path.resolve(params.sessionFile);
@@ -116,3 +180,9 @@ export async function acquireSessionWriteLock(params: {
const owner = payload?.pid ? `pid=${payload.pid}` : "unknown";
throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`);
}
export const __testing = {
cleanupSignals: [...CLEANUP_SIGNALS],
handleTerminationSignal,
releaseAllLocksSync,
};

View File

@@ -3,6 +3,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import {
deleteMessageTelegram,
editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
} from "../../telegram/send.js";
@@ -209,5 +210,50 @@ export async function handleTelegramAction(
return jsonResult({ ok: true, deleted: true });
}
if (action === "editMessage") {
if (!isActionEnabled("editMessage")) {
throw new Error("Telegram editMessage is disabled.");
}
const chatId = readStringOrNumberParam(params, "chatId", {
required: true,
});
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
});
const content = readStringParam(params, "content", {
required: true,
allowEmpty: false,
});
const buttons = readTelegramButtons(params);
if (buttons) {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
accountId: accountId ?? undefined,
});
if (inlineButtonsScope === "off") {
throw new Error(
'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".',
);
}
}
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, {
token,
accountId: accountId ?? undefined,
buttons,
});
return jsonResult({
ok: true,
messageId: result.messageId,
chatId: result.chatId,
});
}
throw new Error(`Unsupported Telegram action: ${action}`);
}

View File

@@ -181,9 +181,44 @@ function buildChatCommands(): ChatCommandDefinition[] {
defineChatCommand({
key: "tts",
nativeName: "tts",
description: "Configure text-to-speech.",
description: "Control text-to-speech (TTS).",
textAlias: "/tts",
acceptsArgs: true,
args: [
{
name: "action",
description: "TTS action",
type: "string",
choices: [
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
{ value: "status", label: "Status" },
{ value: "provider", label: "Provider" },
{ value: "limit", label: "Limit" },
{ value: "summary", label: "Summary" },
{ value: "audio", label: "Audio" },
{ value: "help", label: "Help" },
],
},
{
name: "value",
description: "Provider, limit, or text",
type: "string",
captureRemaining: true,
},
],
argsMenu: {
arg: "action",
title:
"TTS Actions:\n" +
"• On Enable TTS for responses\n" +
"• Off Disable TTS\n" +
"• Status Show current settings\n" +
"• Provider Set voice provider (edge, elevenlabs, openai)\n" +
"• Limit Set max characters for TTS\n" +
"• Summary Toggle AI summary for long texts\n" +
"• Audio Generate TTS from custom text\n" +
"• Help Show usage guide",
},
}),
defineChatCommand({
key: "whoami",

View File

@@ -229,7 +229,12 @@ describe("commands registry args", () => {
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
expect(menu?.choices).toEqual([
{ label: "off", value: "off" },
{ label: "tokens", value: "tokens" },
{ label: "full", value: "full" },
{ label: "cost", value: "cost" },
]);
});
it("does not show menus when arg already provided", () => {
@@ -284,7 +289,10 @@ describe("commands registry args", () => {
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level");
expect(menu?.choices).toEqual(["low", "high"]);
expect(menu?.choices).toEqual([
{ label: "low", value: "low" },
{ label: "high", value: "high" },
]);
expect(seen?.commandKey).toBe("think");
expect(seen?.argName).toBe("level");
expect(seen?.provider).toBeTruthy();

View File

@@ -255,33 +255,41 @@ function resolveDefaultCommandContext(cfg?: ClawdbotConfig): {
};
}
export type ResolvedCommandArgChoice = { value: string; label: string };
export function resolveCommandArgChoices(params: {
command: ChatCommandDefinition;
arg: CommandArgDefinition;
cfg?: ClawdbotConfig;
provider?: string;
model?: string;
}): string[] {
}): ResolvedCommandArgChoice[] {
const { command, arg, cfg } = params;
if (!arg.choices) return [];
const provided = arg.choices;
if (Array.isArray(provided)) return provided;
const defaults = resolveDefaultCommandContext(cfg);
const context: CommandArgChoiceContext = {
cfg,
provider: params.provider ?? defaults.provider,
model: params.model ?? defaults.model,
command,
arg,
};
return provided(context);
const raw = Array.isArray(provided)
? provided
: (() => {
const defaults = resolveDefaultCommandContext(cfg);
const context: CommandArgChoiceContext = {
cfg,
provider: params.provider ?? defaults.provider,
model: params.model ?? defaults.model,
command,
arg,
};
return provided(context);
})();
return raw.map((choice) =>
typeof choice === "string" ? { value: choice, label: choice } : choice,
);
}
export function resolveCommandArgMenu(params: {
command: ChatCommandDefinition;
args?: CommandArgs;
cfg?: ClawdbotConfig;
}): { arg: CommandArgDefinition; choices: string[]; title?: string } | null {
}): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null {
const { command, args, cfg } = params;
if (!command.args || !command.argsMenu) return null;
if (command.argsParsing === "none") return null;

View File

@@ -12,14 +12,16 @@ export type CommandArgChoiceContext = {
arg: CommandArgDefinition;
};
export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => string[];
export type CommandArgChoice = string | { value: string; label: string };
export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => CommandArgChoice[];
export type CommandArgDefinition = {
name: string;
description: string;
type: CommandArgType;
required?: boolean;
choices?: string[] | CommandArgChoicesProvider;
choices?: CommandArgChoice[] | CommandArgChoicesProvider;
captureRemaining?: boolean;
};

View File

@@ -89,6 +89,7 @@ export async function runAgentTurnWithFallback(params: {
registerAgentRunContext(runId, {
sessionKey: params.sessionKey,
verboseLevel: params.resolvedVerboseLevel,
isHeartbeat: params.isHeartbeat,
});
}
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;

View File

@@ -6,20 +6,18 @@ import {
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
isTtsProviderConfigured,
normalizeTtsAutoMode,
resolveTtsAutoMode,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
resolveTtsProviderOrder,
setLastTtsAttempt,
setSummarizationEnabled,
setTtsEnabled,
setTtsMaxLength,
setTtsProvider,
textToSpeech,
} from "../../tts/tts.js";
import { updateSessionStore } from "../../config/sessions.js";
type ParsedTtsCommand = {
action: string;
@@ -40,14 +38,27 @@ function ttsUsage(): ReplyPayload {
// Keep usage in one place so help/validation stays consistent.
return {
text:
"⚙️ Usage: /tts <off|always|inbound|tagged|status|provider|limit|summary|audio> [value]" +
"\nExamples:\n" +
"/tts always\n" +
"/tts provider openai\n" +
"/tts provider edge\n" +
"/tts limit 2000\n" +
"/tts summary off\n" +
"/tts audio Hello from Clawdbot",
`🔊 **TTS (Text-to-Speech) Help**\n\n` +
`**Commands:**\n` +
`• /tts on — Enable automatic TTS for replies\n` +
`• /tts off — Disable TTS\n` +
`• /tts status — Show current settings\n` +
`• /tts provider [name] — View/change provider\n` +
`• /tts limit [number] — View/change text limit\n` +
`• /tts summary [on|off] — View/change auto-summary\n` +
`• /tts audio <text> — Generate audio from text\n\n` +
`**Providers:**\n` +
`• edge — Free, fast (default)\n` +
`• openai — High quality (requires API key)\n` +
`• elevenlabs — Premium voices (requires API key)\n\n` +
`**Text Limit (default: 1500, max: 4096):**\n` +
`When text exceeds the limit:\n` +
`• Summary ON: AI summarizes, then generates audio\n` +
`• Summary OFF: Truncates text, then generates audio\n\n` +
`**Examples:**\n` +
`/tts provider edge\n` +
`/tts limit 2000\n` +
`/tts audio Hello, this is a test!`,
};
}
@@ -72,35 +83,27 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
return { shouldContinue: false, reply: ttsUsage() };
}
const requestedAuto = normalizeTtsAutoMode(
action === "on" ? "always" : action === "off" ? "off" : action,
);
if (requestedAuto) {
const entry = params.sessionEntry;
const sessionKey = params.sessionKey;
const store = params.sessionStore;
if (entry && store && sessionKey) {
entry.ttsAuto = requestedAuto;
entry.updatedAt = Date.now();
store[sessionKey] = entry;
if (params.storePath) {
await updateSessionStore(params.storePath, (store) => {
store[sessionKey] = entry;
});
}
}
const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto;
return {
shouldContinue: false,
reply: {
text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`,
},
};
if (action === "on") {
setTtsEnabled(prefsPath, true);
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
}
if (action === "off") {
setTtsEnabled(prefsPath, false);
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
}
if (action === "audio") {
if (!args.trim()) {
return { shouldContinue: false, reply: ttsUsage() };
return {
shouldContinue: false,
reply: {
text:
`🎤 Generate audio from text.\n\n` +
`Usage: /tts audio <text>\n` +
`Example: /tts audio Hello, this is a test!`,
},
};
}
const start = Date.now();
@@ -146,9 +149,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "provider") {
const currentProvider = getTtsProvider(config, prefsPath);
if (!args.trim()) {
const fallback = resolveTtsProviderOrder(currentProvider)
.slice(1)
.filter((provider) => isTtsProviderConfigured(config, provider));
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
const hasEdge = isTtsProviderConfigured(config, "edge");
@@ -158,7 +158,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
text:
`🎙️ TTS provider\n` +
`Primary: ${currentProvider}\n` +
`Fallbacks: ${fallback.join(", ") || "none"}\n` +
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
@@ -173,18 +172,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
setTtsProvider(prefsPath, requested);
const fallback = resolveTtsProviderOrder(requested)
.slice(1)
.filter((provider) => isTtsProviderConfigured(config, provider));
return {
shouldContinue: false,
reply: {
text:
`✅ TTS provider set to ${requested} (fallbacks: ${fallback.join(", ") || "none"}).` +
(requested === "edge"
? "\nEnable Edge TTS in config: messages.tts.edge.enabled = true."
: ""),
},
reply: { text: `✅ TTS provider set to ${requested}.` },
};
}
@@ -193,12 +183,22 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
const currentLimit = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
reply: { text: `📏 TTS limit: ${currentLimit} characters.` },
reply: {
text:
`📏 TTS limit: ${currentLimit} characters.\n\n` +
`Text longer than this triggers summary (if enabled).\n` +
`Range: 100-4096 chars (Telegram max).\n\n` +
`To change: /tts limit <number>\n` +
`Example: /tts limit 2000`,
},
};
}
const next = Number.parseInt(args.trim(), 10);
if (!Number.isFinite(next) || next < 100 || next > 10_000) {
return { shouldContinue: false, reply: ttsUsage() };
if (!Number.isFinite(next) || next < 100 || next > 4096) {
return {
shouldContinue: false,
reply: { text: "❌ Limit must be between 100 and 4096 characters." },
};
}
setTtsMaxLength(prefsPath, next);
return {
@@ -210,9 +210,17 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "summary") {
if (!args.trim()) {
const enabled = isSummarizationEnabled(prefsPath);
const maxLen = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` },
reply: {
text:
`📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` +
`When text exceeds ${maxLen} chars:\n` +
`• ON: summarizes text, then generates audio\n` +
`• OFF: truncates text, then generates audio\n\n` +
`To change: /tts summary on | off`,
},
};
}
const requested = args.trim().toLowerCase();
@@ -229,27 +237,16 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
if (action === "status") {
const sessionAuto = params.sessionEntry?.ttsAuto;
const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
const enabled = autoMode !== "off";
const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(config, prefsPath);
const hasKey = isTtsProviderConfigured(config, provider);
const providerStatus =
provider === "edge"
? hasKey
? "✅ enabled"
: "❌ disabled"
: hasKey
? "✅ key"
: "❌ no key";
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();
const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
const lines = [
"📊 TTS status",
`Auto: ${enabled ? autoLabel : "off"}`,
`Provider: ${provider} (${providerStatus})`,
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
`Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`,
`Text limit: ${maxLength} chars`,
`Auto-summary: ${summarize ? "on" : "off"}`,
];

View File

@@ -420,3 +420,17 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Status: done");
});
});
describe("handleCommands /tts", () => {
it("returns status for bare /tts on text command surfaces", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
messages: { tts: { prefsPath: path.join(testWorkspaceDir, "tts.json") } },
} as ClawdbotConfig;
const params = buildParams("/tts", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("TTS status");
});
});

View File

@@ -16,7 +16,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js";
import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js";
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
const AUDIO_HEADER_RE = /^\[Audio\b/i;
@@ -266,12 +266,26 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal, counts };
}
// Track accumulated block text for TTS generation after streaming completes.
// When block streaming succeeds, there's no final reply, so we need to generate
// TTS audio separately from the accumulated block content.
let accumulatedBlockText = "";
let blockCount = 0;
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx,
{
...params.replyOptions,
onBlockReply: (payload: ReplyPayload, context) => {
const run = async () => {
// Accumulate block text for TTS generation after streaming
if (payload.text) {
if (accumulatedBlockText.length > 0) {
accumulatedBlockText += "\n";
}
accumulatedBlockText += payload.text;
blockCount++;
}
const ttsPayload = await maybeApplyTtsToPayload({
payload,
cfg,
@@ -327,6 +341,62 @@ export async function dispatchReplyFromConfig(params: {
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
}
}
const ttsMode = resolveTtsConfig(cfg).mode ?? "final";
// Generate TTS-only reply after block streaming completes (when there's no final reply).
// This handles the case where block streaming succeeds and drops final payloads,
// but we still want TTS audio to be generated from the accumulated block content.
if (
ttsMode === "final" &&
replies.length === 0 &&
blockCount > 0 &&
accumulatedBlockText.trim()
) {
try {
const ttsSyntheticReply = await maybeApplyTtsToPayload({
payload: { text: accumulatedBlockText },
cfg,
channel: ttsChannel,
kind: "final",
inboundAudio,
ttsAuto: sessionTtsAuto,
});
// Only send if TTS was actually applied (mediaUrl exists)
if (ttsSyntheticReply.mediaUrl) {
// Send TTS-only payload (no text, just audio) so it doesn't duplicate the block content
const ttsOnlyPayload: ReplyPayload = {
mediaUrl: ttsSyntheticReply.mediaUrl,
audioAsVoice: ttsSyntheticReply.audioAsVoice,
};
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
const result = await routeReply({
payload: ttsOnlyPayload,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
});
queuedFinal = result.ok || queuedFinal;
if (result.ok) routedFinalCount += 1;
if (!result.ok) {
logVerbose(
`dispatch-from-config: route-reply (tts-only) failed: ${result.error ?? "unknown error"}`,
);
}
} else {
const didQueue = dispatcher.sendFinalReply(ttsOnlyPayload);
queuedFinal = didQueue || queuedFinal;
}
}
} catch (err) {
logVerbose(
`dispatch-from-config: accumulated block TTS failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();

View File

@@ -3,6 +3,26 @@ import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
/** Maximum number of group history keys to retain (LRU eviction when exceeded). */
export const MAX_HISTORY_KEYS = 1000;
/**
* Evict oldest keys from a history map when it exceeds MAX_HISTORY_KEYS.
* Uses Map's insertion order for LRU-like behavior.
*/
export function evictOldHistoryKeys<T>(
historyMap: Map<string, T[]>,
maxKeys: number = MAX_HISTORY_KEYS,
): void {
if (historyMap.size <= maxKeys) return;
const keysToDelete = historyMap.size - maxKeys;
const iterator = historyMap.keys();
for (let i = 0; i < keysToDelete; i++) {
const key = iterator.next().value;
if (key !== undefined) historyMap.delete(key);
}
}
export type HistoryEntry = {
sender: string;
body: string;
@@ -34,7 +54,13 @@ export function appendHistoryEntry<T extends HistoryEntry>(params: {
const history = historyMap.get(historyKey) ?? [];
history.push(entry);
while (history.length > params.limit) history.shift();
if (historyMap.has(historyKey)) {
// Refresh insertion order so eviction keeps recently used histories.
historyMap.delete(historyKey);
}
historyMap.set(historyKey, history);
// Evict oldest keys if map exceeds max size to prevent unbounded memory growth
evictOldHistoryKeys(historyMap);
return history;
}

View File

@@ -21,7 +21,11 @@ export async function prependSystemEvents(params: {
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
if (lower.includes("reason periodic")) return null;
if (lower.includes("heartbeat")) return null;
// Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat"
// The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this
if (lower.startsWith("read heartbeat.md")) return null;
// Also filter heartbeat poll/wake noise
if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) return null;
if (trimmed.startsWith("Node:")) {
return trimmed.replace(/ · last input [^·]+/i, "").trim();
}

View File

@@ -62,4 +62,53 @@ describe("telegramMessageActions", () => {
cfg,
);
});
it("maps edit action params into editMessage", async () => {
handleTelegramAction.mockClear();
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
await telegramMessageActions.handleAction({
action: "edit",
params: {
chatId: "123",
messageId: 42,
message: "Updated",
buttons: [],
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
{
action: "editMessage",
chatId: "123",
messageId: 42,
content: "Updated",
buttons: [],
accountId: undefined,
},
cfg,
);
});
it("rejects non-integer messageId for edit before reaching telegram-actions", async () => {
handleTelegramAction.mockClear();
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
await expect(
telegramMessageActions.handleAction({
action: "edit",
params: {
chatId: "123",
messageId: "nope",
message: "Updated",
},
cfg,
accountId: undefined,
}),
).rejects.toThrow();
expect(handleTelegramAction).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import {
createActionGate,
readNumberParam,
readStringOrNumberParam,
readStringParam,
} from "../../../agents/tools/common.js";
@@ -43,6 +44,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
const actions = new Set<ChannelMessageActionName>(["send"]);
if (gate("reactions")) actions.add("react");
if (gate("deleteMessage")) actions.add("delete");
if (gate("editMessage")) actions.add("edit");
return Array.from(actions);
},
supportsButtons: ({ cfg }) => {
@@ -100,14 +102,39 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
const messageId = readStringParam(params, "messageId", {
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
});
return await handleTelegramAction(
{
action: "deleteMessage",
chatId,
messageId: Number(messageId),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "edit") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
});
const message = readStringParam(params, "message", { required: true, allowEmpty: false });
const buttons = params.buttons;
return await handleTelegramAction(
{
action: "editMessage",
chatId,
messageId,
content: message,
buttons,
accountId: accountId ?? undefined,
},
cfg,

View File

@@ -80,14 +80,20 @@ async function promptTelegramAllowFrom(params: {
if (!token) return null;
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
const res = await fetch(url);
const data = (await res.json().catch(() => null)) as {
ok?: boolean;
result?: { id?: number | string };
} | null;
const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") return String(id);
return null;
try {
const res = await fetch(url);
if (!res.ok) return null;
const data = (await res.json().catch(() => null)) as {
ok?: boolean;
result?: { id?: number | string };
} | null;
const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") return String(id);
return null;
} catch {
// Network error during username lookup - return null to prompt user for numeric ID
return null;
}
};
const parseInput = (value: string) =>

View File

@@ -78,6 +78,48 @@ describe("argv helpers", () => {
});
expect(nodeArgv).toEqual(["node", "clawdbot", "status"]);
const versionedNodeArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22", "clawdbot", "status"],
});
expect(versionedNodeArgv).toEqual(["node-22", "clawdbot", "status"]);
const versionedNodeWindowsArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22.2.0.exe", "clawdbot", "status"],
});
expect(versionedNodeWindowsArgv).toEqual(["node-22.2.0.exe", "clawdbot", "status"]);
const versionedNodePatchlessArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22.2", "clawdbot", "status"],
});
expect(versionedNodePatchlessArgv).toEqual(["node-22.2", "clawdbot", "status"]);
const versionedNodeWindowsPatchlessArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22.2.exe", "clawdbot", "status"],
});
expect(versionedNodeWindowsPatchlessArgv).toEqual(["node-22.2.exe", "clawdbot", "status"]);
const versionedNodeWithPathArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["/usr/bin/node-22.2.0", "clawdbot", "status"],
});
expect(versionedNodeWithPathArgv).toEqual(["/usr/bin/node-22.2.0", "clawdbot", "status"]);
const nodejsArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["nodejs", "clawdbot", "status"],
});
expect(nodejsArgv).toEqual(["nodejs", "clawdbot", "status"]);
const nonVersionedNodeArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-dev", "clawdbot", "status"],
});
expect(nonVersionedNodeArgv).toEqual(["node", "clawdbot", "node-dev", "clawdbot", "status"]);
const directArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["clawdbot", "status"],

View File

@@ -96,15 +96,27 @@ export function buildParseArgv(params: {
: baseArgv;
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
const looksLikeNode =
normalizedArgv.length >= 2 &&
(executable === "node" ||
executable === "node.exe" ||
executable === "bun" ||
executable === "bun.exe");
normalizedArgv.length >= 2 && (isNodeExecutable(executable) || isBunExecutable(executable));
if (looksLikeNode) return normalizedArgv;
return ["node", programName || "clawdbot", ...normalizedArgv];
}
const nodeExecutablePattern = /^node-\d+(?:\.\d+)*(?:\.exe)?$/;
function isNodeExecutable(executable: string): boolean {
return (
executable === "node" ||
executable === "node.exe" ||
executable === "nodejs" ||
executable === "nodejs.exe" ||
nodeExecutablePattern.test(executable)
);
}
function isBunExecutable(executable: string): boolean {
return executable === "bun" || executable === "bun.exe";
}
export function shouldMigrateStateFromPath(path: string[]): boolean {
if (path.length === 0) return true;
const [primary, secondary] = path;

View File

@@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
import { getPrimaryCommand } from "./argv.js";
import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
@@ -56,6 +56,15 @@ export async function runCli(argv: string[] = process.argv) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
const shouldSkipPluginRegistration = !primary && hasHelpOrVersion(parseArgv);
if (!shouldSkipPluginRegistration) {
// Register plugin CLI commands before parsing
const { registerPluginCliCommands } = await import("../plugins/cli.js");
const { loadConfig } = await import("../config/config.js");
registerPluginCliCommands(program, loadConfig());
}
await program.parseAsync(parseArgv);
}

View File

@@ -310,6 +310,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
"channels.telegram.retry.jitter": "Telegram Retry Jitter",
"channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily",
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
@@ -643,6 +644,8 @@ const FIELD_HELP: Record<string, string> = {
"channels.telegram.retry.maxDelayMs":
"Maximum retry delay cap in ms for Telegram outbound calls.",
"channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.",
"channels.telegram.network.autoSelectFamily":
"Override Node autoSelectFamily for Telegram (true=enable, false=disable).",
"channels.telegram.timeoutSeconds":
"Max seconds before Telegram API requests are aborted (default: 500 per grammY).",
"channels.whatsapp.dmPolicy":

View File

@@ -15,6 +15,12 @@ export type TelegramActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
deleteMessage?: boolean;
editMessage?: boolean;
};
export type TelegramNetworkConfig = {
/** Override Node's autoSelectFamily behavior (true = enable, false = disable). */
autoSelectFamily?: boolean;
};
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
@@ -95,6 +101,8 @@ export type TelegramAccountConfig = {
timeoutSeconds?: number;
/** Retry policy for outbound Telegram API calls. */
retry?: OutboundRetryConfig;
/** Network transport overrides for Telegram. */
network?: TelegramNetworkConfig;
proxy?: string;
webhookUrl?: string;
webhookSecret?: string;

View File

@@ -110,6 +110,12 @@ export const TelegramAccountSchemaBase = z
mediaMaxMb: z.number().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
retry: RetryConfigSchema,
network: z
.object({
autoSelectFamily: z.boolean().optional(),
})
.strict()
.optional(),
proxy: z.string().optional(),
webhookUrl: z.string().optional(),
webhookSecret: z.string().optional(),

View File

@@ -93,16 +93,18 @@ function buildDiscordCommandOptions(params: {
typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : "";
const choices = resolveCommandArgChoices({ command, arg, cfg });
const filtered = focusValue
? choices.filter((choice) => choice.toLowerCase().includes(focusValue))
? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue))
: choices;
await interaction.respond(
filtered.slice(0, 25).map((choice) => ({ name: choice, value: choice })),
filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),
);
}
: undefined;
const choices =
resolvedChoices.length > 0 && !autocomplete
? resolvedChoices.slice(0, 25).map((choice) => ({ name: choice, value: choice }))
? resolvedChoices
.slice(0, 25)
.map((choice) => ({ name: choice.label, value: choice.value }))
: undefined;
return {
name: arg.name,
@@ -351,7 +353,11 @@ export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgC
function buildDiscordCommandArgMenu(params: {
command: ChatCommandDefinition;
menu: { arg: CommandArgDefinition; choices: string[]; title?: string };
menu: {
arg: CommandArgDefinition;
choices: Array<{ value: string; label: string }>;
title?: string;
};
interaction: CommandInteraction;
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
@@ -365,11 +371,11 @@ function buildDiscordCommandArgMenu(params: {
const buttons = choices.map(
(choice) =>
new DiscordCommandArgButton({
label: choice,
label: choice.label,
customId: buildDiscordCommandArgCustomId({
command: commandLabel,
arg: menu.arg.name,
value: choice,
value: choice.value,
userId,
}),
cfg: params.cfg,

View File

@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
function readTerminalCss() {
// This test is intentionally simple: it guards against regressions where the
// docs header stops being sticky because sticky elements live inside an
// overflow-clipped container.
const path = join(process.cwd(), "docs", "assets", "terminal.css");
return readFileSync(path, "utf8");
}
describe("docs terminal.css", () => {
test("keeps the docs header sticky (shell is sticky)", () => {
const css = readTerminalCss();
expect(css).toMatch(/\.shell\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*\}/s);
});
test("does not rely on making body overflow visible", () => {
const css = readTerminalCss();
expect(css).not.toMatch(/body\s*\{[^}]*overflow-x:\s*visible;[^}]*\}/s);
});
test("does not make the terminal frame overflow visible (can break layout)", () => {
const css = readTerminalCss();
expect(css).not.toMatch(/\.shell__frame\s*\{[^}]*overflow:\s*visible;[^}]*\}/s);
});
});

View File

@@ -1,8 +1,28 @@
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
import { loadConfig } from "../config/config.js";
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
/**
* Check if webchat broadcasts should be suppressed for heartbeat runs.
* Returns true if the run is a heartbeat and showOk is false.
*/
function shouldSuppressHeartbeatBroadcast(runId: string): boolean {
const runContext = getAgentRunContext(runId);
if (!runContext?.isHeartbeat) return false;
try {
const cfg = loadConfig();
const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
return !visibility.showOk;
} catch {
// Default to suppressing if we can't load config
return true;
}
}
export type ChatRunEntry = {
sessionKey: string;
clientRunId: string;
@@ -130,7 +150,10 @@ export function createAgentEventHandler({
timestamp: now,
},
};
broadcast("chat", payload, { dropIfSlow: true });
// Suppress webchat broadcast for heartbeat runs when showOk is false
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
broadcast("chat", payload, { dropIfSlow: true });
}
nodeSendToSession(sessionKey, "chat", payload);
};
@@ -158,7 +181,10 @@ export function createAgentEventHandler({
}
: undefined,
};
broadcast("chat", payload);
// Suppress webchat broadcast for heartbeat runs when showOk is false
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
broadcast("chat", payload);
}
nodeSendToSession(sessionKey, "chat", payload);
return;
}

View File

@@ -291,10 +291,10 @@ export function createGatewayHttpServer(opts: {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
} catch (err) {
} catch {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(String(err));
res.end("Internal Server Error");
}
}

View File

@@ -14,6 +14,7 @@ export type AgentEventPayload = {
export type AgentRunContext = {
sessionKey?: string;
verboseLevel?: VerboseLevel;
isHeartbeat?: boolean;
};
// Keep per-run counters so streams stay strictly monotonic per runId.
@@ -34,6 +35,9 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext)
if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) {
existing.verboseLevel = context.verboseLevel;
}
if (context.isHeartbeat !== undefined && existing.isHeartbeat !== context.isHeartbeat) {
existing.isHeartbeat = context.isHeartbeat;
}
}
export function getAgentRunContext(runId: string) {

View File

@@ -333,6 +333,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: { every: "5m", target: "whatsapp" },
},
},
@@ -461,6 +462,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "last",
@@ -542,6 +544,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: { every: "5m", target: "whatsapp" },
},
},
@@ -597,6 +600,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
@@ -668,6 +672,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
@@ -737,7 +742,7 @@ describe("runHeartbeatOnce", () => {
try {
const cfg: ClawdbotConfig = {
agents: {
defaults: { heartbeat: { every: "5m" } },
defaults: { workspace: tmpDir, heartbeat: { every: "5m" } },
list: [{ id: "work", default: true }],
},
channels: { whatsapp: { allowFrom: ["*"] } },

View File

@@ -247,4 +247,58 @@ describe("resolveHeartbeatVisibility", () => {
useIndicator: true,
});
});
it("webchat uses channel defaults only (no per-channel config)", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: true,
showAlerts: false,
useIndicator: false,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
expect(result).toEqual({
showOk: true,
showAlerts: false,
useIndicator: false,
});
});
it("webchat returns defaults when no channel defaults configured", () => {
const cfg = {} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
expect(result).toEqual({
showOk: false,
showAlerts: true,
useIndicator: true,
});
});
it("webchat ignores accountId (only uses defaults)", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: true,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({
cfg,
channel: "webchat",
accountId: "some-account",
});
expect(result.showOk).toBe(true);
});
});

View File

@@ -1,6 +1,6 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js";
import type { DeliverableMessageChannel } from "../utils/message-channel.js";
import type { GatewayMessageChannel } from "../utils/message-channel.js";
export type ResolvedHeartbeatVisibility = {
showOk: boolean;
@@ -14,13 +14,28 @@ const DEFAULT_VISIBILITY: ResolvedHeartbeatVisibility = {
useIndicator: true, // Emit indicator events
};
/**
* Resolve heartbeat visibility settings for a channel.
* Supports both deliverable channels (telegram, signal, etc.) and webchat.
* For webchat, uses channels.defaults.heartbeat since webchat doesn't have per-channel config.
*/
export function resolveHeartbeatVisibility(params: {
cfg: ClawdbotConfig;
channel: DeliverableMessageChannel;
channel: GatewayMessageChannel;
accountId?: string;
}): ResolvedHeartbeatVisibility {
const { cfg, channel, accountId } = params;
// Webchat uses channel defaults only (no per-channel or per-account config)
if (channel === "webchat") {
const channelDefaults = cfg.channels?.defaults?.heartbeat;
return {
showOk: channelDefaults?.showOk ?? DEFAULT_VISIBILITY.showOk,
showAlerts: channelDefaults?.showAlerts ?? DEFAULT_VISIBILITY.showAlerts,
useIndicator: channelDefaults?.useIndicator ?? DEFAULT_VISIBILITY.useIndicator,
};
}
// Layer 1: Global channel defaults
const channelDefaults = cfg.channels?.defaults?.heartbeat;

View File

@@ -37,10 +37,10 @@ function schedule(coalesceMs: number) {
pendingReason = reason ?? "retry";
schedule(DEFAULT_RETRY_MS);
}
} catch (err) {
} catch {
// Error is already logged by the heartbeat runner; schedule a retry.
pendingReason = reason ?? "retry";
schedule(DEFAULT_RETRY_MS);
throw err;
} finally {
running = false;
if (pendingReason || scheduled) schedule(coalesceMs);

View File

@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTelegramRetryRunner } from "./retry-policy.js";
describe("createTelegramRetryRunner", () => {
afterEach(() => {
vi.useRealTimers();
});
it("retries when custom shouldRetry matches non-telegram error", async () => {
vi.useFakeTimers();
const runner = createTelegramRetryRunner({
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
shouldRetry: (err) => err instanceof Error && err.message === "boom",
});
const fn = vi
.fn<[], Promise<string>>()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValue("ok");
const promise = runner(fn, "request");
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
});
});

View File

@@ -72,16 +72,21 @@ export function createTelegramRetryRunner(params: {
retry?: RetryConfig;
configRetry?: RetryConfig;
verbose?: boolean;
shouldRetry?: (err: unknown) => boolean;
}): RetryRunner {
const retryConfig = resolveRetryConfig(TELEGRAM_RETRY_DEFAULTS, {
...params.configRetry,
...params.retry,
});
const shouldRetry = params.shouldRetry
? (err: unknown) => params.shouldRetry?.(err) || TELEGRAM_RETRY_RE.test(formatErrorMessage(err))
: (err: unknown) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err));
return <T>(fn: () => Promise<T>, label?: string) =>
retryAsync(fn, {
...retryConfig,
label,
shouldRetry: (err) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err)),
shouldRetry,
retryAfterMs: getTelegramRetryAfterMs,
onRetry: params.verbose
? (info) => {

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import { isAbortError, isTransientNetworkError } from "./unhandled-rejections.js";
describe("isAbortError", () => {
it("returns true for error with name AbortError", () => {
const error = new Error("aborted");
error.name = "AbortError";
expect(isAbortError(error)).toBe(true);
});
it('returns true for error with "This operation was aborted" message', () => {
const error = new Error("This operation was aborted");
expect(isAbortError(error)).toBe(true);
});
it("returns true for undici-style AbortError", () => {
// Node's undici throws errors with this exact message
const error = Object.assign(new Error("This operation was aborted"), { name: "AbortError" });
expect(isAbortError(error)).toBe(true);
});
it("returns true for object with AbortError name", () => {
expect(isAbortError({ name: "AbortError", message: "test" })).toBe(true);
});
it("returns false for regular errors", () => {
expect(isAbortError(new Error("Something went wrong"))).toBe(false);
expect(isAbortError(new TypeError("Cannot read property"))).toBe(false);
expect(isAbortError(new RangeError("Invalid array length"))).toBe(false);
});
it("returns false for errors with similar but different messages", () => {
expect(isAbortError(new Error("Operation aborted"))).toBe(false);
expect(isAbortError(new Error("aborted"))).toBe(false);
expect(isAbortError(new Error("Request was aborted"))).toBe(false);
});
it("returns false for null and undefined", () => {
expect(isAbortError(null)).toBe(false);
expect(isAbortError(undefined)).toBe(false);
});
it("returns false for non-error values", () => {
expect(isAbortError("string error")).toBe(false);
expect(isAbortError(42)).toBe(false);
});
it("returns false for plain objects without AbortError name", () => {
expect(isAbortError({ message: "plain object" })).toBe(false);
});
});
describe("isTransientNetworkError", () => {
it("returns true for errors with transient network codes", () => {
const codes = [
"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",
];
for (const code of codes) {
const error = Object.assign(new Error("test"), { code });
expect(isTransientNetworkError(error), `code: ${code}`).toBe(true);
}
});
it('returns true for TypeError with "fetch failed" message', () => {
const error = new TypeError("fetch failed");
expect(isTransientNetworkError(error)).toBe(true);
});
it("returns true for fetch failed with network cause", () => {
const cause = Object.assign(new Error("getaddrinfo ENOTFOUND"), { code: "ENOTFOUND" });
const error = Object.assign(new TypeError("fetch failed"), { cause });
expect(isTransientNetworkError(error)).toBe(true);
});
it("returns true for nested cause chain with network error", () => {
const innerCause = Object.assign(new Error("connection reset"), { code: "ECONNRESET" });
const outerCause = Object.assign(new Error("wrapper"), { cause: innerCause });
const error = Object.assign(new TypeError("fetch failed"), { cause: outerCause });
expect(isTransientNetworkError(error)).toBe(true);
});
it("returns true for AggregateError containing network errors", () => {
const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
const error = new AggregateError([networkError], "Multiple errors");
expect(isTransientNetworkError(error)).toBe(true);
});
it("returns false for regular errors without network codes", () => {
expect(isTransientNetworkError(new Error("Something went wrong"))).toBe(false);
expect(isTransientNetworkError(new TypeError("Cannot read property"))).toBe(false);
expect(isTransientNetworkError(new RangeError("Invalid array length"))).toBe(false);
});
it("returns false for errors with non-network codes", () => {
const error = Object.assign(new Error("test"), { code: "INVALID_CONFIG" });
expect(isTransientNetworkError(error)).toBe(false);
});
it("returns false for null and undefined", () => {
expect(isTransientNetworkError(null)).toBe(false);
expect(isTransientNetworkError(undefined)).toBe(false);
});
it("returns false for non-error values", () => {
expect(isTransientNetworkError("string error")).toBe(false);
expect(isTransientNetworkError(42)).toBe(false);
expect(isTransientNetworkError({ message: "plain object" })).toBe(false);
});
it("returns false for AggregateError with only non-network errors", () => {
const error = new AggregateError([new Error("regular error")], "Multiple errors");
expect(isTransientNetworkError(error)).toBe(false);
});
});

View File

@@ -6,6 +6,83 @@ type UnhandledRejectionHandler = (reason: unknown) => boolean;
const handlers = new Set<UnhandledRejectionHandler>();
/**
* Checks if an error is an AbortError.
* These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash.
*/
export function isAbortError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const name = "name" in err ? String(err.name) : "";
if (name === "AbortError") return true;
// Check for "This operation was aborted" message from Node's undici
const message = "message" in err && typeof err.message === "string" ? err.message : "";
if (message === "This operation was aborted") return true;
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 getErrorCause(err: unknown): unknown {
if (!err || typeof err !== "object") return undefined;
return (err as { cause?: unknown }).cause;
}
/**
* Checks if an error is a transient network error that shouldn't crash the gateway.
* These are typically temporary connectivity issues that will resolve on their own.
*/
export function isTransientNetworkError(err: unknown): boolean {
if (!err) return false;
// Check the error itself
const code = getErrorCode(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;
}
// Check the cause chain recursively
const cause = getErrorCause(err);
if (cause && cause !== err) {
return isTransientNetworkError(cause);
}
// AggregateError may wrap multiple causes
if (err instanceof AggregateError && err.errors?.length) {
return err.errors.some((e) => isTransientNetworkError(e));
}
return false;
}
export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHandler): () => void {
handlers.add(handler);
return () => {
@@ -30,6 +107,21 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
export function installUnhandledRejectionHandler(): void {
process.on("unhandledRejection", (reason, _promise) => {
if (isUnhandledRejectionHandled(reason)) return;
// AbortError is typically an intentional cancellation (e.g., during shutdown)
// Log it but don't crash - these are expected during graceful shutdown
if (isAbortError(reason)) {
console.warn("[clawdbot] Suppressed AbortError:", formatUncaughtError(reason));
return;
}
// Transient network errors (fetch failed, connection reset, etc.) shouldn't crash
// These are temporary connectivity issues that will resolve on their own
if (isTransientNetworkError(reason)) {
console.error("[clawdbot] Network error (non-fatal):", formatUncaughtError(reason));
return;
}
console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason));
process.exit(1);
});

View File

@@ -1,10 +1,10 @@
import type { WebhookRequestBody } from "@line/bot-sdk";
import type { IncomingMessage, ServerResponse } from "node:http";
import crypto from "node:crypto";
import type { ClawdbotConfig } from "../config/config.js";
import { danger, logVerbose } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLineBot } from "./bot.js";
import { validateLineSignature } from "./signature.js";
import { normalizePluginHttpPath } from "../plugins/http-path.js";
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
import {
@@ -85,11 +85,6 @@ export function getLineRuntimeState(accountId: string) {
return runtimeState.get(`line:${accountId}`);
}
function validateLineSignature(body: string, signature: string, channelSecret: string): boolean {
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
return hash === signature;
}
async function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];

View File

@@ -0,0 +1,27 @@
import crypto from "node:crypto";
import { describe, expect, it } from "vitest";
import { validateLineSignature } from "./signature.js";
const sign = (body: string, secret: string) =>
crypto.createHmac("SHA256", secret).update(body).digest("base64");
describe("validateLineSignature", () => {
it("accepts valid signatures", () => {
const secret = "secret";
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
expect(validateLineSignature(rawBody, sign(rawBody, secret), secret)).toBe(true);
});
it("rejects signatures computed with the wrong secret", () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
expect(validateLineSignature(rawBody, sign(rawBody, "wrong-secret"), "secret")).toBe(false);
});
it("rejects signatures with a different length", () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
expect(validateLineSignature(rawBody, "short", "secret")).toBe(false);
});
});

18
src/line/signature.ts Normal file
View File

@@ -0,0 +1,18 @@
import crypto from "node:crypto";
export function validateLineSignature(
body: string,
signature: string,
channelSecret: string,
): boolean {
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
const hashBuffer = Buffer.from(hash);
const signatureBuffer = Buffer.from(signature);
// Use constant-time comparison to prevent timing attacks.
if (hashBuffer.length !== signatureBuffer.length) {
return false;
}
return crypto.timingSafeEqual(hashBuffer, signatureBuffer);
}

View File

@@ -70,4 +70,41 @@ describe("createLineWebhookMiddleware", () => {
expect(res.status).toHaveBeenCalledWith(400);
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects webhooks with invalid signatures", async () => {
const onEvents = vi.fn(async () => {});
const secret = "secret";
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents });
const req = {
headers: { "x-line-signature": "invalid-signature" },
body: rawBody,
} as any;
const res = createRes();
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(401);
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects webhooks with signatures computed using wrong secret", async () => {
const onEvents = vi.fn(async () => {});
const correctSecret = "correct-secret";
const wrongSecret = "wrong-secret";
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const middleware = createLineWebhookMiddleware({ channelSecret: correctSecret, onEvents });
const req = {
headers: { "x-line-signature": sign(rawBody, wrongSecret) },
body: rawBody,
} as any;
const res = createRes();
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(401);
expect(onEvents).not.toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,8 @@
import type { Request, Response, NextFunction } from "express";
import crypto from "node:crypto";
import type { WebhookRequestBody } from "@line/bot-sdk";
import { logVerbose, danger } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { validateLineSignature } from "./signature.js";
export interface LineWebhookOptions {
channelSecret: string;
@@ -10,11 +10,6 @@ export interface LineWebhookOptions {
runtime?: RuntimeEnv;
}
function validateSignature(body: string, signature: string, channelSecret: string): boolean {
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
return hash === signature;
}
function readRawBody(req: Request): string | null {
const rawBody =
(req as { rawBody?: string | Buffer }).rawBody ??
@@ -52,7 +47,7 @@ export function createLineWebhookMiddleware(options: LineWebhookOptions) {
return;
}
if (!validateSignature(rawBody, signature, channelSecret)) {
if (!validateLineSignature(rawBody, signature, channelSecret)) {
logVerbose("line: webhook signature validation failed");
res.status(401).json({ error: "Invalid signature" });
return;

View File

@@ -44,6 +44,7 @@ describe("security audit", () => {
const res = await runSecurityAudit({
config: cfg,
env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
@@ -88,6 +89,7 @@ describe("security audit", () => {
const res = await runSecurityAudit({
config: cfg,
env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
@@ -855,51 +857,62 @@ describe("security audit", () => {
const includePath = path.join(stateDir, "extra.json5");
await fs.writeFile(includePath, "{ logging: { redactSensitive: 'off' } }\n", "utf-8");
await fs.chmod(includePath, 0o644);
if (isWindows) {
// Grant "Everyone" write access to trigger the perms_writable check on Windows
const { execSync } = await import("node:child_process");
execSync(`icacls "${includePath}" /grant Everyone:W`, { stdio: "ignore" });
} else {
await fs.chmod(includePath, 0o644);
}
const configPath = path.join(stateDir, "clawdbot.json");
await fs.writeFile(configPath, `{ "$include": "./extra.json5" }\n`, "utf-8");
await fs.chmod(configPath, 0o600);
const cfg: ClawdbotConfig = { logging: { redactSensitive: "off" } };
const user = "DESKTOP-TEST\\Tester";
const execIcacls = isWindows
? async (_cmd: string, args: string[]) => {
const target = args[0];
if (target === includePath) {
try {
const cfg: ClawdbotConfig = { logging: { redactSensitive: "off" } };
const user = "DESKTOP-TEST\\Tester";
const execIcacls = isWindows
? async (_cmd: string, args: string[]) => {
const target = args[0];
if (target === includePath) {
return {
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(W)\n ${user}:(F)\n`,
stderr: "",
};
}
return {
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(W)\n ${user}:(F)\n`,
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
stderr: "",
};
}
return {
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
stderr: "",
};
}
: undefined;
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath,
platform: isWindows ? "win32" : undefined,
env: isWindows
? { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" }
: undefined,
execIcacls,
});
: undefined;
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath,
platform: isWindows ? "win32" : undefined,
env: isWindows
? { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" }
: undefined,
execIcacls,
});
const expectedCheckId = isWindows
? "fs.config_include.perms_writable"
: "fs.config_include.perms_world_readable";
const expectedCheckId = isWindows
? "fs.config_include.perms_writable"
: "fs.config_include.perms_world_readable";
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: expectedCheckId, severity: "critical" }),
]),
);
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: expectedCheckId, severity: "critical" }),
]),
);
} finally {
// Clean up temp directory with world-writable file
await fs.rm(tmp, { recursive: true, force: true });
}
});
it("flags extensions without plugins.allow", async () => {

View File

@@ -247,12 +247,15 @@ async function collectFilesystemFindings(params: {
return findings;
}
function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] {
function collectGatewayConfigFindings(
cfg: ClawdbotConfig,
env: NodeJS.ProcessEnv,
): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env });
const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false;
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
? cfg.gateway.trustedProxies
@@ -905,7 +908,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
findings.push(...collectAttackSurfaceSummaryFindings(cfg));
findings.push(...collectSyncedFolderFindings({ stateDir, configPath }));
findings.push(...collectGatewayConfigFindings(cfg));
findings.push(...collectGatewayConfigFindings(cfg, env));
findings.push(...collectBrowserControlFindings(cfg));
findings.push(...collectLoggingFindings(cfg));
findings.push(...collectElevatedFindings(cfg));

View File

@@ -103,7 +103,7 @@ function buildSlackCommandArgMenuBlocks(params: {
title: string;
command: string;
arg: string;
choices: string[];
choices: Array<{ value: string; label: string }>;
userId: string;
}) {
const rows = chunkItems(params.choices, 5).map((choices) => ({
@@ -111,11 +111,11 @@ function buildSlackCommandArgMenuBlocks(params: {
elements: choices.map((choice) => ({
type: "button",
action_id: SLACK_COMMAND_ARG_ACTION_ID,
text: { type: "plain_text", text: choice },
text: { type: "plain_text", text: choice.label },
value: encodeSlackCommandArgValue({
command: params.command,
arg: params.arg,
value: choice,
value: choice.value,
userId: params.userId,
}),
})),

View File

@@ -366,10 +366,10 @@ export const registerTelegramNativeCommands = ({
rows.push(
slice.map((choice) => {
const args: CommandArgs = {
values: { [menu.arg.name]: choice },
values: { [menu.arg.name]: choice.value },
};
return {
text: choice,
text: choice.label,
callback_data: buildCommandTextFromArgs(commandDefinition, args),
};
}),

View File

@@ -89,6 +89,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },

View File

@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },

View File

@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },

View File

@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },

View File

@@ -90,6 +90,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
catch = vi.fn();
constructor(
public token: string,
public options?: {

View File

@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },

View File

@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },

View File

@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },

View File

@@ -93,6 +93,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },

View File

@@ -32,6 +32,7 @@ vi.mock("grammy", () => ({
on = onSpy;
command = vi.fn();
stop = stopSpy;
catch = vi.fn();
constructor(public token: string) {}
},
InputFile: class {},

View File

@@ -30,6 +30,7 @@ vi.mock("grammy", () => ({
on = onSpy;
command = vi.fn();
stop = stopSpy;
catch = vi.fn();
constructor(public token: string) {}
},
InputFile: class {},

View File

@@ -126,6 +126,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },

View File

@@ -21,6 +21,7 @@ import {
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { formatUncaughtError } from "../infra/errors.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
@@ -118,7 +119,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
});
const telegramCfg = account.config;
const fetchImpl = resolveTelegramFetch(opts.proxyFetch);
const fetchImpl = resolveTelegramFetch(opts.proxyFetch, {
network: telegramCfg.network,
});
const shouldProvideFetch = Boolean(fetchImpl);
const timeoutSeconds =
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
@@ -137,6 +140,15 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const bot = new Bot(opts.token, client ? { client } : undefined);
bot.api.config.use(apiThrottler());
bot.use(sequentialize(getTelegramSequentialKey));
bot.catch((err) => {
runtime.error?.(danger(`telegram bot error: ${formatUncaughtError(err)}`));
});
// Catch all errors from bot middleware to prevent unhandled rejections
bot.catch((err) => {
const message = err instanceof Error ? err.message : String(err);
runtime.error?.(danger(`telegram bot error: ${message}`));
});
const recentUpdates = createTelegramUpdateDedupe();
let lastUpdateId =

View File

@@ -25,6 +25,24 @@ import type { TelegramContext } from "./types.js";
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
/**
* Wraps a Telegram API call with error logging. Ensures network failures are
* logged with context before propagating, preventing silent unhandled rejections.
*/
async function withMediaErrorHandler<T>(
operation: string,
runtime: RuntimeEnv,
fn: () => Promise<T>,
): Promise<T> {
try {
return await fn();
} catch (err) {
const errText = formatErrorMessage(err);
runtime.error?.(danger(`telegram ${operation} failed: ${errText}`));
throw err;
}
}
export async function deliverReplies(params: {
replies: ReplyPayload[];
chatId: string;
@@ -146,17 +164,17 @@ export async function deliverReplies(params: {
mediaParams.message_thread_id = threadParams.message_thread_id;
}
if (isGif) {
await bot.api.sendAnimation(chatId, file, {
...mediaParams,
});
await withMediaErrorHandler("sendAnimation", runtime, () =>
bot.api.sendAnimation(chatId, file, { ...mediaParams }),
);
} else if (kind === "image") {
await bot.api.sendPhoto(chatId, file, {
...mediaParams,
});
await withMediaErrorHandler("sendPhoto", runtime, () =>
bot.api.sendPhoto(chatId, file, { ...mediaParams }),
);
} else if (kind === "video") {
await bot.api.sendVideo(chatId, file, {
...mediaParams,
});
await withMediaErrorHandler("sendVideo", runtime, () =>
bot.api.sendVideo(chatId, file, { ...mediaParams }),
);
} else if (kind === "audio") {
const { useVoice } = resolveTelegramVoiceSend({
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
@@ -169,9 +187,9 @@ export async function deliverReplies(params: {
// Switch typing indicator to record_voice before sending.
await params.onVoiceRecording?.();
try {
await bot.api.sendVoice(chatId, file, {
...mediaParams,
});
await withMediaErrorHandler("sendVoice", runtime, () =>
bot.api.sendVoice(chatId, file, { ...mediaParams }),
);
} catch (voiceErr) {
// Fall back to text if voice messages are forbidden in this chat.
// This happens when the recipient has Telegram Premium privacy settings
@@ -204,14 +222,14 @@ export async function deliverReplies(params: {
}
} else {
// Audio file - displays with metadata (title, duration) - DEFAULT
await bot.api.sendAudio(chatId, file, {
...mediaParams,
});
await withMediaErrorHandler("sendAudio", runtime, () =>
bot.api.sendAudio(chatId, file, { ...mediaParams }),
);
}
} else {
await bot.api.sendDocument(chatId, file, {
...mediaParams,
});
await withMediaErrorHandler("sendDocument", runtime, () =>
bot.api.sendDocument(chatId, file, { ...mediaParams }),
);
}
if (replyToId && !hasReplied) {
hasReplied = true;

View File

@@ -1,11 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveTelegramFetch } from "./fetch.js";
describe("resolveTelegramFetch", () => {
const originalFetch = globalThis.fetch;
const loadModule = async () => {
const setDefaultAutoSelectFamily = vi.fn();
vi.resetModules();
vi.doMock("node:net", () => ({
setDefaultAutoSelectFamily,
}));
const mod = await import("./fetch.js");
return { resolveTelegramFetch: mod.resolveTelegramFetch, setDefaultAutoSelectFamily };
};
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
if (originalFetch) {
globalThis.fetch = originalFetch;
} else {
@@ -13,16 +23,41 @@ describe("resolveTelegramFetch", () => {
}
});
it("returns wrapped global fetch when available", () => {
it("returns wrapped global fetch when available", async () => {
const fetchMock = vi.fn(async () => ({}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { resolveTelegramFetch } = await loadModule();
const resolved = resolveTelegramFetch();
expect(resolved).toBeTypeOf("function");
});
it("prefers proxy fetch when provided", () => {
it("prefers proxy fetch when provided", async () => {
const fetchMock = vi.fn(async () => ({}));
const { resolveTelegramFetch } = await loadModule();
const resolved = resolveTelegramFetch(fetchMock as unknown as typeof fetch);
expect(resolved).toBeTypeOf("function");
});
it("honors env enable override", async () => {
vi.stubEnv("CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY", "1");
globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch;
const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule();
resolveTelegramFetch();
expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true);
});
it("uses config override when provided", async () => {
globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch;
const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule();
resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } });
expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true);
});
it("env disable override wins over config", async () => {
vi.stubEnv("CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY", "1");
globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch;
const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule();
resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } });
expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(false);
});
});

View File

@@ -1,7 +1,36 @@
import * as net from "node:net";
import { resolveFetch } from "../infra/fetch.js";
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveTelegramAutoSelectFamilyDecision } from "./network-config.js";
let appliedAutoSelectFamily: boolean | null = null;
const log = createSubsystemLogger("telegram/network");
// Node 22 workaround: disable autoSelectFamily to avoid Happy Eyeballs timeouts.
// See: https://github.com/nodejs/node/issues/54359
function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void {
const decision = resolveTelegramAutoSelectFamilyDecision({ network });
if (decision.value === null || decision.value === appliedAutoSelectFamily) return;
appliedAutoSelectFamily = decision.value;
if (typeof net.setDefaultAutoSelectFamily === "function") {
try {
net.setDefaultAutoSelectFamily(decision.value);
const label = decision.source ? ` (${decision.source})` : "";
log.info(`telegram: autoSelectFamily=${decision.value}${label}`);
} catch {
// ignore if unsupported by the runtime
}
}
}
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined {
export function resolveTelegramFetch(
proxyFetch?: typeof fetch,
options?: { network?: TelegramNetworkConfig },
): typeof fetch | undefined {
applyTelegramNetworkWorkarounds(options?.network);
if (proxyFetch) return resolveFetch(proxyFetch);
const fetchImpl = resolveFetch();
if (!fetchImpl) {

View File

@@ -35,6 +35,11 @@ const { initSpy, runSpy, loadConfig } = vi.hoisted(() => ({
})),
}));
const { computeBackoff, sleepWithAbort } = vi.hoisted(() => ({
computeBackoff: vi.fn(() => 0),
sleepWithAbort: vi.fn(async () => undefined),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
@@ -70,6 +75,11 @@ vi.mock("@grammyjs/runner", () => ({
run: runSpy,
}));
vi.mock("../infra/backoff.js", () => ({
computeBackoff,
sleepWithAbort,
}));
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: async (ctx: { Body?: string }) => ({
text: `echo:${ctx.Body}`,
@@ -84,6 +94,8 @@ describe("monitorTelegramProvider (grammY)", () => {
});
initSpy.mockClear();
runSpy.mockClear();
computeBackoff.mockClear();
sleepWithAbort.mockClear();
});
it("processes a DM and sends reply", async () => {
@@ -119,7 +131,11 @@ describe("monitorTelegramProvider (grammY)", () => {
expect.anything(),
expect.objectContaining({
sink: { concurrency: 3 },
runner: expect.objectContaining({ silent: true }),
runner: expect.objectContaining({
silent: true,
maxRetryTime: 5 * 60 * 1000,
retryInterval: "exponential",
}),
}),
);
});
@@ -140,4 +156,32 @@ describe("monitorTelegramProvider (grammY)", () => {
});
expect(api.sendMessage).not.toHaveBeenCalled();
});
it("retries on recoverable network errors", async () => {
const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
runSpy
.mockImplementationOnce(() => ({
task: () => Promise.reject(networkError),
stop: vi.fn(),
}))
.mockImplementationOnce(() => ({
task: () => Promise.resolve(),
stop: vi.fn(),
}));
await monitorTelegramProvider({ token: "tok" });
expect(computeBackoff).toHaveBeenCalled();
expect(sleepWithAbort).toHaveBeenCalled();
expect(runSpy).toHaveBeenCalledTimes(2);
});
it("surfaces non-recoverable errors", async () => {
runSpy.mockImplementationOnce(() => ({
task: () => Promise.reject(new Error("bad token")),
stop: vi.fn(),
}));
await expect(monitorTelegramProvider({ token: "tok" })).rejects.toThrow("bad token");
});
});

View File

@@ -3,11 +3,13 @@ import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { resolveAgentMaxConcurrent } from "../config/agent-limits.js";
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
import { formatErrorMessage } from "../infra/errors.js";
import { formatDurationMs } from "../infra/format-duration.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
import { createTelegramBot } from "./bot.js";
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
import { makeProxyFetch } from "./proxy.js";
import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js";
import { startTelegramWebhook } from "./webhook.js";
@@ -40,6 +42,9 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions<unk
},
// Suppress grammY getUpdates stack traces; we log concise errors ourselves.
silent: true,
// Retry transient failures for a limited window before surfacing errors.
maxRetryTime: 5 * 60 * 1000,
retryInterval: "exponential",
},
};
}
@@ -133,7 +138,6 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
}
// Use grammyjs/runner for concurrent update processing
const log = opts.runtime?.log ?? console.log;
let restartAttempts = 0;
while (!opts.abortSignal?.aborted) {
@@ -152,12 +156,18 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
if (opts.abortSignal?.aborted) {
throw err;
}
if (!isGetUpdatesConflict(err)) {
const isConflict = isGetUpdatesConflict(err);
const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" });
if (!isConflict && !isRecoverable) {
throw err;
}
restartAttempts += 1;
const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts);
log(`Telegram getUpdates conflict; retrying in ${formatDurationMs(delayMs)}.`);
const reason = isConflict ? "getUpdates conflict" : "network error";
const errMsg = formatErrorMessage(err);
(opts.runtime?.error ?? console.error)(
`Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`,
);
try {
await sleepWithAbort(delayMs, opts.abortSignal);
} catch (sleepErr) {

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { resolveTelegramAutoSelectFamilyDecision } from "./network-config.js";
describe("resolveTelegramAutoSelectFamilyDecision", () => {
it("prefers env enable over env disable", () => {
const decision = resolveTelegramAutoSelectFamilyDecision({
env: {
CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY: "1",
CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY: "1",
},
nodeMajor: 22,
});
expect(decision).toEqual({
value: true,
source: "env:CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY",
});
});
it("uses env disable when set", () => {
const decision = resolveTelegramAutoSelectFamilyDecision({
env: { CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY: "1" },
nodeMajor: 22,
});
expect(decision).toEqual({
value: false,
source: "env:CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY",
});
});
it("uses config override when provided", () => {
const decision = resolveTelegramAutoSelectFamilyDecision({
network: { autoSelectFamily: true },
nodeMajor: 22,
});
expect(decision).toEqual({ value: true, source: "config" });
});
it("defaults to disable on Node 22", () => {
const decision = resolveTelegramAutoSelectFamilyDecision({ nodeMajor: 22 });
expect(decision).toEqual({ value: false, source: "default-node22" });
});
it("returns null when no decision applies", () => {
const decision = resolveTelegramAutoSelectFamilyDecision({ nodeMajor: 20 });
expect(decision).toEqual({ value: null });
});
});

View File

@@ -0,0 +1,39 @@
import process from "node:process";
import { isTruthyEnvValue } from "../infra/env.js";
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
export const TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV =
"CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY";
export const TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV = "CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY";
export type TelegramAutoSelectFamilyDecision = {
value: boolean | null;
source?: string;
};
export function resolveTelegramAutoSelectFamilyDecision(params?: {
network?: TelegramNetworkConfig;
env?: NodeJS.ProcessEnv;
nodeMajor?: number;
}): TelegramAutoSelectFamilyDecision {
const env = params?.env ?? process.env;
const nodeMajor =
typeof params?.nodeMajor === "number"
? params.nodeMajor
: Number(process.versions.node.split(".")[0]);
if (isTruthyEnvValue(env[TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV])) {
return { value: true, source: `env:${TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV}` };
}
if (isTruthyEnvValue(env[TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV])) {
return { value: false, source: `env:${TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV}` };
}
if (typeof params?.network?.autoSelectFamily === "boolean") {
return { value: params.network.autoSelectFamily, source: "config" };
}
if (Number.isFinite(nodeMajor) && nodeMajor >= 22) {
return { value: false, source: "default-node22" };
}
return { value: null };
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
describe("isRecoverableTelegramNetworkError", () => {
it("detects recoverable error codes", () => {
const err = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
});
it("detects AbortError names", () => {
const err = Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
});
it("detects nested causes", () => {
const cause = Object.assign(new Error("socket hang up"), { code: "ECONNRESET" });
const err = Object.assign(new TypeError("fetch failed"), { cause });
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
});
it("skips message matches for send context", () => {
const err = new TypeError("fetch failed");
expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false);
expect(isRecoverableTelegramNetworkError(err, { context: "polling" })).toBe(true);
});
it("returns false for unrelated errors", () => {
expect(isRecoverableTelegramNetworkError(new Error("invalid token"))).toBe(false);
});
});

View File

@@ -0,0 +1,112 @@
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
const RECOVERABLE_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"EPIPE",
"ETIMEDOUT",
"ESOCKETTIMEDOUT",
"ENETUNREACH",
"EHOSTUNREACH",
"ENOTFOUND",
"EAI_AGAIN",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_BODY_TIMEOUT",
"UND_ERR_SOCKET",
"UND_ERR_ABORTED",
]);
const RECOVERABLE_ERROR_NAMES = new Set([
"AbortError",
"TimeoutError",
"ConnectTimeoutError",
"HeadersTimeoutError",
"BodyTimeoutError",
]);
const RECOVERABLE_MESSAGE_SNIPPETS = [
"fetch failed",
"network error",
"network request",
"client network socket disconnected",
"socket hang up",
"getaddrinfo",
];
function normalizeCode(code?: string): string {
return code?.trim().toUpperCase() ?? "";
}
function getErrorName(err: unknown): string {
if (!err || typeof err !== "object") return "";
return "name" in err ? String(err.name) : "";
}
function getErrorCode(err: unknown): string | undefined {
const direct = extractErrorCode(err);
if (direct) return direct;
if (!err || typeof err !== "object") return undefined;
const errno = (err as { errno?: unknown }).errno;
if (typeof errno === "string") return errno;
if (typeof errno === "number") return String(errno);
return undefined;
}
function collectErrorCandidates(err: unknown): unknown[] {
const queue = [err];
const seen = new Set<unknown>();
const candidates: unknown[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current == null || seen.has(current)) continue;
seen.add(current);
candidates.push(current);
if (typeof current === "object") {
const cause = (current as { cause?: unknown }).cause;
if (cause && !seen.has(cause)) queue.push(cause);
const reason = (current as { reason?: unknown }).reason;
if (reason && !seen.has(reason)) queue.push(reason);
const errors = (current as { errors?: unknown }).errors;
if (Array.isArray(errors)) {
for (const nested of errors) {
if (nested && !seen.has(nested)) queue.push(nested);
}
}
}
}
return candidates;
}
export type TelegramNetworkErrorContext = "polling" | "send" | "webhook" | "unknown";
export function isRecoverableTelegramNetworkError(
err: unknown,
options: { context?: TelegramNetworkErrorContext; allowMessageMatch?: boolean } = {},
): boolean {
if (!err) return false;
const allowMessageMatch =
typeof options.allowMessageMatch === "boolean"
? options.allowMessageMatch
: options.context !== "send";
for (const candidate of collectErrorCandidates(err)) {
const code = normalizeCode(getErrorCode(candidate));
if (code && RECOVERABLE_ERROR_CODES.has(code)) return true;
const name = getErrorName(candidate);
if (name && RECOVERABLE_ERROR_NAMES.has(name)) return true;
if (allowMessageMatch) {
const message = formatErrorMessage(candidate).toLowerCase();
if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) {
return true;
}
}
}
return false;
}

View File

@@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({
vi.mock("grammy", () => ({
Bot: class {
api = botApi;
catch = vi.fn();
constructor(
public token: string,
public options?: {

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { botApi, botCtorSpy } = vi.hoisted(() => ({
botApi: {
editMessageText: vi.fn(),
},
botCtorSpy: vi.fn(),
}));
vi.mock("grammy", () => ({
Bot: class {
api = botApi;
constructor(public token: string) {
botCtorSpy(token);
}
},
InputFile: class {},
}));
import { editMessageTelegram } from "./send.js";
describe("editMessageTelegram", () => {
beforeEach(() => {
botApi.editMessageText.mockReset();
botCtorSpy.mockReset();
});
it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => {
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
await editMessageTelegram("123", 1, "hi", {
token: "tok",
cfg: {},
});
expect(botCtorSpy).toHaveBeenCalledWith("tok");
expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
const call = botApi.editMessageText.mock.calls[0] ?? [];
const params = call[3] as Record<string, unknown>;
expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" }));
expect(params).not.toHaveProperty("reply_markup");
});
it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => {
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
await editMessageTelegram("123", 1, "hi", {
token: "tok",
cfg: {},
buttons: [],
});
expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record<string, unknown>;
expect(params).toEqual(
expect.objectContaining({
parse_mode: "HTML",
reply_markup: { inline_keyboard: [] },
}),
);
});
it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => {
botApi.editMessageText
.mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities"))
.mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } });
await editMessageTelegram("123", 1, "<bad> html", {
token: "tok",
cfg: {},
buttons: [],
});
expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record<string, unknown>;
expect(firstParams).toEqual(
expect.objectContaining({
parse_mode: "HTML",
reply_markup: { inline_keyboard: [] },
}),
);
const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record<string, unknown>;
expect(secondParams).toEqual(
expect.objectContaining({
reply_markup: { inline_keyboard: [] },
}),
);
});
});

View File

@@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({
vi.mock("grammy", () => ({
Bot: class {
api = botApi;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },

View File

@@ -40,6 +40,7 @@ vi.mock("./fetch.js", () => ({
vi.mock("grammy", () => ({
Bot: class {
api = botApi;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch; timeoutSeconds?: number } },
@@ -76,7 +77,7 @@ describe("telegram proxy client", () => {
await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" });
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined });
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({
@@ -94,7 +95,7 @@ describe("telegram proxy client", () => {
await reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" });
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined });
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({
@@ -112,7 +113,7 @@ describe("telegram proxy client", () => {
await deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" });
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined });
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({

View File

@@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({
vi.mock("grammy", () => ({
Bot: class {
api = botApi;
catch = vi.fn();
constructor(
public token: string,
public options?: {

View File

@@ -22,6 +22,7 @@ import { resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
import { renderTelegramHtmlText } from "./format.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
import { splitTelegramCaption } from "./caption.js";
import { recordSentMessage } from "./sent-message-cache.js";
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
@@ -84,7 +85,9 @@ function resolveTelegramClientOptions(
): ApiClientOptions | undefined {
const proxyUrl = account.config.proxy?.trim();
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
const fetchImpl = resolveTelegramFetch(proxyFetch);
const fetchImpl = resolveTelegramFetch(proxyFetch, {
network: account.config.network,
});
const timeoutSeconds =
typeof account.config.timeoutSeconds === "number" &&
Number.isFinite(account.config.timeoutSeconds)
@@ -203,6 +206,7 @@ export async function sendMessageTelegram(
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
@@ -434,6 +438,7 @@ export async function reactMessageTelegram(
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
@@ -483,6 +488,7 @@ export async function deleteMessageTelegram(
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
@@ -495,6 +501,99 @@ export async function deleteMessageTelegram(
return { ok: true };
}
type TelegramEditOpts = {
token?: string;
accountId?: string;
verbose?: boolean;
api?: Bot["api"];
retry?: RetryConfig;
textMode?: "markdown" | "html";
/** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */
buttons?: Array<Array<{ text: string; callback_data: string }>>;
/** Optional config injection to avoid global loadConfig() (improves testability). */
cfg?: ReturnType<typeof loadConfig>;
};
export async function editMessageTelegram(
chatIdInput: string | number,
messageIdInput: string | number,
text: string,
opts: TelegramEditOpts = {},
): Promise<{ ok: true; messageId: string; chatId: string }> {
const cfg = opts.cfg ?? loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.token, account);
const chatId = normalizeChatId(String(chatIdInput));
const messageId = normalizeMessageId(messageIdInput);
const client = resolveTelegramClientOptions(account);
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
const request = createTelegramRetryRunner({
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
request(fn, label).catch((err) => {
logHttpError(label ?? "request", err);
throw err;
});
const textMode = opts.textMode ?? "markdown";
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: account.accountId,
});
const htmlText = renderTelegramHtmlText(text, { textMode, tableMode });
// Reply markup semantics:
// - buttons === undefined → don't send reply_markup (keep existing)
// - buttons is [] (or filters to empty) → send { inline_keyboard: [] } (remove)
// - otherwise → send built inline keyboard
const shouldTouchButtons = opts.buttons !== undefined;
const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined;
const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined;
const editParams: Record<string, unknown> = {
parse_mode: "HTML",
};
if (replyMarkup !== undefined) {
editParams.reply_markup = replyMarkup;
}
await requestWithDiag(
() => api.editMessageText(chatId, messageId, htmlText, editParams),
"editMessage",
).catch(async (err) => {
// Telegram rejects malformed HTML. Fall back to plain text.
const errText = formatErrorMessage(err);
if (PARSE_ERR_RE.test(errText)) {
if (opts.verbose) {
console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`);
}
const plainParams: Record<string, unknown> = {};
if (replyMarkup !== undefined) {
plainParams.reply_markup = replyMarkup;
}
return await requestWithDiag(
() =>
Object.keys(plainParams).length > 0
? api.editMessageText(chatId, messageId, text, plainParams)
: api.editMessageText(chatId, messageId, text),
"editMessage-plain",
);
}
throw err;
});
logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId}`);
return { ok: true, messageId: String(messageId), chatId };
}
function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
switch (kind) {
case "image":

View File

@@ -1,4 +1,5 @@
import { type ApiClientOptions, Bot } from "grammy";
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
import { resolveTelegramFetch } from "./fetch.js";
export async function setTelegramWebhook(opts: {
@@ -6,8 +7,9 @@ export async function setTelegramWebhook(opts: {
url: string;
secret?: string;
dropPendingUpdates?: boolean;
network?: TelegramNetworkConfig;
}) {
const fetchImpl = resolveTelegramFetch();
const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network });
const client: ApiClientOptions | undefined = fetchImpl
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined;
@@ -18,8 +20,11 @@ export async function setTelegramWebhook(opts: {
});
}
export async function deleteTelegramWebhook(opts: { token: string }) {
const fetchImpl = resolveTelegramFetch();
export async function deleteTelegramWebhook(opts: {
token: string;
network?: TelegramNetworkConfig;
}) {
const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network });
const client: ApiClientOptions | undefined = fetchImpl
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined;

View File

@@ -40,7 +40,7 @@ import { resolveModel } from "../agents/pi-embedded-runner/model.js";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_TTS_MAX_LENGTH = 1500;
const DEFAULT_TTS_SUMMARIZE = true;
const DEFAULT_MAX_TEXT_LENGTH = 4000;
const DEFAULT_MAX_TEXT_LENGTH = 4096;
const TEMP_FILE_CLEANUP_DELAY_MS = 5 * 60 * 1000; // 5 minutes
const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";
@@ -1386,32 +1386,34 @@ export async function maybeApplyTtsToPayload(params: {
if (textForAudio.length > maxLength) {
if (!isSummarizationEnabled(prefsPath)) {
// Truncate text when summarization is disabled
logVerbose(
`TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
`TTS: truncating long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
);
return nextPayload;
}
try {
const summary = await summarizeText({
text: textForAudio,
targetLength: maxLength,
cfg: params.cfg,
config,
timeoutMs: config.timeoutMs,
});
textForAudio = summary.summary;
wasSummarized = true;
if (textForAudio.length > config.maxTextLength) {
logVerbose(
`TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`,
);
textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
} else {
// Summarize text when enabled
try {
const summary = await summarizeText({
text: textForAudio,
targetLength: maxLength,
cfg: params.cfg,
config,
timeoutMs: config.timeoutMs,
});
textForAudio = summary.summary;
wasSummarized = true;
if (textForAudio.length > config.maxTextLength) {
logVerbose(
`TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`,
);
textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
}
} catch (err) {
const error = err as Error;
logVerbose(`TTS: summarization failed, truncating instead: ${error.message}`);
textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
}
} catch (err) {
const error = err as Error;
logVerbose(`TTS: summarization failed: ${error.message}`);
return nextPayload;
}
}
@@ -1436,12 +1438,12 @@ export async function maybeApplyTtsToPayload(params: {
const channelId = resolveChannelId(params.channel);
const shouldVoice = channelId === "telegram" && result.voiceCompatible === true;
return {
const finalPayload = {
...nextPayload,
mediaUrl: result.audioPath,
audioAsVoice: shouldVoice || params.payload.audioAsVoice,
};
return finalPayload;
}
lastTtsAttempt = {