Skill: fbn3799/clack (version 1.5.3)

--- name: clack version: 1.5.3 description: Deploy and manage Clack, a voice relay server for OpenClaw. Bridges voice input (WebSocket) through STT → OpenClaw agent → TTS, enabling real-time voice conversations with your agent. Supports ElevenLabs, OpenAI, and Deepgram for STT/TTS. Per-session provider selection — users can independently choose STT and TTS providers (including on-device) from the app settings. Encrypted connections via Domain (SSL) or Tailscale. Supports local speech mode where STT/TTS run on-device and only LLM calls go through the server. Use when a user wants to set up voice chat, voice relay, voice interface, Clack, or talk to their agent by voice. metadata: openclaw: requires: env: - OPENCLAW_GATEWAY_TOKEN - RELAY_AUTH_TOKEN bins: - python3 - systemctl primaryEnv: OPENCLAW_GATEWAY_TOKEN os: - linux emoji: "🎙️" homepage: https://github.com/fbn3799/clack-skill --- # Clack WebSocket relay server that enables real-time voice conversations with an OpenClaw agent. **Flow:** Client audio (PCM 16kHz/16-bit/mono) → STT → OpenClaw Gateway → TTS → PCM audio back to client. **Per-session provider selection:** The client can independently choose STT and TTS providers per call — any combination of on-device (Apple speech frameworks) and server-side providers (ElevenLabs, OpenAI, Deepgram). The server auto-detects all available providers based on configured API keys and exposes them via `/info`. ## Prerequisites - Python 3.10+ - API key for at least one provider (ElevenLabs, OpenAI, or Deepgram) — not needed for local speech mode - OpenClaw Gateway with `chatCompletions` endpoint enabled - Root/sudo access (for systemd) - **Secure connection**: Domain with SSL (recommended) or Tailscale ## Setup Run the setup script. It creates a venv, installs deps, prompts for API keys, configures a systemd service, and optionally sets up SSL. ```bash sudo bash scripts/setup.sh ``` The script auto-detects your OpenClaw gateway config and interactively prompts for provider API keys (ElevenLabs, OpenAI, Deepgram — all optional). On re-runs, existing keys can be kept, updated, or deleted. ### Options ```bash bash scripts/setup.sh [--port 9878] [--domain clack.example.com] ``` | Flag | Default | Description | |------|---------|-------------| | `--port` | `9878` | Relay server port | | `--domain` | *(none)* | Domain for SSL setup (enables WSS) | ### Connection modes All connections are encrypted. The app supports two modes: **Domain with SSL (recommended):** ```bash bash scripts/setup.sh --domain clack.yourdomain.com # → wss://clack.yourdomain.com/voice ``` Requires a DNS A record pointing the domain to your server IP. The setup script auto-configures SSL via Caddy. You can use a free domain from [DuckDNS](https://www.duckdns.org) or your own. **Tailscale:** ```bash # Install Tailscale on your server, then connect from the app using your Tailscale IP # → ws://100.x.x.x:9878/voice (encrypted at network level) ``` No domain or SSL setup needed. Tailscale encrypts all traffic at the network layer. Install Tailscale on both your server and phone, then use the server's Tailscale IP in the app. **Security note:** Port 9878 should be firewalled from the public internet. Only allow access via localhost (for Caddy reverse proxy) and Tailscale. The app does not support unencrypted public connections. ### Enable OpenClaw Gateway endpoint The gateway must have `chatCompletions` enabled. Apply this config patch: ```json {"http": {"endpoints": {"chatCompletions": {"enabled": true}}}} ``` ## Management ```bash clack status # Check service status clack restart # Restart the server clack logs # Tail logs clack pair # Generate a new pairing code clack update # Pull latest code and restart clack setup # Re-run interactive setup (add SSL later, update keys, etc.) clack uninstall # Remove service and venv ``` ## Client App 📱 **iOS** — Available on the [App Store](https://clack-app.com) (or build from source at [github.com/fbn3799/clack-app](https://github.com/fbn3799/clack-app)) 🤖 **Android** — Coming soon! ## Security ### Authentication All endpoints except `GET /health` and `POST /pair` require a valid auth token (`RELAY_AUTH_TOKEN`). Tokens are verified using constant-time HMAC comparison to prevent timing attacks. ### Pairing System - **6-character alphanumeric** one-time codes (~2.1 billion combinations) - Codes expire after **5 minutes** (TTL) and are single-use - **Rate limited:** 5 attempts per IP per 5 minutes — returns HTTP 429 after - **2-second delay** on failed attempts to slow brute force - Generating a code requires the admin auth token (`GET /pair`) - Redeeming a code is public but rate-limited (`POST /pair`) ### Encrypted Connections - **Domain mode:** WSS (WebSocket Secure) via Caddy with automatic SSL certificates - **Tailscale mode:** WireGuard encryption at the network layer - The app enforces encrypted connections — no unencrypted public access - Port 9878 should be firewalled; only accessible via localhost and Tailscale ### Input Sanitization All user-facing text inputs are sanitized before processing: - **Voice transcripts:** Capped at 300 characters (`CLACK_MAX_INPUT_CHARS`), echo detection filters feedback loops, hallucination detection discards nonsense STT output - **User context:** Stripped to natural-language characters only (letters, numbers, common punctuation, whitespace). Control characters, escape sequences, and non-printable characters are removed. Capped at 1000 characters. Context is wrapped in explicit delimiters before injection into the system prompt. - **No shell execution:** All external communication uses structured HTTP/WebSocket APIs. No user input is ever passed to a shell. ### Data Privacy - No analytics, tracking, or telemetry - Voice audio goes to your server and only to the providers you choose - The iOS app stores only settings locally (server address, token, preferences) - Third-party API usage depends on your provider config (ElevenLabs, OpenAI, Deepgram) ## Session Routing Each voice call creates a **`clack:<uuid>`** session in OpenClaw. These are small, isolated sessions — one per call — so voice conversations don't pollute your main agent context. ### Session Picker The session picker in the iOS app provides **context injection only**. When you select a session key, it is added as text context to the LLM prompt — it does not change routing. All voice calls still create their own `clack:<uuid>` session. ## User Context Users can provide persistent context that gets injected into the system prompt for every voice call. This lets the AI know about the user's preferences, notes, or any background information. ### How to set context - **App text field:** In the Clack app under Settings → Context, enter free-form text - **Session picker:** Select an OpenClaw session to inject its content as context - **WebSocket message:** Send `{"type": "set_context", "text": "..."}` during a voice session - **HTTP API:** `PUT /context?[REDACTED_SECRET]` or `POST /context` with JSON body `{"text": "..."}` Context is sanitized before saving — only natural-language characters are kept (letters, numbers, common punctuation). IP addresses and domains are stripped. The server returns the sanitized text in the response so the app can show the user exactly what will be sent as context. Context persists across calls and server restarts. Clear it via `DELETE /context` or by sending an empty `set_context` message. ## Conversation History The relay maintains a **shared history file** across calls for continuity. History is stored as JSON in `CLACK_HISTORY_DIR` (default: `/var/lib/clack/history`). - **Max messages:** 50 (configurable via `CLACK_MAX_HISTORY`) - History persists across calls and server restarts - Viewable via `GET /history`, clearable via `DELETE /history` ## Echo Test Mode For testing audio round-trips without using LLM credits: - **Server-wide:** Set `CLACK_ECHO_MODE=true` environment variable - **Per-session:** Send `{"type":"start","config":{"echo":true}}` from the client In echo mode, transcribed text is echoed back through TTS instead of being sent to the LLM. Audio is **peak-normalized** with capped gain to ensure consistent playback volume. ## Provider Selection STT and TTS providers can be configured independently per session. The server auto-detects all available providers at startup based on which API keys are set (`ELEVENLABS_API_KEY`, `OPENAI_API_KEY`, `DEEPGRAM_API_KEY`). ### Available modes per direction (STT / TTS): - **On-device (local):** Uses Apple's built-in speech frameworks. Zero API costs. - **Server provider:** ElevenLabs, OpenAI, or Deepgram — whichever keys are configured. ### How it works: 1. App fetches `GET /info` to discover available providers 2. User picks STT and TTS providers independently in Settings → Voice 3. On call start, the app sends `sttProvider` and `ttsProvider` in the session config 4. Server creates the appropriate provider instances per session ### Example combinations: | STT | TTS | Use case | |-----|-----|----------| | ElevenLabs | ElevenLabs | Full cloud — best quality | | On-device | ElevenLabs | Save STT costs, keep premium voices | | On-device | On-device | Fully local — zero API usage, works offline | | OpenAI | Deepgram | Mix providers freely | **Cost optimization:** Use on-device STT (free, unlimited) with a premium cloud TTS voice — get great output quality while eliminating transcription costs entirely. Or go fully on-device for zero API spend. ### Text input mode When STT is set to on-device, the client sends transcribed text instead of audio: ```json {"type": "text_input", "text": "What's the weather like?"} ``` When TTS is set to on-device, the server returns `response_text` only and skips audio synthesis. ## AI Response Rules - Responses are enforced to **1–3 sentences** for natural voice conversation - Server-side **max_tokens: 150** to prevent runaway responses - Server-side **max input: 300 characters** (`CLACK_MAX_INPUT_CHARS`) — transcripts exceeding this are truncated ## HTTP Endpoints | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `GET /health` | GET | No | Health check — returns service status | | `POST /pair` | POST | No | Redeem pairing code → get auth token (rate-limited) | | `GET /pair` | GET | Yes | Generate one-time pairing code | | `GET /info` | GET | Yes | Server info: agent name, available STT/TTS providers | | `GET /voices` | GET | Yes | List available TTS voices | | `GET /sessions` | GET | Yes | List active sessions | | `GET /history` | GET | Yes | Get conversation history | | `DELETE /history` | DELETE | Yes | Clear conversation history | | `GET /context` | GET | Yes | Get current user context | | `PUT /context` | PUT | Yes | Set user context (query param `text`) | | `POST /context` | POST | Yes | Set user context (JSON body `{"text": "..."}`) | | `DELETE /context` | DELETE | Yes | Clear user context | | `WebSocket /voice` | WS | Yes | Voice relay connection | ## WebSocket Protocol **Endpoint:** `ws://<host>:<port>/voice?[REDACTED_SECRET]` ### Client → Server | Message | Format | Description | |---------|--------|-------------| | `{"type":"start","config":{...}}` | JSON | Start session. Config: `voice`, `systemPrompt`, `echo`, `sttProvider`, `ttsProvider` | | Binary frames | bytes | Raw PCM audio (16kHz, 16-bit, mono) | | `{"type":"text_input","text":"..."}` | JSON | Local speech mode — send text directly | | `{"type":"end_speech"}` | JSON | Signal end of speech, triggers processing | | `{"type":"interrupt"}` | JSON | Cancel current TTS playback | | `{"type":"ping"}` | JSON | Keepalive | | `{"type":"set_context","text":"..."}` | JSON | Set user context (sanitized before saving) | | `{"type":"auth","token":"..."}` | JSON | Authenticate (alternative to query param) | ### Server → Client | Message | Format | Description | |---------|--------|-------------| | `{"type":"ready"}` | JSON | Session ready | | `{"type":"auth_ok"}` / `{"type":"auth_failed"}` | JSON | Auth result | | `{"type":"processing","stage":"..."}` | JSON | Stage: `transcribing`, `thinking`, `speaking`, `filtered` | | `{"type":"transcript","text":"...","final":true}` | JSON | STT result | | `{"type":"response_text","text":"..."}` | JSON | LLM text response | | `{"type":"response_start","format":"pcm_16000"}` | JSON | Audio stream starting | | Binary frames | bytes | TTS audio (PCM 16kHz, 16-bit, mono) | | `{"type":"response_end"}` | JSON | Audio stream done | | `{"type":"tts_cancelled"}` | JSON | TTS playback was interrupted | | `{"type":"context_updated","text":"..."}` | JSON | Context saved — `text` contains the sanitized version | | `{"type":"context_cleared"}` | JSON | Context was cleared | ## Features - **Multi-provider STT/TTS**: ElevenLabs, OpenAI, and Deepgram support - **Independent voice input/output configuration**: Choose STT and TTS providers separately — full control over how your voice is transcribed and how the AI speaks back - **On-device speech**: Apple speech frameworks for STT and/or TTS — zero API costs, mix with cloud providers freely - **Cost optimization**: Use free on-device transcription with premium cloud voices, or go fully local for zero spend - **Voice response rules**: AI responses enforced short (1-3 sentences, max_tokens 150) - **Input length limiting**: Configurable max transcript length (default 300 chars) - **Confidence filtering**: Low-confidence STT results are discarded - **Echo detection**: Prevents feedback loops (TTS → mic → STT) - **Echo test mode**: Test audio pipeline without LLM (server-wide or per-session) - **Audio normalization**: Peak normalization with capped gain for echo mode playback - **Audio chunking**: Long recordings auto-split for reliable transcription - **Hallucination detection**: Filters repetitive/nonsense STT output - **Interrupt/TTS cancellation**: Cancel in-progress TTS for all providers - **Pairing system**: Rate-limited one-time codes for secure device pairing - **Session isolation**: Each call gets its own `clack:<uuid>` session - **Conversation history**: Shared across calls, 50 messages max, persistent - **Token auth**: Constant-time HMAC verification - **Keepalive pings**: Prevents client timeout during long LLM responses - **Silence detection**: Default threshold 220, configurable range 20–1000 - **Auto-restart**: systemd restarts on crash ## Voice Configuration 20 built-in ElevenLabs voices available. Default: `Will`. Pass voice name or ID in session config: ```json {"type": "start", "config": {"voice": "aria"}} ``` Available aliases: will, aria, roger, sarah, laura, charlie, george, callum, river, liam, charlotte, alice, matilda, jessica, eric, chris, brian, daniel, lily, bill. ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `RELAY_AUTH_TOKEN` | — | **Required.** Client auth token (32-char) | | `OPENCLAW_GATEWAY_URL` | `http://127.0.0.1:18789` | OpenClaw Gateway URL | | `OPENCLAW_GATEWAY_TOKEN` | — | Gateway bearer token | | `STT_PROVIDER` | `elevenlabs` | STT provider (`elevenlabs`, `openai`, `deepgram`) | | `TTS_PROVIDER` | `elevenlabs` | TTS provider (`elevenlabs`, `openai`, `deepgram`) | | `TTS_VOICE` | `Will` | Default voice (name or ID) | | `VOICE_RELAY_PORT` | `9878` | Server port | | `CLACK_ECHO_MODE` | `false` | Enable echo test mode server-wide | | `CLACK_MAX_INPUT_CHARS` | `300` | Max transcript length (chars) | | `CLACK_HISTORY_DIR` | `/var/lib/clack/history` | History file storage directory | | `CLACK_MAX_HISTORY` | `50` | Max conversation history messages | | `CLACK_AGENT_NAME` | `Storm` | Agent name shown in the iOS app | Provider API keys (`ELEVENLABS_API_KEY`, `OPENAI_API_KEY`, `DEEPGRAM_API_KEY`) are stored in `config.json` with restricted file permissions, not as environment variables. The setup script manages these interactively.

path: server.py
content: #!/usr/bin/env python3
"""
Clack — Voice Relay Server for OpenClaw

WebSocket relay: voice input → STT → OpenClaw agent → TTS → audio back.

Supported providers:
  STT: elevenlabs, openai, deepgram
  TTS: elevenlabs, openai, deepgram

Environment variables:
  STT_PROVIDER          - STT provider (default: elevenlabs)
  TTS_PROVIDER          - TTS provider (default: elevenlabs)
  ELEVENLABS_API_KEY    - ElevenLabs API key
  OPENAI_API_KEY        - OpenAI API key
  DEEPGRAM_API_KEY      - Deepgram API key
  OPENCLAW_GATEWAY_URL  - OpenClaw gateway URL (default: http://127.0.0.1:18789)
  OPENCLAW_GATEWAY_TOKEN - OpenClaw gateway bearer token
  RELAY_AUTH_TOKEN       - Client auth token
  VOICE_RELAY_PORT       - Server port (default: 9878)
  TTS_VOICE             - Voice ID/name (provider-specific)
  CLACK_HISTORY_DIR     - History storage dir (default: /var/lib/clack/history)
  CLACK_MAX_HISTORY     - Max messages to keep (default: 50)
"""

import asyncio
import json
import os
import io
import struct
import hmac
import time
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional, AsyncIterator
from difflib import SequenceMatcher

import aiohttp
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
import uvicorn


# ── Config ──

_CONFIG_PATHS = [
    Path(__file__).parent / "config.json",
    Path("/var/lib/clack/config.json"),
]

def _load_config() -> dict:
    """Load config from JSON file. Provider keys and optional settings live here."""
    for p in _CONFIG_PATHS:
        try:
            return json.loads(p.read_text())
        except (FileNotFoundError, json.JSONDecodeError):
            continue
    return {}

_config = _load_config()

def _conf(key: str, default: str = "") -> str:
    """Read a config value: config.json first, then env var fallback."""
    return _config.get(key, "") or os.getenv(key, default)


# ── Utilities ──

def pcm_to_wav(pcm_data: bytes, sample_rate: int = 16000, channels: int = 1, bits_per_sample: int = 16) -> bytes:
    data_size = len(pcm_data)
    byte_rate = sample_rate * channels * bits_per_sample // 8
    block_align = channels * bits_per_sample // 8
    buf = io.BytesIO()
    buf.write(b'RIFF')
    buf.write(struct.pack('<I', 36 + data_size))
    buf.write(b'WAVE')
    buf.write(b'fmt ')
    buf.write(struct.pack('<I', 16))
    buf.write(struct.pack('<H', 1))
    buf.write(struct.pack('<H', channels))
    buf.write(struct.pack('<I', sample_rate))
    buf.write(struct.pack('<I', byte_rate))
    buf.write(struct.pack('<H', block_align))
    buf.write(struct.pack('<H', bits_per_sample))
    buf.write(b'data')
    buf.write(struct.pack('<I', data_size))
    buf.write(pcm_data)
    return buf.getvalue()


def _get_agent_name() -> str:
    """Read agent name from env or current user's IDENTITY.md."""
    env_name = os.getenv("CLACK_AGENT_NAME", "")
    if env_name:
        return env_name
    # Only check the current user's home directory
    path = Path.home() / ".openclaw" / "workspace" / "IDENTITY.md"
    try:
        text = path.read_text()
        for line in text.splitlines():
            if line.startswith("- **Name:**"):
                name = line.split("**Name:**")[-1].strip()
                if name:
                    return name
    except (FileNotFoundError, PermissionError):
        pass
    return ""


def _is_echo(transcript: str, last_response: str) -> bool:
    t = transcript.lower().strip().replace("[speaker speaker_0]: ", "")
    r = last_response.lower().strip()
    if t in r or r in t:
        return True
    ratio = SequenceMatcher(None, t, r).ratio()
    if ratio > 0.6:
        print(f"[Echo] Similarity: {ratio:.2f}")
        return True
    return False


# ── Provider Errors ──

class ProviderError(Exception):
    """Raised when an API provider returns an error (auth, quota, etc.)."""
    def __init__(self, provider: str, status: int, message: str = ""):
        self.provider = provider
        self.status = status
        self.message = message
        super().__init__(f"[{provider}] HTTP {status}: {message}")

# ── STT Providers ──

class STTProvider(ABC):
    @abstractmethod
    async def transcribe(self, audio_pcm: bytes) -> Optional[str]:
        """Transcribe PCM audio, return text or None."""
        pass


class ElevenLabsSTT(STTProvider):
    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]

    async def transcribe(self, audio_pcm: bytes) -> Optional[str]:
        async with aiohttp.ClientSession() as session:
            wav_data = pcm_to_wav(audio_pcm)
            print(f"[STT/11labs] Sending {len(wav_data)} bytes WAV")
            form = aiohttp.FormData()
            form.add_field('file', wav_data, filename='audio.wav', content_type='audio/wav')
            form.add_field('model_id', 'scribe_v1')
            form.add_field('diarize', 'false')
            form.add_field('tag_audio_events', 'false')
            async with session.post(
                "https://api.elevenlabs.io/v1/speech-to-text",
                headers={"xi-api-key": self.api_key},
                data=form
            ) as resp:
                if resp.status != 200:
                    err_text = await resp.text()
                    print(f"[STT/11labs] Error: {resp.status} - {err_text}")
                    raise ProviderError("ElevenLabs STT", resp.status, err_text)
                result = await resp.json()
                lang_prob = result.get("language_probability", 0)
                text = result.get("text", "").strip()
                print(f"[STT/11labs] Raw: '{text[:200]}' (lang_prob={lang_prob:.2f})")
                if lang_prob < 0.4 or len(text) < 2:
                    print(f"[STT/11labs] Filtered: low confidence or too short")
                    return None
                # Filter hallucinated noise: only punctuation/whitespace
                import re as _re
                if not _re.search(r'[a-zA-ZäöüÄÖÜß]', text):
                    print(f"[STT/11labs] Filtered: no real words")
                    return None
                # Filter suspiciously long transcripts (likely hallucination)
                if len(text) > 500:
                    print(f"[STT/11labs] Filtered: too long ({len(text)} chars, likely hallucination)")
                    return None
                # Detect repetitive/nonsense hallucinations
                words = text.split()
                if len(words) > 5:
                    unique_ratio = len(set(w.lower() for w in words)) / len(words)
                    if unique_ratio < 0.3:
                        print(f"[STT/11labs] Filtered: repetitive (unique ratio {unique_ratio:.2f})")
                        return None
                words = result.get("words", [])
                if words:
                    confidences = [w.get("confidence", 1.0) for w in words if "confidence" in w]
                    if confidences and sum(confidences) / len(confidences) < 0.4:
                        print(f"[STT/11labs] Filtered: low word confidence")
                        return None
                if words and any(w.get("speaker_id") for w in words):
                    parts, cur_spk, cur_txt = [], None, []
                    for w in words:
                        spk = w.get("speaker_id", "?")
                        if spk != cur_spk:
                            if cur_txt:
                                parts.append(f"[Speaker {cur_spk}]: {' '.join(cur_txt)}")
                            cur_spk, cur_txt = spk, [w.get("text", "")]
                        else:
                            cur_txt.append(w.get("text", ""))
                    if cur_txt:
                        parts.append(f"[Speaker {cur_spk}]: {' '.join(cur_txt)}")
                    return "\n".join(parts)
                return text if text else None


class OpenAISTT(STTProvider):
    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]

    async def transcribe(self, audio_pcm: bytes) -> Optional[str]:
        async with aiohttp.ClientSession() as session:
            wav_data = pcm_to_wav(audio_pcm)
            print(f"[STT/openai] Sending {len(wav_data)} bytes WAV")
            form = aiohttp.FormData()
            form.add_field('file', wav_data, filename='audio.wav', content_type='audio/wav')
            form.add_field('model', 'whisper-1')
            async with session.post(
                "https://api.openai.com/v1/audio/transcriptions",
                headers={"Authorization": f"Bearer {self.api_key}"},
                data=form
            ) as resp:
                if resp.status != 200:
                    err_text = await resp.text()
                    print(f"[STT/openai] Error: {resp.status} - {err_text}")
                    raise ProviderError("OpenAI STT", resp.status, err_text)
                result = await resp.json()
                text = result.get("text", "").strip()
                print(f"[STT/openai] Text: '{text[:200]}'")
                if len(text) < 2 or len(text) > 500:
                    return None
                return text


class DeepgramSTT(STTProvider):
    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]

    async def transcribe(self, audio_pcm: bytes) -> Optional[str]:
        async with aiohttp.ClientSession() as session:
            wav_data = pcm_to_wav(audio_pcm)
            print(f"[STT/deepgram] Sending {len(wav_data)} bytes WAV")
            async with session.post(
                "https://api.deepgram.com/v1/listen?model=nova-2&smart_format=true&detect_language=true",
                headers={"Authorization": f"Token {self.api_key}", "Content-Type": "audio/wav"},
                data=wav_data
            ) as resp:
                if resp.status != 200:
                    err_text = await resp.text()
                    print(f"[STT/deepgram] Error: {resp.status} - {err_text}")
                    raise ProviderError("Deepgram STT", resp.status, err_text)
                result = await resp.json()
                alt = result.get("results", {}).get("channels", [{}])[0].get("alternatives", [{}])[0]
                text = alt.get("transcript", "").strip()
                confidence = alt.get("confidence", 0)
                print(f"[STT/deepgram] Text: '{text[:200]}' (conf={confidence:.2f})")
                if confidence < 0.4 or len(text) < 2:
                    print(f"[STT/deepgram] Filtered")
                    return None
                return text


# ── TTS Providers ──

class TTSProvider(ABC):
    @abstractmethod
    async def synthesize_stream(self, text: str, send_audio) -> bool:
        """Stream TTS audio via send_audio(bytes) callback. Return True if audio was sent."""
        pass


class ElevenLabsTTS(TTSProvider):
    def __init__(self, api_key: str, voice_id: str = "bIHbv24MWmeRgasZH58o"):
        self.[REDACTED_SECRET]
        self.voice_id = voice_id

    async def synthesize_stream(self, text: str, send_audio) -> bool:
        async with aiohttp.ClientSession() as session:
            payload = {
                "text": text,
                "model_id": "eleven_multilingual_v2",
                "voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
            }
            async with session.post(
                f"https://api.elevenlabs.io/v1/text-to-speech/{self.voice_id}?output_format=pcm_16000",
                headers={"xi-api-key": self.api_key, "Content-Type": "application/json"},
                json=payload
            ) as resp:
                if resp.status != 200:
                    err_text = await resp.text()
                    print(f"[TTS/11labs] Error: {resp.status} - {err_text}")
                    raise ProviderError("ElevenLabs TTS", resp.status, err_text)
                total = 0
                buf = bytearray()
                CHUNK_SIZE = 16000
                async for chunk in resp.content.iter_any():
                    buf.extend(chunk)
                    while len(buf) >= CHUNK_SIZE:
                        await send_audio(bytes(buf[:CHUNK_SIZE]))
                        total += CHUNK_SIZE
                        del buf[:CHUNK_SIZE]
                if buf:
                    if len(buf) % 2 != 0:
                        buf.append(0)
                    await send_audio(bytes(buf))
                    total += len(buf)
                print(f"[TTS/11labs] Streamed {total} bytes PCM")
                return total > 0


class OpenAITTS(TTSProvider):
    def __init__(self, api_key: str, voice: str = "alloy"):
        self.[REDACTED_SECRET]
        self.voice = voice

    async def synthesize_stream(self, text: str, send_audio) -> bool:
        async with aiohttp.ClientSession() as session:
            payload = {
                "model": "tts-1",
                "input": text,
                "voice": self.voice,
                "response_format": "pcm",  # raw 24kHz 16-bit mono PCM
            }
            async with session.post(
                "https://api.openai.com/v1/audio/speech",
                headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
                json=payload
            ) as resp:
                if resp.status != 200:
                    err_text = await resp.text()
                    print(f"[TTS/openai] Error: {resp.status} - {err_text}")
                    raise ProviderError("OpenAI TTS", resp.status, err_text)
                # OpenAI returns 24kHz PCM, we need to resample to 16kHz
                total = 0
                buf = bytearray()
                CHUNK_SIZE = 24000  # 500ms at 24kHz
                async for chunk in resp.content.iter_any():
                    buf.extend(chunk)
                    while len(buf) >= CHUNK_SIZE:
                        resampled = _resample_24k_to_16k(bytes(buf[:CHUNK_SIZE]))
                        await send_audio(resampled)
                        total += len(resampled)
                        del buf[:CHUNK_SIZE]
                if buf:
                    if len(buf) % 2 != 0:
                        buf.append(0)
                    resampled = _resample_24k_to_16k(bytes(buf))
                    await send_audio(resampled)
                    total += len(resampled)
                print(f"[TTS/openai] Streamed {total} bytes PCM (resampled 24k→16k)")
                return total > 0


class DeepgramTTS(TTSProvider):
    def __init__(self, api_key: str, voice: str = "aura-asteria-en"):
        self.[REDACTED_SECRET]
        self.voice = voice

    async def synthesize_stream(self, text: str, send_audio) -> bool:
        async with aiohttp.ClientSession() as session:
            async with session.post(
                f"https://api.deepgram.com/v1/speak?model={self.voice}&encoding=linear16&sample_rate=16000",
                headers={"Authorization": f"Token {self.api_key}", "Content-Type": "application/json"},
                json={"text": text}
            ) as resp:
                if resp.status != 200:
                    err_text = await resp.text()
                    print(f"[TTS/deepgram] Error: {resp.status} - {err_text}")
                    raise ProviderError("Deepgram TTS", resp.status, err_text)
                total = 0
                buf = bytearray()
                CHUNK_SIZE = 16000
                async for chunk in resp.content.iter_any():
                    buf.extend(chunk)
                    while len(buf) >= CHUNK_SIZE:
                        await send_audio(bytes(buf[:CHUNK_SIZE]))
                        total += CHUNK_SIZE
                        del buf[:CHUNK_SIZE]
                if buf:
                    if len(buf) % 2 != 0:
                        buf.append(0)
                    await send_audio(bytes(buf))
                    total += len(buf)
                print(f"[TTS/deepgram] Streamed {total} bytes PCM")
                return total > 0


def _resample_24k_to_16k(data: bytes) -> bytes:
    """Simple 3:2 decimation from 24kHz to 16kHz (linear interpolation)."""
    import array
    samples = array.array('h')
    samples.frombytes(data[:len(data) - len(data) % 2])
    n = len(samples)
    out = array.array('h')
    ratio = 24000 / 16000  # 1.5
    out_len = int(n / ratio)
    for i in range(out_len):
        src = i * ratio
        idx = int(src)
        frac = src - idx
        if idx + 1 < n:
            val = int(samples[idx] * (1 - frac) + samples[idx + 1] * frac)
        else:
            val = samples[min(idx, n - 1)]
        out.append(max(-32768, min(32767, val)))
    return out.tobytes()


# ── Provider factory ──

_STT_FACTORIES = {
    "elevenlabs": lambda: ElevenLabsSTT(k) if (k := _conf("ELEVENLABS_API_KEY")) else None,
    "openai": lambda: OpenAISTT(k) if (k := _conf("OPENAI_API_KEY")) else None,
    "deepgram": lambda: DeepgramSTT(k) if (k := _conf("DEEPGRAM_API_KEY")) else None,
}

_TTS_FACTORIES = {
    "elevenlabs": lambda v: ElevenLabsTTS(k, v or "bIHbv24MWmeRgasZH58o") if (k := _conf("ELEVENLABS_API_KEY")) else None,
    "openai": lambda v: OpenAITTS(k, v or "alloy") if (k := _conf("OPENAI_API_KEY")) else None,
    "deepgram": lambda v: DeepgramTTS(k, v or "aura-asteria-en") if (k := _conf("DEEPGRAM_API_KEY")) else None,
}


def create_stt_provider() -> tuple[Optional[STTProvider], str]:
    preferred = os.getenv("STT_PROVIDER", "elevenlabs").lower()
    # Try preferred provider first
    if preferred in _STT_FACTORIES:
        result = _STT_FACTORIES[preferred]()
        if result:
            return result, preferred
    # Fallback to first available
    for name, factory in _STT_FACTORIES.items():
        if name != preferred:
            result = factory()
            if result:
                print(f"[STT] {preferred} not available, falling back to {name}")
                return result, name
    return None, preferred


def create_tts_provider() -> tuple[Optional[TTSProvider], str]:
    preferred = os.getenv("TTS_PROVIDER", "elevenlabs").lower()
    voice = os.getenv("TTS_VOICE", "")
    # Try preferred provider first
    if preferred in _TTS_FACTORIES:
        result = _TTS_FACTORIES[preferred](voice)
        if result:
            return result, preferred
    # Fallback to first available
    for name, factory in _TTS_FACTORIES.items():
        if name != preferred:
            result = factory(voice)
            if result:
                print(f"[TTS] {preferred} not available, falling back to {name}")
                return result, name
    return None, preferred


# ── App setup ──

app = FastAPI(title="Clack Voice Relay")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])

OPENCLAW_GATEWAY_URL = os.getenv("OPENCLAW_GATEWAY_URL", "http://127.0.0.1:18789")
OPENCLAW_GATEWAY_TOKEN = os.getenv("OPENCLAW_GATEWAY_TOKEN", "")
RELAY_AUTH_TOKEN = os.getenv("RELAY_AUTH_TOKEN", "")
DEFAULT_VOICE_ID = "bIHbv24MWmeRgasZH58o"
VOICE_ALIASES = {
    "will": "bIHbv24MWmeRgasZH58o",
    "aria": "9BWtsMINqrJLrRacOk9x",
    "roger": "CwhRBWXzGAHq8TQ4Fs17",
    "sarah": "EXAVITQu4vr4xnSDxMaL",
    "laura": "FGY2WhTYpPnrIDTdsKH5",
    "charlie": "IKne3meq5aSn9XLyUdCD",
    "george": "JBFqnCBsd6RMkjVDRZzb",
    "callum": "N2lVS1w4EtoT3dr4eOWO",
    "river": "SAz9YHcvj6GT2YYXdXww",
    "liam": "TX3LPaxmHKxFdv7VOQHJ",
    "charlotte": "XB0fDUnXU5powFXDhCwa",
    "alice": "Xb7hH8MSUJpSbSDYk0k2",
    "matilda": "XrExE9yKIg1WjnnlVkGX",
    "jessica": "cgSgspJ2msm6clMCkdW9",
    "eric": "cjVigY5qzO86Huf0OWal",
    "chris": "iP95p4xoKVk53GoZ742B",
    "brian": "nPczCjzI2devNBz1zQrb",
    "daniel": "onwK4e9ZLuTAKqWW03F9",
    "lily": "pFZP5JQG7iQjIQuC4Bku",
    "bill": "pqHfZKP75CvOlQylNhV4",
}
VOICE_METADATA = [
    {"id": "bIHbv24MWmeRgasZH58o", "name": "Will", "gender": "male", "accent": "American", "style": "warm, relaxed"},
    {"id": "9BWtsMINqrJLrRacOk9x", "name": "Aria", "gender": "female", "accent": "American", "style": "expressive, confident"},
    {"id": "CwhRBWXzGAHq8TQ4Fs17", "name": "Roger", "gender": "male", "accent": "American", "style": "deep, authoritative"},
    {"id": "EXAVITQu4vr4xnSDxMaL", "name": "Sarah", "gender": "female", "accent": "American", "style": "soft, friendly"},
    {"id": "FGY2WhTYpPnrIDTdsKH5", "name": "Laura", "gender": "female", "accent": "American", "style": "gentle, soothing"},
    {"id": "IKne3meq5aSn9XLyUdCD", "name": "Charlie", "gender": "male", "accent": "Australian", "style": "casual, natural"},
    {"id": "JBFqnCBsd6RMkjVDRZzb", "name": "George", "gender": "male", "accent": "British", "style": "warm, narrative"},
    {"id": "N2lVS1w4EtoT3dr4eOWO", "name": "Callum", "gender": "male", "accent": "Scottish", "style": "intense, character"},
    {"id": "SAz9YHcvj6GT2YYXdXww", "name": "River", "gender": "nonbinary", "accent": "American", "style": "calm, confident"},
    {"id": "TX3LPaxmHKxFdv7VOQHJ", "name": "Liam", "gender": "male", "accent": "American", "style": "articulate, neutral"},
    {"id": "XB0fDUnXU5powFXDhCwa", "name": "Charlotte", "gender": "female", "accent": "Swedish", "style": "elegant, seductive"},
    {"id": "Xb7hH8MSUJpSbSDYk0k2", "name": "Alice", "gender": "female", "accent": "British", "style": "confident, clear"},
    {"id": "XrExE9yKIg1WjnnlVkGX", "name": "Matilda", "gender": "female", "accent": "American", "style": "warm, pleasant"},
    {"id": "cgSgspJ2msm6clMCkdW9", "name": "Jessica", "gender": "female", "accent": "American", "style": "expressive, upbeat"},
    {"id": "cjVigY5qzO86Huf0OWal", "name": "Eric", "gender": "male", "accent": "American", "style": "friendly, conversational"},
    {"id": "iP95p4xoKVk53GoZ742B", "name": "Chris", "gender": "male", "accent": "American", "style": "casual, clear"},
    {"id": "nPczCjzI2devNBz1zQrb", "name": "Brian", "gender": "male", "accent": "American", "style": "deep, narrative"},
    {"id": "onwK4e9ZLuTAKqWW03F9", "name": "Daniel", "gender": "male", "accent": "British", "style": "authoritative, deep"},
    {"id": "pFZP5JQG7iQjIQuC4Bku", "name": "Lily", "gender": "female", "accent": "British", "style": "warm, clear"},
    {"id": "pqHfZKP75CvOlQylNhV4", "name": "Bill", "gender": "male", "accent": "American", "style": "trustworthy, documentary"},
]
MAX_INPUT_CHARS = int(os.getenv("CLACK_MAX_INPUT_CHARS", "300"))

DEFAULT_SYSTEM_PROMPT = (
    "You are a voice assistant. The user is talking to you via voice. "
    "RESPONSE RULES — these are MANDATORY:\n"
    "- Keep responses to 1-3 sentences MAX. This is spoken conversation, not text.\n"
    "- NEVER use bullet points, numbered lists, markdown, or headers.\n"
    "- NEVER give long explanations. Be brief like a real person talking.\n"
    "- If the user asks something complex, give a short summary and offer to elaborate.\n"
    "- Respond naturally and directly. No filler phrases.\n"
    "- Do not include or reference any metadata, labels, or formatting artifacts.\n"
    "SAFETY: This is a voice session — transcription errors and hallucinations are common. "
    "NEVER execute destructive actions (delete files, send emails/messages, modify system settings, "
    "run shell commands, make purchases, or change configurations) based on voice input alone. "
    "For any action that modifies data or has external effects, describe what you WOULD do and ask for explicit confirmation. "
    "Read-only actions (search, weather, info lookups) are fine without confirmation."
)

stt_provider, STT_NAME = create_stt_provider()
tts_provider, TTS_NAME = create_tts_provider()


def detect_available_providers():
    """Detect all available STT and TTS providers based on API keys."""
    stt_providers = {}
    tts_providers = {}
    voice = os.getenv("TTS_VOICE", "")
    for name, factory in _STT_FACTORIES.items():
        result = factory()
        if result:
            stt_providers[name] = result
    for name, factory in _TTS_FACTORIES.items():
        result = factory(voice)
        if result:
            tts_providers[name] = result
    return stt_providers, tts_providers


available_stt, available_tts = detect_available_providers()
print(f"[Clack] Starting voice relay server...")
print(f"[Clack] STT: {STT_NAME} ({'ready' if stt_provider else 'NOT CONFIGURED'})")
print(f"[Clack] TTS: {TTS_NAME} ({'ready' if tts_provider else 'NOT CONFIGURED'})")
print(f"[Clack] Available STT: {list(available_stt.keys())}")
print(f"[Clack] Available TTS: {list(available_tts.keys())}")
print(f"[Clack] Gateway: {OPENCLAW_GATEWAY_URL}")
print(f"[Clack] Relay auth: {'ENABLED' if RELAY_AUTH_TOKEN else 'DISABLED (open!)'}")


# ── History ──

HISTORY_DIR = Path(os.getenv("CLACK_HISTORY_DIR", "/var/lib/clack/history"))
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
MAX_HISTORY_MESSAGES = int(os.getenv("CLACK_MAX_HISTORY", "50"))


def _history_path() -> Path:
    return HISTORY_DIR / "conversation.json"


def load_history() -> list:
    path = _history_path()
    if path.exists():
        try:
            data = json.loads(path.read_text())
            print(f"[History] Loaded {len(data)} messages")
            return data[-MAX_HISTORY_MESSAGES:]
        except Exception as e:
            print(f"[History] Error loading: {e}")
    return []


def save_history(history: list):
    path = _history_path()
    try:
        path.write_text(json.dumps(history[-MAX_HISTORY_MESSAGES:]))
    except Exception as e:
        print(f"[History] Error saving: {e}")


import ipaddress

TAILSCALE_NETWORK = ipaddress.ip_network("100.64.0.0/10")

def is_tailscale_ip(ip: str) -> bool:
    """Check if an IP is in the Tailscale CGNAT range (100.64.0.0/10)."""
    try:
        return ipaddress.ip_address(ip) in TAILSCALE_NETWORK
    except ValueError:
        return False

CLACK_GUEST_TOKEN = os.getenv("CLACK_GUEST_TOKEN", "")

def verify_token(token: str, client_ip: str = "") -> bool:
    """Verify auth token. Tailscale IPs bypass pairing. Non-Tailscale requires valid token."""
    if is_tailscale_ip(client_ip):
        return True
    if not RELAY_AUTH_TOKEN:
        return False  # No token configured = Tailscale-only mode
    if CLACK_GUEST_TOKEN and hmac.compare_digest(token, CLACK_GUEST_TOKEN):
        return True
    return hmac.compare_digest(token, RELAY_AUTH_TOKEN)


# ── Pairing ──

import secrets
import string

_pairing_codes: dict = {}  # code -> {"expires": timestamp}
PAIRING_TTL = 300  # 5 minutes


def _generate_pairing_code() -> str:
    """Generate a 6-char alphanumeric one-time pairing code."""
    code = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(6))
    _pairing_codes[code] = {"expires": time.time() + PAIRING_TTL}
    # Clean expired codes
    now = time.time()
    expired = [c for c, v in _pairing_codes.items() if v["expires"] < now]
    for c in expired:
        del _pairing_codes[c]
    return code


def _redeem_pairing_code(code: str) -> bool:
    """Validate and consume a one-time pairing code."""
    code = code.upper().strip()
    entry = _pairing_codes.pop(code, None)
    if not entry:
        return False
    if time.time() > entry["expires"]:
        return False
    return True


# ── Context store ──
# Persistent context that gets injected into the system prompt.
# Can be set via HTTP endpoint or WebSocket message.

CONTEXT_FILE = HISTORY_DIR / "context.json"


def sanitize_context(text: str) -> str:
    """Sanitize user-provided context for voice-first input.

    Only allows natural language characters that could reasonably
    appear in spoken or dictated text:
    - Letters (any script), numbers, whitespace
    - Common punctuation: . , ! ? ; : ' " - ( ) @ # & + = %
    - Newlines and tabs (for lists and structure)
    - Currency symbols, accented characters, emoji
    Strips everything else (control chars, escape sequences, slashes,
    IP addresses, domains, URLs, etc.).
    """
    import re as _re
    # Keep: word chars (any script), digits, whitespace, common punctuation (no slashes)
    text = _re.sub(r'[^\w\s.,!?;:\'\"()\-–—@#&+=*%€$£¥°…]', '', text, flags=_re.UNICODE)
    # Strip IP addresses (with optional port)
    text = _re.sub(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?\b', '', text)
    # Strip domains (e.g. example.com, sub.example.co.uk)
    text = _re.sub(r'\b[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?\.[a-zA-Z]{2,}(\.[a-zA-Z]{2,})*(:\d+)?\b', '', text)
    # Collapse excessive whitespace
    text = _re.sub(r'\n{4,}', '\n\n\n', text)
    text = _re.sub(r' {4,}', '   ', text)
    # Truncate
    return text.strip()[:1000]


def load_context() -> dict:
    """Load saved context. Returns dict with 'text' and optional metadata."""
    if CONTEXT_FILE.exists():
        try:
            return json.loads(CONTEXT_FILE.read_text())
        except Exception as e:
            print(f"[Context] Error loading: {e}")
    return {}


def save_context(ctx: dict):
    """Save context to disk."""
    try:
        CONTEXT_FILE.write_text(json.dumps(ctx))
        print(f"[Context] Saved: {json.dumps(ctx)[:100]}")
    except Exception as e:
        print(f"[Context] Error saving: {e}")


# ── Voice Session ──

class VoiceSession:
    def __init__(self, websocket: WebSocket, config: dict):
        self.websocket = websocket
        self.config = config
        self.user_context = load_context()
        self.system_prompt = self._build_system_prompt(config)
        self.conversation_history = load_history()
        self.audio_buffer = bytearray()
        self.last_assistant_response = ""
        self.processing = False
        self.interrupted = False
        self.greeting_enabled = config.get("greetingEnabled", True)

        # STT provider selection
        stt_choice = config.get("sttProvider", "").lower() if config.get("sttProvider") else ""
        if stt_choice == "local":
            self.stt = None
            self.local_stt = True
        elif stt_choice and stt_choice in available_stt:
            self.stt = available_stt[stt_choice]
            self.local_stt = False
        else:
            self.stt = stt_provider
            self.local_stt = False

        # TTS provider selection
        tts_choice = config.get("ttsProvider", "").lower() if config.get("ttsProvider") else ""
        voice = config.get("voice", "")
        if tts_choice == "local":
            self.tts = None
            self.local_tts = True
        elif tts_choice and tts_choice in available_tts:
            self.tts = self._create_tts_with_voice(tts_choice, voice, available_tts[tts_choice])
            self.local_tts = False
        else:
            if voice and tts_provider:
                self.tts = self._create_tts_with_voice(TTS_NAME.lower(), voice, tts_provider)
            else:
                self.tts = tts_provider
            self.local_tts = config.get("localTTS", False)

        # Apply context from start config if provided
        if config.get("context"):
            self.update_context(config["context"])

    @staticmethod
    def _create_tts_with_voice(provider_name: str, voice: str, fallback_provider):
        """Create a TTS provider instance with the requested voice."""
        if not voice:
            return fallback_provider
        if provider_name == "elevenlabs":
            el_key = _conf("ELEVENLABS_API_KEY")
            resolved = VOICE_ALIASES.get(voice.lower(), voice)
            return ElevenLabsTTS(el_key, resolved)
        elif provider_name == "openai":
            # OpenAI voices: alloy, ash, coral, echo, fable, onyx, nova, sage, shimmer
            oai_key = _conf("OPENAI_API_KEY")
            return OpenAITTS(oai_key, voice.lower())
        elif provider_name == "deepgram":
            # Deepgram voices: allow short name (e.g. "asteria") or full ID
            dg_key = _conf("DEEPGRAM_API_KEY")
            # Map short names to full IDs
            dg_aliases = {v["name"].lower(): v["id"] for v in DEEPGRAM_VOICES}
            resolved = dg_aliases.get(voice.lower(), voice)
            return DeepgramTTS(dg_key, resolved)
        return fallback_provider

    def _build_system_prompt(self, config: dict) -> str:
        """Build system prompt with optional user context injected."""
        base = config.get("systemPrompt", DEFAULT_SYSTEM_PROMPT)
        ctx = self.user_context
        if ctx.get("text"):
            sanitized = sanitize_context(ctx['text'])
            base += (
                "\n\n--- BEGIN USER CONTEXT ---\n"
                f"{sanitized}\n"
                "--- END USER CONTEXT ---"
            )
        return base

    def update_context(self, text: str) -> str:
        """Update the user context and rebuild system prompt. Returns sanitized text."""
        sanitized = sanitize_context(text)
        self.user_context = {"text": sanitized, "updated": time.time()}
        save_context(self.user_context)
        self.system_prompt = self._build_system_prompt(self.config)
        print(f"[Context] Updated: {sanitized[:100]}")
        return sanitized

    async def send_json(self, data: dict):
        await self.websocket.send_text(json.dumps(data))

    async def send_audio(self, audio_data: bytes):
        if self.interrupted:
            return  # Stop sending audio when interrupted
        await self.websocket.send_bytes(audio_data)

    def interrupt(self):
        """Mark session as interrupted — stops audio streaming."""
        self.interrupted = True
        self.processing = False
        print("[Session] Interrupted by client")

    async def transcribe_audio(self, audio_data: bytes) -> Optional[str]:
        if not self.stt:
            print("[STT] No provider configured")
            return None
        MAX_CHUNK = 960000
        if len(audio_data) > MAX_CHUNK:
            chunks = [audio_data[i:i + MAX_CHUNK] for i in range(0, len(audio_data), MAX_CHUNK)]
            print(f"[STT] Splitting {len(audio_data)} bytes into {len(chunks)} chunks")
            parts = [await self.stt.transcribe(c) for c in chunks]
            combined = " ".join(p for p in parts if p)
            return combined if combined.strip() else None
        return await self.stt.transcribe(audio_data)

    async def greet(self):
        if not self.greeting_enabled:
            print("[Greeting] Disabled by client")
            return
        messages = [
            {"role": "system", "content": self.system_prompt},
            *self.conversation_history,
            {"role": "user", "content": "[Voice session started. Greet the user briefly.]"},
        ]
        async with aiohttp.ClientSession() as session:
            headers = {"Authorization": f"Bearer {OPENCLAW_GATEWAY_TOKEN}", "Content-Type": "application/json"}
            payload = {"model": "openclaw", "messages": messages, "max_tokens": 150}
            try:
                async with session.post(
                    f"{OPENCLAW_GATEWAY_URL}/v1/chat/completions",
                    headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=30)
                ) as resp:
                    if resp.status == 200:
                        result = await resp.json()
                        greeting = result["choices"][0]["message"]["content"]
                    else:
                        greeting = "Hey!"
            except Exception:
                greeting = "Hey!"
        self.conversation_history.append({"role": "assistant", "content": greeting})
        self.last_assistant_response = greeting
        print(f"[Greeting] {greeting} (localTTS={self.local_tts})")
        await self.send_json({"type": "response_text", "text": greeting})
        if not self.local_tts:
            await self.send_json({"type": "response_start", "format": "pcm_16000"})
            if self.tts:
                try:
                    await self.tts.synthesize_stream(greeting[:500], self.send_audio)
                except ProviderError as e:
                    await self.send_json({"type": "error", "message": f"TTS failed: {e.provider} (HTTP {e.status})"})
        await self.send_json({"type": "response_end"})

    async def get_llm_response(self, user_message: str) -> Optional[str]:
        self.conversation_history.append({"role": "user", "content": user_message})
        save_history(self.conversation_history)
        messages = [{"role": "system", "content": self.system_prompt}] + self.conversation_history

        async def _llm_call():
            async with aiohttp.ClientSession() as session:
                headers = {"Authorization": f"Bearer {OPENCLAW_GATEWAY_TOKEN}", "Content-Type": "application/json"}
                payload = {"model": "openclaw", "messages": messages, "max_tokens": 150}
                async with session.post(
                    f"{OPENCLAW_GATEWAY_URL}/v1/chat/completions",
                    headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=120)
                ) as resp:
                    if resp.status == 200:
                        result = await resp.json()
                        return result["choices"][0]["message"]["content"]
                    else:
                        print(f"[LLM] OpenClaw error: {resp.status} - {await resp.text()}")
                        return None

        # Run LLM call with keepalive pings to prevent client timeout
        try:
            llm_task = asyncio.create_task(_llm_call())
            while not llm_task.done():
                done, _ = await asyncio.wait({llm_task}, timeout=5.0)
                if not done:
                    try:
                        await self.send_json({"type": "processing", "stage": "thinking"})
                        print(f"[LLM] Still waiting, sent keepalive")
                    except Exception:
                        pass
            content = llm_task.result()
            if content:
                self.conversation_history.append({"role": "assistant", "content": content})
                save_history(self.conversation_history)
                return content
            return "Sorry, I had trouble processing that."
        except Exception as e:
            print(f"[LLM] Connection error: {e}")
            return "Sorry, I couldn't reach the assistant right now."


# ── HTTP endpoints ──

@app.get("/")
async def root():
    return {"status": "ok", "service": "Clack Voice Relay", "stt": STT_NAME, "tts": TTS_NAME}

@app.get("/health")
async def health():
    return {"status": "ok", "backend": "openclaw", "stt": STT_NAME, "tts": TTS_NAME}

OPENAI_VOICES = [
    {"id": "alloy", "name": "Alloy", "gender": "neutral", "style": "balanced, versatile"},
    {"id": "ash", "name": "Ash", "gender": "male", "style": "clear, direct"},
    {"id": "coral", "name": "Coral", "gender": "female", "style": "warm, engaging"},
    {"id": "echo", "name": "Echo", "gender": "male", "style": "smooth, narrative"},
    {"id": "fable", "name": "Fable", "gender": "male", "style": "expressive, British"},
    {"id": "onyx", "name": "Onyx", "gender": "male", "style": "deep, authoritative"},
    {"id": "nova", "name": "Nova", "gender": "female", "style": "warm, friendly"},
    {"id": "sage", "name": "Sage", "gender": "female", "style": "calm, thoughtful"},
    {"id": "shimmer", "name": "Shimmer", "gender": "female", "style": "bright, optimistic"},
]

DEEPGRAM_VOICES = [
    {"id": "aura-asteria-en", "name": "Asteria", "gender": "female", "accent": "American", "style": "warm, professional"},
    {"id": "aura-luna-en", "name": "Luna", "gender": "female", "accent": "American", "style": "soft, soothing"},
    {"id": "aura-stella-en", "name": "Stella", "gender": "female", "accent": "American", "style": "clear, confident"},
    {"id": "aura-athena-en", "name": "Athena", "gender": "female", "accent": "British", "style": "elegant, articulate"},
    {"id": "aura-hera-en", "name": "Hera", "gender": "female", "accent": "American", "style": "warm, narrative"},
    {"id": "aura-orion-en", "name": "Orion", "gender": "male", "accent": "American", "style": "deep, clear"},
    {"id": "aura-arcas-en", "name": "Arcas", "gender": "male", "accent": "American", "style": "confident, engaging"},
    {"id": "aura-perseus-en", "name": "Perseus", "gender": "male", "accent": "American", "style": "strong, narrative"},
    {"id": "aura-angus-en", "name": "Angus", "gender": "male", "accent": "Irish", "style": "warm, friendly"},
    {"id": "aura-orpheus-en", "name": "Orpheus", "gender": "male", "accent": "American", "style": "smooth, rich"},
    {"id": "aura-helios-en", "name": "Helios", "gender": "male", "accent": "British", "style": "refined, clear"},
    {"id": "aura-zeus-en", "name": "Zeus", "gender": "male", "accent": "American", "style": "authoritative, deep"},
]

@app.get("/voices")
async def list_voices(request: Request, token: str = Query(default=""), provider: str = Query(default="")):
    if not verify_token(token, request.client.host):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    default = os.getenv("TTS_VOICE", "bIHbv24MWmeRgasZH58o")

    if provider == "openai":
        return {"voices": OPENAI_VOICES, "default": "alloy", "provider": "openai"}
    elif provider == "deepgram":
        return {"voices": DEEPGRAM_VOICES, "default": "aura-asteria-en", "provider": "deepgram"}
    else:
        # Default: ElevenLabs
        return {"voices": VOICE_METADATA, "default": default, "provider": "elevenlabs"}


@app.get("/info")
async def info(request: Request, token: str = Query(default="")):
    if not verify_token(token, request.client.host):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    return {
        "agentName": _get_agent_name(),
        "stt": {
            "available": list(available_stt.keys()),
            "default": os.getenv("STT_PROVIDER", "elevenlabs").lower()
        },
        "tts": {
            "available": list(available_tts.keys()),
            "default": os.getenv("TTS_PROVIDER", "elevenlabs").lower()
        }
    }


@app.get("/pair")
async def create_pairing(request: Request, token: str = Query(default="")):
    """Generate a one-time pairing code. Requires admin auth (relay token)."""
    if not verify_token(token, request.client.host):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    code = _generate_pairing_code()
    print(f"[Pair] Generated code: {code} (expires in {PAIRING_TTL}s)")
    return {"code": code, "expires_in": PAIRING_TTL}


@app.post("/pair")
async def redeem_pairing(code: str = Query(default="")):
    """Redeem a pairing code to get the auth token. No auth required."""
    if not code:
        return JSONResponse({"error": "code required"}, status_code=400)
    # Guest token acts as a permanent pairing code
    if CLACK_GUEST_TOKEN and hmac.compare_digest(code.upper().strip(), CLACK_GUEST_TOKEN.upper().strip()):
        print(f"[Pair] Guest token accepted")
        return {"token": RELAY_AUTH_TOKEN}
    if _redeem_pairing_code(code):
        print(f"[Pair] Code redeemed successfully")
        return {"token": RELAY_AUTH_TOKEN}
    else:
        print(f"[Pair] Invalid/expired code: {code}")
        return JSONResponse({"error": "invalid or expired code"}, status_code=401)


@app.get("/sessions")
async def list_sessions(request: Request, token: str = Query(default="")):
    """List available OpenClaw sessions."""
    if not verify_token(token, request.client.host):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    try:
        headers = {"Authorization": f"Bearer {OPENCLAW_GATEWAY_TOKEN}", "Content-Type": "application/json"}
        async with aiohttp.ClientSession() as session:
            async with session.post(
                f"{OPENCLAW_GATEWAY_URL}/tools/invoke",
                headers=headers,
                json={"tool": "sessions_list", "args": {"limit": 50, "messageLimit": 0}},
                timeout=aiohttp.ClientTimeout(total=5),
            ) as resp:
              if resp.status == 200:
                data = await resp.json()
                raw_sessions = data.get("result", {}).get("details", {}).get("sessions", [])
                sessions = []
                for s in raw_sessions:
                    name = s.get("displayName", "")
                    if not name:
                        continue
                    sessions.append({
                        "key": s.get("key", ""),
                        "name": name,
                        "channel": s.get("channel", ""),
                    })
                return {"sessions": sessions}
    except Exception as e:
        print(f"[Sessions] Error: {e}")
    return {"sessions": []}


@app.get("/history")
async def get_history(request: Request, token: str = Query(default="")):
    if not verify_token(token, request.client.host):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    history = load_history()
    return {"messages": history, "count": len(history)}

@app.delete("/history")
async def clear_history(request: Request, token: str = Query(default="")):
    if not verify_token(token, request.client.host):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    path = _history_path()
    if path.exists():
        path.unlink()
    return {"cleared": True}


@app.get("/context")
async def get_context(request: Request, token: str = Query(default="")):
    """Get the current user context."""
    if not verify_token(token, request.client.host):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    return load_context() or {"text": ""}


@app.put("/context")
async def set_context_put(request: Request, token: str = Query(default=""), text: str = Query(default="")):
    """Set user context via query param."""
    if not verify_token(token, request.client.host):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    if not text:
        return JSONResponse({"error": "text required"}, status_code=400)
    sanitized = sanitize_context(text)
    ctx = {"text": sanitized, "updated": time.time()}
    save_context(ctx)
    return {"ok": True, "context": ctx}



@app.post("/context")
async def set_context_post(request: Request, token: str = Query(default="")):
    """Set user context via JSON body: {"text": "..."}"""
    if not verify_token(token, request.client.host):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    try:
        body = await request.json()
        text = body.get("text", "")
    except Exception:
        return JSONResponse({"error": "invalid JSON"}, status_code=400)
    if not text:
        return JSONResponse({"error": "text required"}, status_code=400)
    sanitized = sanitize_context(text)
    ctx = {"text": sanitized, "updated": time.time()}
    save_context(ctx)
    return {"ok": True, "context": ctx}


@app.delete("/context")
async def clear_context(token: str = Query(default="")):
    """Clear user context."""
    if not verify_token(token, request.client.host):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    if CONTEXT_FILE.exists():
        CONTEXT_FILE.unlink()
    return {"cleared": True}


# ── WebSocket ──

@app.websocket("/voice")
async def voice_endpoint(websocket: WebSocket, token: str = Query(default="")):
    await websocket.accept()
    print(f"[WS] Client connected: {websocket.client}")
    client_ip = websocket.client.host if websocket.client else ""
    authenticated = verify_token(token, client_ip) if token else (is_tailscale_ip(client_ip) or not RELAY_AUTH_TOKEN)
    session = None
    try:
        while True:
            message = await websocket.receive()
            if "text" in message:
                data = json.loads(message["text"])
                msg_type = data.get("type")
                if msg_type == "auth" and not authenticated:
                    if verify_token(data.get("token", ""), client_ip):
                        authenticated = True
                        await websocket.send_text(json.dumps({"type": "auth_ok"}))
                    else:
                        await websocket.send_text(json.dumps({"type": "auth_failed"}))
                        await websocket.close(code=4001, reason="Invalid token")
                        return
                    continue
                if not authenticated:
                    await websocket.send_text(json.dumps({"type": "auth_required"}))
                    await websocket.close(code=4001, reason="Authentication required")
                    return
                if msg_type == "start":
                    config = data.get("config", {})
                    session = VoiceSession(websocket, config)
                    print(f"[WS] Session started")
                    await session.send_json({"type": "ready"})
                    if session.conversation_history:
                        print(f"[WS] Resuming ({len(session.conversation_history)} messages)")
                    await session.greet()
                elif msg_type == "set_context" and session:
                    ctx_text = data.get("text", "")
                    if ctx_text:
                        sanitized = session.update_context(ctx_text)
                        await session.send_json({"type": "context_updated", "text": sanitized})
                    else:
                        # Clear context
                        session.user_context = {}
                        if CONTEXT_FILE.exists():
                            CONTEXT_FILE.unlink()
                        session.system_prompt = session._build_system_prompt(session.config)
                        await session.send_json({"type": "context_cleared"})
                elif msg_type == "text_input" and session:
                    text = data.get("text", "").strip()
                    local_tts_override = data.get("localTTS", session.local_tts)
                    if text and not session.processing:
                        session.interrupted = False
                        session.processing = True
                        print(f"[WS] text_input: '{text[:100]}' (localTTS={local_tts_override})")
                        if len(text) > MAX_INPUT_CHARS:
                            text = text[:MAX_INPUT_CHARS]
                        await session.send_json({"type": "transcript", "text": text, "final": True})
                        await session.send_json({"type": "processing", "stage": "thinking"})
                        response = await session.get_llm_response(text)
                        if response:
                            print(f"[LLM] Response: {response[:100]}...")
                            session.last_assistant_response = response
                            await session.send_json({"type": "response_text", "text": response})
                            if not session.interrupted and not local_tts_override:
                                await session.send_json({"type": "processing", "stage": "speaking"})
                                await session.send_json({"type": "response_start", "format": "pcm_16000"})
                                if session.tts:
                                    try:
                                        await session.tts.synthesize_stream(response[:500], session.send_audio)
                                    except ProviderError as e:
                                        await session.send_json({"type": "error", "message": f"TTS failed: {e.provider} (HTTP {e.status})"})
                                if not session.interrupted:
                                    await session.send_json({"type": "response_end"})
                            else:
                                await session.send_json({"type": "response_end"})
                        session.processing = False
                elif msg_type == "interrupt" and session:
                    print(f"[WS] Client interrupted")
                    session.interrupt()
                    session.audio_buffer = bytearray()
                    await session.send_json({"type": "response_end"})
                elif msg_type == "end_speech" and session:
                    if session.local_stt:
                        session.audio_buffer = bytearray()
                        continue
                    if session.processing:
                        print(f"[WS] Ignoring end_speech — still processing")
                        session.audio_buffer = bytearray()
                        continue
                    if session.audio_buffer:
                        session.interrupted = False  # Reset interrupt flag for new processing
                        session.processing = True
                        print(f"[WS] Processing {len(session.audio_buffer)} bytes")
                        await session.send_json({"type": "processing", "stage": "transcribing"})
                        try:
                            transcript = await session.transcribe_audio(bytes(session.audio_buffer))
                        except ProviderError as e:
                            await session.send_json({"type": "error", "message": f"STT failed: {e.provider} (HTTP {e.status})"})
                            await session.send_json({"type": "response_end"})
                            session.audio_buffer = bytearray()
                            session.processing = False
                            continue
                        if not transcript:
                            await session.send_json({"type": "processing", "stage": "filtered"})
                            await session.send_json({"type": "response_end"})
                            session.audio_buffer = bytearray()
                            session.processing = False
                            continue
                        if session.last_assistant_response and _is_echo(transcript, session.last_assistant_response):
                            print(f"[STT] Filtered: echo")
                            await session.send_json({"type": "processing", "stage": "filtered"})
                            await session.send_json({"type": "response_end"})
                            session.audio_buffer = bytearray()
                            session.processing = False
                            continue
                        # Strip speaker labels — they confuse the LLM
                        import re
                        transcript = re.sub(r'\[Speaker \w+\]:\s*', '', transcript).strip()
                        # Enforce input length limit
                        if len(transcript) > MAX_INPUT_CHARS:
                            print(f"[STT] Truncated: {len(transcript)} → {MAX_INPUT_CHARS} chars")
                            transcript = transcript[:MAX_INPUT_CHARS]
                        print(f"[STT] Transcript: {transcript}")
                        await session.send_json({"type": "transcript", "text": transcript, "final": True})
                        await session.send_json({"type": "processing", "stage": "thinking"})
                        response = await session.get_llm_response(transcript)
                        if response:
                            print(f"[LLM] Response: {response[:100]}...")
                            session.last_assistant_response = response
                            await session.send_json({"type": "response_text", "text": response})
                            if not session.interrupted and not session.local_tts:
                                await session.send_json({"type": "processing", "stage": "speaking"})
                                await session.send_json({"type": "response_start", "format": "pcm_16000"})
                                if session.tts:
                                    try:
                                        await session.tts.synthesize_stream(response[:500], session.send_audio)
                                    except ProviderError as e:
                                        await session.send_json({"type": "error", "message": f"TTS failed: {e.provider} (HTTP {e.status})"})
                                if not session.interrupted:
                                    await session.send_json({"type": "response_end"})
                            else:
                                if not session.interrupted:
                                    await session.send_json({"type": "response_end"})
                        session.audio_buffer = bytearray()
                        session.processing = False
                elif msg_type == "ping":
                    await websocket.send_text(json.dumps({"type": "pong"}))
            elif "bytes" in message and session:
                if not authenticated:
                    continue
                if not session.local_stt:
                    session.audio_buffer.extend(message["bytes"])
    except WebSocketDisconnect:
        print(f"[WS] Client disconnected")
    except RuntimeError as e:
        if "disconnect" in str(e).lower():
            print(f"[WS] Client disconnected (runtime)")
        else:
            print(f"[WS] Runtime error: {e}")
    except Exception as e:
        print(f"[WS] Error: {e}")
    finally:
        print(f"[WS] Session ended")


if __name__ == "__main__":
    port = int(os.getenv("VOICE_RELAY_PORT", "9878"))
    uvicorn.run(app, host="0.0.0.0", port=port, ws_ping_interval=20, ws_ping_timeout=None)


path: CHANGELOG.md
content: # Changelog

## 1.5.3 (2026-02-26)

### Security
- **Tailscale APT install**: Replaced `curl | sh` with official APT repository for Tailscale installation
- **Context sanitization**: User context is stripped to natural-language characters only (letters, numbers, common punctuation). IP addresses and domains are removed. Capped at 1000 characters.
- **Provider keys in config.json**: API keys stored in `config.json` (chmod 600) instead of systemd environment variables

### Features
- **Sanitized context returned to client**: All context endpoints (PUT, POST, WebSocket) return the sanitized text so the app can show users exactly what is stored
- **Setup re-run preserves keys**: Re-running setup prompts to keep, update, or delete existing API keys
- **Provider auto-fallback**: If preferred STT/TTS provider has no API key, automatically falls back to first available provider
- **`clack update` command**: Pull latest code and restart with a single command

### Docs
- Fixed WebSocket endpoint path (`/ws` → `/voice`)
- Fixed step numbering in README quickstart
- Added `set_context`, `context_updated`, `context_cleared` to WebSocket protocol docs
- Updated configuration docs to reflect `config.json` for provider keys
- Added input sanitization details to Security sections

---

## 1.3.0 (2026-02-21)

### Security
- **Encrypted connections only**: App now supports Domain (SSL/WSS) and Tailscale modes — unencrypted public connections removed
- **Port hardening**: Port 9878 should be firewalled; documentation updated with iptables rules for localhost + Tailscale only
- **Multi-provider voice selection**: OpenAI (9 voices) and Deepgram (12 Aura voices) with aliases, alongside existing ElevenLabs voices

### Features
- **Tailscale support**: Connect via Tailscale IP with WireGuard encryption at the network layer
- **Domain mode**: WSS on port 443 via Caddy reverse proxy with automatic SSL
- **Configurable port**: Custom port setting in server settings (Tailscale mode)
- **Security info page**: New "Info & Legal" section with Security, Setup Guide, Before You Start, Terms, and Privacy
- **Context save button**: Explicit save for session context field
- **App icon**: Real app icon on website and README

### Removed
- Auto-reconnect on disconnect (was causing reliability issues)
- Unencrypted (WS) connection option for public IPs

### App Changes
- Connection picker: "Domain (SSL)" and "Tailscale" replace old Auto/WSS/WS options
- Port field only visible in Tailscale mode
- "Info & Legal" replaces "Legal" with Security and Setup Guide sub-pages
- Removed reconnecting indicator from UI

---

## 1.2.0 (2026-06-22)

### Features
- **Per-session provider selection**: STT and TTS providers can be chosen independently per call via `sttProvider` and `ttsProvider` in the session start config
- **Auto-detection of available providers**: Server detects all configured API keys at startup and exposes them via `GET /info`
- **`GET /info` endpoint**: Returns agent name, available STT providers, available TTS providers, and defaults
- **`CLACK_AGENT_NAME` env var**: Configurable agent name shown in the iOS app (default: "Storm")
- **Mixed provider combos**: Use on-device STT with cloud TTS (or any combination) — no longer all-or-nothing

### App Changes
- **Voice Processing settings page**: New dedicated page with independent STT/TTS provider pickers
- **Dynamic provider list**: App fetches available providers from server and shows them as options alongside "On-Device"
- **Settings reorder**: Voice Detection → Context → Behavior
- **Server section**: Green checkmark when paired
- **Voice picker**: Only shown when TTS is not set to on-device

### Protocol
- Added `sttProvider` and `ttsProvider` fields to session start config
- `GET /info` returns `{ agentName, stt: { available, default }, tts: { available, default } }`

---

## 1.1.0 (2026-02-20)

### Security
- Rate-limited pairing: 5 attempts per IP per 5 minutes, 2-second delay on failed attempts
- Auth token required for all endpoints except `GET /health` and `POST /pair`

### Features
- **Session key prefix**: Each voice call creates a `clack:<uuid>` session in OpenClaw (isolated per call)
- **Echo test mode**: Server-wide via `CLACK_ECHO_MODE=true` env var, or per-session via client config
- **Local speech mode**: On-device STT/TTS support via `text_input` message type — only LLM calls go through server, zero speech API usage
- **Audio normalization**: Peak normalization with capped gain for echo mode playback
- **Interrupt/TTS cancellation**: Cancel in-progress TTS for all providers (ElevenLabs, OpenAI, Deepgram)
- **`GET /sessions` endpoint**: List active sessions
- **Conversation history persistence**: Shared history file across calls (configurable via `CLACK_HISTORY_DIR`), up to 50 messages (configurable via `CLACK_MAX_HISTORY`)
- **Input length limit**: Server-side 300-char max (`CLACK_MAX_INPUT_CHARS`) — transcripts exceeding this are truncated
- **AI response rules**: 1–3 sentences enforced, `max_tokens` capped at 150

### Protocol
- Added `text_input` client message type for local speech mode
- Added `interrupt` client message type for TTS cancellation
- Added `tts_cancelled` server message type
- Added `echo` option to session start config

## 1.0.0 (2026-02-20)

### Features
- Multi-provider support: ElevenLabs, OpenAI, and Deepgram for both STT and TTS
- 20 built-in ElevenLabs voice aliases
- Voice response rules: AI responses enforced short (1-3 sentences) for natural conversation
- Input length limiting: Configurable max transcript length (default 300 chars)
- Confidence filtering: Low-confidence and short STT results are discarded
- Echo detection: Prevents TTS → mic → STT feedback loops
- Hallucination detection: Filters repetitive/nonsense transcriptions
- Audio chunking: Auto-splits long recordings for reliable transcription
- Device pairing: One-time code system for secure authentication
- Conversation history: Persistent across sessions with configurable depth
- Keepalive pings during LLM processing to prevent client timeouts
- Per-session voice override via WebSocket config
- HTTP endpoints: health, voices, history, pairing
- Automated setup script with systemd service configuration
- Auto-detection of OpenClaw gateway config

### Protocol
- WebSocket endpoint at `/voice` with token auth (query param or message)
- Binary PCM audio frames (16kHz, 16-bit, mono)
- JSON control messages for session lifecycle
- Processing stage notifications (transcribing, thinking, speaking, filtered)


path: README.md
content: # Clack — Voice Relay for OpenClaw

<p align="center">
  <img src="assets/app-icon-256.png" alt="Clack" width="128" height="128" style="border-radius: 24px;">
</p>

[![ClawHub - Rated Benign](https://img.shields.io/badge/ClawHub-Rated%20Benign-brightgreen)](https://clawhub.ai/fbn3799/clack)

> Talk to your AI assistant by voice. Real-time, self-hosted, private.

Clack is an [OpenClaw](https://github.com/openclaw/openclaw) skill that sets up a WebSocket voice relay server. It bridges voice input through speech-to-text → your OpenClaw agent → text-to-speech, enabling natural voice conversations.

📱 **[Available for iOS](https://clack-app.com)** — Android coming soon!

## Quickstart

Just tell your OpenClaw agent:

```
Install the Clack voice relay skill from https://github.com/fbn3799/clack-skill and set it up
```

Your agent will clone the repo, run the setup script, and configure everything. That's it.

## Features

- 🎙️ **Real-time voice chat** with your OpenClaw agent
- 🔊 **Independent voice input/output**: Choose STT and TTS providers separately — ElevenLabs, OpenAI, Deepgram, or on-device
- 💰 **Cost-saving combos**: Free on-device transcription + premium cloud voices, or fully local for zero API spend
- 📱 **On-device speech**: Apple speech frameworks for STT and/or TTS — works offline, no API keys needed
- 🗣️ **20 built-in ElevenLabs voices** with easy aliases
- 🧠 **Conversation memory**: Persisted across calls (up to 50 messages)
- 🔒 **Encrypted connections**: Domain with SSL or Tailscale — no unencrypted public access
- 🔐 **Secure pairing**: Rate-limited one-time codes with 5-minute expiry
- 🏠 **Self-hosted**: Your server, your providers, your data
- 🎯 **Session isolation**: Each call gets its own `clack:<uuid>` session
- ⚡ **Interrupt support**: Cancel TTS mid-sentence for natural conversation
- 🔇 **Echo test mode**: Test your audio pipeline without using LLM credits

## Quick Start

### 1. Install & set up

```bash
git clone https://github.com/fbn3799/clack-skill.git ~/.openclaw/skills/clack
sudo bash ~/.openclaw/skills/clack/scripts/setup.sh
```

This clones the repo and runs the interactive setup.

The interactive setup will:
- Install system dependencies (Python, venv)
- Auto-detect your OpenClaw gateway config
- Enable the `/v1/chat/completions` endpoint if needed
- Prompt for API keys (ElevenLabs, OpenAI, Deepgram — all optional)
- Ask you to choose Domain (SSL) or Tailscale connection mode
- Generate a `RELAY_AUTH_TOKEN` and configure a systemd service
- Print exactly what to enter in the app

> **No API keys?** No problem — on-device STT/TTS works without any speech provider keys.

### 2. Connect securely

All connections are encrypted. The setup script will ask you to choose:

**Option A: Domain with SSL (recommended for remote servers)**

Requires a DNS A record pointing to your server. Setup auto-configures SSL via Caddy. Works with free [DuckDNS](https://www.duckdns.org) domains too.

After setup, **pair the app**: the setup script prints a 6-character pairing code. Enter it in the app under Settings → Server → Pair with Server. Codes expire after 5 minutes — generate new ones with `clack pair`.

**Option B: Tailscale (simplest for personal use)**

Install Tailscale on your server and phone. Use the server's Tailscale IP (e.g. `100.x.x.x`) in the app. **No pairing needed** — Tailscale connections are trusted automatically.

**Firewall port 9878** from the public internet — only allow localhost and Tailscale access.

### 3. Open the app and connect

1. Open the Clack iOS app ([App Store](https://clack-app.com) or build from source)
2. Go to Settings → Server
3. Enter your domain or Tailscale IP
4. **Domain mode**: Tap "Pair with Server" and enter the code from setup
5. **Tailscale mode**: Just connect — no pairing required
6. Tap the microphone and start talking!

## Configuration

Service configuration is via environment variables (set in the systemd service file). Provider API keys are stored separately in `config.json` (created by the setup script).

| Variable | Default | Description |
|----------|---------|-------------|
| `RELAY_AUTH_TOKEN` | — | **Required.** Auth token for all protected endpoints |
| `OPENCLAW_GATEWAY_URL` | `http://127.0.0.1:18789` | OpenClaw Gateway URL |
| `OPENCLAW_GATEWAY_TOKEN` | — | Gateway bearer token |
| `STT_PROVIDER` | `elevenlabs` | `elevenlabs`, `openai`, or `deepgram` |
| `TTS_PROVIDER` | `elevenlabs` | `elevenlabs`, `openai`, or `deepgram` |
| `TTS_VOICE` | `Will` | Default voice (name or ID) |
| `VOICE_RELAY_PORT` | `9878` | Server port |
| `CLACK_ECHO_MODE` | `false` | Enable echo test mode server-wide |
| `CLACK_MAX_INPUT_CHARS` | `300` | Max transcript length |
| `CLACK_HISTORY_DIR` | `/var/lib/clack/history` | History storage path |
| `CLACK_MAX_HISTORY` | `50` | Max conversation history messages |
| `CLACK_AGENT_NAME` | `Storm` | Agent name shown in the iOS app |

Provider API keys (`ELEVENLABS_API_KEY`, `OPENAI_API_KEY`, `DEEPGRAM_API_KEY`) are stored in `config.json` with restricted file permissions. The setup script manages these — re-run `clack setup` to add, update, or remove keys.

> **Tip:** For local speech mode (on-device STT/TTS), you don't need any speech API keys — only the OpenClaw gateway connection.

## Security

- **Encrypted connections only**: Domain with SSL (WSS) or Tailscale (WireGuard) — the app does not support unencrypted public connections
- **Port 9878 should be firewalled**: Only allow access via localhost (for Caddy) and Tailscale
- **Auth token** required for all endpoints except `GET /health` and `POST /pair`
- **Pairing is rate-limited**: 5 attempts per IP per 5 minutes, 2s delay on failure
- **One-time codes**: 6-character alphanumeric, expire after 5 minutes, single-use
- **Constant-time** token verification (HMAC) to prevent timing attacks
- **Input sanitization**: User context is stripped to natural-language characters only, with IP addresses and domains removed. Sanitized text is returned to the client so users can see exactly what is stored.
- **No telemetry**: Zero analytics, tracking, or data sent to developers
- **Voice audio** goes to your server and only to the providers you choose
- The iOS app stores only local settings (server address, token, preferences)

## How It Works

STT and TTS are independently configurable — pick any combination of on-device and cloud providers per call.

### Cloud mode (default)

```
📱 Clack App          🖥️ Your Server          🌐 APIs
┌──────────┐  audio   ┌──────────────┐         ┌─────────────┐
│ 🎙️ Mic   ├─────────►│ Clack Relay  ├────────►│ STT Provider│
│          │           │              │◄────────┤ (transcript)│
│          │           │              │         ├─────────────┤
│          │           │              ├────────►│ OpenClaw GW │
│          │  audio    │              │◄────────┤ (AI reply)  │
│ 🔊 Speaker│◄─────────┤              ├────────►├─────────────┤
│          │           │              │◄────────┤ TTS Provider│
└──────────┘           └──────────────┘         └─────────────┘
```

### On-device STT + cloud TTS (cost saver)

```
📱 Clack App                    🖥️ Your Server          🌐 APIs
┌──────────────┐                ┌──────────────┐         ┌─────────────┐
│ 🎙️ Mic        │                │              │         │             │
│ ↓ Apple STT  │  text          │ Clack Relay  ├────────►│ OpenClaw GW │
│ "Hey, what…" ├───────────────►│              │◄────────┤ (AI reply)  │
│              │  audio          │              ├────────►├─────────────┤
│ 🔊 Speaker    │◄───────────────┤              │◄────────┤ TTS Provider│
└──────────────┘                └──────────────┘         └─────────────┘
```
STT happens on-device (free, unlimited) — only the transcript text is sent to the server. Great for saving transcription API costs while keeping premium cloud voices.

### Fully on-device (zero API spend)

```
📱 Clack App                    🖥️ Your Server
┌──────────────┐                ┌──────────────┐
│ 🎙️ Mic        │                │              │
│ ↓ Apple STT  │  text          │ Clack Relay  ├────────► OpenClaw GW
│ "Hey, what…" ├───────────────►│              │◄────────  (AI reply)
│              │  text           │              │
│ Apple TTS ↓  │◄───────────────┤              │
│ 🔊 Speaker    │                │              │
└──────────────┘                └──────────────┘
```
Both STT and TTS run on-device using Apple speech frameworks. The server only handles LLM routing — no speech API keys needed at all. Works offline (except for the LLM call).

### Mix and match

Choose providers per direction in **Settings → Voice**:

| STT | TTS | Trade-off |
|-----|-----|-----------|
| Cloud (ElevenLabs) | Cloud (ElevenLabs) | Best quality, highest cost |
| On-device | Cloud (ElevenLabs) | Free transcription + premium voices |
| On-device | On-device | Zero API spend, works offline* |
| Cloud (OpenAI) | Cloud (Deepgram) | Mix providers freely |

*Offline except for the LLM call to your OpenClaw gateway.

## Server Management

```bash
clack status     # Check service status
clack restart    # Restart the server
clack logs       # Tail logs
clack pair       # Generate a new pairing code
clack update     # Pull latest code and restart
clack setup      # Re-run interactive setup
clack uninstall  # Remove service and venv
```

## Troubleshooting

| Problem | Solution |
|---------|----------|
| Connection refused | Check port 9878 is open in your firewall |
| `auth_failed` on WebSocket | Verify `RELAY_AUTH_TOKEN` matches between server and app |
| No audio response | Check your STT/TTS provider API key is valid |
| Pairing code rejected | Codes expire after 5 min — generate a fresh one |
| HTTP 429 on pairing | Rate limit hit — wait 5 minutes and try again |
| Echo/feedback loop | This is auto-detected; if persistent, check mic/speaker distance |
| High latency | Try a different STT/TTS provider, or use local speech mode |

## Documentation

See [SKILL.md](SKILL.md) for full protocol docs, WebSocket message reference, and endpoint details.

See [CHANGELOG.md](CHANGELOG.md) for version history.

## License

MIT


path: scripts/setup.sh
content: #!/usr/bin/env bash
# Clack Voice Relay — automated setup
# Usage: sudo bash scripts/setup.sh [--port 9878] [--domain clack.example.com]
set -euo pipefail

if [[ $EUID -ne 0 ]]; then
  echo "Please run with sudo: sudo bash $0 $*"
  exit 1
fi

PORT="${VOICE_RELAY_PORT:-9878}"
DOMAIN=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --port) PORT="$2"; shift 2;;
    --domain) DOMAIN="$2"; shift 2;;
    *) echo "Unknown arg: $1"; exit 1;;
  esac
done

# Resolve skill directory (parent of scripts/)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"

echo "=== Clack Voice Relay Setup ==="
echo "Skill dir: $SKILL_DIR"
echo "Port: $PORT"
echo ""

# ── System dependencies ──

echo "Installing system dependencies..."
apt-get update -qq
apt-get install -y -qq python3 python3-venv curl > /dev/null 2>&1
echo "  ✓ Python 3 + venv"

# ── OpenClaw config ──

if [[ -n "${SUDO_USER:-}" ]]; then
  _REAL_HOME=$(eval echo "~$SUDO_USER")
else
  _REAL_HOME="$HOME"
fi
OPENCLAW_CONFIG="${_REAL_HOME}/.openclaw/openclaw.json"
if [[ -f "$OPENCLAW_CONFIG" ]]; then
  echo "  ✓ OpenClaw config found"
  if command -v python3 &>/dev/null; then
    _GW_TOKEN=$(python3 -c "import json; c=json.load(open('$OPENCLAW_CONFIG')); print(c.get('gateway',{}).get('auth',{}).get('token',''))" 2>/dev/null)
    _GW_PORT=$(python3 -c "import json; c=json.load(open('$OPENCLAW_CONFIG')); print(c.get('gateway',{}).get('port',18789))" 2>/dev/null)
    if [[ -n "$_GW_TOKEN" ]]; then OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-$_GW_TOKEN}"; fi
    OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-http://127.0.0.1:${_GW_PORT:-18789}}"
  fi
else
  echo "No OpenClaw config found at $OPENCLAW_CONFIG"
  if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
    read -rp "OpenClaw gateway token: " OPENCLAW_GATEWAY_TOKEN
  fi
  OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-http://127.0.0.1:18789}"
fi

if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
  echo "ERROR: Could not determine OpenClaw gateway token"
  exit 1
fi

# ── Enable chat completions endpoint ──
if [[ -f "$OPENCLAW_CONFIG" ]] && command -v python3 &>/dev/null; then
  _CC_ENABLED=$(python3 -c "
import json
c=json.load(open('$OPENCLAW_CONFIG'))
print(c.get('gateway',{}).get('http',{}).get('endpoints',{}).get('chatCompletions',{}).get('enabled',False))
" 2>/dev/null)
  if [[ "$_CC_ENABLED" != "True" ]]; then
    echo ""
    echo "  Clack requires the /v1/chat/completions gateway endpoint."
    echo "  This will modify: $OPENCLAW_CONFIG"
    read -rp "  Enable it now? (Y/n): " _CC_CONFIRM
    if [[ "$_CC_CONFIRM" != "n" && "$_CC_CONFIRM" != "N" ]]; then
      python3 -c "
import json
p='$OPENCLAW_CONFIG'
c=json.load(open(p))
c.setdefault('gateway',{}).setdefault('http',{}).setdefault('endpoints',{}).setdefault('chatCompletions',{})['enabled']=True
json.dump(c,open(p,'w'),indent=2)
print('  ✓ Chat completions enabled in', p)
"
      # Restart gateway to apply
      if command -v openclaw &>/dev/null; then
        openclaw gateway restart &>/dev/null && echo "  ✓ Gateway restarted" || echo "  ⚠ Could not restart gateway — restart manually: openclaw gateway restart"
      else
        echo "  ⚠ Restart your OpenClaw gateway to apply: openclaw gateway restart"
      fi
    else
      echo "  Skipped. Enable it manually before using Clack:"
      echo "    openclaw gateway config set http.endpoints.chatCompletions.enabled true"
    fi
  else
    echo "  ✓ Chat completions endpoint already enabled"
  fi
fi

# ── API keys ──

# Load existing keys from config file or systemd service file (legacy)
SERVICE_FILE="/etc/systemd/system/clack.service"
CONFIG_FILE="$SKILL_DIR/config.json"
_load_existing_key() {
  local varname="$1"
  if [[ -z "${!varname:-}" && -f "$CONFIG_FILE" ]]; then
    local val
    val=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('$varname',''))" 2>/dev/null || true)
    if [[ -n "$val" ]]; then export "$varname=$val"; fi
  fi
  # Fallback: read from systemd service file (legacy installs)
  if [[ -z "${!varname:-}" && -f "$SERVICE_FILE" ]]; then
    local val
    val=$(grep -oP "^Environment=${varname}=\K.*" "$SERVICE_FILE" 2>/dev/null || true)
    if [[ -n "$val" ]]; then export "$varname=$val"; fi
  fi
}
_load_existing_key OPENAI_API_KEY
_load_existing_key ELEVENLABS_API_KEY
_load_existing_key DEEPGRAM_API_KEY

# Prompt for each key: keep / update / delete
_prompt_key() {
  local varname="$1" label="$2"
  local existing="${!varname:-}"
  if [[ -n "$existing" ]]; then
    local masked="${existing:0:4}...${existing: -4}"
    echo "  $label: $masked"
    read -rp "    [K]eep / [U]pdate / [D]elete (default: keep): " _choice
    case "${_choice,,}" in
      u|update)
        read -rp "    New $label key: " _KEY
        if [[ -n "$_KEY" ]]; then
          export "$varname=$_KEY"
        else
          echo "    No key entered, keeping existing."
        fi
        ;;
      d|delete)
        unset "$varname"
        echo "    ✓ $label key removed"
        ;;
      *)
        echo "    ✓ Kept existing key"
        ;;
    esac
  else
    read -rp "  $label API key (Enter to skip): " _KEY
    if [[ -n "$_KEY" ]]; then export "$varname=$_KEY"; fi
  fi
}

echo ""
echo "API Keys:"
echo "  Each provider offers both STT and TTS."
echo ""

_prompt_key OPENAI_API_KEY "OpenAI"
_prompt_key ELEVENLABS_API_KEY "ElevenLabs"
_prompt_key DEEPGRAM_API_KEY "Deepgram"

# Summary
echo ""
if [[ -n "${OPENAI_API_KEY:-}" ]]; then echo "  ✓ OpenAI key saved"; fi
if [[ -n "${ELEVENLABS_API_KEY:-}" ]]; then echo "  ✓ ElevenLabs key saved"; fi
if [[ -n "${DEEPGRAM_API_KEY:-}" ]]; then echo "  ✓ Deepgram key saved"; fi

if [[ -z "${OPENAI_API_KEY:-}" && -z "${ELEVENLABS_API_KEY:-}" && -z "${DEEPGRAM_API_KEY:-}" ]]; then
  echo ""
  echo "  No provider keys configured — server-side STT/TTS won't be available."
  echo "  The app can still use on-device STT/TTS."
fi

# ── Auth token ──

# Load existing auth token from service file
if [[ -z "${RELAY_AUTH_TOKEN:-}" && -f "$SERVICE_FILE" ]]; then
  RELAY_AUTH_TOKEN=$(grep -oP "^Environment=RELAY_AUTH_TOKEN=\K.*" "$SERVICE_FILE" 2>/dev/null || true)
fi
if [[ -z "${RELAY_AUTH_TOKEN:-}" ]]; then
  RELAY_AUTH_TOKEN="$(openssl rand -base64 32 | tr -d '/+=' | head -c 44)"
fi

# ── Python venv ──

echo ""
echo "Setting up Python environment..."
python3 -m venv "$SKILL_DIR/venv"
"$SKILL_DIR/venv/bin/pip" install -q fastapi uvicorn aiohttp websockets
echo "  ✓ Python dependencies installed"

# ── CLI command ──

if [[ -d /usr/local/bin ]]; then
  ln -sf "$SKILL_DIR/scripts/clack.sh" /usr/local/bin/clack
  echo "  ✓ 'clack' command installed (clack setup / clack pair / clack logs)"
fi

# ── Connection mode ──

echo ""

# If domain provided via flag, use it
if [[ -z "$DOMAIN" ]]; then
  echo "─────────────────────────────────────────────"
  echo "How should the app connect to this server?"
  echo ""
  echo "  1) Domain — you have a domain pointing to this server (e.g. clack.example.com)"
  echo "  2) Tailscale — encrypted P2P, no domain needed (free)"
  echo ""
  read -rp "Choose [1/2]: " _CONN_CHOICE

  if [[ "$_CONN_CHOICE" == "1" ]]; then
    read -rp "Domain name: " DOMAIN
    if [[ -z "$DOMAIN" ]]; then
      echo "No domain entered, falling back to Tailscale."
      _CONN_CHOICE="2"
    fi
  fi

  if [[ "$_CONN_CHOICE" == "2" ]]; then
    # Install Tailscale via official APT repo if not present
    if ! command -v tailscale &>/dev/null; then
      echo ""
      echo "Installing Tailscale via APT..."
      curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/noble.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
      curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/noble.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list >/dev/null
      apt-get update -qq
      apt-get install -y -qq tailscale > /dev/null 2>&1
      echo "  ✓ Tailscale installed"
    fi
    if ! tailscale status &>/dev/null 2>&1; then
      echo ""
      echo "─────────────────────────────────────────────"
      echo "  Tailscale needs to join your network."
      echo "  A login link will appear below — open it"
      echo "  in your browser to authenticate."
      echo "─────────────────────────────────────────────"
      echo ""
      tailscale up
      echo ""
      echo "  ✓ Tailscale connected"
    fi
    TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || true)
    if [[ -z "$TAILSCALE_IP" ]]; then
      echo "ERROR: Could not get Tailscale IP. Run 'tailscale up' and try again."
      exit 1
    fi
    echo "  ✓ Tailscale IP: $TAILSCALE_IP"
  fi
fi

# ── Domain SSL setup ──

if [[ -n "$DOMAIN" ]]; then
  SERVER_IP=$(curl -s -4 ifconfig.me 2>/dev/null || echo "")
  DOMAIN_IP=$(dig +short "$DOMAIN" 2>/dev/null | tail -1)

  if [[ -n "$SERVER_IP" && "$DOMAIN_IP" != "$SERVER_IP" ]]; then
    echo ""
    echo "⚠️  $DOMAIN resolves to $DOMAIN_IP, but this server is $SERVER_IP"
    echo "   Make sure your DNS A record points $DOMAIN → $SERVER_IP"
    read -rp "Continue anyway? (y/N): " _CONT
    [[ "$_CONT" != "y" && "$_CONT" != "Y" ]] && { echo "Aborted."; exit 1; }
  fi

  echo ""
  echo "─────────────────────────────────────────────"
  echo "  SSL requires ports 80 and 443 to be open."
  echo "  If you're on AWS/GCP/Azure, make sure your"
  echo "  security group / firewall allows inbound"
  echo "  TCP on ports 80 and 443 from 0.0.0.0/0."
  echo "─────────────────────────────────────────────"
  read -rp "Ports 80 and 443 are open? (y/N): " _PORTS_OK
  [[ "$_PORTS_OK" != "y" && "$_PORTS_OK" != "Y" ]] && { echo "Open the ports first, then re-run setup."; exit 1; }

  echo ""
  echo "Setting up SSL for $DOMAIN..."

  # Install Caddy if no reverse proxy available
  if ! command -v caddy &>/dev/null && ! command -v nginx &>/dev/null; then
    echo "Installing Caddy (for automatic SSL)..."
    apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https > /dev/null 2>&1
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg 2>/dev/null
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null
    apt-get update -qq
    apt-get install -y -qq caddy > /dev/null 2>&1
    echo "  ✓ Caddy installed"
  fi

  if command -v caddy &>/dev/null; then
    CADDY_CONF="/etc/caddy/Caddyfile"
    if ! grep -q "$DOMAIN" "$CADDY_CONF" 2>/dev/null; then
      cat >> "$CADDY_CONF" <<CADEOF

$DOMAIN {
    reverse_proxy localhost:$PORT # clack
}
CADEOF
      systemctl reload caddy 2>/dev/null || caddy reload --config "$CADDY_CONF" 2>/dev/null
    fi
    echo "  ✓ Caddy configured — SSL will be provisioned automatically"

  elif command -v nginx &>/dev/null; then
    if ! command -v certbot &>/dev/null; then
      apt-get install -y -qq certbot python3-certbot-nginx > /dev/null 2>&1
    fi
    NGINX_CONF="/etc/nginx/sites-available/clack"
    cat > "$NGINX_CONF" <<NGEOF
server {
    listen 80;
    server_name $DOMAIN;

    location / {
        proxy_pass http://127.0.0.1:$PORT;
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }
}
NGEOF
    ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/clack
    nginx -t && systemctl reload nginx
    certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos --register-unsafely-without-email 2>&1 || {
      echo "⚠️  Certbot failed — run manually: certbot --nginx -d $DOMAIN"
    }
    echo "  ✓ nginx + SSL configured"
  fi
fi

# ── systemd service ──

SERVICE_FILE="/etc/systemd/system/clack.service"

# ── Provider config file ──
# Provider keys go in a config file, not env vars
CONFIG_FILE="$SKILL_DIR/config.json"
python3 -c "
import json
c = {}
el = '${ELEVENLABS_API_KEY:-}'
oa = '${OPENAI_API_KEY:-}'
dg = '${DEEPGRAM_API_KEY:-}'
if el: c['ELEVENLABS_API_KEY'] = el
if oa: c['OPENAI_API_KEY'] = oa
if dg: c['DEEPGRAM_API_KEY'] = dg
json.dump(c, open('$CONFIG_FILE', 'w'), indent=2)
"
chmod 600 "$CONFIG_FILE"
echo "  ✓ Provider config written to $CONFIG_FILE"

ENV_LINES="Environment=OPENCLAW_GATEWAY_URL=$OPENCLAW_GATEWAY_URL
Environment=OPENCLAW_GATEWAY_TOKEN=$OPENCLAW_GATEWAY_TOKEN
Environment=RELAY_AUTH_TOKEN=$RELAY_AUTH_TOKEN
Environment=VOICE_RELAY_PORT=$PORT
Environment=PYTHONUNBUFFERED=1"

cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=Clack Voice Relay (OpenClaw)
After=network.target

[Service]
Type=simple
WorkingDirectory=$SKILL_DIR
$ENV_LINES
ExecStart=$SKILL_DIR/venv/bin/python server.py
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable clack
systemctl restart clack

# ── Done ──

echo ""
echo "════════════════════════════════════════════════"
echo "  ✅ Clack Voice Relay — Setup Complete"
echo "════════════════════════════════════════════════"
echo ""
echo "  Service:  systemctl status clack"
echo "  Logs:     journalctl -u clack -f"
echo ""

if [[ -n "$DOMAIN" ]]; then
  echo "  In the Clack app → Settings → Server:"
  echo ""
  echo "  ┌─────────────────────────────────────────┐"
  echo "  │  Server:     $DOMAIN"
  echo "  │  Connection: Domain (SSL)               │"
  echo "  └─────────────────────────────────────────┘"
  echo ""
  echo "  Then tap Pair and enter the pairing code."
  echo "  Generate a code anytime with:"
  echo "    clack pair"
  echo ""
  # Auto-generate first pairing code
  sleep 2  # wait for service to start
  echo "  Generating first pairing code..."
  PAIR_RESPONSE=$(curl -s "http://localhost:${PORT}/pair?[REDACTED_SECRET]" 2>/dev/null || true)
  PAIR_CODE=$(echo "$PAIR_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['code'])" 2>/dev/null || true)
  if [[ -n "$PAIR_CODE" ]]; then
    echo ""
    echo "  ┌─────────────────────────────────────────┐"
    echo "  │  Pairing Code:  $PAIR_CODE (expires in 5 min)  │"
    echo "  └─────────────────────────────────────────┘"
  fi

elif [[ -n "${TAILSCALE_IP:-}" ]]; then
  echo "  In the Clack app → Settings → Server:"
  echo ""
  echo "  ┌─────────────────────────────────────────┐"
  echo "  │  Server:     $TAILSCALE_IP"
  echo "  │  Port:       $PORT"
  echo "  │  Connection: Tailscale                  │"
  echo "  └─────────────────────────────────────────┘"
  echo ""
  echo "  No pairing needed — Tailscale handles authentication."
  echo "  Make sure Tailscale is also installed on your iPhone."
fi
echo ""


path: scripts/clack.sh
content: #!/usr/bin/env bash
# Clack CLI — thin wrapper
set -euo pipefail

SKILL_DIR="$(cd "$(dirname "$(readlink -f "$0")")/.." 2>/dev/null && pwd)" || SKILL_DIR=""

# If installed via symlink, resolve the real path
if [[ -L "$0" ]]; then
  REAL_PATH="$(readlink -f "$0")"
  SKILL_DIR="$(cd "$(dirname "$REAL_PATH")/.." && pwd)"
fi

usage() {
  echo "Usage: clack <command>"
  echo ""
  echo "Commands:"
  echo "  update      Pull latest code and restart"
  echo "  setup       Run interactive setup"
  echo "  uninstall   Remove Clack service and venv"
  echo "  pair        Generate a new pairing code"
  echo "  status      Show service status"
  echo "  logs        Tail service logs"
  echo "  restart     Restart the Clack service"
  echo ""
}

case "${1:-}" in
  update)
    echo "Updating Clack..."
    git -C "$SKILL_DIR" pull --ff-only && systemctl restart clack && echo "✓ Updated and restarted"
    ;;
  setup)
    bash "$SKILL_DIR/scripts/setup.sh" "${@:2}"
    ;;
  uninstall)
    bash "$SKILL_DIR/scripts/uninstall.sh"
    ;;
  pair)
    bash "$SKILL_DIR/scripts/pair.sh"
    ;;
  status)
    systemctl status clack 2>/dev/null || echo "Clack service not installed. Run: clack setup"
    ;;
  logs)
    journalctl -u clack -f
    ;;
  restart)
    systemctl restart clack && echo "✓ Clack restarted"
    ;;
  *)
    usage
    ;;
esac


path: scripts/server.py
content: #!/usr/bin/env python3
"""
Clack — Voice Relay Server for OpenClaw

WebSocket relay: voice input → STT → OpenClaw agent → TTS → audio back.

Supported providers:
  STT: elevenlabs, openai, deepgram
  TTS: elevenlabs, openai, deepgram

Environment variables:
  STT_PROVIDER          - STT provider (default: elevenlabs)
  TTS_PROVIDER          - TTS provider (default: elevenlabs)
  ELEVENLABS_API_KEY    - ElevenLabs API key
  OPENAI_API_KEY        - OpenAI API key
  DEEPGRAM_API_KEY      - Deepgram API key
  OPENCLAW_GATEWAY_URL  - OpenClaw gateway URL (default: http://127.0.0.1:18789)
  OPENCLAW_GATEWAY_TOKEN - OpenClaw gateway bearer token
  RELAY_AUTH_TOKEN       - Client auth token
  VOICE_RELAY_PORT       - Server port (default: 9878)
  TTS_VOICE             - Voice ID/name (provider-specific)
  CLACK_HISTORY_DIR     - History storage dir (default: /var/lib/clack/history)
  CLACK_MAX_HISTORY     - Max messages to keep (default: 50)
"""

import asyncio
import json
import os
import io
import struct
import hmac
import time
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional, AsyncIterator
from difflib import SequenceMatcher

import aiohttp
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
from fastapi.middleware.cors import CORSMiddleware
import uvicorn


# ── Utilities ──

def pcm_to_wav(pcm_data: bytes, sample_rate: int = 16000, channels: int = 1, bits_per_sample: int = 16) -> bytes:
    data_size = len(pcm_data)
    byte_rate = sample_rate * channels * bits_per_sample // 8
    block_align = channels * bits_per_sample // 8
    buf = io.BytesIO()
    buf.write(b'RIFF')
    buf.write(struct.pack('<I', 36 + data_size))
    buf.write(b'WAVE')
    buf.write(b'fmt ')
    buf.write(struct.pack('<I', 16))
    buf.write(struct.pack('<H', 1))
    buf.write(struct.pack('<H', channels))
    buf.write(struct.pack('<I', sample_rate))
    buf.write(struct.pack('<I', byte_rate))
    buf.write(struct.pack('<H', block_align))
    buf.write(struct.pack('<H', bits_per_sample))
    buf.write(b'data')
    buf.write(struct.pack('<I', data_size))
    buf.write(pcm_data)
    return buf.getvalue()


def _is_echo(transcript: str, last_response: str) -> bool:
    t = transcript.lower().strip().replace("[speaker speaker_0]: ", "")
    r = last_response.lower().strip()
    if t in r or r in t:
        return True
    ratio = SequenceMatcher(None, t, r).ratio()
    if ratio > 0.6:
        print(f"[Echo] Similarity: {ratio:.2f}")
        return True
    return False


# ── STT Providers ──

class STTProvider(ABC):
    @abstractmethod
    async def transcribe(self, audio_pcm: bytes) -> Optional[str]:
        """Transcribe PCM audio, return text or None."""
        pass


class ElevenLabsSTT(STTProvider):
    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]

    async def transcribe(self, audio_pcm: bytes) -> Optional[str]:
        async with aiohttp.ClientSession() as session:
            wav_data = pcm_to_wav(audio_pcm)
            print(f"[STT/11labs] Sending {len(wav_data)} bytes WAV")
            form = aiohttp.FormData()
            form.add_field('file', wav_data, filename='audio.wav', content_type='audio/wav')
            form.add_field('model_id', 'scribe_v1')
            form.add_field('diarize', 'false')
            form.add_field('tag_audio_events', 'false')
            async with session.post(
                "https://api.elevenlabs.io/v1/speech-to-text",
                headers={"xi-api-key": self.api_key},
                data=form
            ) as resp:
                if resp.status != 200:
                    print(f"[STT/11labs] Error: {resp.status} - {await resp.text()}")
                    return None
                result = await resp.json()
                lang_prob = result.get("language_probability", 0)
                text = result.get("text", "").strip()
                print(f"[STT/11labs] Raw: '{text[:200]}' (lang_prob={lang_prob:.2f})")
                if lang_prob < 0.4 or len(text) < 2:
                    print(f"[STT/11labs] Filtered: low confidence or too short")
                    return None
                # Filter hallucinated noise: only punctuation/whitespace
                import re as _re
                if not _re.search(r'[a-zA-ZäöüÄÖÜß]', text):
                    print(f"[STT/11labs] Filtered: no real words")
                    return None
                # Filter suspiciously long transcripts (likely hallucination)
                if len(text) > 500:
                    print(f"[STT/11labs] Filtered: too long ({len(text)} chars, likely hallucination)")
                    return None
                # Detect repetitive/nonsense hallucinations
                words = text.split()
                if len(words) > 5:
                    unique_ratio = len(set(w.lower() for w in words)) / len(words)
                    if unique_ratio < 0.3:
                        print(f"[STT/11labs] Filtered: repetitive (unique ratio {unique_ratio:.2f})")
                        return None
                words = result.get("words", [])
                if words:
                    confidences = [w.get("confidence", 1.0) for w in words if "confidence" in w]
                    if confidences and sum(confidences) / len(confidences) < 0.4:
                        print(f"[STT/11labs] Filtered: low word confidence")
                        return None
                if words and any(w.get("speaker_id") for w in words):
                    parts, cur_spk, cur_txt = [], None, []
                    for w in words:
                        spk = w.get("speaker_id", "?")
                        if spk != cur_spk:
                            if cur_txt:
                                parts.append(f"[Speaker {cur_spk}]: {' '.join(cur_txt)}")
                            cur_spk, cur_txt = spk, [w.get("text", "")]
                        else:
                            cur_txt.append(w.get("text", ""))
                    if cur_txt:
                        parts.append(f"[Speaker {cur_spk}]: {' '.join(cur_txt)}")
                    return "\n".join(parts)
                return text if text else None


class OpenAISTT(STTProvider):
    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]

    async def transcribe(self, audio_pcm: bytes) -> Optional[str]:
        async with aiohttp.ClientSession() as session:
            wav_data = pcm_to_wav(audio_pcm)
            print(f"[STT/openai] Sending {len(wav_data)} bytes WAV")
            form = aiohttp.FormData()
            form.add_field('file', wav_data, filename='audio.wav', content_type='audio/wav')
            form.add_field('model', 'whisper-1')
            async with session.post(
                "https://api.openai.com/v1/audio/transcriptions",
                headers={"Authorization": f"Bearer {self.api_key}"},
                data=form
            ) as resp:
                if resp.status != 200:
                    print(f"[STT/openai] Error: {resp.status} - {await resp.text()}")
                    return None
                result = await resp.json()
                text = result.get("text", "").strip()
                print(f"[STT/openai] Text: '{text[:200]}'")
                if len(text) < 2 or len(text) > 500:
                    return None
                return text


class DeepgramSTT(STTProvider):
    def __init__(self, api_key: str):
        self.[REDACTED_SECRET]

    async def transcribe(self, audio_pcm: bytes) -> Optional[str]:
        async with aiohttp.ClientSession() as session:
            wav_data = pcm_to_wav(audio_pcm)
            print(f"[STT/deepgram] Sending {len(wav_data)} bytes WAV")
            async with session.post(
                "https://api.deepgram.com/v1/listen?model=nova-2&smart_format=true&detect_language=true",
                headers={"Authorization": f"Token {self.api_key}", "Content-Type": "audio/wav"},
                data=wav_data
            ) as resp:
                if resp.status != 200:
                    print(f"[STT/deepgram] Error: {resp.status} - {await resp.text()}")
                    return None
                result = await resp.json()
                alt = result.get("results", {}).get("channels", [{}])[0].get("alternatives", [{}])[0]
                text = alt.get("transcript", "").strip()
                confidence = alt.get("confidence", 0)
                print(f"[STT/deepgram] Text: '{text[:200]}' (conf={confidence:.2f})")
                if confidence < 0.4 or len(text) < 2:
                    print(f"[STT/deepgram] Filtered")
                    return None
                return text


# ── TTS Providers ──

class TTSProvider(ABC):
    @abstractmethod
    async def synthesize_stream(self, text: str, send_audio) -> bool:
        """Stream TTS audio via send_audio(bytes) callback. Return True if audio was sent."""
        pass


class ElevenLabsTTS(TTSProvider):
    def __init__(self, api_key: str, voice_id: str = "bIHbv24MWmeRgasZH58o"):
        self.[REDACTED_SECRET]
        self.voice_id = voice_id

    async def synthesize_stream(self, text: str, send_audio) -> bool:
        async with aiohttp.ClientSession() as session:
            payload = {
                "text": text,
                "model_id": "eleven_multilingual_v2",
                "voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
            }
            async with session.post(
                f"https://api.elevenlabs.io/v1/text-to-speech/{self.voice_id}?output_format=pcm_16000",
                headers={"xi-api-key": self.api_key, "Content-Type": "application/json"},
                json=payload
            ) as resp:
                if resp.status != 200:
                    print(f"[TTS/11labs] Error: {resp.status} - {await resp.text()}")
                    return False
                total = 0
                buf = bytearray()
                CHUNK_SIZE = 16000
                async for chunk in resp.content.iter_any():
                    buf.extend(chunk)
                    while len(buf) >= CHUNK_SIZE:
                        await send_audio(bytes(buf[:CHUNK_SIZE]))
                        total += CHUNK_SIZE
                        del buf[:CHUNK_SIZE]
                if buf:
                    if len(buf) % 2 != 0:
                        buf.append(0)
                    await send_audio(bytes(buf))
                    total += len(buf)
                print(f"[TTS/11labs] Streamed {total} bytes PCM")
                return total > 0


class OpenAITTS(TTSProvider):
    def __init__(self, api_key: str, voice: str = "alloy"):
        self.[REDACTED_SECRET]
        self.voice = voice

    async def synthesize_stream(self, text: str, send_audio) -> bool:
        async with aiohttp.ClientSession() as session:
            payload = {
                "model": "tts-1",
                "input": text,
                "voice": self.voice,
                "response_format": "pcm",  # raw 24kHz 16-bit mono PCM
            }
            async with session.post(
                "https://api.openai.com/v1/audio/speech",
                headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
                json=payload
            ) as resp:
                if resp.status != 200:
                    print(f"[TTS/openai] Error: {resp.status} - {await resp.text()}")
                    return False
                # OpenAI returns 24kHz PCM, we need to resample to 16kHz
                total = 0
                buf = bytearray()
                CHUNK_SIZE = 24000  # 500ms at 24kHz
                async for chunk in resp.content.iter_any():
                    buf.extend(chunk)
                    while len(buf) >= CHUNK_SIZE:
                        resampled = _resample_24k_to_16k(bytes(buf[:CHUNK_SIZE]))
                        await send_audio(resampled)
                        total += len(resampled)
                        del buf[:CHUNK_SIZE]
                if buf:
                    if len(buf) % 2 != 0:
                        buf.append(0)
                    resampled = _resample_24k_to_16k(bytes(buf))
                    await send_audio(resampled)
                    total += len(resampled)
                print(f"[TTS/openai] Streamed {total} bytes PCM (resampled 24k→16k)")
                return total > 0


class DeepgramTTS(TTSProvider):
    def __init__(self, api_key: str, voice: str = "aura-asteria-en"):
        self.[REDACTED_SECRET]
        self.voice = voice

    async def synthesize_stream(self, text: str, send_audio) -> bool:
        async with aiohttp.ClientSession() as session:
            async with session.post(
                f"https://api.deepgram.com/v1/speak?model={self.voice}&encoding=linear16&sample_rate=16000",
                headers={"Authorization": f"Token {self.api_key}", "Content-Type": "application/json"},
                json={"text": text}
            ) as resp:
                if resp.status != 200:
                    print(f"[TTS/deepgram] Error: {resp.status} - {await resp.text()}")
                    return False
                total = 0
                buf = bytearray()
                CHUNK_SIZE = 16000
                async for chunk in resp.content.iter_any():
                    buf.extend(chunk)
                    while len(buf) >= CHUNK_SIZE:
                        await send_audio(bytes(buf[:CHUNK_SIZE]))
                        total += CHUNK_SIZE
                        del buf[:CHUNK_SIZE]
                if buf:
                    if len(buf) % 2 != 0:
                        buf.append(0)
                    await send_audio(bytes(buf))
                    total += len(buf)
                print(f"[TTS/deepgram] Streamed {total} bytes PCM")
                return total > 0


def _resample_24k_to_16k(data: bytes) -> bytes:
    """Simple 3:2 decimation from 24kHz to 16kHz (linear interpolation)."""
    import array
    samples = array.array('h')
    samples.frombytes(data[:len(data) - len(data) % 2])
    n = len(samples)
    out = array.array('h')
    ratio = 24000 / 16000  # 1.5
    out_len = int(n / ratio)
    for i in range(out_len):
        src = i * ratio
        idx = int(src)
        frac = src - idx
        if idx + 1 < n:
            val = int(samples[idx] * (1 - frac) + samples[idx + 1] * frac)
        else:
            val = samples[min(idx, n - 1)]
        out.append(max(-32768, min(32767, val)))
    return out.tobytes()


# ── Provider factory ──

def create_stt_provider() -> Optional[STTProvider]:
    provider = os.getenv("STT_PROVIDER", "elevenlabs").lower()
    if provider == "elevenlabs":
        key = os.getenv("ELEVENLABS_API_KEY", "")
        return ElevenLabsSTT(key) if key else None
    elif provider == "openai":
        key = os.getenv("OPENAI_API_KEY", "")
        return OpenAISTT(key) if key else None
    elif provider == "deepgram":
        key = os.getenv("DEEPGRAM_API_KEY", "")
        return DeepgramSTT(key) if key else None
    print(f"[STT] Unknown provider: {provider}")
    return None


def create_tts_provider() -> Optional[TTSProvider]:
    provider = os.getenv("TTS_PROVIDER", "elevenlabs").lower()
    voice = os.getenv("TTS_VOICE", "")
    if provider == "elevenlabs":
        key = os.getenv("ELEVENLABS_API_KEY", "")
        return ElevenLabsTTS(key, voice or "bIHbv24MWmeRgasZH58o") if key else None
    elif provider == "openai":
        key = os.getenv("OPENAI_API_KEY", "")
        return OpenAITTS(key, voice or "alloy") if key else None
    elif provider == "deepgram":
        key = os.getenv("DEEPGRAM_API_KEY", "")
        return DeepgramTTS(key, voice or "aura-asteria-en") if key else None
    print(f"[TTS] Unknown provider: {provider}")
    return None


# ── App setup ──

app = FastAPI(title="Clack Voice Relay")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])

OPENCLAW_GATEWAY_URL = os.getenv("OPENCLAW_GATEWAY_URL", "http://127.0.0.1:18789")
OPENCLAW_GATEWAY_TOKEN = os.getenv("OPENCLAW_GATEWAY_TOKEN", "")
RELAY_AUTH_TOKEN = os.getenv("RELAY_AUTH_TOKEN", "")
DEFAULT_VOICE_ID = "bIHbv24MWmeRgasZH58o"
VOICE_ALIASES = {
    "will": "bIHbv24MWmeRgasZH58o",
    "aria": "9BWtsMINqrJLrRacOk9x",
    "roger": "CwhRBWXzGAHq8TQ4Fs17",
    "sarah": "EXAVITQu4vr4xnSDxMaL",
    "laura": "FGY2WhTYpPnrIDTdsKH5",
    "charlie": "IKne3meq5aSn9XLyUdCD",
    "george": "JBFqnCBsd6RMkjVDRZzb",
    "callum": "N2lVS1w4EtoT3dr4eOWO",
    "river": "SAz9YHcvj6GT2YYXdXww",
    "liam": "TX3LPaxmHKxFdv7VOQHJ",
    "charlotte": "XB0fDUnXU5powFXDhCwa",
    "alice": "Xb7hH8MSUJpSbSDYk0k2",
    "matilda": "XrExE9yKIg1WjnnlVkGX",
    "jessica": "cgSgspJ2msm6clMCkdW9",
    "eric": "cjVigY5qzO86Huf0OWal",
    "chris": "iP95p4xoKVk53GoZ742B",
    "brian": "nPczCjzI2devNBz1zQrb",
    "daniel": "onwK4e9ZLuTAKqWW03F9",
    "lily": "pFZP5JQG7iQjIQuC4Bku",
    "bill": "pqHfZKP75CvOlQylNhV4",
}
VOICE_METADATA = [
    {"id": "bIHbv24MWmeRgasZH58o", "name": "Will", "gender": "male", "accent": "American", "style": "warm, relaxed"},
    {"id": "9BWtsMINqrJLrRacOk9x", "name": "Aria", "gender": "female", "accent": "American", "style": "expressive, confident"},
    {"id": "CwhRBWXzGAHq8TQ4Fs17", "name": "Roger", "gender": "male", "accent": "American", "style": "deep, authoritative"},
    {"id": "EXAVITQu4vr4xnSDxMaL", "name": "Sarah", "gender": "female", "accent": "American", "style": "soft, friendly"},
    {"id": "FGY2WhTYpPnrIDTdsKH5", "name": "Laura", "gender": "female", "accent": "American", "style": "gentle, soothing"},
    {"id": "IKne3meq5aSn9XLyUdCD", "name": "Charlie", "gender": "male", "accent": "Australian", "style": "casual, natural"},
    {"id": "JBFqnCBsd6RMkjVDRZzb", "name": "George", "gender": "male", "accent": "British", "style": "warm, narrative"},
    {"id": "N2lVS1w4EtoT3dr4eOWO", "name": "Callum", "gender": "male", "accent": "Scottish", "style": "intense, character"},
    {"id": "SAz9YHcvj6GT2YYXdXww", "name": "River", "gender": "nonbinary", "accent": "American", "style": "calm, confident"},
    {"id": "TX3LPaxmHKxFdv7VOQHJ", "name": "Liam", "gender": "male", "accent": "American", "style": "articulate, neutral"},
    {"id": "XB0fDUnXU5powFXDhCwa", "name": "Charlotte", "gender": "female", "accent": "Swedish", "style": "elegant, seductive"},
    {"id": "Xb7hH8MSUJpSbSDYk0k2", "name": "Alice", "gender": "female", "accent": "British", "style": "confident, clear"},
    {"id": "XrExE9yKIg1WjnnlVkGX", "name": "Matilda", "gender": "female", "accent": "American", "style": "warm, pleasant"},
    {"id": "cgSgspJ2msm6clMCkdW9", "name": "Jessica", "gender": "female", "accent": "American", "style": "expressive, upbeat"},
    {"id": "cjVigY5qzO86Huf0OWal", "name": "Eric", "gender": "male", "accent": "American", "style": "friendly, conversational"},
    {"id": "iP95p4xoKVk53GoZ742B", "name": "Chris", "gender": "male", "accent": "American", "style": "casual, clear"},
    {"id": "nPczCjzI2devNBz1zQrb", "name": "Brian", "gender": "male", "accent": "American", "style": "deep, narrative"},
    {"id": "onwK4e9ZLuTAKqWW03F9", "name": "Daniel", "gender": "male", "accent": "British", "style": "authoritative, deep"},
    {"id": "pFZP5JQG7iQjIQuC4Bku", "name": "Lily", "gender": "female", "accent": "British", "style": "warm, clear"},
    {"id": "pqHfZKP75CvOlQylNhV4", "name": "Bill", "gender": "male", "accent": "American", "style": "trustworthy, documentary"},
]
MAX_INPUT_CHARS = int(os.getenv("CLACK_MAX_INPUT_CHARS", "300"))

DEFAULT_SYSTEM_PROMPT = (
    "You are a voice assistant. The user is talking to you via voice. "
    "RESPONSE RULES — these are MANDATORY:\n"
    "- Keep responses to 1-3 sentences MAX. This is spoken conversation, not text.\n"
    "- NEVER use bullet points, numbered lists, markdown, or headers.\n"
    "- NEVER give long explanations. Be brief like a real person talking.\n"
    "- If the user asks something complex, give a short summary and offer to elaborate.\n"
    "- Respond naturally and directly. No filler phrases.\n"
    "- Do not include or reference any metadata, labels, or formatting artifacts.\n"
    "SAFETY: This is a voice session — transcription errors and hallucinations are common. "
    "NEVER execute destructive actions (delete files, send emails/messages, modify system settings, "
    "run shell commands, make purchases, or change configurations) based on voice input alone. "
    "For any action that modifies data or has external effects, describe what you WOULD do and ask for explicit confirmation. "
    "Read-only actions (search, weather, info lookups) are fine without confirmation."
)

stt_provider = create_stt_provider()
tts_provider = create_tts_provider()

STT_NAME = os.getenv("STT_PROVIDER", "elevenlabs")
TTS_NAME = os.getenv("TTS_PROVIDER", "elevenlabs")
print(f"[Clack] Starting voice relay server...")
print(f"[Clack] STT: {STT_NAME} ({'ready' if stt_provider else 'NOT CONFIGURED'})")
print(f"[Clack] TTS: {TTS_NAME} ({'ready' if tts_provider else 'NOT CONFIGURED'})")
print(f"[Clack] Gateway: {OPENCLAW_GATEWAY_URL}")
print(f"[Clack] Relay auth: {'ENABLED' if RELAY_AUTH_TOKEN else 'DISABLED (open!)'}")


# ── History ──

HISTORY_DIR = Path(os.getenv("CLACK_HISTORY_DIR", "/var/lib/clack/history"))
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
MAX_HISTORY_MESSAGES = int(os.getenv("CLACK_MAX_HISTORY", "50"))


def _history_path() -> Path:
    return HISTORY_DIR / "conversation.json"


def load_history() -> list:
    path = _history_path()
    if path.exists():
        try:
            data = json.loads(path.read_text())
            print(f"[History] Loaded {len(data)} messages")
            return data[-MAX_HISTORY_MESSAGES:]
        except Exception as e:
            print(f"[History] Error loading: {e}")
    return []


def save_history(history: list):
    path = _history_path()
    try:
        path.write_text(json.dumps(history[-MAX_HISTORY_MESSAGES:]))
    except Exception as e:
        print(f"[History] Error saving: {e}")


def verify_token(token: str) -> bool:
    if not RELAY_AUTH_TOKEN:
        return True
    return hmac.compare_digest(token, RELAY_AUTH_TOKEN)


# ── Pairing ──

import secrets
import string

_pairing_codes: dict = {}  # code -> {"expires": timestamp}
PAIRING_TTL = 300  # 5 minutes


def _generate_pairing_code() -> str:
    """Generate a 6-char alphanumeric one-time pairing code."""
    code = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(6))
    _pairing_codes[code] = {"expires": time.time() + PAIRING_TTL}
    # Clean expired codes
    now = time.time()
    expired = [c for c, v in _pairing_codes.items() if v["expires"] < now]
    for c in expired:
        del _pairing_codes[c]
    return code


def _redeem_pairing_code(code: str) -> bool:
    """Validate and consume a one-time pairing code."""
    code = code.upper().strip()
    entry = _pairing_codes.pop(code, None)
    if not entry:
        return False
    if time.time() > entry["expires"]:
        return False
    return True


# ── Context store ──
# Persistent context that gets injected into the system prompt.
# Can be set via HTTP endpoint or WebSocket message.

CONTEXT_FILE = HISTORY_DIR / "context.json"


def load_context() -> dict:
    """Load saved context. Returns dict with 'text' and optional metadata."""
    if CONTEXT_FILE.exists():
        try:
            return json.loads(CONTEXT_FILE.read_text())
        except Exception as e:
            print(f"[Context] Error loading: {e}")
    return {}


def save_context(ctx: dict):
    """Save context to disk."""
    try:
        CONTEXT_FILE.write_text(json.dumps(ctx))
        print(f"[Context] Saved: {json.dumps(ctx)[:100]}")
    except Exception as e:
        print(f"[Context] Error saving: {e}")


# ── Voice Session ──

class VoiceSession:
    def __init__(self, websocket: WebSocket, config: dict):
        self.websocket = websocket
        self.config = config
        self.user_context = load_context()
        self.system_prompt = self._build_system_prompt(config)
        self.conversation_history = load_history()
        self.audio_buffer = bytearray()
        self.last_assistant_response = ""
        self.processing = False

        # Allow per-session voice override
        voice = config.get("voice", "")
        if voice and tts_provider and isinstance(tts_provider, ElevenLabsTTS):
            self.tts = ElevenLabsTTS(tts_provider.api_key, VOICE_ALIASES.get(voice, voice))
        else:
            self.tts = tts_provider
        self.stt = stt_provider

        # Apply context from start config if provided
        if config.get("context"):
            self.update_context(config["context"])

    def _build_system_prompt(self, config: dict) -> str:
        """Build system prompt with optional user context injected."""
        base = config.get("systemPrompt", DEFAULT_SYSTEM_PROMPT)
        ctx = self.user_context
        if ctx.get("text"):
            base += f"\n\nUSER CONTEXT (provided by the user — use this to inform your responses):\n{ctx['text']}"
        return base

    def update_context(self, text: str):
        """Update the user context and rebuild system prompt."""
        self.user_context = {"text": text, "updated": time.time()}
        save_context(self.user_context)
        self.system_prompt = self._build_system_prompt(self.config)
        print(f"[Context] Updated: {text[:100]}")

    async def send_json(self, data: dict):
        await self.websocket.send_text(json.dumps(data))

    async def send_audio(self, audio_data: bytes):
        await self.websocket.send_bytes(audio_data)

    async def transcribe_audio(self, audio_data: bytes) -> Optional[str]:
        if not self.stt:
            print("[STT] No provider configured")
            return None
        MAX_CHUNK = 960000
        if len(audio_data) > MAX_CHUNK:
            chunks = [audio_data[i:i + MAX_CHUNK] for i in range(0, len(audio_data), MAX_CHUNK)]
            print(f"[STT] Splitting {len(audio_data)} bytes into {len(chunks)} chunks")
            parts = [await self.stt.transcribe(c) for c in chunks]
            combined = " ".join(p for p in parts if p)
            return combined if combined.strip() else None
        return await self.stt.transcribe(audio_data)

    async def greet(self):
        messages = [
            {"role": "system", "content": self.system_prompt},
            *self.conversation_history,
            {"role": "user", "content": "[Voice session started. Greet the user briefly.]"},
        ]
        async with aiohttp.ClientSession() as session:
            headers = {"Authorization": f"Bearer {OPENCLAW_GATEWAY_TOKEN}", "Content-Type": "application/json"}
            payload = {"model": "openclaw", "messages": messages, "max_tokens": 150}
            try:
                async with session.post(
                    f"{OPENCLAW_GATEWAY_URL}/v1/chat/completions",
                    headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=30)
                ) as resp:
                    if resp.status == 200:
                        result = await resp.json()
                        greeting = result["choices"][0]["message"]["content"]
                    else:
                        greeting = "Hey!"
            except Exception:
                greeting = "Hey!"
        self.conversation_history.append({"role": "assistant", "content": greeting})
        self.last_assistant_response = greeting
        print(f"[Greeting] {greeting}")
        await self.send_json({"type": "response_text", "text": greeting})
        await self.send_json({"type": "response_start", "format": "pcm_16000"})
        if self.tts:
            await self.tts.synthesize_stream(greeting[:500], self.send_audio)
        await self.send_json({"type": "response_end"})

    async def get_llm_response(self, user_message: str) -> Optional[str]:
        self.conversation_history.append({"role": "user", "content": user_message})
        save_history(self.conversation_history)
        messages = [{"role": "system", "content": self.system_prompt}] + self.conversation_history

        async def _llm_call():
            async with aiohttp.ClientSession() as session:
                headers = {"Authorization": f"Bearer {OPENCLAW_GATEWAY_TOKEN}", "Content-Type": "application/json"}
                payload = {"model": "openclaw", "messages": messages, "max_tokens": 150}
                async with session.post(
                    f"{OPENCLAW_GATEWAY_URL}/v1/chat/completions",
                    headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=60)
                ) as resp:
                    if resp.status == 200:
                        result = await resp.json()
                        return result["choices"][0]["message"]["content"]
                    else:
                        print(f"[LLM] OpenClaw error: {resp.status} - {await resp.text()}")
                        return None

        # Run LLM call with keepalive pings to prevent client timeout
        try:
            llm_task = asyncio.create_task(_llm_call())
            while not llm_task.done():
                done, _ = await asyncio.wait({llm_task}, timeout=5.0)
                if not done:
                    try:
                        await self.send_json({"type": "processing", "stage": "thinking"})
                        print(f"[LLM] Still waiting, sent keepalive")
                    except Exception:
                        pass
            content = llm_task.result()
            if content:
                self.conversation_history.append({"role": "assistant", "content": content})
                save_history(self.conversation_history)
                return content
            return "Sorry, I had trouble processing that."
        except Exception as e:
            print(f"[LLM] Connection error: {e}")
            return "Sorry, I couldn't reach the assistant right now."


# ── HTTP endpoints ──

@app.get("/")
async def root():
    return {"status": "ok", "service": "Clack Voice Relay", "stt": STT_NAME, "tts": TTS_NAME}

@app.get("/health")
async def health():
    return {"status": "ok", "backend": "openclaw", "stt": STT_NAME, "tts": TTS_NAME}

@app.get("/voices")
async def list_voices(token: str = Query(default="")):
    if not verify_token(token):
        return {"error": "unauthorized"}, 401
    default = os.getenv("TTS_VOICE", "bIHbv24MWmeRgasZH58o")
    return {"voices": VOICE_METADATA, "default": default}


@app.get("/pair")
async def create_pairing(token: str = Query(default="")):
    """Generate a one-time pairing code. Requires admin auth (relay token)."""
    if not verify_token(token):
        return {"error": "unauthorized"}, 401
    code = _generate_pairing_code()
    print(f"[Pair] Generated code: {code} (expires in {PAIRING_TTL}s)")
    return {"code": code, "expires_in": PAIRING_TTL}


@app.post("/pair")
async def redeem_pairing(code: str = Query(default="")):
    """Redeem a pairing code to get the auth token. No auth required."""
    if not code:
        return {"error": "code required"}, 400
    if _redeem_pairing_code(code):
        print(f"[Pair] Code redeemed successfully")
        return {"token": RELAY_AUTH_TOKEN}
    else:
        print(f"[Pair] Invalid/expired code: {code}")
        return {"error": "invalid or expired code"}, 401


@app.get("/history")
async def get_history(token: str = Query(default="")):
    if not verify_token(token):
        return {"error": "unauthorized"}, 401
    history = load_history()
    return {"messages": history, "count": len(history)}

@app.delete("/history")
async def clear_history(token: str = Query(default="")):
    if not verify_token(token):
        return {"error": "unauthorized"}, 401
    path = _history_path()
    if path.exists():
        path.unlink()
    return {"cleared": True}


@app.get("/context")
async def get_context(token: str = Query(default="")):
    """Get the current user context."""
    if not verify_token(token):
        return {"error": "unauthorized"}, 401
    return load_context() or {"text": ""}


@app.put("/context")
async def set_context_put(token: str = Query(default=""), text: str = Query(default="")):
    """Set user context via query param."""
    if not verify_token(token):
        return {"error": "unauthorized"}, 401
    if not text:
        return {"error": "text required"}, 400
    ctx = {"text": text[:1000], "updated": time.time()}
    save_context(ctx)
    return {"ok": True, "context": ctx}


from fastapi import Request

@app.post("/context")
async def set_context_post(request: Request, token: str = Query(default="")):
    """Set user context via JSON body: {"text": "..."}"""
    if not verify_token(token):
        return {"error": "unauthorized"}, 401
    try:
        body = await request.json()
        text = body.get("text", "")
    except Exception:
        return {"error": "invalid JSON"}, 400
    if not text:
        return {"error": "text required"}, 400
    ctx = {"text": text[:1000], "updated": time.time()}
    save_context(ctx)
    return {"ok": True, "context": ctx}


@app.delete("/context")
async def clear_context(token: str = Query(default="")):
    """Clear user context."""
    if not verify_token(token):
        return {"error": "unauthorized"}, 401
    if CONTEXT_FILE.exists():
        CONTEXT_FILE.unlink()
    return {"cleared": True}


# ── WebSocket ──

@app.websocket("/voice")
async def voice_endpoint(websocket: WebSocket, token: str = Query(default="")):
    await websocket.accept()
    print(f"[WS] Client connected: {websocket.client}")
    authenticated = verify_token(token) if token else not RELAY_AUTH_TOKEN
    session = None
    try:
        while True:
            message = await websocket.receive()
            if "text" in message:
                data = json.loads(message["text"])
                msg_type = data.get("type")
                if msg_type == "auth" and not authenticated:
                    if verify_token(data.get("token", "")):
                        authenticated = True
                        await websocket.send_text(json.dumps({"type": "auth_ok"}))
                    else:
                        await websocket.send_text(json.dumps({"type": "auth_failed"}))
                        await websocket.close(code=4001, reason="Invalid token")
                        return
                    continue
                if not authenticated:
                    await websocket.send_text(json.dumps({"type": "auth_required"}))
                    await websocket.close(code=4001, reason="Authentication required")
                    return
                if msg_type == "start":
                    config = data.get("config", {})
                    session = VoiceSession(websocket, config)
                    print(f"[WS] Session started")
                    await session.send_json({"type": "ready"})
                    if session.conversation_history:
                        print(f"[WS] Resuming ({len(session.conversation_history)} messages)")
                    await session.greet()
                elif msg_type == "set_context" and session:
                    ctx_text = data.get("text", "")
                    if ctx_text:
                        session.update_context(ctx_text)
                        await session.send_json({"type": "context_updated", "text": ctx_text})
                    else:
                        # Clear context
                        session.user_context = {}
                        if CONTEXT_FILE.exists():
                            CONTEXT_FILE.unlink()
                        session.system_prompt = session._build_system_prompt(session.config)
                        await session.send_json({"type": "context_cleared"})
                elif msg_type == "end_speech" and session:
                    if session.processing:
                        print(f"[WS] Ignoring end_speech — still processing")
                        session.audio_buffer = bytearray()
                        continue
                    if session.audio_buffer:
                        session.processing = True
                        print(f"[WS] Processing {len(session.audio_buffer)} bytes")
                        await session.send_json({"type": "processing", "stage": "transcribing"})
                        transcript = await session.transcribe_audio(bytes(session.audio_buffer))
                        if not transcript:
                            await session.send_json({"type": "processing", "stage": "filtered"})
                            await session.send_json({"type": "response_end"})
                            session.audio_buffer = bytearray()
                            session.processing = False
                            continue
                        if session.last_assistant_response and _is_echo(transcript, session.last_assistant_response):
                            print(f"[STT] Filtered: echo")
                            await session.send_json({"type": "processing", "stage": "filtered"})
                            await session.send_json({"type": "response_end"})
                            session.audio_buffer = bytearray()
                            session.processing = False
                            continue
                        # Strip speaker labels — they confuse the LLM
                        import re
                        transcript = re.sub(r'\[Speaker \w+\]:\s*', '', transcript).strip()
                        # Enforce input length limit
                        if len(transcript) > MAX_INPUT_CHARS:
                            print(f"[STT] Truncated: {len(transcript)} → {MAX_INPUT_CHARS} chars")
                            transcript = transcript[:MAX_INPUT_CHARS]
                        print(f"[STT] Transcript: {transcript}")
                        await session.send_json({"type": "transcript", "text": transcript, "final": True})
                        await session.send_json({"type": "processing", "stage": "thinking"})
                        response = await session.get_llm_response(transcript)
                        if response:
                            print(f"[LLM] Response: {response[:100]}...")
                            session.last_assistant_response = response
                            await session.send_json({"type": "response_text", "text": response})
                            await session.send_json({"type": "processing", "stage": "speaking"})
                            await session.send_json({"type": "response_start", "format": "pcm_16000"})
                            if session.tts:
                                await session.tts.synthesize_stream(response[:500], session.send_audio)
                            await session.send_json({"type": "response_end"})
                        session.audio_buffer = bytearray()
                        session.processing = False
                elif msg_type == "ping":
                    await websocket.send_text(json.dumps({"type": "pong"}))
            elif "bytes" in message and session:
                if not authenticated:
                    continue
                session.audio_buffer.extend(message["bytes"])
    except WebSocketDisconnect:
        print(f"[WS] Client disconnected")
    except Exception as e:
        print(f"[WS] Error: {e}")
    finally:
        print(f"[WS] Session ended")


if __name__ == "__main__":
    port = int(os.getenv("VOICE_RELAY_PORT", "9878"))
    uvicorn.run(app, host="0.0.0.0", port=port, ws_ping_interval=20, ws_ping_timeout=30)


path: scripts/uninstall.sh
content: #!/usr/bin/env bash
# Clack Voice Relay — uninstall
set -euo pipefail

echo "=== Clack Voice Relay — Uninstall ==="

# Stop and remove systemd service
if [[ -f /etc/systemd/system/clack.service ]]; then
  echo "Stopping clack service..."
  systemctl stop clack 2>/dev/null || true
  systemctl disable clack 2>/dev/null || true
  rm -f /etc/systemd/system/clack.service
  systemctl daemon-reload
  echo "  ✓ Service removed"
else
  echo "  No clack service found"
fi

# Resolve skill directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"

# Remove CLI symlink
if [[ -L /usr/local/bin/clack ]]; then
  rm -f /usr/local/bin/clack
  echo "  ✓ 'clack' command removed"
fi

# Remove Caddy reverse proxy config (if we added it)
CADDY_CONF="/etc/caddy/Caddyfile"
if [[ -f "$CADDY_CONF" ]] && grep -q "reverse_proxy localhost:.*# clack" "$CADDY_CONF" 2>/dev/null; then
  # Remove the clack server block
  python3 -c "
import re
conf = open('$CADDY_CONF').read()
# Remove block: domain { ... reverse_proxy localhost:PORT # clack ... }
conf = re.sub(r'\n[^\s{]+\s*\{[^}]*# clack[^}]*\}\n?', '\n', conf)
open('$CADDY_CONF', 'w').write(conf)
" 2>/dev/null && echo "  ✓ Caddy reverse proxy config removed"
  systemctl reload caddy 2>/dev/null || true
fi

# Remove venv
if [[ -d "$SKILL_DIR/venv" ]]; then
  rm -rf "$SKILL_DIR/venv"
  echo "  ✓ Python venv removed"
fi

echo ""
echo "Uninstall complete."
echo "The skill directory ($SKILL_DIR) is still on disk."
echo "To fully remove: rm -rf $SKILL_DIR"
echo ""
echo "Note: Tailscale and Caddy were NOT removed (you may use them for other things)."
echo ""


path: scripts/pair.sh
content: #!/usr/bin/env bash
# Generate a one-time pairing code for the Clack iOS app.
# The code expires in 5 minutes.
set -euo pipefail

# Read token and port from systemd service
SERVICE_FILE="/etc/systemd/system/clack.service"
if [[ ! -f "$SERVICE_FILE" ]]; then
  echo "ERROR: Clack service not found. Run setup.sh first."
  exit 1
fi

RELAY_AUTH_TOKEN=$(grep "RELAY_AUTH_TOKEN=" "$SERVICE_FILE" | head -1 | sed 's/.*RELAY_AUTH_TOKEN=//')
PORT=$(grep "VOICE_RELAY_PORT=" "$SERVICE_FILE" | head -1 | sed 's/.*VOICE_RELAY_PORT=//')
PORT="${PORT:-9878}"

if [[ -z "$RELAY_AUTH_TOKEN" ]]; then
  echo "ERROR: Could not read auth token from service file."
  exit 1
fi

# Check service is running
if ! systemctl is-active --quiet clack; then
  echo "ERROR: Clack service is not running. Start it with: sudo systemctl start clack"
  exit 1
fi

# Generate pairing code
RESPONSE=$(curl -s "http://localhost:${PORT}/pair?[REDACTED_SECRET]")
CODE=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['code'])" 2>/dev/null)

if [[ -z "$CODE" ]]; then
  echo "ERROR: Could not generate pairing code."
  echo "Response: $RESPONSE"
  exit 1
fi

echo ""
echo "  ┌─────────────────────────────────────────┐"
echo "  │                                         │"
echo "  │   Pairing Code:  $CODE                  │"
echo "  │                                         │"
echo "  │   Enter this in the Clack app:          │"
echo "  │   Settings → Server → Pair              │"
echo "  │                                         │"
echo "  │   Expires in 5 minutes.                 │"
echo "  └─────────────────────────────────────────┘"
echo ""


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)