Files
flynn/src/channels/telegram/adapter.test.ts
T

472 lines
15 KiB
TypeScript

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');
});
});