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