diff --git a/extensions/telegram/src/api-fetch.ts b/extensions/telegram/src/api-fetch.ts index 8831caa2b8a..cbe325897b9 100644 --- a/extensions/telegram/src/api-fetch.ts +++ b/extensions/telegram/src/api-fetch.ts @@ -1,9 +1,13 @@ +import { resolveTelegramApiBase } from "./fetch.js"; + export async function fetchTelegramChatId(params: { token: string; chatId: string; signal?: AbortSignal; + apiRoot?: string; }): Promise { - const url = `https://api.telegram.org/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`; + const apiBase = resolveTelegramApiBase(params.apiRoot); + const url = `${apiBase}/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`; try { const res = await fetch(url, params.signal ? { signal: params.signal } : undefined); if (!res.ok) { diff --git a/extensions/telegram/src/audit-membership-runtime.ts b/extensions/telegram/src/audit-membership-runtime.ts index 930d768778e..a8cc98f4701 100644 --- a/extensions/telegram/src/audit-membership-runtime.ts +++ b/extensions/telegram/src/audit-membership-runtime.ts @@ -5,11 +5,9 @@ import type { TelegramGroupMembershipAudit, TelegramGroupMembershipAuditEntry, } from "./audit.js"; -import { resolveTelegramFetch } from "./fetch.js"; +import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; -const TELEGRAM_API_BASE = "https://api.telegram.org"; - type TelegramApiOk = { ok: true; result: T }; type TelegramApiErr = { ok: false; description?: string }; type TelegramGroupMembershipAuditData = Omit; @@ -18,8 +16,11 @@ export async function auditTelegramGroupMembershipImpl( params: AuditTelegramGroupMembershipParams, ): Promise { const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined; - const fetcher = resolveTelegramFetch(proxyFetch, { network: params.network }); - const base = `${TELEGRAM_API_BASE}/bot${params.token}`; + const fetcher = resolveTelegramFetch(proxyFetch, { + network: params.network, + }); + const apiBase = resolveTelegramApiBase(params.apiRoot); + const base = `${apiBase}/bot${params.token}`; const groups: TelegramGroupMembershipAuditEntry[] = []; for (const chatId of params.groupIds) { diff --git a/extensions/telegram/src/audit.ts b/extensions/telegram/src/audit.ts index f7fb0969090..f205dc49127 100644 --- a/extensions/telegram/src/audit.ts +++ b/extensions/telegram/src/audit.ts @@ -66,6 +66,7 @@ export type AuditTelegramGroupMembershipParams = { groupIds: string[]; proxyUrl?: string; network?: TelegramNetworkConfig; + apiRoot?: string; timeoutMs: number; }; diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 6df428d1273..96726785db2 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -361,7 +361,13 @@ export const registerTelegramHandlers = ({ for (const { ctx } of entry.messages) { let media; try { - media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport); + media = await resolveMedia( + ctx, + mediaMaxBytes, + opts.token, + telegramTransport, + telegramCfg.apiRoot, + ); } catch (mediaErr) { if (!isRecoverableMediaGroupError(mediaErr)) { throw mediaErr; @@ -466,6 +472,7 @@ export const registerTelegramHandlers = ({ mediaMaxBytes, opts.token, telegramTransport, + telegramCfg.apiRoot, ); if (!media) { return []; @@ -977,7 +984,13 @@ export const registerTelegramHandlers = ({ let media: Awaited> = null; try { - media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport); + media = await resolveMedia( + ctx, + mediaMaxBytes, + opts.token, + telegramTransport, + telegramCfg.apiRoot, + ); } catch (mediaErr) { if (isMediaSizeLimitError(mediaErr)) { if (sendOversizeWarning) { diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts index 479560c8e38..11c394518c4 100644 --- a/extensions/telegram/src/bot.ts +++ b/extensions/telegram/src/bot.ts @@ -230,11 +230,13 @@ export function createTelegramBot(opts: TelegramBotOptions) { typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds) ? Math.max(1, Math.floor(telegramCfg.timeoutSeconds)) : undefined; + const apiRoot = telegramCfg.apiRoot?.trim() || undefined; const client: ApiClientOptions | undefined = - finalFetch || timeoutSeconds + finalFetch || timeoutSeconds || apiRoot ? { ...(finalFetch ? { fetch: finalFetch } : {}), ...(timeoutSeconds ? { timeoutSeconds } : {}), + ...(apiRoot ? { apiRoot } : {}), } : undefined; diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index 52f6eef966c..3199ac4c14f 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -4,18 +4,35 @@ import { retryAsync } from "openclaw/plugin-sdk/infra-runtime"; import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; -import { shouldRetryTelegramTransportFallback, type TelegramTransport } from "../fetch.js"; +import { + resolveTelegramApiBase, + shouldRetryTelegramTransportFallback, + type TelegramTransport, +} from "../fetch.js"; import { cacheSticker, getCachedSticker } from "../sticker-cache.js"; import { resolveTelegramMediaPlaceholder } from "./helpers.js"; import type { StickerMetadata, TelegramContext } from "./types.js"; const FILE_TOO_BIG_RE = /file is too big/i; -const TELEGRAM_MEDIA_SSRF_POLICY = { - // Telegram file downloads should trust api.telegram.org even when DNS/proxy - // resolution maps to private/internal ranges in restricted networks. - allowedHostnames: ["api.telegram.org"], - allowRfc2544BenchmarkRange: true, -}; +function buildTelegramMediaSsrfPolicy(apiRoot?: string) { + const hostnames = ["api.telegram.org"]; + if (apiRoot) { + try { + const customHost = new URL(apiRoot).hostname; + if (customHost && !hostnames.includes(customHost)) { + hostnames.push(customHost); + } + } catch { + // invalid URL; fall through to default + } + } + return { + // Telegram file downloads should trust the API hostname even when DNS/proxy + // resolution maps to private/internal ranges in restricted networks. + allowedHostnames: hostnames, + allowRfc2544BenchmarkRange: true, + }; +} /** * Returns true if the error is Telegram's "file is too big" error. @@ -124,8 +141,10 @@ async function downloadAndSaveTelegramFile(params: { transport: TelegramTransport; maxBytes: number; telegramFileName?: string; + apiRoot?: string; }) { - const url = `https://api.telegram.org/file/bot${params.token}/${params.filePath}`; + const apiBase = resolveTelegramApiBase(params.apiRoot); + const url = `${apiBase}/file/bot${params.token}/${params.filePath}`; const fetched = await fetchRemoteMedia({ url, fetchImpl: params.transport.sourceFetch, @@ -134,7 +153,7 @@ async function downloadAndSaveTelegramFile(params: { filePathHint: params.filePath, maxBytes: params.maxBytes, readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS, - ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY, + ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot), }); const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath; return saveMediaBuffer( @@ -152,6 +171,7 @@ async function resolveStickerMedia(params: { maxBytes: number; token: string; transport?: TelegramTransport; + apiRoot?: string; }): Promise< | { path: string; @@ -192,6 +212,7 @@ async function resolveStickerMedia(params: { token, transport: resolvedTransport, maxBytes, + apiRoot: params.apiRoot, }); // Check sticker cache for existing description @@ -247,6 +268,7 @@ export async function resolveMedia( maxBytes: number, token: string, transport?: TelegramTransport, + apiRoot?: string, ): Promise<{ path: string; contentType?: string; @@ -260,6 +282,7 @@ export async function resolveMedia( maxBytes, token, transport, + apiRoot, }); if (stickerResolved !== undefined) { return stickerResolved; @@ -283,6 +306,7 @@ export async function resolveMedia( transport: resolveRequiredTelegramTransport(transport), maxBytes, telegramFileName: resolveTelegramFileName(msg), + apiRoot, }); const placeholder = resolveTelegramMediaPlaceholder(msg) ?? ""; return { path: saved.path, contentType: saved.contentType, placeholder }; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index a56606af2e0..5a481ba8ac3 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -586,6 +586,7 @@ export const telegramPlugin: ChannelPlugin { const lines = []; @@ -637,6 +638,7 @@ export const telegramPlugin: ChannelPlugin vi.fn()); vi.mock("./fetch.js", () => ({ resolveTelegramFetch, + resolveTelegramApiBase: (apiRoot?: string) => + apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org", })); vi.mock("./proxy.js", () => ({ @@ -190,6 +192,7 @@ describe("probeTelegram retry logic", () => { autoSelectFamily: false, dnsResultOrder: "ipv4first", }, + apiRoot: undefined, }); }); diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index d297635e4a1..bec56269927 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -1,11 +1,9 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; import type { TelegramNetworkConfig } from "../runtime-api.js"; -import { resolveTelegramFetch } from "./fetch.js"; +import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; -const TELEGRAM_API_BASE = "https://api.telegram.org"; - export type TelegramProbe = BaseProbeResult & { status?: number | null; elapsedMs: number; @@ -23,6 +21,7 @@ export type TelegramProbeOptions = { proxyUrl?: string; network?: TelegramNetworkConfig; accountId?: string; + apiRoot?: string; }; const probeFetcherCache = new Map(); @@ -56,7 +55,8 @@ function buildProbeFetcherCacheKey(token: string, options?: TelegramProbeOptions const autoSelectFamilyKey = typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default"; const dnsResultOrderKey = options?.network?.dnsResultOrder ?? "default"; - return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}`; + const apiRootKey = options?.apiRoot?.trim() ?? ""; + return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${apiRootKey}`; } function setCachedProbeFetcher(cacheKey: string, fetcher: typeof fetch): typeof fetch { @@ -82,7 +82,9 @@ function resolveProbeFetcher(token: string, options?: TelegramProbeOptions): typ const proxyUrl = options?.proxyUrl?.trim(); const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; - const resolved = resolveTelegramFetch(proxyFetch, { network: options?.network }); + const resolved = resolveTelegramFetch(proxyFetch, { + network: options?.network, + }); if (cacheKey) { return setCachedProbeFetcher(cacheKey, resolved); @@ -100,7 +102,8 @@ export async function probeTelegram( const deadlineMs = started + timeoutBudgetMs; const options = resolveProbeOptions(proxyOrOptions); const fetcher = resolveProbeFetcher(token, options); - const base = `${TELEGRAM_API_BASE}/bot${token}`; + const apiBase = resolveTelegramApiBase(options?.apiRoot); + const base = `${apiBase}/bot${token}`; const retryDelayMs = Math.max(50, Math.min(1000, Math.floor(timeoutBudgetMs / 5))); const resolveRemainingBudgetMs = () => Math.max(0, deadlineMs - Date.now()); diff --git a/extensions/telegram/src/send.proxy.test.ts b/extensions/telegram/src/send.proxy.test.ts index e5c58063155..4f5709e581e 100644 --- a/extensions/telegram/src/send.proxy.test.ts +++ b/extensions/telegram/src/send.proxy.test.ts @@ -37,6 +37,8 @@ vi.mock("./proxy.js", () => ({ vi.mock("./fetch.js", () => ({ resolveTelegramFetch, + resolveTelegramApiBase: (apiRoot?: string) => + apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org", })); vi.mock("grammy", () => ({ diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index ec824d88ec7..55f1d689359 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -25,7 +25,7 @@ import { withTelegramApiErrorLogging } from "./api-logging.js"; import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { splitTelegramCaption } from "./caption.js"; -import { resolveTelegramFetch } from "./fetch.js"; +import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js"; import { renderTelegramHtmlText, splitTelegramHtmlChunks } from "./format.js"; import { isRecoverableTelegramNetworkError, @@ -192,9 +192,10 @@ function buildTelegramClientOptionsCacheKey(params: { const autoSelectFamilyKey = typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default"; const dnsResultOrderKey = params.account.config.network?.dnsResultOrder ?? "default"; + const apiRootKey = params.account.config.apiRoot?.trim() ?? ""; const timeoutSecondsKey = typeof params.timeoutSeconds === "number" ? String(params.timeoutSeconds) : "default"; - return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${timeoutSecondsKey}`; + return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${apiRootKey}::${timeoutSecondsKey}`; } function setCachedTelegramClientOptions( @@ -233,14 +234,16 @@ function resolveTelegramClientOptions( const proxyUrl = account.config.proxy?.trim(); const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; + const apiRoot = account.config.apiRoot?.trim() || undefined; const fetchImpl = resolveTelegramFetch(proxyFetch, { network: account.config.network, }); const clientOptions = - fetchImpl || timeoutSeconds + fetchImpl || timeoutSeconds || apiRoot ? { ...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}), ...(timeoutSeconds ? { timeoutSeconds } : {}), + ...(apiRoot ? { apiRoot } : {}), } : undefined; if (cacheKey) { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 947726bd7e8..233900305fa 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1532,6 +1532,8 @@ export const FIELD_HELP: Record = { "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", "channels.telegram.silentErrorReplies": "When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.", + "channels.telegram.apiRoot": + "Custom Telegram Bot API root URL. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked.", "channels.telegram.threadBindings.enabled": "Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set.", "channels.telegram.threadBindings.idleHours": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 53317e2fcd2..e762e979c71 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -732,6 +732,7 @@ export const FIELD_LABELS: Record = { "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.telegram.silentErrorReplies": "Telegram Silent Error Replies", + "channels.telegram.apiRoot": "Telegram API Root URL", "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", "channels.telegram.execApprovals": "Telegram Exec Approvals", "channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 71ded650deb..33b090317ca 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -216,6 +216,8 @@ export type TelegramAccountConfig = { * Telegram expects unicode emoji (e.g., "👀") rather than shortcodes. */ ackReaction?: string; + /** Custom Telegram Bot API root URL (e.g. "https://my-proxy.example.com" or a local Bot API server). */ + apiRoot?: string; }; export type TelegramTopicConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index e65030d8f38..897accf2878 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -280,6 +280,7 @@ export const TelegramAccountSchemaBase = z silentErrorReplies: z.boolean().optional(), responsePrefix: z.string().optional(), ackReaction: z.string().optional(), + apiRoot: z.string().url().optional(), }) .strict();