diff --git a/CHANGELOG.md b/CHANGELOG.md index cc500bfaa7e..cfb9c747750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Gateway/sessions: estimate context usage from local/OpenAI-compatible transcripts when provider usage telemetry is missing, so status no longer shows empty usage for real local-model sessions. Fixes #73990. (#82317) Thanks @giodl73-repo. - Agents/sessions: preserve fresh post-compaction token snapshots across stale usage updates, preventing repeated auto-compaction after every message. Fixes #82576. (#82578) Thanks @njuboy11. - Agents/OpenAI Responses: log redacted diagnostics for detail-less `response.failed` events while preserving failed response ids, so operators can correlate provider-side failures. Fixes #82558. +- Telegram: cache successful startup bot identity by account and token fingerprint for up to 24 hours, so restarts can skip redundant `getMe` probes during Telegram API slow periods without permanently pinning renamed bots. Refs #82525. - Gateway/sessions: discard stale metadata when recreating dead main session rows, so replacement sessions do not inherit old labels or transcript paths. - Codex app-server: mark native context compaction completion events as successful, preventing false "Compaction incomplete" notices after successful Codex-managed compaction. Fixes #82470. (#81593) Thanks @Kyzcreig. - Codex app-server: keep long-running turns alive while current-turn approvals, user input, dynamic tools, and notifications make progress, and carry that progress into the outer run timeout. (#82601) Thanks @100yenadmin. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index f259fe25933..2bc4fe038e6 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -76,6 +76,7 @@ openclaw pairing approve telegram Token resolution order is account-aware. In practice, config values win over env fallback, and `TELEGRAM_BOT_TOKEN` only applies to the default account. +After a successful startup, OpenClaw caches the bot identity in the state directory for up to 24 hours so restarts can avoid an extra Telegram `getMe` call; changing or removing the token clears that cache. ## Telegram side settings diff --git a/extensions/telegram/src/bot-info-cache.test.ts b/extensions/telegram/src/bot-info-cache.test.ts new file mode 100644 index 00000000000..6bc656e5142 --- /dev/null +++ b/extensions/telegram/src/bot-info-cache.test.ts @@ -0,0 +1,130 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + deleteCachedTelegramBotInfo, + readCachedTelegramBotInfo, + resolveTelegramBotInfoCachePath, + TELEGRAM_BOT_INFO_CACHE_MAX_AGE_MS, + writeCachedTelegramBotInfo, +} from "./bot-info-cache.js"; +import type { TelegramBotInfo } from "./bot-info.js"; + +const tempRoots: string[] = []; + +const botInfo: TelegramBotInfo = { + 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, +}; + +async function useTempStateDir(): Promise { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tg-bot-info-")); + tempRoots.push(stateDir); + return { ...process.env, OPENCLAW_STATE_DIR: stateDir }; +} + +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all( + tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +describe("Telegram bot info cache", () => { + it("reads botInfo for the same account and bot token", async () => { + const env = await useTempStateDir(); + + await writeCachedTelegramBotInfo({ + accountId: "ops", + botToken: "123456:secret", + botInfo, + env, + }); + + await expect( + readCachedTelegramBotInfo({ accountId: "ops", botToken: "123456:secret", env }), + ).resolves.toMatchObject({ botInfo }); + }); + + it("ignores botInfo written for a different token fingerprint", async () => { + const env = await useTempStateDir(); + + await writeCachedTelegramBotInfo({ + accountId: "ops", + botToken: "123456:old-secret", + botInfo, + env, + }); + + await expect( + readCachedTelegramBotInfo({ accountId: "ops", botToken: "123456:new-secret", env }), + ).resolves.toBeNull(); + }); + + it("treats stale botInfo as a cache miss", async () => { + const env = await useTempStateDir(); + + await writeCachedTelegramBotInfo({ + accountId: "ops", + botToken: "123456:secret", + botInfo, + env, + }); + + await expect( + readCachedTelegramBotInfo({ + accountId: "ops", + botToken: "123456:secret", + env, + now: new Date(Date.now() + TELEGRAM_BOT_INFO_CACHE_MAX_AGE_MS + 1), + }), + ).resolves.toBeNull(); + }); + + it("deletes cached botInfo for an account", async () => { + const env = await useTempStateDir(); + + await writeCachedTelegramBotInfo({ + accountId: "ops", + botToken: "123456:secret", + botInfo, + env, + }); + await deleteCachedTelegramBotInfo({ accountId: "ops", env }); + + await expect( + readCachedTelegramBotInfo({ accountId: "ops", botToken: "123456:secret", env }), + ).resolves.toBeNull(); + }); + + it("treats malformed persisted botInfo as a cache miss", async () => { + const env = await useTempStateDir(); + const filePath = resolveTelegramBotInfoCachePath("ops", env); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + JSON.stringify({ + version: 1, + tokenFingerprint: "not-the-token", + fetchedAt: new Date().toISOString(), + botInfo: { id: 123456, is_bot: true }, + }), + "utf8", + ); + + await expect( + readCachedTelegramBotInfo({ accountId: "ops", botToken: "123456:secret", env }), + ).resolves.toBeNull(); + }); +}); diff --git a/extensions/telegram/src/bot-info-cache.ts b/extensions/telegram/src/bot-info-cache.ts new file mode 100644 index 00000000000..8bc531d185b --- /dev/null +++ b/extensions/telegram/src/bot-info-cache.ts @@ -0,0 +1,135 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { normalizeTelegramBotInfo, type TelegramBotInfo } from "./bot-info.js"; +import { fingerprintTelegramBotToken } from "./token-fingerprint.js"; + +const STORE_VERSION = 1; +export const TELEGRAM_BOT_INFO_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +type TelegramBotInfoCacheState = { + version: number; + tokenFingerprint: string; + fetchedAt: string; + botInfo: TelegramBotInfo; +}; + +export type CachedTelegramBotInfo = { + botInfo: TelegramBotInfo; + fetchedAt: string; +}; + +function normalizeAccountId(accountId?: string) { + const trimmed = accountId?.trim(); + if (!trimmed) { + return "default"; + } + return trimmed.replace(/[^a-z0-9._-]+/gi, "_"); +} + +function fingerprintFromToken(botToken?: string): string | null { + const trimmed = botToken?.trim(); + if (!trimmed) { + return null; + } + return fingerprintTelegramBotToken(trimmed); +} + +export function resolveTelegramBotInfoCachePath( + accountId?: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const stateDir = resolveStateDir(env, os.homedir); + return path.join(stateDir, "telegram", `bot-info-${normalizeAccountId(accountId)}.json`); +} + +function parseCachedTelegramBotInfo(value: unknown): TelegramBotInfoCacheState | null { + if (!value || typeof value !== "object") { + return null; + } + const state = value as { + version?: unknown; + tokenFingerprint?: unknown; + fetchedAt?: unknown; + botInfo?: unknown; + }; + if ( + state.version !== STORE_VERSION || + typeof state.tokenFingerprint !== "string" || + typeof state.fetchedAt !== "string" || + Number.isNaN(Date.parse(state.fetchedAt)) + ) { + return null; + } + const botInfo = normalizeTelegramBotInfo(state.botInfo); + if (!botInfo) { + return null; + } + return { + version: STORE_VERSION, + tokenFingerprint: state.tokenFingerprint, + fetchedAt: state.fetchedAt, + botInfo, + }; +} + +export async function readCachedTelegramBotInfo(params: { + accountId?: string; + botToken?: string; + env?: NodeJS.ProcessEnv; + now?: Date; +}): Promise { + const tokenFingerprint = fingerprintFromToken(params.botToken); + if (!tokenFingerprint) { + return null; + } + const filePath = resolveTelegramBotInfoCachePath(params.accountId, params.env); + const { value } = await readJsonFileWithFallback(filePath, null); + const parsed = parseCachedTelegramBotInfo(value); + if (!parsed || parsed.tokenFingerprint !== tokenFingerprint) { + return null; + } + const fetchedAtMs = Date.parse(parsed.fetchedAt); + const nowMs = params.now?.getTime() ?? Date.now(); + if (nowMs - fetchedAtMs > TELEGRAM_BOT_INFO_CACHE_MAX_AGE_MS) { + return null; + } + return { botInfo: parsed.botInfo, fetchedAt: parsed.fetchedAt }; +} + +export async function writeCachedTelegramBotInfo(params: { + accountId?: string; + botToken: string; + botInfo: TelegramBotInfo; + env?: NodeJS.ProcessEnv; +}): Promise { + const tokenFingerprint = fingerprintFromToken(params.botToken); + if (!tokenFingerprint) { + return; + } + const filePath = resolveTelegramBotInfoCachePath(params.accountId, params.env); + const payload: TelegramBotInfoCacheState = { + version: STORE_VERSION, + tokenFingerprint, + fetchedAt: new Date().toISOString(), + botInfo: params.botInfo, + }; + await writeJsonFileAtomically(filePath, payload); +} + +export async function deleteCachedTelegramBotInfo(params: { + accountId?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const filePath = resolveTelegramBotInfoCachePath(params.accountId, params.env); + try { + await fs.unlink(filePath); + } catch (err) { + if ((err as { code?: string }).code === "ENOENT") { + return; + } + throw err; + } +} diff --git a/extensions/telegram/src/bot-info.ts b/extensions/telegram/src/bot-info.ts index 76fac8ef8c4..7e1fb77fe70 100644 --- a/extensions/telegram/src/bot-info.ts +++ b/extensions/telegram/src/bot-info.ts @@ -14,3 +14,38 @@ export type TelegramBotInfo = { has_topics_enabled: boolean; allows_users_to_create_topics: boolean; }; + +function normalizeBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +export function normalizeTelegramBotInfo(value: unknown): TelegramBotInfo | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const bot = value as Record; + 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, + }; +} diff --git a/extensions/telegram/src/channel.gateway.test.ts b/extensions/telegram/src/channel.gateway.test.ts index 5b821a58a7a..507fd09a2f7 100644 --- a/extensions/telegram/src/channel.gateway.test.ts +++ b/extensions/telegram/src/channel.gateway.test.ts @@ -1,9 +1,14 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { createPluginRuntimeMock, createStartAccountContext, } from "openclaw/plugin-sdk/channel-test-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { readCachedTelegramBotInfo, writeCachedTelegramBotInfo } from "./bot-info-cache.js"; +import type { TelegramBotInfo } from "./bot-info.js"; import { telegramPlugin } from "./channel.js"; import type { TelegramMonitorFn } from "./monitor.types.js"; import { @@ -18,6 +23,29 @@ import { resetTelegramStartupProbeLimiterForTests } from "./startup-probe-limite const probeTelegram = vi.fn(); const monitorTelegramProvider = vi.fn(); const sendMessageTelegram = vi.fn(); +const tempRoots: string[] = []; + +const startupBotInfo: TelegramBotInfo = { + 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, +}; + +async function useTempStateDir(): Promise { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tg-channel-")); + tempRoots.push(stateDir); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + return stateDir; +} function installTelegramRuntime() { const runtime = createPluginRuntimeMock(); @@ -112,18 +140,22 @@ async function waitForCondition(check: () => boolean, message: string, attempts if (check()) { return; } - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); } throw new Error(message); } -afterEach(() => { +afterEach(async () => { clearTelegramRuntime(); resetTelegramPollingLeasesForTests(); resetTelegramStartupProbeLimiterForTests(); probeTelegram.mockReset(); monitorTelegramProvider.mockReset(); sendMessageTelegram.mockReset(); + vi.unstubAllEnvs(); + await Promise.all( + tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), + ); }); describe("telegramPlugin gateway startup", () => { @@ -200,37 +232,67 @@ 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, + id: startupBotInfo.id, + username: startupBotInfo.username, }, - botInfo, + botInfo: startupBotInfo, }); monitorTelegramProvider.mockResolvedValue(undefined); const { task } = startTelegramAccount(); await expect(task).resolves.toBeUndefined(); - expect(latestMonitorOptions().botInfo).toBe(botInfo); + expect(latestMonitorOptions().botInfo).toBe(startupBotInfo); + }); + + it("caches successful startup probe botInfo for later restarts", async () => { + await useTempStateDir(); + installTelegramRuntime(); + probeTelegram.mockResolvedValue({ + ok: true, + status: null, + error: null, + elapsedMs: 12, + bot: { + id: startupBotInfo.id, + username: startupBotInfo.username, + }, + botInfo: startupBotInfo, + }); + monitorTelegramProvider.mockResolvedValue(undefined); + + const { task } = startTelegramAccount("ops"); + + await expect(task).resolves.toBeUndefined(); + await expect( + readCachedTelegramBotInfo({ + accountId: "ops", + botToken: "123456:bad-token", + }), + ).resolves.toMatchObject({ botInfo: startupBotInfo }); + }); + + it("uses cached startup botInfo without calling getMe", async () => { + await useTempStateDir(); + installTelegramRuntime(); + await writeCachedTelegramBotInfo({ + accountId: "ops", + botToken: "123456:bad-token", + botInfo: startupBotInfo, + }); + monitorTelegramProvider.mockResolvedValue(undefined); + + const { task } = startTelegramAccount("ops"); + + await expect(task).resolves.toBeUndefined(); + expect(probeTelegram).not.toHaveBeenCalled(); + expect(latestMonitorOptions().botInfo).toEqual(startupBotInfo); }); it("honors higher per-account timeoutSeconds for startup probe", async () => { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 75d329ef7e1..831289d75b0 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -40,6 +40,11 @@ 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 { + deleteCachedTelegramBotInfo, + readCachedTelegramBotInfo, + writeCachedTelegramBotInfo, +} from "./bot-info-cache.js"; import type { TelegramBotInfo } from "./bot-info.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import { telegramMessageActions as telegramMessageActionsImpl } from "./channel-actions.js"; @@ -109,6 +114,48 @@ function resolveTelegramProbe() { ); } +async function readStartupBotInfoCache(params: { + accountId: string; + token: string; + log?: { debug?: (message: string) => void }; +}): Promise { + try { + const cached = await readCachedTelegramBotInfo({ + accountId: params.accountId, + botToken: params.token, + }); + return cached?.botInfo; + } catch (err) { + if (getTelegramRuntime().logging.shouldLogVerbose()) { + params.log?.debug?.(`[${params.accountId}] bot info cache read failed: ${String(err)}`); + } + return undefined; + } +} + +async function writeStartupBotInfoCache(params: { + accountId: string; + token: string; + botInfo: TelegramBotInfo; + log?: { debug?: (message: string) => void }; +}): Promise { + try { + await writeCachedTelegramBotInfo({ + accountId: params.accountId, + botToken: params.token, + botInfo: params.botInfo, + }); + } catch (err) { + if (getTelegramRuntime().logging.shouldLogVerbose()) { + params.log?.debug?.(`[${params.accountId}] bot info cache write failed: ${String(err)}`); + } + } +} + +async function deleteStartupBotInfoCache(accountId: string): Promise { + await deleteCachedTelegramBotInfo({ accountId }).catch(() => undefined); +} + function resolveTelegramAuditCollector() { return ( getOptionalTelegramRuntime()?.channel?.telegram?.collectTelegramUnmentionedGroupIds ?? @@ -741,12 +788,18 @@ export const telegramPlugin = createChatChannelPlugin({ const nextToken = resolveTelegramAccount({ cfg: nextCfg, accountId }).token.trim(); if (previousToken !== nextToken) { const { deleteTelegramUpdateOffset } = await loadTelegramUpdateOffsetRuntime(); - await deleteTelegramUpdateOffset({ accountId }); + await Promise.all([ + deleteTelegramUpdateOffset({ accountId }), + deleteStartupBotInfoCache(accountId), + ]); } }, onAccountRemoved: async ({ accountId }) => { const { deleteTelegramUpdateOffset } = await loadTelegramUpdateOffsetRuntime(); - await deleteTelegramUpdateOffset({ accountId }); + await Promise.all([ + deleteTelegramUpdateOffset({ accountId }), + deleteStartupBotInfoCache(accountId), + ]); }, }, heartbeat: { @@ -897,34 +950,53 @@ export const telegramPlugin = createChatChannelPlugin({ let telegramBotLabel = ""; let unauthorizedTokenReason: string | null = null; let botInfo: TelegramBotInfo | undefined; - try { - const probe = await withTelegramStartupProbeSlot(ctx.abortSignal, () => - resolveTelegramProbe()( - token, - resolveTelegramStartupProbeTimeoutMs(account.config.timeoutSeconds), - { + const cachedBotInfo = await readStartupBotInfoCache({ + accountId: account.accountId, + token, + log: ctx.log, + }); + if (cachedBotInfo) { + botInfo = cachedBotInfo; + telegramBotLabel = ` (@${cachedBotInfo.username})`; + } else { + try { + const probe = await withTelegramStartupProbeSlot(ctx.abortSignal, () => + resolveTelegramProbe()( + token, + resolveTelegramStartupProbeTimeoutMs(account.config.timeoutSeconds), + { + accountId: account.accountId, + proxyUrl: account.config.proxy, + network: account.config.network, + apiRoot: account.config.apiRoot, + includeWebhookInfo: false, + }, + ), + ); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) { + telegramBotLabel = ` (@${username})`; + } + botInfo = probe.ok ? probe.botInfo : undefined; + if (probe.ok && probe.botInfo) { + await writeStartupBotInfoCache({ accountId: account.accountId, - proxyUrl: account.config.proxy, - network: account.config.network, - apiRoot: account.config.apiRoot, - includeWebhookInfo: false, - }, - ), - ); - const username = probe.ok ? probe.bot?.username?.trim() : null; - if (username) { - telegramBotLabel = ` (@${username})`; - } - botInfo = probe.ok ? probe.botInfo : undefined; - if (!probe.ok && probe.status === 401) { - unauthorizedTokenReason = formatTelegramUnauthorizedTokenError(account); - } - } catch (err) { - if (ctx.abortSignal.aborted) { - return; - } - if (getTelegramRuntime().logging.shouldLogVerbose()) { - ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); + token, + botInfo: probe.botInfo, + log: ctx.log, + }); + } + if (!probe.ok && probe.status === 401) { + await deleteStartupBotInfoCache(account.accountId); + unauthorizedTokenReason = formatTelegramUnauthorizedTokenError(account); + } + } catch (err) { + if (ctx.abortSignal.aborted) { + return; + } + if (getTelegramRuntime().logging.shouldLogVerbose()) { + ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); + } } } if (unauthorizedTokenReason) { @@ -1020,6 +1092,9 @@ export const telegramPlugin = createChatChannelPlugin({ afterWrite: { mode: "auto" }, }); } + if (cleared || loggedOut) { + await deleteStartupBotInfoCache(accountId); + } return { cleared, envToken: Boolean(envToken), loggedOut }; }, }, diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index de81e64d20c..6a674ba9da1 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -2,7 +2,7 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { fetchWithTimeout } from "openclaw/plugin-sdk/text-utility-runtime"; -import type { TelegramBotInfo } from "./bot-info.js"; +import { normalizeTelegramBotInfo, type TelegramBotInfo } from "./bot-info.js"; import { resolveTelegramApiBase, resolveTelegramTransport, @@ -114,37 +114,6 @@ 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; - 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,