fix(telegram): honor timeoutSeconds (thanks @Snaver) (#863)
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ??
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user