Skip to content

Commit 5b94c4c

Browse files
obviyussteipete
andauthored
fix(telegram): start polling after webhook cleanup timeout (#76735)
Summary: - The branch changes Telegram polling startup to reuse the successful probe `getMe` result as grammY `botInfo` ... es` after recoverable `deleteWebhook` failures, and updates Telegram docs, changelog, and regression tests. - Reproducibility: yes. for the narrow PR bug: source inspection shows current main can block before polling o ... d timeout coverage that reaches `run()`. The full linked high-RTT report remains only partially reproduced. Automerge notes: - Ran the ClawSweeper repair loop before final review. - Included post-review commit in the final squash: fix(telegram): start polling after webhook cleanup timeout - Included post-review commit in the final squash: fix(telegram): extract bot info contract Validation: - ClawSweeper review passed for head c74bbdd. - Required merge gates passed before the squash merge. Prepared head SHA: c74bbdd Review: #76735 (comment) Co-authored-by: Ayaan Zaidi <hi@obviy.us> Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent d0497d1 commit 5b94c4c

16 files changed

Lines changed: 225 additions & 74 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
2727
- Control UI/Sessions: avoid full `sessions.list` reloads for chat-turn `sessions.changed` payloads, so large session stores no longer add multi-second delays while chat responses are being delivered. (#76676) Thanks @VACInc.
2828
- Discord/status: honor explicit `messages.statusReactions.enabled: true` in tool-only guild channels so queued ack reactions can progress through thinking/done lifecycle reactions instead of stopping at the initial emoji. Thanks @Marvinthebored.
2929
- Agents/OpenAI: omit Chat Completions `reasoning_effort` for `gpt-5.4-mini` only when function tools are present while preserving tool-free Chat and Responses reasoning support, preventing Telegram-routed fallback runs from hanging after OpenAI rejects tool payloads. Fixes #76176. Thanks @ThisIsAdilah and @chinar-amrutkar.
30+
- Telegram: reuse the successful startup `getMe` probe for grammY polling startup and continue into `getUpdates` after recoverable `deleteWebhook` cleanup failures, reducing high-latency Bot API control-plane calls before long polling starts. Refs #76388. Thanks @jackiedepp.
3031
- Agents/models: forward model `maxTokens` as the default output-token limit for OpenAI-compatible Responses and Completions transports when no runtime override is provided, preventing provider defaults from silently truncating larger outputs. (#76645) Thanks @joeyfrasier.
3132
- macOS CLI/onboarding: honor sensitive wizard text steps in `openclaw-mac wizard` with termios no-echo input, suppressing saved credential previews while preserving long API keys and gateway tokens. Fixes #76698. Thanks @anurag-bg-neu and @sallyom.
3233
- Control UI/Skills: fix skill detail modal silently failing to open in all browsers by deferring `showModal()` until the dialog element is connected to the DOM; the Lit `ref` callback fired before connection causing a `DOMException: HTMLDialogElement.showModal: Dialog element is not connected` on every skill click. Thanks @nickmopen.

docs/channels/telegram.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -855,7 +855,8 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance
855855
- `getMe returned 401` is a Telegram authentication failure for the configured bot token.
856856
- Re-copy or regenerate the bot token in BotFather, then update `channels.telegram.botToken`, `channels.telegram.tokenFile`, `channels.telegram.accounts.<id>.botToken`, or `TELEGRAM_BOT_TOKEN` for the default account.
857857
- `deleteWebhook 401 Unauthorized` during startup is also an auth failure; treating it as "no webhook exists" would only defer the same bad-token failure to later API calls.
858-
- If `deleteWebhook` fails with a transient network error during polling startup, OpenClaw checks `getWebhookInfo`; when Telegram reports an empty webhook URL, polling continues because cleanup is already satisfied.
858+
- If `deleteWebhook` fails with a transient network error during polling startup, OpenClaw continues into long polling instead of making another pre-poll control-plane call. A still-active webhook surfaces as a `getUpdates` conflict; OpenClaw then rebuilds the Telegram transport and retries webhook cleanup.
859+
- After a successful startup `getMe` probe, OpenClaw reuses that bot identity for grammY polling startup so the runner does not need a second `getMe` before the first `getUpdates`.
859860

860861
</Accordion>
861862

extensions/telegram/src/bot-core.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,11 @@ export function createTelegramBotCore(
348348
}
349349
: undefined;
350350

351-
const bot = new botRuntime.Bot(opts.token, client ? { client } : undefined);
351+
const botConfig =
352+
client || opts.botInfo
353+
? { ...(client ? { client } : {}), ...(opts.botInfo ? { botInfo: opts.botInfo } : {}) }
354+
: undefined;
355+
const bot = new botRuntime.Bot(opts.token, botConfig);
352356
bot.api.config.use(botRuntime.apiThrottler());
353357
// Catch all errors from bot middleware to prevent unhandled rejections
354358
bot.catch((err) => {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export type TelegramBotInfo = {
2+
id: number;
3+
is_bot: true;
4+
first_name: string;
5+
last_name?: string;
6+
username: string;
7+
language_code?: string;
8+
can_join_groups: boolean;
9+
can_read_all_group_messages: boolean;
10+
can_manage_bots: boolean;
11+
supports_inline_queries: boolean;
12+
can_connect_to_business: boolean;
13+
has_main_web_app: boolean;
14+
has_topics_enabled: boolean;
15+
allows_users_to_create_topics: boolean;
16+
};

extensions/telegram/src/bot.create-telegram-bot.test-harness.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,9 @@ const grammySpies = vi.hoisted(() => ({
266266
onSpy: vi.fn(),
267267
stopSpy: vi.fn(),
268268
commandSpy: vi.fn(),
269-
botCtorSpy: vi.fn((_: string, __?: { client?: { fetch?: typeof fetch } }) => undefined),
269+
botCtorSpy: vi.fn(
270+
(_: string, __?: { client?: { fetch?: typeof fetch }; botInfo?: unknown }) => undefined,
271+
),
270272
answerCallbackQuerySpy: vi.fn(async () => undefined) as AnyAsyncMock,
271273
sendChatActionSpy: vi.fn(),
272274
editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
@@ -290,7 +292,7 @@ export const onSpy: AnyMock = grammySpies.onSpy;
290292
export const stopSpy: AnyMock = grammySpies.stopSpy;
291293
export const commandSpy: AnyMock = grammySpies.commandSpy;
292294
export const botCtorSpy: MockFn<
293-
(token: string, options?: { client?: { fetch?: typeof fetch } }) => void
295+
(token: string, options?: { client?: { fetch?: typeof fetch }; botInfo?: unknown }) => void
294296
> = grammySpies.botCtorSpy;
295297
export const answerCallbackQuerySpy: AnyAsyncMock = grammySpies.answerCallbackQuerySpy;
296298
export const sendChatActionSpy: AnyMock = grammySpies.sendChatActionSpy;
@@ -341,7 +343,7 @@ export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = {
341343
catch = vi.fn();
342344
constructor(
343345
public token: string,
344-
public options?: { client?: { fetch?: typeof fetch } },
346+
public options?: { client?: { fetch?: typeof fetch }; botInfo?: unknown },
345347
) {
346348
(grammySpies.botCtorSpy as unknown as (token: string, options?: unknown) => void)(
347349
token,

extensions/telegram/src/bot.create-telegram-bot.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,32 @@ describe("createTelegramBot", () => {
278278
);
279279
});
280280

281+
it("passes startup probe botInfo to grammY", () => {
282+
const botInfo = {
283+
id: 123456,
284+
is_bot: true,
285+
first_name: "OpenClaw",
286+
username: "openclaw_bot",
287+
can_join_groups: true,
288+
can_read_all_group_messages: false,
289+
can_manage_bots: false,
290+
supports_inline_queries: false,
291+
can_connect_to_business: false,
292+
has_main_web_app: false,
293+
has_topics_enabled: false,
294+
allows_users_to_create_topics: false,
295+
} as const;
296+
297+
createTelegramBot({ token: "tok", botInfo });
298+
299+
expect(botCtorSpy).toHaveBeenCalledWith(
300+
"tok",
301+
expect.objectContaining({
302+
botInfo,
303+
}),
304+
);
305+
});
306+
281307
it("normalizes full Telegram bot endpoint apiRoot before passing it to grammY", () => {
282308
loadConfig.mockReturnValue({
283309
channels: {

extensions/telegram/src/bot.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-types";
22
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
33
import type { TelegramBotDeps } from "./bot-deps.js";
4+
import type { TelegramBotInfo } from "./bot-info.js";
45
import type { TelegramTransport } from "./fetch.js";
56

67
export type TelegramBotOptions = {
@@ -14,6 +15,8 @@ export type TelegramBotOptions = {
1415
replyToMode?: ReplyToMode;
1516
proxyFetch?: typeof fetch;
1617
config?: OpenClawConfig;
18+
/** Bot identity returned by the startup getMe probe. Avoids a duplicate grammY init getMe before polling. */
19+
botInfo?: TelegramBotInfo;
1720
/** Signal to abort in-flight Telegram API fetch requests (e.g. getUpdates) on shutdown. */
1821
fetchAbortSignal?: AbortSignal;
1922
/** Minimum grammY client timeout when timeoutSeconds is configured on long-polling bots. */

extensions/telegram/src/channel.gateway.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,45 @@ describe("telegramPlugin gateway startup", () => {
156156
);
157157
});
158158

159+
it("passes successful startup probe botInfo into the polling monitor", async () => {
160+
installTelegramRuntime();
161+
const botInfo = {
162+
id: 123456,
163+
is_bot: true,
164+
first_name: "OpenClaw",
165+
username: "openclaw_bot",
166+
can_join_groups: true,
167+
can_read_all_group_messages: false,
168+
can_manage_bots: false,
169+
supports_inline_queries: false,
170+
can_connect_to_business: false,
171+
has_main_web_app: false,
172+
has_topics_enabled: false,
173+
allows_users_to_create_topics: false,
174+
} as const;
175+
probeTelegram.mockResolvedValue({
176+
ok: true,
177+
status: null,
178+
error: null,
179+
elapsedMs: 12,
180+
bot: {
181+
id: botInfo.id,
182+
username: botInfo.username,
183+
},
184+
botInfo,
185+
});
186+
monitorTelegramProvider.mockResolvedValue(undefined);
187+
188+
const { task } = startTelegramAccount();
189+
190+
await expect(task).resolves.toBeUndefined();
191+
expect(monitorTelegramProvider).toHaveBeenCalledWith(
192+
expect.objectContaining({
193+
botInfo,
194+
}),
195+
);
196+
});
197+
159198
it("honors higher per-account timeoutSeconds for startup probe", async () => {
160199
installTelegramRuntime();
161200
probeTelegram.mockResolvedValue({

extensions/telegram/src/channel.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { resolveTelegramAutoThreadId } from "./action-threading.js";
4040
import { lookupTelegramChatId } from "./api-fetch.js";
4141
import { telegramApprovalCapability } from "./approval-native.js";
4242
import * as auditModule from "./audit.js";
43+
import type { TelegramBotInfo } from "./bot-info.js";
4344
import { buildTelegramGroupPeerId } from "./bot/helpers.js";
4445
import { telegramMessageActions as telegramMessageActionsImpl } from "./channel-actions.js";
4546
import {
@@ -897,6 +898,7 @@ export const telegramPlugin = createChatChannelPlugin({
897898
const token = (account.token ?? "").trim();
898899
let telegramBotLabel = "";
899900
let unauthorizedTokenReason: string | null = null;
901+
let botInfo: TelegramBotInfo | undefined;
900902
try {
901903
const probe = await resolveTelegramProbe()(
902904
token,
@@ -913,6 +915,7 @@ export const telegramPlugin = createChatChannelPlugin({
913915
if (username) {
914916
telegramBotLabel = ` (@${username})`;
915917
}
918+
botInfo = probe.ok ? probe.botInfo : undefined;
916919
if (!probe.ok && probe.status === 401) {
917920
unauthorizedTokenReason = formatTelegramUnauthorizedTokenError(account);
918921
}
@@ -944,6 +947,7 @@ export const telegramPlugin = createChatChannelPlugin({
944947
webhookHost: account.config.webhookHost,
945948
webhookPort: account.config.webhookPort,
946949
webhookCertPath: account.config.webhookCertPath,
950+
botInfo,
947951
setStatus,
948952
});
949953
},

extensions/telegram/src/monitor.test.ts

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -495,52 +495,37 @@ describe("monitorTelegramProvider (grammY)", () => {
495495
expect(order).toEqual(["deleteWebhook", "run"]);
496496
});
497497

498-
it("retries recoverable deleteWebhook failures before polling", async () => {
498+
it("starts polling after recoverable deleteWebhook failures", async () => {
499499
const abort = new AbortController();
500500
const cleanupError = makeRecoverableFetchError();
501501
api.deleteWebhook.mockReset();
502-
api.getWebhookInfo.mockReset().mockResolvedValueOnce({ url: "https://example.test/hook" });
503-
api.deleteWebhook.mockRejectedValueOnce(cleanupError).mockResolvedValueOnce(true);
502+
api.getWebhookInfo.mockReset();
503+
api.deleteWebhook.mockRejectedValueOnce(cleanupError);
504504
mockRunOnceAndAbort(abort);
505505

506506
await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
507507

508-
expect(api.deleteWebhook).toHaveBeenCalledTimes(2);
509-
expect(api.getWebhookInfo).toHaveBeenCalledTimes(1);
508+
expect(api.deleteWebhook).toHaveBeenCalledTimes(1);
509+
expect(api.getWebhookInfo).not.toHaveBeenCalled();
510510
expectRecoverableRetryState(1);
511511
});
512512

513-
it("continues polling when deleteWebhook transiently fails but webhook is already absent", async () => {
513+
it("does not run webhook confirmation when deleteWebhook transiently fails", async () => {
514514
const abort = new AbortController();
515515
const cleanupError = makeRecoverableFetchError();
516516
api.deleteWebhook.mockReset();
517-
api.getWebhookInfo.mockReset().mockResolvedValueOnce({ url: "" });
517+
api.getWebhookInfo.mockReset();
518518
api.deleteWebhook.mockRejectedValueOnce(cleanupError);
519519
mockRunOnceAndAbort(abort);
520520

521521
await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
522522

523523
expect(api.deleteWebhook).toHaveBeenCalledTimes(1);
524-
expect(api.getWebhookInfo).toHaveBeenCalledTimes(1);
524+
expect(api.getWebhookInfo).not.toHaveBeenCalled();
525525
expect(runSpy).toHaveBeenCalledTimes(1);
526526
expect(sleepWithAbort).not.toHaveBeenCalled();
527527
});
528528

529-
it("retries cleanup when deleteWebhook and webhook confirmation both transiently fail", async () => {
530-
const abort = new AbortController();
531-
const cleanupError = makeRecoverableFetchError();
532-
api.deleteWebhook.mockReset();
533-
api.getWebhookInfo.mockReset().mockRejectedValueOnce(makeRecoverableFetchError());
534-
api.deleteWebhook.mockRejectedValueOnce(cleanupError).mockResolvedValueOnce(true);
535-
mockRunOnceAndAbort(abort);
536-
537-
await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
538-
539-
expect(api.deleteWebhook).toHaveBeenCalledTimes(2);
540-
expect(api.getWebhookInfo).toHaveBeenCalledTimes(1);
541-
expectRecoverableRetryState(1);
542-
});
543-
544529
it("retries setup-time recoverable errors before starting polling", async () => {
545530
const abort = new AbortController();
546531
const setupError = makeRecoverableFetchError();

0 commit comments

Comments
 (0)