diff --git a/ARIA-AGENT-v0.0.0.1.zip b/ARIA-AGENT-v0.0.0.1.zip deleted file mode 100644 index 6c9a38b..0000000 Binary files a/ARIA-AGENT-v0.0.0.1.zip and /dev/null differ diff --git a/ARIA-AGENT-v0.0.0.2.zip b/ARIA-AGENT-v0.0.0.2.zip deleted file mode 100644 index 36beac3..0000000 Binary files a/ARIA-AGENT-v0.0.0.2.zip and /dev/null differ diff --git a/CHANGELOG.md b/CHANGELOG.md index f237901..9895200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c --- +## [0.0.0.4] — 2026-03-11 + +### Geändert + +**Bridge → aria-core: OpenClaw Gateway Protokoll** +- Bridge nutzt jetzt das echte OpenClaw Gateway WebSocket-Protokoll (Port 18789 statt 8080) +- Vollständiger Handshake: `connect.challenge` → `connect` Request (mit Auth-Token) → `hello-ok` +- Nachrichten über `chat.send` Method mit `sessionKey` und `idempotencyKey` +- Antworten über `chat:final` Events (statt custom JSON) +- Streaming-Support vorbereitet (`chat:delta` Events werden empfangen) +- Fehlerbehandlung für `chat:error` Events — werden an die App weitergeleitet + +**Docker-Compose Fixes** +- `OPENCLAW_GATEWAY_BIND=0.0.0.0` — Gateway bindet auf alle Interfaces (sonst nur 127.0.0.1, Bridge kann nicht zugreifen) +- `OPENCLAW_GATEWAY_TOKEN` statt `AUTH_TOKEN` — korrekter Env-Var-Name fuer OpenClaw Gateway Auth +- `ARIA_AUTH_TOKEN` an Bridge-Container durchgereicht — Bridge authentifiziert sich am Gateway + +--- + ## [0.0.0.3] — 2026-03-09 ### Geändert @@ -50,6 +69,7 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c **Docker & Infrastruktur** - OpenClaw Image fix: `openclaw/openclaw:latest` → `ghcr.io/openclaw/openclaw:latest` +- Proxy fix: Binary heißt `claude-max-api`, braucht `@anthropic-ai/claude-code` als Peer-Dependency - Proxy Binary-Name fix: `claude-max-api-proxy` → `claude-max-api` (npm-Paket heißt anders als die Binary) - `libportaudio2` in Bridge Dockerfile hinzugefügt — `sounddevice` braucht PortAudio - `aria-data/config/aria.env.example` hinzugefügt — Voice Bridge Konfigurationsvorlage @@ -80,6 +100,12 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c - `network_security_config.xml` hinzugefuegt — Android 9+ blockiert sonst `ws://` (Cleartext) - Verbindungslog im Settings-Tab — zeigt jeden Verbindungsversuch, Fehler, Fallback (scrollbar, max 200px) - Gespeicherte Config wird beim Start in die Einstellungsfelder geladen +- Fix: TLS-Fallback erzeugte Doppel-Verbindungen (onerror + onclose beide reconnected) + +**RVS — Ghost-Client Fix** +- Heartbeat-Intervall 30s → 15s, Cleanup 60s → 30s — tote Clients werden schneller entfernt +- `heartbeat` als erlaubter Nachrichtentyp hinzugefuegt — App-Heartbeats halten Verbindung lebendig +- App-seitiger JSON-Heartbeat zaehlt als Lebenszeichen (zusaetzlich zu WebSocket Ping/Pong) **Neues Script: `get-voices.sh`** - Lädt Piper Stimmen (Ramona + Thorsten) von HuggingFace herunter diff --git a/Josef Behrens.conf b/Josef Behrens.conf deleted file mode 100644 index fcb6d69..0000000 --- a/Josef Behrens.conf +++ /dev/null @@ -1,11 +0,0 @@ -[Interface] -Address = 10.252.1.21/32 -PrivateKey = 2JmAeJQ1wL+nfaAVp32RiEsPFcaoXVtZh/p7pqHGCl4= -MTU = 1450 - -[Peer] -PublicKey = IHBroF1ChESXWQQ+2RC4DmrNoHQl54Hc/xhH+iYLTBA= -PresharedKey = A1i59KCEjvwtx9J03pkcqDdGP7Jhr4PcbA5Um32iMoY= -AllowedIPs = 192.168.0.0/24 -Endpoint = stb-er.selfhost.eu:51820 -PersistentKeepalive = 15 diff --git a/README.md b/README.md index 2d58dad..e4b01a5 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,8 @@ services: - proxy environment: - CANVAS_HOST=127.0.0.1 - - AUTH_TOKEN=${ARIA_AUTH_TOKEN} + - OPENCLAW_GATEWAY_BIND=0.0.0.0 # Bridge muss von Docker-Netz zugreifen + - OPENCLAW_GATEWAY_TOKEN=${ARIA_AUTH_TOKEN} - OPENAI_API_KEY=not-needed - OPENAI_BASE_URL=http://proxy:3456/v1 - DEFAULT_MODEL=openai/claude-sonnet-4-6 @@ -249,6 +250,7 @@ services: - /dev/snd environment: - PULSE_SERVER=unix:/run/user/1000/pulse/native + - ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-} - RVS_HOST=${RVS_HOST:-} - RVS_PORT=${RVS_PORT:-443} - RVS_TLS=${RVS_TLS:-true} @@ -406,7 +408,19 @@ cp .env.example .env # → RVS_HOST + RVS_PORT eintragen (z.B. rvs.hackersoft.de / 443) ``` -### 3. Konfiguration & Stimmen +### 3. Claude CLI verbinden (Proxy-Auth) + +Der Proxy-Container leitet API-Calls über deine Claude Max Subscription. +Dafür muss die Claude CLI einmalig auf der VM eingeloggt sein: + +```bash +npm install -g @anthropic-ai/claude-code +claude login +# → Zeigt einen Link + Code — im Browser öffnen und bestätigen +# → Credentials landen in ~/.config/claude/ (wird read-only in den Container gemounted) +``` + +### 4. Konfiguration & Stimmen ```bash # Voice Bridge Konfiguration anlegen @@ -417,7 +431,7 @@ cp aria-data/config/aria.env.example aria-data/config/aria.env ./get-voices.sh ``` -### 4. Token generieren & starten +### 5. Token generieren & starten ```bash # Token erzeugen — schreibt RVS_TOKEN automatisch in .env, zeigt QR-Code @@ -427,11 +441,11 @@ cp aria-data/config/aria.env.example aria-data/config/aria.env docker compose up -d ``` -### 5. App verbinden +### 6. App verbinden App öffnen → QR-Code scannen → "ARIA, hörst du mich?" 🎙️ -> Alles was über diese fünf Schritte hinausgeht macht ARIA selbst. +> Alles was über diese sechs Schritte hinausgeht macht ARIA selbst. --- diff --git a/android/src/services/rvs.ts b/android/src/services/rvs.ts index 6b6396c..518dc47 100644 --- a/android/src/services/rvs.ts +++ b/android/src/services/rvs.ts @@ -224,10 +224,17 @@ class RVSConnection { // TLS-Fallback: Wenn wss:// fehlschlaegt, auf ws:// wechseln if (this.config?.useTLS && !this.usingTLSFallback) { this.usingTLSFallback = true; + // shouldReconnect kurz deaktivieren damit onclose keinen + // parallelen Reconnect ausloest — wir machen das selbst + this.shouldReconnect = false; this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)'); this.clearTimers(); - this.ws?.close(); + if (this.ws) { + this.ws.onclose = null; // onclose-Handler entfernen um Doppel-Reconnect zu verhindern + try { this.ws.close(); } catch (_) {} + } this.ws = null; + this.shouldReconnect = true; this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS; this.establishConnection(); return; diff --git a/aria-data/config/TOOLING.md b/aria-data/config/TOOLING.md.example similarity index 100% rename from aria-data/config/TOOLING.md rename to aria-data/config/TOOLING.md.example diff --git a/aria-data/config/aria.env.example b/aria-data/config/aria.env.example index 64f6773..25d67b7 100644 --- a/aria-data/config/aria.env.example +++ b/aria-data/config/aria.env.example @@ -1,4 +1,10 @@ -OPENCLAW_URL=http://aria-core:18789 +# Bridge → aria-core (OpenClaw Gateway) +# Standard: ws://aria-core:18789 (internes Docker-Netz) +ARIA_CORE_WS=ws://aria-core:18789 + +# Piper TTS Stimmen PIPER_RAMONA=/voices/de_DE-ramona-low.onnx PIPER_THORSTEN=/voices/de_DE-thorsten-high.onnx -WAKE_WORD=aria \ No newline at end of file + +# Wake-Word +WAKE_WORD=aria diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index d7fec93..bf6e764 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -25,6 +25,7 @@ import signal import ssl import sys import tempfile +import uuid import wave from pathlib import Path from typing import Optional @@ -51,7 +52,8 @@ logger = logging.getLogger("aria-bridge") CONFIG_PATH = Path("/config/aria.env") VOICES_DIR = Path("/voices") -CORE_WS_URL = os.getenv("ARIA_CORE_WS", "ws://aria:8080") +CORE_WS_URL = os.getenv("ARIA_CORE_WS", "ws://aria-core:18789") +CORE_AUTH_TOKEN = os.getenv("ARIA_AUTH_TOKEN", "") # OpenClaw Gateway Token RVS_HOST = os.getenv("RVS_HOST", "") # z.B. rvs.hackersoft.de RVS_PORT = os.getenv("RVS_PORT", "443") # Port des RVS RVS_TLS = os.getenv("RVS_TLS", "true") # true = wss://, false = ws:// @@ -405,6 +407,9 @@ class ARIABridge: def __init__(self) -> None: self.config = load_config() self.ws_url = self.config.get("ARIA_CORE_WS", CORE_WS_URL) + self.core_auth_token = self.config.get("ARIA_AUTH_TOKEN", CORE_AUTH_TOKEN) + self._req_id_counter = 0 + self._session_key = "aria-bridge" # Feste Session fuer die Bridge # RVS-Verbindungsinfo aus Config oder Env rvs_host = self.config.get("RVS_HOST", RVS_HOST) rvs_port = self.config.get("RVS_PORT", RVS_PORT) @@ -473,19 +478,99 @@ class ARIABridge: logger.warning(" Setze RVS_HOST, RVS_PORT, RVS_TOKEN in /config/aria.env") logger.info("Modus: %s %s", self.current_mode.config.emoji, self.current_mode.config.name) - # ── aria-core Verbindung ───────────────────────────────── + # ── aria-core Verbindung (OpenClaw Gateway Protokoll) ─── + + def _next_req_id(self) -> str: + """Erzeugt eine eindeutige Request-ID fuer das OpenClaw-Protokoll.""" + self._req_id_counter += 1 + return f"bridge-{self._req_id_counter}" + + async def _openclaw_handshake(self, ws: websockets.WebSocketClientProtocol) -> bool: + """Fuehrt den OpenClaw Gateway Handshake durch. + + 1. Wartet auf connect.challenge Event vom Gateway + 2. Sendet connect Request mit Auth-Token + 3. Wartet auf hello-ok Response + + Returns: + True wenn Handshake erfolgreich, sonst False. + """ + try: + # Schritt 1: Auf Challenge warten (max 10s) + raw = await asyncio.wait_for(ws.recv(), timeout=10.0) + challenge = json.loads(raw) + + if challenge.get("type") != "event" or challenge.get("event") != "connect.challenge": + logger.error("[core] Unerwartete erste Nachricht: %s", raw[:200]) + return False + + nonce = challenge.get("payload", {}).get("nonce", "") + logger.info("[core] Challenge empfangen (nonce: %s...)", nonce[:8] if nonce else "?") + + # Schritt 2: Connect Request senden + connect_req = { + "type": "req", + "id": self._next_req_id(), + "method": "connect", + "params": { + "minProtocol": 3, + "maxProtocol": 3, + "client": { + "id": "aria-bridge", + "version": "0.0.3", + "platform": "linux", + "mode": "operator", + }, + "role": "operator", + "scopes": ["operator.read", "operator.write"], + "caps": ["voice"], + "commands": [], + "permissions": {}, + "auth": {"token": self.core_auth_token} if self.core_auth_token else {}, + "locale": "de-DE", + "userAgent": "aria-bridge/0.0.3", + }, + } + await ws.send(json.dumps(connect_req)) + logger.info("[core] Connect-Request gesendet") + + # Schritt 3: Auf hello-ok warten (max 10s) + raw = await asyncio.wait_for(ws.recv(), timeout=10.0) + response = json.loads(raw) + + if response.get("type") == "res" and response.get("ok"): + logger.info("[core] Handshake erfolgreich — hello-ok empfangen") + return True + else: + error = response.get("error", response) + logger.error("[core] Handshake fehlgeschlagen: %s", json.dumps(error)[:200]) + return False + + except asyncio.TimeoutError: + logger.error("[core] Handshake-Timeout (10s)") + return False + except Exception: + logger.exception("[core] Handshake-Fehler") + return False async def connect_to_core(self) -> None: - """Persistente WebSocket-Verbindung zu aria-core mit Auto-Reconnect.""" + """Persistente WebSocket-Verbindung zu aria-core (OpenClaw Gateway).""" retry_delay = 2 while self.running: try: logger.info("[core] Verbinde: %s", self.ws_url) async with websockets.connect(self.ws_url) as ws: + # OpenClaw Handshake durchfuehren + if not await self._openclaw_handshake(ws): + logger.error("[core] Handshake fehlgeschlagen — Reconnect") + await asyncio.sleep(retry_delay) + retry_delay = min(retry_delay * 2, 30) + continue + self.ws_core = ws retry_delay = 2 - logger.info("[core] Verbunden") + logger.info("[core] Verbunden und authentifiziert") async for message in ws: await self._handle_core_message(message) @@ -493,7 +578,7 @@ class ARIABridge: except websockets.ConnectionClosed: logger.warning("[core] Verbindung verloren") except ConnectionRefusedError: - logger.warning("[core] Nicht erreichbar") + logger.warning("[core] Nicht erreichbar (%s)", self.ws_url) except Exception: logger.exception("[core] WebSocket-Fehler") finally: @@ -505,10 +590,11 @@ class ARIABridge: retry_delay = min(retry_delay * 2, 30) async def _handle_core_message(self, raw_message: str) -> None: - """Verarbeitet Nachrichten von aria-core. + """Verarbeitet Nachrichten von aria-core (OpenClaw Gateway Protokoll). - - Leitet Antworten an die App weiter (via RVS) - - Sprachausgabe ueber TTS (wenn Modus erlaubt) + Unterstuetzte Frame-Typen: + - event: chat:delta (Streaming-Tokens), chat:final (fertige Antwort) + - res: Antworten auf Requests (chat.send Acknowledgment) """ try: message = json.loads(raw_message) @@ -516,13 +602,71 @@ class ARIABridge: logger.error("[core] Ungueltige JSON: %s", raw_message[:100]) return - text = message.get("text", "") - metadata = message.get("metadata", {}) + frame_type = message.get("type", "") + + # ── Response auf unsere Requests (z.B. chat.send Ack) ── + if frame_type == "res": + req_id = message.get("id", "") + if message.get("ok"): + logger.debug("[core] Request %s bestätigt", req_id) + else: + error = message.get("error", "Unbekannt") + logger.error("[core] Request %s fehlgeschlagen: %s", req_id, error) + return + + # ── Events vom Gateway ── + if frame_type != "event": + logger.debug("[core] Unbekannter Frame-Typ: %s", frame_type) + return + + event_name = message.get("event", "") + payload = message.get("payload", {}) + + if event_name == "chat:delta": + # Streaming-Delta — fuer spaeter (Live-Typing in der App) + delta = payload.get("delta", payload.get("text", "")) + if delta: + logger.debug("[core] Delta: '%s'", delta[:40]) + return + + if event_name == "chat:final": + # Fertige Antwort von aria-core + text = payload.get("text", payload.get("message", "")) + if not text: + logger.warning("[core] chat:final ohne Text: %s", json.dumps(payload)[:200]) + return + + logger.info("[core] Antwort: '%s'", text[:80]) + await self._process_core_response(text, payload) + return + + if event_name == "chat:error": + error = payload.get("error", payload.get("message", "Unbekannt")) + logger.error("[core] Chat-Fehler: %s", error) + # Fehler auch an die App melden + await self._send_to_rvs({ + "type": "chat", + "payload": { + "text": f"[Fehler] {error}", + "sender": "aria", + }, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + return + + # Andere Events loggen (presence, tick, etc.) + logger.debug("[core] Event: %s", event_name) + + async def _process_core_response(self, text: str, payload: dict) -> None: + """Verarbeitet eine fertige Antwort von aria-core. + + - Leitet Antwort an die App weiter (via RVS) + - Sprachausgabe ueber TTS (wenn Modus erlaubt) + """ + metadata = payload.get("metadata", {}) is_critical = metadata.get("critical", False) requested_voice = metadata.get("voice") - logger.info("[core] Nachricht: '%s'", text[:80]) - # Modus-Wechsel pruefen new_mode = detect_mode_switch(text) if new_mode is not None: @@ -532,7 +676,6 @@ class ARIABridge: self.current_mode.config.emoji, self.current_mode.config.name, ) - # Modus-Aenderung auch an die App senden await self._send_to_rvs({ "type": "mode", "payload": {"mode": self.current_mode.name}, @@ -576,21 +719,26 @@ class ARIABridge: logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name) async def send_to_core(self, text: str, source: str = "bridge") -> None: - """Sendet Text an aria-core.""" + """Sendet Text an aria-core (OpenClaw chat.send Protokoll).""" if self.ws_core is None: logger.error("[core] Nicht verbunden — Nachricht verworfen: '%s'", text[:60]) return + req_id = self._next_req_id() message = json.dumps({ - "type": "voice_input" if source == "bridge" else "chat_input", - "text": text, - "mode": self.current_mode.name, - "source": source, + "type": "req", + "id": req_id, + "method": "chat.send", + "params": { + "sessionKey": self._session_key, + "text": text, + "idempotencyKey": str(uuid.uuid4()), + }, }) try: await self.ws_core.send(message) - logger.info("[core] Gesendet (%s): '%s'", source, text[:80]) + logger.info("[core] chat.send (%s, id=%s): '%s'", source, req_id, text[:80]) except Exception: logger.exception("[core] Sendefehler") diff --git a/docker-compose.yml b/docker-compose.yml index 993c763..3e2059a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,8 @@ services: - proxy environment: - CANVAS_HOST=127.0.0.1 - - AUTH_TOKEN=${ARIA_AUTH_TOKEN} + - OPENCLAW_GATEWAY_BIND=0.0.0.0 # Bridge muss von Docker-Netz zugreifen (kein Port-Mapping nach aussen) + - OPENCLAW_GATEWAY_TOKEN=${ARIA_AUTH_TOKEN} - OPENAI_API_KEY=not-needed - OPENAI_BASE_URL=http://proxy:3456/v1 - DEFAULT_MODEL=openai/claude-sonnet-4-6 @@ -54,6 +55,7 @@ services: - /dev/snd environment: - PULSE_SERVER=unix:/run/user/1000/pulse/native + - ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-} - RVS_HOST=${RVS_HOST:-} - RVS_PORT=${RVS_PORT:-443} - RVS_TLS=${RVS_TLS:-true} diff --git a/rvs/server.js b/rvs/server.js index 180a6d9..fef876b 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -8,7 +8,7 @@ const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10); // Erlaubte Nachrichtentypen — alles andere wird verworfen const ALLOWED_TYPES = new Set([ - "chat", "audio", "file", "location", "mode", "log", "event", + "chat", "audio", "file", "location", "mode", "log", "event", "heartbeat", ]); // Token-Raum: token -> { clients: Set } @@ -129,13 +129,14 @@ function registerClient(ws, token) { }); } -// ── Heartbeat — hält Verbindungen am Leben ────────────────────────── +// ── Heartbeat — hält Verbindungen am Leben, räumt tote auf ────────── -const HEARTBEAT_INTERVAL = 30_000; +const HEARTBEAT_INTERVAL = 15_000; const heartbeat = setInterval(() => { for (const client of wss.clients) { if (client.isAlive === false) { + log(`Toter Client entfernt (kein Pong)`); client.terminate(); continue; } @@ -147,10 +148,18 @@ const heartbeat = setInterval(() => { wss.on("connection", (ws) => { ws.isAlive = true; ws.on("pong", () => { ws.isAlive = true; }); + // App-seitiger Heartbeat (JSON) zaehlt auch als lebendig + const origOnMessage = ws._events?.message; + ws.on("message", (raw) => { + try { + const msg = JSON.parse(raw); + if (msg.type === "heartbeat") ws.isAlive = true; + } catch {} + }); }); -// Aufräumen alle 60 Sekunden -const cleanup = setInterval(cleanupRooms, 60_000); +// Aufräumen alle 30 Sekunden (statt 60) +const cleanup = setInterval(cleanupRooms, 30_000); wss.on("close", () => { clearInterval(heartbeat);