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

* 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>

* fix(telegram): thread apiRoot through allowFrom lookups

* fix(telegram): honor lookup transport and local file paths

* refactor(telegram): unify username lookup plumbing

* fix(telegram): restore doctor lookup imports

* fix: document Telegram apiRoot support (#48842) (thanks @Cypherm)

---------

Co-authored-by: Cypherm <28184436+Cypherm@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Cypherm
2026-03-21 12:40:38 +08:00
committed by GitHub
parent 598f1826d8
commit 6b4c24c2e5
25 changed files with 397 additions and 61 deletions

View File

@@ -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<string, { allowFrom?: string[] }>;
};
};
};
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();

View File

@@ -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<string, unknown> | 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<ReturnType<typeof resolveTelegramAccount>> =>
Boolean(account),
)
.map((account) => (account.tokenSource === "none" ? "" : account.token))
.map((token) => token.trim())
.filter(Boolean),
),
);
const lookupAccounts: ResolvedTelegramLookupAccount[] = [];
const seenLookupAccounts = new Set<string>();
for (const accountId of listTelegramAccountIds(resolvedConfig)) {
let account: NonNullable<ReturnType<typeof resolveTelegramAccount>>;
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;

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

View File

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