aa95f2132c
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)
257 lines
8.2 KiB
TypeScript
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);
|
|
});
|
|
});
|