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:
parent
598f1826d8
commit
6b4c24c2e5
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string | null> {
|
||||
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<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)}`;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -66,6 +66,7 @@ export type AuditTelegramGroupMembershipParams = {
|
||||
groupIds: string[];
|
||||
proxyUrl?: string;
|
||||
network?: TelegramNetworkConfig;
|
||||
apiRoot?: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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: "<media:document>",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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: "<media:sticker>",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMedia original filename preservation", () => {
|
||||
|
||||
@ -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) ?? "<media:document>";
|
||||
return { path: saved.path, contentType: saved.contentType, placeholder };
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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", () => ({
|
||||
|
||||
@ -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) {
|
||||
|
||||
46
extensions/telegram/src/setup-core.test.ts
Normal file
46
extensions/telegram/src/setup-core.test.ts
Normal file
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user