diff --git a/CHANGELOG.md b/CHANGELOG.md index 625bbddb9c8..0267d3d7170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ Docs: https://docs.openclaw.ai - Memory/Dreaming: retry Dream Diary once with the session default when a configured dreaming model is unavailable, while leaving subagent trust and allowlist errors visible instead of silently masking configuration problems. Refs #67409 and #69209. Thanks @Ghiggins18 and @everySympathy. - Feishu/inbound files: recover CJK filenames from plain `Content-Disposition: filename=` download headers when Feishu exposes UTF-8 bytes through Latin-1 header decoding, while leaving valid Latin-1 and JSON-derived names unchanged. (#48578, #50435, #59431) Thanks @alex-xuweilong, @lishuaigit, and @DoChaoing. +### Fixes + +- Channels/Telegram: normalize accidental full `/bot` Telegram `apiRoot` values at runtime and teach `openclaw doctor --fix` to remove the suffix, so startup control calls no longer 404 when direct Bot API curl commands work. Fixes #55387. Thanks @brendanmatthewjones-cmyk, @techfindubai-ux, and @Sivlerback-Chris. + ## 2026.4.27 ### Changes diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 04e8808f96a..adcc4689906 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -b80df5537b3569826a23b8176910476493ae569b65f9b4c2fa9e0ad415eb4a2b config-baseline.json +9caccd04afca25d18cfcc4a66bdc30c995f5ec51eaa764c076ce58c9af11a7bf config-baseline.json 8530c8fd54e04a2ab7f6704195f9959311e289ae122ebd8e27af236de435fef9 config-baseline.core.json -c4f07c228d4f07e7afafa5b600b4a80f5b26aaed7267c7287a64d04a527be8e8 config-baseline.channel.json +a9f058ee9616e189dab7fc223e1207a49ae52b8490b8028935c9d0a2b16f81b2 config-baseline.channel.json 1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 13564b36db1..f540e2d99e2 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -364,6 +364,7 @@ curl "https://api.telegram.org/bot/getUpdates" Common setup failures: - `setMyCommands failed` with `BOT_COMMANDS_TOO_MUCH` means the Telegram menu still overflowed after trimming; reduce plugin/skill/custom commands or disable `channels.telegram.commands.native`. + - `deleteWebhook`, `deleteMyCommands`, or `setMyCommands` failing with `404: Not Found` while direct Bot API curl commands work can mean `channels.telegram.apiRoot` was set to the full `/bot` endpoint. `apiRoot` must be only the Bot API root, and `openclaw doctor --fix` removes an accidental trailing `/bot`. - `setMyCommands failed` with network/fetch errors usually means outbound DNS/HTTPS to `api.telegram.org` is blocked. ### Device pairing commands (`device-pair` plugin) @@ -925,6 +926,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels - streaming: `streaming` (preview), `streaming.preview.toolProgress`, `blockStreaming` - formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix` - media/network: `mediaMaxMb`, `timeoutSeconds`, `pollingStallThresholdMs`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy` +- custom API root: `apiRoot` (Bot API root only; do not include `/bot`) - webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost` - actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker` - reactions: `reactionNotifications`, `reactionLevel` diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 1fec24ca793..4dd4d432b4f 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -195,6 +195,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat autoSelectFamily: true, dnsResultOrder: "ipv4first", }, + apiRoot: "https://api.telegram.org", proxy: "socks5://localhost:9050", webhookUrl: "https://example.com/telegram-webhook", webhookSecret: "secret", @@ -205,6 +206,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat ``` - Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile` (regular file only; symlinks rejected), with `TELEGRAM_BOT_TOKEN` as fallback for the default account. +- `apiRoot` is the Telegram Bot API root only. Use `https://api.telegram.org` or your self-hosted/proxy root, not `https://api.telegram.org/bot`; `openclaw doctor --fix` removes an accidental trailing `/bot` suffix. - Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id. - In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid. - `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`). diff --git a/extensions/telegram/src/api-root.test.ts b/extensions/telegram/src/api-root.test.ts new file mode 100644 index 00000000000..9754e484265 --- /dev/null +++ b/extensions/telegram/src/api-root.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_TELEGRAM_API_ROOT, + hasTelegramBotEndpointApiRoot, + normalizeTelegramApiRoot, +} from "./api-root.js"; + +describe("telegram api root", () => { + it("defaults to the public Telegram Bot API root", () => { + expect(normalizeTelegramApiRoot()).toBe(DEFAULT_TELEGRAM_API_ROOT); + expect(normalizeTelegramApiRoot(" ")).toBe(DEFAULT_TELEGRAM_API_ROOT); + }); + + it("keeps custom Bot API roots without a bot-token endpoint", () => { + expect(normalizeTelegramApiRoot("https://telegram.internal:8443/custom-bot-api/")).toBe( + "https://telegram.internal:8443/custom-bot-api", + ); + expect(hasTelegramBotEndpointApiRoot("https://telegram.internal:8443/custom-bot-api/")).toBe( + false, + ); + }); + + it("strips a full bot endpoint from apiRoot", () => { + const root = "https://api.telegram.org/bot123456:ABC_def-ghi/"; + + expect(hasTelegramBotEndpointApiRoot(root)).toBe(true); + expect(normalizeTelegramApiRoot(root)).toBe("https://api.telegram.org"); + }); + + it("strips only terminal bot-token endpoint segments", () => { + expect(normalizeTelegramApiRoot("https://proxy.example.com/custom/bot123456:ABC_def")).toBe( + "https://proxy.example.com/custom", + ); + expect(normalizeTelegramApiRoot("https://proxy.example.com/bot123456")).toBe( + "https://proxy.example.com/bot123456", + ); + }); +}); diff --git a/extensions/telegram/src/api-root.ts b/extensions/telegram/src/api-root.ts new file mode 100644 index 00000000000..3d2ea4c6d9c --- /dev/null +++ b/extensions/telegram/src/api-root.ts @@ -0,0 +1,49 @@ +export const DEFAULT_TELEGRAM_API_ROOT = "https://api.telegram.org"; + +const TELEGRAM_BOT_ENDPOINT_SEGMENT_RE = /^bot\d+:[^/]+$/u; + +function isTelegramBotEndpointSegment(segment: string): boolean { + try { + return TELEGRAM_BOT_ENDPOINT_SEGMENT_RE.test(decodeURIComponent(segment)); + } catch { + return TELEGRAM_BOT_ENDPOINT_SEGMENT_RE.test(segment); + } +} + +export function normalizeTelegramApiRoot(apiRoot?: string): string { + const trimmed = apiRoot?.trim(); + if (!trimmed) { + return DEFAULT_TELEGRAM_API_ROOT; + } + + let normalized = trimmed.replace(/\/+$/u, ""); + try { + const url = new URL(normalized); + const segments = url.pathname.split("/").filter(Boolean); + if (segments.length > 0 && isTelegramBotEndpointSegment(segments[segments.length - 1] ?? "")) { + segments.pop(); + url.pathname = segments.length > 0 ? `/${segments.join("/")}` : "/"; + url.search = ""; + url.hash = ""; + normalized = url.toString().replace(/\/+$/u, ""); + } + } catch { + // Config validation catches invalid URLs; keep legacy runtime behavior for + // callers that reached this helper with unchecked input. + } + return normalized; +} + +export function hasTelegramBotEndpointApiRoot(apiRoot: unknown): boolean { + if (typeof apiRoot !== "string" || !apiRoot.trim()) { + return false; + } + try { + const url = new URL(apiRoot.trim()); + const segments = url.pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1]; + return Boolean(last && isTelegramBotEndpointSegment(last)); + } catch { + return false; + } +} diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index 09dd6d28099..7b80e998e55 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -24,6 +24,7 @@ import { normalizeOptionalString, } from "openclaw/plugin-sdk/text-runtime"; import { resolveTelegramAccount } from "./accounts.js"; +import { normalizeTelegramApiRoot } from "./api-root.js"; import type { TelegramBotDeps } from "./bot-deps.js"; import { registerTelegramHandlers } from "./bot-handlers.runtime.js"; import { createTelegramMessageProcessor } from "./bot-message.js"; @@ -296,12 +297,13 @@ export function createTelegramBotCore( ? Math.max(1, Math.floor(telegramCfg.timeoutSeconds)) : undefined; const apiRoot = normalizeOptionalString(telegramCfg.apiRoot); + const normalizedApiRoot = apiRoot ? normalizeTelegramApiRoot(apiRoot) : undefined; const client: ApiClientOptions | undefined = - finalFetch || timeoutSeconds || apiRoot + finalFetch || timeoutSeconds || normalizedApiRoot ? { ...(finalFetch ? { fetch: asTelegramClientFetch(finalFetch) } : {}), ...(timeoutSeconds ? { timeoutSeconds } : {}), - ...(apiRoot ? { apiRoot } : {}), + ...(normalizedApiRoot ? { apiRoot: normalizedApiRoot } : {}), } : undefined; diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index d0d29990e2d..35d84f18a90 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -247,6 +247,28 @@ describe("createTelegramBot", () => { }), ); }); + + it("normalizes full Telegram bot endpoint apiRoot before passing it to grammY", () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + apiRoot: "https://api.telegram.org/bot123456:ABC/", + }, + }, + }); + + createTelegramBot({ token: "tok" }); + + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ apiRoot: "https://api.telegram.org" }), + }), + ); + }); + it("sequentializes updates by chat and thread", () => { createTelegramBot({ token: "tok" }); expect(sequentializeSpy).toHaveBeenCalledTimes(1); diff --git a/extensions/telegram/src/config-ui-hints.ts b/extensions/telegram/src/config-ui-hints.ts index e82c8533bc9..ae8f35a3f13 100644 --- a/extensions/telegram/src/config-ui-hints.ts +++ b/extensions/telegram/src/config-ui-hints.ts @@ -103,7 +103,7 @@ export const telegramChannelConfigUiHints = { }, apiRoot: { label: "Telegram API Root URL", - help: "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.", + help: "Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. 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.", }, trustedLocalFileRoots: { label: "Telegram Trusted Local File Roots", diff --git a/extensions/telegram/src/doctor.test.ts b/extensions/telegram/src/doctor.test.ts index 63714606fa7..e2e6e54d392 100644 --- a/extensions/telegram/src/doctor.test.ts +++ b/extensions/telegram/src/doctor.test.ts @@ -2,9 +2,12 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { collectTelegramInvalidAllowFromWarnings, + collectTelegramApiRootWarnings, collectTelegramEmptyAllowlistExtraWarnings, collectTelegramGroupPolicyWarnings, + maybeRepairTelegramApiRoots, maybeRepairTelegramAllowFromUsernames, + scanTelegramBotEndpointApiRoots, scanTelegramInvalidAllowFromEntries, telegramDoctor, } from "./doctor.js"; @@ -288,4 +291,68 @@ describe("telegram doctor", () => { expect(warnings[0]).toContain("invalid sender entries"); expect(warnings[1]).toContain("openclaw doctor --fix"); }); + + it("warns and repairs Telegram apiRoot values that include the bot endpoint", () => { + const cfg = { + channels: { + telegram: { + apiRoot: "https://api.telegram.org/bot123456:ABC", + accounts: { + work: { + apiRoot: "https://proxy.example.test/custom/bot234567:DEF/", + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const hits = scanTelegramBotEndpointApiRoots(cfg); + expect(hits.map((hit) => hit.path)).toEqual([ + "channels.telegram.apiRoot", + "channels.telegram.accounts.work.apiRoot", + ]); + expect( + collectTelegramApiRootWarnings({ hits, doctorFixCommand: "openclaw doctor --fix" }), + ).toContain( + "- channels.telegram.apiRoot points at a full Telegram bot endpoint; apiRoot must be the Bot API root only. This can make startup calls like deleteWebhook, deleteMyCommands, and setMyCommands fail with 404 even when direct curl commands work.", + ); + + const repaired = maybeRepairTelegramApiRoots(cfg); + expect(repaired.config.channels?.telegram?.apiRoot).toBe("https://api.telegram.org"); + expect(repaired.config.channels?.telegram?.accounts?.work?.apiRoot).toBe( + "https://proxy.example.test/custom", + ); + expect(repaired.changes).toEqual([ + "- channels.telegram.apiRoot: removed trailing /bot from Telegram apiRoot.", + "- channels.telegram.accounts.work.apiRoot: removed trailing /bot from Telegram apiRoot.", + ]); + }); + + it("wires apiRoot preview warnings and repair through the doctor adapter", async () => { + const cfg = { + channels: { + telegram: { + apiRoot: "https://api.telegram.org/bot123456:ABC", + }, + }, + } as unknown as OpenClawConfig; + + expect( + await telegramDoctor.collectPreviewWarnings?.({ + cfg, + doctorFixCommand: "openclaw doctor --fix", + }), + ).toContain( + "- channels.telegram.apiRoot points at a full Telegram bot endpoint; apiRoot must be the Bot API root only. This can make startup calls like deleteWebhook, deleteMyCommands, and setMyCommands fail with 404 even when direct curl commands work.", + ); + + const repaired = await telegramDoctor.repairConfig?.({ + cfg, + doctorFixCommand: "openclaw doctor --fix", + }); + expect(repaired?.config.channels?.telegram?.apiRoot).toBe("https://api.telegram.org"); + expect(repaired?.changes).toEqual([ + "- channels.telegram.apiRoot: removed trailing /bot from Telegram apiRoot.", + ]); + }); }); diff --git a/extensions/telegram/src/doctor.ts b/extensions/telegram/src/doctor.ts index 74e969cbc72..8cc18ad79bb 100644 --- a/extensions/telegram/src/doctor.ts +++ b/extensions/telegram/src/doctor.ts @@ -9,12 +9,19 @@ import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; import { isNumericTelegramSenderUserId, normalizeTelegramAllowFromEntry } from "./allow-from.js"; import { lookupTelegramChatId } from "./api-fetch.js"; +import { hasTelegramBotEndpointApiRoot, normalizeTelegramApiRoot } from "./api-root.js"; import { legacyConfigRules as TELEGRAM_LEGACY_CONFIG_RULES, normalizeCompatibilityConfig as normalizeTelegramCompatibilityConfig, } from "./doctor-contract.js"; type TelegramAllowFromInvalidHit = { path: string; entry: string }; +type TelegramApiRootBotEndpointHit = { + path: string; + pathSegments: string[]; + value: string; + normalized: string; +}; type DoctorAllowFromList = Array; type DoctorAccountRecord = Record; @@ -40,13 +47,21 @@ function hasAllowFromEntries(values?: DoctorAllowFromList): boolean { function collectTelegramAccountScopes( cfg: OpenClawConfig, -): Array<{ prefix: string; account: Record }> { - const scopes: Array<{ prefix: string; account: Record }> = []; +): Array<{ prefix: string; pathSegments: string[]; account: Record }> { + const scopes: Array<{ + prefix: string; + pathSegments: string[]; + account: Record; + }> = []; const telegram = asObjectRecord((cfg.channels as Record | undefined)?.telegram); if (!telegram) { return scopes; } - scopes.push({ prefix: "channels.telegram", account: telegram }); + scopes.push({ + prefix: "channels.telegram", + pathSegments: ["channels", "telegram"], + account: telegram, + }); const accounts = asObjectRecord(telegram.accounts); if (!accounts) { return scopes; @@ -54,7 +69,11 @@ function collectTelegramAccountScopes( for (const key of Object.keys(accounts)) { const account = asObjectRecord(accounts[key]); if (account) { - scopes.push({ prefix: `channels.telegram.accounts.${key}`, account }); + scopes.push({ + prefix: `channels.telegram.accounts.${key}`, + pathSegments: ["channels", "telegram", "accounts", key], + account, + }); } } return scopes; @@ -140,6 +159,83 @@ export function collectTelegramInvalidAllowFromWarnings(params: { ]; } +export function scanTelegramBotEndpointApiRoots( + cfg: OpenClawConfig, +): TelegramApiRootBotEndpointHit[] { + const hits: TelegramApiRootBotEndpointHit[] = []; + for (const scope of collectTelegramAccountScopes(cfg)) { + const value = scope.account.apiRoot; + if (typeof value !== "string" || !hasTelegramBotEndpointApiRoot(value)) { + continue; + } + hits.push({ + path: `${scope.prefix}.apiRoot`, + pathSegments: [...scope.pathSegments, "apiRoot"], + value, + normalized: normalizeTelegramApiRoot(value), + }); + } + return hits; +} + +export function collectTelegramApiRootWarnings(params: { + hits: TelegramApiRootBotEndpointHit[]; + doctorFixCommand: string; +}): string[] { + if (params.hits.length === 0) { + return []; + } + const samplePath = sanitizeForLog(params.hits[0]?.path ?? "channels.telegram.apiRoot"); + return [ + `- ${samplePath} points at a full Telegram bot endpoint; apiRoot must be the Bot API root only. This can make startup calls like deleteWebhook, deleteMyCommands, and setMyCommands fail with 404 even when direct curl commands work.`, + `- Run "${params.doctorFixCommand}" to remove the trailing /bot path from Telegram apiRoot.`, + ]; +} + +export function maybeRepairTelegramApiRoots(cfg: OpenClawConfig): { + config: OpenClawConfig; + changes: string[]; +} { + const hits = scanTelegramBotEndpointApiRoots(cfg); + if (hits.length === 0) { + return { config: cfg, changes: [] }; + } + + const next = structuredClone(cfg); + const apply = (path: string[], normalized: string) => { + let target: Record | null = next as Record; + for (const segment of path.slice(0, -1)) { + target = asObjectRecord(target?.[segment]); + if (!target) { + return; + } + } + target[path[path.length - 1] ?? "apiRoot"] = normalized; + }; + + for (const hit of hits) { + apply(hit.pathSegments, hit.normalized); + } + return { + config: next, + changes: hits.map( + (hit) => `- ${sanitizeForLog(hit.path)}: removed trailing /bot from Telegram apiRoot.`, + ), + }; +} + +async function repairTelegramConfig(params: { cfg: OpenClawConfig }): Promise<{ + config: OpenClawConfig; + changes: string[]; +}> { + const apiRootRepair = maybeRepairTelegramApiRoots(params.cfg); + const allowFromRepair = await maybeRepairTelegramAllowFromUsernames(apiRootRepair.config); + return { + config: allowFromRepair.config, + changes: [...apiRootRepair.changes, ...allowFromRepair.changes], + }; +} + export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promise<{ config: OpenClawConfig; changes: string[]; @@ -376,12 +472,17 @@ export function collectTelegramEmptyAllowlistExtraWarnings( export const telegramDoctor: ChannelDoctorAdapter = { legacyConfigRules: TELEGRAM_LEGACY_CONFIG_RULES, normalizeCompatibilityConfig: normalizeTelegramCompatibilityConfig, - collectPreviewWarnings: ({ cfg, doctorFixCommand }) => - collectTelegramInvalidAllowFromWarnings({ + collectPreviewWarnings: ({ cfg, doctorFixCommand }) => [ + ...collectTelegramInvalidAllowFromWarnings({ hits: scanTelegramInvalidAllowFromEntries(cfg), doctorFixCommand, }), - repairConfig: async ({ cfg }) => await maybeRepairTelegramAllowFromUsernames(cfg), + ...collectTelegramApiRootWarnings({ + hits: scanTelegramBotEndpointApiRoots(cfg), + doctorFixCommand, + }), + ], + repairConfig: async ({ cfg }) => await repairTelegramConfig({ cfg }), collectEmptyAllowlistExtraWarnings: collectTelegramEmptyAllowlistExtraWarnings, shouldSkipDefaultEmptyGroupAllowlistWarning: (params) => params.channelName === "telegram", }; diff --git a/extensions/telegram/src/fetch.test.ts b/extensions/telegram/src/fetch.test.ts index 0b57e954524..4bf75327c8f 100644 --- a/extensions/telegram/src/fetch.test.ts +++ b/extensions/telegram/src/fetch.test.ts @@ -98,6 +98,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ })); let resolveTelegramFetch: typeof import("./fetch.js").resolveTelegramFetch; +let resolveTelegramApiBase: typeof import("./fetch.js").resolveTelegramApiBase; let resolveTelegramTransport: typeof import("./fetch.js").resolveTelegramTransport; type TelegramDispatcherPolicy = NonNullable< @@ -105,7 +106,8 @@ type TelegramDispatcherPolicy = NonNullable< >[number]["dispatcherPolicy"]; beforeAll(async () => { - ({ resolveTelegramFetch, resolveTelegramTransport } = await import("./fetch.js")); + ({ resolveTelegramApiBase, resolveTelegramFetch, resolveTelegramTransport } = + await import("./fetch.js")); }); beforeEach(() => { @@ -308,6 +310,12 @@ afterEach(() => { }); describe("resolveTelegramFetch", () => { + it("normalizes a full bot endpoint apiRoot before callers append bot paths", () => { + expect(resolveTelegramApiBase("https://api.telegram.org/bot123456:ABC/")).toBe( + "https://api.telegram.org", + ); + }); + it("wraps proxy fetches and leaves retry policy to caller-provided fetch", async () => { const proxyFetch = vi.fn(async () => ({ ok: true }) as Response) as unknown as typeof fetch; diff --git a/extensions/telegram/src/fetch.ts b/extensions/telegram/src/fetch.ts index 8a8c226fc1e..df6e65ab7fa 100644 --- a/extensions/telegram/src/fetch.ts +++ b/extensions/telegram/src/fetch.ts @@ -16,6 +16,7 @@ import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; +import { normalizeTelegramApiRoot } from "./api-root.js"; import { resolveTelegramAutoSelectFamilyDecision, resolveTelegramDnsResultOrderDecision, @@ -730,6 +731,5 @@ export function resolveTelegramFetch( * 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}`; + return normalizeTelegramApiRoot(apiRoot); } diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 46917ef7b51..0de7ac36547 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -491,6 +491,31 @@ describe("sendMessageTelegram", () => { } }); + it("normalizes full Telegram bot endpoint apiRoot before send clients reach grammY", async () => { + const cfg = { + channels: { + telegram: { + accounts: { + foo: { + apiRoot: "https://api.telegram.org/bot123456:ABC/", + }, + }, + }, + }, + }; + loadConfig.mockReturnValue(cfg); + botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + + await sendMessageTelegram("123", "hi", { cfg, token: "tok", accountId: "foo" }); + + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ apiRoot: "https://api.telegram.org" }), + }), + ); + }); + it("falls back to plain text when Telegram rejects HTML and preserves send params", async () => { const parseErr = new Error( "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9", diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index a4b4b7493d0..7e3552ae94b 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -10,6 +10,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOptionalString, redactSensitiveText } from "openclaw/plugin-sdk/text-runtime"; import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { normalizeTelegramApiRoot } from "./api-root.js"; import { buildTypingThreadParams } from "./bot/helpers.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { splitTelegramCaption } from "./caption.js"; @@ -262,15 +263,16 @@ function resolveTelegramClientOptions( const proxyUrl = normalizeOptionalString(account.config.proxy); const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; const apiRoot = normalizeOptionalString(account.config.apiRoot); + const normalizedApiRoot = apiRoot ? normalizeTelegramApiRoot(apiRoot) : undefined; const fetchImpl = resolveTelegramFetch(proxyFetch, { network: account.config.network, }); const clientOptions = - fetchImpl || timeoutSeconds || apiRoot + fetchImpl || timeoutSeconds || normalizedApiRoot ? { ...(fetchImpl ? { fetch: asTelegramClientFetch(fetchImpl) } : {}), ...(timeoutSeconds ? { timeoutSeconds } : {}), - ...(apiRoot ? { apiRoot } : {}), + ...(normalizedApiRoot ? { apiRoot: normalizedApiRoot } : {}), } : undefined; if (cacheKey) { diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 7843f16c159..129505c987b 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -7371,6 +7371,25 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ { type: "boolean", }, + { + type: "object", + properties: { + mode: { + type: "string", + enum: ["partial", "quiet", "off"], + }, + preview: { + type: "object", + properties: { + toolProgress: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, ], }, replyToMode: { @@ -10010,6 +10029,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "string", enum: ["off", "partial"], }, + c2cStreamApi: { + type: "boolean", + }, }, required: ["mode"], additionalProperties: {}, @@ -10250,6 +10272,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "string", enum: ["off", "partial"], }, + c2cStreamApi: { + type: "boolean", + }, }, required: ["mode"], additionalProperties: {}, @@ -15152,7 +15177,7 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, apiRoot: { label: "Telegram API Root URL", - help: "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.", + help: "Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. 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.", }, trustedLocalFileRoots: { label: "Telegram Trusted Local File Roots", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 8b7c7b01f4a..7c6857069e3 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -212,7 +212,7 @@ 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). */ + /** Custom Telegram Bot API root URL (e.g. "https://my-proxy.example.com" or a local Bot API server), not a /bot endpoint. */ apiRoot?: string; /** Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. */ trustedLocalFileRoots?: string[];