mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
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 <hi@obviy.us>
This commit is contained in:
@@ -796,6 +796,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
<Accordion title="Long polling vs webhook">
|
||||
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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) ?? "";
|
||||
|
||||
Reference in New Issue
Block a user