From 81954b1b0c4c50c085d9dd1c8d0d72d4c7d70884 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Mon, 30 Mar 2026 08:32:31 +0400 Subject: [PATCH] =?UTF-8?q?v0.0.44:=20web=20UI=20polish=20=E2=80=94=20ETH?= =?UTF-8?q?=20display,=20peer=20input,=20call=20fixes,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web UI: - Peer input Enter key now resolves ETH/@alias (like /peer command) - ETH address stored and shown everywhere instead of raw fingerprint - Call UI shows ETH address: "Calling 0x0021...", "In call with 0x9D70..." - Server URL color: #444 → #666 (readable on dark background) - Peer input placeholder: "ETH address, fingerprint, or @alias" - peerEthAddr persisted in localStorage across sessions Server: - WS binary header: strip zero-padding from 64-char to 32-char fingerprint - Call routing now works (was failing due to padded fingerprint lookup) - startCall() resolves ETH/alias before sending CallSignal::Offer - Audio bridge sends auth token to wzp-web as first WS message - Deterministic room name: sorted fingerprint pair (both peers same room) Docs updated: - SERVER.md: WZP integration section (components, running, TLS, auth flow) - USAGE.md: voice call usage for web and TUI - LLM_HELP.md: call architecture, key files, environment vars - LLM_BOT_DEV.md: note that bots cannot participate in calls - TESTING_E2E.md: updated WZP prerequisites with correct flags Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/web.rs | 121 +++++++++++++++--- .../crates/warzone-server/src/routes/ws.rs | 9 +- warzone/docs/LLM_BOT_DEV.md | 4 + warzone/docs/LLM_HELP.md | 34 +++++ warzone/docs/SERVER.md | 50 ++++++++ warzone/docs/TESTING_E2E.md | 19 +-- warzone/docs/USAGE.md | 26 ++++ 10 files changed, 240 insertions(+), 37 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index d61dd71..1a43439 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.43" +version = "0.0.44" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.43" +version = "0.0.44" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.43" +version = "0.0.44" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.43" +version = "0.0.44" dependencies = [ "anyhow", "axum", @@ -3054,7 +3054,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.43" +version = "0.0.44" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 42963c0..075c793 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.43" +version = "0.0.44" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 87fd382..c23f065 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.43" +version = "0.0.44" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 8cda4bc..2742672 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v25'; +const CACHE = 'wz-v26'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -150,7 +150,7 @@ const WEB_HTML: &str = r##" #chat-header .tag { padding: 1px 6px; border-radius: 3px; font-size: 0.85em; } .tag-fp { background: #0a2e0a; color: #4ade80; } .tag-peer { background: #2e2e0a; color: #e6a23c; } - .tag-server { color: #444; } + .tag-server { color: #666; font-size: 0.8em; } #chat-header input { background: #1a1a2e; border: 1px solid #333; color: #e6a23c; padding: 2px 6px; border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; } @@ -248,7 +248,7 @@ const WEB_HTML: &str = r##" - +
@@ -281,13 +281,14 @@ let wasmIdentity = null; // WasmIdentity from WASM let myFingerprint = ''; let myEthAddress = ''; let mySeedHex = ''; +let peerEthAddr = null; // Peer's ETH address (for display; null if set by fingerprint) let sessions = {}; // peerFP -> { session: WasmSession, data: base64 } let peerBundles = {}; // peerFP -> bundle bytes let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.43'; +const VERSION = '0.0.44'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -348,6 +349,23 @@ function normFP(fp) { return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); } +function peerDisplayName() { + if (peerEthAddr) return peerEthAddr.slice(0, 12) + '...'; + const v = document.getElementById('peer-input').value.trim(); + return v ? v.slice(0, 16) + '...' : '?'; +} + +function updatePeerDisplay() { + // Resolve ETH address for display if we have a fingerprint + const fp = document.getElementById('peer-input').value.trim(); + if (fp && !fp.startsWith('#') && !fp.startsWith('@') && !peerEthAddr) { + // Try to get ETH address from server + fetch(SERVER + '/v1/resolve/' + fp).then(r => r.json()).then(data => { + if (data.eth_address) { peerEthAddr = data.eth_address; } + }).catch(() => {}); + } +} + function makeAddressClickable(text) { // Match fingerprint format: xxxx:xxxx:xxxx:xxxx... (at least 4 groups) text = text.replace(/([0-9a-f]{4}(?::[0-9a-f]{4}){3,})/gi, function(match) { @@ -1084,6 +1102,7 @@ async function enterChat() { if (savedPeer) { $peerInput.value = savedPeer; } + peerEthAddr = localStorage.getItem('wz-peer-eth') || null; connectWebSocket(); @@ -1244,18 +1263,18 @@ function updateCallUI() { } break; case 'calling': - status.textContent = '\u{1F4DE} Calling ' + (callPeer || '...').slice(0, 16); + status.textContent = '\u{1F4DE} Calling ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '...').slice(0, 16)); status.className = 'call-status'; btnHangup.style.display = ''; break; case 'ringing': - status.textContent = '\u{1F4DE} Incoming call from ' + (callPeer || '?').slice(0, 16); + status.textContent = '\u{1F4DE} Incoming call from ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16)); status.className = 'call-status incoming-call'; btnAccept.style.display = ''; btnReject.style.display = ''; break; case 'active': - status.textContent = '\u{1F50A} In call with ' + (callPeer || '?').slice(0, 16); + status.textContent = '\u{1F50A} In call with ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16)); status.className = 'call-status'; btnHangup.style.display = ''; break; @@ -1263,18 +1282,29 @@ function updateCallUI() { } async function startCall() { - const peer = $peerInput.value.trim(); + let peer = $peerInput.value.trim(); if (!peer || peer.startsWith('#')) { addSys('Set a peer first'); return; } + // Resolve ETH address or @alias to fingerprint + if (peer.startsWith('@') || peer.startsWith('0x') || peer.startsWith('0X')) { + const endpoint = peer.startsWith('@') ? '/v1/alias/resolve/' + peer.slice(1) : '/v1/resolve/' + peer; + try { + const resp = await fetch(SERVER + endpoint); + const data = await resp.json(); + if (data.error) { addSys('Cannot resolve peer: ' + data.error); return; } + peer = data.fingerprint; + } catch(e) { addSys('Cannot resolve peer: ' + e.message); return; } + } + callState = 'calling'; callPeer = peer; updateCallUI(); // Send CallSignal::Offer via WS try { - const signalBytes = create_call_signal(wasmIdentity, 'offer', '', normFP(peer)); + const fp = normFP(peer); + const signalBytes = create_call_signal(wasmIdentity, 'offer', '', fp); if (ws && ws.readyState === WebSocket.OPEN) { - const fp = normFP(peer); const header = new TextEncoder().encode(fp.padEnd(64, '0').slice(0, 64)); const payload = new Uint8Array(header.length + signalBytes.length); payload.set(header); @@ -1409,13 +1439,14 @@ let captureNode = null; let playbackNode = null; async function startAudio() { - // Fetch relay config - let relayAddr; + // Fetch relay config (includes auth token) + let relayAddr, authToken; try { const resp = await fetch(SERVER + '/v1/wzp/relay-config'); const data = await resp.json(); relayAddr = data.relay_addr; - dbg('Relay address:', relayAddr); + authToken = data.token; + dbg('Relay address:', relayAddr, 'token:', authToken); } catch(e) { addSys('Audio: cannot get relay config \u2014 ' + e.message); return; @@ -1433,18 +1464,23 @@ async function startAudio() { audioCtx = new AudioContext({ sampleRate: 48000 }); - // Generate room name from call peer (deterministic) - const room = callPeer ? normFP(callPeer).slice(0, 16) : 'default'; - const proto = relayAddr.startsWith('https') ? 'wss:' : 'ws:'; - const host = relayAddr.replace(/^https?:\\/\\//, ''); + // Deterministic room: sort both fingerprints so both peers get the same room + const myFP = normFP(myFingerprint); + const peerFP = callPeer ? normFP(callPeer) : ''; + const roomPair = [myFP, peerFP].sort().join('-'); + const room = roomPair.slice(0, 32); + const host = relayAddr.replace(/^https?:\/\//, ''); + const proto = host.startsWith('localhost') || host.startsWith('127.') ? 'ws:' : 'wss:'; const wsUrl = proto + '//' + host + '/ws/' + room; - addSys('Audio: connecting to ' + room + '...'); + addSys('Audio: connecting to room ' + room.slice(0, 12) + '...'); audioWs = new WebSocket(wsUrl); audioWs.binaryType = 'arraybuffer'; audioWs.onopen = async () => { + // Send auth token as first message (required by wzp-web --auth-url) + audioWs.send(JSON.stringify({ type: 'auth', token: authToken })); addSys('Audio: connected \u2014 mic active'); // Capture: mic -> PCM frames -> WS @@ -1658,14 +1694,20 @@ async function doSend() { const resp = await fetch(SERVER + endpoint); const data = await resp.json(); if (data.error) { addSys('Cannot resolve ' + val + ': ' + data.error); return; } + peerEthAddr = (val.startsWith('0x') || val.startsWith('0X')) ? val : (data.eth_address || null); $peerInput.value = data.fingerprint; + localStorage.setItem('wz-peer', val); + if (peerEthAddr) localStorage.setItem('wz-peer-eth', peerEthAddr); addSys(val + ' \u2192 ' + data.fingerprint.slice(0,16) + '...'); + addSys('Peer set to ' + (peerEthAddr || data.fingerprint.slice(0,16) + '...')); } else { $peerInput.value = val; + peerEthAddr = null; + localStorage.setItem('wz-peer', val); + localStorage.removeItem('wz-peer-eth'); } currentGroup = null; - localStorage.setItem('wz-peer', $peerInput.value); - addSys('Peer set to ' + $peerInput.value.slice(0,16) + '...'); + addSys('Peer set to ' + peerDisplayName()); updateCallUI(); return; } @@ -1793,6 +1835,45 @@ $input.addEventListener('input', function() { this.style.height = Math.min(this.scrollHeight, 120) + 'px'; }); +// Peer input: Enter sets peer (like /peer command) +document.getElementById('peer-input').addEventListener('keydown', async (e) => { + if (e.key !== 'Enter') return; + const val = e.target.value.trim(); + if (!val) return; + // Treat as /peer command + if (val.startsWith('@') || val.startsWith('0x') || val.startsWith('0X') || /^[0-9a-fA-F]{16,}$/.test(val)) { + const endpoint = val.startsWith('@') ? '/v1/alias/resolve/' + val.slice(1) : (val.startsWith('0x') || val.startsWith('0X')) ? '/v1/resolve/' + val : null; + if (endpoint) { + try { + const resp = await fetch(SERVER + endpoint); + const data = await resp.json(); + if (data.error) { addSys('Cannot resolve: ' + data.error); return; } + // Store ETH address for display, use fingerprint internally + peerEthAddr = val; + e.target.value = data.fingerprint; + localStorage.setItem('wz-peer', val); + localStorage.setItem('wz-peer-eth', val); + addSys(val + ' \u2192 ' + data.fingerprint.slice(0,16) + '...'); + addSys('Peer set to ' + val.slice(0,16) + '...'); + updatePeerDisplay(); + } catch(err) { addSys('Resolve failed: ' + err.message); } + } else { + // Raw fingerprint + peerEthAddr = null; + localStorage.setItem('wz-peer', val); + localStorage.removeItem('wz-peer-eth'); + addSys('Peer set to ' + val.slice(0,16) + '...'); + updatePeerDisplay(); + } + } else if (val.startsWith('#')) { + // Group shortcut + const gname = val.replace('#',''); + e.target.value = '#' + gname; + localStorage.setItem('wz-peer', '#' + gname); + addSys('Switched to group #' + gname); + } +}); + // Wire up buttons (module scope can't use onclick in HTML) document.getElementById('btn-generate').onclick = () => doGenerate(); document.getElementById('btn-show-recover').onclick = () => document.getElementById('recover-area').style.display = 'block'; diff --git a/warzone/crates/warzone-server/src/routes/ws.rs b/warzone/crates/warzone-server/src/routes/ws.rs index f440fde..d18175e 100644 --- a/warzone/crates/warzone-server/src/routes/ws.rs +++ b/warzone/crates/warzone-server/src/routes/ws.rs @@ -135,7 +135,14 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String) // For simplicity: first 32 hex chars = recipient fp, rest = message if data.len() > 64 { let header = String::from_utf8_lossy(&data[..64]).to_string(); - let to_fp = normalize_fp(&header); + let raw_fp = normalize_fp(&header); + // The WS header is 64 hex chars (32 bytes padded with '0'). + // Fingerprints are 32 hex chars. Truncate to 32 if zero-padded. + let to_fp = if raw_fp.len() > 32 && raw_fp[32..].chars().all(|c| c == '0') { + raw_fp[..32].to_string() + } else { + raw_fp + }; let message = &data[64..]; // Dedup: skip if we already processed this message ID diff --git a/warzone/docs/LLM_BOT_DEV.md b/warzone/docs/LLM_BOT_DEV.md index 5962a25..16fbe57 100644 --- a/warzone/docs/LLM_BOT_DEV.md +++ b/warzone/docs/LLM_BOT_DEV.md @@ -253,6 +253,10 @@ The bridge translates numeric chat_id ↔ fingerprints automatically. | parse_mode HTML | rendered | rendered in web client | | Media groups | yes | not yet | +## Voice Calls + +Bots cannot initiate or participate in voice calls. Voice is peer-to-peer only between human clients (web or TUI). Call signaling messages (`CallSignal` type) are delivered to bots via getUpdates as `text="/call_Offer"` etc., but bots should ignore them -- there is no audio path for bots. + ## Key Rules 1. **Always use offset** in getUpdates — without it you reprocess messages diff --git a/warzone/docs/LLM_HELP.md b/warzone/docs/LLM_HELP.md index e049f97..263ef1b 100644 --- a/warzone/docs/LLM_HELP.md +++ b/warzone/docs/LLM_HELP.md @@ -195,6 +195,40 @@ while True: time.sleep(1) ``` +## Voice Calls + +### Architecture +Call signaling flows through the featherChat WebSocket (offer/answer/hangup/reject/ringing/busy). +Audio flows through a separate WZP relay infrastructure: + +``` +Browser A <--WS--> wzp-web <--QUIC--> wzp-relay <--QUIC--> wzp-web <--WS--> Browser B + | | + featherChat server (/v1/auth/validate) +``` + +### Key files +- Call signaling: `warzone-server/src/routes/ws.rs` (WireMessage::CallSignal handling) +- Call state: `warzone-server/src/state.rs` (CallState, active_calls) +- Relay config: `warzone-server/src/routes/wzp.rs` (token issuance) +- Web audio: `warzone-server/src/routes/web.rs` (startAudio/stopAudio functions) +- TUI calls: `warzone-client/src/tui/commands.rs` (/call, /accept, /reject, /hangup) +- Protocol: `warzone-protocol/src/message.rs` (CallSignal, CallSignalType) + +### Environment +- `WZP_RELAY_ADDR` -- tells featherChat server where wzp-web bridge is (e.g., `127.0.0.1:8080`) +- Without this, `/v1/wzp/relay-config` returns default `127.0.0.1:4433` + +### Commands + +cmd | action | example +--- | --- | --- +/call | start voice call with current peer | /call +/call | start voice call with specific peer | /call @alice +/accept | accept incoming call | /accept +/reject | reject incoming call | /reject +/hangup | end current call | /hangup + ## Server API (other endpoints) - POST /v1/register -- upload prekey bundle diff --git a/warzone/docs/SERVER.md b/warzone/docs/SERVER.md index 5371dcc..b83fd69 100644 --- a/warzone/docs/SERVER.md +++ b/warzone/docs/SERVER.md @@ -431,6 +431,56 @@ Telegram bot libraries can be adapted with minimal changes. --- +## Voice Calls (WZP Integration) + +featherChat supports voice calls via the WarzonePhone (WZP) audio relay. Three components work together: + +### Components + +| Component | Binary | Port | Purpose | +|-----------|--------|------|---------| +| featherChat server | `warzone-server` | 7700 | Signaling (offer/answer/hangup) + auth tokens | +| WZP relay | `wzp-relay` | 4433 | QUIC audio relay (SFU) | +| WZP web bridge | `wzp-web` | 8080 | Browser WebSocket ↔ QUIC bridge | + +### Running + +```bash +# 1. WZP relay (QUIC audio) +./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate + +# 2. WZP web bridge (browser ↔ relay) +./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate + +# 3. featherChat server (with relay address) +WZP_RELAY_ADDR=127.0.0.1:8080 ./warzone-server +``` + +### TLS Requirements + +| Scenario | TLS needed? | Why | +|----------|-------------|-----| +| localhost dev | No | Browser allows mic on localhost without HTTPS | +| LAN/remote | wzp-web needs TLS | Browsers require HTTPS for `getUserMedia()` on non-localhost | +| Production | All three should use TLS | Security best practice | + +For production TLS on wzp-web: +```bash +./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate --cert /path/to/cert.pem --key /path/to/key.pem +``` + +### Auth Flow + +1. User clicks Call -> signaling via featherChat WebSocket +2. Call accepted -> both clients fetch `GET /v1/wzp/relay-config` +3. Server returns `{ relay_addr, token, expires_in: 300 }` +4. Clients connect WebSocket to `ws://relay_addr/ws/ROOM` +5. First message: `{"type":"auth","token":""}` +6. wzp-web validates token against featherChat `/v1/auth/validate` +7. Audio flows: mic -> PCM -> WS -> wzp-web -> QUIC -> wzp-relay -> peer + +--- + ## 6. Database The server uses **sled** (embedded key-value store). All data lives under diff --git a/warzone/docs/TESTING_E2E.md b/warzone/docs/TESTING_E2E.md index e75c2d9..2d69187 100644 --- a/warzone/docs/TESTING_E2E.md +++ b/warzone/docs/TESTING_E2E.md @@ -31,14 +31,15 @@ wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg ### Voice Call Testing (requires WZP relay) ```bash -# Start WZP web bridge (from warzone-phone repo) -./wzp-web --bind 0.0.0.0:8080 --relay 127.0.0.1:4433 +# Terminal A: WZP relay (QUIC audio SFU) +./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate -# Start WZP relay -./wzp-relay --bind 0.0.0.0:4433 +# Terminal B: WZP web bridge (browser WebSocket <-> QUIC) +./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate -# Set relay address for featherChat +# Terminal C: featherChat server with relay address export WZP_RELAY_ADDR=127.0.0.1:8080 +./warzone-server ``` --- @@ -218,11 +219,11 @@ WARZONE_HOME=~/.warzone-b ./target/release/warzone-client tui --server http://lo ### Prerequisites ```bash -# Terminal 1: WZP relay -./wzp-relay --bind 0.0.0.0:4433 +# Terminal 1: WZP relay (QUIC audio SFU) +./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate -# Terminal 2: WZP web bridge -./wzp-web --bind 0.0.0.0:8080 --relay 127.0.0.1:4433 +# Terminal 2: WZP web bridge (browser WebSocket <-> QUIC) +./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate # Terminal 3: featherChat server WZP_RELAY_ADDR=127.0.0.1:8080 ./warzone-server diff --git a/warzone/docs/USAGE.md b/warzone/docs/USAGE.md index 1555412..5c65f55 100644 --- a/warzone/docs/USAGE.md +++ b/warzone/docs/USAGE.md @@ -287,6 +287,32 @@ The web client supports the same slash commands as the TUI: `/peer`, `/p`, `/r`, --- +## Voice Calls + +### Web Client +1. Set a peer (paste ETH address or use `/peer @alias`) +2. Click the Call button or type `/call` +3. Peer sees "Incoming call" and clicks Accept +4. Both allow microphone access +5. Audio flows -- speak normally +6. Click "End Call" or type `/hangup` to end + +### TUI Client +1. `/call ` -- initiate call +2. Peer sees notification and can use `/accept` or `/reject` +3. Audio currently requires web client (TUI shows hint) +4. `/hangup` -- end call + +### Commands +| Command | Description | +|---------|-------------| +| `/call` | Start voice call with current peer | +| `/accept` | Accept incoming call | +| `/reject` | Reject incoming call | +| `/hangup` | End current call | + +--- + ## Groups ### Creating and Using Groups