#!/usr/bin/env node /** * ClawdNode Gateway - Custom protocol for direct phone control * * Protocol: * - Server sends: {"type":"welcome","protocol":"clawdnode/1.0","serverTime":...} * - Client sends: {"type":"hello","client":"clawdnode-android","version":"..."} * - Server sends: {"type":"ready","message":"Connected"} * - Then bidirectional events/commands * * If a client connects speaking Clawdbot protocol, we send a clear error. */ const http = require('http'); const { WebSocketServer, WebSocket } = require('ws'); const fs = require('fs'); const HTTP_PORT = 9877; const WS_PORT = 9878; const LOG_FILE = '/tmp/clawdnode-gateway.log'; const PROTOCOL_VERSION = 'clawdnode/1.0'; // Connected clients const clients = new Map(); let commandQueue = []; const pendingCommands = new Map(); // commandId -> { resolve, timer } // Logging const logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' }); function log(msg) { const ts = new Date().toISOString(); const line = `[${ts}] ${msg}`; console.log(line); logStream.write(line + '\n'); } // Callback for notifications/events (James can hook into this) let onNotification = null; let onCall = null; // ============================================ // WebSocket Server (for Android app) // ============================================ const wss = new WebSocketServer({ port: WS_PORT }); wss.on('connection', (ws, req) => { const clientId = `client_${Date.now()}`; const ip = req.socket.remoteAddress; log(`📱 [CONNECT] ${clientId} from ${ip}`); clients.set(clientId, { ws, ip, connectedAt: Date.now(), ready: false }); // Send our protocol welcome - this identifies us as ClawdNode gateway ws.send(JSON.stringify({ type: 'welcome', protocol: PROTOCOL_VERSION, serverTime: Date.now(), message: 'ClawdNode Gateway - Send {"type":"hello",...} to connect' })); ws.on('message', (data) => { try { const msg = JSON.parse(data.toString()); handleMessage(clientId, msg, ws); } catch (e) { log(`❌ [ERROR] Parse error from ${clientId}: ${e.message}`); ws.send(JSON.stringify({ type: 'error', code: 'PARSE_ERROR', message: 'Invalid JSON' })); } }); ws.on('close', (code, reason) => { log(`📱 [DISCONNECT] ${clientId} code=${code} reason=${reason || 'n/a'}`); clients.delete(clientId); }); ws.on('error', (err) => { log(`❌ [WS_ERROR] ${clientId}: ${err.message}`); }); }); function handleMessage(clientId, msg, ws) { const type = msg.type || msg.event || 'unknown'; const client = clients.get(clientId); // Detect Clawdbot protocol attempts if (msg.method === 'connect' || msg.event === 'connect.handshake' || msg.type === 'req') { log(`⚠️ [PROTOCOL_ERROR] ${clientId} is using Clawdbot protocol`); ws.send(JSON.stringify({ type: 'error', code: 'WRONG_PROTOCOL', message: 'This is ClawdNode Gateway (port 9878), not Clawdbot Gateway (port 18789). ' + 'Update your app to use ws://0.0.0.0:9878 with ClawdNode protocol.', expected: PROTOCOL_VERSION, received: msg.method || msg.type })); ws.close(4000, 'Wrong protocol - use ClawdNode protocol'); return; } switch (type) { case 'hello': // Client hello - mark as ready log(`👋 [HELLO] ${clientId}: ${msg.client || 'unknown'} v${msg.version || '?'}`); if (client) { client.ready = true; client.clientInfo = { client: msg.client, version: msg.version }; } ws.send(JSON.stringify({ type: 'ready', message: 'Connected to ClawdNode Gateway', protocol: PROTOCOL_VERSION })); break; case 'notification': log(`🔔 [NOTIFICATION] ${msg.app || msg.packageName}:`); log(` Title: ${msg.title}`); log(` Text: ${msg.text}`); log(` Actions: ${JSON.stringify(msg.actions || [])}`); log(` ID: ${msg.id}`); if (onNotification) onNotification(msg); break; case 'call': log(`📞 [CALL] ${msg.state}:`); log(` Number: ${msg.number}`); log(` Contact: ${msg.contact || 'Unknown'}`); log(` CallID: ${msg.callId}`); if (onCall) onCall(msg); // Alert on incoming calls if (msg.state === 'incoming' || msg.state === 'ringing') { log(`🚨 INCOMING CALL from ${msg.contact || msg.number}`); } break; case 'call.incoming': log(`📞 [INCOMING CALL]:`); log(` Number: ${msg.number}`); log(` Contact: ${msg.contact || 'Unknown'}`); if (onCall) onCall({ ...msg, state: 'incoming' }); break; case 'response': case 'result': log(`✅ [RESPONSE] Command ${msg.commandId}: ${msg.success ? 'OK' : 'FAILED'}`); if (msg.error) log(` Error: ${msg.error}`); const pending = pendingCommands.get(msg.commandId); if (pending) { clearTimeout(pending.timer); pendingCommands.delete(msg.commandId); pending.resolve(msg); } break; case 'log': log(`📋 [LOG] ${msg.message}`); break; case 'lifecycle': log(`🔄 [LIFECYCLE:${msg.event}] ${msg.message || ''}`); break; default: log(`⚡ [EVENT:${type}] ${JSON.stringify(msg)}`); } } function broadcast(msg) { const data = JSON.stringify(msg); let sent = 0; clients.forEach((client, id) => { if (client.ws.readyState === WebSocket.OPEN && client.ready) { client.ws.send(data); sent++; } }); return sent; } function sendCommand(command, params = {}) { const msg = { type: 'command', command, params, commandId: `cmd_${Date.now()}`, timestamp: Date.now() }; const sent = broadcast(msg); if (sent === 0) { commandQueue.push(msg); log(`📤 [QUEUE] No ready clients, queued: ${command}`); } else { log(`📤 [COMMAND] Sent to ${sent} client(s): ${command}`); } return msg.commandId; } // ============================================ // HTTP API (for James to send commands) // ============================================ const httpServer = http.createServer(async (req, res) => { res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); if (req.method === 'OPTIONS') { res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); res.end(); return; } const body = await parseBody(req); const url = req.url.split('?')[0]; switch (url) { case '/health': const readyClients = Array.from(clients.values()).filter(c => c.ready).length; res.end(JSON.stringify({ status: 'ok', protocol: PROTOCOL_VERSION, clients: clients.size, readyClients, queuedCommands: commandQueue.length, time: new Date().toISOString() })); break; case '/clients': const clientList = []; clients.forEach((c, id) => clientList.push({ id, ip: c.ip, ready: c.ready, clientInfo: c.clientInfo, connectedAt: c.connectedAt, uptime: Date.now() - c.connectedAt })); res.end(JSON.stringify({ clients: clientList })); break; case '/command': if (req.method !== 'POST') { res.statusCode = 405; res.end(JSON.stringify({ error: 'POST required' })); return; } const cmdId = sendCommand(body.command, body.params || {}); if (body.wait !== false) { const response = await new Promise((resolve) => { const timer = setTimeout(() => { pendingCommands.delete(cmdId); resolve({ success: false, error: 'timeout', commandId: cmdId }); }, body.timeout || 10000); pendingCommands.set(cmdId, { resolve, timer }); }); res.end(JSON.stringify(response)); } else { res.end(JSON.stringify({ status: 'sent', commandId: cmdId, clients: clients.size })); } break; case '/notification/action': if (req.method !== 'POST') { res.statusCode = 405; res.end(JSON.stringify({ error: 'POST required' })); return; } const actionCmdId = sendCommand('notification.action', { notificationId: body.notificationId || body.id, action: body.action, replyText: body.replyText || body.text }); log(`🔔 [ACTION] Triggering "${body.action}" on ${body.notificationId || body.id}`); res.end(JSON.stringify({ status: 'sent', commandId: actionCmdId })); break; case '/call/answer': const answerCmdId = sendCommand('call.answer', { callId: body.callId }); log(`📞 [ANSWER] Answering call ${body.callId}`); res.end(JSON.stringify({ status: 'sent', commandId: answerCmdId })); break; case '/call/reject': const rejectCmdId = sendCommand('call.reject', { callId: body.callId }); log(`📞 [REJECT] Rejecting call ${body.callId}`); res.end(JSON.stringify({ status: 'sent', commandId: rejectCmdId })); break; case '/call/hangup': const hangupCmdId = sendCommand('call.hangup', { callId: body.callId }); log(`📞 [HANGUP] Hanging up call ${body.callId}`); res.end(JSON.stringify({ status: 'sent', commandId: hangupCmdId })); break; default: res.end(JSON.stringify({ name: 'ClawdNode Gateway', protocol: PROTOCOL_VERSION, endpoints: [ 'GET /health - Server status', 'GET /clients - Connected clients', 'POST /command - Send raw command', 'POST /notification/action - Trigger notification action', 'POST /call/answer - Answer incoming call', 'POST /call/reject - Reject incoming call', 'POST /call/hangup - Hang up active call' ], note: 'This is ClawdNode Gateway (custom protocol), NOT Clawdbot Gateway.' })); } }); function parseBody(req) { return new Promise((resolve) => { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); } }); }); } httpServer.listen(HTTP_PORT, '0.0.0.0', () => { log('================================================'); log('ClawdNode Gateway - Custom Protocol'); log(`Protocol: ${PROTOCOL_VERSION}`); log('================================================'); log(`HTTP API: http://0.0.0.0:${HTTP_PORT}`); log(`WebSocket: ws://0.0.0.0:${WS_PORT}`); log(`Log file: ${LOG_FILE}`); log(''); log('NOTE: This is NOT Clawdbot Gateway (port 18789).'); log(' Configure ClawdNode app to use port 9878.'); log('================================================'); }); // Export for potential module use module.exports = { sendCommand, broadcast, onNotification, onCall };