mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:20:42 +00:00
fix(telegram): keep outbound timeout guard authoritative
This commit is contained in:
@@ -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