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:
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.45"
|
version = "0.0.46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -2989,7 +2989,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-mule"
|
name = "warzone-mule"
|
||||||
version = "0.0.45"
|
version = "0.0.46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2998,7 +2998,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.45"
|
version = "0.0.46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
@@ -3023,7 +3023,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-server"
|
name = "warzone-server"
|
||||||
version = "0.0.45"
|
version = "0.0.46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3054,7 +3054,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-wasm"
|
name = "warzone-wasm"
|
||||||
version = "0.0.45"
|
version = "0.0.46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.45"
|
version = "0.0.46"
|
||||||
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.45"
|
version = "0.0.46"
|
||||||
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)"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/groups/:name/leave", post(leave_group))
|
.route("/groups/:name/leave", post(leave_group))
|
||||||
.route("/groups/:name/kick", post(kick_member))
|
.route("/groups/:name/kick", post(kick_member))
|
||||||
.route("/groups/:name/members", get(get_members))
|
.route("/groups/:name/members", get(get_members))
|
||||||
|
.route("/groups/:name/signal", post(signal_group))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
@@ -305,3 +306,47 @@ async fn get_members(
|
|||||||
"online_count": online_count,
|
"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 })))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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-v27';
|
const CACHE = 'wz-v28';
|
||||||
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 => {
|
||||||
@@ -288,7 +288,7 @@ let pollTimer = null;
|
|||||||
let ws = null; // WebSocket connection
|
let ws = null; // WebSocket connection
|
||||||
let wasmReady = false;
|
let wasmReady = false;
|
||||||
|
|
||||||
const VERSION = '0.0.45';
|
const VERSION = '0.0.46';
|
||||||
let DEBUG = true; // toggle with /debug command
|
let DEBUG = true; // toggle with /debug command
|
||||||
|
|
||||||
// ── Receipt tracking ──
|
// ── Receipt tracking ──
|
||||||
@@ -705,6 +705,16 @@ function connectWebSocket() {
|
|||||||
async function handleIncomingMessage(bytes) {
|
async function handleIncomingMessage(bytes) {
|
||||||
dbg('Processing message,', bytes.length, 'bytes, sessions:', Object.keys(sessions));
|
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)
|
// Quick check: try to parse as Receipt first (no session needed, no decrypt)
|
||||||
try {
|
try {
|
||||||
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null);
|
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null);
|
||||||
@@ -1120,13 +1130,21 @@ async function enterChat() {
|
|||||||
if (!savedPeer) {
|
if (!savedPeer) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
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)}) });
|
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)}) });
|
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';
|
currentGroup = 'ops';
|
||||||
$peerInput.value = '#ops';
|
$peerInput.value = '#ops';
|
||||||
|
localStorage.setItem('wz-peer', '#ops');
|
||||||
addSys('Welcome! You have been added to #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); }
|
} catch(e) { dbg('Auto-join #ops failed:', e); }
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
@@ -1697,12 +1715,12 @@ async function startGroupCall() {
|
|||||||
groupCallParticipants = [normFP(myFingerprint)];
|
groupCallParticipants = [normFP(myFingerprint)];
|
||||||
updateGroupCallUI();
|
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) });
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] })
|
body: msg
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
addSys('Group call started in #' + gname + ' \u2014 waiting for others to join');
|
addSys('Group call started in #' + gname + ' \u2014 waiting for others to join');
|
||||||
@@ -1717,12 +1735,12 @@ async function joinGroupCall(gname, room) {
|
|||||||
}
|
}
|
||||||
updateGroupCallUI();
|
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) });
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] })
|
body: msg
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
addSys('Joined group call in #' + gname);
|
addSys('Joined group call in #' + gname);
|
||||||
@@ -1807,12 +1825,12 @@ function leaveGroupCall() {
|
|||||||
if (!groupCallGroup) return;
|
if (!groupCallGroup) return;
|
||||||
const gname = groupCallGroup;
|
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) });
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] })
|
body: msg
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
stopAudio();
|
stopAudio();
|
||||||
@@ -2003,6 +2021,32 @@ async function doSend() {
|
|||||||
else addSys('Alias @' + parts[0] + ' removed by admin');
|
else addSys('Alias @' + parts[0] + ' removed by admin');
|
||||||
return;
|
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 ')) {
|
if (text.startsWith('/r ') || text.startsWith('/reply ')) {
|
||||||
const replyText = text.startsWith('/r ') ? text.slice(3) : text.slice(7);
|
const replyText = text.startsWith('/r ') ? text.slice(3) : text.slice(7);
|
||||||
if (!lastDmPeer) { addSys('No one to reply to'); return; }
|
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 r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/members');
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.error) { addSys('Error: '+d.error); return; }
|
if (d.error) { addSys('Error: '+d.error); return; }
|
||||||
addSys('Members of #'+currentGroup+':');
|
const onlineCount = d.online_count || d.members.filter(m => m.online).length;
|
||||||
for (const m of d.members) {
|
addSys('Members of #'+currentGroup+' ('+onlineCount+'/'+d.members.length+' online):');
|
||||||
const a = m.alias ? '@'+m.alias : m.fingerprint.slice(0,12)+'...';
|
// Resolve ETH addresses for members without aliases
|
||||||
addSys(' '+a+(m.is_creator?' ★':''));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (text === '/friend' || text === '/friends') {
|
if (text === '/friend' || text === '/friends') {
|
||||||
|
|||||||
Reference in New Issue
Block a user