Web client: - After call goes "active", connects to WZP web bridge WS - Mic capture: getUserMedia → ScriptProcessor → PCM int16 frames → WS - Playback: WS → PCM int16 → Float32 → AudioContext.createBufferSource - Room name derived from peer fingerprint (deterministic) - Relay address fetched from /v1/wzp/relay-config - Audio auto-starts on accept/answer, auto-stops on hangup/reject - startAudio()/stopAudio() manage full lifecycle TUI: - /call shows "Audio: use web client for voice (TUI audio coming soon)" - Signaling works, audio requires web client for now This completes the last critical task — voice calls work end-to-end: User A calls → signaling via featherChat WS → User B accepts → both connect to WZP relay → audio flows Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1222 lines
66 KiB
Rust
1222 lines
66 KiB
Rust
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 <fp>, /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 <name> Register an alias for yourself",
|
|
" /aliases List all registered aliases",
|
|
" /unalias Remove your alias",
|
|
" /friend List friends with online status",
|
|
" /friend <address> Add a friend",
|
|
" /unfriend <address> Remove a friend",
|
|
" /devices List your active device sessions",
|
|
" /kick <device_id> Kick a specific device session",
|
|
" /g <name> Switch to group (auto-join)",
|
|
" /gcreate <name> Create a new group",
|
|
" /gjoin <name> Join a group",
|
|
" /glist List all groups",
|
|
" /gleave Leave current group",
|
|
" /gkick <fp> Kick member from group",
|
|
" /gmembers List group members",
|
|
" /file <path> 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::<serde_json::Value>().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::<serde_json::Value>().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 <fingerprint> (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::<serde_json::Value>().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 <address> 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::<serde_json::Value>().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 <address> 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 <address>".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::<serde_json::Value>().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 <address>".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::<serde_json::Value>().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::<serde_json::Value>().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 <device_id>".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::<serde_json::Value>().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::<serde_json::Value>().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 <fp>".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 <name> 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 <fp> 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 <fingerprint>".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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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<String> = 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<serde_json::Value> = 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::<serde_json::Value>().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<String> {
|
|
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::<serde_json::Value>().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<String> {
|
|
let url = format!("{}/v1/resolve/{}", client.base_url, addr);
|
|
match client.client.get(&url).send().await {
|
|
Ok(resp) => {
|
|
if let Ok(data) = resp.json::<serde_json::Value>().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::<serde_json::Value>().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() }),
|
|
}
|
|
}
|
|
}
|