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,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,
|
||||
})
|
||||
}
|
||||
|
||||
390
warzone/crates/warzone-server/src/routes/bot.rs
Normal file
390
warzone/crates/warzone-server/src/routes/bot.rs
Normal 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,
|
||||
}
|
||||
}))
|
||||
}
|
||||
54
warzone/crates/warzone-server/src/routes/friends.rs
Normal file
54
warzone/crates/warzone-server/src/routes/friends.rs
Normal 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 })))
|
||||
}
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
102
warzone/crates/warzone-server/src/routes/resolve.rs
Normal file
102
warzone/crates/warzone-server/src/routes/resolve.rs
Normal 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" })))
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user