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