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,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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user