mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
fix(telegram): normalize bot endpoint api roots
This commit is contained in:
@@ -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<TOKEN>` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -364,6 +364,7 @@ curl "https://api.telegram.org/bot<bot_token>/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<TOKEN>` endpoint. `apiRoot` must be only the Bot API root, and `openclaw doctor --fix` removes an accidental trailing `/bot<TOKEN>`.
|
||||
- `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<TOKEN>`)
|
||||
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
|
||||
- actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker`
|
||||
- reactions: `reactionNotifications`, `reactionLevel`
|
||||
|
||||
@@ -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<TOKEN>`; `openclaw doctor --fix` removes an accidental trailing `/bot<TOKEN>` 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`).
|
||||
|
||||
38
extensions/telegram/src/api-root.test.ts
Normal file
38
extensions/telegram/src/api-root.test.ts
Normal file
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
49
extensions/telegram/src/api-root.ts
Normal file
49
extensions/telegram/src/api-root.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TOKEN> 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",
|
||||
|
||||
@@ -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<TOKEN> from Telegram apiRoot.",
|
||||
"- channels.telegram.accounts.work.apiRoot: removed trailing /bot<TOKEN> 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<TOKEN> from Telegram apiRoot.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string | number>;
|
||||
type DoctorAccountRecord = Record<string, unknown>;
|
||||
|
||||
@@ -40,13 +47,21 @@ function hasAllowFromEntries(values?: DoctorAllowFromList): boolean {
|
||||
|
||||
function collectTelegramAccountScopes(
|
||||
cfg: OpenClawConfig,
|
||||
): Array<{ prefix: string; account: Record<string, unknown> }> {
|
||||
const scopes: Array<{ prefix: string; account: Record<string, unknown> }> = [];
|
||||
): Array<{ prefix: string; pathSegments: string[]; account: Record<string, unknown> }> {
|
||||
const scopes: Array<{
|
||||
prefix: string;
|
||||
pathSegments: string[];
|
||||
account: Record<string, unknown>;
|
||||
}> = [];
|
||||
const telegram = asObjectRecord((cfg.channels as Record<string, unknown> | 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<TOKEN> 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<string, unknown> | null = next as Record<string, unknown>;
|
||||
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<TOKEN> 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",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<TOKEN> 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",
|
||||
|
||||
@@ -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<TOKEN> endpoint. */
|
||||
apiRoot?: string;
|
||||
/** Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. */
|
||||
trustedLocalFileRoots?: string[];
|
||||
|
||||
Reference in New Issue
Block a user