diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1edda7aab..a83519fef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ Status: unreleased.
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
- Docs: add migration guide for moving to a new machine. (#2381)
+- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN.
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
@@ -43,6 +44,7 @@ Status: unreleased.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
+- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
@@ -51,8 +53,12 @@ Status: unreleased.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### 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.
+- 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 535cd1c75..e72fe7e16 100644
--- a/README.md
+++ b/README.md
@@ -477,34 +477,35 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
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/docs.json b/docs/docs.json
index 2cc5ae78b..c53902451 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -805,6 +805,10 @@
"source": "/install/railway/",
"destination": "/railway"
},
+ {
+ "source": "/install/northflank/",
+ "destination": "/northflank"
+ },
{
"source": "/gcp",
"destination": "/platforms/gcp"
@@ -852,6 +856,7 @@
"install/docker",
"railway",
"render",
+ "northflank",
"install/bun"
]
},
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/docs/northflank.mdx b/docs/northflank.mdx
new file mode 100644
index 000000000..aae9c6a22
--- /dev/null
+++ b/docs/northflank.mdx
@@ -0,0 +1,53 @@
+---
+title: Deploy on Northflank
+---
+
+Deploy Clawdbot on Northflank with a one-click template and finish setup in your browser.
+This is the easiest “no terminal on the server” path: Northflank runs the Gateway for you,
+and you configure everything via the `/setup` web wizard.
+
+## How to get started
+
+1. Click [Deploy Clawdbot](https://northflank.com/stacks/deploy-clawdbot) to open the template.
+2. Create an [account on Northflank](https://app.northflank.com/signup) if you don’t already have one.
+3. Click **Deploy Clawdbot now**.
+4. Set the required environment variable: `SETUP_PASSWORD`.
+5. Click **Deploy stack** to build and run the Clawdbot template.
+6. Wait for the deployment to complete, then click **View resources**.
+7. Open the Clawdbot service.
+8. Open the public Clawdbot URL and complete setup at `/setup`.
+9. Open the Control UI at `/clawdbot`.
+
+## What you get
+
+- Hosted Clawdbot Gateway + Control UI
+- Web setup wizard at `/setup` (no terminal commands)
+- Persistent storage via Northflank Volume (`/data`) so config/credentials/workspace survive redeploys
+
+## Setup flow
+
+1) Visit `https:///setup` and enter your `SETUP_PASSWORD`.
+2) Choose a model/auth provider and paste your key.
+3) (Optional) Add Telegram/Discord/Slack tokens.
+4) Click **Run setup**.
+5) Open the Control UI at `https:///clawdbot`
+
+If Telegram DMs are set to pairing, the setup wizard can approve the pairing code.
+
+## Getting chat tokens
+
+### Telegram bot token
+
+1) Message `@BotFather` in Telegram
+2) Run `/newbot`
+3) Copy the token (looks like `123456789:AA...`)
+4) Paste it into `/setup`
+
+### Discord bot token
+
+1) Go to https://discord.com/developers/applications
+2) **New Application** → choose a name
+3) **Bot** → **Add Bot**
+4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup)
+5) Copy the **Bot Token** and paste into `/setup`
+6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
diff --git a/docs/vps.md b/docs/vps.md
index 192ab830e..08910733f 100644
--- a/docs/vps.md
+++ b/docs/vps.md
@@ -11,6 +11,8 @@ deployments work at a high level.
## Pick a provider
+- **Railway** (one‑click + browser setup): [Railway](/railway)
+- **Northflank** (one‑click + browser setup): [Northflank](/northflank)
- **Oracle Cloud (Always Free)**: [Oracle](/platforms/oracle) — $0/month (Always Free, ARM; capacity/signup can be finicky)
- **Fly.io**: [Fly.io](/platforms/fly)
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
diff --git a/package.json b/package.json
index 0c63d5d69..1299d72d5 100644
--- a/package.json
+++ b/package.json
@@ -237,6 +237,9 @@
"vitest": "^4.0.18",
"wireit": "^0.14.12"
},
+ "overrides": {
+ "tar": "7.5.4"
+ },
"pnpm": {
"minimumReleaseAge": 2880,
"overrides": {
diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts
index c167ac32a..891ab2b45 100644
--- a/src/agents/tools/telegram-actions.ts
+++ b/src/agents/tools/telegram-actions.ts
@@ -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}`);
}
diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts
index 939fa92f0..3537972e4 100644
--- a/src/auto-reply/reply/agent-runner-execution.ts
+++ b/src/auto-reply/reply/agent-runner-execution.ts
@@ -89,6 +89,7 @@ export async function runAgentTurnWithFallback(params: {
registerAgentRunContext(runId, {
sessionKey: params.sessionKey,
verboseLevel: params.resolvedVerboseLevel,
+ isHeartbeat: params.isHeartbeat,
});
}
let runResult: Awaited>;
diff --git a/src/auto-reply/reply/history.ts b/src/auto-reply/reply/history.ts
index bc59b4f2e..45ad44d5a 100644
--- a/src/auto-reply/reply/history.ts
+++ b/src/auto-reply/reply/history.ts
@@ -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(
+ historyMap: Map,
+ 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(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;
}
diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts
index 970a714d0..0fea27708 100644
--- a/src/auto-reply/reply/session-updates.ts
+++ b/src/auto-reply/reply/session-updates.ts
@@ -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();
}
diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts
index 6b79bf5ba..b2673134d 100644
--- a/src/channels/plugins/actions/telegram.test.ts
+++ b/src/channels/plugins/actions/telegram.test.ts
@@ -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();
+ });
});
diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts
index e281772bd..364707e0a 100644
--- a/src/channels/plugins/actions/telegram.ts
+++ b/src/channels/plugins/actions/telegram.ts
@@ -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(["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,
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 5d0b80e25..fa9e2890a 100644
--- a/src/config/types.telegram.ts
+++ b/src/config/types.telegram.ts
@@ -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;
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/gateway/server-chat.ts b/src/gateway/server-chat.ts
index 9ef62e688..8c67767a6 100644
--- a/src/gateway/server-chat.ts
+++ b/src/gateway/server-chat.ts
@@ -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;
}
diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts
index 08415f346..b72939c6a 100644
--- a/src/gateway/server-http.ts
+++ b/src/gateway/server-http.ts
@@ -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");
}
}
diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts
index c11dff8ab..5c41c3c95 100644
--- a/src/infra/agent-events.ts
+++ b/src/infra/agent-events.ts
@@ -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) {
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/infra/heartbeat-visibility.test.ts b/src/infra/heartbeat-visibility.test.ts
index 17a7dc128..e98054bbb 100644
--- a/src/infra/heartbeat-visibility.test.ts
+++ b/src/infra/heartbeat-visibility.test.ts
@@ -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);
+ });
});
diff --git a/src/infra/heartbeat-visibility.ts b/src/infra/heartbeat-visibility.ts
index 75555b878..c24b10417 100644
--- a/src/infra/heartbeat-visibility.ts
+++ b/src/infra/heartbeat-visibility.ts
@@ -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;
diff --git a/src/infra/heartbeat-wake.ts b/src/infra/heartbeat-wake.ts
index 9d5a4c4ce..eb26bf499 100644
--- a/src/infra/heartbeat-wake.ts
+++ b/src/infra/heartbeat-wake.ts
@@ -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);
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/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/security/audit.test.ts b/src/security/audit.test.ts
index 1006934d3..3a43ff4cc 100644
--- a/src/security/audit.test.ts
+++ b/src/security/audit.test.ts
@@ -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 () => {
diff --git a/src/security/audit.ts b/src/security/audit.ts
index 2169f197d..6cac2c37c 100644
--- a/src/security/audit.ts
+++ b/src/security/audit.ts
@@ -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 ({
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 d958d5616..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,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 =
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 7fdaef301..ebed468c9 100644
--- a/src/telegram/fetch.ts
+++ b/src/telegram/fetch.ts
@@ -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) {
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 24c8743df..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,6 +42,9 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions {
+ 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.edit-message.test.ts b/src/telegram/send.edit-message.test.ts
new file mode 100644
index 000000000..d495dfff5
--- /dev/null
+++ b/src/telegram/send.edit-message.test.ts
@@ -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;
+ 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;
+ 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, " html", {
+ token: "tok",
+ cfg: {},
+ buttons: [],
+ });
+
+ expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
+
+ const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record;
+ expect(firstParams).toEqual(
+ expect.objectContaining({
+ parse_mode: "HTML",
+ reply_markup: { inline_keyboard: [] },
+ }),
+ );
+
+ const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record;
+ expect(secondParams).toEqual(
+ expect.objectContaining({
+ reply_markup: { inline_keyboard: [] },
+ }),
+ );
+ });
+});
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 f9557bf1e..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) =>
@@ -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>;
+ /** Optional config injection to avoid global loadConfig() (improves testability). */
+ cfg?: ReturnType;
+};
+
+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 = (fn: () => Promise, 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 = {
+ 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 = {};
+ 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) {
switch (kind) {
case "image":
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;