v0.0.46: fix group calls, admin commands, ETH in members

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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-30 09:31:00 +04:00
parent 2612d46f5c
commit 93923676a8
5 changed files with 122 additions and 25 deletions

10
warzone/Cargo.lock generated
View File

@@ -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",

View File

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

View File

@@ -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)"

View File

@@ -18,6 +18,7 @@ pub fn routes() -> Router<AppState> {
.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<AppState>,
Path(name): Path<String>,
Json(req): Json<serde_json::Value>,
) -> AppResult<Json<serde_json::Value>> {
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 })))
}

View File

@@ -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 <a> <pw> \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') {