const express = require('express'); const cors = require('cors'); const { createProxyMiddleware } = require('http-proxy-middleware'); const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const dns = require('dns'); const https = require('https'); const GeminiWebAPI = require('./GeminiWebAPI.cjs'); // === IN-MEMORY LOG CAPTURE === const appLogs = []; const maxLogs = 500; function addLog(source, msg) { appLogs.push(`[${new Date().toISOString()}] [${source}] ${msg}`); if (appLogs.length > maxLogs) appLogs.shift(); } // ============================= // Force Node to use reliable public DNS instead of Hugging Face's broken K8s DNS dns.setServers(['8.8.8.8', '1.1.1.1', '8.8.4.4']); // Custom HTTPS Agent that intercepts DNS and bypasses the OS const proxyAgent = new https.Agent({ keepAlive: true, lookup: (hostname, options, callback) => { // Force manual DNS resolution for Telegram if (hostname.includes('telegram.org')) { dns.resolve4(hostname, (err, addresses) => { if (err) return dns.lookup(hostname, options, callback); // Fallback if (options && options.all) { callback(null, addresses.map(a => ({ address: a, family: 4 }))); } else { callback(null, addresses[0], 4); } }); } else { // Native lookup for others dns.lookup(hostname, options, callback); } } }); const app = express(); app.use(cors()); // Safe JSON parser that won't crash on 'null' or weird payloads from the Expo app app.use(express.json({ strict: false, limit: '50mb' })); app.use((err, req, res, next) => { if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { console.error('[JSON Parse Error]', err.message); return res.status(400).json({ error: "Invalid JSON payload" }); // Send JSON instead of HTML } next(); }); const PORT = process.env.PORT || 7860; // Automatic Cookie Injection for Headless Hugging Face if (process.env.GEMINI_SESSION_B64) { console.log("Found GEMINI_SESSION_B64 environment variable. Injecting session cookies..."); try { const decoded = Buffer.from(process.env.GEMINI_SESSION_B64, 'base64').toString('utf8'); fs.writeFileSync('./user_session.json', decoded); console.log("✅ Successfully injected user_session.json"); } catch (e) { console.error("❌ Failed to decode GEMINI_SESSION_B64:", e.message); } } // Initialize GeminiWebAPI console.log("Starting Web API Proxy layer..."); const gemini = new GeminiWebAPI({ headless: true, userDataDir: './user_data_hybrid' }); gemini.auth().then(() => { console.log("✅ GeminiWebAPI Authenticated and Ready."); }).catch(err => { console.error("❌ Failed to authenticate GeminiWebAPI:", err); // On Hugging face we might not have a session json initially. // It will freeze waiting for manual login. console.log("❗ WARNING: No session found. Browser automation requires manual login on first run."); }); // ========================================== // OPENAI COMPATIBLE API (/v1/chat/completions) // ========================================== app.post('/v1/chat/completions', async (req, res) => { try { const { messages, stream } = req.body; if (!messages || messages.length === 0) { return res.status(400).json({ error: "Missing messages array" }); } const lastUserMessage = messages.filter(m => m.role === 'user').pop(); if (!lastUserMessage) { return res.status(400).json({ error: "No user message found" }); } // Just take the raw content string let promptText = ""; if (typeof lastUserMessage.content === 'string') { promptText = lastUserMessage.content; } else if (Array.isArray(lastUserMessage.content)) { promptText = lastUserMessage.content.map(c => c.text || JSON.stringify(c)).join("\n"); } if (!gemini.isAuthenticated) { return res.status(503).json({ error: "GeminiWebAPI is not authenticated yet. Please check logs." }); } console.log(`[Proxy] Forwarding prompt to Gemini Web (Stream: ${!!stream}): ${promptText.substring(0, 50)}...`); if (stream) { // Setup SSE res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); let lastLength = 0; await gemini.askStream(promptText, (currentText, thinking) => { if (currentText && currentText.length > lastLength) { const delta = currentText.slice(lastLength); lastLength = currentText.length; const chunkObj = { id: `chatcmpl-${Date.now()}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: "gemini-web", choices: [{ index: 0, delta: { content: delta } }] }; res.write(`data: ${JSON.stringify(chunkObj)}\n\n`); } }); res.write('data: [DONE]\n\n'); res.end(); return; } // Non-streaming fallback const responseText = await gemini.ask(promptText); const responseObj = { id: `chatcmpl-${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model: "gemini-web", choices: [{ index: 0, message: { role: "assistant", content: responseText, }, finish_reason: "stop" }], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } }; res.json(responseObj); } catch (e) { console.error("[Proxy] Error processing request:", e); if (!res.headersSent) { res.status(500).json({ error: e.message }); } else { res.end(); } } }); // ========================================== // GRAVITYCLAW EXPO APP COMPATIBILITY (/api/chat) // ========================================== app.post('/api/chat', async (req, res) => { try { const { message } = req.body; if (!message) { return res.status(400).json({ error: "Message is required" }); } res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); // Necessary to flush headers immediately in some environments res.flushHeaders && res.flushHeaders(); const sendEvent = (type, data) => { res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`); }; if (!gemini.isAuthenticated) { sendEvent('error', { message: "GeminiWebAPI is not authenticated yet." }); return res.end(); } // The Expo App hardcodes "start" to show the typing indicator sendEvent('start', { status: 'Processing...' }); const startTime = Date.now(); const responseText = await gemini.ask(message); // The Expo App waits for "done" to show the final message sendEvent('done', { response: responseText, metadata: { executionTimeMs: Date.now() - startTime, tokens: Math.floor(responseText.length / 4), cost: "$0.00" } }); res.end(); } catch (e) { console.error("[Proxy Expo App] Error:", e); if (!res.headersSent) { res.status(500).json({ error: e.message }); } else { res.write(`data: ${JSON.stringify({ type: 'error', message: e.message })}\n\n`); res.end(); } } }); // ========================================== // TELEGRAM API PROXY (manual fetch-based, bypasses HF's HTTPS block) // ========================================== app.use('/tg', async (req, res) => { // Strip /tg prefix → forward to api.telegram.org const targetPath = req.originalUrl.replace(/^\/tg/, ''); const targetUrl = `https://api.telegram.org${targetPath}`; try { // Collect request body — Express may have already parsed it let body = undefined; if (req.method === 'POST') { if (req.body !== undefined && req.body !== null) { // Express already parsed the JSON body body = Buffer.from(JSON.stringify(req.body)); } else { // Fallback: read raw stream body = await new Promise((resolve, reject) => { const chunks = []; req.on('data', c => chunks.push(c)); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } } // Use native https module with our custom DNS-resolving agent const result = await new Promise((resolve, reject) => { const urlObj = new URL(targetUrl); const options = { hostname: urlObj.hostname, port: 443, path: urlObj.pathname + urlObj.search, method: req.method, agent: proxyAgent, headers: { 'Content-Type': req.headers['content-type'] || 'application/json', 'Accept': 'application/json', ...(body ? { 'Content-Length': body.length } : {}) }, timeout: 120000 // 2 min for long polling }; const proxyReq = https.request(options, (proxyRes) => { const chunks = []; proxyRes.on('data', c => chunks.push(c)); proxyRes.on('end', () => { resolve({ status: proxyRes.statusCode, headers: proxyRes.headers, body: Buffer.concat(chunks) }); }); }); proxyReq.on('error', reject); proxyReq.on('timeout', () => { proxyReq.destroy(); reject(new Error('Telegram API request timed out')); }); if (body) proxyReq.write(body); proxyReq.end(); }); // Forward response back to Go client res.writeHead(result.status, { 'Content-Type': result.headers['content-type'] || 'application/json', 'Connection': 'close' // Critical for fasthttp compatibility }); res.end(result.body); } catch (err) { console.error('[TG Proxy Error]', err.message); addLog('TG-Proxy', `ERROR: ${err.message}`); res.writeHead(502, { 'Content-Type': 'application/json', 'Connection': 'close' }); res.end(JSON.stringify({ ok: false, error_code: 502, description: `Proxy error: ${err.message}` })); } }); // ========================================== // WEBHOOK PROXY TO PICOCLAW // ========================================== app.use('/webhook', (req, res) => { // We forward webhook requests (e.g. from Telegram to line) // from this front proxy port 7860 down to picoclaw's raw port 18790 console.log(`[Proxy] Webhook intercepted: ${req.method} ${req.url}`); const fetch = require('node-fetch'); // Let's use fetch if available or native http const targetUrl = `http://127.0.0.1:18790/webhook${req.url}`; // Simplistic forwarder just for telegram JSON bodies const options = { method: req.method, headers: { 'Content-Type': req.headers['content-type'] || 'application/json' }, body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body) }; fetch(targetUrl, options) .then(response => response.json()) .then(data => res.status(response.status).json(data)) .catch(err => { console.error(`[Proxy] Webhook forwarding error:`, err); res.status(502).json({ error: "Bad Gateway" }); }); }); // Health check needed by huggingface app.get('/', (req, res) => { res.json({ status: "alive", component: "Picoclaw-Hybrid-Proxy" }); }); // Expose logs for remote debugging app.get('/logs', (req, res) => { res.json(appLogs); }); // Debug endpoint to inspect container filesystem and env vars app.get('/debug', (req, res) => { const configPath = path.resolve(__dirname, 'data/config.json'); const binaryPath = path.resolve(__dirname, 'picoclaw-bin'); const dataDir = path.resolve(__dirname, 'data'); const appDir = __dirname; const info = { __dirname: appDir, configPath, configExists: fs.existsSync(configPath), configContent: null, binaryExists: fs.existsSync(binaryPath), dataDirExists: fs.existsSync(dataDir), dataDirContents: null, appDirContents: null, env: { PICOCLAW_CONFIG: process.env.PICOCLAW_CONFIG || '(not set)', PICOCLAW_HOME: process.env.PICOCLAW_HOME || '(not set)', HOME: process.env.HOME || '(not set)', PORT: process.env.PORT || '(not set)', } }; try { info.appDirContents = fs.readdirSync(appDir); } catch (e) { info.appDirContents = e.message; } try { info.dataDirContents = fs.readdirSync(dataDir); } catch (e) { info.dataDirContents = e.message; } try { info.configContent = fs.readFileSync(configPath, 'utf8'); } catch (e) { info.configContent = e.message; } // Also check ~/.picoclaw/config.json (the default fallback path) const homeConfigPath = path.join(process.env.HOME || '/root', '.picoclaw', 'config.json'); info.homeConfigPath = homeConfigPath; info.homeConfigExists = fs.existsSync(homeConfigPath); try { info.homeConfigContent = fs.readFileSync(homeConfigPath, 'utf8'); } catch (e) { info.homeConfigContent = e.message; } res.json(info); }); // Start Express app.listen(PORT, '0.0.0.0', () => { console.log(`\n🚀 Proxy Server Listening on port ${PORT}`); startPicoclaw(); }); // ========================================== // SPAWN PICOCLAW // ========================================== let picoclawProcess; function startPicoclaw() { const binaryPath = path.resolve(__dirname, 'picoclaw-bin'); if (!fs.existsSync(binaryPath)) { console.error(`❌ ERROR: Picoclaw binary not found at ${binaryPath}. Make sure it compiled during the Docker build stage!`); return; } console.log(`🌀 Spawning Picoclaw agent...`); // Override /etc/resolv.conf at RUNTIME so Go binary can resolve api.telegram.org // (K8s may overwrite the Docker-build-time version) try { fs.writeFileSync('/etc/resolv.conf', 'nameserver 8.8.8.8\nnameserver 1.1.1.1\nnameserver 8.8.4.4\n'); console.log('✅ /etc/resolv.conf overridden with Google DNS'); } catch (e) { console.warn('⚠️ Could not override /etc/resolv.conf:', e.message); } // Provide the config file explicitly picoclawProcess = spawn(binaryPath, ['gateway'], { env: { ...process.env, PICOCLAW_CONFIG: path.resolve(__dirname, 'data/config.json'), PICOCLAW_CHANNELS_TELEGRAM_BASE_URL: "http://127.0.0.1:7860/tg", // Force telego to use net/http instead of the buggy fasthttp by providing a dummy proxy env var. // telego switches to net/http when HTTPS_PROXY is found. // NO_PROXY="*" ensures the standard library immediately ignores the dummy proxy for all requests. HTTPS_PROXY: "http://127.0.0.1:1", NO_PROXY: "*" } }); picoclawProcess.stdout.on('data', (data) => { const str = data.toString(); console.log(`[Pico] ${str}`); addLog('Pico', str.trim()); }); picoclawProcess.stderr.on('data', (data) => { const str = data.toString(); console.error(`[Pico] ${str}`); addLog('Pico ERR', str.trim()); }); picoclawProcess.on('close', (code) => { console.log(`[Pico] Process exited with code ${code}`); addLog('Pico EXIT', `Code ${code}`); }); } // Ensure the Expo app doesn't get HTML 404s for other API endpoints app.all('/api/*', (req, res) => { // Send safe empty mocks for GravityClaw endpoints like history, list sessions etc. res.json({ success: true, history: [], sessions: [], notifications: [], content: "Mocked by HF Proxy" }); }); // Cleanup process.on('SIGTERM', () => { if (picoclawProcess) picoclawProcess.kill(); gemini.close(); process.exit(0); }); process.on('SIGINT', () => { if (picoclawProcess) picoclawProcess.kill(); gemini.close(); process.exit(0); });