#!/usr/bin/env python3 """APESS voice client host + gateway proxy. Serves index.html AND proxies POST /webhook to the node's ZeroClaw gateway on the same origin — so the browser needs no CORS and no bearer token, and we reuse the reliable /webhook path (the /ws/chat path drops the peripheral matrix tool). Usage: NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=zc_xxx python3 serve.py [port] - NODE_URL node gateway base (default http://127.0.0.1:8080; via `adb forward tcp:8080 tcp:8080` over USB, or the board's LAN IP:8080 on the day). - NODE_TOKEN gateway bearer token (kept server-side, never sent to the browser). - port local port to serve on (default 8090). Open http://localhost:. localhost is a secure context, so the browser grants mic access; the proxy hop is server-side, so there is no CORS. One process, one origin. """ import os, sys, json, urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer HERE = os.path.dirname(os.path.abspath(__file__)) NODE_URL = os.environ.get("NODE_URL", "http://127.0.0.1:8080").rstrip("/") NODE_TOKEN = os.environ.get("NODE_TOKEN", "") PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8090 # Optional ElevenLabs TTS (server-side; key never reaches the browser). If unset, # the client falls back to the browser's built-in speechSynthesis voice. ELEVEN_KEY = os.environ.get("ELEVENLABS_API_KEY", "") ELEVEN_VOICE = os.environ.get("ELEVENLABS_VOICE_ID", "EXAVITQu4vr4xnSDxMaL") # Sarah (free-tier usable) ELEVEN_MODEL = os.environ.get("ELEVENLABS_MODEL", "eleven_turbo_v2_5") class H(BaseHTTPRequestHandler): def log_message(self, *a): pass # quiet def _send(self, code, body, ctype="application/json"): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path == "/ping": return self._send(200, b'{"ok":true}') if self.path == "/config": mode = "elevenlabs" if ELEVEN_KEY else "browser" return self._send(200, json.dumps({"tts": mode}).encode()) path = "/index.html" if self.path in ("/", "") else self.path.split("?")[0] fp = os.path.normpath(os.path.join(HERE, path.lstrip("/"))) if not fp.startswith(HERE) or not os.path.isfile(fp): return self._send(404, b"not found", "text/plain") ctype = "text/html" if fp.endswith(".html") else "text/plain" with open(fp, "rb") as f: self._send(200, f.read(), ctype) def do_POST(self): if self.path == "/tts": return self._tts() if not self.path.startswith("/webhook"): return self._send(404, b'{"error":"not found"}') n = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(n) q = self.path[len("/webhook"):] # keep ?agent=... req = urllib.request.Request( f"{NODE_URL}/webhook{q}", data=body, method="POST", headers={"Content-Type": "application/json", "Authorization": f"Bearer {NODE_TOKEN}"}) try: with urllib.request.urlopen(req, timeout=60) as resp: self._send(resp.status, resp.read()) except urllib.error.HTTPError as e: self._send(e.code, e.read() or b'{"error":"upstream"}') except Exception as e: self._send(502, json.dumps({"error": str(e)}).encode()) def _tts(self): if not ELEVEN_KEY: return self._send(503, b'{"error":"tts disabled"}') n = int(self.headers.get("Content-Length", 0)) try: text = json.loads(self.rfile.read(n)).get("text", "").strip() except Exception: text = "" if not text: return self._send(400, b'{"error":"no text"}') payload = json.dumps({ "text": text, "model_id": ELEVEN_MODEL, "voice_settings": {"stability": 0.5, "similarity_boost": 0.75}, }).encode() req = urllib.request.Request( f"https://api.elevenlabs.io/v1/text-to-speech/{ELEVEN_VOICE}", data=payload, method="POST", headers={"xi-api-key": ELEVEN_KEY, "Content-Type": "application/json", "Accept": "audio/mpeg"}) try: with urllib.request.urlopen(req, timeout=30) as resp: self._send(200, resp.read(), "audio/mpeg") except urllib.error.HTTPError as e: self._send(e.code, e.read() or b'{"error":"tts upstream"}') except Exception as e: self._send(502, json.dumps({"error": str(e)}).encode()) if __name__ == "__main__": if not NODE_TOKEN: print("WARN: NODE_TOKEN is empty — gateway calls will 401.", file=sys.stderr) print(f"APESS voice client → {NODE_URL}") print(f"TTS: {'ElevenLabs ('+ELEVEN_VOICE+')' if ELEVEN_KEY else 'browser (speechSynthesis)'}") print(f"open http://localhost:{PORT}") ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()