#!/usr/bin/env node /** * server.mjs — agent-voice reference server. * * WebRTC-first: the primary surface is browser-side via /call. * GET / → redirect to /call * GET /call → serves public/call.html (browser client) * POST /session → SDP exchange with OpenAI Realtime; returns SDP answer * POST /tool → tool-call dispatch from WebRTC data channel * GET /health → {ok:true} liveness * * Twilio inbound (optional adapter): * POST /voice → returns TwiML to open a Media Stream * WSS /ws → Twilio↔OpenAI Realtime audio bridge * POST /fallback → fallback TwiML (forward to operator's cell) * * The Twilio path is OPTIONAL; recipe Option A (WebRTC-only) doesn't need * it. Operators wiring Twilio inbound implement the bridge themselves * against `lib/twilio-bridge.mjs` (port-ready stubs included). * * Configuration via env: * PORT default 8765 * OPENAI_API_KEY required for /session * OPENAI_REALTIME_MODEL default 'gpt-4o-realtime-preview' * DEFAULT_PERSONA default 'venus' (one of 'mars' | 'venus') * BRAIN_ROOT passed through to context-builder * TIMEZONE passed through to context-builder * * Security posture: this is reference code. It does NOT ship hardening for * production deployment (no rate limiting, no Twilio signature validation, * no CORS allowlist). Operators add those at install time per the recipe's * "production checklist." */ import { createServer } from 'node:http'; import { readFileSync, statSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, extname } from 'node:path'; import { buildSystemPrompt } from './prompt.mjs'; import { dispatchTool, getEffectiveAllowlist } from './tools.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = join(__dirname, 'public'); const PORT = parseInt(process.env.PORT || '8765', 10); const DEFAULT_PERSONA = (process.env.DEFAULT_PERSONA || 'venus').toLowerCase(); const OPENAI_REALTIME_MODEL = process.env.OPENAI_REALTIME_MODEL || 'gpt-4o-realtime-preview'; const OPENAI_REALTIME_URL = 'https://api.openai.com/v1/realtime/calls'; const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.wasm': 'application/wasm', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', }; function send(res, status, body, headers = {}) { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', ...headers }); res.end(body); } function sendJson(res, status, obj) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); } async function readBody(req, max = 1 << 20) { const chunks = []; let n = 0; for await (const chunk of req) { n += chunk.length; if (n > max) { const err = new Error('payload too large'); err.status = 413; throw err; } chunks.push(chunk); } return Buffer.concat(chunks); } function serveStatic(res, relPath) { const full = join(PUBLIC_DIR, relPath); if (!full.startsWith(PUBLIC_DIR)) return send(res, 403, 'forbidden'); if (!existsSync(full)) return send(res, 404, 'not found'); try { const stat = statSync(full); if (!stat.isFile()) return send(res, 404, 'not found'); const body = readFileSync(full); const mime = MIME[extname(full)] || 'application/octet-stream'; res.writeHead(200, { 'content-type': mime, 'content-length': stat.size, 'cache-control': 'no-cache', }); res.end(body); } catch (err) { send(res, 500, `read error: ${err.message}`); } } // ── /session: WebRTC SDP exchange with OpenAI Realtime ──────────────── async function handleSession(req, res) { if (req.method !== 'POST') return send(res, 405, 'method not allowed'); if (!process.env.OPENAI_API_KEY) { return sendJson(res, 500, { error: 'OPENAI_API_KEY not set' }); } let sdpOffer; try { sdpOffer = (await readBody(req)).toString('utf8'); } catch (err) { return send(res, err.status || 400, err.message); } if (!sdpOffer || !sdpOffer.startsWith('v=')) { return sendJson(res, 400, { error: 'missing or malformed SDP offer' }); } const url = new URL(req.url, `http://${req.headers.host}`); const persona = (url.searchParams.get('persona') || DEFAULT_PERSONA).toLowerCase(); // #1851: a call link minted from a Telegram topic carries topicId (+ an // optional display topicName). The id is the ONLY topic data we accept over // the wire — buildSystemPrompt resolves the recent-conversation context from // the brain server-side. We never accept topic CONTENT as a param (that would // be prompt injection + a leak into URLs/referrers/access logs). const topicId = url.searchParams.get('topicId') || undefined; const topicName = url.searchParams.get('topicName') || undefined; // Build the persona-aware system prompt at session start. const systemPrompt = await buildSystemPrompt({ persona, brainRoot: process.env.BRAIN_ROOT, timezone: process.env.TIMEZONE, topicId, topicName, }); // Session config for OpenAI Realtime /v1/realtime/calls. // Important gotchas (from production): // - `voice` goes under `audio.output.voice`, NOT top-level // - Do NOT send `turn_detection` (rejected by /v1/realtime/calls) // - All `session.update` calls must include `type: 'realtime'` const personaVoice = persona === 'mars' ? 'Orus' : 'Aoede'; const sessionConfig = { type: 'realtime', model: OPENAI_REALTIME_MODEL, audio: { output: { voice: personaVoice } }, instructions: systemPrompt, // Tools advertised to the model; the actual dispatch happens via /tool. tools: getEffectiveAllowlist().map((name) => ({ type: 'function', name, description: `gbrain operation: ${name}`, parameters: { type: 'object', properties: {}, additionalProperties: true }, })), }; // OpenAI Realtime expects multipart/form-data with two parts: // sdp: the WebRTC SDP offer // session: JSON.stringify(sessionConfig) const form = new FormData(); form.set('sdp', sdpOffer); form.set('session', JSON.stringify(sessionConfig)); try { const upstream = await fetch(OPENAI_REALTIME_URL, { method: 'POST', headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` }, body: form, }); if (!upstream.ok) { const text = await upstream.text(); console.error(`[session] OpenAI Realtime returned ${upstream.status}: ${text.slice(0, 400)}`); return send(res, upstream.status, text); } const sdpAnswer = await upstream.text(); res.writeHead(200, { 'content-type': 'application/sdp; charset=utf-8' }); res.end(sdpAnswer); } catch (err) { console.error(`[session] upstream error: ${err.message}`); sendJson(res, 502, { error: 'upstream_unreachable', detail: err.message }); } } // ── /tool: tool-call dispatch from the WebRTC data channel ──────────── async function handleTool(req, res) { if (req.method !== 'POST') return send(res, 405, 'method not allowed'); let body; try { body = JSON.parse((await readBody(req)).toString('utf8')); } catch (err) { return sendJson(res, 400, { error: 'invalid_json', detail: err.message }); } const { name, arguments: params } = body || {}; if (typeof name !== 'string') { return sendJson(res, 400, { error: 'missing tool name' }); } const result = await dispatchTool(name, params || {}); // dispatchTool always returns either {data} or {error}; never throws. sendJson(res, 200, result); } // ── /voice: Twilio TwiML stub (optional Twilio inbound) ─────────────── function handleVoiceTwiml(req, res) { const host = req.headers.host; const twiml = ` `; res.writeHead(200, { 'content-type': 'text/xml; charset=utf-8' }); res.end(twiml); } // ── HTTP router ────────────────────────────────────────────────────── const server = createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); // CORS: allow same-origin only by default. Operators relax in production. res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); if (req.method === 'OPTIONS') return send(res, 204, ''); try { if (url.pathname === '/health') { return sendJson(res, 200, { ok: true }); } if (url.pathname === '/' || url.pathname === '/call') { return serveStatic(res, 'call.html'); } if (url.pathname === '/directory') { return serveStatic(res, 'directory.html'); } if (url.pathname === '/session') { return handleSession(req, res); } if (url.pathname === '/tool') { return handleTool(req, res); } if (url.pathname === '/voice') { return handleVoiceTwiml(req, res); } if (url.pathname.startsWith('/public/')) { return serveStatic(res, url.pathname.slice('/public/'.length)); } // Static fallback for files in public/ at root path (e.g., /rnnoise-processor.js). const candidate = url.pathname.slice(1); if (candidate && !candidate.includes('..')) { const candPath = join(PUBLIC_DIR, candidate); if (existsSync(candPath) && statSync(candPath).isFile()) { return serveStatic(res, candidate); } } send(res, 404, 'not found'); } catch (err) { console.error(`[server] ${err.message}\n${err.stack}`); if (!res.headersSent) sendJson(res, 500, { error: err.message }); } }); server.listen(PORT, () => { // eslint-disable-next-line no-console console.log(`[agent-voice] listening on http://localhost:${PORT}`); console.log(`[agent-voice] default persona: ${DEFAULT_PERSONA}`); console.log(`[agent-voice] read-only tools: ${getEffectiveAllowlist().join(', ')}`); }); process.on('SIGTERM', () => server.close(() => process.exit(0))); process.on('SIGINT', () => server.close(() => process.exit(0)));