feat(telegram): support custom apiRoot for alternative API endpoints

Add `apiRoot` config option to allow users to specify custom Telegram Bot
API endpoints (e.g., self-hosted Bot API servers). Threads the configured
base URL through all Telegram API call sites: bot creation, send, probe,
audit, media download, and api-fetch. Extends SSRF policy to dynamically
trust custom apiRoot hostname for media downloads.

Closes #28535

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cypherm 2026-03-21 08:42:31 +08:00 committed by Ayaan Zaidi
parent 598f1826d8
commit 92b07b383d
No known key found for this signature in database
16 changed files with 101 additions and 27 deletions

View File

@ -1,9 +1,13 @@
import { resolveTelegramApiBase } from "./fetch.js";
export async function fetchTelegramChatId(params: {
token: string;
chatId: string;
signal?: AbortSignal;
apiRoot?: string;
}): Promise<string | null> {
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) {

View File

@ -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<T> = { ok: true; result: T };
type TelegramApiErr = { ok: false; description?: string };
type TelegramGroupMembershipAuditData = Omit<TelegramGroupMembershipAudit, "elapsedMs">;
@ -18,8 +16,11 @@ export async function auditTelegramGroupMembershipImpl(
params: AuditTelegramGroupMembershipParams,
): Promise<TelegramGroupMembershipAuditData> {
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) {

View File

@ -66,6 +66,7 @@ export type AuditTelegramGroupMembershipParams = {
groupIds: string[];
proxyUrl?: string;
network?: TelegramNetworkConfig;
apiRoot?: string;
timeoutMs: number;
};

View File

@ -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<ReturnType<typeof resolveMedia>> = 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) {

View File

@ -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;

View File

@ -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) ?? "<media:document>";
return { path: saved.path, contentType: saved.contentType, placeholder };

View File

@ -586,6 +586,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
accountId: account.accountId,
proxyUrl: account.config.proxy,
network: account.config.network,
apiRoot: account.config.apiRoot,
}),
formatCapabilitiesProbe: ({ probe }) => {
const lines = [];
@ -637,6 +638,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
groupIds,
proxyUrl: account.config.proxy,
network: account.config.network,
apiRoot: account.config.apiRoot,
timeoutMs,
});
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
@ -704,6 +706,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
accountId: account.accountId,
proxyUrl: account.config.proxy,
network: account.config.network,
apiRoot: account.config.apiRoot,
});
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) {

View File

@ -589,3 +589,12 @@ export function resolveTelegramFetch(
): typeof fetch {
return resolveTelegramTransport(proxyFetch, options).fetch;
}
/**
* Resolve the Telegram Bot API base URL from an optional `apiRoot` config value.
* Returns a trimmed URL without trailing slash, or the standard default.
*/
export function resolveTelegramApiBase(apiRoot?: string): string {
const trimmed = apiRoot?.trim();
return trimmed ? trimmed.replace(/\/+$/, "") : `https://${TELEGRAM_API_HOSTNAME}`;
}

View File

@ -7,6 +7,8 @@ const makeProxyFetch = vi.hoisted(() => 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,
});
});

View File

@ -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<string, typeof fetch>();
@ -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());

View File

@ -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", () => ({

View File

@ -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) {

View File

@ -1532,6 +1532,8 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@ -732,6 +732,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@ -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 = {

View File

@ -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();