import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import type { Authenticator } from '../auth/authenticator.js'; import type { AgentHarness, HarnessFactory } from '../harness/agent-harness.js'; import type { InboundMessage } from '../types/messages.js'; import { randomUUID } from 'crypto'; import type { ChannelAdapter, ChannelCapabilities } from '../workspace/index.js'; export interface TelegramHandlerConfig { authenticator: Authenticator; telegramBotToken: string; createHarness: HarnessFactory; } interface TelegramUpdate { update_id: number; message?: { message_id: number; from: { id: number; first_name: string; username?: string; }; chat: { id: number; type: string; }; text?: string; photo?: Array<{ file_id: string; file_size: number; }>; }; } interface TelegramSession { harness: AgentHarness; lastActivity: number; } /** * Telegram webhook handler */ export class TelegramHandler { private config: TelegramHandlerConfig; private sessions = new Map(); private chatIds = new Map(); // sessionId -> chatId constructor(config: TelegramHandlerConfig) { this.config = config; } /** * Register Telegram webhook routes */ register(app: FastifyInstance): void { app.post('/webhook/telegram', async (request: FastifyRequest, reply: FastifyReply) => { await this.handleWebhook(request, reply, app); }); } /** * Handle Telegram webhook */ private async handleWebhook( request: FastifyRequest, reply: FastifyReply, app: FastifyInstance ): Promise { const logger = app.log; try { const update = request.body as TelegramUpdate; if (!update.message?.text) { // Ignore non-text messages for now reply.code(200).send({ ok: true }); return; } const telegramUserId = update.message.from.id.toString(); const chatId = update.message.chat.id; const text = update.message.text; logger.info({ telegramUserId, chatId, text }, 'Received Telegram message'); // Authenticate const authContext = await this.config.authenticator.authenticateTelegram(telegramUserId); if (!authContext) { logger.warn({ telegramUserId }, 'Telegram user not authenticated'); await this.sendTelegramMessage( chatId, 'Please link your Telegram account to Dexorder first.' ); reply.code(200).send({ ok: true }); return; } // Store chatId for this session this.chatIds.set(authContext.sessionId, chatId); // Create Telegram channel adapter const telegramAdapter: ChannelAdapter = { sendSnapshot: () => { // Telegram doesn't support sync protocol }, sendPatch: () => { // Telegram doesn't support sync protocol }, sendText: (msg) => { this.sendTelegramMessage(chatId, msg.text).catch((err) => { logger.error({ error: err }, 'Failed to send Telegram text'); }); }, sendChunk: () => { // Telegram doesn't support streaming; full response sent after handleMessage resolves }, sendImage: (msg) => { this.sendTelegramPhoto(chatId, msg.data, msg.mimeType, msg.caption).catch((err) => { logger.error({ error: err }, 'Failed to send Telegram image'); }); }, getCapabilities: (): ChannelCapabilities => ({ supportsSync: false, supportsImages: true, supportsMarkdown: true, supportsStreaming: false, supportsTradingViewEmbed: false, }), }; // Get or create harness let session = this.sessions.get(authContext.sessionId); if (!session) { const harness = this.config.createHarness({ userId: authContext.userId, sessionId: authContext.sessionId, license: authContext.license, mcpServerUrl: authContext.mcpServerUrl, logger, channelAdapter: telegramAdapter, channelType: authContext.channelType, channelUserId: authContext.channelUserId, }); await harness.initialize(); session = { harness, lastActivity: Date.now() }; this.sessions.set(authContext.sessionId, session); } else { // Update channel adapter and activity timestamp for existing session session.harness.setChannelAdapter(telegramAdapter); session.lastActivity = Date.now(); } // Process message const inboundMessage: InboundMessage = { messageId: randomUUID(), userId: authContext.userId, sessionId: authContext.sessionId, content: text, attachments: [], // TODO: Add image support for Telegram timestamp: new Date(), }; const response = await session.harness.handleMessage(inboundMessage); // Send response back to Telegram await this.sendTelegramMessage(chatId, response.content); reply.code(200).send({ ok: true }); } catch (error) { logger.error({ error }, 'Error handling Telegram webhook'); reply.code(500).send({ ok: false, error: 'Internal server error' }); } } /** * Send text message to Telegram chat */ private async sendTelegramMessage(chatId: number, text: string): Promise { const url = `https://api.telegram.org/bot${this.config.telegramBotToken}/sendMessage`; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'Markdown', }), }); if (!response.ok) { throw new Error(`Telegram API error: ${response.statusText}`); } } catch (error) { console.error('Failed to send Telegram message:', error); throw error; } } /** * Send photo to Telegram chat * Converts base64 image data to a buffer and sends via sendPhoto API */ private async sendTelegramPhoto( chatId: number, base64Data: string, mimeType: string, caption?: string ): Promise { const url = `https://api.telegram.org/bot${this.config.telegramBotToken}/sendPhoto`; try { // Convert base64 to buffer const imageBuffer = Buffer.from(base64Data, 'base64'); // Determine filename from mimeType const extension = mimeType.split('/')[1] || 'png'; const filename = `image.${extension}`; // Create FormData for multipart upload const formData = new FormData(); formData.append('chat_id', chatId.toString()); formData.append('photo', new Blob([imageBuffer], { type: mimeType }), filename); if (caption) { formData.append('caption', caption); } const response = await fetch(url, { method: 'POST', body: formData, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Telegram API error: ${response.statusText} - ${errorText}`); } } catch (error) { console.error('Failed to send Telegram photo:', error); throw error; } } /** * Clean up sessions that have been idle longer than maxAgeMs. * Triggers Iceberg flush for each expired session via harness.cleanup(). */ async cleanupSessions(maxAgeMs = 30 * 60 * 1000): Promise { const now = Date.now(); const expired: string[] = []; for (const [sessionId, session] of this.sessions) { if (now - session.lastActivity > maxAgeMs) { expired.push(sessionId); } } for (const sessionId of expired) { const session = this.sessions.get(sessionId); if (session) { await session.harness.cleanup().catch(() => {}); this.sessions.delete(sessionId); this.chatIds.delete(sessionId); } } } /** * Flush and clean up all active sessions. * Called during graceful shutdown. */ async endAllSessions(): Promise { const cleanups = Array.from(this.sessions.values()).map(s => s.harness.cleanup()); await Promise.allSettled(cleanups); this.sessions.clear(); this.chatIds.clear(); } }