v0.0.21: TUI overhaul, WZP call infrastructure, security hardening, federation

TUI:
- Split 1,756-line app.rs monolith into 7 modules (types, draw, commands, input, file_transfer, network, mod)
- Message timestamps [HH:MM], scrolling (PageUp/Down/arrows), connection status dot, unread badge
- /help command, terminal bell on incoming DM, /devices + /kick commands
- 44 unit tests (types, input, draw with TestBackend)

Server — WZP Call Infrastructure (FC-2/3/5/6/7/10):
- Call state management (CallState, CallStatus, active_calls, calls + missed_calls sled trees)
- WS call signal awareness (Offer/Answer/Hangup update state, missed call on offline)
- Group call endpoint (POST /groups/:name/call with SHA-256 room ID, fan-out)
- Presence API (GET /presence/:fp, POST /presence/batch)
- Missed call flush on WS reconnect
- WZP relay config + CORS

Server — Security (FC-P1):
- Auth enforcement middleware (AuthFingerprint extractor on 13 write handlers)
- Session auto-recovery (delete corrupted ratchet, show [session reset])
- WS connection cap (5/fingerprint) + global concurrency limit (200)
- Device management (GET /devices, POST /devices/:id/kick, POST /devices/revoke-all)

Server — Federation:
- Two-server federation via JSON config (--federation flag)
- Periodic presence sync (every 5s, full-state, self-healing)
- Message forwarding via HTTP POST with SHA-256(secret||body) auth
- Graceful degradation (peer down = queue locally)
- deliver_or_queue() replaces push-or-queue in ws.rs + messages.rs

Client — Group Messaging:
- SenderKeyDistribution storage + GroupSenderKey decryption in TUI
- sender_keys sled tree in LocalDb

WASM:
- All 8 WireMessage variants handled (no more "unsupported")
- decrypt_group_message() + create_sender_key_from_distribution() exports
- CallSignal parsing with signal_type mapping

Docs:
- ARCHITECTURE.md rewritten with Mermaid diagrams
- README.md created
- TASK_PLAN.md with FC-P{phase}-T{task} naming
- PROGRESS.md updated to v0.0.21

WZP submodule updated to 6f4e8eb (IAX2 trunking, adaptive quality, metrics, all S-tasks done)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-28 16:45:58 +04:00
parent 4a4fa9fab4
commit 3e0889e5dc
36 changed files with 5237 additions and 2232 deletions

View File

@@ -0,0 +1,798 @@
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 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 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" {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Your fingerprint: {}", self.our_fp),
is_system: true,
is_self: false,
message_id: 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",
" /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",
" /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)",
" /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, 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, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: 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, 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, 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, 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);
let label = match alias {
Some(a) => format!(" @{} ({}) — {} msgs", a, &fp[..fp.len().min(12)], count),
None => format!(" {}{} msgs", &fp[..fp.len().min(16)], count),
};
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, timestamp: Local::now() }),
}
return;
}
if text == "/r" || text == "/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, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: 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 fp = if raw.starts_with('@') {
match self.resolve_alias(&raw[1..], client).await {
Some(resolved) => resolved,
None => return,
}
} else {
raw
};
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Peer set to {}", fp),
is_system: true,
is_self: false,
message_id: 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, 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, 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, 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, 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, timestamp: Local::now() });
}
}
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, 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, 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, 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, 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, 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, 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, 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: self.our_fp[..12].to_string(),
text: text.clone(),
is_system: false,
is_self: true,
message_id: Some(msg_id), 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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!("{} [#{}]", &self.our_fp[..12], group_name),
text: text.to_string(),
is_system: false,
is_self: true,
message_id: 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, timestamp: Local::now() }),
}
}
}