moltmobile-gateway/server.js

520 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* MoltMobile Gateway - Voice-enabled mobile extension for Molt
* Fast LLM (MiniMax) + TTS for real-time phone interactions
*/
const http = require('http');
const https = require('https');
const { WebSocketServer, WebSocket } = require('ws');
const fs = require('fs');
// ============================================
// Configuration
// ============================================
const CONFIG = {
// MiniMax API (set via environment)
MINIMAX_API_KEY: process.env.MINIMAX_API_KEY || '',
MINIMAX_API_BASE: 'https://api.minimax.io',
// Models
LLM_MODEL: 'MiniMax-M2.1-lightning', // ~100 tps, very fast
TTS_MODEL: 'speech-2.8-turbo', // Low latency TTS
TTS_VOICE: 'English_expressive_narrator',
// Clawdbot integration (for escalation to Molt/James)
CLAWDBOT_GATEWAY: 'http://localhost:18789',
// Server ports
HTTP_PORT: 9877,
WS_PORT: 9878,
// Phone owner
JOHAN_SIGNAL: '+17272252475',
// Logging
LOG_FILE: '/tmp/moltmobile-gateway.log'
};
// ============================================
// MiniMax API Client
// ============================================
class MiniMaxClient {
constructor(apiKey) {
this.apiKey = apiKey;
this.baseUrl = CONFIG.MINIMAX_API_BASE;
}
/**
* Fast LLM completion - for triage and quick responses
*/
async chat(messages, options = {}) {
const startTime = Date.now();
const response = await fetch(`${this.baseUrl}/anthropic/v1/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: options.model || CONFIG.LLM_MODEL,
max_tokens: options.maxTokens || 500,
system: options.system || PROMPTS.TRIAGE,
messages: messages,
stream: false
})
});
if (!response.ok) {
const error = await response.text();
throw new Error(`MiniMax API error: ${response.status} - ${error}`);
}
const data = await response.json();
const latency = Date.now() - startTime;
log(`🧠 [LLM] Response in ${latency}ms`);
return {
content: data.content?.[0]?.text || '',
latency,
usage: data.usage
};
}
/**
* Text-to-Speech - for voice responses
*/
async speak(text, options = {}) {
const startTime = Date.now();
const response = await fetch(`${this.baseUrl}/v1/t2a_v2`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: options.model || CONFIG.TTS_MODEL,
text: text,
stream: false,
language_boost: 'auto',
output_format: 'hex',
voice_setting: {
voice_id: options.voice || CONFIG.TTS_VOICE,
speed: options.speed || 1.0,
vol: 1,
pitch: 0
},
audio_setting: {
sample_rate: 24000,
bitrate: 128000,
format: 'mp3',
channel: 1
}
})
});
if (!response.ok) {
const error = await response.text();
throw new Error(`MiniMax TTS error: ${response.status} - ${error}`);
}
const data = await response.json();
const latency = Date.now() - startTime;
log(`🔊 [TTS] Generated ${data.extra_info?.audio_length}ms audio in ${latency}ms`);
return {
audio: data.data?.audio, // hex-encoded MP3
audioLength: data.extra_info?.audio_length,
latency
};
}
}
// ============================================
// Prompts for different scenarios
// ============================================
const PROMPTS = {
TRIAGE: `You are MoltMobile, a fast voice assistant for phone calls. You triage incoming events and decide immediate actions.
Your responses MUST be JSON with this structure:
{
"action": "answer|reject|hold|escalate|speak|silent",
"speak_text": "What to say (if action involves speaking)",
"reason": "Brief reason for decision",
"escalate_to": "james" (only if action is escalate)
}
Rules:
- SPAM/telemarketing/unknown: action=answer, then speak the legal disclaimer
- Known contacts: action=answer, greet them warmly
- Emergency keywords: action=escalate immediately
- Be FAST - lives may depend on it`,
SPAM_DISCLAIMER: `This call is being recorded. You have reached a number registered on the National Do Not Call Registry. This is an unsolicited call. We request immediate removal from your calling list. Any recurring call from your organization may result in a formal complaint and penalties up to $10,000 per violation under the Florida Telemarketing Act.`,
KNOWN_CALLER_GREETING: `Hello! This is MoltMobile, Johan's assistant. Johan isn't available right now. How can I help you?`,
SUPER_ATTENTION: `URGENT: Triggering maximum attention mode on device.`
};
// ============================================
// Call Handler - Uses MiniMax for decisions
// ============================================
class CallHandler {
constructor(minimax) {
this.minimax = minimax;
this.activeCalls = new Map();
}
async handleIncoming(callInfo) {
const { number, contact, callId } = callInfo;
log(`📞 [INCOMING] ${contact || number} (${callId})`);
// Build context for LLM
const context = {
number,
contact: contact || 'Unknown',
isSpamLikely: this.isSpamLikely(number, contact),
isKnownContact: !!contact && contact !== 'Unknown',
timestamp: new Date().toISOString()
};
try {
// Get fast decision from MiniMax
const decision = await this.minimax.chat([
{
role: 'user',
content: JSON.stringify({
event: 'incoming_call',
...context
})
}
]);
const action = JSON.parse(decision.content);
log(`🧠 [DECISION] ${action.action}: ${action.reason}`);
return action;
} catch (e) {
log(`❌ [ERROR] Decision failed: ${e.message}`);
// Default: answer and greet
return {
action: 'answer',
speak_text: PROMPTS.KNOWN_CALLER_GREETING,
reason: 'Fallback - decision error'
};
}
}
isSpamLikely(number, contact) {
// Basic spam detection heuristics
if (!contact || contact === 'Unknown' || contact === 'Spam Likely') return true;
if (number?.startsWith('+1800') || number?.startsWith('+1888')) return true;
return false;
}
}
// ============================================
// Global instances
// ============================================
let minimax = null;
let callHandler = null;
const clients = new Map();
let commandQueue = [];
// Logging
const logStream = fs.createWriteStream(CONFIG.LOG_FILE, { flags: 'a' });
function log(msg) {
const ts = new Date().toISOString();
const line = `[${ts}] ${msg}`;
console.log(line);
logStream.write(line + '\n');
}
// ============================================
// WebSocket Server (for Android app)
// ============================================
const wss = new WebSocketServer({ port: CONFIG.WS_PORT });
wss.on('connection', (ws, req) => {
const clientId = `mm_${Date.now()}`;
const ip = req.socket.remoteAddress;
log(`📱 [CONNECT] ${clientId} from ${ip}`);
clients.set(clientId, { ws, ip, connectedAt: Date.now() });
ws.send(JSON.stringify({
type: 'hello',
clientId,
serverTime: Date.now(),
name: 'MoltMobile Gateway'
}));
// Send queued commands
while (commandQueue.length > 0) {
const cmd = commandQueue.shift();
ws.send(JSON.stringify(cmd));
}
ws.on('message', async (data) => {
try {
const msg = JSON.parse(data.toString());
await handleMessage(clientId, msg, ws);
} catch (e) {
log(`❌ [ERROR] ${e.message}`);
}
});
ws.on('close', () => {
log(`📱 [DISCONNECT] ${clientId}`);
clients.delete(clientId);
});
});
async function handleMessage(clientId, msg, ws) {
const type = msg.type || msg.event || 'unknown';
switch (type) {
case 'call.incoming':
case 'call':
if (msg.state === 'incoming' || type === 'call.incoming') {
// Use MiniMax for fast decision
if (callHandler) {
const decision = await callHandler.handleIncoming(msg);
// Execute decision
if (decision.action === 'answer') {
sendCommand('call.answer', { callId: msg.callId });
// Generate and send TTS if we need to speak
if (decision.speak_text && minimax) {
try {
const audio = await minimax.speak(decision.speak_text);
sendCommand('audio.play', {
audioHex: audio.audio,
callId: msg.callId
});
} catch (e) {
log(`❌ [TTS ERROR] ${e.message}`);
}
}
} else if (decision.action === 'reject') {
sendCommand('call.reject', { callId: msg.callId });
} else if (decision.action === 'escalate') {
escalateToJames(`📞 Call from ${msg.contact || msg.number} needs attention: ${decision.reason}`);
}
}
}
log(`📞 [CALL] ${msg.state}: ${msg.contact || msg.number}`);
break;
case 'notification':
log(`🔔 [NOTIF] ${msg.app}: ${msg.title}`);
break;
case 'response':
log(`✅ [RESPONSE] ${msg.commandId}: ${msg.success ? 'OK' : 'FAIL'}`);
break;
default:
log(`⚡ [EVENT:${type}] ${JSON.stringify(msg).slice(0, 200)}`);
}
}
function broadcast(msg) {
const data = JSON.stringify(msg);
let sent = 0;
clients.forEach((client) => {
if (client.ws.readyState === WebSocket.OPEN) {
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] ${command}`);
} else {
log(`📤 [CMD] ${command}${sent} client(s)`);
}
return msg.commandId;
}
async function escalateToJames(message) {
log(`🚨 [ESCALATE] ${message}`);
// TODO: Send via Clawdbot gateway to James
}
// ============================================
// HTTP API
// ============================================
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');
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':
res.end(JSON.stringify({
status: 'ok',
name: 'MoltMobile Gateway',
clients: clients.size,
minimax: !!minimax,
time: new Date().toISOString()
}));
break;
case '/speak':
// Generate TTS and send to phone
if (!minimax) {
res.statusCode = 503;
res.end(JSON.stringify({ error: 'MiniMax not configured' }));
return;
}
try {
const audio = await minimax.speak(body.text, body.options);
sendCommand('audio.play', { audioHex: audio.audio });
res.end(JSON.stringify({ status: 'ok', latency: audio.latency }));
} catch (e) {
res.statusCode = 500;
res.end(JSON.stringify({ error: e.message }));
}
break;
case '/chat':
// Quick LLM response
if (!minimax) {
res.statusCode = 503;
res.end(JSON.stringify({ error: 'MiniMax not configured' }));
return;
}
try {
const response = await minimax.chat([
{ role: 'user', content: body.message }
], { system: body.system });
res.end(JSON.stringify({
response: response.content,
latency: response.latency
}));
} catch (e) {
res.statusCode = 500;
res.end(JSON.stringify({ error: e.message }));
}
break;
case '/attention/super':
// SUPER ATTENTION MODE
log(`🚨 [SUPER ATTENTION] Triggered!`);
sendCommand('attention.super', {
message: body.message || 'URGENT ALERT',
duration: body.duration || 0 // 0 = until acknowledged
});
res.end(JSON.stringify({ status: 'triggered' }));
break;
case '/attention/stop':
sendCommand('attention.stop', {});
res.end(JSON.stringify({ status: 'stopped' }));
break;
case '/browser/open':
sendCommand('browser.open', { url: body.url });
res.end(JSON.stringify({ status: 'sent' }));
break;
case '/call/answer':
sendCommand('call.answer', { callId: body.callId });
res.end(JSON.stringify({ status: 'sent' }));
break;
case '/call/reject':
sendCommand('call.reject', { callId: body.callId });
res.end(JSON.stringify({ status: 'sent' }));
break;
case '/call/hangup':
sendCommand('call.hangup', { callId: body.callId });
res.end(JSON.stringify({ status: 'sent' }));
break;
default:
res.end(JSON.stringify({
name: 'MoltMobile Gateway',
version: '0.2.0',
endpoints: [
'GET /health - Server status',
'POST /speak - TTS via MiniMax',
'POST /chat - Quick LLM response',
'POST /attention/super - SUPER ATTENTION MODE 🚨',
'POST /attention/stop - Stop attention mode',
'POST /browser/open - Open URL on phone',
'POST /call/answer - Answer call',
'POST /call/reject - Reject call',
'POST /call/hangup - Hang up call'
]
}));
}
});
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({}); }
});
});
}
// ============================================
// Startup
// ============================================
httpServer.listen(CONFIG.HTTP_PORT, '0.0.0.0', () => {
log('═══════════════════════════════════════════════');
log(' MoltMobile Gateway - Voice-enabled mobile extension');
log('═══════════════════════════════════════════════');
log(` HTTP API: http://0.0.0.0:${CONFIG.HTTP_PORT}`);
log(` WebSocket: ws://0.0.0.0:${CONFIG.WS_PORT}`);
log(` Log file: ${CONFIG.LOG_FILE}`);
if (CONFIG.MINIMAX_API_KEY) {
minimax = new MiniMaxClient(CONFIG.MINIMAX_API_KEY);
callHandler = new CallHandler(minimax);
log(` MiniMax: ✅ Configured`);
log(` LLM Model: ${CONFIG.LLM_MODEL}`);
log(` TTS Model: ${CONFIG.TTS_MODEL}`);
} else {
log(` MiniMax: ❌ Not configured (set MINIMAX_API_KEY)`);
}
log('═══════════════════════════════════════════════');
});