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

@@ -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" })))
}