diff --git a/src/telegram/bot.create-telegram-bot.test-harness.ts b/src/telegram/bot.create-telegram-bot.test-harness.ts index 76a658648e6..b95341a77e0 100644 --- a/src/telegram/bot.create-telegram-bot.test-harness.ts +++ b/src/telegram/bot.create-telegram-bot.test-harness.ts @@ -76,6 +76,35 @@ vi.mock("../pairing/pairing-store.js", () => ({ upsertChannelPairingRequest, })); +const skillCommandsHoisted = vi.hoisted(() => ({ + listSkillCommandsForAgents: vi.fn(() => []), +})); +export const listSkillCommandsForAgents = skillCommandsHoisted.listSkillCommandsForAgents; + +vi.mock("../auto-reply/skill-commands.js", () => ({ + listSkillCommandsForAgents, +})); + +const systemEventsHoisted = vi.hoisted(() => ({ + enqueueSystemEventSpy: vi.fn(), +})); +export const enqueueSystemEventSpy = systemEventsHoisted.enqueueSystemEventSpy; + +vi.mock("../infra/system-events.js", () => ({ + enqueueSystemEvent: enqueueSystemEventSpy, +})); + +const sentMessageCacheHoisted = vi.hoisted(() => ({ + wasSentByBot: vi.fn(() => false), +})); +export const wasSentByBot = sentMessageCacheHoisted.wasSentByBot; + +vi.mock("./sent-message-cache.js", () => ({ + wasSentByBot, + recordSentMessage: vi.fn(), + clearSentMessageCache: vi.fn(), +})); + export const useSpy: MockFn<(arg: unknown) => void> = vi.fn(); export const middlewareUseSpy: AnyMock = vi.fn(); export const onSpy: AnyMock = vi.fn(); @@ -84,9 +113,9 @@ export const commandSpy: AnyMock = vi.fn(); export const botCtorSpy: AnyMock = vi.fn(); export const answerCallbackQuerySpy: AnyAsyncMock = vi.fn(async () => undefined); export const sendChatActionSpy: AnyMock = vi.fn(); +export const editMessageTextSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 })); export const setMessageReactionSpy: AnyAsyncMock = vi.fn(async () => undefined); export const setMyCommandsSpy: AnyAsyncMock = vi.fn(async () => undefined); -export const deleteMyCommandsSpy: AnyAsyncMock = vi.fn(async () => undefined); export const getMeSpy: AnyAsyncMock = vi.fn(async () => ({ username: "openclaw_bot", has_topics_enabled: true, @@ -99,9 +128,9 @@ type ApiStub = { config: { use: (arg: unknown) => void }; answerCallbackQuery: typeof answerCallbackQuerySpy; sendChatAction: typeof sendChatActionSpy; + editMessageText: typeof editMessageTextSpy; setMessageReaction: typeof setMessageReactionSpy; setMyCommands: typeof setMyCommandsSpy; - deleteMyCommands: typeof deleteMyCommandsSpy; getMe: typeof getMeSpy; sendMessage: typeof sendMessageSpy; sendAnimation: typeof sendAnimationSpy; @@ -112,9 +141,9 @@ const apiStub: ApiStub = { config: { use: useSpy }, answerCallbackQuery: answerCallbackQuerySpy, sendChatAction: sendChatActionSpy, + editMessageText: editMessageTextSpy, setMessageReaction: setMessageReactionSpy, setMyCommands: setMyCommandsSpy, - deleteMyCommands: deleteMyCommandsSpy, getMe: getMeSpy, sendMessage: sendMessageSpy, sendAnimation: sendAnimationSpy, @@ -269,13 +298,18 @@ beforeEach(() => { sendChatActionSpy.mockResolvedValue(undefined); setMyCommandsSpy.mockReset(); setMyCommandsSpy.mockResolvedValue(undefined); - deleteMyCommandsSpy.mockReset(); - deleteMyCommandsSpy.mockResolvedValue(undefined); getMeSpy.mockReset(); getMeSpy.mockResolvedValue({ username: "openclaw_bot", has_topics_enabled: true, }); + editMessageTextSpy.mockReset(); + editMessageTextSpy.mockResolvedValue({ message_id: 88 }); + enqueueSystemEventSpy.mockReset(); + wasSentByBot.mockReset(); + wasSentByBot.mockReturnValue(false); + listSkillCommandsForAgents.mockReset(); + listSkillCommandsForAgents.mockReturnValue([]); middlewareUseSpy.mockReset(); sequentializeSpy.mockReset(); botCtorSpy.mockReset(); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index d8cf574673f..96d9bd32219 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -1,180 +1,38 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, } from "../auto-reply/commands-registry.js"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import { + answerCallbackQuerySpy, + commandSpy, + editMessageTextSpy, + enqueueSystemEventSpy, + getLoadConfigMock, + getReadChannelAllowFromStoreMock, + getOnHandler, + listSkillCommandsForAgents, + onSpy, + replySpy, + sendMessageSpy, + setMyCommandsSpy, + wasSentByBot, +} from "./bot.create-telegram-bot.test-harness.js"; import { createTelegramBot } from "./bot.js"; -let replyModule: typeof import("../auto-reply/reply.js"); -const { listSkillCommandsForAgents } = vi.hoisted(() => ({ - listSkillCommandsForAgents: vi.fn(() => []), -})); -vi.mock("../auto-reply/skill-commands.js", () => ({ - listSkillCommandsForAgents, -})); - -const { sessionStorePath } = vi.hoisted(() => ({ - sessionStorePath: `/tmp/openclaw-telegram-bot-${Math.random().toString(16).slice(2)}.json`, -})); +const loadConfig = getLoadConfigMock(); +const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); function resolveSkillCommands(config: Parameters[0]) { return listSkillCommandsForAgents({ cfg: config }); } -const { loadWebMedia } = vi.hoisted(() => ({ - loadWebMedia: vi.fn(), -})); - -vi.mock("../web/media.js", () => ({ - loadWebMedia, -})); - -const { loadConfig } = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({})), -})); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig, - }; -}); - -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), - }; -}); - -const { readChannelAllowFromStore, upsertChannelPairingRequest } = vi.hoisted(() => ({ - readChannelAllowFromStore: vi.fn(async () => [] as string[]), - upsertChannelPairingRequest: vi.fn(async () => ({ - code: "PAIRCODE", - created: true, - })), -})); - -vi.mock("../pairing/pairing-store.js", () => ({ - readChannelAllowFromStore, - upsertChannelPairingRequest, -})); - -const { enqueueSystemEvent } = vi.hoisted(() => ({ - enqueueSystemEvent: vi.fn(), -})); -vi.mock("../infra/system-events.js", () => ({ - enqueueSystemEvent, -})); - -const { wasSentByBot } = vi.hoisted(() => ({ - wasSentByBot: vi.fn(() => false), -})); -vi.mock("./sent-message-cache.js", () => ({ - wasSentByBot, - recordSentMessage: vi.fn(), - clearSentMessageCache: vi.fn(), -})); - -const useSpy = vi.fn(); -const middlewareUseSpy = vi.fn(); -const onSpy = vi.fn(); -const stopSpy = vi.fn(); -const commandSpy = vi.fn(); -const botCtorSpy = vi.fn(); -const answerCallbackQuerySpy = vi.fn(async () => undefined); -const sendChatActionSpy = vi.fn(); -const editMessageTextSpy = vi.fn(async () => ({ message_id: 88 })); -const setMessageReactionSpy = vi.fn(async () => undefined); -const setMyCommandsSpy = vi.fn(async () => undefined); -const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); -const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 })); -const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 })); -type ApiStub = { - config: { use: (arg: unknown) => void }; - answerCallbackQuery: typeof answerCallbackQuerySpy; - sendChatAction: typeof sendChatActionSpy; - editMessageText: typeof editMessageTextSpy; - setMessageReaction: typeof setMessageReactionSpy; - setMyCommands: typeof setMyCommandsSpy; - sendMessage: typeof sendMessageSpy; - sendAnimation: typeof sendAnimationSpy; - sendPhoto: typeof sendPhotoSpy; -}; -const apiStub: ApiStub = { - config: { use: useSpy }, - answerCallbackQuery: answerCallbackQuerySpy, - sendChatAction: sendChatActionSpy, - editMessageText: editMessageTextSpy, - setMessageReaction: setMessageReactionSpy, - setMyCommands: setMyCommandsSpy, - sendMessage: sendMessageSpy, - sendAnimation: sendAnimationSpy, - sendPhoto: sendPhotoSpy, -}; - -vi.mock("grammy", () => ({ - Bot: class { - api = apiStub; - use = middlewareUseSpy; - on = onSpy; - stop = stopSpy; - command = commandSpy; - catch = vi.fn(); - constructor( - public token: string, - public options?: { client?: { fetch?: typeof fetch } }, - ) { - botCtorSpy(token, options); - } - }, - InputFile: class {}, - webhookCallback: vi.fn(), -})); - -const sequentializeMiddleware = vi.fn(); -const sequentializeSpy = vi.fn(() => sequentializeMiddleware); -vi.mock("@grammyjs/runner", () => ({ - sequentialize: (_keyFn: (ctx: unknown) => string) => { - return sequentializeSpy(); - }, -})); - -const throttlerSpy = vi.fn(() => "throttler"); - -vi.mock("@grammyjs/transformer-throttler", () => ({ - apiThrottler: () => throttlerSpy(), -})); - -vi.mock("../auto-reply/reply.js", () => { - const replySpy = vi.fn(async (_ctx, opts) => { - await opts?.onReplyStart?.(); - return undefined; - }); - return { getReplyFromConfig: replySpy, __replySpy: replySpy }; -}); - -const getOnHandler = (event: string) => { - const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; - if (!handler) { - throw new Error(`Missing handler for event: ${event}`); - } - return handler as (ctx: Record) => Promise; -}; - const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeAll(async () => { - replyModule = await import("../auto-reply/reply.js"); - }); - beforeEach(() => { process.env.TZ = "UTC"; - resetInboundDedupe(); loadConfig.mockReturnValue({ agents: { defaults: { @@ -185,17 +43,6 @@ describe("createTelegramBot", () => { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, }); - loadWebMedia.mockReset(); - sendAnimationSpy.mockReset(); - sendPhotoSpy.mockReset(); - setMessageReactionSpy.mockReset(); - answerCallbackQuerySpy.mockReset(); - editMessageTextSpy.mockReset(); - setMyCommandsSpy.mockReset(); - wasSentByBot.mockReset(); - middlewareUseSpy.mockReset(); - sequentializeSpy.mockReset(); - botCtorSpy.mockReset(); }); afterEach(() => { process.env.TZ = ORIGINAL_TZ; @@ -304,7 +151,6 @@ describe("createTelegramBot", () => { it("blocks callback_query when inline buttons are allowlist-only and sender not authorized", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); createTelegramBot({ @@ -426,7 +272,6 @@ describe("createTelegramBot", () => { it("includes sender identity in group envelope headers", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -479,7 +324,6 @@ describe("createTelegramBot", () => { it("uses quote text when a Telegram partial reply is received", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); createTelegramBot({ token: "tok" }); @@ -515,7 +359,6 @@ describe("createTelegramBot", () => { it("handles quote-only replies without reply metadata", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); createTelegramBot({ token: "tok" }); @@ -546,7 +389,6 @@ describe("createTelegramBot", () => { it("uses external_reply quote text for partial replies", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); createTelegramBot({ token: "tok" }); @@ -581,7 +423,6 @@ describe("createTelegramBot", () => { it("accepts group replies to the bot without explicit mention when requireMention is enabled", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -614,7 +455,6 @@ describe("createTelegramBot", () => { it("inherits group allowlist + requireMention in topics", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -658,7 +498,6 @@ describe("createTelegramBot", () => { it("prefers topic allowFrom over group allowFrom", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -701,7 +540,6 @@ describe("createTelegramBot", () => { it("allows group messages for per-group groupPolicy open override (global groupPolicy allowlist)", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -737,7 +575,6 @@ describe("createTelegramBot", () => { it("blocks control commands from unauthorized senders in per-group open groups", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -774,7 +611,6 @@ describe("createTelegramBot", () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockResolvedValue({ text: "response" }); @@ -817,7 +653,6 @@ describe("createTelegramBot", () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockResolvedValue({ text: "response" }); @@ -862,7 +697,6 @@ describe("createTelegramBot", () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -910,7 +744,7 @@ describe("createTelegramBot", () => { it("enqueues system event for reaction", async () => { onSpy.mockReset(); - enqueueSystemEvent.mockReset(); + enqueueSystemEventSpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -935,8 +769,8 @@ describe("createTelegramBot", () => { }, }); - expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); - expect(enqueueSystemEvent).toHaveBeenCalledWith( + expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); + expect(enqueueSystemEventSpy).toHaveBeenCalledWith( "Telegram reaction added: 👍 by Ada (@ada_bot) on msg 42", expect.objectContaining({ contextKey: expect.stringContaining("telegram:reaction:add:1234:42:9"), @@ -946,7 +780,7 @@ describe("createTelegramBot", () => { it("skips reaction when reactionNotifications is off", async () => { onSpy.mockReset(); - enqueueSystemEvent.mockReset(); + enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ @@ -972,12 +806,12 @@ describe("createTelegramBot", () => { }, }); - expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("defaults reactionNotifications to own", async () => { onSpy.mockReset(); - enqueueSystemEvent.mockReset(); + enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ @@ -1003,12 +837,12 @@ describe("createTelegramBot", () => { }, }); - expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); }); it("allows reaction in all mode regardless of message sender", async () => { onSpy.mockReset(); - enqueueSystemEvent.mockReset(); + enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(false); loadConfig.mockReturnValue({ @@ -1034,8 +868,8 @@ describe("createTelegramBot", () => { }, }); - expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); - expect(enqueueSystemEvent).toHaveBeenCalledWith( + expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); + expect(enqueueSystemEventSpy).toHaveBeenCalledWith( "Telegram reaction added: 🎉 by Ada on msg 99", expect.any(Object), ); @@ -1043,7 +877,7 @@ describe("createTelegramBot", () => { it("skips reaction in own mode when message is not sent by bot", async () => { onSpy.mockReset(); - enqueueSystemEvent.mockReset(); + enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(false); loadConfig.mockReturnValue({ @@ -1069,12 +903,12 @@ describe("createTelegramBot", () => { }, }); - expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("allows reaction in own mode when message is sent by bot", async () => { onSpy.mockReset(); - enqueueSystemEvent.mockReset(); + enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ @@ -1100,12 +934,12 @@ describe("createTelegramBot", () => { }, }); - expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); }); it("skips reaction from bot users", async () => { onSpy.mockReset(); - enqueueSystemEvent.mockReset(); + enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ @@ -1131,12 +965,12 @@ describe("createTelegramBot", () => { }, }); - expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("skips reaction removal (only processes added reactions)", async () => { onSpy.mockReset(); - enqueueSystemEvent.mockReset(); + enqueueSystemEventSpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1161,12 +995,12 @@ describe("createTelegramBot", () => { }, }); - expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("routes forum group reactions to the general topic (thread id not available on reactions)", async () => { onSpy.mockReset(); - enqueueSystemEvent.mockReset(); + enqueueSystemEventSpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1193,8 +1027,8 @@ describe("createTelegramBot", () => { }, }); - expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); - expect(enqueueSystemEvent).toHaveBeenCalledWith( + expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); + expect(enqueueSystemEventSpy).toHaveBeenCalledWith( "Telegram reaction added: 🔥 by Bob (@bob_user) on msg 100", expect.objectContaining({ sessionKey: expect.stringContaining("telegram:group:5678:topic:1"), @@ -1205,7 +1039,7 @@ describe("createTelegramBot", () => { it("uses correct session key for forum group reactions in general topic", async () => { onSpy.mockReset(); - enqueueSystemEvent.mockReset(); + enqueueSystemEventSpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1231,8 +1065,8 @@ describe("createTelegramBot", () => { }, }); - expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); - expect(enqueueSystemEvent).toHaveBeenCalledWith( + expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); + expect(enqueueSystemEventSpy).toHaveBeenCalledWith( "Telegram reaction added: 👀 by Bob on msg 101", expect.objectContaining({ sessionKey: expect.stringContaining("telegram:group:5678:topic:1"), @@ -1243,7 +1077,7 @@ describe("createTelegramBot", () => { it("uses correct session key for regular group reactions without topic", async () => { onSpy.mockReset(); - enqueueSystemEvent.mockReset(); + enqueueSystemEventSpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1268,8 +1102,8 @@ describe("createTelegramBot", () => { }, }); - expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); - expect(enqueueSystemEvent).toHaveBeenCalledWith( + expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); + expect(enqueueSystemEventSpy).toHaveBeenCalledWith( "Telegram reaction added: ❤️ by Charlie on msg 200", expect.objectContaining({ sessionKey: expect.stringContaining("telegram:group:9999"), @@ -1277,7 +1111,7 @@ describe("createTelegramBot", () => { }), ); // Verify session key does NOT contain :topic: - const sessionKey = enqueueSystemEvent.mock.calls[0][1].sessionKey; + const sessionKey = enqueueSystemEventSpy.mock.calls[0][1].sessionKey; expect(sessionKey).not.toContain(":topic:"); }); });