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,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,
}
}))
}