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:
10
warzone/Cargo.lock
generated
10
warzone/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user