use warzone_protocol::identity::IdentityKeyPair; use warzone_protocol::message::WireMessage; use warzone_protocol::ratchet::RatchetState; use warzone_protocol::types::Fingerprint; use warzone_protocol::x3dh; use x25519_dalek::PublicKey; use crate::net::ServerClient; use crate::storage::LocalDb; use base64::Engine; use chrono::Local; use super::types::{App, ChatLine, ReceiptStatus, normfp}; impl App { pub async fn handle_send( &mut self, identity: &IdentityKeyPair, db: &LocalDb, client: &ServerClient, ) { let mut text = self.input.trim().to_string(); self.input.clear(); self.cursor_pos = 0; if text.is_empty() { return; } // Commands if text == "/quit" || text == "/q" { self.should_quit = true; return; } if text == "/info" { if !self.our_eth.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: format!("Identity: {}", self.our_eth), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } self.add_message(ChatLine { sender: "system".into(), text: format!("Fingerprint: {}", self.our_fp), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } if text == "/help" || text == "/?" { let help_lines = [ "Commands:", " /help, /? Show this help", " /info Show your fingerprint", " /eth Show Ethereum address", " /seed Show recovery mnemonic (24 words)", " /backup Create encrypted backup now", " /peer , /p Set DM peer by fingerprint", " /peer @alias Set DM peer by alias", " /reply, /r Reply to last DM sender", " /dm Switch to DM mode (clear peer)", " /contacts, /c List contacts with message counts", " /history, /h [fp] Show conversation history", " /alias Register an alias for yourself", " /aliases List all registered aliases", " /unalias Remove your alias", " /friend List friends with online status", " /friend
Add a friend", " /unfriend
Remove a friend", " /devices List your active device sessions", " /kick Kick a specific device session", " /g Switch to group (auto-join)", " /gcreate Create a new group", " /gjoin Join a group", " /glist List all groups", " /gleave Leave current group", " /gkick Kick member from group", " /gmembers List group members", " /file Send a file (max 10MB)", " /call [fp|@alias] Call current peer (or specified peer)", " /accept Accept incoming call", " /reject Reject incoming call", " /hangup End current call", " /quit, /q Exit", "", "Navigation:", " PageUp/PageDown Scroll messages", " Up/Down Scroll by 1 (when input empty)", " Ctrl+C, Esc Quit", ]; for line in &help_lines { self.add_message(ChatLine { sender: "system".into(), text: line.to_string(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); } return; } if text.starts_with("/alias ") { let name = text[7..].trim(); self.register_alias(name, client).await; return; } if text == "/aliases" { self.list_aliases(client).await; return; } if text == "/unalias" { let url = format!("{}/v1/alias/unregister", client.base_url); match client.client.post(&url) .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)})) .send().await { Ok(resp) => if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } }, Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } return; } if text == "/contacts" || text == "/c" { match db.list_contacts() { Ok(contacts) => { if contacts.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "No contacts yet".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); for c in &contacts { let fp = c.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?"); let alias = c.get("alias").and_then(|v| v.as_str()); let count = c.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0); // Check online status via presence endpoint let online = match client.client.get(format!("{}/v1/presence/{}", client.base_url, normfp(fp))).send().await { Ok(r) => r.json::().await.ok() .and_then(|d| d.get("online").and_then(|v| v.as_bool())) .unwrap_or(false), Err(_) => false, }; let status = if online { "●" } else { "○" }; let label = match alias { Some(a) => format!(" {} @{} ({}) — {} msgs", status, a, &fp[..fp.len().min(12)], count), None => format!(" {} {} — {} msgs", status, &fp[..fp.len().min(16)], count), }; self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } return; } if text.starts_with("/history") || text.starts_with("/h ") { let peer = if text.starts_with("/h ") { text[3..].trim() } else if text.starts_with("/history ") { text[9..].trim() } else { "" }; let fp = if let Some(ref p) = self.peer_fp { if !p.starts_with('#') { p.as_str() } else { peer } } else { peer }; if fp.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "Usage: /history or /h (or set peer first)".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { match db.get_history(fp, 50) { Ok(msgs) => { if msgs.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "No history with this peer".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); for m in &msgs { let sender = m.get("sender").and_then(|v| v.as_str()).unwrap_or("?"); let txt = m.get("text").and_then(|v| v.as_str()).unwrap_or(""); let is_self = m.get("is_self").and_then(|v| v.as_bool()).unwrap_or(false); self.add_message(ChatLine { sender: sender[..sender.len().min(12)].to_string(), text: txt.to_string(), is_system: false, is_self, message_id: None, sender_fp: None, timestamp: Local::now(), }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } return; } if text == "/eth" { // Show ethereum address from seed if let Ok(seed) = crate::keystore::load_seed_raw() { let eth = warzone_protocol::ethereum::derive_eth_identity(&seed); self.add_message(ChatLine { sender: "system".into(), text: format!("ETH: {}", eth.address.to_checksum()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } return; } if text == "/seed" { if let Ok(seed) = crate::keystore::load_seed_raw() { let mnemonic = warzone_protocol::identity::Seed::from_bytes(seed).to_mnemonic(); self.add_message(ChatLine { sender: "system".into(), text: "Your recovery seed (keep secret!):".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); self.add_message(ChatLine { sender: "system".into(), text: mnemonic, is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { self.add_message(ChatLine { sender: "system".into(), text: "Failed to load seed".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } return; } if text == "/backup" { if let Ok(seed) = crate::keystore::load_seed_raw() { match db.create_backup(&seed) { Ok(path) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Backup saved: {}", path.display()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Backup failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } return; } if text == "/friend" || text == "/friends" { // Fetch encrypted friend list from server, decrypt locally let url = format!("{}/v1/friends", client.base_url); match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { match data.get("data").and_then(|v| v.as_str()) { Some(blob_b64) => { if let Ok(seed) = crate::keystore::load_seed_raw() { let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); match warzone_protocol::friends::FriendList::decrypt(&seed, &blob) { Ok(list) => { if list.friends.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend
to add.".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { self.add_message(ChatLine { sender: "system".into(), text: format!("Friends ({}):", list.friends.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); for f in &list.friends { // Check presence let presence_url = format!("{}/v1/presence/{}", client.base_url, normfp(&f.address)); let online = match client.client.get(&presence_url).send().await { Ok(r) => r.json::().await.ok() .and_then(|d| d.get("online").and_then(|v| v.as_bool())) .unwrap_or(false), Err(_) => false, }; let status = if online { "online" } else { "offline" }; let label = match &f.alias { Some(a) => format!(" @{} ({}) — {}", a, &f.address[..f.address.len().min(16)], status), None => format!(" {} — {}", &f.address[..f.address.len().min(16)], status), }; self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Failed to decrypt friend list: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } } _ => { self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend
to add.".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } return; } if text.starts_with("/friend ") { let addr = text[8..].trim().to_string(); if addr.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "Usage: /friend
".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } if let Ok(seed) = crate::keystore::load_seed_raw() { // Fetch existing list let url = format!("{}/v1/friends", client.base_url); let mut list = match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(blob_b64) = data.get("data").and_then(|v| v.as_str()) { let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); warzone_protocol::friends::FriendList::decrypt(&seed, &blob).unwrap_or_default() } else { warzone_protocol::friends::FriendList::new() } } else { warzone_protocol::friends::FriendList::new() } } Err(_) => warzone_protocol::friends::FriendList::new(), }; list.add(&addr, None); let encrypted = list.encrypt(&seed); let blob_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted); let _ = client.client.post(&url).json(&serde_json::json!({"data": blob_b64})).send().await; self.add_message(ChatLine { sender: "system".into(), text: format!("Added {} to friends", addr), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } return; } if text.starts_with("/unfriend ") { let addr = text[10..].trim().to_string(); if addr.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "Usage: /unfriend
".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } if let Ok(seed) = crate::keystore::load_seed_raw() { let url = format!("{}/v1/friends", client.base_url); let mut list = match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(blob_b64) = data.get("data").and_then(|v| v.as_str()) { let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); warzone_protocol::friends::FriendList::decrypt(&seed, &blob).unwrap_or_default() } else { warzone_protocol::friends::FriendList::new() } } else { warzone_protocol::friends::FriendList::new() } } Err(_) => warzone_protocol::friends::FriendList::new(), }; list.remove(&addr); let encrypted = list.encrypt(&seed); let blob_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted); let _ = client.client.post(&url).json(&serde_json::json!({"data": blob_b64})).send().await; self.add_message(ChatLine { sender: "system".into(), text: format!("Removed {} from friends", addr), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } return; } if text == "/devices" { let url = format!("{}/v1/devices", client.base_url); // Try to get bearer token from a recent auth (for now, make unauthenticated GET) match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(devices) = data.get("devices").and_then(|v| v.as_array()) { if devices.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "No active devices".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { self.add_message(ChatLine { sender: "system".into(), text: format!("Active devices ({}):", devices.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); for d in devices { let id = d.get("device_id").and_then(|v| v.as_str()).unwrap_or("?"); let connected = d.get("connected_at").and_then(|v| v.as_i64()).unwrap_or(0); let when = chrono::DateTime::from_timestamp(connected, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| "?".to_string()); self.add_message(ChatLine { sender: "system".into(), text: format!(" {} — connected {}", id, when), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } else if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } return; } if text.starts_with("/kick ") { let device_id = text[6..].trim(); if device_id.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "Usage: /kick ".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } let url = format!("{}/v1/devices/{}/kick", client.base_url, device_id); match client.client.post(&url).json(&serde_json::json!({})).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(kicked) = data.get("kicked").and_then(|v| v.as_str()) { self.add_message(ChatLine { sender: "system".into(), text: format!("Device {} kicked", kicked), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } return; } if text == "/r" || text == "/reply" || text.starts_with("/r ") || text.starts_with("/reply ") { let last = self.last_dm_peer.lock().unwrap().clone(); if let Some(ref peer) = last { self.peer_fp = Some(peer.clone()); self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); // If there's a message after /r, mutate text and fall through to send let reply_msg = if text.starts_with("/reply ") { text[7..].trim().to_string() } else if text.starts_with("/r ") { text[3..].trim().to_string() } else { String::new() }; if reply_msg.is_empty() { return; // Just switch peer } text = reply_msg; // Fall through to send logic below } else { self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } } if text.starts_with("/peer ") || text.starts_with("/p ") { let text = if text.starts_with("/p ") { format!("/peer {}", &text[3..]) } else { text.clone() }; let raw = text[6..].trim().to_string(); let is_eth_input = raw.starts_with("0x") || raw.starts_with("0X"); let eth_input = if is_eth_input { Some(raw.clone()) } else { None }; let fp = if raw.starts_with('@') { match self.resolve_alias(&raw[1..], client).await { Some(resolved) => resolved, None => return, } } else if raw.starts_with("0x") || raw.starts_with("0X") { // Resolve ETH address via server match self.resolve_address(&raw, client).await { Some(resolved) => resolved, None => return, } } else { raw }; if normfp(&fp) == normfp(&self.our_fp) { self.add_message(ChatLine { sender: "system".into(), text: "Cannot set yourself as peer".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } // Resolve peer ETH for display if is_eth_input { self.peer_eth = eth_input; } else { // Try to look up ETH for this fingerprint let resolve_url = format!("{}/v1/resolve/{}", client.base_url, normfp(&fp)); if let Ok(resp) = client.client.get(&resolve_url).send().await { if let Ok(data) = resp.json::().await { self.peer_eth = data.get("eth_address").and_then(|v| v.as_str()).map(String::from); } } } let display = self.peer_eth.as_deref().unwrap_or(&fp); self.add_message(ChatLine { sender: "system".into(), text: format!("Peer set to {}", display), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); self.peer_fp = Some(fp); return; } if text.starts_with("/gcreate ") { let name = text[9..].trim(); self.group_create(name, client).await; return; } if text.starts_with("/gjoin ") { let name = text[7..].trim(); self.group_join(name, client).await; return; } if text.starts_with("/g ") { let name = text[3..].trim().to_string(); // Auto-join self.group_join(&name, client).await; self.add_message(ChatLine { sender: "system".into(), text: format!("Switched to group #{}", name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); self.peer_fp = Some(format!("#{}", name)); return; } if text == "/dm" { self.add_message(ChatLine { sender: "system".into(), text: "Switched to DM mode. Use /peer ".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); self.peer_fp = None; return; } if text == "/glist" { self.group_list(client).await; return; } if text == "/gleave" { if let Some(ref peer) = self.peer_fp { if peer.starts_with('#') { let name = peer[1..].to_string(); self.group_leave(&name, client).await; self.peer_fp = None; } else { self.add_message(ChatLine { sender: "system".into(), text: "Not in a group. Use /g first".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } return; } if text.starts_with("/gkick ") { if let Some(ref peer) = self.peer_fp { if peer.starts_with('#') { let name = peer[1..].to_string(); let target = text[7..].trim().to_string(); self.group_kick(&name, &target, client).await; } else { self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } return; } if text == "/gmembers" { if let Some(ref peer) = self.peer_fp { if peer.starts_with('#') { let name = peer[1..].to_string(); self.group_members(&name, client).await; } else { self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } return; } if text == "/call" || text.starts_with("/call ") { let target = if text.starts_with("/call ") { let arg = text[6..].trim(); if arg.starts_with('@') { match self.resolve_alias(&arg[1..], client).await { Some(fp) => Some(fp), None => return, } } else if arg.starts_with("0x") || arg.starts_with("0X") { match self.resolve_address(arg, client).await { Some(fp) => Some(fp), None => return, } } else if !arg.is_empty() { Some(arg.to_string()) } else { None } } else { None }; let peer = target.or_else(|| self.peer_fp.clone()); let peer = match peer { Some(p) if !p.starts_with('#') => p, _ => { self.add_message(ChatLine { sender: "system".into(), text: "No peer to call. Use /call or set a peer first.".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; let peer_fp_clean = normfp(&peer); let msg_id = uuid::Uuid::new_v4().to_string(); let our_pub = identity.public_identity(); let wire = warzone_protocol::message::WireMessage::CallSignal { id: msg_id.clone(), sender_fingerprint: our_pub.fingerprint.to_string(), signal_type: warzone_protocol::message::CallSignalType::Offer, payload: String::new(), target: peer_fp_clean.clone(), }; let encoded = match bincode::serialize(&wire) { Ok(e) => e, Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Call failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; match client.send_message(&peer_fp_clean, Some(&self.our_fp), &encoded).await { Ok(_) => { let display = self.peer_eth.as_deref() .or(Some(&peer)) .map(|s| if s.len() > 16 { format!("{}...", &s[..16]) } else { s.to_string() }) .unwrap_or_default(); self.add_message(ChatLine { sender: "system".into(), text: format!("📞 Calling {}...", display), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); self.add_message(ChatLine { sender: "system".into(), text: "Audio: use web client for voice (TUI audio coming soon)".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); self.call_state = Some(super::types::CallInfo { peer_fp: peer_fp_clean.clone(), peer_display: display.clone(), state: super::types::CallPhase::Calling, started_at: Local::now(), }); } Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Call failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } return; } if text == "/accept" { let peer = match self.last_dm_peer.lock().unwrap().clone() { Some(p) => p, None => { self.add_message(ChatLine { sender: "system".into(), text: "No incoming call to accept".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; let msg_id = uuid::Uuid::new_v4().to_string(); let our_pub = identity.public_identity(); let wire = warzone_protocol::message::WireMessage::CallSignal { id: msg_id, sender_fingerprint: our_pub.fingerprint.to_string(), signal_type: warzone_protocol::message::CallSignalType::Answer, payload: String::new(), target: normfp(&peer), }; if let Ok(encoded) = bincode::serialize(&wire) { let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await; self.add_message(ChatLine { sender: "system".into(), text: "✓ Call accepted".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); self.call_state = Some(super::types::CallInfo { peer_fp: normfp(&peer), peer_display: peer[..peer.len().min(16)].to_string(), state: super::types::CallPhase::Active, started_at: Local::now(), }); } return; } if text == "/reject" { let peer = match self.last_dm_peer.lock().unwrap().clone() { Some(p) => p, None => { self.add_message(ChatLine { sender: "system".into(), text: "No incoming call to reject".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; let msg_id = uuid::Uuid::new_v4().to_string(); let our_pub = identity.public_identity(); let wire = warzone_protocol::message::WireMessage::CallSignal { id: msg_id, sender_fingerprint: our_pub.fingerprint.to_string(), signal_type: warzone_protocol::message::CallSignalType::Reject, payload: String::new(), target: normfp(&peer), }; if let Ok(encoded) = bincode::serialize(&wire) { let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await; self.add_message(ChatLine { sender: "system".into(), text: "✗ Call rejected".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); self.call_state = None; } return; } if text == "/hangup" { let peer = self.peer_fp.clone().or_else(|| self.last_dm_peer.lock().unwrap().clone()); let peer = match peer { Some(p) if !p.starts_with('#') => p, _ => { self.add_message(ChatLine { sender: "system".into(), text: "No active call".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; let msg_id = uuid::Uuid::new_v4().to_string(); let our_pub = identity.public_identity(); let wire = warzone_protocol::message::WireMessage::CallSignal { id: msg_id, sender_fingerprint: our_pub.fingerprint.to_string(), signal_type: warzone_protocol::message::CallSignalType::Hangup, payload: String::new(), target: normfp(&peer), }; if let Ok(encoded) = bincode::serialize(&wire) { let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await; self.add_message(ChatLine { sender: "system".into(), text: "Call ended".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); self.call_state = None; } return; } if text.starts_with("/file ") { let path_str = text[6..].trim(); self.handle_file_send(path_str, identity, db, client).await; return; } // Send message (group or DM) let peer = match &self.peer_fp { Some(p) if p.starts_with('#') => { // Group mode let group_name = p[1..].to_string(); self.group_send(&group_name, &text, identity, db, client).await; return; } Some(p) => p.clone(), None => { self.add_message(ChatLine { sender: "system".into(), text: "No peer set. Use /peer ".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } }; // Prevent self-messaging (causes ratchet corruption) if normfp(&peer) == normfp(&self.our_fp) { self.add_message(ChatLine { sender: "system".into(), text: "Cannot send messages to yourself".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } let peer_fp = match Fingerprint::from_hex(&peer) { Ok(fp) => fp, Err(_) => { self.add_message(ChatLine { sender: "system".into(), text: "Invalid peer fingerprint".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } }; // If peer is a bot alias, send plaintext (no E2E) let is_bot_peer = { let url = format!("{}/v1/alias/whois/{}", client.base_url, normfp(&peer)); match client.client.get(&url).send().await { Ok(resp) => resp.json::().await.ok() .and_then(|d| d.get("alias").and_then(|a| a.as_str().map(|s| s.ends_with("bot") || s.ends_with("Bot") || s.ends_with("_bot") || s == "botfather"))) .unwrap_or(false), Err(_) => false, } }; if is_bot_peer { let msg_id = uuid::Uuid::new_v4().to_string(); let bot_msg = serde_json::json!({ "type": "bot_message", "id": msg_id, "from": normfp(&self.our_fp), "from_name": if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { self.our_eth.clone() }, "text": text, "timestamp": chrono::Utc::now().timestamp(), }); let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default(); match client.send_message(&peer, Some(&self.our_fp), &msg_bytes).await { Ok(_) => { self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent); let _ = db.touch_contact(&peer, None); let _ = db.store_message(&peer, &self.our_fp, &text, true); self.add_message(ChatLine { sender: if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { format!("{}...", &self.our_eth[..self.our_eth.len().min(12)]) }, text: text.clone(), is_system: false, is_self: true, message_id: Some(msg_id), sender_fp: None, timestamp: Local::now(), }); } Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } return; } let msg_id = uuid::Uuid::new_v4().to_string(); let our_pub = identity.public_identity(); let mut ratchet = db.load_session(&peer_fp).ok().flatten(); let wire_msg = if let Some(ref mut state) = ratchet { match state.encrypt(text.as_bytes()) { Ok(encrypted) => { let _ = db.save_session(&peer_fp, state); WireMessage::Message { id: msg_id.clone(), sender_fingerprint: our_pub.fingerprint.to_string(), ratchet_message: encrypted, } } Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Encrypt failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } } } else { // X3DH let bundle = match client.fetch_bundle(&peer).await { Ok(b) => b, Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Failed to fetch bundle: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } }; let x3dh_result = match x3dh::initiate(identity, &bundle) { Ok(r) => r, Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("X3DH failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } }; let their_spk = PublicKey::from(bundle.signed_pre_key.public_key); let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); match state.encrypt(text.as_bytes()) { Ok(encrypted) => { let _ = db.save_session(&peer_fp, &state); WireMessage::KeyExchange { id: msg_id.clone(), sender_fingerprint: our_pub.fingerprint.to_string(), sender_identity_encryption_key: *our_pub.encryption.as_bytes(), ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id, ratchet_message: encrypted, } } Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Encrypt failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } } }; let encoded = match bincode::serialize(&wire_msg) { Ok(e) => e, Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Serialize failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } }; match client.send_message(&peer, Some(&self.our_fp), &encoded).await { Ok(_) => { // Track receipt status self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent); // Store in contacts + history let _ = db.touch_contact(&peer, None); let _ = db.store_message(&peer, &self.our_fp, &text, true); self.add_message(ChatLine { sender: if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { format!("{}...", &self.our_eth[..self.our_eth.len().min(12)]) }, text: text.clone(), is_system: false, is_self: true, message_id: Some(msg_id), sender_fp: None, timestamp: Local::now(), }); } Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); } } } pub(crate) async fn group_create(&self, name: &str, client: &ServerClient) { let url = format!("{}/v1/groups/create", client.base_url); match client.client.post(&url) .json(&serde_json::json!({"name": name, "creator": normfp(&self.our_fp)})) .send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } pub(crate) async fn group_join(&self, name: &str, client: &ServerClient) { let url = format!("{}/v1/groups/{}/join", client.base_url, name); match client.client.post(&url) .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)})) .send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { let members = data.get("members").and_then(|v| v.as_u64()).unwrap_or(0); self.add_message(ChatLine { sender: "system".into(), text: format!("Joined '{}' ({} members)", name, members), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } async fn group_list(&self, client: &ServerClient) { let url = format!("{}/v1/groups", client.base_url); match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(groups) = data.get("groups").and_then(|v| v.as_array()) { if groups.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "No groups".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { for g in groups { let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("?"); let members = g.get("members").and_then(|v| v.as_u64()).unwrap_or(0); self.add_message(ChatLine { sender: "system".into(), text: format!(" #{} ({} members)", name, members), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } async fn group_leave(&self, name: &str, client: &ServerClient) { let url = format!("{}/v1/groups/{}/leave", client.base_url, name); match client.client.post(&url) .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)})) .send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } async fn group_kick(&self, name: &str, target: &str, client: &ServerClient) { let url = format!("{}/v1/groups/{}/kick", client.base_url, name); match client.client.post(&url) .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp), "target": target})) .send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { let kicked = data.get("kicked").and_then(|v| v.as_str()).unwrap_or("?"); self.add_message(ChatLine { sender: "system".into(), text: format!("Kicked {} from '{}'", kicked, name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } async fn group_members(&self, name: &str, client: &ServerClient) { let url = format!("{}/v1/groups/{}/members", client.base_url, name); match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(members) = data.get("members").and_then(|v| v.as_array()) { self.add_message(ChatLine { sender: "system".into(), text: format!("Members of #{}:", name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); for m in members { let fp = m.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?"); let alias = m.get("alias").and_then(|v| v.as_str()); let creator = m.get("is_creator").and_then(|v| v.as_bool()).unwrap_or(false); let label = match alias { Some(a) => format!(" @{} ({}{})", a, &fp[..fp.len().min(12)], if creator { " ★" } else { "" }), None => format!(" {}...{}", &fp[..fp.len().min(12)], if creator { " ★" } else { "" }), }; self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } pub(crate) async fn group_send( &self, group_name: &str, text: &str, identity: &IdentityKeyPair, db: &LocalDb, client: &ServerClient, ) { // Get members let url = format!("{}/v1/groups/{}", client.base_url, group_name); let group_data = match client.client.get(&url).send().await { Ok(resp) => match resp.json::().await { Ok(d) => d, Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }, Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; let my_fp = normfp(&self.our_fp); let members: Vec = group_data.get("members") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(); let our_pub = identity.public_identity(); let mut wire_messages: Vec = Vec::new(); for member in &members { if *member == my_fp { continue; } let member_fp = match Fingerprint::from_hex(member) { Ok(fp) => fp, Err(_) => continue, }; let mut ratchet = db.load_session(&member_fp).ok().flatten(); let wire_msg = if let Some(ref mut state) = ratchet { match state.encrypt(text.as_bytes()) { Ok(encrypted) => { let _ = db.save_session(&member_fp, state); WireMessage::Message { id: uuid::Uuid::new_v4().to_string(), sender_fingerprint: our_pub.fingerprint.to_string(), ratchet_message: encrypted, } } Err(_) => continue, } } else { // Need X3DH — fetch bundle let bundle = match client.fetch_bundle(member).await { Ok(b) => b, Err(_) => continue, }; let x3dh_result = match x3dh::initiate(identity, &bundle) { Ok(r) => r, Err(_) => continue, }; let their_spk = PublicKey::from(bundle.signed_pre_key.public_key); let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); match state.encrypt(text.as_bytes()) { Ok(encrypted) => { let _ = db.save_session(&member_fp, &state); WireMessage::KeyExchange { id: uuid::Uuid::new_v4().to_string(), sender_fingerprint: our_pub.fingerprint.to_string(), sender_identity_encryption_key: *our_pub.encryption.as_bytes(), ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id, ratchet_message: encrypted, } } Err(_) => continue, } }; let encoded = match bincode::serialize(&wire_msg) { Ok(e) => e, Err(_) => continue, }; wire_messages.push(serde_json::json!({ "to": member, "message": encoded, })); } if wire_messages.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "No members to send to".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } let send_url = format!("{}/v1/groups/{}/send", client.base_url, group_name); match client.client.post(&send_url) .json(&serde_json::json!({ "from": my_fp, "messages": wire_messages, })) .send().await { Ok(_) => { self.add_message(ChatLine { sender: format!("{} [#{}]", if self.our_eth.is_empty() { &self.our_fp[..12] } else { &self.our_eth[..self.our_eth.len().min(12)] }, group_name), text: text.to_string(), is_system: false, is_self: true, message_id: None, sender_fp: None, timestamp: Local::now(), }); } Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } async fn register_alias(&self, name: &str, client: &ServerClient) { let url = format!("{}/v1/alias/register", client.base_url); match client.client.post(&url) .json(&serde_json::json!({"alias": name, "fingerprint": normfp(&self.our_fp)})) .send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { let alias = data.get("alias").and_then(|v| v.as_str()).unwrap_or(name); self.add_message(ChatLine { sender: "system".into(), text: format!("Alias @{} registered", alias), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } pub(crate) async fn resolve_alias(&self, name: &str, client: &ServerClient) -> Option { let url = format!("{}/v1/alias/resolve/{}", client.base_url, name); match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) { self.add_message(ChatLine { sender: "system".into(), text: format!("@{} → {}", name, fp), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return Some(fp.to_string()); } if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Unknown alias @{}: {}", name, err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } None } Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); None } } } /// Resolve an ETH address (0x...) or any address format via the server. pub(crate) async fn resolve_address(&self, addr: &str, client: &ServerClient) -> Option { let url = format!("{}/v1/resolve/{}", client.base_url, addr); match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) { // Format fingerprint with colons: xxxx:xxxx:xxxx:... let formatted: String = fp.chars().enumerate() .flat_map(|(i, c)| if i > 0 && i % 4 == 0 { vec![':', c] } else { vec![c] }) .collect(); self.add_message(ChatLine { sender: "system".into(), text: format!("{} → {}", addr, formatted), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return Some(fp.to_string()); } if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Cannot resolve {}: {}", addr, err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } None } Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); None } } } async fn list_aliases(&self, client: &ServerClient) { let url = format!("{}/v1/alias/list", client.base_url); match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(aliases) = data.get("aliases").and_then(|v| v.as_array()) { if aliases.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "No aliases".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { for a in aliases { let name = a.get("alias").and_then(|v| v.as_str()).unwrap_or("?"); let fp = a.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?"); self.add_message(ChatLine { sender: "system".into(), text: format!(" @{} → {}...", name, &fp[..fp.len().min(16)]), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } }