v0.0.44: web UI polish — ETH display, peer input, call fixes, docs

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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-30 08:32:31 +04:00
parent 7c4e6a1c1e
commit 81954b1b0c
10 changed files with 240 additions and 37 deletions

10
warzone/Cargo.lock generated
View File

@@ -2956,7 +2956,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-client" name = "warzone-client"
version = "0.0.43" version = "0.0.44"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -2989,7 +2989,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-mule" name = "warzone-mule"
version = "0.0.43" version = "0.0.44"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -2998,7 +2998,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-protocol" name = "warzone-protocol"
version = "0.0.43" version = "0.0.44"
dependencies = [ dependencies = [
"base64", "base64",
"bincode", "bincode",
@@ -3023,7 +3023,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-server" name = "warzone-server"
version = "0.0.43" version = "0.0.44"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -3054,7 +3054,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-wasm" name = "warzone-wasm"
version = "0.0.43" version = "0.0.44"
dependencies = [ dependencies = [
"base64", "base64",
"bincode", "bincode",

View File

@@ -9,7 +9,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.0.43" version = "0.0.44"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
rust-version = "1.75" rust-version = "1.75"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "warzone-protocol" name = "warzone-protocol"
version = "0.0.43" version = "0.0.44"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
description = "Core crypto & wire protocol for featherChat (Warzone messenger)" description = "Core crypto & wire protocol for featherChat (Warzone messenger)"

View File

@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
async fn service_worker() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/javascript")], r##" ([(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']; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
self.addEventListener('install', e => { self.addEventListener('install', e => {
@@ -150,7 +150,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
#chat-header .tag { padding: 1px 6px; border-radius: 3px; font-size: 0.85em; } #chat-header .tag { padding: 1px 6px; border-radius: 3px; font-size: 0.85em; }
.tag-fp { background: #0a2e0a; color: #4ade80; } .tag-fp { background: #0a2e0a; color: #4ade80; }
.tag-peer { background: #2e2e0a; color: #e6a23c; } .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; #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; } border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; }
@@ -248,7 +248,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
<span class="tag tag-fp" id="hdr-fp" style="cursor:pointer" title="Click to copy"></span> <span class="tag tag-fp" id="hdr-fp" style="cursor:pointer" title="Click to copy"></span>
<span id="hdr-eth" style="display:none"></span> <span id="hdr-eth" style="display:none"></span>
<span>→</span> <span>→</span>
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off"> <input id="peer-input" placeholder="ETH address, fingerprint, or @alias" autocomplete="off">
<span class="tag-server" id="hdr-server"></span> <span class="tag-server" id="hdr-server"></span>
</div> </div>
<div id="call-bar"> <div id="call-bar">
@@ -281,13 +281,14 @@ let wasmIdentity = null; // WasmIdentity from WASM
let myFingerprint = ''; let myFingerprint = '';
let myEthAddress = ''; let myEthAddress = '';
let mySeedHex = ''; let mySeedHex = '';
let peerEthAddr = null; // Peer's ETH address (for display; null if set by fingerprint)
let sessions = {}; // peerFP -> { session: WasmSession, data: base64 } let sessions = {}; // peerFP -> { session: WasmSession, data: base64 }
let peerBundles = {}; // peerFP -> bundle bytes let peerBundles = {}; // peerFP -> bundle bytes
let pollTimer = null; let pollTimer = null;
let ws = null; // WebSocket connection let ws = null; // WebSocket connection
let wasmReady = false; let wasmReady = false;
const VERSION = '0.0.43'; const VERSION = '0.0.44';
let DEBUG = true; // toggle with /debug command let DEBUG = true; // toggle with /debug command
// ── Receipt tracking ── // ── Receipt tracking ──
@@ -348,6 +349,23 @@ function normFP(fp) {
return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); 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) { function makeAddressClickable(text) {
// Match fingerprint format: xxxx:xxxx:xxxx:xxxx... (at least 4 groups) // 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) { text = text.replace(/([0-9a-f]{4}(?::[0-9a-f]{4}){3,})/gi, function(match) {
@@ -1084,6 +1102,7 @@ async function enterChat() {
if (savedPeer) { if (savedPeer) {
$peerInput.value = savedPeer; $peerInput.value = savedPeer;
} }
peerEthAddr = localStorage.getItem('wz-peer-eth') || null;
connectWebSocket(); connectWebSocket();
@@ -1244,18 +1263,18 @@ function updateCallUI() {
} }
break; break;
case 'calling': 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'; status.className = 'call-status';
btnHangup.style.display = ''; btnHangup.style.display = '';
break; break;
case 'ringing': 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'; status.className = 'call-status incoming-call';
btnAccept.style.display = ''; btnAccept.style.display = '';
btnReject.style.display = ''; btnReject.style.display = '';
break; break;
case 'active': 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'; status.className = 'call-status';
btnHangup.style.display = ''; btnHangup.style.display = '';
break; break;
@@ -1263,18 +1282,29 @@ function updateCallUI() {
} }
async function startCall() { async function startCall() {
const peer = $peerInput.value.trim(); let peer = $peerInput.value.trim();
if (!peer || peer.startsWith('#')) { addSys('Set a peer first'); return; } 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'; callState = 'calling';
callPeer = peer; callPeer = peer;
updateCallUI(); updateCallUI();
// Send CallSignal::Offer via WS // Send CallSignal::Offer via WS
try { try {
const signalBytes = create_call_signal(wasmIdentity, 'offer', '', normFP(peer));
if (ws && ws.readyState === WebSocket.OPEN) {
const fp = normFP(peer); const fp = normFP(peer);
const signalBytes = create_call_signal(wasmIdentity, 'offer', '', fp);
if (ws && ws.readyState === WebSocket.OPEN) {
const header = new TextEncoder().encode(fp.padEnd(64, '0').slice(0, 64)); const header = new TextEncoder().encode(fp.padEnd(64, '0').slice(0, 64));
const payload = new Uint8Array(header.length + signalBytes.length); const payload = new Uint8Array(header.length + signalBytes.length);
payload.set(header); payload.set(header);
@@ -1409,13 +1439,14 @@ let captureNode = null;
let playbackNode = null; let playbackNode = null;
async function startAudio() { async function startAudio() {
// Fetch relay config // Fetch relay config (includes auth token)
let relayAddr; let relayAddr, authToken;
try { try {
const resp = await fetch(SERVER + '/v1/wzp/relay-config'); const resp = await fetch(SERVER + '/v1/wzp/relay-config');
const data = await resp.json(); const data = await resp.json();
relayAddr = data.relay_addr; relayAddr = data.relay_addr;
dbg('Relay address:', relayAddr); authToken = data.token;
dbg('Relay address:', relayAddr, 'token:', authToken);
} catch(e) { } catch(e) {
addSys('Audio: cannot get relay config \u2014 ' + e.message); addSys('Audio: cannot get relay config \u2014 ' + e.message);
return; return;
@@ -1433,18 +1464,23 @@ async function startAudio() {
audioCtx = new AudioContext({ sampleRate: 48000 }); audioCtx = new AudioContext({ sampleRate: 48000 });
// Generate room name from call peer (deterministic) // Deterministic room: sort both fingerprints so both peers get the same room
const room = callPeer ? normFP(callPeer).slice(0, 16) : 'default'; const myFP = normFP(myFingerprint);
const proto = relayAddr.startsWith('https') ? 'wss:' : 'ws:'; const peerFP = callPeer ? normFP(callPeer) : '';
const host = relayAddr.replace(/^https?:\\/\\//, ''); 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; 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 = new WebSocket(wsUrl);
audioWs.binaryType = 'arraybuffer'; audioWs.binaryType = 'arraybuffer';
audioWs.onopen = async () => { 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'); addSys('Audio: connected \u2014 mic active');
// Capture: mic -> PCM frames -> WS // Capture: mic -> PCM frames -> WS
@@ -1658,14 +1694,20 @@ async function doSend() {
const resp = await fetch(SERVER + endpoint); const resp = await fetch(SERVER + endpoint);
const data = await resp.json(); const data = await resp.json();
if (data.error) { addSys('Cannot resolve ' + val + ': ' + data.error); return; } 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; $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(val + ' \u2192 ' + data.fingerprint.slice(0,16) + '...');
addSys('Peer set to ' + (peerEthAddr || data.fingerprint.slice(0,16) + '...'));
} else { } else {
$peerInput.value = val; $peerInput.value = val;
peerEthAddr = null;
localStorage.setItem('wz-peer', val);
localStorage.removeItem('wz-peer-eth');
} }
currentGroup = null; currentGroup = null;
localStorage.setItem('wz-peer', $peerInput.value); addSys('Peer set to ' + peerDisplayName());
addSys('Peer set to ' + $peerInput.value.slice(0,16) + '...');
updateCallUI(); updateCallUI();
return; return;
} }
@@ -1793,6 +1835,45 @@ $input.addEventListener('input', function() {
this.style.height = Math.min(this.scrollHeight, 120) + 'px'; 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) // Wire up buttons (module scope can't use onclick in HTML)
document.getElementById('btn-generate').onclick = () => doGenerate(); document.getElementById('btn-generate').onclick = () => doGenerate();
document.getElementById('btn-show-recover').onclick = () => document.getElementById('recover-area').style.display = 'block'; document.getElementById('btn-show-recover').onclick = () => document.getElementById('recover-area').style.display = 'block';

View File

@@ -135,7 +135,14 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
// For simplicity: first 32 hex chars = recipient fp, rest = message // For simplicity: first 32 hex chars = recipient fp, rest = message
if data.len() > 64 { if data.len() > 64 {
let header = String::from_utf8_lossy(&data[..64]).to_string(); 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..]; let message = &data[64..];
// Dedup: skip if we already processed this message ID // Dedup: skip if we already processed this message ID

View File

@@ -253,6 +253,10 @@ The bridge translates numeric chat_id ↔ fingerprints automatically.
| parse_mode HTML | rendered | rendered in web client | | parse_mode HTML | rendered | rendered in web client |
| Media groups | yes | not yet | | 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 ## Key Rules
1. **Always use offset** in getUpdates — without it you reprocess messages 1. **Always use offset** in getUpdates — without it you reprocess messages

View File

@@ -195,6 +195,40 @@ while True:
time.sleep(1) 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 <addr> | 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) ## Server API (other endpoints)
- POST /v1/register -- upload prekey bundle - POST /v1/register -- upload prekey bundle

View File

@@ -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":"<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 ## 6. Database
The server uses **sled** (embedded key-value store). All data lives under The server uses **sled** (embedded key-value store). All data lives under

View File

@@ -31,14 +31,15 @@ wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg
### Voice Call Testing (requires WZP relay) ### Voice Call Testing (requires WZP relay)
```bash ```bash
# Start WZP web bridge (from warzone-phone repo) # Terminal A: WZP relay (QUIC audio SFU)
./wzp-web --bind 0.0.0.0:8080 --relay 127.0.0.1:4433 ./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# Start WZP relay # Terminal B: WZP web bridge (browser WebSocket <-> QUIC)
./wzp-relay --bind 0.0.0.0:4433 ./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 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 ### Prerequisites
```bash ```bash
# Terminal 1: WZP relay # Terminal 1: WZP relay (QUIC audio SFU)
./wzp-relay --bind 0.0.0.0:4433 ./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# Terminal 2: WZP web bridge # Terminal 2: WZP web bridge (browser WebSocket <-> QUIC)
./wzp-web --bind 0.0.0.0:8080 --relay 127.0.0.1:4433 ./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 # Terminal 3: featherChat server
WZP_RELAY_ADDR=127.0.0.1:8080 ./warzone-server WZP_RELAY_ADDR=127.0.0.1:8080 ./warzone-server

View File

@@ -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 <peer_address>` -- 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 ## Groups
### Creating and Using Groups ### Creating and Using Groups