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
+208
View File
@@ -0,0 +1,208 @@
import { Bot } from 'grammy';
import type { HookEngine } from '../../hooks/index.js';
import type {
InboundMessage,
OutboundMessage,
ChannelAdapter,
ChannelStatus,
} from '../types.js';
import { isAllowedChat } from '../../frontends/telegram/handlers.js';
import { parseConfirmationCallback } from '../../frontends/telegram/confirmations.js';
/** Configuration for the Telegram channel adapter. */
export interface TelegramAdapterConfig {
botToken: string;
allowedChatIds: number[];
hookEngine?: HookEngine;
}
/**
* Split a long message into chunks that respect Telegram's 4096 char limit.
* Prefers splitting at newlines, then spaces, then hard-cuts.
*/
function splitMessage(text: string, maxLength: number): string[] {
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining);
break;
}
// Try to split at a newline within the allowed window
let splitIndex = remaining.lastIndexOf('\n', maxLength);
if (splitIndex === -1 || splitIndex < maxLength / 2) {
splitIndex = remaining.lastIndexOf(' ', maxLength);
}
if (splitIndex === -1 || splitIndex < maxLength / 2) {
splitIndex = maxLength;
}
chunks.push(remaining.slice(0, splitIndex));
remaining = remaining.slice(splitIndex).trimStart();
}
return chunks;
}
/**
* Telegram channel adapter backed by grammy.
*
* Handles authentication via allowed-chat-id filtering,
* confirmation callbacks (when a HookEngine is provided),
* and message chunking for Telegram's 4096-char limit.
*/
export class TelegramAdapter implements ChannelAdapter {
readonly name = 'telegram';
private _status: ChannelStatus = 'disconnected';
private bot: Bot | null = null;
private messageHandler?: (msg: InboundMessage) => void;
private config: TelegramAdapterConfig;
get status(): ChannelStatus {
return this._status;
}
constructor(config: TelegramAdapterConfig) {
this.config = config;
}
/** Register the inbound message handler. Called by the registry before connect(). */
onMessage(handler: (msg: InboundMessage) => void): void {
this.messageHandler = handler;
}
/** Create the grammy bot, wire up middleware & handlers, and start long-polling. */
async connect(): Promise<void> {
this.bot = new Bot(this.config.botToken);
this._status = 'connecting';
// ── Auth middleware — reject messages from unknown chats ──
this.bot.use(async (ctx, next) => {
const chatId = ctx.chat?.id;
if (chatId === undefined || !isAllowedChat(chatId, this.config.allowedChatIds)) {
console.log(`Rejected message from unauthorized chat: ${chatId}`);
return;
}
await next();
});
// ── Confirmation callback handler (requires hookEngine) ──
if (this.config.hookEngine) {
const hookEngine = this.config.hookEngine;
this.bot.on('callback_query:data', async (ctx) => {
const data = ctx.callbackQuery.data;
const parsed = parseConfirmationCallback(data);
if (!parsed) {
await ctx.answerCallbackQuery({ text: 'Invalid action' });
return;
}
const resolved = hookEngine.resolveConfirmation(parsed.id, {
approved: parsed.approved,
reason: parsed.approved ? undefined : 'Denied by user',
});
if (resolved) {
await ctx.answerCallbackQuery({
text: parsed.approved ? '✅ Approved' : '❌ Denied',
});
await ctx.editMessageText(
ctx.callbackQuery.message?.text + `\n\n${parsed.approved ? '✅ Approved' : '❌ Denied'}`,
{ parse_mode: 'Markdown' },
);
} else {
await ctx.answerCallbackQuery({ text: 'Confirmation expired or not found' });
}
});
}
// ── Command handlers ──
this.bot.command('start', async (ctx) => {
await ctx.reply('Flynn is ready. Send me a message!');
});
this.bot.command('reset', async (ctx) => {
// Deliver a special reset message through the channel
if (this.messageHandler) {
this.messageHandler({
id: String(ctx.message?.message_id ?? Date.now()),
channel: 'telegram',
senderId: String(ctx.chat.id),
senderName: ctx.from?.first_name,
text: '/reset',
timestamp: Date.now(),
metadata: { isCommand: true, command: 'reset' },
});
}
await ctx.reply('Conversation reset.');
});
// ── Text message handler ──
this.bot.on('message:text', async (ctx) => {
if (!this.messageHandler) return;
const text = ctx.message.text;
// Show typing indicator while processing
await ctx.replyWithChatAction('typing');
this.messageHandler({
id: String(ctx.message.message_id),
channel: 'telegram',
senderId: String(ctx.chat.id),
senderName: ctx.from?.first_name,
text,
timestamp: Date.now(),
});
});
// ── Start long polling ──
this.bot.start({
onStart: (botInfo) => {
console.log(`Telegram bot started: @${botInfo.username}`);
this._status = 'connected';
},
});
// bot.start() returns immediately for long polling.
// The onStart callback sets connected above; also set here for safety
// in case the callback fires before this line is reached.
this._status = 'connected';
}
/** Stop the bot and clean up. */
async disconnect(): Promise<void> {
if (this.bot) {
await this.bot.stop();
this.bot = null;
}
this._status = 'disconnected';
}
/** Send an outbound message, automatically chunking if it exceeds Telegram's limit. */
async send(peerId: string, message: OutboundMessage): Promise<void> {
if (!this.bot) throw new Error('Telegram adapter not connected');
const chatId = Number(peerId);
const text = message.text;
// Telegram enforces a 4096-character limit per message
if (text.length <= 4096) {
await this.bot.api.sendMessage(chatId, text, { parse_mode: 'Markdown' });
} else {
const chunks = splitMessage(text, 4096);
for (const chunk of chunks) {
await this.bot.api.sendMessage(chatId, chunk, { parse_mode: 'Markdown' });
}
}
}
}