Spaces:
Runtime error
Runtime error
Luis Milke
fix(telegram): explicitly force PICOCLAW_CHANNELS_TELEGRAM_BASE_URL to avoid env override
a6057c9 | 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); | |
| }); | |