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 c74bbdd1ff.
- Required merge gates passed before the squash merge.

Prepared head SHA: c74bbdd1ff
Review: https://github.com/openclaw/openclaw/pull/76735#issuecomment-4366417178

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Ayaan Zaidi
2026-05-03 20:46:32 +05:30
committed by GitHub
parent d0497d13d1
commit 5b94c4ce93
16 changed files with 228 additions and 77 deletions

View File

@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.

View File

@@ -855,7 +855,8 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance
- `getMe returned 401` is a Telegram authentication failure for the configured bot token.
- 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.
- `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.
- 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.
- 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.
- 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`.
</Accordion>

View File

@@ -348,7 +348,11 @@ export function createTelegramBotCore(
}
: undefined;
const bot = new botRuntime.Bot(opts.token, client ? { client } : undefined);
const botConfig =
client || opts.botInfo
? { ...(client ? { client } : {}), ...(opts.botInfo ? { botInfo: opts.botInfo } : {}) }
: undefined;
const bot = new botRuntime.Bot(opts.token, botConfig);
bot.api.config.use(botRuntime.apiThrottler());
// Catch all errors from bot middleware to prevent unhandled rejections
bot.catch((err) => {

View File

@@ -0,0 +1,16 @@
export type TelegramBotInfo = {
id: number;
is_bot: true;
first_name: string;
last_name?: string;
username: string;
language_code?: string;
can_join_groups: boolean;
can_read_all_group_messages: boolean;
can_manage_bots: boolean;
supports_inline_queries: boolean;
can_connect_to_business: boolean;
has_main_web_app: boolean;
has_topics_enabled: boolean;
allows_users_to_create_topics: boolean;
};

View File

@@ -266,7 +266,9 @@ const grammySpies = vi.hoisted(() => ({
onSpy: vi.fn(),
stopSpy: vi.fn(),
commandSpy: vi.fn(),
botCtorSpy: vi.fn((_: string, __?: { client?: { fetch?: typeof fetch } }) => undefined),
botCtorSpy: vi.fn(
(_: string, __?: { client?: { fetch?: typeof fetch }; botInfo?: unknown }) => undefined,
),
answerCallbackQuerySpy: vi.fn(async () => undefined) as AnyAsyncMock,
sendChatActionSpy: vi.fn(),
editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
@@ -290,7 +292,7 @@ export const onSpy: AnyMock = grammySpies.onSpy;
export const stopSpy: AnyMock = grammySpies.stopSpy;
export const commandSpy: AnyMock = grammySpies.commandSpy;
export const botCtorSpy: MockFn<
(token: string, options?: { client?: { fetch?: typeof fetch } }) => void
(token: string, options?: { client?: { fetch?: typeof fetch }; botInfo?: unknown }) => void
> = grammySpies.botCtorSpy;
export const answerCallbackQuerySpy: AnyAsyncMock = grammySpies.answerCallbackQuerySpy;
export const sendChatActionSpy: AnyMock = grammySpies.sendChatActionSpy;
@@ -341,7 +343,7 @@ export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = {
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
public options?: { client?: { fetch?: typeof fetch }; botInfo?: unknown },
) {
(grammySpies.botCtorSpy as unknown as (token: string, options?: unknown) => void)(
token,

View File

@@ -278,6 +278,32 @@ describe("createTelegramBot", () => {
);
});
it("passes startup probe botInfo to grammY", () => {
const botInfo = {
id: 123456,
is_bot: true,
first_name: "OpenClaw",
username: "openclaw_bot",
can_join_groups: true,
can_read_all_group_messages: false,
can_manage_bots: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: false,
allows_users_to_create_topics: false,
} as const;
createTelegramBot({ token: "tok", botInfo });
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({
botInfo,
}),
);
});
it("normalizes full Telegram bot endpoint apiRoot before passing it to grammY", () => {
loadConfig.mockReturnValue({
channels: {

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-types";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramBotDeps } from "./bot-deps.js";
import type { TelegramBotInfo } from "./bot-info.js";
import type { TelegramTransport } from "./fetch.js";
export type TelegramBotOptions = {
@@ -14,6 +15,8 @@ export type TelegramBotOptions = {
replyToMode?: ReplyToMode;
proxyFetch?: typeof fetch;
config?: OpenClawConfig;
/** Bot identity returned by the startup getMe probe. Avoids a duplicate grammY init getMe before polling. */
botInfo?: TelegramBotInfo;
/** Signal to abort in-flight Telegram API fetch requests (e.g. getUpdates) on shutdown. */
fetchAbortSignal?: AbortSignal;
/** Minimum grammY client timeout when timeoutSeconds is configured on long-polling bots. */

View File

@@ -156,6 +156,45 @@ describe("telegramPlugin gateway startup", () => {
);
});
it("passes successful startup probe botInfo into the polling monitor", async () => {
installTelegramRuntime();
const botInfo = {
id: 123456,
is_bot: true,
first_name: "OpenClaw",
username: "openclaw_bot",
can_join_groups: true,
can_read_all_group_messages: false,
can_manage_bots: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: false,
allows_users_to_create_topics: false,
} as const;
probeTelegram.mockResolvedValue({
ok: true,
status: null,
error: null,
elapsedMs: 12,
bot: {
id: botInfo.id,
username: botInfo.username,
},
botInfo,
});
monitorTelegramProvider.mockResolvedValue(undefined);
const { task } = startTelegramAccount();
await expect(task).resolves.toBeUndefined();
expect(monitorTelegramProvider).toHaveBeenCalledWith(
expect.objectContaining({
botInfo,
}),
);
});
it("honors higher per-account timeoutSeconds for startup probe", async () => {
installTelegramRuntime();
probeTelegram.mockResolvedValue({

View File

@@ -40,6 +40,7 @@ import { resolveTelegramAutoThreadId } from "./action-threading.js";
import { lookupTelegramChatId } from "./api-fetch.js";
import { telegramApprovalCapability } from "./approval-native.js";
import * as auditModule from "./audit.js";
import type { TelegramBotInfo } from "./bot-info.js";
import { buildTelegramGroupPeerId } from "./bot/helpers.js";
import { telegramMessageActions as telegramMessageActionsImpl } from "./channel-actions.js";
import {
@@ -897,6 +898,7 @@ export const telegramPlugin = createChatChannelPlugin({
const token = (account.token ?? "").trim();
let telegramBotLabel = "";
let unauthorizedTokenReason: string | null = null;
let botInfo: TelegramBotInfo | undefined;
try {
const probe = await resolveTelegramProbe()(
token,
@@ -913,6 +915,7 @@ export const telegramPlugin = createChatChannelPlugin({
if (username) {
telegramBotLabel = ` (@${username})`;
}
botInfo = probe.ok ? probe.botInfo : undefined;
if (!probe.ok && probe.status === 401) {
unauthorizedTokenReason = formatTelegramUnauthorizedTokenError(account);
}
@@ -944,6 +947,7 @@ export const telegramPlugin = createChatChannelPlugin({
webhookHost: account.config.webhookHost,
webhookPort: account.config.webhookPort,
webhookCertPath: account.config.webhookCertPath,
botInfo,
setStatus,
});
},

View File

@@ -495,50 +495,35 @@ describe("monitorTelegramProvider (grammY)", () => {
expect(order).toEqual(["deleteWebhook", "run"]);
});
it("retries recoverable deleteWebhook failures before polling", async () => {
it("starts polling after recoverable deleteWebhook failures", async () => {
const abort = new AbortController();
const cleanupError = makeRecoverableFetchError();
api.deleteWebhook.mockReset();
api.getWebhookInfo.mockReset().mockResolvedValueOnce({ url: "https://example.test/hook" });
api.deleteWebhook.mockRejectedValueOnce(cleanupError).mockResolvedValueOnce(true);
mockRunOnceAndAbort(abort);
await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
expect(api.deleteWebhook).toHaveBeenCalledTimes(2);
expect(api.getWebhookInfo).toHaveBeenCalledTimes(1);
expectRecoverableRetryState(1);
});
it("continues polling when deleteWebhook transiently fails but webhook is already absent", async () => {
const abort = new AbortController();
const cleanupError = makeRecoverableFetchError();
api.deleteWebhook.mockReset();
api.getWebhookInfo.mockReset().mockResolvedValueOnce({ url: "" });
api.getWebhookInfo.mockReset();
api.deleteWebhook.mockRejectedValueOnce(cleanupError);
mockRunOnceAndAbort(abort);
await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
expect(api.deleteWebhook).toHaveBeenCalledTimes(1);
expect(api.getWebhookInfo).toHaveBeenCalledTimes(1);
expect(runSpy).toHaveBeenCalledTimes(1);
expect(sleepWithAbort).not.toHaveBeenCalled();
expect(api.getWebhookInfo).not.toHaveBeenCalled();
expectRecoverableRetryState(1);
});
it("retries cleanup when deleteWebhook and webhook confirmation both transiently fail", async () => {
it("does not run webhook confirmation when deleteWebhook transiently fails", async () => {
const abort = new AbortController();
const cleanupError = makeRecoverableFetchError();
api.deleteWebhook.mockReset();
api.getWebhookInfo.mockReset().mockRejectedValueOnce(makeRecoverableFetchError());
api.deleteWebhook.mockRejectedValueOnce(cleanupError).mockResolvedValueOnce(true);
api.getWebhookInfo.mockReset();
api.deleteWebhook.mockRejectedValueOnce(cleanupError);
mockRunOnceAndAbort(abort);
await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
expect(api.deleteWebhook).toHaveBeenCalledTimes(2);
expect(api.getWebhookInfo).toHaveBeenCalledTimes(1);
expectRecoverableRetryState(1);
expect(api.deleteWebhook).toHaveBeenCalledTimes(1);
expect(api.getWebhookInfo).not.toHaveBeenCalled();
expect(runSpy).toHaveBeenCalledTimes(1);
expect(sleepWithAbort).not.toHaveBeenCalled();
});
it("retries setup-time recoverable errors before starting polling", async () => {

View File

@@ -248,6 +248,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
accountId: account.accountId,
runtime: opts.runtime,
proxyFetch,
botInfo: opts.botInfo,
abortSignal: opts.abortSignal,
runnerOptions: createTelegramRunnerOptions(cfg),
getLastUpdateId: () => lastUpdateId,

View File

@@ -4,6 +4,7 @@ import type {
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramBotInfo } from "./bot-info.js";
export type MonitorTelegramOpts = {
token?: string;
@@ -20,6 +21,7 @@ export type MonitorTelegramOpts = {
proxyFetch?: typeof fetch;
webhookUrl?: string;
webhookCertPath?: string;
botInfo?: TelegramBotInfo;
setStatus?: (patch: Omit<ChannelAccountSnapshot, "accountId">) => void;
};

View File

@@ -572,6 +572,30 @@ describe("TelegramPollingSession", () => {
expect(createTelegramTransport).toHaveBeenCalledTimes(1);
});
it("starts polling when webhook cleanup times out during startup", async () => {
const abort = new AbortController();
const cleanupError = new Error("Telegram deleteWebhook timed out after 15000ms");
const bot = makeBot();
bot.api.deleteWebhook.mockRejectedValueOnce(cleanupError);
createTelegramBotMock.mockReturnValueOnce(bot);
runMock.mockReturnValueOnce({
task: async () => {
abort.abort();
},
stop: vi.fn(async () => undefined),
isRunning: () => false,
});
const session = createPollingSession({
abortSignal: abort.signal,
});
await session.runUntilAbort();
expect(bot.api.deleteWebhook).toHaveBeenCalledTimes(1);
expect(runMock).toHaveBeenCalledTimes(1);
});
it("does not trigger stall restart shortly after a getUpdates error", async () => {
const abort = new AbortController();
const botStop = vi.fn(async () => undefined);

View File

@@ -67,6 +67,7 @@ type TelegramPollingSessionOpts = {
accountId: string;
runtime: Parameters<typeof createTelegramBot>[0]["runtime"];
proxyFetch: Parameters<typeof createTelegramBot>[0]["proxyFetch"];
botInfo?: Parameters<typeof createTelegramBot>[0]["botInfo"];
abortSignal?: AbortSignal;
runnerOptions: RunOptions<unknown>;
getLastUpdateId: () => number | null;
@@ -187,6 +188,7 @@ export class TelegramPollingSession {
proxyFetch: this.opts.proxyFetch,
config: this.opts.config,
accountId: this.opts.accountId,
botInfo: this.opts.botInfo,
fetchAbortSignal: fetchAbortController.signal,
minimumClientTimeoutSeconds: TELEGRAM_POLLING_CLIENT_TIMEOUT_FLOOR_SECONDS,
updateOffset: {
@@ -217,10 +219,9 @@ export class TelegramPollingSession {
this.#webhookCleared = true;
return "ready";
} catch (err) {
if (await this.#confirmWebhookAlreadyAbsent(bot, err)) {
this.#webhookCleared = true;
if (isRecoverableTelegramNetworkError(err, { context: "unknown" })) {
this.opts.log(
"[telegram] deleteWebhook failed, but getWebhookInfo confirmed no webhook is set; continuing with polling.",
`[telegram] deleteWebhook failed with a recoverable network error; continuing to polling so getUpdates can confirm webhook state: ${formatErrorMessage(err)}`,
);
return "ready";
}
@@ -232,29 +233,6 @@ export class TelegramPollingSession {
}
}
async #confirmWebhookAlreadyAbsent(
bot: TelegramBot,
deleteWebhookError: unknown,
): Promise<boolean> {
if (!isRecoverableTelegramNetworkError(deleteWebhookError, { context: "unknown" })) {
return false;
}
try {
const webhookInfo = await withTelegramApiErrorLogging({
operation: "getWebhookInfo",
runtime: this.opts.runtime,
shouldLog: (err) => !isRecoverableTelegramNetworkError(err, { context: "unknown" }),
fn: () => bot.api.getWebhookInfo(),
});
return typeof webhookInfo?.url === "string" && webhookInfo.url.trim().length === 0;
} catch (err) {
if (!isRecoverableTelegramNetworkError(err, { context: "unknown" })) {
throw err;
}
return false;
}
}
async #runPollingCycle(bot: TelegramBot): Promise<"continue" | "exit"> {
const liveness = new TelegramPollingLivenessTracker({
onPollSuccess: (finishedAt) => this.#status.notePollSuccess(finishedAt),

View File

@@ -33,7 +33,20 @@ describe("probeTelegram retry logic", () => {
ok: true,
json: vi.fn().mockResolvedValue({
ok: true,
result: { id: 123, username: "test_bot" },
result: {
id: 123,
is_bot: true,
first_name: "Test",
username: "test_bot",
can_join_groups: true,
can_read_all_group_messages: false,
can_manage_bots: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: false,
allows_users_to_create_topics: false,
},
}),
});
}
@@ -181,6 +194,14 @@ describe("probeTelegram retry logic", () => {
expect(result.ok).toBe(true);
expect(result.webhook).toBeUndefined();
expect(result.botInfo).toEqual(
expect.objectContaining({
id: 123,
is_bot: true,
first_name: "Test",
username: "test_bot",
}),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.telegram.org/bottest-token/getMe");
});

View File

@@ -2,6 +2,7 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime";
import type { TelegramBotInfo } from "./bot-info.js";
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
@@ -10,11 +11,19 @@ export type TelegramProbe = BaseProbeResult & {
elapsedMs: number;
bot?: {
id?: number | null;
isBot?: boolean | null;
firstName?: string | null;
username?: string | null;
canJoinGroups?: boolean | null;
canReadAllGroupMessages?: boolean | null;
canManageBots?: boolean | null;
supportsInlineQueries?: boolean | null;
canConnectToBusiness?: boolean | null;
hasMainWebApp?: boolean | null;
hasTopicsEnabled?: boolean | null;
allowsUsersToCreateTopics?: boolean | null;
};
botInfo?: TelegramBotInfo;
webhook?: { url?: string | null; hasCustomCert?: boolean | null };
};
@@ -94,6 +103,41 @@ function resolveProbeFetcher(token: string, options?: TelegramProbeOptions): typ
return resolved;
}
function normalizeBoolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
function normalizeTelegramBotInfo(value: unknown): TelegramBotInfo | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const bot = value as Record<string, unknown>;
if (
typeof bot.id !== "number" ||
bot.is_bot !== true ||
typeof bot.first_name !== "string" ||
typeof bot.username !== "string"
) {
return undefined;
}
return {
id: bot.id,
is_bot: true,
first_name: bot.first_name,
username: bot.username,
...(typeof bot.last_name === "string" ? { last_name: bot.last_name } : {}),
...(typeof bot.language_code === "string" ? { language_code: bot.language_code } : {}),
can_join_groups: normalizeBoolean(bot.can_join_groups) ?? false,
can_read_all_group_messages: normalizeBoolean(bot.can_read_all_group_messages) ?? false,
can_manage_bots: normalizeBoolean(bot.can_manage_bots) ?? false,
supports_inline_queries: normalizeBoolean(bot.supports_inline_queries) ?? false,
can_connect_to_business: normalizeBoolean(bot.can_connect_to_business) ?? false,
has_main_web_app: normalizeBoolean(bot.has_main_web_app) ?? false,
has_topics_enabled: normalizeBoolean(bot.has_topics_enabled) ?? false,
allows_users_to_create_topics: normalizeBoolean(bot.allows_users_to_create_topics) ?? false,
};
}
export async function probeTelegram(
token: string,
timeoutMs: number,
@@ -157,13 +201,7 @@ export async function probeTelegram(
const meJson = (await meRes.json()) as {
ok?: boolean;
description?: string;
result?: {
id?: number;
username?: string;
can_join_groups?: boolean;
can_read_all_group_messages?: boolean;
supports_inline_queries?: boolean;
};
result?: unknown;
};
if (!meRes.ok || !meJson?.ok) {
result.status = meRes.status;
@@ -171,19 +209,25 @@ export async function probeTelegram(
return { ...result, elapsedMs: Date.now() - started };
}
const botInfo = normalizeTelegramBotInfo(meJson.result);
const rawBot = meJson.result && typeof meJson.result === "object" ? meJson.result : {};
const bot = rawBot as Record<string, unknown>;
if (botInfo) {
result.botInfo = botInfo;
}
result.bot = {
id: meJson.result?.id ?? null,
username: meJson.result?.username ?? null,
canJoinGroups:
typeof meJson.result?.can_join_groups === "boolean" ? meJson.result?.can_join_groups : null,
canReadAllGroupMessages:
typeof meJson.result?.can_read_all_group_messages === "boolean"
? meJson.result?.can_read_all_group_messages
: null,
supportsInlineQueries:
typeof meJson.result?.supports_inline_queries === "boolean"
? meJson.result?.supports_inline_queries
: null,
id: typeof bot.id === "number" ? bot.id : null,
isBot: normalizeBoolean(bot.is_bot),
firstName: typeof bot.first_name === "string" ? bot.first_name : null,
username: typeof bot.username === "string" ? bot.username : null,
canJoinGroups: normalizeBoolean(bot.can_join_groups),
canReadAllGroupMessages: normalizeBoolean(bot.can_read_all_group_messages),
canManageBots: normalizeBoolean(bot.can_manage_bots),
supportsInlineQueries: normalizeBoolean(bot.supports_inline_queries),
canConnectToBusiness: normalizeBoolean(bot.can_connect_to_business),
hasMainWebApp: normalizeBoolean(bot.has_main_web_app),
hasTopicsEnabled: normalizeBoolean(bot.has_topics_enabled),
allowsUsersToCreateTopics: normalizeBoolean(bot.allows_users_to_create_topics),
};
if (includeWebhookInfo) {