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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user