fix(telegram): keep outbound timeout guard authoritative

This commit is contained in:
Peter Steinberger
2026-05-02 11:00:14 +01:00
parent e0a267afc6
commit e497681dea
7 changed files with 146 additions and 17 deletions

View File

@@ -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.<chatId>.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:<service>:<account>` `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.

View File

@@ -724,7 +724,7 @@ curl "https://api.telegram.org/bot<bot_token>/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`
</Accordion>
@@ -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`.

View File

@@ -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;

View File

@@ -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 }),
}),
);
});

View File

@@ -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<AbortSignal>((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<AbortSignal>((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"), {

View File

@@ -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();

View File

@@ -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 {