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)
197 lines
5.6 KiB
TypeScript
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();
|
|
});
|
|
});
|