feat: friend list, bot API, ETH addressing, deep links, docs overhaul

Tier 1 — New features:
- E2E encrypted friend list: server stores opaque blob (POST/GET /v1/friends),
  protocol-level encrypt/decrypt with HKDF-derived key, 4 tests
- Telegram Bot API compatibility: /bot/register, /bot/:token/getUpdates,
  sendMessage, getMe — TG-style Update objects with proper message mapping
- ETH address resolution: GET /v1/resolve/:address (0x.../alias/@.../fp),
  bidirectional ETH↔fp mapping stored on key registration
- Seed recovery: /seed command in TUI + web client
- URL deep links: /message/@alias, /message/0xABC, /group/#ops
- Group members with online status in GET /groups/:name/members

Tier 2 — UX polish:
- TUI: /friend, /friend <addr>, /unfriend <addr> with presence checking
- Web: friend commands, showGroupMembers() on group join
- Web: ETH address in header, clickable addresses (click→peer or copy)
- Bot: full WireMessage→TG Update mapping (encrypted base64, CallSignal,
  FileHeader, bot_message JSON)

Documentation:
- USAGE.md rewritten: complete user guide with all commands
- SERVER.md rewritten: full admin guide with all 50+ endpoints
- CLIENT.md rewritten: architecture, commands, keyboard, storage
- LLM_HELP.md created: 1083-word token-optimized reference for helper LLM

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-29 07:31:54 +04:00
parent dbf5d136cf
commit 7b72f7cba5
15 changed files with 2181 additions and 1023 deletions

View File

@@ -8,6 +8,7 @@ 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};
@@ -48,6 +49,7 @@ impl App {
" /help, /? Show this help",
" /info Show your fingerprint",
" /eth Show Ethereum address",
" /seed Show recovery mnemonic (24 words)",
" /peer <fp>, /p Set DM peer by fingerprint",
" /peer @alias Set DM peer by alias",
" /reply, /r Reply to last DM sender",
@@ -57,6 +59,9 @@ impl App {
" /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)",
@@ -173,6 +178,127 @@ impl App {
}
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, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: mnemonic, is_system: true, is_self: false, message_id: 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, 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, 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, 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, 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, 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, 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("/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, 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, 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, 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, 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)