import { beforeEach, describe, expect, it, vi } from "vitest"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { createTelegramBot } from "./bot.js"; 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, }; }); const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ code: "PAIRCODE", created: true, })), })); vi.mock("./pairing-store.js", () => ({ readTelegramAllowFromStore, upsertTelegramPairingRequest, })); 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 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; 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, 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; 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); let _sequentializeKey: ((ctx: unknown) => string) | undefined; vi.mock("@grammyjs/runner", () => ({ sequentialize: (keyFn: (ctx: unknown) => string) => { _sequentializeKey = keyFn; 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 replyModule = await import("../auto-reply/reply.js"); 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; }; describe("createTelegramBot", () => { beforeEach(() => { resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, }); loadWebMedia.mockReset(); sendAnimationSpy.mockReset(); sendPhotoSpy.mockReset(); setMessageReactionSpy.mockReset(); answerCallbackQuerySpy.mockReset(); setMyCommandsSpy.mockReset(); middlewareUseSpy.mockReset(); sequentializeSpy.mockReset(); botCtorSpy.mockReset(); _sequentializeKey = undefined; }); // groupPolicy tests it("applies topic skill filters and system prompts", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "open", groups: { "-1001234567890": { requireMention: false, systemPrompt: "Group prompt", skills: ["group-skill"], topics: { "99": { skills: [], systemPrompt: "Topic prompt", }, }, }, }, }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: -1001234567890, type: "supergroup", title: "Forum Group", is_forum: true, }, from: { id: 12345, username: "testuser" }, text: "hello", date: 1736380800, message_id: 42, message_thread_id: 99, }, me: { username: "clawdbot_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt"); const opts = replySpy.mock.calls[0][1]; expect(opts?.skillFilter).toEqual([]); }); it("passes message_thread_id to topic replies", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockResolvedValue({ text: "response" }); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "open", groups: { "*": { requireMention: false } }, }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: -1001234567890, type: "supergroup", title: "Forum Group", is_forum: true, }, from: { id: 12345, username: "testuser" }, text: "hello", date: 1736380800, message_id: 42, message_thread_id: 99, }, me: { username: "clawdbot_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(sendMessageSpy).toHaveBeenCalledWith( "-1001234567890", expect.any(String), expect.objectContaining({ message_thread_id: 99 }), ); }); it("threads native command replies inside topics", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockResolvedValue({ text: "response" }); loadConfig.mockReturnValue({ commands: { native: true }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], groups: { "*": { requireMention: false } }, }, }, }); createTelegramBot({ token: "tok" }); expect(commandSpy).toHaveBeenCalled(); const handler = commandSpy.mock.calls[0][1] as (ctx: Record) => Promise; await handler({ message: { chat: { id: -1001234567890, type: "supergroup", title: "Forum Group", is_forum: true, }, from: { id: 12345, username: "testuser" }, text: "/status", date: 1736380800, message_id: 42, message_thread_id: 99, }, match: "", }); expect(sendMessageSpy).toHaveBeenCalledWith( "-1001234567890", expect.any(String), expect.objectContaining({ message_thread_id: 99 }), ); }); it("streams tool summaries for native slash commands", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockImplementation(async (_ctx, opts) => { await opts?.onToolResult?.({ text: "tool update" }); return { text: "final reply" }; }); loadConfig.mockReturnValue({ commands: { native: true }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }); createTelegramBot({ token: "tok" }); const verboseHandler = commandSpy.mock.calls.find((call) => call[0] === "verbose")?.[1] as | ((ctx: Record) => Promise) | undefined; if (!verboseHandler) throw new Error("verbose command handler missing"); await verboseHandler({ message: { chat: { id: 12345, type: "private" }, from: { id: 12345, username: "testuser" }, text: "/verbose on", date: 1736380800, message_id: 42, }, match: "on", }); expect(sendMessageSpy).toHaveBeenCalledTimes(2); expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("tool update"); expect(sendMessageSpy.mock.calls[1]?.[1]).toContain("final reply"); }); it("dedupes duplicate message updates by update_id", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; const ctx = { update: { update_id: 111 }, message: { chat: { id: 123, type: "private" }, from: { id: 456, username: "testuser" }, text: "hello", date: 1736380800, message_id: 42, }, me: { username: "clawdbot_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }; await handler(ctx); await handler(ctx); expect(replySpy).toHaveBeenCalledTimes(1); }); });