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)
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { WebChatAdapter } from './adapter.js';
|
||||
import type { GatewayServer } from '../../gateway/index.js';
|
||||
|
||||
/** Mock GatewayServer — the adapter wraps this but doesn't manage its lifecycle. */
|
||||
const mockGateway = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
getWss: vi.fn(() => null),
|
||||
getHttpServer: vi.fn(() => null),
|
||||
getSessionBridge: vi.fn(),
|
||||
getMethods: vi.fn(() => []),
|
||||
} as unknown as GatewayServer;
|
||||
|
||||
describe('WebChatAdapter', () => {
|
||||
let adapter: WebChatAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
adapter = new WebChatAdapter({ gateway: mockGateway });
|
||||
});
|
||||
|
||||
it('has correct name', () => {
|
||||
expect(adapter.name).toBe('webchat');
|
||||
});
|
||||
|
||||
it('starts as disconnected', () => {
|
||||
expect(adapter.status).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('connect sets status to connected', async () => {
|
||||
await adapter.connect();
|
||||
expect(adapter.status).toBe('connected');
|
||||
});
|
||||
|
||||
it('disconnect sets status to disconnected', async () => {
|
||||
await adapter.connect();
|
||||
expect(adapter.status).toBe('connected');
|
||||
|
||||
await adapter.disconnect();
|
||||
expect(adapter.status).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('connect does not call gateway.start', async () => {
|
||||
await adapter.connect();
|
||||
expect(mockGateway.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disconnect does not call gateway.stop', async () => {
|
||||
await adapter.connect();
|
||||
await adapter.disconnect();
|
||||
expect(mockGateway.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('send is a no-op', async () => {
|
||||
// Should not throw
|
||||
await adapter.send('peer1', { text: 'hello' });
|
||||
});
|
||||
|
||||
it('getGateway returns the gateway instance', () => {
|
||||
expect(adapter.getGateway()).toBe(mockGateway);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* WebChat channel adapter.
|
||||
*
|
||||
* Thin wrapper around the existing GatewayServer. The gateway already
|
||||
* handles WebSocket connections, sessions, and agent routing. This adapter
|
||||
* exposes the gateway as a ChannelAdapter so the ChannelRegistry has a
|
||||
* uniform interface for all channels.
|
||||
*/
|
||||
|
||||
import type { GatewayServer } from '../../gateway/index.js';
|
||||
import type {
|
||||
InboundMessage,
|
||||
OutboundMessage,
|
||||
ChannelAdapter,
|
||||
ChannelStatus,
|
||||
} from '../types.js';
|
||||
|
||||
/** Configuration for the WebChat adapter. */
|
||||
export interface WebChatAdapterConfig {
|
||||
gateway: GatewayServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebChatAdapter wraps a GatewayServer to satisfy the ChannelAdapter interface.
|
||||
*
|
||||
* The gateway's lifecycle (start/stop) is managed by the daemon, not by
|
||||
* this adapter. Connect/disconnect only track the adapter's logical status.
|
||||
*/
|
||||
export class WebChatAdapter implements ChannelAdapter {
|
||||
readonly name = 'webchat';
|
||||
|
||||
private _status: ChannelStatus = 'disconnected';
|
||||
private gateway: GatewayServer;
|
||||
private messageHandler?: (msg: InboundMessage) => void;
|
||||
|
||||
get status(): ChannelStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
constructor(config: WebChatAdapterConfig) {
|
||||
this.gateway = config.gateway;
|
||||
}
|
||||
|
||||
/** Register the inbound message handler. Called by registry before connect(). */
|
||||
onMessage(handler: (msg: InboundMessage) => void): void {
|
||||
this.messageHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect the adapter. The gateway's lifecycle is managed by the daemon,
|
||||
* so this just marks the adapter as connected. The gateway should already
|
||||
* be started (or will be started) by the daemon.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
this._status = 'connected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the adapter. Does NOT stop the gateway — that's managed
|
||||
* by the daemon lifecycle. Just marks this adapter as disconnected.
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
this._status = 'disconnected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a WebSocket peer. This is a no-op placeholder —
|
||||
* the gateway handles outbound messages directly via its own WS connections.
|
||||
* This method exists to satisfy the ChannelAdapter interface.
|
||||
*/
|
||||
async send(_peerId: string, _message: OutboundMessage): Promise<void> {
|
||||
// Gateway handles outbound via its own WS event system (GatewayEvent).
|
||||
// This adapter doesn't need to implement send() because the gateway's
|
||||
// agent.send handler already streams responses back to the WS client.
|
||||
}
|
||||
|
||||
/** Get the underlying gateway server. */
|
||||
getGateway(): GatewayServer {
|
||||
return this.gateway;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { WebChatAdapter, type WebChatAdapterConfig } from './adapter.js';
|
||||
Reference in New Issue
Block a user