From ed08d1352b8bc7ca664e49426eb0cec2cedf0855 Mon Sep 17 00:00:00 2001 From: Peter Lee Date: Fri, 14 Aug 2026 07:37:31 -0500 Subject: [PATCH] fix(telegram): reject webhook and health route collisions (#119268) Reject reserved /healthz webhook configurations before registration, preventing silent Telegram update loss. Warn through Doctor and document recovery. Co-authored-by: Peter Lee <22994703+xialonglee@users.noreply.github.com> Co-authored-by: Ayaan Zaidi --- docs/channels/telegram.md | 2 + extensions/telegram/src/doctor.test.ts | 56 +++++++++++++++++++++++++ extensions/telegram/src/doctor.ts | 7 ++++ extensions/telegram/src/webhook.test.ts | 7 ++++ extensions/telegram/src/webhook.ts | 3 ++ 5 files changed, 75 insertions(+) diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index afee35d93d56..9bde84f6b90a 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -796,6 +796,8 @@ curl "https://api.telegram.org/bot/getUpdates" Default is long polling. For webhook mode, set `channels.telegram.webhookUrl` and `channels.telegram.webhookSecret`; optional `webhookPath` (default `/telegram-webhook`), `webhookHost` (default `127.0.0.1`), `webhookPort` (default `8787`), `webhookCertPath` (self-signed cert PEM for direct-IP or no-domain setups). + The listener reserves `/healthz` for health checks, so `webhookPath` must use a different route. If an existing setup uses `/healthz`, choose another route, update the path in `webhookUrl` and the reverse proxy mapping, then restart OpenClaw. + In long-polling mode, OpenClaw persists its restart watermark only after an update dispatches successfully; a failed handler leaves that update retryable in the same process instead of marking it completed. The local listener binds to `127.0.0.1:8787` by default. For public ingress, put a reverse proxy in front of the local port, or set `webhookHost: "0.0.0.0"` intentionally. diff --git a/extensions/telegram/src/doctor.test.ts b/extensions/telegram/src/doctor.test.ts index 5a7a4d69c341..b0440cb8c0cc 100644 --- a/extensions/telegram/src/doctor.test.ts +++ b/extensions/telegram/src/doctor.test.ts @@ -534,6 +534,62 @@ describe("telegram doctor", () => { expect(warnings[1]).toContain(DOCTOR_FIX_COMMAND); }); + it("warns only when a selected webhook account uses the reserved health path", async () => { + listTelegramAccountIdsMock.mockReturnValue(["ops"]); + const cfg = { + channels: { + telegram: { + enabled: true, + webhookUrl: "https://example.test/healthz", + webhookPath: "/healthz", + accounts: { + ops: { + botToken: "123:abc", + webhookUrl: "https://example.test/ops", + webhookPath: "/ops", + }, + }, + }, + }, + } satisfies OpenClawConfig; + + expect((await collectPreviewWarnings(cfg)).join("\n")).not.toContain("reserved"); + + cfg.channels.telegram.accounts.ops.webhookUrl = "https://example.test/healthz"; + cfg.channels.telegram.accounts.ops.webhookPath = "/healthz"; + + expect((await collectPreviewWarnings(cfg)).join("\n")).toContain( + 'Telegram account "ops" resolves webhookPath to /healthz, which is reserved', + ); + + const disabledCfg = { + ...cfg, + channels: { telegram: { ...cfg.channels.telegram, enabled: false } }, + } satisfies OpenClawConfig; + expect((await collectPreviewWarnings(disabledCfg)).join("\n")).not.toContain("reserved"); + }); + + it("identifies an explicit default account in the webhook path warning", async () => { + listTelegramAccountIdsMock.mockReturnValue(["default"]); + const cfg = { + channels: { + telegram: { + accounts: { + default: { + botToken: "123:abc", + webhookUrl: "https://example.test/healthz", + webhookPath: "/healthz", + }, + }, + }, + }, + } satisfies OpenClawConfig; + + expect((await collectPreviewWarnings(cfg)).join("\n")).toContain( + 'Telegram account "default" resolves webhookPath to /healthz, which is reserved', + ); + }); + it("warns and repairs Telegram apiRoot values that include the bot endpoint", async () => { const cfg = { channels: { diff --git a/extensions/telegram/src/doctor.ts b/extensions/telegram/src/doctor.ts index 95d02481424d..405ecbba67e8 100644 --- a/extensions/telegram/src/doctor.ts +++ b/extensions/telegram/src/doctor.ts @@ -16,6 +16,7 @@ import { import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { inspectTelegramAccount } from "./account-inspect.js"; import { + listEnabledTelegramAccounts, listTelegramAccountIds, mergeTelegramAccountConfig, resolveDefaultTelegramAccountId, @@ -584,6 +585,12 @@ export const telegramDoctor: ChannelDoctorAdapter = { hits: scanTelegramBotEndpointApiRoots(cfg), doctorFixCommand, }), + ...listEnabledTelegramAccounts(cfg) + .filter(({ config }) => Boolean(config.webhookUrl) && config.webhookPath === "/healthz") + .map( + ({ accountId }) => + `- Telegram account "${accountId}" resolves webhookPath to /healthz, which is reserved for webhook listener health checks. Change webhookPath and the public webhook URL or proxy route before restarting OpenClaw.`, + ), ...collectTelegramSelectedQuoteToolProgressWarnings({ hits: scanTelegramSelectedQuoteToolProgressWarnings(cfg), }), diff --git a/extensions/telegram/src/webhook.test.ts b/extensions/telegram/src/webhook.test.ts index 2d8f54c060ef..30a8209080dc 100644 --- a/extensions/telegram/src/webhook.test.ts +++ b/extensions/telegram/src/webhook.test.ts @@ -2609,6 +2609,13 @@ describe("startTelegramWebhook", () => { ).rejects.toThrow(/requires a non-empty secret token/i); }); + it("rejects startup when the webhook path collides with the health path", async () => { + await expect( + withStartedWebhook({ secret: TELEGRAM_SECRET, path: "/healthz" }, async () => undefined), + ).rejects.toThrow(/webhook path.*conflicts with.*health/i); + expect(setWebhookSpy).not.toHaveBeenCalled(); + }); + it("registers webhook using the bound listening port when port is 0", async () => { setWebhookSpy.mockClear(); const runtimeLog = vi.fn(); diff --git a/extensions/telegram/src/webhook.ts b/extensions/telegram/src/webhook.ts index cb7e22e6c433..f138307c6312 100644 --- a/extensions/telegram/src/webhook.ts +++ b/extensions/telegram/src/webhook.ts @@ -314,6 +314,9 @@ export async function startTelegramWebhook(opts: { }) { const path = opts.path ?? "/telegram-webhook"; const healthPath = opts.healthPath ?? "/healthz"; + if (path === healthPath) { + throw new Error(`Telegram webhook path "${path}" conflicts with the health path.`); + } const port = opts.port ?? 8787; const host = opts.host ?? "127.0.0.1"; const secret = normalizeOptionalString(opts.secret) ?? "";