From 93923676a8be29f99c0b4c25c4baa940db0665d7 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Mon, 30 Mar 2026 09:31:00 +0400 Subject: [PATCH] v0.0.46: fix group calls, admin commands, ETH in members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - POST /v1/groups/:name/signal — broadcast plaintext JSON to all online group members via WS push (for call signals, typing, etc.) - No auth required (membership validated from 'from' field) Web client: - Group call signals now use /signal endpoint (was broken with /send) - WS handler detects group_call JSON signals early - Auto-join #ops now properly registers presence + fetches members - /gmembers resolves ETH addresses via /v1/resolve - /admin-calls — list all active calls in the system - /admin-help — list all admin/debug commands Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +-- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../warzone-server/src/routes/groups.rs | 45 ++++++++++ .../crates/warzone-server/src/routes/web.rs | 88 +++++++++++++++---- 5 files changed, 122 insertions(+), 25 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 11a15f2..94925d8 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.45" +version = "0.0.46" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.45" +version = "0.0.46" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.45" +version = "0.0.46" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.45" +version = "0.0.46" dependencies = [ "anyhow", "axum", @@ -3054,7 +3054,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.45" +version = "0.0.46" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 35be02e..2c5dc88 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.45" +version = "0.0.46" 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 02ed18c..db43fc2 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.45" +version = "0.0.46" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/groups.rs b/warzone/crates/warzone-server/src/routes/groups.rs index 036728a..d437039 100644 --- a/warzone/crates/warzone-server/src/routes/groups.rs +++ b/warzone/crates/warzone-server/src/routes/groups.rs @@ -18,6 +18,7 @@ pub fn routes() -> Router { .route("/groups/:name/leave", post(leave_group)) .route("/groups/:name/kick", post(kick_member)) .route("/groups/:name/members", get(get_members)) + .route("/groups/:name/signal", post(signal_group)) } #[derive(Serialize, Deserialize, Clone)] @@ -305,3 +306,47 @@ async fn get_members( "online_count": online_count, }))) } + +/// Broadcast a plaintext signal to all online group members via WS push. +/// Used for group calls, typing indicators, etc. — NOT for encrypted messages. +async fn signal_group( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> AppResult> { + let group = match load_group(&state.db.groups, &name) { + Some(g) => g, + None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), + }; + + let from = req + .get("from") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let from = normalize_fp(&from); + if !group.members.contains(&from) { + return Ok(Json(serde_json::json!({ "error": "not a member" }))); + } + + // Broadcast the raw JSON payload to all online members except sender + let payload = serde_json::to_vec(&req).unwrap_or_default(); + let mut pushed = 0; + for member in &group.members { + if *member == from { + continue; + } + if state.push_to_client(member, &payload).await { + pushed += 1; + } + } + + tracing::info!( + "Group '{}' signal from {}: pushed to {}/{} members", + name, + from, + pushed, + group.members.len() - 1 + ); + Ok(Json(serde_json::json!({ "ok": true, "pushed": pushed }))) +} diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index f755713..fbc7ffc 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-v27'; +const CACHE = 'wz-v28'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -288,7 +288,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.45'; +const VERSION = '0.0.46'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -705,6 +705,16 @@ function connectWebSocket() { async function handleIncomingMessage(bytes) { dbg('Processing message,', bytes.length, 'bytes, sessions:', Object.keys(sessions)); + // Check for plaintext JSON signals (group call, typing, etc.) from /signal endpoint + try { + const textData = new TextDecoder().decode(bytes); + const signalData = JSON.parse(textData); + if (signalData.type === 'group_call') { + handleGroupCallSignal(signalData); + return; + } + } catch(e) {} // Not JSON or not a signal, continue with normal handling + // Quick check: try to parse as Receipt first (no session needed, no decrypt) try { const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null); @@ -1120,13 +1130,21 @@ async function enterChat() { if (!savedPeer) { setTimeout(async () => { try { - // Create #ops if it doesn't exist (ignore error if already exists) await fetch(SERVER + '/v1/groups/create', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({name:'ops', creator: normFP(myFingerprint)}) }); - // Join (no auth needed for join in current setup) await fetch(SERVER + '/v1/groups/ops/join', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({fingerprint: normFP(myFingerprint)}) }); + // Switch to #ops with full presence registration currentGroup = 'ops'; $peerInput.value = '#ops'; + localStorage.setItem('wz-peer', '#ops'); addSys('Welcome! You have been added to #ops'); + // Fetch member list to confirm + const resp = await fetch(SERVER + '/v1/groups/ops'); + if (resp.ok) { + const data = await resp.json(); + if (data.members) { + addSys('Switched to group "ops" (' + data.members.length + ' members)'); + } + } } catch(e) { dbg('Auto-join #ops failed:', e); } }, 500); } @@ -1697,12 +1715,12 @@ async function startGroupCall() { groupCallParticipants = [normFP(myFingerprint)]; updateGroupCallUI(); - // Notify group members + // Notify group members via signal endpoint (plaintext broadcast) const msg = JSON.stringify({ type: 'group_call', action: 'started', group: gname, room: groupCallRoom, from: normFP(myFingerprint) }); - await fetch(SERVER + '/v1/groups/' + gname + '/send', { + await fetch(SERVER + '/v1/groups/' + gname + '/signal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] }) + body: msg }).catch(() => {}); addSys('Group call started in #' + gname + ' \u2014 waiting for others to join'); @@ -1717,12 +1735,12 @@ async function joinGroupCall(gname, room) { } updateGroupCallUI(); - // Notify others we joined + // Notify others we joined via signal endpoint const msg = JSON.stringify({ type: 'group_call', action: 'joined', group: gname, room: room, from: normFP(myFingerprint) }); - await fetch(SERVER + '/v1/groups/' + gname + '/send', { + await fetch(SERVER + '/v1/groups/' + gname + '/signal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] }) + body: msg }).catch(() => {}); addSys('Joined group call in #' + gname); @@ -1807,12 +1825,12 @@ function leaveGroupCall() { if (!groupCallGroup) return; const gname = groupCallGroup; - // Notify others + // Notify others via signal endpoint const msg = JSON.stringify({ type: 'group_call', action: 'left', group: gname, room: groupCallRoom, from: normFP(myFingerprint) }); - fetch(SERVER + '/v1/groups/' + gname + '/send', { + fetch(SERVER + '/v1/groups/' + gname + '/signal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] }) + body: msg }).catch(() => {}); stopAudio(); @@ -2003,6 +2021,32 @@ async function doSend() { else addSys('Alias @' + parts[0] + ' removed by admin'); return; } + if (text === '/admin-calls') { + try { + const resp = await fetch(SERVER + '/v1/calls/active'); + const data = await resp.json(); + if (data.calls && data.calls.length > 0) { + addSys('Active calls (' + data.calls.length + '):'); + data.calls.forEach(c => { + addSys(' ' + c.call_id.slice(0,8) + ' ' + c.caller_fp.slice(0,12) + ' \u2192 ' + c.callee_fp.slice(0,12) + ' [' + c.status + '] ' + (c.group_name ? '#' + c.group_name : 'DM')); + }); + } else { + addSys('No active calls'); + } + } catch(e) { addSys('Error: ' + e.message); } + return; + } + if (text === '/admin-help' || text === '/admin') { + addSys('Admin commands:'); + addSys(' /admin-calls \u2014 list all active calls'); + addSys(' /admin-unalias \u2014 force-remove an alias'); + addSys(' /bundleinfo \u2014 debug key bundle info'); + addSys(' /sessions \u2014 list cached sessions'); + addSys(' /selftest \u2014 run WASM self-test'); + addSys(' /debug \u2014 toggle debug mode'); + addSys(' /reset \u2014 clear all local data'); + return; + } if (text.startsWith('/r ') || text.startsWith('/reply ')) { const replyText = text.startsWith('/r ') ? text.slice(3) : text.slice(7); if (!lastDmPeer) { addSys('No one to reply to'); return; } @@ -2072,11 +2116,19 @@ async function doSend() { const r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/members'); const d = await r.json(); if (d.error) { addSys('Error: '+d.error); return; } - addSys('Members of #'+currentGroup+':'); - for (const m of d.members) { - const a = m.alias ? '@'+m.alias : m.fingerprint.slice(0,12)+'...'; - addSys(' '+a+(m.is_creator?' ★':'')); - } + const onlineCount = d.online_count || d.members.filter(m => m.online).length; + addSys('Members of #'+currentGroup+' ('+onlineCount+'/'+d.members.length+' online):'); + // Resolve ETH addresses for members without aliases + const ethPromises = d.members.map(m => + m.alias ? Promise.resolve('@'+m.alias) : + fetch(SERVER+'/v1/resolve/'+m.fingerprint).then(r2=>r2.json()).then(rd=>rd.eth_address ? rd.eth_address.slice(0,12)+'...' : m.fingerprint.slice(0,12)+'...').catch(()=>m.fingerprint.slice(0,12)+'...') + ); + const labels = await Promise.all(ethPromises); + d.members.forEach((m, i) => { + const status = m.online ? '\u{1F7E2}' : '\u26AB'; + const isSelf = m.fingerprint === normFP(myFingerprint); + addSys(' '+status+' '+labels[i]+(m.is_creator?' ★':'')+(isSelf?' *':'')); + }); return; } if (text === '/friend' || text === '/friends') {