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

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