diff --git a/CHANGELOG.md b/CHANGELOG.md index 5151bdff365..ae4be395195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -285,6 +285,7 @@ Docs: https://docs.openclaw.ai - Gateway/systemd service restart hardening: clear stale gateway listeners by explicit run-port before service bind, add restart stale-pid port-override support, tune systemd start/stop/exit handling, and disable detached child mode only in service-managed runtime so cgroup stop semantics clean up descendants reliably. (#38463) Thanks @spirittechie. - Discord/plugin native command aliases: let plugins declare provider-specific slash names so native Discord registration can avoid built-in command collisions; the bundled Talk voice plugin now uses `/talkvoice` natively on Discord while keeping text `/voice`. - Daemon/Windows schtasks status normalization: derive runtime state from locale-neutral numeric `Last Run Result` codes only (without language string matching) and surface unknown when numeric result data is unavailable, preventing locale-specific misclassification drift. (#39153) Thanks @scoootscooob. +- Telegram/polling conflict recovery: reset the polling `webhookCleared` latch on `getUpdates` 409 conflicts so webhook cleanup re-runs on restart cycles and polling avoids infinite conflict loops. (#39205) Thanks @amittell. ## 2026.3.2 diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index b5f072ebfd1..d5dc43c5335 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -591,6 +591,44 @@ describe("monitorTelegramProvider (grammY)", () => { expect(api.getUpdates).not.toHaveBeenCalled(); }); + it("resets webhookCleared latch on 409 conflict so deleteWebhook re-runs", async () => { + const abort = new AbortController(); + api.deleteWebhook.mockReset(); + api.deleteWebhook.mockResolvedValue(true); + + const conflictError = Object.assign( + new Error("Conflict: terminated by other getUpdates request"), + { + error_code: 409, + method: "getUpdates", + }, + ); + + let pollingCycle = 0; + runSpy + // First cycle: throw 409 conflict + .mockImplementationOnce(() => + makeRunnerStub({ + task: () => { + pollingCycle++; + return Promise.reject(conflictError); + }, + }), + ) + // Second cycle: succeed then abort + .mockImplementationOnce(() => { + pollingCycle++; + return makeAbortRunner(abort); + }); + + await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); + + // deleteWebhook should be called twice: once on initial cleanup, once after 409 reset + expect(api.deleteWebhook).toHaveBeenCalledTimes(2); + expect(pollingCycle).toBe(2); + expect(runSpy).toHaveBeenCalledTimes(2); + }); + it("falls back to configured webhookSecret when not passed explicitly", async () => { await monitorTelegramProvider({ token: "tok", diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 16c2db551b4..c4e12959953 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -373,6 +373,9 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { throw err; } const isConflict = isGetUpdatesConflict(err); + if (isConflict) { + webhookCleared = false; + } const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" }); if (!isConflict && !isRecoverable) { throw err;