diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ca45239ee..e7c6adf328e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai - Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp. - Plugins/context engines: pass the embedded runner `modelId` into context-engine `assemble()` so plugins can adapt context formatting per model. (#47437) thanks @jscianna. - Plugins/context engines: add transcript maintenance rewrites for context engines, preserve active-branch transcript metadata during rewrites, and harden overflow-recovery truncation to rewrite sessions under the normal session write lock. (#51191) Thanks @jalehman. +- Telegram/apiRoot: add per-account custom Bot API endpoint support across send, probe, setup, doctor repair, and inbound media download paths so proxied or self-hosted Telegram deployments work end to end. (#48842) Thanks @Cypherm. ### Fixes diff --git a/extensions/telegram/src/api-fetch.test.ts b/extensions/telegram/src/api-fetch.test.ts index e65499ef25c..5de45f6ee75 100644 --- a/extensions/telegram/src/api-fetch.test.ts +++ b/extensions/telegram/src/api-fetch.test.ts @@ -54,4 +54,28 @@ describe("fetchTelegramChatId", () => { undefined, ); }); + + it("uses caller-provided fetch impl when present", async () => { + const customFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ ok: true, result: { id: 12345 } }), + })); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("global fetch should not be called"); + }), + ); + + await fetchTelegramChatId({ + token: "abc", + chatId: "@user", + fetchImpl: customFetch as unknown as typeof fetch, + }); + + expect(customFetch).toHaveBeenCalledWith( + "https://api.telegram.org/botabc/getChat?chat_id=%40user", + undefined, + ); + }); }); diff --git a/extensions/telegram/src/api-fetch.ts b/extensions/telegram/src/api-fetch.ts index 8831caa2b8a..21dd8fd64e5 100644 --- a/extensions/telegram/src/api-fetch.ts +++ b/extensions/telegram/src/api-fetch.ts @@ -1,11 +1,48 @@ +import type { TelegramNetworkConfig } from "../runtime-api.js"; +import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js"; +import { makeProxyFetch } from "./proxy.js"; + +export function resolveTelegramChatLookupFetch(params?: { + proxyUrl?: string; + network?: TelegramNetworkConfig; +}): typeof fetch { + const proxyUrl = params?.proxyUrl?.trim(); + const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; + return resolveTelegramFetch(proxyFetch, { network: params?.network }); +} + +export async function lookupTelegramChatId(params: { + token: string; + chatId: string; + signal?: AbortSignal; + apiRoot?: string; + proxyUrl?: string; + network?: TelegramNetworkConfig; +}): Promise { + return fetchTelegramChatId({ + token: params.token, + chatId: params.chatId, + signal: params.signal, + apiRoot: params.apiRoot, + fetchImpl: resolveTelegramChatLookupFetch({ + proxyUrl: params.proxyUrl, + network: params.network, + }), + }); +} + export async function fetchTelegramChatId(params: { token: string; chatId: string; signal?: AbortSignal; + apiRoot?: string; + fetchImpl?: typeof fetch; }): 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)}`; + const fetchImpl = params.fetchImpl ?? fetch; try { - const res = await fetch(url, params.signal ? { signal: params.signal } : undefined); + const res = await fetchImpl(url, params.signal ? { signal: params.signal } : undefined); if (!res.ok) { return null; } 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-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index b1cd7eb4d8a..86d6e608dce 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -360,6 +360,38 @@ describe("resolveMedia getFile retry", () => { }), ); }); + + it("uses local absolute file paths directly for media downloads", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); + + const result = await resolveMedia(makeCtx("document", getFile), MAX_MEDIA_BYTES, BOT_TOKEN); + + expect(fetchRemoteMedia).not.toHaveBeenCalled(); + expect(saveMediaBuffer).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + path: "/var/lib/telegram-bot-api/file.pdf", + placeholder: "", + }), + ); + }); + + it("uses local absolute file paths directly for sticker downloads", async () => { + const getFile = vi + .fn() + .mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/sticker.webp" }); + + const result = await resolveMedia(makeCtx("sticker", getFile), MAX_MEDIA_BYTES, BOT_TOKEN); + + expect(fetchRemoteMedia).not.toHaveBeenCalled(); + expect(saveMediaBuffer).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + path: "/var/lib/telegram-bot-api/sticker.webp", + placeholder: "", + }), + ); + }); }); describe("resolveMedia original filename preservation", () => { diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index 52f6eef966c..2e552529dec 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -1,21 +1,39 @@ +import path from "node:path"; import { GrammyError } from "grammy"; import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; 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 +142,13 @@ async function downloadAndSaveTelegramFile(params: { transport: TelegramTransport; maxBytes: number; telegramFileName?: string; + apiRoot?: string; }) { - const url = `https://api.telegram.org/file/bot${params.token}/${params.filePath}`; + if (path.isAbsolute(params.filePath)) { + return { path: params.filePath, contentType: undefined }; + } + 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 +157,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 +175,7 @@ async function resolveStickerMedia(params: { maxBytes: number; token: string; transport?: TelegramTransport; + apiRoot?: string; }): Promise< | { path: string; @@ -192,6 +216,7 @@ async function resolveStickerMedia(params: { token, transport: resolvedTransport, maxBytes, + apiRoot: params.apiRoot, }); // Check sticker cache for existing description @@ -247,6 +272,7 @@ export async function resolveMedia( maxBytes: number, token: string, transport?: TelegramTransport, + apiRoot?: string, ): Promise<{ path: string; contentType?: string; @@ -260,6 +286,7 @@ export async function resolveMedia( maxBytes, token, transport, + apiRoot, }); if (stickerResolved !== undefined) { return stickerResolved; @@ -283,6 +310,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/extensions/telegram/src/setup-core.test.ts b/extensions/telegram/src/setup-core.test.ts new file mode 100644 index 00000000000..5cf316c54d6 --- /dev/null +++ b/extensions/telegram/src/setup-core.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveTelegramAllowFromEntries } from "./setup-core.js"; + +describe("resolveTelegramAllowFromEntries", () => { + it("passes apiRoot through username lookups", async () => { + const globalFetch = vi.fn(async () => { + throw new Error("global fetch should not be called"); + }); + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ ok: true, result: { id: 12345 } }), + })); + vi.stubGlobal("fetch", globalFetch); + const proxyFetch = vi.fn(); + const fetchModule = await import("./fetch.js"); + const proxyModule = await import("./proxy.js"); + const resolveTelegramFetch = vi.spyOn(fetchModule, "resolveTelegramFetch"); + const makeProxyFetch = vi.spyOn(proxyModule, "makeProxyFetch"); + makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); + resolveTelegramFetch.mockReturnValue(fetchMock as unknown as typeof fetch); + + try { + const resolved = await resolveTelegramAllowFromEntries({ + entries: ["@user"], + credentialValue: "tok", + apiRoot: "https://custom.telegram.test/root/", + proxyUrl: "http://127.0.0.1:8080", + network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, + }); + + expect(resolved).toEqual([{ input: "@user", resolved: true, id: "12345" }]); + expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8080"); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { + network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://custom.telegram.test/root/bottok/getChat?chat_id=%40user", + undefined, + ); + } finally { + makeProxyFetch.mockRestore(); + resolveTelegramFetch.mockRestore(); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index afc302500bf..6e24563a9c9 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -9,8 +9,9 @@ import { } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; +import type { TelegramNetworkConfig } from "../runtime-api.js"; import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; -import { fetchTelegramChatId } from "./api-fetch.js"; +import { lookupTelegramChatId } from "./api-fetch.js"; const channel = "telegram" as const; @@ -46,6 +47,9 @@ export function parseTelegramAllowFromId(raw: string): string | null { export async function resolveTelegramAllowFromEntries(params: { entries: string[]; credentialValue?: string; + apiRoot?: string; + proxyUrl?: string; + network?: TelegramNetworkConfig; }) { return await Promise.all( params.entries.map(async (entry) => { @@ -58,9 +62,12 @@ export async function resolveTelegramAllowFromEntries(params: { return { input: entry, resolved: false, id: null }; } const username = stripped.startsWith("@") ? stripped : `@${stripped}`; - const id = await fetchTelegramChatId({ + const id = await lookupTelegramChatId({ token: params.credentialValue, chatId: username, + apiRoot: params.apiRoot, + proxyUrl: params.proxyUrl, + network: params.network, }); return { input: entry, resolved: Boolean(id), id }; }), @@ -96,6 +103,9 @@ export async function promptTelegramAllowFromForAccount(params: { resolveTelegramAllowFromEntries({ credentialValue: token, entries, + apiRoot: resolved.config.apiRoot, + proxyUrl: resolved.config.proxy, + network: resolved.config.network, }), }); return patchChannelConfigForAccount({ diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index 75ebee401a2..f7b0c3e5ebb 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -119,10 +119,11 @@ export const telegramSetupWizard: ChannelSetupWizard = { "Telegram token missing; use numeric sender ids (usernames require a bot token).", parseInputs: splitSetupEntries, parseId: parseTelegramAllowFromId, - resolveEntries: async ({ credentialValues, entries }) => + resolveEntries: async ({ cfg, accountId, credentialValues, entries }) => resolveTelegramAllowFromEntries({ credentialValue: credentialValues.token, entries, + apiRoot: resolveTelegramAccount({ cfg, accountId }).config.apiRoot, }), apply: async ({ cfg, accountId, allowFrom }) => patchChannelConfigForAccount({ diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 4a461c58267..7590402bde5 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; +import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js"; import * as noteModule from "../terminal/note.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js"; @@ -516,8 +517,11 @@ describe("doctor config flow", () => { }); it("resolves Telegram @username allowFrom entries to numeric IDs on repair", async () => { - const fetchSpy = vi.fn(async (url: string) => { - const u = String(url); + const globalFetch = vi.fn(async () => { + throw new Error("global fetch should not be called"); + }); + const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const u = input instanceof URL ? input.href : typeof input === "string" ? input : input.url; const chatId = new URL(u).searchParams.get("chat_id") ?? ""; const id = chatId.toLowerCase() === "@testuser" @@ -534,7 +538,14 @@ describe("doctor config flow", () => { json: async () => (id != null ? { ok: true, result: { id } } : { ok: false }), } as unknown as Response; }); - vi.stubGlobal("fetch", fetchSpy); + vi.stubGlobal("fetch", globalFetch); + const proxyFetch = vi.fn(); + const telegramFetchModule = await import("../../extensions/telegram/src/fetch.js"); + const telegramProxyModule = await import("../../extensions/telegram/src/proxy.js"); + const resolveTelegramFetch = vi.spyOn(telegramFetchModule, "resolveTelegramFetch"); + const makeProxyFetch = vi.spyOn(telegramProxyModule, "makeProxyFetch"); + makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); + resolveTelegramFetch.mockReturnValue(fetchSpy as unknown as typeof fetch); try { const result = await runDoctorConfigWithInput({ repair: true, @@ -580,6 +591,8 @@ describe("doctor config flow", () => { expect(cfg.channels.telegram.accounts.default.allowFrom).toEqual(["111"]); expect(cfg.channels.telegram.accounts.default.groupAllowFrom).toEqual(["222"]); } finally { + makeProxyFetch.mockRestore(); + resolveTelegramFetch.mockRestore(); vi.unstubAllGlobals(); } }); @@ -632,6 +645,88 @@ describe("doctor config flow", () => { } }); + it("uses account apiRoot when repairing Telegram allowFrom usernames", async () => { + const globalFetch = vi.fn(async () => { + throw new Error("global fetch should not be called"); + }); + const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url; + expect(url).toBe("https://custom.telegram.test/root/bottok/getChat?chat_id=%40testuser"); + return { + ok: true, + json: async () => ({ ok: true, result: { id: 12345 } }), + }; + }); + vi.stubGlobal("fetch", globalFetch); + const proxyFetch = vi.fn(); + const telegramFetchModule = await import("../../extensions/telegram/src/fetch.js"); + const telegramProxyModule = await import("../../extensions/telegram/src/proxy.js"); + const resolveTelegramFetch = vi.spyOn(telegramFetchModule, "resolveTelegramFetch"); + const makeProxyFetch = vi.spyOn(telegramProxyModule, "makeProxyFetch"); + makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); + resolveTelegramFetch.mockReturnValue(fetchSpy as unknown as typeof fetch); + const resolveSecretsSpy = vi + .spyOn(commandSecretGatewayModule, "resolveCommandSecretRefsViaGateway") + .mockResolvedValue({ + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + resolvedConfig: { + channels: { + telegram: { + accounts: { + work: { + botToken: "tok", + apiRoot: "https://custom.telegram.test/root/", + proxy: "http://127.0.0.1:8888", + network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, + allowFrom: ["@testuser"], + }, + }, + }, + }, + }, + }); + + try { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + channels: { + telegram: { + accounts: { + work: { + botToken: "tok", + allowFrom: ["@testuser"], + }, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const cfg = result.cfg as { + channels?: { + telegram?: { + accounts?: Record; + }; + }; + }; + expect(cfg.channels?.telegram?.accounts?.work?.allowFrom).toEqual(["12345"]); + expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8888"); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { + network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + } finally { + makeProxyFetch.mockRestore(); + resolveTelegramFetch.mockRestore(); + resolveSecretsSpy.mockRestore(); + vi.unstubAllGlobals(); + } + }); + it("warns and continues when Telegram account inspection hits inactive SecretRef surfaces", async () => { const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); const fetchSpy = vi.fn(); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 3bd8c871e6e..628fd656b2d 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,8 +1,8 @@ import { - fetchTelegramChatId, inspectTelegramAccount, isNumericTelegramUserId, listTelegramAccountIds, + lookupTelegramChatId, normalizeTelegramAllowFromEntry, } from "../../extensions/telegram/api.js"; import { normalizeChatChannelId } from "../channels/registry.js"; @@ -15,6 +15,7 @@ import { CONFIG_PATH, migrateLegacyConfig } from "../config/config.js"; import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; import { parseToolsBySenderTypedKey } from "../config/types.tools.js"; import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js"; import { @@ -84,6 +85,13 @@ type TelegramAllowFromListRef = { key: "allowFrom" | "groupAllowFrom"; }; +type ResolvedTelegramLookupAccount = { + token: string; + apiRoot?: string; + proxyUrl?: string; + network?: TelegramNetworkConfig; +}; + function asObjectRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -399,29 +407,34 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi return inspected.enabled && inspected.tokenStatus === "configured_unavailable"; }); const tokenResolutionWarnings: string[] = []; - const tokens = Array.from( - new Set( - listTelegramAccountIds(resolvedConfig) - .map((accountId) => { - try { - return resolveTelegramAccount({ cfg: resolvedConfig, accountId }); - } catch (error) { - tokenResolutionWarnings.push( - `- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`, - ); - return null; - } - }) - .filter((account): account is NonNullable> => - Boolean(account), - ) - .map((account) => (account.tokenSource === "none" ? "" : account.token)) - .map((token) => token.trim()) - .filter(Boolean), - ), - ); + const lookupAccounts: ResolvedTelegramLookupAccount[] = []; + const seenLookupAccounts = new Set(); + for (const accountId of listTelegramAccountIds(resolvedConfig)) { + let account: NonNullable>; + try { + account = resolveTelegramAccount({ cfg: resolvedConfig, accountId }); + } catch (error) { + tokenResolutionWarnings.push( + `- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`, + ); + continue; + } + const token = account.tokenSource === "none" ? "" : account.token.trim(); + if (!token) { + continue; + } + const apiRoot = account.config.apiRoot?.trim() || undefined; + const proxyUrl = account.config.proxy?.trim() || undefined; + const network = account.config.network; + const cacheKey = `${token}::${apiRoot ?? ""}::${proxyUrl ?? ""}::${JSON.stringify(network ?? {})}`; + if (seenLookupAccounts.has(cacheKey)) { + continue; + } + seenLookupAccounts.add(cacheKey); + lookupAccounts.push({ token, apiRoot, proxyUrl, network }); + } - if (tokens.length === 0) { + if (lookupAccounts.length === 0) { return { config: cfg, changes: [ @@ -449,14 +462,17 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi return null; } const username = stripped.startsWith("@") ? stripped : `@${stripped}`; - for (const token of tokens) { + for (const account of lookupAccounts) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 4000); try { - const id = await fetchTelegramChatId({ - token, + const id = await lookupTelegramChatId({ + token: account.token, chatId: username, signal: controller.signal, + apiRoot: account.apiRoot, + proxyUrl: account.proxyUrl, + network: account.network, }); if (id) { return id; 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(); diff --git a/src/plugins/runtime/runtime-telegram-contract.ts b/src/plugins/runtime/runtime-telegram-contract.ts index 6700ae25429..fc0b680f51d 100644 --- a/src/plugins/runtime/runtime-telegram-contract.ts +++ b/src/plugins/runtime/runtime-telegram-contract.ts @@ -82,7 +82,7 @@ export { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, } from "../../../extensions/telegram/api.js"; -export { fetchTelegramChatId } from "../../../extensions/telegram/api.js"; +export { fetchTelegramChatId, lookupTelegramChatId } from "../../../extensions/telegram/api.js"; export { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType,