From 9930ba91c5cca80dacc6fdd5725ea1bad1efef7a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 14 Jan 2026 10:09:26 +0000 Subject: [PATCH] fix(telegram): honor timeoutSeconds (thanks @Snaver) (#863) --- CHANGELOG.md | 1 + apps/macos/Sources/Clawdbot/CronModels.swift | 68 +++++++++---------- docs/channels/telegram.md | 1 + src/config/schema.ts | 3 + src/config/types.telegram.ts | 2 + src/config/zod-schema.providers-core.ts | 1 + ...gram-bot.installs-grammy-throttler.test.ts | 18 ++++- src/telegram/bot.ts | 32 ++++++--- ...send.returns-undefined-empty-input.test.ts | 29 +++++++- src/telegram/send.ts | 17 ++++- 10 files changed, 122 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b76b4e27f..b6350f7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes - Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`). +- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. ## 2026.1.13 diff --git a/apps/macos/Sources/Clawdbot/CronModels.swift b/apps/macos/Sources/Clawdbot/CronModels.swift index 1b40310d8..7c7e77e92 100644 --- a/apps/macos/Sources/Clawdbot/CronModels.swift +++ b/apps/macos/Sources/Clawdbot/CronModels.swift @@ -67,20 +67,20 @@ enum CronSchedule: Codable, Equatable { } } - enum CronPayload: Codable, Equatable { - case systemEvent(text: String) - case agentTurn( - message: String, - thinking: String?, - timeoutSeconds: Int?, - deliver: Bool?, - channel: String?, - to: String?, - bestEffortDeliver: Bool?) +enum CronPayload: Codable, Equatable { + case systemEvent(text: String) + case agentTurn( + message: String, + thinking: String?, + timeoutSeconds: Int?, + deliver: Bool?, + channel: String?, + to: String?, + bestEffortDeliver: Bool?) - enum CodingKeys: String, CodingKey { - case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver - } + enum CodingKeys: String, CodingKey { + case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver + } var kind: String { switch self { @@ -95,16 +95,16 @@ enum CronSchedule: Codable, Equatable { switch kind { case "systemEvent": self = try .systemEvent(text: container.decode(String.self, forKey: .text)) - case "agentTurn": - self = try .agentTurn( - message: container.decode(String.self, forKey: .message), - thinking: container.decodeIfPresent(String.self, forKey: .thinking), - timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), - deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), - channel: container.decodeIfPresent(String.self, forKey: .channel) - ?? container.decodeIfPresent(String.self, forKey: .provider), - to: container.decodeIfPresent(String.self, forKey: .to), - bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) + case "agentTurn": + self = try .agentTurn( + message: container.decode(String.self, forKey: .message), + thinking: container.decodeIfPresent(String.self, forKey: .thinking), + timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), + deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), + channel: container.decodeIfPresent(String.self, forKey: .channel) + ?? container.decodeIfPresent(String.self, forKey: .provider), + to: container.decodeIfPresent(String.self, forKey: .to), + bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) default: throw DecodingError.dataCorruptedError( forKey: .kind, @@ -119,17 +119,17 @@ enum CronSchedule: Codable, Equatable { switch self { case let .systemEvent(text): try container.encode(text, forKey: .text) - case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): - try container.encode(message, forKey: .message) - try container.encodeIfPresent(thinking, forKey: .thinking) - try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) - try container.encodeIfPresent(deliver, forKey: .deliver) - try container.encodeIfPresent(channel, forKey: .channel) - try container.encodeIfPresent(to, forKey: .to) - try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) - } - } - } + case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): + try container.encode(message, forKey: .message) + try container.encodeIfPresent(thinking, forKey: .thinking) + try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) + try container.encodeIfPresent(deliver, forKey: .deliver) + try container.encodeIfPresent(channel, forKey: .channel) + try container.encodeIfPresent(to, forKey: .to) + try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) + } + } +} struct CronIsolation: Codable, Equatable { var postToMainPrefix: String? diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 2c787f9a4..bf746f2f6 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -103,6 +103,7 @@ group messages, so use admin if you need full visibility. ## Limits - Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000). - Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5). +- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs. - Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). ## Group activation modes diff --git a/src/config/schema.ts b/src/config/schema.ts index 593e73a4a..3b60c2ed8 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -175,6 +175,7 @@ const FIELD_LABELS: Record = { "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", "channels.telegram.retry.jitter": "Telegram Retry Jitter", + "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", "channels.signal.dmPolicy": "Signal DM Policy", @@ -330,6 +331,8 @@ const FIELD_HELP: Record = { "Maximum retry delay cap in ms for Telegram outbound calls.", "channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.", + "channels.telegram.timeoutSeconds": + "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", "channels.whatsapp.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', "channels.whatsapp.selfChatMode": diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index e03dd72f3..366c48e65 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -63,6 +63,8 @@ export type TelegramAccountConfig = { /** Draft streaming mode for Telegram (off|partial|block). Default: partial. */ streamMode?: "off" | "partial" | "block"; mediaMaxMb?: number; + /** Telegram API client timeout in seconds (grammY ApiClientOptions). */ + timeoutSeconds?: number; /** Retry policy for outbound Telegram API calls. */ retry?: OutboundRetryConfig; proxy?: string; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 9d1c6296c..62f512ba2 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -53,6 +53,7 @@ export const TelegramAccountSchemaBase = z.object({ blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"), mediaMaxMb: z.number().positive().optional(), + timeoutSeconds: z.number().int().positive().optional(), retry: RetryConfigSchema, proxy: z.string().optional(), webhookUrl: z.string().optional(), diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index 8233bba78..9fa973947 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -80,7 +80,9 @@ vi.mock("grammy", () => ({ command = commandSpy; constructor( public token: string, - public options?: { client?: { fetch?: typeof fetch } }, + public options?: { + client?: { fetch?: typeof fetch; timeoutSeconds?: number }; + }, ) { botCtorSpy(token, options); } @@ -195,6 +197,20 @@ describe("createTelegramBot", () => { } } }); + it("passes timeoutSeconds even without a custom fetch", () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"], timeoutSeconds: 60 }, + }, + }); + createTelegramBot({ token: "tok" }); + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ timeoutSeconds: 60 }), + }), + ); + }); it("sequentializes updates by chat and thread", () => { createTelegramBot({ token: "tok" }); expect(sequentializeSpy).toHaveBeenCalledTimes(1); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index d8504abe2..423b1aaae 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -93,14 +93,30 @@ export function createTelegramBot(opts: TelegramBotOptions) { throw new Error(`exit ${code}`); }, }; + const cfg = opts.config ?? loadConfig(); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const telegramCfg = account.config; + const fetchImpl = resolveTelegramFetch(opts.proxyFetch); const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun); const shouldProvideFetch = Boolean(opts.proxyFetch) || isBun; - const client: ApiClientOptions | undefined = fetchImpl - ? shouldProvideFetch - ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } - : undefined - : undefined; + const timeoutSeconds = + typeof telegramCfg?.timeoutSeconds === "number" && + Number.isFinite(telegramCfg.timeoutSeconds) + ? Math.max(1, Math.floor(telegramCfg.timeoutSeconds)) + : undefined; + const client: ApiClientOptions | undefined = + shouldProvideFetch || timeoutSeconds + ? { + ...(shouldProvideFetch && fetchImpl + ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } + : {}), + ...(timeoutSeconds ? { timeoutSeconds } : {}), + } + : undefined; const bot = new Bot(opts.token, client ? { client } : undefined); bot.api.config.use(apiThrottler()); @@ -138,12 +154,6 @@ export function createTelegramBot(opts: TelegramBotOptions) { recordUpdateId(ctx); }); - const cfg = opts.config ?? loadConfig(); - const account = resolveTelegramAccount({ - cfg, - accountId: opts.accountId, - }); - const telegramCfg = account.config; const historyLimit = Math.max( 0, telegramCfg.historyLimit ?? diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts index 6ed549869..4027c7eac 100644 --- a/src/telegram/send.returns-undefined-empty-input.test.ts +++ b/src/telegram/send.returns-undefined-empty-input.test.ts @@ -21,7 +21,9 @@ vi.mock("grammy", () => ({ api = botApi; constructor( public token: string, - public options?: { client?: { fetch?: typeof fetch } }, + public options?: { + client?: { fetch?: typeof fetch; timeoutSeconds?: number }; + }, ) { botCtorSpy(token, options); } @@ -29,6 +31,17 @@ vi.mock("grammy", () => ({ InputFile: class {}, })); +const { loadConfig } = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), +})); +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig, + }; +}); + import { buildInlineKeyboard, sendMessageTelegram } from "./send.js"; describe("buildInlineKeyboard", () => { @@ -73,11 +86,25 @@ describe("buildInlineKeyboard", () => { describe("sendMessageTelegram", () => { beforeEach(() => { + loadConfig.mockReturnValue({}); loadWebMedia.mockReset(); botApi.sendMessage.mockReset(); botCtorSpy.mockReset(); }); + it("passes timeoutSeconds to grammY client when configured", async () => { + loadConfig.mockReturnValue({ + channels: { telegram: { timeoutSeconds: 60 } }, + }); + await sendMessageTelegram("123", "hi", { token: "tok" }); + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ timeoutSeconds: 60 }), + }), + ); + }); + it("falls back to plain text when Telegram rejects HTML", async () => { const chatId = "123"; const parseErr = new Error( diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 3cfabd872..43f9fe131 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -148,9 +148,20 @@ export async function sendMessageTelegram( // Use provided api or create a new Bot instance. The nullish coalescing // operator ensures api is always defined (Bot.api is always non-null). const fetchImpl = resolveTelegramFetch(); - const client: ApiClientOptions | undefined = fetchImpl - ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } - : undefined; + const timeoutSeconds = + typeof account.config.timeoutSeconds === "number" && + Number.isFinite(account.config.timeoutSeconds) + ? Math.max(1, Math.floor(account.config.timeoutSeconds)) + : undefined; + const client: ApiClientOptions | undefined = + fetchImpl || timeoutSeconds + ? { + ...(fetchImpl + ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } + : {}), + ...(timeoutSeconds ? { timeoutSeconds } : {}), + } + : undefined; const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const mediaUrl = opts.mediaUrl?.trim(); const replyMarkup = buildInlineKeyboard(opts.buttons);