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,116 @@
|
||||
/**
|
||||
* Channel registry — manages adapter lifecycle and message routing.
|
||||
*
|
||||
* The ChannelRegistry holds all registered channel adapters and routes
|
||||
* inbound messages through a single MessageHandler. Each adapter's
|
||||
* onMessage callback is wired at registration time so that messages
|
||||
* flow through handleInbound → messageHandler → reply.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ChannelAdapter,
|
||||
InboundMessage,
|
||||
MessageHandler,
|
||||
OutboundMessage,
|
||||
} from './types.js';
|
||||
|
||||
export class ChannelRegistry {
|
||||
private adapters: Map<string, ChannelAdapter> = new Map();
|
||||
private messageHandler?: MessageHandler;
|
||||
|
||||
/** Register an adapter. Throws if name already registered. */
|
||||
register(adapter: ChannelAdapter): void {
|
||||
if (this.adapters.has(adapter.name)) {
|
||||
throw new Error(`Channel adapter '${adapter.name}' is already registered`);
|
||||
}
|
||||
|
||||
// Wire the adapter's onMessage to route through our messageHandler
|
||||
adapter.onMessage((msg) => this.handleInbound(msg));
|
||||
this.adapters.set(adapter.name, adapter);
|
||||
}
|
||||
|
||||
/** Unregister an adapter by name. Calls disconnect() if connected. */
|
||||
async unregister(name: string): Promise<void> {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (!adapter) return;
|
||||
|
||||
if (adapter.status === 'connected' || adapter.status === 'connecting') {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
|
||||
this.adapters.delete(name);
|
||||
}
|
||||
|
||||
/** Get an adapter by name. */
|
||||
get(name: string): ChannelAdapter | undefined {
|
||||
return this.adapters.get(name);
|
||||
}
|
||||
|
||||
/** List all registered adapters. */
|
||||
list(): ChannelAdapter[] {
|
||||
return Array.from(this.adapters.values());
|
||||
}
|
||||
|
||||
/** Set the message handler that all adapters route inbound messages to. */
|
||||
setMessageHandler(handler: MessageHandler): void {
|
||||
this.messageHandler = handler;
|
||||
}
|
||||
|
||||
/** Start all registered adapters. Logs errors per adapter, doesn't throw. */
|
||||
async startAll(): Promise<void> {
|
||||
const adapters = Array.from(this.adapters.values());
|
||||
const results = await Promise.allSettled(
|
||||
adapters.map((a) => a.connect()),
|
||||
);
|
||||
|
||||
for (const [i, result] of results.entries()) {
|
||||
if (result.status === 'rejected') {
|
||||
console.error(
|
||||
`Failed to start channel '${adapters[i].name}':`,
|
||||
result.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop all registered adapters. */
|
||||
async stopAll(): Promise<void> {
|
||||
const adapters = Array.from(this.adapters.values());
|
||||
const results = await Promise.allSettled(
|
||||
adapters.map((a) => a.disconnect()),
|
||||
);
|
||||
|
||||
for (const [i, result] of results.entries()) {
|
||||
if (result.status === 'rejected') {
|
||||
console.error(
|
||||
`Failed to stop channel '${adapters[i].name}':`,
|
||||
result.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal: route an inbound message to the message handler. */
|
||||
private handleInbound(msg: InboundMessage): void {
|
||||
if (!this.messageHandler) {
|
||||
console.warn(`No message handler set, dropping message from '${msg.channel}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
const adapter = this.adapters.get(msg.channel);
|
||||
if (!adapter) {
|
||||
console.warn(`Unknown channel '${msg.channel}' in inbound message`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a reply function bound to this message's channel and sender
|
||||
const reply = async (response: OutboundMessage): Promise<void> => {
|
||||
await adapter.send(msg.senderId, response);
|
||||
};
|
||||
|
||||
// Fire and forget — errors are logged, not propagated
|
||||
this.messageHandler(msg, reply).catch((err: unknown) => {
|
||||
console.error(`Error handling message from '${msg.channel}':`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user