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)

View File

@@ -0,0 +1,113 @@
//! Encrypted friend list — stored on server as opaque blob.
use serde::{Deserialize, Serialize};
use crate::crypto::{aead_encrypt, aead_decrypt, hkdf_derive};
/// A friend entry.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Friend {
/// ETH address or fingerprint
pub address: String,
/// Optional display name / alias
pub alias: Option<String>,
/// When this friend was added (unix timestamp)
pub added_at: i64,
}
/// The full friend list (plaintext, before encryption).
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct FriendList {
pub friends: Vec<Friend>,
}
impl FriendList {
pub fn new() -> Self {
FriendList { friends: vec![] }
}
pub fn add(&mut self, address: &str, alias: Option<&str>) {
// Don't add duplicates
if self.friends.iter().any(|f| f.address == address) {
return;
}
self.friends.push(Friend {
address: address.to_string(),
alias: alias.map(String::from),
added_at: chrono::Utc::now().timestamp(),
});
}
pub fn remove(&mut self, address: &str) {
self.friends.retain(|f| f.address != address);
}
/// Encrypt the friend list for server storage.
/// Key is derived from the user's seed: HKDF(seed, info="warzone-friends").
pub fn encrypt(&self, seed: &[u8; 32]) -> Vec<u8> {
let key_bytes = hkdf_derive(seed, b"", b"warzone-friends", 32);
let mut key = [0u8; 32];
key.copy_from_slice(&key_bytes);
let plaintext = serde_json::to_vec(self).unwrap_or_default();
aead_encrypt(&key, &plaintext, b"warzone-friends-aad")
}
/// Decrypt a friend list blob from the server.
pub fn decrypt(seed: &[u8; 32], ciphertext: &[u8]) -> Result<Self, crate::errors::ProtocolError> {
let key_bytes = hkdf_derive(seed, b"", b"warzone-friends", 32);
let mut key = [0u8; 32];
key.copy_from_slice(&key_bytes);
let plaintext = aead_decrypt(&key, ciphertext, b"warzone-friends-aad")?;
serde_json::from_slice(&plaintext)
.map_err(|e| crate::errors::ProtocolError::RatchetError(format!("friend list json: {}", e)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypt_decrypt_roundtrip() {
let seed = [42u8; 32];
let mut list = FriendList::new();
list.add("0x1234abcd", Some("alice"));
list.add("0xdeadbeef", None);
let encrypted = list.encrypt(&seed);
let decrypted = FriendList::decrypt(&seed, &encrypted).unwrap();
assert_eq!(decrypted.friends.len(), 2);
assert_eq!(decrypted.friends[0].address, "0x1234abcd");
assert_eq!(decrypted.friends[0].alias.as_deref(), Some("alice"));
assert_eq!(decrypted.friends[1].address, "0xdeadbeef");
}
#[test]
fn wrong_seed_fails() {
let seed = [42u8; 32];
let wrong_seed = [99u8; 32];
let mut list = FriendList::new();
list.add("0x1234", None);
let encrypted = list.encrypt(&seed);
assert!(FriendList::decrypt(&wrong_seed, &encrypted).is_err());
}
#[test]
fn no_duplicate_add() {
let mut list = FriendList::new();
list.add("0x1234", None);
list.add("0x1234", Some("alice"));
assert_eq!(list.friends.len(), 1);
}
#[test]
fn remove_works() {
let mut list = FriendList::new();
list.add("0x1234", None);
list.add("0x5678", None);
list.remove("0x1234");
assert_eq!(list.friends.len(), 1);
assert_eq!(list.friends[0].address, "0x5678");
}
}

View File

@@ -12,3 +12,4 @@ pub mod store;
pub mod history;
pub mod sender_keys;
pub mod ethereum;
pub mod friends;

View File

@@ -8,6 +8,8 @@ pub struct Database {
pub tokens: sled::Tree,
pub calls: sled::Tree,
pub missed_calls: sled::Tree,
pub friends: sled::Tree,
pub eth_addresses: sled::Tree,
_db: sled::Db,
}
@@ -21,6 +23,8 @@ impl Database {
let tokens = db.open_tree("tokens")?;
let calls = db.open_tree("calls")?;
let missed_calls = db.open_tree("missed_calls")?;
let friends = db.open_tree("friends")?;
let eth_addresses = db.open_tree("eth_addresses")?;
Ok(Database {
keys,
messages,
@@ -29,6 +33,8 @@ impl Database {
tokens,
calls,
missed_calls,
friends,
eth_addresses,
_db: db,
})
}

View File

@@ -0,0 +1,390 @@
//! Telegram Bot API compatibility layer.
//!
//! Bots register with a fingerprint and get a token.
//! They use `/bot<token>/getUpdates` and `/bot<token>/sendMessage`
//! to communicate with featherChat users.
use axum::{
extract::{Path, State},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use base64::Engine;
use crate::errors::AppResult;
use crate::state::AppState;
/// Build the bot API routes (nested under `/v1`).
pub fn routes() -> Router<AppState> {
Router::new()
.route("/bot/register", post(register_bot))
.route("/bot/:token/getUpdates", post(get_updates))
.route("/bot/:token/sendMessage", post(send_message))
.route("/bot/:token/getMe", get(get_me))
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Validate a bot token against the `tokens` sled tree.
/// Returns the stored bot info JSON if the token is valid.
fn validate_bot_token(state: &AppState, token: &str) -> Option<serde_json::Value> {
let key = format!("bot:{}", token);
let ivec = state.db.tokens.get(key.as_bytes()).ok()??;
serde_json::from_slice(&ivec).ok()
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct RegisterBotRequest {
name: String,
fingerprint: String,
}
/// Register a bot and receive a token.
///
/// `POST /v1/bot/register`
///
/// ```json
/// { "name": "mybot", "fingerprint": "aabbccdd..." }
/// ```
async fn register_bot(
State(state): State<AppState>,
Json(req): Json<RegisterBotRequest>,
) -> AppResult<Json<serde_json::Value>> {
let fp = req
.fingerprint
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect::<String>()
.to_lowercase();
let random_bytes: [u8; 16] = rand::random();
let token = format!(
"{}:{}",
&fp[..fp.len().min(16)],
hex::encode(random_bytes),
);
let bot_info = serde_json::json!({
"name": req.name,
"fingerprint": fp,
"token": token,
"created_at": chrono::Utc::now().timestamp(),
});
// Store bot info keyed by token.
let key = format!("bot:{}", token);
state
.db
.tokens
.insert(key.as_bytes(), serde_json::to_vec(&bot_info)?.as_slice())?;
// Reverse lookup: fingerprint -> token.
let fp_key = format!("bot_fp:{}", fp);
state
.db
.tokens
.insert(fp_key.as_bytes(), token.as_bytes())?;
tracing::info!(
"Bot registered: {} ({}) token={}...",
req.name,
fp,
&token[..token.len().min(20)]
);
Ok(Json(serde_json::json!({
"ok": true,
"result": {
"token": token,
"name": req.name,
"fingerprint": fp,
}
})))
}
/// `GET /bot/:token/getMe` -- returns bot info (Telegram-compatible shape).
async fn get_me(
State(state): State<AppState>,
Path(token): Path<String>,
) -> Json<serde_json::Value> {
match validate_bot_token(&state, &token) {
Some(info) => Json(serde_json::json!({
"ok": true,
"result": {
"id": info["fingerprint"],
"is_bot": true,
"first_name": info["name"],
"username": info["name"],
}
})),
None => Json(serde_json::json!({
"ok": false,
"description": "invalid token",
})),
}
}
/// `POST /bot/:token/getUpdates` -- long-poll for messages sent to this bot.
///
/// Reads from the `queue:<bot_fp>:*` key range in the messages sled tree,
/// converts each entry into a Telegram-style `Update` object, and deletes
/// consumed entries.
async fn get_updates(
State(state): State<AppState>,
Path(token): Path<String>,
Json(params): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
let bot_info = match validate_bot_token(&state, &token) {
Some(info) => info,
None => {
return Json(serde_json::json!({
"ok": false,
"description": "invalid token",
}))
}
};
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("");
let timeout = params
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let prefix = format!("queue:{}", bot_fp);
let mut updates = Vec::new();
let mut keys_to_delete = Vec::new();
let mut update_id = 1u64;
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
let (key, value) = match item {
Ok(pair) => pair,
Err(_) => continue,
};
if let Ok(wire) =
bincode::deserialize::<warzone_protocol::message::WireMessage>(&value)
{
match wire {
warzone_protocol::message::WireMessage::Message {
id,
sender_fingerprint,
..
} => {
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(&value);
updates.push(serde_json::json!({
"update_id": update_id,
"message": {
"message_id": id,
"from": {
"id": &sender_fingerprint,
"is_bot": false,
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
},
"chat": {
"id": &sender_fingerprint,
"type": "private",
},
"date": chrono::Utc::now().timestamp(),
"text": null,
"raw_encrypted": raw_b64,
}
}));
update_id += 1;
}
warzone_protocol::message::WireMessage::KeyExchange {
id,
sender_fingerprint,
..
} => {
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(&value);
updates.push(serde_json::json!({
"update_id": update_id,
"message": {
"message_id": id,
"from": {
"id": &sender_fingerprint,
"is_bot": false,
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
},
"chat": {
"id": &sender_fingerprint,
"type": "private",
},
"date": chrono::Utc::now().timestamp(),
"text": null,
"raw_encrypted": raw_b64,
}
}));
update_id += 1;
}
warzone_protocol::message::WireMessage::CallSignal {
id,
sender_fingerprint,
signal_type,
payload,
..
} => {
updates.push(serde_json::json!({
"update_id": update_id,
"message": {
"message_id": id,
"from": {
"id": &sender_fingerprint,
"is_bot": false,
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
},
"chat": {
"id": &sender_fingerprint,
"type": "private",
},
"date": chrono::Utc::now().timestamp(),
"text": format!("/call_{:?}", signal_type),
"call_signal": {
"type": format!("{:?}", signal_type),
"payload": payload,
},
}
}));
update_id += 1;
}
warzone_protocol::message::WireMessage::FileHeader {
id,
sender_fingerprint,
filename,
file_size,
..
} => {
updates.push(serde_json::json!({
"update_id": update_id,
"message": {
"message_id": id,
"from": {
"id": &sender_fingerprint,
"is_bot": false,
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
},
"chat": {
"id": &sender_fingerprint,
"type": "private",
},
"date": chrono::Utc::now().timestamp(),
"document": {
"file_name": filename,
"file_size": file_size,
},
}
}));
update_id += 1;
}
// Skip receipts — don't deliver as updates.
warzone_protocol::message::WireMessage::Receipt { .. } => {}
// Skip other variants (FileChunk, GroupSenderKey, SenderKeyDistribution).
_ => {}
}
} else if let Ok(bot_msg) = serde_json::from_slice::<serde_json::Value>(&value) {
// Try plaintext bot message (from other bots via sendMessage).
if bot_msg.get("type").and_then(|v| v.as_str()) == Some("bot_message") {
updates.push(serde_json::json!({
"update_id": update_id,
"message": {
"message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"from": {
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""),
"is_bot": true,
},
"chat": {
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""),
"type": "private",
},
"date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0),
"text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""),
}
}));
update_id += 1;
}
}
keys_to_delete.push(key);
}
// Remove consumed messages.
for key in &keys_to_delete {
let _ = state.db.messages.remove(key);
}
// Simplified long-poll: if the queue was empty, wait up to `timeout` seconds
// (capped at 5 s) before returning, giving new messages a chance to arrive.
if updates.is_empty() && timeout > 0 {
let wait = std::cmp::min(timeout, 5);
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
}
Json(serde_json::json!({
"ok": true,
"result": updates,
}))
}
#[derive(Deserialize)]
struct SendMessageRequest {
chat_id: String,
text: String,
}
/// `POST /bot/:token/sendMessage` -- send a plaintext message to a user.
///
/// In v1, bot messages are **not** E2E-encrypted; they are delivered as
/// plain JSON envelopes through the normal routing layer.
async fn send_message(
State(state): State<AppState>,
Path(token): Path<String>,
Json(req): Json<SendMessageRequest>,
) -> Json<serde_json::Value> {
let bot_info = match validate_bot_token(&state, &token) {
Some(info) => info,
None => {
return Json(serde_json::json!({
"ok": false,
"description": "invalid token",
}))
}
};
let to_fp = req
.chat_id
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect::<String>()
.to_lowercase();
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
let msg_id = uuid::Uuid::new_v4().to_string();
let bot_msg = serde_json::json!({
"type": "bot_message",
"id": msg_id,
"from": bot_fp,
"text": req.text,
"timestamp": chrono::Utc::now().timestamp(),
});
let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default();
let delivered = state.deliver_or_queue(&to_fp, &msg_bytes).await;
Json(serde_json::json!({
"ok": true,
"result": {
"message_id": msg_id,
"chat": { "id": to_fp, "type": "private" },
"text": req.text,
"date": chrono::Utc::now().timestamp(),
"delivered": delivered,
}
}))
}

View File

@@ -0,0 +1,54 @@
use axum::{
extract::State,
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use crate::auth_middleware::AuthFingerprint;
use crate::errors::AppResult;
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/friends", get(get_friends))
.route("/friends", post(save_friends))
}
/// Get the encrypted friend list blob for the authenticated user.
async fn get_friends(
auth: AuthFingerprint,
State(state): State<AppState>,
) -> AppResult<Json<serde_json::Value>> {
match state.db.friends.get(auth.fingerprint.as_bytes())? {
Some(data) => {
let blob = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
Ok(Json(serde_json::json!({
"fingerprint": auth.fingerprint,
"data": blob,
})))
}
None => Ok(Json(serde_json::json!({
"fingerprint": auth.fingerprint,
"data": null,
}))),
}
}
#[derive(Deserialize)]
struct SaveFriendsRequest {
data: String, // base64-encoded encrypted blob
}
/// Save the encrypted friend list blob.
async fn save_friends(
auth: AuthFingerprint,
State(state): State<AppState>,
Json(req): Json<SaveFriendsRequest>,
) -> AppResult<Json<serde_json::Value>> {
let blob = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &req.data)
.map_err(|e| anyhow::anyhow!("invalid base64: {}", e))?;
state.db.friends.insert(auth.fingerprint.as_bytes(), blob)?;
tracing::info!("Saved friend list for {} ({} bytes)", auth.fingerprint, req.data.len());
Ok(Json(serde_json::json!({ "ok": true })))
}

View File

@@ -281,16 +281,22 @@ async fn get_members(
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
};
// Resolve aliases for each member
// Resolve aliases and online status for each member
let mut members_info: Vec<serde_json::Value> = Vec::new();
let mut online_count: usize = 0;
for fp in &group.members {
let alias = state.db.aliases.get(format!("fp:{}", fp).as_bytes())
.ok().flatten()
.map(|v| String::from_utf8_lossy(&v).to_string());
let online = state.is_online(fp).await;
if online {
online_count += 1;
}
members_info.push(serde_json::json!({
"fingerprint": fp,
"alias": alias,
"is_creator": *fp == group.creator,
"online": online,
}));
}
@@ -298,5 +304,6 @@ async fn get_members(
"name": group.name,
"members": members_info,
"count": members_info.len(),
"online_count": online_count,
})))
}

View File

@@ -46,6 +46,8 @@ struct RegisterRequest {
#[serde(default)]
device_id: Option<String>,
bundle: Vec<u8>,
#[serde(default)]
eth_address: Option<String>,
}
#[derive(Serialize)]
@@ -68,6 +70,16 @@ async fn register_keys(
let device_key = format!("device:{}:{}", fp, device_id);
let _ = state.db.keys.insert(device_key.as_bytes(), req.bundle);
// Store ETH address mapping if provided
if let Some(ref eth) = req.eth_address {
let eth_lower = eth.to_lowercase();
// eth -> fp
let _ = state.db.eth_addresses.insert(eth_lower.as_bytes(), fp.as_bytes());
// fp -> eth (reverse lookup)
let _ = state.db.eth_addresses.insert(format!("rev:{}", fp).as_bytes(), eth_lower.as_bytes());
tracing::info!("ETH address mapped: {} -> {}", eth_lower, fp);
}
tracing::info!("Registered bundle for {} (device: {})", fp, device_id);
Json(RegisterResponse { ok: true })
}

View File

@@ -1,13 +1,16 @@
mod aliases;
pub mod auth;
mod bot;
mod calls;
mod devices;
mod federation;
mod friends;
mod groups;
mod health;
mod keys;
pub mod messages;
mod presence;
mod resolve;
mod web;
mod ws;
mod wzp;
@@ -29,7 +32,10 @@ pub fn router() -> Router<AppState> {
.merge(devices::routes())
.merge(presence::routes())
.merge(wzp::routes())
.merge(friends::routes())
.merge(federation::routes())
.merge(bot::routes())
.merge(resolve::routes())
}
/// Web UI router (served at root, outside /v1)

View File

@@ -0,0 +1,102 @@
use axum::{
extract::{Path, State},
routing::get,
Json, Router,
};
use crate::errors::AppResult;
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new().route("/resolve/:address", get(resolve_address))
}
/// Resolve an address to a fingerprint.
///
/// Accepts: ETH address (`0x...`), alias (`@name`), or raw fingerprint.
async fn resolve_address(
State(state): State<AppState>,
Path(address): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let addr = address.trim().to_lowercase();
// ETH address: 0x...
if addr.starts_with("0x") {
if let Some(fp_bytes) = state.db.eth_addresses.get(addr.as_bytes())? {
let fp = String::from_utf8_lossy(&fp_bytes).to_string();
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"type": "eth",
})));
}
// Try federation
if let Some(ref federation) = state.federation {
let url = format!("{}/v1/resolve/{}", federation.config.peer.url, addr);
if let Ok(resp) = federation.client.get(&url).send().await {
if resp.status().is_success() {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) {
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"type": "eth",
"federated": true,
})));
}
}
}
}
}
return Ok(Json(serde_json::json!({ "error": "address not found" })));
}
// Alias: @name
if addr.starts_with('@') {
let alias = &addr[1..];
// Try local alias resolution
let alias_key = format!("a:{}", alias);
if let Some(fp_bytes) = state.db.aliases.get(alias_key.as_bytes())? {
let fp = String::from_utf8_lossy(&fp_bytes).to_string();
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"type": "alias",
})));
}
// Try federation
if let Some(ref federation) = state.federation {
if let Some(fp) = federation.resolve_remote_alias(alias).await {
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"type": "alias",
"federated": true,
})));
}
}
return Ok(Json(serde_json::json!({ "error": "alias not found" })));
}
// Raw fingerprint: just echo back with optional reverse ETH lookup
let fp = addr
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect::<String>();
if fp.len() == 32 {
let rev_key = format!("rev:{}", fp);
let eth = state
.db
.eth_addresses
.get(rev_key.as_bytes())?
.map(|v| String::from_utf8_lossy(&v).to_string());
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"eth_address": eth,
"type": "fingerprint",
})));
}
Ok(Json(serde_json::json!({ "error": "unrecognized address format" })))
}

View File

@@ -171,6 +171,9 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
cursor: pointer; font-size: 14px; min-height: 40px; }
#send-btn:hover { background: #c73e54; }
.addr { color: #4fc3f7; cursor: pointer; text-decoration: underline; }
.addr:hover { color: #81d4fa; }
@media (max-width: 500px) {
.msg { font-size: 0.8em; }
#chat-header input { width: 180px; }
@@ -207,6 +210,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
<div id="chat" class="screen">
<div id="chat-header">
<span class="tag tag-fp" id="hdr-fp"></span>
<span class="tag" id="hdr-eth" style="background:#1a1a3e;color:#4fc3f7;font-size:0.8em;cursor:pointer" title=""></span>
<span>→</span>
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
<span class="tag-server" id="hdr-server"></span>
@@ -230,6 +234,7 @@ const $peerInput = document.getElementById('peer-input');
// ── State ──
let wasmIdentity = null; // WasmIdentity from WASM
let myFingerprint = '';
let myEthAddress = '';
let mySeedHex = '';
let sessions = {}; // peerFP -> { session: WasmSession, data: base64 }
let peerBundles = {}; // peerFP -> bundle bytes
@@ -298,6 +303,33 @@ function normFP(fp) {
return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
}
function makeAddressClickable(text) {
// Match fingerprint format: xxxx:xxxx:xxxx:xxxx... (at least 4 groups)
text = text.replace(/([0-9a-f]{4}(?::[0-9a-f]{4}){3,})/gi, function(match) {
const fp = match.replace(/:/g, '');
return '<span class="addr" data-addr="' + fp + '" title="Click to message">' + match + '</span>';
});
// Match ETH addresses: 0x followed by 40 hex chars
text = text.replace(/(0x[0-9a-fA-F]{40})/g, function(match) {
return '<span class="addr" data-addr="' + match + '" title="Click to message">' + match + '</span>';
});
return text;
}
function handleAddrClick(addr) {
const input = document.getElementById('msg-input');
if (input && input.value.trim().length > 0) {
navigator.clipboard.writeText(addr).then(() => {
addSys('Copied: ' + addr);
});
} else {
$peerInput.value = addr;
currentGroup = null;
localStorage.setItem('wz-peer', addr);
addSys('Peer set to ' + addr.slice(0,16) + '...');
}
}
// ── WASM-based crypto (same as CLI: X25519 + ChaCha20 + Double Ratchet) ──
async function initWasm() {
@@ -442,6 +474,43 @@ async function sendEncrypted(peerFP, plaintext) {
return msgId;
}
// URL deep links: /message/@alias, /message/0xABC, /group/#ops
function handleDeepLink() {
const path = window.location.pathname;
if (path.startsWith('/message/')) {
const target = decodeURIComponent(path.slice(9));
if (target) {
setTimeout(() => {
$peerInput.value = target;
if (target.startsWith('@')) {
fetch(SERVER + '/v1/alias/resolve/' + target.slice(1)).then(r => r.json()).then(data => {
if (!data.error) {
$peerInput.value = data.fingerprint;
currentGroup = null;
localStorage.setItem('wz-peer', data.fingerprint);
addSys('Deep link: peer set to ' + target + ' (' + data.fingerprint.slice(0,16) + '...)');
} else {
addSys('Deep link: unknown alias ' + target);
}
});
} else {
currentGroup = null;
localStorage.setItem('wz-peer', target);
addSys('Deep link: peer set to ' + target.slice(0,16) + '...');
}
}, 500);
}
} else if (path.startsWith('/group/')) {
let group = decodeURIComponent(path.slice(7));
if (group.startsWith('#')) group = group.slice(1);
if (group) {
setTimeout(() => {
groupSwitch(group);
}, 500);
}
}
}
function connectWebSocket() {
const fp = normFP(myFingerprint);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -454,6 +523,7 @@ function connectWebSocket() {
ws.onopen = () => {
dbg('WebSocket connected');
addSys('Real-time connection established');
handleDeepLink();
};
ws.onmessage = async (event) => {
@@ -669,7 +739,11 @@ function addMsg(from, text, isSelf, messageId) {
const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent';
receiptHtml = ' <span class="receipt" style="color:' + receiptColor(status) + '"> ' + receiptIndicator(status) + '</span>';
}
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + esc(from) + '</span>: ' + esc(text) + receiptHtml;
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + makeAddressClickable(esc(from)) + '</span>: ' + makeAddressClickable(esc(text)) + receiptHtml;
// Attach click handler for .addr spans
d.querySelectorAll('.addr').forEach(el => {
el.addEventListener('click', () => handleAddrClick(el.dataset.addr));
});
$messages.appendChild(d);
$messages.scrollTop = $messages.scrollHeight;
// Store reference to the receipt span so we can update it later
@@ -724,8 +798,21 @@ async function enterChat() {
await registerKey();
addSys('Identity loaded: ' + myFingerprint);
addSys('Key registered with server');
// Fetch ETH address from server
try {
const resolveResp = await fetch(SERVER + '/v1/resolve/' + normFP(myFingerprint));
const resolveData = await resolveResp.json();
if (resolveData.eth_address) {
myEthAddress = resolveData.eth_address;
addSys('ETH: ' + myEthAddress);
document.getElementById('hdr-eth').textContent = myEthAddress.slice(0, 10) + '...';
document.getElementById('hdr-eth').title = myEthAddress;
}
} catch(e) { dbg('ETH resolve failed:', e); }
addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above');
addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /file · /info');
addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info');
const savedPeer = localStorage.getItem('wz-peer');
if (savedPeer) $peerInput.value = savedPeer;
@@ -758,6 +845,22 @@ async function groupJoin(name) {
addSys('Joined group "' + name + '" (' + data.members + ' members)');
}
async function showGroupMembers(groupName) {
try {
const resp = await fetch(SERVER + '/v1/groups/' + groupName + '/members');
const data = await resp.json();
if (data.members && data.members.length > 0) {
const online = data.members.filter(m => m.online).length;
addSys('Members of #' + groupName + ' (' + online + '/' + data.members.length + ' online):');
for (const m of data.members) {
const status = m.online ? '\u{1F7E2}' : '\u26AA';
const label = m.alias ? '@' + m.alias : m.fingerprint.slice(0, 16) + '...';
addSys(' ' + status + ' ' + label + (m.is_creator ? ' *' : ''));
}
}
} catch(e) { dbg('Failed to fetch members:', e); }
}
async function groupSwitch(name) {
// Auto-join
await groupJoin(name);
@@ -767,6 +870,7 @@ async function groupSwitch(name) {
currentGroup = name;
$peerInput.value = '#' + name;
addSys('Switched to group "' + name + '" (' + data.count + ' members: ' + data.members.map(m => m.slice(0,8)).join(', ') + ')');
await showGroupMembers(name);
}
async function groupList() {
@@ -838,6 +942,7 @@ async function doSend() {
const aliasData = await aliasResp.json();
const aliasStr = aliasData.alias ? ' (@' + aliasData.alias + ')' : '';
addSys('Fingerprint: ' + myFingerprint + aliasStr);
if (myEthAddress) addSys('ETH Address: ' + myEthAddress);
return;
}
if (text === '/clear') { $messages.innerHTML = ''; return; }
@@ -871,6 +976,11 @@ async function doSend() {
} catch(e) { addSys('Bundle info error: ' + e); }
return;
}
if (text === '/seed') {
addSys('Your recovery seed (keep secret!):');
addSys(wasmIdentity.mnemonic());
return;
}
if (text === '/quit') { window.close(); return; }
if (text === '/glist') { await groupList(); return; }
if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; }
@@ -970,6 +1080,32 @@ async function doSend() {
}
return;
}
if (text === '/friend' || text === '/friends') {
try {
const resp = await fetch(SERVER + '/v1/friends', {
headers: { 'Authorization': 'Bearer ' + normFP(myFingerprint) }
});
const data = await resp.json();
if (data.data) {
addSys('Friends:');
addSys('(encrypted friend list stored on server -- use TUI for full friend management)');
} else {
addSys('No friends yet. Use /friend <address> to add.');
}
} catch(e) { addSys('Error: ' + e.message); }
return;
}
if (text.startsWith('/friend ')) {
const addr = text.slice(8).trim();
if (!addr) { addSys('Usage: /friend <address>'); return; }
addSys('Friend management requires TUI client (encrypted locally). Use warzone-client for full support.');
addSys('Hint: /friend in TUI to manage friends with E2E encryption.');
return;
}
if (text.startsWith('/unfriend ')) {
addSys('Friend management requires TUI client (encrypted locally).');
return;
}
if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; }
// Send to group or DM
@@ -1021,6 +1157,9 @@ document.getElementById('btn-show-recover').onclick = () => document.getElementB
document.getElementById('btn-recover').onclick = () => doRecover();
document.getElementById('btn-enter').onclick = () => enterChat();
document.getElementById('send-btn').onclick = () => doSend();
document.getElementById('hdr-eth').onclick = function() {
if (myEthAddress) navigator.clipboard.writeText(myEthAddress).then(() => addSys('Copied ETH address'));
};
document.getElementById('file-input').onchange = async function() {
if (!this.files.length) return;
const file = this.files[0];