import { describe, it, expect, vi, beforeEach } from 'vitest'; // ── Mock grammy before importing adapter ────────────────────────── const mockUse = vi.fn(); const mockOn = vi.fn(); const mockCommand = vi.fn(); const mockStart = vi.fn(); const mockStop = vi.fn(); const mockSendMessage = vi.fn(); vi.mock('grammy', () => ({ Bot: vi.fn().mockImplementation(() => ({ use: mockUse, on: mockOn, command: mockCommand, start: mockStart, stop: mockStop, api: { sendMessage: mockSendMessage }, })), })); import { TelegramAdapter, type TelegramAdapterConfig } from './adapter.js'; import type { InboundMessage } from '../types.js'; const baseConfig: TelegramAdapterConfig = { botToken: 'test-token-123', allowedChatIds: [100, 200], }; describe('TelegramAdapter', () => { let adapter: TelegramAdapter; beforeEach(() => { vi.clearAllMocks(); adapter = new TelegramAdapter(baseConfig); }); // ── Basic properties ────────────────────────────────────────── it('has name "telegram"', () => { expect(adapter.name).toBe('telegram'); }); it('starts as disconnected', () => { expect(adapter.status).toBe('disconnected'); }); // ── connect / disconnect ────────────────────────────────────── it('connect creates a bot and sets status to connected', async () => { await adapter.connect(); expect(adapter.status).toBe('connected'); // Bot constructor called with the token const { Bot } = await import('grammy'); expect(Bot).toHaveBeenCalledWith('test-token-123'); }); it('connect registers auth middleware, commands, and message handler', async () => { await adapter.connect(); // .use() for auth middleware expect(mockUse).toHaveBeenCalledTimes(1); // .command() for /start, /reset, /model, /local, /cloud expect(mockCommand).toHaveBeenCalledTimes(5); expect(mockCommand.mock.calls[0][0]).toBe('start'); expect(mockCommand.mock.calls[1][0]).toBe('reset'); expect(mockCommand.mock.calls[2][0]).toBe('model'); expect(mockCommand.mock.calls[3][0]).toBe('local'); expect(mockCommand.mock.calls[4][0]).toBe('cloud'); // .on('message:text', ...) for text handler expect(mockOn).toHaveBeenCalledWith('message:text', expect.any(Function)); // .start() to begin long polling expect(mockStart).toHaveBeenCalledTimes(1); }); it('connect registers callback_query handler when hookEngine is provided', async () => { const hookEngine = { resolveConfirmation: vi.fn() }; const adapterWithHooks = new TelegramAdapter({ ...baseConfig, hookEngine: hookEngine as never, }); await adapterWithHooks.connect(); // Should have .on('callback_query:data', ...) plus .on('message:text', ...) expect(mockOn).toHaveBeenCalledWith('callback_query:data', expect.any(Function)); expect(mockOn).toHaveBeenCalledWith('message:text', expect.any(Function)); }); it('disconnect stops the bot and sets status to disconnected', async () => { await adapter.connect(); expect(adapter.status).toBe('connected'); await adapter.disconnect(); expect(mockStop).toHaveBeenCalledTimes(1); expect(adapter.status).toBe('disconnected'); }); it('disconnect is safe to call when not connected', async () => { await adapter.disconnect(); expect(adapter.status).toBe('disconnected'); expect(mockStop).not.toHaveBeenCalled(); }); // ── send ────────────────────────────────────────────────────── it('send throws when adapter is not connected', async () => { await expect(adapter.send('100', { text: 'hello' })).rejects.toThrow( 'Telegram adapter not connected', ); }); it('send delivers a short message in a single API call', async () => { await adapter.connect(); await adapter.send('100', { text: 'Hello there' }); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect(mockSendMessage).toHaveBeenCalledWith(100, 'Hello there', { parse_mode: 'Markdown' }); }); it('send chunks a long message that exceeds 4096 chars', async () => { await adapter.connect(); // Create a message that is longer than 4096 chars — two halves joined by a newline const half = 'A'.repeat(3000); const longMessage = `${half}\n${'B'.repeat(3000)}`; await adapter.send('200', { text: longMessage }); // Should have been split into 2 chunks expect(mockSendMessage.mock.calls.length).toBeGreaterThanOrEqual(2); // Each call uses numeric chatId and parse_mode for (const call of mockSendMessage.mock.calls) { expect(call[0]).toBe(200); expect(call[2]).toEqual({ parse_mode: 'Markdown' }); } }); // ── onMessage / inbound handling ────────────────────────────── it('onMessage registers a handler that receives text messages', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); // Get the registered message:text handler from mockOn const textHandlerCall = mockOn.mock.calls.find( (call) => call[0] === 'message:text', ); expect(textHandlerCall).toBeDefined(); const textHandler = textHandlerCall![1]; // Simulate a grammy context object const ctx = { message: { message_id: 42, text: 'Hello Flynn' }, chat: { id: 100 }, from: { first_name: 'Will' }, replyWithChatAction: vi.fn(), }; await textHandler(ctx); expect(ctx.replyWithChatAction).toHaveBeenCalledWith('typing'); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.channel).toBe('telegram'); expect(msg.senderId).toBe('100'); expect(msg.senderName).toBe('Will'); expect(msg.text).toBe('Hello Flynn'); expect(msg.id).toBe('42'); }); it('text handler does nothing when no message handler is registered', async () => { // Don't call onMessage — no handler await adapter.connect(); const textHandlerCall = mockOn.mock.calls.find( (call) => call[0] === 'message:text', ); const textHandler = textHandlerCall![1]; const ctx = { message: { message_id: 1, text: 'test' }, chat: { id: 100 }, from: { first_name: 'Will' }, replyWithChatAction: vi.fn(), }; // Should not throw await textHandler(ctx); expect(ctx.replyWithChatAction).not.toHaveBeenCalled(); }); // ── /reset command ──────────────────────────────────────────── it('/reset command delivers a reset inbound message', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); // Find the /reset command handler const resetCall = mockCommand.mock.calls.find((call) => call[0] === 'reset'); expect(resetCall).toBeDefined(); const resetHandler = resetCall![1]; const ctx = { message: { message_id: 99 }, chat: { id: 100 }, from: { first_name: 'Will' }, reply: vi.fn(), }; await resetHandler(ctx); expect(ctx.reply).toHaveBeenCalledWith('Conversation reset.'); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('/reset'); expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' }); }); it('/model command strips @bot suffix in groups', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); // Find the /model command handler const modelCall = mockCommand.mock.calls.find((call) => call[0] === 'model'); expect(modelCall).toBeDefined(); const modelHandler = modelCall![1]; const ctx = { message: { message_id: 123, text: '/model@flynn_bot default github/gpt-5-mini' }, chat: { id: 100 }, from: { first_name: 'Will' }, }; await modelHandler(ctx); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('/model default github/gpt-5-mini'); expect(msg.metadata).toEqual({ isCommand: true, command: 'model', commandArgs: 'default github/gpt-5-mini', }); }); // ── Auth middleware ─────────────────────────────────────────── it('auth middleware blocks unauthorized chat IDs', async () => { await adapter.connect(); // The first .use() call is the auth middleware const authMiddleware = mockUse.mock.calls[0][0]; const next = vi.fn(); const ctx = { chat: { id: 999 } }; // Not in allowedChatIds await authMiddleware(ctx, next); expect(next).not.toHaveBeenCalled(); }); it('auth middleware allows authorized chat IDs', async () => { await adapter.connect(); const authMiddleware = mockUse.mock.calls[0][0]; const next = vi.fn(); const ctx = { chat: { id: 100 } }; // In allowedChatIds await authMiddleware(ctx, next); expect(next).toHaveBeenCalledTimes(1); }); // ── Group chat mention gating ────────────────────────────────── it('ignores group messages without mention when requireMention is true', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); // Simulate onStart callback to set botInfo const startCall = mockStart.mock.calls[0][0]; startCall.onStart({ id: 12345, username: 'flynn_bot' }); const textHandlerCall = mockOn.mock.calls.find( (call) => call[0] === 'message:text', ); const textHandler = textHandlerCall![1]; const ctx = { message: { message_id: 42, text: 'Hello everyone', reply_to_message: undefined }, chat: { id: 100, type: 'group' }, from: { first_name: 'Will' }, replyWithChatAction: vi.fn(), }; await textHandler(ctx); expect(handler).not.toHaveBeenCalled(); expect(ctx.replyWithChatAction).not.toHaveBeenCalled(); }); it('processes group messages with bot mention when requireMention is true', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); // Simulate onStart callback to set botInfo const startCall = mockStart.mock.calls[0][0]; startCall.onStart({ id: 12345, username: 'flynn_bot' }); const textHandlerCall = mockOn.mock.calls.find( (call) => call[0] === 'message:text', ); const textHandler = textHandlerCall![1]; const ctx = { message: { message_id: 42, text: '@flynn_bot What is the weather?', reply_to_message: undefined }, chat: { id: 100, type: 'group' }, from: { first_name: 'Will' }, replyWithChatAction: vi.fn(), }; await textHandler(ctx); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('What is the weather?'); }); it('processes group messages as reply to bot when requireMention is true', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); // Simulate onStart callback to set botInfo const startCall = mockStart.mock.calls[0][0]; startCall.onStart({ id: 12345, username: 'flynn_bot' }); const textHandlerCall = mockOn.mock.calls.find( (call) => call[0] === 'message:text', ); const textHandler = textHandlerCall![1]; const ctx = { message: { message_id: 42, text: 'Follow up question', reply_to_message: { from: { id: 12345 } }, }, chat: { id: 100, type: 'supergroup' }, from: { first_name: 'Will' }, replyWithChatAction: vi.fn(), }; await textHandler(ctx); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('Follow up question'); }); it('processes group messages without mention when requireMention is false', async () => { const noMentionAdapter = new TelegramAdapter({ ...baseConfig, requireMention: false, }); const handler = vi.fn(); noMentionAdapter.onMessage(handler); await noMentionAdapter.connect(); // Simulate onStart callback const startCall = mockStart.mock.calls[0][0]; startCall.onStart({ id: 12345, username: 'flynn_bot' }); const textHandlerCall = mockOn.mock.calls.find( (call) => call[0] === 'message:text', ); const textHandler = textHandlerCall![1]; const ctx = { message: { message_id: 42, text: 'Hello everyone', reply_to_message: undefined }, chat: { id: 100, type: 'group' }, from: { first_name: 'Will' }, replyWithChatAction: vi.fn(), }; await textHandler(ctx); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('Hello everyone'); }); it('DMs are always processed regardless of requireMention setting', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); // Simulate onStart callback to set botInfo const startCall = mockStart.mock.calls[0][0]; startCall.onStart({ id: 12345, username: 'flynn_bot' }); const textHandlerCall = mockOn.mock.calls.find( (call) => call[0] === 'message:text', ); const textHandler = textHandlerCall![1]; const ctx = { message: { message_id: 42, text: 'Hello Flynn', reply_to_message: undefined }, chat: { id: 100, type: 'private' }, from: { first_name: 'Will' }, replyWithChatAction: vi.fn(), }; await textHandler(ctx); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('Hello Flynn'); }); it('strips bot mention from group message text', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); // Simulate onStart callback to set botInfo const startCall = mockStart.mock.calls[0][0]; startCall.onStart({ id: 12345, username: 'flynn_bot' }); const textHandlerCall = mockOn.mock.calls.find( (call) => call[0] === 'message:text', ); const textHandler = textHandlerCall![1]; const ctx = { message: { message_id: 42, text: '@flynn_bot tell me a joke', reply_to_message: undefined }, chat: { id: 100, type: 'group' }, from: { first_name: 'Will' }, replyWithChatAction: vi.fn(), }; await textHandler(ctx); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('tell me a joke'); }); });