diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa1ee4c849..f7cd285788c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - Plugins: clarify config-selected duplicate plugin override diagnostics and document manifest schema updates for bundled-plugin forks. Fixes #8582. Thanks @sachah. - CLI backends/Claude: make live-session JSONL turn caps bounded and configurable via `reliability.outputLimits`, raising the default guard for tool-heavy Claude CLI turns while preserving memory limits. Fixes #75838. Thanks @hcordoba840. - Telegram/DMs: keep incidental `message_thread_id` reply-with-quote metadata on the flat DM session by default while preserving opt-in DM topic isolation for configured topics, `dm.threadReplies`, and `direct..threadReplies`. Fixes #75975. Thanks @ProjectEvolutionEVE. +- Telegram/network: raise outbound text and typing Bot API request guards to 60 seconds, keep low grammY client timeouts from preempting those guards, let higher `timeoutSeconds` configs extend safe method guards, and retry timed-out typing indicators through the transport fallback without risking duplicate messages. Fixes #76013. Thanks @iaki1206. - Providers/OpenAI: resolve `keychain::` `OPENAI_API_KEY` refs before creating OpenAI Realtime browser sessions or voice bridges, with a bounded cached Keychain lookup. Fixes #72120. Thanks @ctbritt. - Discord/gateway: reconnect when the gateway socket closes while waiting for the shared IDENTIFY concurrency window, instead of silently skipping IDENTIFY and leaving the bot online but unresponsive. Fixes #74617. Thanks @zeeskdr-ai. - Voice Call: add `sessionScope: "per-call"` for fresh per-call agent memory while preserving the default per-phone caller history. Fixes #45280. Thanks @pondcountry. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 127bc0b52bc..3030f91efff 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -724,7 +724,7 @@ curl "https://api.telegram.org/bot/getUpdates" - `channels.telegram.textChunkLimit` default is 4000. - `channels.telegram.chunkMode="newline"` prefers paragraph boundaries (blank lines) before length splitting. - `channels.telegram.mediaMaxMb` (default 100) caps inbound and outbound Telegram media size. - - `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies). Long-polling bot clients clamp configured values below the 45-second `getUpdates` request guard so idle polls are not aborted before the 30-second poll window completes. + - `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies). Bot clients clamp configured values below the 60-second outbound text/typing request guard so grammY does not abort visible reply delivery before OpenClaw's transport guard and fallback can run. Long polling still uses a 45-second `getUpdates` request guard so idle polls are not abandoned indefinitely. - `channels.telegram.pollingStallThresholdMs` defaults to `120000`; tune between `30000` and `600000` only for false-positive polling-stall restarts. - group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables. - reply/quote/forward supplemental context is currently passed as received. @@ -846,7 +846,7 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance - authorize your sender identity (pairing and/or numeric `allowFrom`) - command authorization still applies even when group policy is `open` - `setMyCommands failed` with `BOT_COMMANDS_TOO_MUCH` means the native menu has too many entries; reduce plugin/skill/custom commands or disable native menus - - `deleteMyCommands` / `setMyCommands` startup calls are bounded and retry once through Telegram's transport fallback on request timeout. Persistent network/fetch errors usually indicate DNS/HTTPS reachability issues to `api.telegram.org` + - `deleteMyCommands` / `setMyCommands` startup calls and `sendChatAction` typing calls are bounded and retry once through Telegram's transport fallback on request timeout. Persistent network/fetch errors usually indicate DNS/HTTPS reachability issues to `api.telegram.org` @@ -864,7 +864,7 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance - Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch. - Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures. - If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors. - - If Telegram sockets recycle on a short fixed cadence, check for a low `channels.telegram.timeoutSeconds`; long-polling bot clients clamp configured values below the `getUpdates` request guard, but older releases could abort every poll when this was set below the long-poll timeout. + - If Telegram sockets recycle on a short fixed cadence, check for a low `channels.telegram.timeoutSeconds`; bot clients clamp configured values below the outbound and `getUpdates` request guards, but older releases could abort every poll or reply when this was set below those guards. - If logs include `Polling stall detected`, OpenClaw restarts polling and rebuilds the Telegram transport after 120 seconds without completed long-poll liveness by default. - `openclaw channels status --probe` and `openclaw doctor` warn when a running polling account has not completed `getUpdates` after startup grace, when a running webhook account has not completed `setWebhook` after startup grace, or when the last successful polling transport activity is stale. - Increase `channels.telegram.pollingStallThresholdMs` only when long-running `getUpdates` calls are healthy but your host still reports false polling-stall restarts. Persistent stalls usually point to proxy, DNS, IPv6, or TLS egress issues between the host and `api.telegram.org`. diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index fe9b2f86339..cbf88db7d55 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -132,6 +132,7 @@ const TELEGRAM_TIMEOUT_FALLBACK_METHODS = new Set([ "deletemycommands", "deletewebhook", "getme", + "sendchataction", "setmycommands", "setwebhook", ]); @@ -154,6 +155,23 @@ function resolveTelegramClientTimeoutSeconds(params: { return Math.max(configured, Math.max(1, Math.floor(minimum))); } +function resolveTelegramClientTimeoutMinimumSeconds(values: readonly (number | undefined)[]) { + let minimum: number | undefined; + for (const value of values) { + if (typeof value !== "number" || !Number.isFinite(value)) { + continue; + } + const normalized = Math.max(1, Math.ceil(value)); + minimum = minimum === undefined ? normalized : Math.max(minimum, normalized); + } + return minimum; +} + +function resolveTelegramOutboundClientTimeoutFloorSeconds(timeoutSeconds: unknown) { + const timeoutMs = resolveTelegramRequestTimeoutMs("sendmessage", timeoutSeconds); + return timeoutMs === undefined ? undefined : timeoutMs / 1000; +} + export function createTelegramBotCore( opts: TelegramBotOptions & { telegramDeps: TelegramBotDeps }, ): TelegramBotInstance { @@ -214,7 +232,7 @@ export function createTelegramBotCore( // causing "signals[0] must be an instance of AbortSignal" errors). finalFetch = async (input: TelegramFetchInput, init?: TelegramFetchInit) => { const method = extractTelegramApiMethod(input); - const requestTimeoutMs = resolveTelegramRequestTimeoutMs(method); + const requestTimeoutMs = resolveTelegramRequestTimeoutMs(method, telegramCfg?.timeoutSeconds); const shutdownSignal = isTelegramAbortSignalLike(opts.fetchAbortSignal) ? opts.fetchAbortSignal : undefined; @@ -314,7 +332,10 @@ export function createTelegramBotCore( const timeoutSeconds = resolveTelegramClientTimeoutSeconds({ value: telegramCfg?.timeoutSeconds, - minimum: opts.minimumClientTimeoutSeconds, + minimum: resolveTelegramClientTimeoutMinimumSeconds([ + opts.minimumClientTimeoutSeconds, + resolveTelegramOutboundClientTimeoutFloorSeconds(telegramCfg?.timeoutSeconds), + ]), }); const apiRoot = normalizeOptionalString(telegramCfg.apiRoot); const normalizedApiRoot = apiRoot ? normalizeTelegramApiRoot(apiRoot) : undefined; diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 71c48c8bde9..bb3a85e143e 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -248,7 +248,7 @@ describe("createTelegramBot", () => { ); }); - it("honors low timeoutSeconds when no polling floor is requested", () => { + it("keeps low timeoutSeconds above the outbound request guard", () => { loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], timeoutSeconds: 10 }, @@ -258,12 +258,12 @@ describe("createTelegramBot", () => { expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ - client: expect.objectContaining({ timeoutSeconds: 10 }), + client: expect.objectContaining({ timeoutSeconds: 60 }), }), ); }); - it("keeps polling client timeout above the getUpdates request guard", () => { + it("keeps polling client timeout above the outbound request guard", () => { loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], timeoutSeconds: 10 }, @@ -273,7 +273,7 @@ describe("createTelegramBot", () => { expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ - client: expect.objectContaining({ timeoutSeconds: 45 }), + client: expect.objectContaining({ timeoutSeconds: 60 }), }), ); }); diff --git a/extensions/telegram/src/bot.fetch-abort.test.ts b/extensions/telegram/src/bot.fetch-abort.test.ts index 4bd6bd1f2a4..b9881512e14 100644 --- a/extensions/telegram/src/bot.fetch-abort.test.ts +++ b/extensions/telegram/src/bot.fetch-abort.test.ts @@ -14,11 +14,15 @@ const createTelegramBot = (opts: import("./bot.types.js").TelegramBotOptions) => telegramDeps: telegramBotDepsForTest, }); -function createWrappedTelegramClientFetch(proxyFetch: typeof fetch) { +function createWrappedTelegramClientFetch( + proxyFetch: typeof fetch, + config?: import("openclaw/plugin-sdk/config-types").OpenClawConfig, +) { const shutdown = new AbortController(); botCtorSpy.mockClear(); createTelegramBot({ token: "tok", + ...(config ? { config } : {}), fetchAbortSignal: shutdown.signal, proxyFetch, }); @@ -111,6 +115,53 @@ describe("createTelegramBot fetch abort", () => { vi.useRealTimers(); }); + it("uses the longer outbound text timeout for sendMessage", async () => { + vi.useFakeTimers(); + const fetchSpy = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((resolve) => { + const signal = init?.signal as AbortSignal; + signal.addEventListener("abort", () => resolve(signal), { once: true }); + }), + ); + const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch); + + const observedSignalPromise = clientFetch("https://api.telegram.org/bot123456:ABC/sendMessage"); + await vi.advanceTimersByTimeAsync(60_000); + const observedSignal = (await observedSignalPromise) as AbortSignal; + + expect(observedSignal).toBeInstanceOf(AbortSignal); + expect(observedSignal.aborted).toBe(true); + vi.useRealTimers(); + }); + + it("lets configured timeoutSeconds extend outbound method guards", async () => { + vi.useFakeTimers(); + const fetchSpy = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((resolve) => { + const signal = init?.signal as AbortSignal; + signal.addEventListener("abort", () => resolve(signal), { once: true }); + }), + ); + const { clientFetch } = createWrappedTelegramClientFetch( + fetchSpy as unknown as typeof fetch, + { + channels: { telegram: { timeoutSeconds: 90 } }, + } as never, + ); + + const observedSignalPromise = clientFetch( + "https://api.telegram.org/bot123456:ABC/editMessageText", + ); + await vi.advanceTimersByTimeAsync(90_000); + const observedSignal = (await observedSignalPromise) as AbortSignal; + + expect(observedSignal).toBeInstanceOf(AbortSignal); + expect(observedSignal.aborted).toBe(true); + vi.useRealTimers(); + }); + it("retries timed-out control calls once after forcing transport fallback", async () => { vi.useFakeTimers(); const forceFallback = vi.fn(() => true); @@ -168,6 +219,33 @@ describe("createTelegramBot fetch abort", () => { }, ); + it("retries timed-out sendChatAction once after forcing transport fallback", async () => { + vi.useFakeTimers(); + const forceFallback = vi.fn(() => true); + const fetchSpy = vi + .fn() + .mockImplementationOnce( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal; + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + ) + .mockResolvedValueOnce({ ok: true } as Response); + const { clientFetch } = createWrappedTelegramClientFetchWithTransport({ + fetch: fetchSpy as unknown as typeof fetch, + forceFallback, + }); + + const resultPromise = clientFetch("https://api.telegram.org/bot123456:ABC/sendChatAction"); + await vi.advanceTimersByTimeAsync(60_000); + + await expect(resultPromise).resolves.toEqual({ ok: true }); + expect(forceFallback).toHaveBeenCalledWith("request-timeout"); + expect(fetchSpy).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + it("preserves the original fetch error when tagging cannot attach metadata", async () => { const frozenError = Object.freeze( Object.assign(new TypeError("fetch failed"), { diff --git a/extensions/telegram/src/request-timeouts.test.ts b/extensions/telegram/src/request-timeouts.test.ts index c43e34c3b73..64205be8292 100644 --- a/extensions/telegram/src/request-timeouts.test.ts +++ b/extensions/telegram/src/request-timeouts.test.ts @@ -18,12 +18,25 @@ describe("resolveTelegramRequestTimeoutMs", () => { }); it("bounds outbound delivery methods", () => { - expect(resolveTelegramRequestTimeoutMs("sendmessage")).toBe(20_000); - expect(resolveTelegramRequestTimeoutMs("sendchataction")).toBe(10_000); + expect(resolveTelegramRequestTimeoutMs("sendmessage")).toBe(60_000); + expect(resolveTelegramRequestTimeoutMs("sendchataction")).toBe(60_000); + expect(resolveTelegramRequestTimeoutMs("sendmessagedraft")).toBe(60_000); expect(resolveTelegramRequestTimeoutMs("editmessagetext")).toBe(15_000); expect(resolveTelegramRequestTimeoutMs("sendphoto")).toBe(30_000); }); + it("honors higher configured timeoutSeconds except for long polling", () => { + expect(resolveTelegramRequestTimeoutMs("sendmessage", 90)).toBe(90_000); + expect(resolveTelegramRequestTimeoutMs("sendchataction", 90)).toBe(90_000); + expect(resolveTelegramRequestTimeoutMs("editmessagetext", 90)).toBe(90_000); + expect(resolveTelegramRequestTimeoutMs("getupdates", 90)).toBe(45_000); + }); + + it("does not let low timeoutSeconds shorten method guards", () => { + expect(resolveTelegramRequestTimeoutMs("sendmessage", 10)).toBe(60_000); + expect(resolveTelegramRequestTimeoutMs("getme", 10)).toBe(15_000); + }); + it("does not assign hard timeouts to unrelated Telegram methods", () => { expect(resolveTelegramRequestTimeoutMs("answercallbackquery")).toBeUndefined(); expect(resolveTelegramRequestTimeoutMs(null)).toBeUndefined(); diff --git a/extensions/telegram/src/request-timeouts.ts b/extensions/telegram/src/request-timeouts.ts index 207238dbf8d..a8fd004a3c3 100644 --- a/extensions/telegram/src/request-timeouts.ts +++ b/extensions/telegram/src/request-timeouts.ts @@ -1,4 +1,5 @@ export const TELEGRAM_GET_UPDATES_REQUEST_TIMEOUT_MS = 45_000; +const TELEGRAM_OUTBOUND_TEXT_REQUEST_TIMEOUT_MS = 60_000; const TELEGRAM_REQUEST_TIMEOUTS_MS = { // Bound startup/control-plane calls so the gateway cannot report Telegram as @@ -15,10 +16,10 @@ const TELEGRAM_REQUEST_TIMEOUTS_MS = { pinchatmessage: 15_000, sendanimation: 30_000, sendaudio: 30_000, - sendchataction: 10_000, + sendchataction: TELEGRAM_OUTBOUND_TEXT_REQUEST_TIMEOUT_MS, senddocument: 30_000, - sendmessage: 20_000, - sendmessagedraft: 20_000, + sendmessage: TELEGRAM_OUTBOUND_TEXT_REQUEST_TIMEOUT_MS, + sendmessagedraft: TELEGRAM_OUTBOUND_TEXT_REQUEST_TIMEOUT_MS, sendphoto: 30_000, sendvideo: 30_000, sendvoice: 30_000, @@ -27,11 +28,26 @@ const TELEGRAM_REQUEST_TIMEOUTS_MS = { setwebhook: 15_000, } as const; -export function resolveTelegramRequestTimeoutMs(method: string | null): number | undefined { +function resolveConfiguredTelegramRequestTimeoutMs(timeoutSeconds: unknown): number | undefined { + if (typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds)) { + return undefined; + } + return Math.max(1, Math.floor(timeoutSeconds)) * 1000; +} + +export function resolveTelegramRequestTimeoutMs( + method: string | null, + timeoutSeconds?: unknown, +): number | undefined { if (!method) { return undefined; } - return TELEGRAM_REQUEST_TIMEOUTS_MS[method as keyof typeof TELEGRAM_REQUEST_TIMEOUTS_MS]; + const baseTimeoutMs = + TELEGRAM_REQUEST_TIMEOUTS_MS[method as keyof typeof TELEGRAM_REQUEST_TIMEOUTS_MS]; + if (baseTimeoutMs === undefined || method === "getupdates") { + return baseTimeoutMs; + } + return Math.max(baseTimeoutMs, resolveConfiguredTelegramRequestTimeoutMs(timeoutSeconds) ?? 0); } export function resolveTelegramStartupProbeTimeoutMs(timeoutSeconds: unknown): number {