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:
William Valentin
2026-02-05 20:00:36 -08:00
parent 282a15d2b9
commit aa95f2132c
19 changed files with 4123 additions and 37 deletions
+111 -36
View File
@@ -1,13 +1,13 @@
import { Bot } from 'grammy';
import { Lifecycle } from './lifecycle.js';
import type { Config } from '../config/index.js';
import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, ModelRouter } from '../models/index.js';
import { NativeAgent } from '../backends/index.js';
import { createTelegramBot } from '../frontends/telegram/index.js';
import { SessionStore, SessionManager } from '../session/index.js';
import { HookEngine } from '../hooks/index.js';
import { ToolRegistry, ToolExecutor, allBuiltinTools } from '../tools/index.js';
import { GatewayServer } from '../gateway/index.js';
import { ChannelRegistry, TelegramAdapter, WebChatAdapter } from '../channels/index.js';
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
import { resolve } from 'path';
import { homedir } from 'os';
import { mkdirSync, readFileSync, existsSync } from 'fs';
@@ -15,8 +15,6 @@ import { mkdirSync, readFileSync, existsSync } from 'fs';
export interface DaemonContext {
config: Config;
lifecycle: Lifecycle;
bot: Bot;
agent: NativeAgent;
sessionStore: SessionStore;
sessionManager: SessionManager;
hookEngine: HookEngine;
@@ -24,6 +22,7 @@ export interface DaemonContext {
toolRegistry: ToolRegistry;
toolExecutor: ToolExecutor;
gateway: GatewayServer;
channelRegistry: ChannelRegistry;
}
function loadSystemPrompt(): string {
@@ -106,6 +105,59 @@ function createModelRouter(config: Config): ModelRouter {
});
}
/**
* Create the unified message handler for the channel registry.
* Each channel+sender pair gets its own NativeAgent backed by a persistent session.
*/
function createMessageRouter(deps: {
sessionManager: SessionManager;
modelRouter: ModelRouter;
systemPrompt: string;
toolRegistry: ToolRegistry;
toolExecutor: ToolExecutor;
}) {
// Cache agents by session ID to avoid recreating on every message
const agents = new Map<string, NativeAgent>();
function getOrCreateAgent(channel: string, senderId: string): NativeAgent {
const sessionId = `${channel}:${senderId}`;
let agent = agents.get(sessionId);
if (!agent) {
const session = deps.sessionManager.getSession(channel, senderId);
agent = new NativeAgent({
modelClient: deps.modelRouter,
systemPrompt: deps.systemPrompt,
session,
toolRegistry: deps.toolRegistry,
toolExecutor: deps.toolExecutor,
});
agents.set(sessionId, agent);
}
return agent;
}
return async (msg: InboundMessage, reply: (response: OutboundMessage) => Promise<void>): Promise<void> => {
const agent = getOrCreateAgent(msg.channel, msg.senderId);
// Handle special commands
if (msg.metadata?.isCommand && msg.metadata.command === 'reset') {
agent.reset();
return;
}
try {
const response = await agent.process(msg.text);
await reply({ text: response, replyTo: msg.id });
} catch (error) {
console.error(`Error processing message from ${msg.channel}:${msg.senderId}:`, error);
await reply({
text: 'Sorry, an error occurred while processing your message.',
replyTo: msg.id,
});
}
};
}
export async function startDaemon(config: Config): Promise<DaemonContext> {
const lifecycle = new Lifecycle();
@@ -138,26 +190,6 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
// Load system prompt once for reuse
const systemPrompt = loadSystemPrompt();
// Get Telegram session
const telegramUserId = String(config.telegram.allowed_chat_ids[0]);
const session = sessionManager.getSession('telegram', telegramUserId);
// Initialize native agent with session and tools
const agent = new NativeAgent({
modelClient: modelRouter,
systemPrompt,
session,
toolRegistry,
toolExecutor,
});
// Initialize Telegram bot with hook engine
const bot = createTelegramBot({
telegram: config.telegram,
agent,
hookEngine,
});
// Initialize gateway WebSocket server
const gateway = new GatewayServer({
port: config.server.port,
@@ -167,9 +199,43 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
systemPrompt,
toolRegistry,
toolExecutor,
uiDir: resolve(import.meta.dirname, '../gateway/ui'),
config,
restart: async () => {
console.log('Restart requested via gateway');
await lifecycle.shutdown();
// Exit with code 75 (EX_TEMPFAIL) — process supervisor should restart
process.exit(75);
},
});
// Register signal handlers
// ── Channel Registry ──────────────────────────────────────────
const channelRegistry = new ChannelRegistry();
// Set up the unified message handler
channelRegistry.setMessageHandler(createMessageRouter({
sessionManager,
modelRouter,
systemPrompt,
toolRegistry,
toolExecutor,
}));
// Register Telegram adapter
const telegramAdapter = new TelegramAdapter({
botToken: config.telegram.bot_token,
allowedChatIds: config.telegram.allowed_chat_ids,
hookEngine,
});
channelRegistry.register(telegramAdapter);
// Register WebChat adapter (wraps the gateway)
const webChatAdapter = new WebChatAdapter({ gateway });
channelRegistry.register(webChatAdapter);
// ── Signal Handlers ───────────────────────────────────────────
const signalHandler = () => {
lifecycle.shutdown().then(() => process.exit(0));
};
@@ -182,20 +248,18 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
process.off('SIGTERM', signalHandler);
});
// Start bot
// ── Start Services ────────────────────────────────────────────
// Register shutdown handler for channels (stops Telegram bot etc.)
lifecycle.onShutdown(async () => {
await bot.stop();
console.log('Telegram bot stopped');
await channelRegistry.stopAll();
console.log('Channel adapters stopped');
});
// Use long polling (no webhook, no internet exposure)
bot.start({
onStart: (botInfo) => {
console.log(`Telegram bot started: @${botInfo.username}`);
},
});
// Start all channel adapters (Telegram long polling, WebChat status)
await channelRegistry.startAll();
// Start gateway
// Start gateway (HTTP + WS server)
lifecycle.onShutdown(async () => {
await gateway.stop();
console.log('Gateway server stopped');
@@ -205,7 +269,18 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
console.log('Flynn daemon started');
return { config, lifecycle, bot, agent, sessionStore, sessionManager, hookEngine, modelRouter, toolRegistry, toolExecutor, gateway };
return {
config,
lifecycle,
sessionStore,
sessionManager,
hookEngine,
modelRouter,
toolRegistry,
toolExecutor,
gateway,
channelRegistry,
};
}
export { Lifecycle } from './lifecycle.js';