mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 03:00:21 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user