picoclaww / server.js
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);
});