Files
flynn/src/channels/registry.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

197 lines
5.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ChannelRegistry } from './registry.js';
import type { ChannelAdapter, InboundMessage, OutboundMessage } from './types.js';
/** Create a mock adapter with spy functions and a triggerMessage helper. */
function createMockAdapter(name: string): ChannelAdapter & {
connectFn: ReturnType<typeof vi.fn>;
disconnectFn: ReturnType<typeof vi.fn>;
sendFn: ReturnType<typeof vi.fn>;
triggerMessage: (msg: InboundMessage) => void;
} {
let messageHandler: ((msg: InboundMessage) => void) | undefined;
let _status: 'disconnected' | 'connecting' | 'connected' | 'error' = 'disconnected';
const connectFn = vi.fn(async () => { _status = 'connected'; });
const disconnectFn = vi.fn(async () => { _status = 'disconnected'; });
const sendFn = vi.fn(async () => {});
return {
name,
get status() { return _status; },
connect: connectFn,
disconnect: disconnectFn,
send: sendFn,
onMessage: (handler: (msg: InboundMessage) => void) => { messageHandler = handler; },
triggerMessage: (msg: InboundMessage) => { messageHandler?.(msg); },
connectFn,
disconnectFn,
sendFn,
};
}
/** Create a sample inbound message for a given channel. */
function makeMessage(channel: string): InboundMessage {
return {
id: 'msg-1',
channel,
senderId: 'user-42',
senderName: 'Alice',
text: 'Hello',
timestamp: Date.now(),
};
}
describe('ChannelRegistry', () => {
let registry: ChannelRegistry;
beforeEach(() => {
registry = new ChannelRegistry();
});
it('registers and lists adapters', () => {
const a1 = createMockAdapter('alpha');
const a2 = createMockAdapter('beta');
registry.register(a1);
registry.register(a2);
const listed = registry.list();
expect(listed).toHaveLength(2);
expect(listed.map((a) => a.name)).toContain('alpha');
expect(listed.map((a) => a.name)).toContain('beta');
});
it('throws on duplicate registration', () => {
const a1 = createMockAdapter('dup');
registry.register(a1);
const a2 = createMockAdapter('dup');
expect(() => registry.register(a2)).toThrow('already registered');
});
it('gets adapter by name', () => {
const adapter = createMockAdapter('test');
registry.register(adapter);
expect(registry.get('test')).toBe(adapter);
expect(registry.get('unknown')).toBeUndefined();
});
it('starts all adapters', async () => {
const a1 = createMockAdapter('one');
const a2 = createMockAdapter('two');
registry.register(a1);
registry.register(a2);
await registry.startAll();
expect(a1.connectFn).toHaveBeenCalledOnce();
expect(a2.connectFn).toHaveBeenCalledOnce();
});
it('stops all adapters', async () => {
const a1 = createMockAdapter('one');
const a2 = createMockAdapter('two');
registry.register(a1);
registry.register(a2);
// Connect first so they are in connected state
await a1.connect();
await a2.connect();
await registry.stopAll();
expect(a1.disconnectFn).toHaveBeenCalled();
expect(a2.disconnectFn).toHaveBeenCalled();
});
it('routes inbound messages to handler', async () => {
const adapter = createMockAdapter('test-channel');
registry.register(adapter);
const handler = vi.fn(async (_msg: InboundMessage, reply: (r: OutboundMessage) => Promise<void>) => {
await reply({ text: 'pong' });
});
registry.setMessageHandler(handler);
const msg = makeMessage('test-channel');
adapter.triggerMessage(msg);
// Allow the async handler to settle
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledOnce();
});
// Handler receives the original inbound message
expect(handler.mock.calls[0][0]).toBe(msg);
// The reply function should have called adapter.send with the sender's peerId
expect(adapter.sendFn).toHaveBeenCalledWith('user-42', { text: 'pong' });
});
it('unregisters adapter', () => {
const adapter = createMockAdapter('removeme');
registry.register(adapter);
registry.unregister('removeme');
expect(registry.list()).toHaveLength(0);
expect(registry.get('removeme')).toBeUndefined();
});
it('unregister disconnects connected adapter', async () => {
const adapter = createMockAdapter('connected-one');
registry.register(adapter);
await adapter.connect();
expect(adapter.status).toBe('connected');
await registry.unregister('connected-one');
expect(adapter.disconnectFn).toHaveBeenCalled();
});
it('logs warning when no message handler set', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const adapter = createMockAdapter('no-handler');
registry.register(adapter);
// Trigger a message WITHOUT calling setMessageHandler
adapter.triggerMessage(makeMessage('no-handler'));
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('No message handler set'),
);
warnSpy.mockRestore();
});
it('handles errors in message handler gracefully', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const adapter = createMockAdapter('err-channel');
registry.register(adapter);
registry.setMessageHandler(async () => {
throw new Error('handler exploded');
});
// Trigger a message — should not throw
adapter.triggerMessage(makeMessage('err-channel'));
// Allow the async error path to settle
await vi.waitFor(() => {
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Error handling message'),
expect.any(Error),
);
});
errorSpy.mockRestore();
});
});