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) ?? "";