mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
fix(telegram): keep outbound timeout guard authoritative
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"), {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user