From 042d06a19b02d51edba6d4bed6907f249bd28a38 Mon Sep 17 00:00:00 2001 From: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:30:26 +0800 Subject: [PATCH] Telegram: stop bot on polling teardown --- CHANGELOG.md | 1 + src/telegram/monitor.test.ts | 25 ++++++++++++++++++++++++- src/telegram/monitor.ts | 8 ++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e34e46952..c64c389502d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,6 +122,7 @@ Docs: https://docs.openclaw.ai - Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting. - Security/Workspace safe writes: harden `writeFileWithinRoot` against symlink-retarget TOCTOU races by opening existing files without truncation, creating missing files with exclusive create, deferring truncation until post-open identity+boundary validation, and removing out-of-root create artifacts on blocked races; added regression tests for truncate/create race paths. This ships in the next npm release (`2026.3.1`). Thanks @tdjackey for reporting. - Control UI/Cron editor: include `{ mode: "none" }` in `cron.update` patches when editing an existing job and selecting “Result delivery = None (internal)”, so saved jobs no longer keep stale announce delivery mode. Fixes #31075. +- Telegram/Restart polling teardown: stop the Telegram bot instance when a polling cycle exits so in-process SIGUSR1 restarts fully tear down old long-poll loops before restart, reducing post-restart `getUpdates` 409 conflict storms. Fixes #31107. - Security/Node metadata policy: harden node platform classification against Unicode confusables and switch unknown platform defaults to a conservative allowlist that excludes `system.run`/`system.which` unless explicitly allowlisted, preventing metadata canonicalization drift from broadening node command permissions. Thanks @tdjackey for reporting. - Plugins/Discovery precedence: load bundled plugins before auto-discovered global extensions so bundled channel plugins win duplicate-ID resolution by default (explicit `plugins.load.paths` overrides remain highest precedence), with loader regression coverage. Landed from contributor PR #29710 by @Sid-Qin. Thanks @Sid-Qin. - Discord/Reconnect integrity: release Discord message listener lane immediately while preserving serialized handler execution, add HELLO-stall resume-first recovery with bounded fresh-identify fallback after repeated stalls, and extend lifecycle/listener regression coverage for forced reconnect scenarios. Landed from contributor PR #29508 by @cgdusek. Thanks @cgdusek. diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index 5c0df3de6ef..afcb4994379 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -59,6 +59,10 @@ const { createTelegramBotErrors } = vi.hoisted(() => ({ createTelegramBotErrors: [] as unknown[], })); +const { createdBotStops } = vi.hoisted(() => ({ + createdBotStops: [] as Array void>>>, +})); + const { computeBackoff, sleepWithAbort } = vi.hoisted(() => ({ computeBackoff: vi.fn(() => 0), sleepWithAbort: vi.fn(async () => undefined), @@ -111,6 +115,8 @@ vi.mock("./bot.js", () => ({ if (nextError) { throw nextError; } + const stop = vi.fn<() => void>(); + createdBotStops.push(stop); handlers.message = async (ctx: MockCtx) => { const chatId = ctx.message.chat.id; const isGroup = ctx.message.chat.type !== "private"; @@ -128,7 +134,7 @@ vi.mock("./bot.js", () => ({ api, me: { username: "mybot" }, init: initSpy, - stop: vi.fn(), + stop, start: vi.fn(), }; }, @@ -179,6 +185,7 @@ describe("monitorTelegramProvider (grammY)", () => { registerUnhandledRejectionHandlerMock.mockClear(); resetUnhandledRejection(); createTelegramBotErrors.length = 0; + createdBotStops.length = 0; consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); }); @@ -382,6 +389,22 @@ describe("monitorTelegramProvider (grammY)", () => { expect(runSpy).toHaveBeenCalledTimes(2); }); + it("stops bot instance when polling cycle exits", async () => { + const abort = new AbortController(); + runSpy.mockImplementationOnce(() => + makeRunnerStub({ + task: async () => { + abort.abort(); + }, + }), + ); + + await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); + + expect(createdBotStops.length).toBe(1); + expect(createdBotStops[0]).toHaveBeenCalledTimes(1); + }); + it("surfaces non-recoverable errors", async () => { runSpy.mockImplementationOnce(() => makeRunnerStub({ diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 06410b74ed1..7b252cf6b8f 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -270,6 +270,13 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { }); return stopPromise; }; + const stopBot = () => { + return Promise.resolve(bot.stop()) + .then(() => undefined) + .catch(() => { + // Bot may already be stopped by runner stop/abort paths. + }); + }; const stopOnAbort = () => { if (opts.abortSignal?.aborted) { void stopRunner(); @@ -309,6 +316,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { } finally { opts.abortSignal?.removeEventListener("abort", stopOnAbort); await stopRunner(); + await stopBot(); } };