diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 46f8527725b..1ba1beca569 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -10,7 +10,14 @@ import { const createTelegramDraftStream = vi.hoisted(() => vi.fn()); const dispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => vi.fn()); const deliverReplies = vi.hoisted(() => vi.fn()); +const createForumTopicTelegram = vi.hoisted(() => vi.fn()); +const deleteMessageTelegram = vi.hoisted(() => vi.fn()); +const editForumTopicTelegram = vi.hoisted(() => vi.fn()); const editMessageTelegram = vi.hoisted(() => vi.fn()); +const reactMessageTelegram = vi.hoisted(() => vi.fn()); +const sendMessageTelegram = vi.hoisted(() => vi.fn()); +const sendPollTelegram = vi.hoisted(() => vi.fn()); +const sendStickerTelegram = vi.hoisted(() => vi.fn()); const loadSessionStore = vi.hoisted(() => vi.fn()); const resolveStorePath = vi.hoisted(() => vi.fn(() => "/tmp/sessions.json")); @@ -18,23 +25,30 @@ vi.mock("./draft-stream.js", () => ({ createTelegramDraftStream, })); -vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ - dispatchReplyWithBufferedBlockDispatcher, -})); +vi.mock("./bot-deps.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + defaultTelegramBotDeps: { + ...actual.defaultTelegramBotDeps, + dispatchReplyWithBufferedBlockDispatcher, + }, + }; +}); vi.mock("./bot/delivery.js", () => ({ deliverReplies, })); vi.mock("./send.js", () => ({ - createForumTopicTelegram: vi.fn(), - deleteMessageTelegram: vi.fn(), - editForumTopicTelegram: vi.fn(), + createForumTopicTelegram, + deleteMessageTelegram, + editForumTopicTelegram, editMessageTelegram, - reactMessageTelegram: vi.fn(), - sendMessageTelegram: vi.fn(), - sendPollTelegram: vi.fn(), - sendStickerTelegram: vi.fn(), + reactMessageTelegram, + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, })); vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 027b9d12cc7..ee5d7afceb3 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -13,7 +13,6 @@ const { commandSpy, dispatchReplyWithBufferedBlockDispatcher, getLoadConfigMock, - getLoadWebMediaMock, getOnHandler, getReadChannelAllowFromStoreMock, getUpsertChannelPairingRequestMock, @@ -51,7 +50,6 @@ const createTelegramBot = (opts: Parameters[0]) => }); const loadConfig = getLoadConfigMock(); -const loadWebMedia = getLoadWebMediaMock(); const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); const upsertChannelPairingRequest = getUpsertChannelPairingRequestMock(); @@ -61,6 +59,30 @@ const TELEGRAM_TEST_TIMINGS = { textFragmentGapMs: 30, } as const; +async function withIsolatedStateDirAsync(fn: () => Promise): Promise { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-state-")); + return await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + try { + return await fn(); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); +} + +async function withConfigPathAsync(cfg: unknown, fn: () => Promise): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-cfg-")); + const configPath = path.join(dir, "openclaw.json"); + fs.writeFileSync(configPath, JSON.stringify(cfg), "utf-8"); + return await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath }, async () => { + try { + return await fn(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +} + describe("createTelegramBot", () => { beforeAll(() => { process.env.TZ = "UTC"; @@ -199,107 +221,102 @@ describe("createTelegramBot", () => { const cases = [ { name: "new unknown sender", - upsertResults: [{ code: "PAIRME12", created: true }], messages: ["hello"], expectedSendCount: 1, - expectPairingText: true, }, { name: "already pending request", - upsertResults: [ - { code: "PAIRME12", created: true }, - { code: "PAIRME12", created: false }, - ], messages: ["hello", "hello again"], expectedSendCount: 1, - expectPairingText: false, }, ] as const; - for (const testCase of cases) { - onSpy.mockClear(); - sendMessageSpy.mockClear(); - replySpy.mockClear(); + await withIsolatedStateDirAsync(async () => { + for (const [index, testCase] of cases.entries()) { + onSpy.mockClear(); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readChannelAllowFromStore.mockResolvedValue([]); + upsertChannelPairingRequest.mockClear(); + upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRCODE", created: true }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const senderId = Number(`${Date.now()}${index}`.slice(-9)); + for (const text of testCase.messages) { + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text, + date: 1736380800, + from: { id: senderId, username: "random" }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + } + + expect(replySpy, testCase.name).not.toHaveBeenCalled(); + expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount); + expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234); + const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]); + expect(pairingText, testCase.name).toContain(`Your Telegram user id: ${senderId}`); + expect(pairingText, testCase.name).toContain("Pairing code:"); + const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1]; + expect(code, testCase.name).toBeDefined(); + expect(pairingText, testCase.name).toContain(`openclaw pairing approve telegram ${code}`); + expect(pairingText, testCase.name).not.toContain(""); + } + }); + }); + it("blocks unauthorized DM media before download and sends pairing reply", async () => { + await withIsolatedStateDirAsync(async () => { loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "pairing" } }, }); readChannelAllowFromStore.mockResolvedValue([]); - upsertChannelPairingRequest.mockClear(); - upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRCODE", created: true }); - for (const result of testCase.upsertResults) { - upsertChannelPairingRequest.mockResolvedValueOnce(result); - } + upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + const senderId = Number(`${Date.now()}01`.slice(-9)); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); + + try { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - for (const text of testCase.messages) { await handler({ message: { chat: { id: 1234, type: "private" }, - text, + message_id: 410, date: 1736380800, - from: { id: 999, username: "random" }, + photo: [{ file_id: "p1" }], + from: { id: senderId, username: "random" }, }, me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), + getFile: getFileSpy, }); - } - expect(replySpy, testCase.name).not.toHaveBeenCalled(); - expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount); - if (testCase.expectPairingText) { - expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234); - const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]); - expect(pairingText, testCase.name).toContain("Your Telegram user id: 999"); - expect(pairingText, testCase.name).toContain("Pairing code:"); - expect(pairingText, testCase.name).toContain("PAIRME12"); - expect(pairingText, testCase.name).toContain("openclaw pairing approve telegram PAIRME12"); - expect(pairingText, testCase.name).not.toContain(""); + expect(getFileSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); } - } - }); - it("blocks unauthorized DM media before download and sends pairing reply", async () => { - loadConfig.mockReturnValue({ - channels: { telegram: { dmPolicy: "pairing" } }, }); - readChannelAllowFromStore.mockResolvedValue([]); - upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); - sendMessageSpy.mockClear(); - replySpy.mockClear(); - - const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( - async () => - new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { - status: 200, - headers: { "content-type": "image/jpeg" }, - }), - ); - const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); - - try { - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 1234, type: "private" }, - message_id: 410, - date: 1736380800, - photo: [{ file_id: "p1" }], - from: { id: 999, username: "random" }, - }, - me: { username: "openclaw_bot" }, - getFile: getFileSpy, - }); - - expect(getFileSpy).not.toHaveBeenCalled(); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); - expect(replySpy).not.toHaveBeenCalled(); - } finally { - fetchSpy.mockRestore(); - } }); it("blocks DM media downloads completely when dmPolicy is disabled", async () => { loadConfig.mockReturnValue({ @@ -342,48 +359,51 @@ describe("createTelegramBot", () => { } }); it("blocks unauthorized DM media groups before any photo download", async () => { - loadConfig.mockReturnValue({ - channels: { telegram: { dmPolicy: "pairing" } }, - }); - readChannelAllowFromStore.mockResolvedValue([]); - upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); - sendMessageSpy.mockClear(); - replySpy.mockClear(); - - const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( - async () => - new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { - status: 200, - headers: { "content-type": "image/jpeg" }, - }), - ); - const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); - - try { - createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 1234, type: "private" }, - message_id: 412, - media_group_id: "dm-album-1", - date: 1736380800, - photo: [{ file_id: "p1" }], - from: { id: 999, username: "random" }, - }, - me: { username: "openclaw_bot" }, - getFile: getFileSpy, + await withIsolatedStateDirAsync(async () => { + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, }); + readChannelAllowFromStore.mockResolvedValue([]); + upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + const senderId = Number(`${Date.now()}02`.slice(-9)); - expect(getFileSpy).not.toHaveBeenCalled(); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); - expect(replySpy).not.toHaveBeenCalled(); - } finally { - fetchSpy.mockRestore(); - } + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); + + try { + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + message_id: 412, + media_group_id: "dm-album-1", + date: 1736380800, + photo: [{ file_id: "p1" }], + from: { id: senderId, username: "random" }, + }, + me: { username: "openclaw_bot" }, + getFile: getFileSpy, + }); + + expect(getFileSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); }); it("triggers typing cue via onReplyStart", async () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( @@ -800,13 +820,15 @@ describe("createTelegramBot", () => { }); it("routes DMs by telegram accountId binding", async () => { - loadConfig.mockReturnValue({ + const config = { channels: { telegram: { + allowFrom: ["*"], accounts: { opie: { botToken: "tok-opie", dmPolicy: "open", + allowFrom: ["*"], }, }, }, @@ -817,27 +839,30 @@ describe("createTelegramBot", () => { match: { channel: "telegram", accountId: "opie" }, }, ], + }; + loadConfig.mockReturnValue(config); + + await withConfigPathAsync(config, async () => { + createTelegramBot({ token: "tok", accountId: "opie" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "private" }, + from: { id: 999, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.AccountId).toBe("opie"); + expect(payload.SessionKey).toBe("agent:opie:main"); }); - - createTelegramBot({ token: "tok", accountId: "opie" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123, type: "private" }, - from: { id: 999, username: "testuser" }, - text: "hello", - date: 1736380800, - message_id: 42, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.AccountId).toBe("opie"); - expect(payload.SessionKey).toBe("agent:opie:main"); }); it("routes non-default account DMs to the per-account fallback session without explicit bindings", async () => { @@ -1036,26 +1061,28 @@ describe("createTelegramBot", () => { ]; for (const testCase of cases) { - resetHarnessSpies(); - loadConfig.mockReturnValue(testCase.config); - await dispatchMessage({ - message: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, + await withConfigPathAsync(testCase.config, async () => { + resetHarnessSpies(); + loadConfig.mockReturnValue(testCase.config); + await dispatchMessage({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 999, username: "testuser" }, + text: testCase.text, + date: 1736380800, + message_id: 42, + message_thread_id: 99, }, - from: { id: 999, username: "testuser" }, - text: testCase.text, - date: 1736380800, - message_id: 42, - message_thread_id: 99, - }, + }); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment); }); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment); } }); @@ -1064,35 +1091,38 @@ describe("createTelegramBot", () => { text: "caption", mediaUrl: "https://example.com/fun", }); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(Buffer.from("GIF89a"), { + status: 200, + headers: { "content-type": "image/gif" }, + }), + ); + try { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("GIF89a"), - contentType: "image/gif", - fileName: "fun.gif", - }); + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text: "hello world", + date: 1736380800, + message_id: 5, + from: { first_name: "Ada" }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 1234, type: "private" }, - text: "hello world", - date: 1736380800, - message_id: 5, - from: { first_name: "Ada" }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(sendAnimationSpy).toHaveBeenCalledTimes(1); - expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), { - caption: "caption", - parse_mode: "HTML", - reply_to_message_id: undefined, - }); - expect(sendPhotoSpy).not.toHaveBeenCalled(); + expect(sendAnimationSpy).toHaveBeenCalledTimes(1); + expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), { + caption: "caption", + parse_mode: "HTML", + reply_to_message_id: undefined, + }); + expect(sendPhotoSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } }); function resetHarnessSpies() { @@ -1746,7 +1776,7 @@ describe("createTelegramBot", () => { }), "utf-8", ); - loadConfig.mockReturnValue({ + const config = { channels: { telegram: { groupPolicy: "open", @@ -1763,23 +1793,26 @@ describe("createTelegramBot", () => { }, ], session: { store: storePath }, + }; + loadConfig.mockReturnValue(config); + + await withConfigPathAsync(config, async () => { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Routing" }, + from: { id: 999, username: "ops" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123, type: "group", title: "Routing" }, - from: { id: 999, username: "ops" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); }); it("applies topic skill filters and system prompts", async () => {