import { Telegraf, Markup } from 'telegraf'; import fs from 'fs'; import sessionManager from './state.js'; import { scrapeProjects, openProjectWorkspace, submitPrompt, observeBuild, clickOptionButton, takeBrowserScreenshot } from './browser.js'; /** * HTML escape utility. Prevents all parse_mode: 'HTML' crashes. */ function esc(text) { return String(text).replace(/&/g, '&').replace(//g, '>'); } /** * #4: Safe URL getter — returns URL string or 'N/A' if page is closed/crashed. */ function safeUrl(session) { try { if (session.page && !session.page.isClosed()) return session.page.url(); } catch {} return 'N/A'; } /** * Initializes and configures all Telegraf handlers. * @param {string} token - The Telegram Bot Token * @param {number[]} allowedUsers - Array of authorized Telegram user IDs * @returns {Telegraf} The configured bot instance */ export function setupBot(token, allowedUsers = []) { const bot = new Telegraf(token); // Global error handler bot.catch((err, ctx) => { console.error(`[Telegraf Error] Update ${ctx?.update?.update_id}:`, err.message); }); // Native slash command menu bot.telegram.setMyCommands([ { command: 'start', description: 'Open projects list' }, { command: 'screenshot', description: 'Capture browser screen' }, { command: 'status', description: 'Check bot status' }, { command: 'cancel', description: 'Cancel running prompt' }, { command: 'stop', description: 'Stop browser & free memory' }, { command: 'help', description: 'View manual' } ]).catch(err => console.warn('[Bot] Command menu registration failed:', err.message)); // Persistent bottom keyboard — clean 5-button layout const persistentMenu = Markup.keyboard([ ['Projects', 'Screenshot'], ['Status', 'Cancel', 'Stop'] ]).resize(); // Auth middleware bot.use(async (ctx, next) => { const userId = ctx.from?.id; if (allowedUsers.length > 0 && !allowedUsers.includes(userId)) { console.warn(`[Auth] Blocked user ${userId}`); return ctx.reply(`⚠️ Access Denied: User ID ${userId} is not authorized.`); } return next(); }); // ─── Handler Functions ──────────────────────────────────────── async function sendHomeMenu(ctx) { const chatId = ctx.chat.id; const session = sessionManager.getSession(chatId); const loadingMsg = await ctx.reply( '🔄 Connecting to Lovable.dev...\nFetching your projects. Please wait...', { parse_mode: 'HTML', ...persistentMenu } ); try { const projects = await scrapeProjects(session); try { await ctx.telegram.deleteMessage(chatId, loadingMsg.message_id); } catch {} if (projects.length === 0) { return ctx.reply( '⚠️ No Active Projects Found\n\nYour Lovable account has no visible projects.', { parse_mode: 'HTML', ...persistentMenu } ); } const buttons = projects.map((proj, i) => [ Markup.button.callback(proj.name.slice(0, 60), `select_project:${i}`) ]); await ctx.reply( '🏠 Lovable Projects Dashboard\n\nSelect a project to activate:', { parse_mode: 'HTML', ...Markup.inlineKeyboard(buttons) } ); } catch (err) { console.error('[Bot] Scrape failed:', err); try { await ctx.telegram.deleteMessage(chatId, loadingMsg.message_id); } catch {} await ctx.reply( `❌ Dashboard Error\n\n${esc(err.message)}\n\nCheck your LOVABLE_SESSION_COOKIE.`, { parse_mode: 'HTML', ...persistentMenu } ); } } async function closeActiveSession(ctx) { const chatId = ctx.chat.id; await sessionManager.closeSession(chatId); await ctx.reply('🛑 Browser Offline\nChromium terminated. Memory freed.', { parse_mode: 'HTML', ...persistentMenu }); } async function handleViewScreen(ctx) { const chatId = ctx.chat.id; const session = sessionManager.getSession(chatId); if (!session.page) { return ctx.reply('⚠️ No active page\nSelect a project first.', { parse_mode: 'HTML', ...persistentMenu }); } const loadingMsg = await ctx.reply('📸 Capturing viewport...', { parse_mode: 'HTML' }); try { const photoPath = await takeBrowserScreenshot(session); try { await ctx.telegram.deleteMessage(chatId, loadingMsg.message_id); } catch {} await ctx.replyWithPhoto( { source: photoPath }, { caption: `📸 Live Screen\n🌐 ${esc(safeUrl(session))}`, parse_mode: 'HTML' } ); // #12 partial: Safe file cleanup try { fs.unlinkSync(photoPath); } catch {} } catch (err) { console.error('[Bot] Screenshot failed:', err); try { await ctx.telegram.deleteMessage(chatId, loadingMsg.message_id); } catch {} await ctx.reply(`❌ Screenshot failed: ${esc(err.message)}`, { parse_mode: 'HTML' }); } } async function handleStatus(ctx) { const chatId = ctx.chat.id; const session = sessionManager.getSession(chatId); // #4: Use safeUrl() instead of raw session.page.url() const lines = [ 'ℹ️ Bot Status', '', `• Project: ${session.activeProject ? esc(session.activeProject.name) : 'None'}`, `• Browser: ${session.browser ? '🟢 Running' : '🔴 Offline'}`, `• Page: ${session.page ? '🟢 Connected' : '🔴 Disconnected'}`, `• State: ${session.isProcessing ? '⏳ Processing...' : '✅ Idle'}`, `• Queue: ${session.promptQueue.length > 0 ? `${session.promptQueue.length} prompt(s) waiting` : 'Empty'}`, `• URL: ${esc(safeUrl(session))}` ]; await ctx.reply(lines.join('\n'), { parse_mode: 'HTML', disable_web_page_preview: true, ...persistentMenu }); } // #15: Cancel command — stops the running observer and unlocks isProcessing async function handleCancel(ctx) { const chatId = ctx.chat.id; const session = sessionManager.getSession(chatId); if (!session.isProcessing) { return ctx.reply('ℹ️ Nothing is running right now.', { ...persistentMenu }); } session.isProcessing = false; session.promptQueue = []; // Clear queue too console.log(`[Bot] User cancelled running prompt for chat ${chatId}`); await ctx.reply('❌ Cancelled\nPrompt execution stopped. Ready for new input.', { parse_mode: 'HTML', ...persistentMenu }); } async function handleHelp(ctx) { const lines = [ '📖 Lovable Remote Bot Manual', '', 'This bot controls your Lovable.dev account remotely via Telegram.', '', 'Controls:', '• 🏠 Dashboard — Load project list', '• 📸 View Screen — Screenshot the browser', '• 🔄 Refresh — Re-scan dashboard', '• ℹ️ Status — Check connections', '• 🛑 Stop Browser — Kill Chromium', '• ❌ /cancel — Cancel running prompt', '• ❓ Help — This manual', '', 'Sending Prompts:', 'Once a project is active, just type any message. If a build is running, your prompt is auto-queued and submitted when the current build finishes.' ]; await ctx.reply(lines.join('\n'), { parse_mode: 'HTML', ...persistentMenu }); } // ─── Core prompt execution function (used by text handler + queue) ──── async function executePrompt(chatId, session, promptText, ctx) { session.isProcessing = true; session.lastMessageHash = ''; session.lastEditTime = 0; const statusMsg = await ctx.telegram.sendMessage(chatId, `🚀 Submitting prompt to ${esc(session.activeProject.name)}...`, { parse_mode: 'HTML' } ); session.lastStatusMessageId = statusMsg.message_id; try { await submitPrompt(session, promptText); // #1 FIX: Don't edit with identical text. Show a different confirmation instead. await ctx.telegram.editMessageText(chatId, session.lastStatusMessageId, null, `✅ Prompt submitted to ${esc(session.activeProject.name)}\nObserving build progress...`, { parse_mode: 'HTML' } ); // Start observer observeBuild( session, // onUpdate async (statusText, fileOps, progressText, terminalLogs) => { try { let msg = `🚀 Working on ${esc(session.activeProject.name)}...\n`; if (progressText) msg += `\n📊 Progress: ${esc(progressText)}\n`; if (statusText) msg += `\n🔨 Status:\n${esc(statusText)}`; if (fileOps && fileOps.length > 0) { msg += '\n\n📂 Files:\n' + fileOps.map(f => { const icon = f.op === 'create' ? '🆕' : f.op === 'delete' ? '🗑️' : '✏️'; return `${icon} ${esc(f.path)}`; }).join('\n'); } if (terminalLogs) { msg += `\n\n📋 Terminal:\n
${esc(terminalLogs)}
`; } // Dedup check const hash = msg.length + ':' + msg.slice(-100); if (hash === session.lastMessageHash) return; // #18: Throttle — minimum 3 seconds between edits const now = Date.now(); if (now - session.lastEditTime < 3000) return; session.lastMessageHash = hash; session.lastEditTime = now; await ctx.telegram.editMessageText(chatId, session.lastStatusMessageId, null, msg, { parse_mode: 'HTML', ...Markup.inlineKeyboard([[Markup.button.callback('❌ Cancel', 'cancel_prompt')]]) }); } catch (e) { if (!e.message.includes('not modified')) { console.warn('[Bot] Status edit error:', e.message); } } }, // onQuestion async (questionText, options) => { try { const buttons = options.map(opt => [ Markup.button.callback(opt.text, `click_option:${opt.index}`) ]); await ctx.telegram.editMessageText(chatId, session.lastStatusMessageId, null, `❓ Action Required\n\n${esc(questionText)}`, { parse_mode: 'HTML', ...Markup.inlineKeyboard(buttons) } ); } catch (e) { console.error('[Bot] Question render failed:', e.message); } }, // onFinished async (previewUrl, fullResponse) => { try { // Send full Lovable response if (fullResponse && fullResponse.trim().length > 10) { const maxLen = 4000; const responseText = fullResponse.trim(); if (responseText.length <= maxLen) { await ctx.telegram.sendMessage(chatId, `📝 Lovable Response:\n\n${esc(responseText)}`, { parse_mode: 'HTML' } ); } else { const lines = responseText.split('\n'); let chunk = ''; let partNum = 1; for (const line of lines) { if ((chunk + '\n' + line).length > maxLen) { await ctx.telegram.sendMessage(chatId, `📝 Lovable Response (Part ${partNum}):\n\n${esc(chunk.trim())}`, { parse_mode: 'HTML' } ); partNum++; chunk = line; } else { chunk += (chunk ? '\n' : '') + line; } } if (chunk.trim()) { await ctx.telegram.sendMessage(chatId, `📝 Lovable Response (Part ${partNum}):\n\n${esc(chunk.trim())}`, { parse_mode: 'HTML' } ); } } } // Completion panel const buttons = []; if (previewUrl) buttons.push([Markup.button.url('🌐 Open Preview', previewUrl)]); buttons.push( [Markup.button.callback('🔄 Reload', 'console_reload'), Markup.button.callback('📋 Logs', 'console_logs')], [Markup.button.callback('📸 Snapshot', 'console_screenshot'), Markup.button.callback('🏠 Projects', 'go_home')] ); await ctx.telegram.editMessageText(chatId, session.lastStatusMessageId, null, `🎉 Build Complete!`, { parse_mode: 'HTML', ...Markup.inlineKeyboard(buttons) } ); } catch (e) { console.error('[Bot] Finish render failed:', e.message); } finally { session.isProcessing = false; // #14: Process next queued prompt processQueue(chatId, session, ctx); } }, // onTimeout async () => { try { await ctx.telegram.editMessageText(chatId, session.lastStatusMessageId, null, `⏱ Observation Timeout\n\nNo completion detected after 5 minutes.\nUse 📸 View Screen to check manually.`, { parse_mode: 'HTML', ...Markup.inlineKeyboard([ [Markup.button.callback('📸 View Screen', 'console_screenshot'), Markup.button.callback('🏠 Projects', 'go_home')] ]) } ); } catch (e) { console.error('[Bot] Timeout render failed:', e.message); } finally { session.isProcessing = false; // #14: Process next queued prompt even after timeout processQueue(chatId, session, ctx); } } ); } catch (err) { console.error('[Bot] Prompt submission failed:', err); try { await ctx.telegram.editMessageText(chatId, session.lastStatusMessageId, null, `❌ Failed:\n${esc(err.message)}`, { parse_mode: 'HTML' } ); } catch { await ctx.telegram.sendMessage(chatId, `❌ Failed: ${esc(err.message)}`, { parse_mode: 'HTML' } ); } session.isProcessing = false; // #14: Try next in queue even after failure processQueue(chatId, session, ctx); } } // #14: Process queued prompts automatically async function processQueue(chatId, session, ctx) { if (session.promptQueue.length === 0) return; if (session.isProcessing) return; const nextPrompt = session.promptQueue.shift(); console.log(`[Bot] Auto-submitting queued prompt (${session.promptQueue.length} remaining)`); await executePrompt(chatId, session, nextPrompt, ctx); } // ─── Slash Commands ─────────────────────────────────────────── bot.command('start', sendHomeMenu); bot.command('home', sendHomeMenu); bot.command('screenshot', handleViewScreen); bot.command('status', handleStatus); bot.command('stop', closeActiveSession); bot.command('cancel', handleCancel); bot.command('help', handleHelp); // Keyboard button patterns (substring matches to support emojis) bot.hears(/projects/i, sendHomeMenu); bot.hears(/screenshot/i, handleViewScreen); bot.hears(/status/i, handleStatus); bot.hears(/cancel/i, handleCancel); bot.hears(/stop/i, closeActiveSession); // ─── Callback Query Handlers ────────────────────────────────── // Project selection bot.action(/^select_project:(\d+)$/, async (ctx) => { const index = parseInt(ctx.match[1], 10); const chatId = ctx.chat.id; const session = sessionManager.getSession(chatId); const proj = session.projects[index]; if (!proj) return ctx.answerCbQuery('⚠️ Project not found.', { show_alert: true }); await ctx.answerCbQuery(); await ctx.editMessageText( `🔄 Activating...\nLoading ${esc(proj.name)}...`, { parse_mode: 'HTML' } ); try { session.activeProject = proj; await openProjectWorkspace(session, proj.url); await ctx.editMessageText( `✅ Active: ${esc(proj.name)}\n\nSend any text message to deploy it as a prompt.`, { parse_mode: 'HTML' } ); } catch (err) { console.error('[Bot] Workspace open failed:', err); await ctx.editMessageText( `❌ Failed: ${esc(err.message)}`, { parse_mode: 'HTML' } ); } }); // Interactive option click bot.action(/^click_option:(\d+)$/, async (ctx) => { const index = parseInt(ctx.match[1], 10); const chatId = ctx.chat.id; const session = sessionManager.getSession(chatId); if (!session.activeQuestionOptions) { return ctx.answerCbQuery('⚠️ Options expired.', { show_alert: true }); } const opt = session.activeQuestionOptions.find(o => o.index === index); if (!opt) return ctx.answerCbQuery('⚠️ Option not found.', { show_alert: true }); await ctx.answerCbQuery(); await ctx.editMessageText( `⏳ Selected: ${esc(opt.text)}\nResuming Lovable...`, { parse_mode: 'HTML' } ); try { await clickOptionButton(session, opt.text); } catch (err) { console.error('[Bot] Option click failed:', err); await ctx.telegram.sendMessage(chatId, `❌ Click failed: ${esc(err.message)}`, { parse_mode: 'HTML' } ); } }); // #15: Inline cancel button handler bot.action('cancel_prompt', async (ctx) => { const chatId = ctx.chat.id; const session = sessionManager.getSession(chatId); session.isProcessing = false; session.promptQueue = []; await ctx.answerCbQuery('❌ Cancelled'); try { await ctx.editMessageText( '❌ Cancelled by user.\nReady for new input.', { parse_mode: 'HTML' } ); } catch {} }); // Console: Force Reload bot.action('console_reload', async (ctx) => { const session = sessionManager.getSession(ctx.chat.id); if (!session.page) return ctx.answerCbQuery('⚠️ No active page.', { show_alert: true }); await ctx.answerCbQuery('🔄 Reloading...'); try { await session.page.reload({ waitUntil: 'load', timeout: 30000 }); await ctx.reply('✅ Page reloaded.', { parse_mode: 'HTML' }); } catch (err) { await ctx.reply(`❌ Reload failed: ${esc(err.message)}`, { parse_mode: 'HTML' }); } }); // Console Logs — reads from cached session data bot.action('console_logs', async (ctx) => { const session = sessionManager.getSession(ctx.chat.id); await ctx.answerCbQuery('📋 Fetching...'); const scan = session.lastScanData; if (!scan) { return ctx.reply('📋 No cached logs available.\nSubmit a prompt first.', { parse_mode: 'HTML' }); } const parts = []; if (scan.statusText) parts.push(`Status:\n${esc(scan.statusText)}`); if (scan.terminalLogs) parts.push(`Terminal:\n
${esc(scan.terminalLogs)}
`); if (scan.fileOps && scan.fileOps.length > 0) { parts.push('Files:\n' + scan.fileOps.map(f => { const icon = f.op === 'create' ? '🆕' : f.op === 'delete' ? '🗑️' : '✏️'; return `${icon} ${esc(f.path)}`; }).join('\n')); } const text = parts.length > 0 ? `📋 Cached Logs\n\n${parts.join('\n\n')}` : '📋 No activity detected.'; await ctx.reply(text, { parse_mode: 'HTML' }); }); // Console: Screenshot shortcut bot.action('console_screenshot', async (ctx) => { await ctx.answerCbQuery('📸 Capturing...'); await handleViewScreen(ctx); }); // Home redirect bot.action('go_home', async (ctx) => { await ctx.answerCbQuery(); await sendHomeMenu(ctx); }); // ─── Prompt Text Handler ────────────────────────────────────── bot.on('text', async (ctx) => { const chatId = ctx.chat.id; const session = sessionManager.getSession(chatId); // Trim input to remove any leading/trailing spaces const promptText = (ctx.message.text || '').trim(); console.log(`[Bot] Received text update from user ${ctx.from?.id}: "${promptText}"`); // Strict guard: ignore any keyboard buttons or standard slash commands completely const isMenuButtonOrCommand = /(projects|screenshot|status|cancel|stop|help)/i.test(promptText) || promptText.startsWith('/'); if (isMenuButtonOrCommand) { console.log(`[Bot] Blocked menu text "${promptText}" from falling through as a prompt.`); return; } if (!session.activeProject) { return ctx.reply('⚠️ No Project Active\nUse /start to select one.', { parse_mode: 'HTML' }); } // #14: If busy, queue the prompt instead of rejecting if (session.isProcessing) { session.promptQueue.push(promptText); const pos = session.promptQueue.length; return ctx.reply( `📥 Queued (Position: ${pos})\nYour prompt will auto-submit when the current build finishes.\n\nUse /cancel to clear the queue.`, { parse_mode: 'HTML' } ); } await executePrompt(chatId, session, promptText, ctx); }); return bot; }