fix(telegram): honor timeoutSeconds (thanks @Snaver) (#863)

This commit is contained in:
Peter Steinberger
2026-01-14 10:09:26 +00:00
parent 802c02eb74
commit 9930ba91c5
10 changed files with 122 additions and 50 deletions

View File

@@ -4,6 +4,7 @@
### Fixes ### Fixes
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`). - 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 ## 2026.1.13

View File

@@ -67,20 +67,20 @@ enum CronSchedule: Codable, Equatable {
} }
} }
enum CronPayload: Codable, Equatable { enum CronPayload: Codable, Equatable {
case systemEvent(text: String) case systemEvent(text: String)
case agentTurn( case agentTurn(
message: String, message: String,
thinking: String?, thinking: String?,
timeoutSeconds: Int?, timeoutSeconds: Int?,
deliver: Bool?, deliver: Bool?,
channel: String?, channel: String?,
to: String?, to: String?,
bestEffortDeliver: Bool?) bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
} }
var kind: String { var kind: String {
switch self { switch self {
@@ -95,16 +95,16 @@ enum CronSchedule: Codable, Equatable {
switch kind { switch kind {
case "systemEvent": case "systemEvent":
self = try .systemEvent(text: container.decode(String.self, forKey: .text)) self = try .systemEvent(text: container.decode(String.self, forKey: .text))
case "agentTurn": case "agentTurn":
self = try .agentTurn( self = try .agentTurn(
message: container.decode(String.self, forKey: .message), message: container.decode(String.self, forKey: .message),
thinking: container.decodeIfPresent(String.self, forKey: .thinking), thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
channel: container.decodeIfPresent(String.self, forKey: .channel) channel: container.decodeIfPresent(String.self, forKey: .channel)
?? container.decodeIfPresent(String.self, forKey: .provider), ?? container.decodeIfPresent(String.self, forKey: .provider),
to: container.decodeIfPresent(String.self, forKey: .to), to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default: default:
throw DecodingError.dataCorruptedError( throw DecodingError.dataCorruptedError(
forKey: .kind, forKey: .kind,
@@ -119,17 +119,17 @@ enum CronSchedule: Codable, Equatable {
switch self { switch self {
case let .systemEvent(text): case let .systemEvent(text):
try container.encode(text, forKey: .text) try container.encode(text, forKey: .text)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
try container.encode(message, forKey: .message) try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking) try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver) try container.encodeIfPresent(deliver, forKey: .deliver)
try container.encodeIfPresent(channel, forKey: .channel) try container.encodeIfPresent(channel, forKey: .channel)
try container.encodeIfPresent(to, forKey: .to) try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
} }
} }
} }
struct CronIsolation: Codable, Equatable { struct CronIsolation: Codable, Equatable {
var postToMainPrefix: String? var postToMainPrefix: String?

View File

@@ -103,6 +103,7 @@ group messages, so use admin if you need full visibility.
## Limits ## Limits
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000). - Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5). - 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 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 ## Group activation modes

View File

@@ -175,6 +175,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
"channels.telegram.retry.jitter": "Telegram Retry Jitter", "channels.telegram.retry.jitter": "Telegram Retry Jitter",
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy", "channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
"channels.signal.dmPolicy": "Signal DM Policy", "channels.signal.dmPolicy": "Signal DM Policy",
@@ -330,6 +331,8 @@ const FIELD_HELP: Record<string, string> = {
"Maximum retry delay cap in ms for Telegram outbound calls.", "Maximum retry delay cap in ms for Telegram outbound calls.",
"channels.telegram.retry.jitter": "channels.telegram.retry.jitter":
"Jitter factor (0-1) applied to Telegram retry delays.", "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": "channels.whatsapp.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', 'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].',
"channels.whatsapp.selfChatMode": "channels.whatsapp.selfChatMode":

View File

@@ -63,6 +63,8 @@ export type TelegramAccountConfig = {
/** Draft streaming mode for Telegram (off|partial|block). Default: partial. */ /** Draft streaming mode for Telegram (off|partial|block). Default: partial. */
streamMode?: "off" | "partial" | "block"; streamMode?: "off" | "partial" | "block";
mediaMaxMb?: number; mediaMaxMb?: number;
/** Telegram API client timeout in seconds (grammY ApiClientOptions). */
timeoutSeconds?: number;
/** Retry policy for outbound Telegram API calls. */ /** Retry policy for outbound Telegram API calls. */
retry?: OutboundRetryConfig; retry?: OutboundRetryConfig;
proxy?: string; proxy?: string;

View File

@@ -53,6 +53,7 @@ export const TelegramAccountSchemaBase = z.object({
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"), streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"),
mediaMaxMb: z.number().positive().optional(), mediaMaxMb: z.number().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
retry: RetryConfigSchema, retry: RetryConfigSchema,
proxy: z.string().optional(), proxy: z.string().optional(),
webhookUrl: z.string().optional(), webhookUrl: z.string().optional(),

View File

@@ -80,7 +80,9 @@ vi.mock("grammy", () => ({
command = commandSpy; command = commandSpy;
constructor( constructor(
public token: string, public token: string,
public options?: { client?: { fetch?: typeof fetch } }, public options?: {
client?: { fetch?: typeof fetch; timeoutSeconds?: number };
},
) { ) {
botCtorSpy(token, options); 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", () => { it("sequentializes updates by chat and thread", () => {
createTelegramBot({ token: "tok" }); createTelegramBot({ token: "tok" });
expect(sequentializeSpy).toHaveBeenCalledTimes(1); expect(sequentializeSpy).toHaveBeenCalledTimes(1);

View File

@@ -93,14 +93,30 @@ export function createTelegramBot(opts: TelegramBotOptions) {
throw new Error(`exit ${code}`); 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 fetchImpl = resolveTelegramFetch(opts.proxyFetch);
const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun); const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun);
const shouldProvideFetch = Boolean(opts.proxyFetch) || isBun; const shouldProvideFetch = Boolean(opts.proxyFetch) || isBun;
const client: ApiClientOptions | undefined = fetchImpl const timeoutSeconds =
? shouldProvideFetch typeof telegramCfg?.timeoutSeconds === "number" &&
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } Number.isFinite(telegramCfg.timeoutSeconds)
: undefined ? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
: undefined; : 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); const bot = new Bot(opts.token, client ? { client } : undefined);
bot.api.config.use(apiThrottler()); bot.api.config.use(apiThrottler());
@@ -138,12 +154,6 @@ export function createTelegramBot(opts: TelegramBotOptions) {
recordUpdateId(ctx); recordUpdateId(ctx);
}); });
const cfg = opts.config ?? loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const telegramCfg = account.config;
const historyLimit = Math.max( const historyLimit = Math.max(
0, 0,
telegramCfg.historyLimit ?? telegramCfg.historyLimit ??

View File

@@ -21,7 +21,9 @@ vi.mock("grammy", () => ({
api = botApi; api = botApi;
constructor( constructor(
public token: string, public token: string,
public options?: { client?: { fetch?: typeof fetch } }, public options?: {
client?: { fetch?: typeof fetch; timeoutSeconds?: number };
},
) { ) {
botCtorSpy(token, options); botCtorSpy(token, options);
} }
@@ -29,6 +31,17 @@ vi.mock("grammy", () => ({
InputFile: class {}, InputFile: class {},
})); }));
const { loadConfig } = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig,
};
});
import { buildInlineKeyboard, sendMessageTelegram } from "./send.js"; import { buildInlineKeyboard, sendMessageTelegram } from "./send.js";
describe("buildInlineKeyboard", () => { describe("buildInlineKeyboard", () => {
@@ -73,11 +86,25 @@ describe("buildInlineKeyboard", () => {
describe("sendMessageTelegram", () => { describe("sendMessageTelegram", () => {
beforeEach(() => { beforeEach(() => {
loadConfig.mockReturnValue({});
loadWebMedia.mockReset(); loadWebMedia.mockReset();
botApi.sendMessage.mockReset(); botApi.sendMessage.mockReset();
botCtorSpy.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 () => { it("falls back to plain text when Telegram rejects HTML", async () => {
const chatId = "123"; const chatId = "123";
const parseErr = new Error( const parseErr = new Error(

View File

@@ -148,9 +148,20 @@ export async function sendMessageTelegram(
// Use provided api or create a new Bot instance. The nullish coalescing // Use provided api or create a new Bot instance. The nullish coalescing
// operator ensures api is always defined (Bot.api is always non-null). // operator ensures api is always defined (Bot.api is always non-null).
const fetchImpl = resolveTelegramFetch(); const fetchImpl = resolveTelegramFetch();
const client: ApiClientOptions | undefined = fetchImpl const timeoutSeconds =
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } typeof account.config.timeoutSeconds === "number" &&
: undefined; 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 api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
const mediaUrl = opts.mediaUrl?.trim(); const mediaUrl = opts.mediaUrl?.trim();
const replyMarkup = buildInlineKeyboard(opts.buttons); const replyMarkup = buildInlineKeyboard(opts.buttons);