Files
flynn/src/channels/telegram/adapter.test.ts
T
William Valentin aa95f2132c feat: add channel adapter abstraction with Telegram and WebChat adapters
Implement Phase 3 channel adapters that decouple message sources from
the agent via a uniform ChannelAdapter interface and ChannelRegistry.

- Add ChannelAdapter/InboundMessage/OutboundMessage types
- Add ChannelRegistry for adapter lifecycle and message routing
- Add TelegramAdapter (grammy bot, auth middleware, confirmations, chunking)
- Add WebChatAdapter (thin shim over GatewayServer)
- Refactor daemon to use ChannelRegistry with per-channel-per-user agents
- Add config.get/config.patch gateway handlers (Phase 2 loose end)
- Add system.restart gateway handler (Phase 2 loose end)
- Add implementation plans and design docs

Tests: 225 passing (33 new channel adapter + gateway handler tests)
2026-02-05 20:00:36 -08:00

257 lines
8.2 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 and /reset
expect(mockCommand).toHaveBeenCalledTimes(2);
expect(mockCommand.mock.calls[0][0]).toBe('start');
expect(mockCommand.mock.calls[1][0]).toBe('reset');
// .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' });
});
// ── 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);
});
});