From a8ad242f885de2225455ae00f8564c83c7c4b162 Mon Sep 17 00:00:00 2001
From: Dominic <43616264+dominicnunez@users.noreply.github.com>
Date: Mon, 26 Jan 2026 18:27:53 -0600
Subject: [PATCH 1/3] fix(security): properly test Windows ACL audit for config
includes (#2403)
* fix(security): properly test Windows ACL audit for config includes
The test expected fs.config_include.perms_writable on Windows but
chmod 0o644 has no effect on Windows ACLs. Use icacls to grant
Everyone write access, which properly triggers the security check.
Also stubs execIcacls to return proper ACL output so the audit
can parse permissions without running actual icacls on the system.
Adds cleanup via try/finally to remove temp directory containing
world-writable test file.
Fixes checks-windows CI failure.
* test: isolate heartbeat runner tests from user workspace
* docs: update changelog for #2403
---------
Co-authored-by: Tyler Yust
---
CHANGELOG.md | 1 +
...tbeat-runner.returns-default-unset.test.ts | 7 +-
src/security/audit.test.ts | 79 +++++++++++--------
3 files changed, 52 insertions(+), 35 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e57c7b4b1..4667e60d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,6 +54,7 @@ Status: unreleased.
### Fixes
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
+- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez.
- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts
index 621f895fa..595cbaed7 100644
--- a/src/infra/heartbeat-runner.returns-default-unset.test.ts
+++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts
@@ -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: ["*"] } },
diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts
index deebf7c70..3a43ff4cc 100644
--- a/src/security/audit.test.ts
+++ b/src/security/audit.test.ts
@@ -857,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 () => {
From e43f4c0628228bd5ae3a9fe8774881f7c7753f65 Mon Sep 17 00:00:00 2001
From: techboss
Date: Mon, 26 Jan 2026 15:25:27 -0700
Subject: [PATCH 2/3] fix(telegram): handle network errors gracefully
- Add bot.catch() to prevent unhandled rejections from middleware
- Add isRecoverableNetworkError() to retry on transient failures
- Add maxRetryTime and exponential backoff to grammY runner
- Global unhandled rejection handler now logs recoverable errors
instead of crashing (fetch failures, timeouts, connection resets)
Fixes crash loop when Telegram API is temporarily unreachable.
---
src/infra/unhandled-rejections.ts | 37 +++++++++++++++++++++++++++++++
src/telegram/bot.ts | 6 +++++
src/telegram/fetch.ts | 12 ++++++++++
src/telegram/monitor.ts | 31 ++++++++++++++++++++++++--
4 files changed, 84 insertions(+), 2 deletions(-)
diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts
index c444baaa2..c45923c4b 100644
--- a/src/infra/unhandled-rejections.ts
+++ b/src/infra/unhandled-rejections.ts
@@ -13,6 +13,36 @@ export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHan
};
}
+/**
+ * Check if an error is a recoverable/transient error that shouldn't crash the process.
+ * These include network errors and abort signals during shutdown.
+ */
+function isRecoverableError(reason: unknown): boolean {
+ if (!reason) return false;
+
+ // Check error name for AbortError
+ if (reason instanceof Error && reason.name === "AbortError") {
+ return true;
+ }
+
+ const message = reason instanceof Error ? reason.message : String(reason);
+ const lowerMessage = message.toLowerCase();
+ return (
+ lowerMessage.includes("fetch failed") ||
+ lowerMessage.includes("network request") ||
+ lowerMessage.includes("econnrefused") ||
+ lowerMessage.includes("econnreset") ||
+ lowerMessage.includes("etimedout") ||
+ lowerMessage.includes("socket hang up") ||
+ lowerMessage.includes("enotfound") ||
+ lowerMessage.includes("network error") ||
+ lowerMessage.includes("getaddrinfo") ||
+ lowerMessage.includes("client network socket disconnected") ||
+ lowerMessage.includes("this operation was aborted") ||
+ lowerMessage.includes("aborted")
+ );
+}
+
export function isUnhandledRejectionHandled(reason: unknown): boolean {
for (const handler of handlers) {
try {
@@ -30,6 +60,13 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
export function installUnhandledRejectionHandler(): void {
process.on("unhandledRejection", (reason, _promise) => {
if (isUnhandledRejectionHandled(reason)) return;
+
+ // Don't crash on recoverable/transient errors - log them and continue
+ if (isRecoverableError(reason)) {
+ console.error("[clawdbot] Recoverable error (not crashing):", formatUncaughtError(reason));
+ return;
+ }
+
console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason));
process.exit(1);
});
diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts
index d958d5616..d1996bade 100644
--- a/src/telegram/bot.ts
+++ b/src/telegram/bot.ts
@@ -138,6 +138,12 @@ export function createTelegramBot(opts: TelegramBotOptions) {
bot.api.config.use(apiThrottler());
bot.use(sequentialize(getTelegramSequentialKey));
+ // 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 =
typeof opts.updateOffset?.lastUpdateId === "number" ? opts.updateOffset.lastUpdateId : null;
diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts
index 7fdaef301..00a21be9b 100644
--- a/src/telegram/fetch.ts
+++ b/src/telegram/fetch.ts
@@ -1,5 +1,17 @@
+import { setDefaultAutoSelectFamily } from "net";
import { resolveFetch } from "../infra/fetch.js";
+// Workaround for Node.js 22 "Happy Eyeballs" (autoSelectFamily) bug
+// that causes intermittent ETIMEDOUT errors when connecting to Telegram's
+// dual-stack servers. Disabling autoSelectFamily forces sequential IPv4/IPv6
+// attempts which works reliably.
+// See: https://github.com/nodejs/node/issues/54359
+try {
+ setDefaultAutoSelectFamily(false);
+} catch {
+ // Ignore if not available (older Node versions)
+}
+
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined {
if (proxyFetch) return resolveFetch(proxyFetch);
diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts
index 24c8743df..aeb5aae7c 100644
--- a/src/telegram/monitor.ts
+++ b/src/telegram/monitor.ts
@@ -40,6 +40,10 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions {
return haystack.includes("getupdates");
};
+const isRecoverableNetworkError = (err: unknown): boolean => {
+ if (!err) return false;
+ const message = err instanceof Error ? err.message : String(err);
+ const lowerMessage = message.toLowerCase();
+ // Recoverable network errors that should trigger retry, not crash
+ return (
+ lowerMessage.includes("fetch failed") ||
+ lowerMessage.includes("network request") ||
+ lowerMessage.includes("econnrefused") ||
+ lowerMessage.includes("econnreset") ||
+ lowerMessage.includes("etimedout") ||
+ lowerMessage.includes("socket hang up") ||
+ lowerMessage.includes("enotfound") ||
+ lowerMessage.includes("abort")
+ );
+};
+
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveTelegramAccount({
@@ -152,12 +173,18 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
if (opts.abortSignal?.aborted) {
throw err;
}
- if (!isGetUpdatesConflict(err)) {
+ const isConflict = isGetUpdatesConflict(err);
+ const isNetworkError = isRecoverableNetworkError(err);
+ if (!isConflict && !isNetworkError) {
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 = err instanceof Error ? err.message : String(err);
+ (opts.runtime?.error ?? console.error)(
+ `Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`,
+ );
try {
await sleepWithAbort(delayMs, opts.abortSignal);
} catch (sleepErr) {
From b861a0bd73e6c8dbf7c62a2cb99b400f40e2e4ea Mon Sep 17 00:00:00 2001
From: Gustavo Madeira Santana
Date: Mon, 26 Jan 2026 19:24:13 -0500
Subject: [PATCH 3/3] Telegram: harden network retries and config
Co-authored-by: techboss
---
CHANGELOG.md | 1 +
README.md | 29 ++---
docs/channels/telegram.md | 1 +
docs/gateway/configuration.md | 3 +
src/config/schema.ts | 3 +
src/config/types.telegram.ts | 7 ++
src/config/zod-schema.providers-core.ts | 6 +
src/infra/retry-policy.test.ts | 27 +++++
src/infra/retry-policy.ts | 7 +-
...patterns-match-without-botusername.test.ts | 1 +
...topic-skill-filters-system-prompts.test.ts | 1 +
...-all-group-messages-grouppolicy-is.test.ts | 1 +
...e-callback-query-updates-by-update.test.ts | 1 +
...gram-bot.installs-grammy-throttler.test.ts | 1 +
...lowfrom-entries-case-insensitively.test.ts | 1 +
...-case-insensitively-grouppolicy-is.test.ts | 1 +
...-dms-by-telegram-accountid-binding.test.ts | 1 +
...ies-without-native-reply-threading.test.ts | 1 +
...s-media-file-path-no-file-download.test.ts | 1 +
...udes-location-text-ctx-fields-pins.test.ts | 1 +
src/telegram/bot.test.ts | 1 +
src/telegram/bot.ts | 8 +-
src/telegram/fetch.test.ts | 43 ++++++-
src/telegram/fetch.ts | 37 ++++--
src/telegram/monitor.test.ts | 46 ++++++-
src/telegram/monitor.ts | 29 +----
src/telegram/network-config.test.ts | 48 ++++++++
src/telegram/network-config.ts | 39 ++++++
src/telegram/network-errors.test.ts | 31 +++++
src/telegram/network-errors.ts | 112 ++++++++++++++++++
src/telegram/send.caption-split.test.ts | 1 +
...-thread-params-plain-text-fallback.test.ts | 1 +
src/telegram/send.proxy.test.ts | 7 +-
...send.returns-undefined-empty-input.test.ts | 1 +
src/telegram/send.ts | 8 +-
src/telegram/webhook-set.ts | 11 +-
36 files changed, 457 insertions(+), 61 deletions(-)
create mode 100644 src/infra/retry-policy.test.ts
create mode 100644 src/telegram/network-config.test.ts
create mode 100644 src/telegram/network-config.ts
create mode 100644 src/telegram/network-errors.test.ts
create mode 100644 src/telegram/network-errors.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4667e60d5..a83519fef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -58,6 +58,7 @@ Status: unreleased.
- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
+- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
diff --git a/README.md b/README.md
index 3f8853b93..e72fe7e16 100644
--- a/README.md
+++ b/README.md
@@ -485,12 +485,12 @@ Thanks to all clawtributors:
-
-
-
-
-
-
+
+
+
+
+
+
@@ -500,12 +500,13 @@ Thanks to all clawtributors:
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md
index e708e2e64..39f3a2ec3 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -529,6 +529,7 @@ Provider options:
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
+- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts.
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
- `channels.telegram.webhookUrl`: enable webhook mode.
- `channels.telegram.webhookSecret`: webhook secret (optional).
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 31dd1602b..9c850e070 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -1029,6 +1029,9 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
maxDelayMs: 30000,
jitter: 0.1
},
+ network: { // transport overrides
+ autoSelectFamily: false
+ },
proxy: "socks5://localhost:9050",
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret",
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 9627d64f3..3261b5170 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -310,6 +310,7 @@ const FIELD_LABELS: Record = {
"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 = {
"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":
diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts
index f6a7c3db8..fa9e2890a 100644
--- a/src/config/types.telegram.ts
+++ b/src/config/types.telegram.ts
@@ -18,6 +18,11 @@ export type TelegramActionConfig = {
editMessage?: boolean;
};
+export type TelegramNetworkConfig = {
+ /** Override Node's autoSelectFamily behavior (true = enable, false = disable). */
+ autoSelectFamily?: boolean;
+};
+
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
export type TelegramCapabilitiesConfig =
@@ -96,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;
diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts
index 374e6e8aa..26e279faf 100644
--- a/src/config/zod-schema.providers-core.ts
+++ b/src/config/zod-schema.providers-core.ts
@@ -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(),
diff --git a/src/infra/retry-policy.test.ts b/src/infra/retry-policy.test.ts
new file mode 100644
index 000000000..02aedb087
--- /dev/null
+++ b/src/infra/retry-policy.test.ts
@@ -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>()
+ .mockRejectedValueOnce(new Error("boom"))
+ .mockResolvedValue("ok");
+
+ const promise = runner(fn, "request");
+ await vi.runAllTimersAsync();
+
+ await expect(promise).resolves.toBe("ok");
+ expect(fn).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/infra/retry-policy.ts b/src/infra/retry-policy.ts
index f5a3c4b33..6d647aa5e 100644
--- a/src/infra/retry-policy.ts
+++ b/src/infra/retry-policy.ts
@@ -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 (fn: () => Promise, label?: string) =>
retryAsync(fn, {
...retryConfig,
label,
- shouldRetry: (err) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err)),
+ shouldRetry,
retryAfterMs: getTelegramRetryAfterMs,
onRetry: params.verbose
? (info) => {
diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
index 66e60ecca..0b2f9c9af 100644
--- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
+++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
@@ -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 } },
diff --git a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts
index 1a7a9d40c..b5d154c42 100644
--- a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts
+++ b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts
@@ -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 } },
diff --git a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts
index 0aa431d1b..d6c22256b 100644
--- a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts
+++ b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts
@@ -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 } },
diff --git a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts
index 8ed8e189f..6e04be767 100644
--- a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts
+++ b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts
@@ -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 } },
diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
index c30b5e33a..4c7a93529 100644
--- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
+++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
@@ -90,6 +90,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
+ catch = vi.fn();
constructor(
public token: string,
public options?: {
diff --git a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts
index 805aa34da..4ddb83c02 100644
--- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts
+++ b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts
@@ -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 } },
diff --git a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts
index ec81283bb..ba3d802e2 100644
--- a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts
+++ b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts
@@ -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 } },
diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts
index 63ddd9bec..514ff1452 100644
--- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts
+++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts
@@ -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 } },
diff --git a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts
index dffe8ee88..1aff63ed3 100644
--- a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts
+++ b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts
@@ -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 } },
diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts
index 2ea914874..b6c1ca419 100644
--- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts
+++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts
@@ -32,6 +32,7 @@ vi.mock("grammy", () => ({
on = onSpy;
command = vi.fn();
stop = stopSpy;
+ catch = vi.fn();
constructor(public token: string) {}
},
InputFile: class {},
diff --git a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts b/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts
index 2242941ce..f5ac0a268 100644
--- a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts
+++ b/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts
@@ -30,6 +30,7 @@ vi.mock("grammy", () => ({
on = onSpy;
command = vi.fn();
stop = stopSpy;
+ catch = vi.fn();
constructor(public token: string) {}
},
InputFile: class {},
diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts
index 8dc52ab57..274f7c6a9 100644
--- a/src/telegram/bot.test.ts
+++ b/src/telegram/bot.test.ts
@@ -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 } },
diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts
index d1996bade..6705d359f 100644
--- a/src/telegram/bot.ts
+++ b/src/telegram/bot.ts
@@ -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,9 @@ 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) => {
diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts
index 4042be60d..17cda1d00 100644
--- a/src/telegram/fetch.test.ts
+++ b/src/telegram/fetch.test.ts
@@ -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);
+ });
});
diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts
index 00a21be9b..ebed468c9 100644
--- a/src/telegram/fetch.ts
+++ b/src/telegram/fetch.ts
@@ -1,19 +1,36 @@
-import { setDefaultAutoSelectFamily } from "net";
+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";
-// Workaround for Node.js 22 "Happy Eyeballs" (autoSelectFamily) bug
-// that causes intermittent ETIMEDOUT errors when connecting to Telegram's
-// dual-stack servers. Disabling autoSelectFamily forces sequential IPv4/IPv6
-// attempts which works reliably.
+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
-try {
- setDefaultAutoSelectFamily(false);
-} catch {
- // Ignore if not available (older Node versions)
+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) {
diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts
index bfd8c83ac..2fc46827b 100644
--- a/src/telegram/monitor.test.ts
+++ b/src/telegram/monitor.test.ts
@@ -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();
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");
+ });
});
diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts
index aeb5aae7c..5247c2af3 100644
--- a/src/telegram/monitor.ts
+++ b/src/telegram/monitor.ts
@@ -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,9 +42,8 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions {
return haystack.includes("getupdates");
};
-const isRecoverableNetworkError = (err: unknown): boolean => {
- if (!err) return false;
- const message = err instanceof Error ? err.message : String(err);
- const lowerMessage = message.toLowerCase();
- // Recoverable network errors that should trigger retry, not crash
- return (
- lowerMessage.includes("fetch failed") ||
- lowerMessage.includes("network request") ||
- lowerMessage.includes("econnrefused") ||
- lowerMessage.includes("econnreset") ||
- lowerMessage.includes("etimedout") ||
- lowerMessage.includes("socket hang up") ||
- lowerMessage.includes("enotfound") ||
- lowerMessage.includes("abort")
- );
-};
-
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveTelegramAccount({
@@ -154,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) {
@@ -174,14 +157,14 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
throw err;
}
const isConflict = isGetUpdatesConflict(err);
- const isNetworkError = isRecoverableNetworkError(err);
- if (!isConflict && !isNetworkError) {
+ const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" });
+ if (!isConflict && !isRecoverable) {
throw err;
}
restartAttempts += 1;
const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts);
const reason = isConflict ? "getUpdates conflict" : "network error";
- const errMsg = err instanceof Error ? err.message : String(err);
+ const errMsg = formatErrorMessage(err);
(opts.runtime?.error ?? console.error)(
`Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`,
);
diff --git a/src/telegram/network-config.test.ts b/src/telegram/network-config.test.ts
new file mode 100644
index 000000000..cb4bc4c6e
--- /dev/null
+++ b/src/telegram/network-config.test.ts
@@ -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 });
+ });
+});
diff --git a/src/telegram/network-config.ts b/src/telegram/network-config.ts
new file mode 100644
index 000000000..ac5dd05a7
--- /dev/null
+++ b/src/telegram/network-config.ts
@@ -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 };
+}
diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts
new file mode 100644
index 000000000..ae42cbb97
--- /dev/null
+++ b/src/telegram/network-errors.test.ts
@@ -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);
+ });
+});
diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts
new file mode 100644
index 000000000..70cd81994
--- /dev/null
+++ b/src/telegram/network-errors.ts
@@ -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();
+ 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;
+}
diff --git a/src/telegram/send.caption-split.test.ts b/src/telegram/send.caption-split.test.ts
index 58e0a921a..7911e2890 100644
--- a/src/telegram/send.caption-split.test.ts
+++ b/src/telegram/send.caption-split.test.ts
@@ -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?: {
diff --git a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts b/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts
index 18176d259..2f9e7d057 100644
--- a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts
+++ b/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts
@@ -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 } },
diff --git a/src/telegram/send.proxy.test.ts b/src/telegram/send.proxy.test.ts
index b395662e4..39ef9e2d0 100644
--- a/src/telegram/send.proxy.test.ts
+++ b/src/telegram/send.proxy.test.ts
@@ -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({
diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts
index 6e2ea85d0..d086fe2a3 100644
--- a/src/telegram/send.returns-undefined-empty-input.test.ts
+++ b/src/telegram/send.returns-undefined-empty-input.test.ts
@@ -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?: {
diff --git a/src/telegram/send.ts b/src/telegram/send.ts
index 43a3a5e8c..d28cff55e 100644
--- a/src/telegram/send.ts
+++ b/src/telegram/send.ts
@@ -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 = (fn: () => Promise, 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 = (fn: () => Promise, 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 = (fn: () => Promise, label?: string) =>
diff --git a/src/telegram/webhook-set.ts b/src/telegram/webhook-set.ts
index eced660e6..2880c8254 100644
--- a/src/telegram/webhook-set.ts
+++ b/src/telegram/webhook-set.ts
@@ -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;