diff --git a/src/commands/channels.adds-non-default-telegram-account.e2e.test.ts b/src/commands/channels.adds-non-default-telegram-account.e2e.test.ts index c73120c39d4..5f276d2807e 100644 --- a/src/commands/channels.adds-non-default-telegram-account.e2e.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.e2e.test.ts @@ -11,6 +11,10 @@ const authMocks = vi.hoisted(() => ({ loadAuthProfileStore: vi.fn(), })); +const offsetMocks = vi.hoisted(() => ({ + deleteTelegramUpdateOffset: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -28,6 +32,14 @@ vi.mock("../agents/auth-profiles.js", async (importOriginal) => { }; }); +vi.mock("../telegram/update-offset-store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset, + }; +}); + import { channelsAddCommand, channelsListCommand, @@ -42,6 +54,7 @@ describe("channels command", () => { configMocks.readConfigFileSnapshot.mockReset(); configMocks.writeConfigFile.mockClear(); authMocks.loadAuthProfileStore.mockReset(); + offsetMocks.deleteTelegramUpdateOffset.mockClear(); runtime.log.mockClear(); runtime.error.mockClear(); runtime.exit.mockClear(); @@ -456,4 +469,70 @@ describe("channels command", () => { }); expect(disconnected.join("\n")).toMatch(/disconnected/i); }); + + it("cleans up telegram update offset when deleting a telegram account", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + telegram: { botToken: "123:abc", enabled: true }, + }, + }, + }); + + await channelsRemoveCommand( + { channel: "telegram", account: "default", delete: true }, + runtime, + { + hasFlags: true, + }, + ); + + expect(offsetMocks.deleteTelegramUpdateOffset).toHaveBeenCalledWith({ accountId: "default" }); + }); + + it("does not clean up offset when deleting a non-telegram channel", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + discord: { + accounts: { + default: { token: "d0" }, + }, + }, + }, + }, + }); + + await channelsRemoveCommand({ channel: "discord", account: "default", delete: true }, runtime, { + hasFlags: true, + }); + + expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled(); + }); + + it("does not clean up offset when disabling (not deleting) a telegram account", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + telegram: { botToken: "123:abc", enabled: true }, + }, + }, + }); + + const prompt = { confirm: vi.fn().mockResolvedValue(true) }; + const prompterModule = await import("../wizard/clack-prompter.js"); + const promptSpy = vi + .spyOn(prompterModule, "createClackPrompter") + .mockReturnValue(prompt as never); + + await channelsRemoveCommand({ channel: "telegram", account: "default" }, runtime, { + hasFlags: true, + }); + + expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled(); + promptSpy.mockRestore(); + }); }); diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index b0700c5566b..5766a4250fd 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -7,6 +7,7 @@ import { import { type OpenClawConfig, writeConfigFile } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; +import { deleteTelegramUpdateOffset } from "../../telegram/update-offset-store.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { type ChatChannel, channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; @@ -112,6 +113,11 @@ export async function channelsRemoveCommand( cfg: next, accountId: resolvedAccountId, }); + + // Clean up Telegram polling offset to prevent stale offset on bot token change (#18233) + if (channel === "telegram") { + await deleteTelegramUpdateOffset({ accountId: resolvedAccountId }); + } } else { if (!plugin.config.setAccountEnabled) { runtime.error(`Channel ${channel} does not support disable.`); diff --git a/src/telegram/update-offset-store.test.ts b/src/telegram/update-offset-store.test.ts new file mode 100644 index 00000000000..38fd9753d7c --- /dev/null +++ b/src/telegram/update-offset-store.test.ts @@ -0,0 +1,55 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + deleteTelegramUpdateOffset, + readTelegramUpdateOffset, + writeTelegramUpdateOffset, +} from "./update-offset-store.js"; + +async function withTempStateDir(fn: (dir: string) => Promise) { + const previous = process.env.OPENCLAW_STATE_DIR; + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tg-offset-")); + process.env.OPENCLAW_STATE_DIR = dir; + try { + return await fn(dir); + } finally { + if (previous === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previous; + } + await fs.rm(dir, { recursive: true, force: true }); + } +} + +describe("deleteTelegramUpdateOffset", () => { + it("removes the offset file so a new bot starts fresh", async () => { + await withTempStateDir(async () => { + await writeTelegramUpdateOffset({ accountId: "default", updateId: 432_000_000 }); + expect(await readTelegramUpdateOffset({ accountId: "default" })).toBe(432_000_000); + + await deleteTelegramUpdateOffset({ accountId: "default" }); + expect(await readTelegramUpdateOffset({ accountId: "default" })).toBeNull(); + }); + }); + + it("does not throw when the offset file does not exist", async () => { + await withTempStateDir(async () => { + await expect(deleteTelegramUpdateOffset({ accountId: "nonexistent" })).resolves.not.toThrow(); + }); + }); + + it("only removes the targeted account offset, leaving others intact", async () => { + await withTempStateDir(async () => { + await writeTelegramUpdateOffset({ accountId: "default", updateId: 100 }); + await writeTelegramUpdateOffset({ accountId: "alerts", updateId: 200 }); + + await deleteTelegramUpdateOffset({ accountId: "default" }); + + expect(await readTelegramUpdateOffset({ accountId: "default" })).toBeNull(); + expect(await readTelegramUpdateOffset({ accountId: "alerts" })).toBe(200); + }); + }); +}); diff --git a/src/telegram/update-offset-store.ts b/src/telegram/update-offset-store.ts index 6597fa25c3c..6000c4d1443 100644 --- a/src/telegram/update-offset-store.ts +++ b/src/telegram/update-offset-store.ts @@ -80,3 +80,19 @@ export async function writeTelegramUpdateOffset(params: { await fs.chmod(tmp, 0o600); await fs.rename(tmp, filePath); } + +export async function deleteTelegramUpdateOffset(params: { + accountId?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const filePath = resolveTelegramUpdateOffsetPath(params.accountId, params.env); + try { + await fs.unlink(filePath); + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return; + } + throw err; + } +}