diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 820c347..637eef9 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.25" +version = "0.0.27" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.25" +version = "0.0.27" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.25" +version = "0.0.27" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.25" +version = "0.0.27" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.25" +version = "0.0.27" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 3b39bbe..c30fc26 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.25" +version = "0.0.27" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index fe7c4be..3fc5219 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -541,6 +541,48 @@ impl App { } }; + // If peer is a bot alias, send plaintext (no E2E) + let is_bot_peer = { + let url = format!("{}/v1/alias/whois/{}", client.base_url, normfp(&peer)); + match client.client.get(&url).send().await { + Ok(resp) => resp.json::().await.ok() + .and_then(|d| d.get("alias").and_then(|a| a.as_str().map(|s| s.ends_with("bot") || s.ends_with("Bot") || s.ends_with("_bot")))) + .unwrap_or(false), + Err(_) => false, + } + }; + + if is_bot_peer { + let msg_id = uuid::Uuid::new_v4().to_string(); + let bot_msg = serde_json::json!({ + "type": "bot_message", + "id": msg_id, + "from": normfp(&self.our_fp), + "from_name": if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { self.our_eth.clone() }, + "text": text, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default(); + match client.send_message(&peer, Some(&self.our_fp), &msg_bytes).await { + Ok(_) => { + self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent); + let _ = db.touch_contact(&peer, None); + let _ = db.store_message(&peer, &self.our_fp, &text, true); + self.add_message(ChatLine { + sender: if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { format!("{}...", &self.our_eth[..self.our_eth.len().min(12)]) }, + text: text.clone(), + is_system: false, + is_self: true, + message_id: Some(msg_id), timestamp: Local::now(), + }); + } + Err(e) => { + self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } + } + return; + } + let msg_id = uuid::Uuid::new_v4().to_string(); let our_pub = identity.public_identity(); let mut ratchet = db.load_session(&peer_fp).ok().flatten(); diff --git a/warzone/crates/warzone-client/src/tui/draw.rs b/warzone/crates/warzone-client/src/tui/draw.rs index 94a8309..78a7445 100644 --- a/warzone/crates/warzone-client/src/tui/draw.rs +++ b/warzone/crates/warzone-client/src/tui/draw.rs @@ -216,15 +216,16 @@ mod tests { // 2. header_contains_fingerprint // ---------------------------------------------------------------- #[test] - fn header_contains_fingerprint() { + fn header_contains_identity() { let app = make_app(); let mut terminal = make_terminal(); terminal.draw(|f| app.draw(f)).unwrap(); let header = row_text(&terminal, 0); + // Header shows ETH address (if seed exists) or fingerprint assert!( - header.contains("aabbcc"), - "header should contain our fingerprint 'aabbcc', got: {header}" + header.contains("aabbcc") || header.contains("0x"), + "header should contain fingerprint or ETH address, got: {header}" ); } diff --git a/warzone/crates/warzone-client/src/tui/types.rs b/warzone/crates/warzone-client/src/tui/types.rs index 5782eab..f6265fa 100644 --- a/warzone/crates/warzone-client/src/tui/types.rs +++ b/warzone/crates/warzone-client/src/tui/types.rs @@ -163,7 +163,8 @@ mod tests { let msgs = app.messages.lock().unwrap(); assert!(msgs.len() >= 2); assert!(msgs[0].is_system); - assert!(msgs[0].text.contains("aabbcc")); + // First message shows ETH address (if seed exists) or fingerprint + assert!(msgs[0].text.contains("You are")); } #[test] diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 4981782..89dca0e 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.25" +version = "0.0.27" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/main.rs b/warzone/crates/warzone-server/src/main.rs index 418a120..55b5335 100644 --- a/warzone/crates/warzone-server/src/main.rs +++ b/warzone/crates/warzone-server/src/main.rs @@ -22,6 +22,10 @@ struct Cli { /// Federation config file (JSON). Enables server-to-server message relay. #[arg(short, long)] federation: Option, + + /// Enable bot API (disabled by default) + #[arg(long, default_value = "false")] + enable_bots: bool, } #[tokio::main] @@ -49,6 +53,36 @@ async fn main() -> anyhow::Result<()> { state.federation = Some(handle); } + // Enable bot API if requested + state.bots_enabled = cli.enable_bots; + if cli.enable_bots { + tracing::info!("Bot API enabled"); + + // Auto-create BotFather if it doesn't exist + let botfather_fp = "0000000000000000botfather00000000"; + let botfather_key = format!("bot_fp:{}", botfather_fp); + if state.db.tokens.get(botfather_key.as_bytes()).ok().flatten().is_none() { + let token = format!("botfather:{}", hex::encode(rand::random::<[u8; 16]>())); + let bot_info = serde_json::json!({ + "name": "BotFather", + "fingerprint": botfather_fp, + "token": token, + "owner": "system", + "e2e": false, + "created_at": chrono::Utc::now().timestamp(), + }); + let key = format!("bot:{}", token); + let _ = state.db.tokens.insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default()); + let _ = state.db.tokens.insert(botfather_key.as_bytes(), token.as_bytes()); + // Register alias + let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes()); + let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather"); + tracing::info!("BotFather created: @botfather (token: {}...)", &token[..20]); + } else { + tracing::info!("BotFather already exists"); + } + } + // Spawn federation outgoing WS connection if enabled if let Some(ref fed) = state.federation { let handle = fed.clone(); diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index 969d54f..1e9f4ab 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -49,12 +49,55 @@ pub fn routes() -> Router { /// Validate a bot token against the `tokens` sled tree. /// Returns the stored bot info JSON if the token is valid. +/// Returns `None` if bots are disabled on this server instance. fn validate_bot_token(state: &AppState, token: &str) -> Option { + if !state.bots_enabled { + return None; + } let key = format!("bot:{}", token); let ivec = state.db.tokens.get(key.as_bytes()).ok()??; serde_json::from_slice(&ivec).ok() } +/// Resolve a `chat_id` that may be a string fingerprint, ETH address, or numeric ID. +fn resolve_chat_id(state: &AppState, chat_id: &serde_json::Value) -> Option { + match chat_id { + serde_json::Value::String(s) => { + let clean: String = s + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + if clean.len() >= 16 { + Some(clean) + } else if s.starts_with("0x") { + // ETH address -- resolve + state + .db + .eth_addresses + .get(s.to_lowercase().as_bytes()) + .ok()? + .map(|v| String::from_utf8_lossy(&v).to_string()) + } else { + Some(s.clone()) + } + } + serde_json::Value::Number(n) => { + let num = n.as_i64().unwrap_or(0); + for item in state.db.keys.iter().flatten() { + let key_str = String::from_utf8_lossy(&item.0).to_string(); + if !key_str.contains(':') && key_str.len() == 32 { + if crate::routes::resolve::fp_to_numeric_id(&key_str) == num { + return Some(key_str); + } + } + } + None + } + _ => None, + } +} + /// Get the next update_id for a bot and atomically increment the counter. /// /// The counter is stored in the `tokens` tree under `bot_update_id:`. @@ -92,6 +135,91 @@ fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value) } } +// --------------------------------------------------------------------------- +// Webhook delivery (public -- called from state.rs deliver_or_queue) +// --------------------------------------------------------------------------- + +/// Check if a fingerprint belongs to a bot with a webhook, and deliver the message. +/// +/// Called from `AppState::deliver_or_queue` after queueing. Returns `true` if +/// the webhook accepted the update (HTTP 2xx), meaning the queued entry can be +/// removed. +pub async fn try_bot_webhook(state: &AppState, to_fp: &str, message: &[u8]) -> bool { + // 1. Check if this fingerprint is a bot + let token_key = format!("bot_fp:{}", to_fp); + let token = match state.db.tokens.get(token_key.as_bytes()) { + Ok(Some(v)) => String::from_utf8_lossy(&v).to_string(), + _ => return false, + }; + + // 2. Load bot info and check for webhook URL + let bot_info: serde_json::Value = + match state.db.tokens.get(format!("bot:{}", token).as_bytes()) { + Ok(Some(v)) => match serde_json::from_slice(&v) { + Ok(v) => v, + Err(_) => return false, + }, + _ => return false, + }; + + let webhook_url = match bot_info.get("webhook_url").and_then(|v| v.as_str()) { + Some(url) if !url.is_empty() => url.to_string(), + _ => return false, + }; + + // 3. Build Telegram-style update from the raw message bytes + let update = if let Ok(wire) = + bincode::deserialize::(message) + { + wire_message_to_update(&wire, message) + } else if let Ok(bot_msg) = serde_json::from_slice::(message) { + bot_json_to_update(&bot_msg) + } else { + None + }; + + let mut update = match update { + Some(u) => u, + None => return false, + }; + + // Assign a real update_id so the webhook consumer can track ordering + let uid = next_update_id(state, to_fp); + update["update_id"] = serde_json::json!(uid); + + // 4. POST to webhook URL with a short timeout + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap_or_default(); + + match client + .post(&webhook_url) + .header("Content-Type", "application/json") + .json(&update) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + tracing::info!("Webhook delivered to {} for bot {}", webhook_url, to_fp); + true + } + Ok(resp) => { + tracing::warn!( + "Webhook {} returned {} for bot {}", + webhook_url, + resp.status(), + to_fp + ); + false + } + Err(e) => { + tracing::warn!("Webhook {} failed for bot {}: {}", webhook_url, to_fp, e); + false + } + } +} + // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -100,6 +228,14 @@ fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value) struct RegisterBotRequest { name: String, fingerprint: String, + #[serde(default)] + bundle: Option>, // bincode PreKeyBundle for E2E bots + #[serde(default)] + eth_address: Option, + #[serde(default)] + e2e: Option, // true = E2E bot, false/None = plaintext bot + #[serde(default)] + owner: Option, // fingerprint of the bot creator } /// Register a bot and receive a token. @@ -113,6 +249,12 @@ async fn register_bot( State(state): State, Json(req): Json, ) -> AppResult> { + // TODO: In production, only @botfather should be able to register bots. + // For v1, direct registration is allowed for development. + if !state.bots_enabled { + return Ok(Json(serde_json::json!({"ok": false, "description": "Bot API is disabled on this server. Use a server with --enable-bots"}))); + } + let fp = req .fingerprint .chars() @@ -131,6 +273,8 @@ async fn register_bot( "name": req.name, "fingerprint": fp, "token": token, + "owner": req.owner.as_deref().unwrap_or(&fp), + "e2e": req.e2e.unwrap_or(false), "created_at": chrono::Utc::now().timestamp(), }); @@ -148,11 +292,29 @@ async fn register_bot( .tokens .insert(fp_key.as_bytes(), token.as_bytes())?; + // If E2E bot, register pre-key bundle (bot can receive encrypted messages) + if req.e2e.unwrap_or(false) { + if let Some(ref bundle_bytes) = req.bundle { + let _ = state.db.keys.insert(fp.as_bytes(), bundle_bytes.as_slice()); + let device_key = format!("device:{}:bot", fp); + let _ = state.db.keys.insert(device_key.as_bytes(), bundle_bytes.as_slice()); + tracing::info!("E2E bot: registered pre-key bundle for {}", fp); + } + } + + // Store ETH address mapping if provided + if let Some(ref eth) = req.eth_address { + let eth_lower = eth.to_lowercase(); + let _ = state.db.eth_addresses.insert(eth_lower.as_bytes(), fp.as_bytes()); + let _ = state.db.eth_addresses.insert(format!("rev:{}", fp).as_bytes(), eth_lower.as_bytes()); + } + tracing::info!( - "Bot registered: {} ({}) token={}...", + "Bot registered: {} ({}) token={}... e2e={}", req.name, fp, - &token[..token.len().min(20)] + &token[..token.len().min(20)], + req.e2e.unwrap_or(false), ); // Auto-register bot alias (name must end with Bot or _bot) @@ -174,6 +336,7 @@ async fn register_bot( "name": req.name, "fingerprint": fp, "alias": format!("@{}", bot_alias), + "e2e": req.e2e.unwrap_or(false), } }))) } @@ -184,15 +347,19 @@ async fn get_me( Path(token): Path, ) -> Json { 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"], - } - })), + Some(info) => { + let fp = info["fingerprint"].as_str().unwrap_or(""); + Json(serde_json::json!({ + "ok": true, + "result": { + "id": crate::routes::resolve::fp_to_numeric_id(fp), + "id_str": fp, + "is_bot": true, + "first_name": info["name"], + "username": info["name"], + } + })) + } None => Json(serde_json::json!({ "ok": false, "description": "invalid token", @@ -273,7 +440,7 @@ async fn get_updates( // Step 4: Long-poll if empty. if updates.is_empty() && timeout > 0 { - let wait = std::cmp::min(timeout, 30); + let wait = std::cmp::min(timeout, 50); // Poll in 1-second intervals so new messages are picked up promptly. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait); loop { @@ -346,16 +513,19 @@ fn wire_message_to_update( .. } => { let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); + let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, "from": { - "id": sender_fingerprint, + "id": numeric, + "id_str": sender_fingerprint, "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { - "id": sender_fingerprint, + "id": numeric, + "id_str": sender_fingerprint, "type": "private", }, "date": chrono::Utc::now().timestamp(), @@ -370,16 +540,19 @@ fn wire_message_to_update( .. } => { let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); + let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, "from": { - "id": sender_fingerprint, + "id": numeric, + "id_str": sender_fingerprint, "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { - "id": sender_fingerprint, + "id": numeric, + "id_str": sender_fingerprint, "type": "private", }, "date": chrono::Utc::now().timestamp(), @@ -394,51 +567,61 @@ fn wire_message_to_update( signal_type, payload, .. - } => Some(serde_json::json!({ - "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, - }, - } - })), + } => { + let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); + Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": numeric, + "id_str": sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": numeric, + "id_str": sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": format!("/call_{:?}", signal_type), + "call_signal": { + "type": format!("{:?}", signal_type), + "payload": payload, + }, + } + })) + } warzone_protocol::message::WireMessage::FileHeader { id, sender_fingerprint, filename, file_size, .. - } => Some(serde_json::json!({ - "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, - }, - } - })), + } => { + let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); + Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": numeric, + "id_str": sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": numeric, + "id_str": sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "document": { + "file_name": filename, + "file_size": file_size, + }, + } + })) + } // Skip receipts and other variants. warzone_protocol::message::WireMessage::Receipt { .. } => None, _ => None, @@ -449,32 +632,43 @@ fn wire_message_to_update( fn bot_json_to_update(bot_msg: &serde_json::Value) -> Option { let msg_type = bot_msg.get("type").and_then(|v| v.as_str())?; match msg_type { - "bot_message" => Some(serde_json::json!({ - "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(""), - } - })), - "callback_query" => Some(serde_json::json!({ - "callback_query": { - "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": false, - }, - "data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""), - "message": bot_msg.get("message"), - } - })), + "bot_message" => { + let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); + let numeric = crate::routes::resolve::fp_to_numeric_id(from_fp); + Some(serde_json::json!({ + "message": { + "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "from": { + "id": numeric, + "id_str": from_fp, + "is_bot": true, + }, + "chat": { + "id": numeric, + "id_str": from_fp, + "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(""), + } + })) + } + "callback_query" => { + let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); + let numeric = crate::routes::resolve::fp_to_numeric_id(from_fp); + Some(serde_json::json!({ + "callback_query": { + "id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "from": { + "id": numeric, + "id_str": from_fp, + "is_bot": false, + }, + "data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""), + "message": bot_msg.get("message"), + } + })) + } _ => None, } } @@ -504,7 +698,7 @@ fn collect_updates(state: &AppState, bot_fp: &str, limit: usize) -> Vec, @@ -533,12 +727,12 @@ async fn send_message( } }; - let to_fp = req - .chat_id - .chars() - .filter(|c| c.is_ascii_hexdigit()) - .collect::() - .to_lowercase(); + let to_fp = match resolve_chat_id(&state, &req.chat_id) { + Some(fp) => fp, + None => { + return Json(serde_json::json!({"ok": false, "description": "chat_id not found"})) + } + }; let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); let msg_id = uuid::Uuid::new_v4().to_string(); @@ -608,7 +802,7 @@ async fn answer_callback_query( #[derive(Deserialize)] struct EditMessageRequest { - chat_id: String, + chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID) message_id: String, text: String, #[serde(default)] @@ -626,12 +820,12 @@ async fn edit_message_text( None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), }; let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); - let to_fp = req - .chat_id - .chars() - .filter(|c| c.is_ascii_hexdigit()) - .collect::() - .to_lowercase(); + let to_fp = match resolve_chat_id(&state, &req.chat_id) { + Some(fp) => fp, + None => { + return Json(serde_json::json!({"ok": false, "description": "chat_id not found"})) + } + }; let edit_msg = serde_json::json!({ "type": "bot_edit", @@ -732,7 +926,7 @@ async fn get_webhook_info( #[derive(Deserialize)] struct SendDocumentRequest { - chat_id: String, + chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID) /// File path, URL, or file_id reference. In v1, the reference is stored /// and forwarded as-is without server-side file hosting. document: String, @@ -751,12 +945,12 @@ async fn send_document( None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), }; let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); - let to_fp = req - .chat_id - .chars() - .filter(|c| c.is_ascii_hexdigit()) - .collect::() - .to_lowercase(); + let to_fp = match resolve_chat_id(&state, &req.chat_id) { + Some(fp) => fp, + None => { + return Json(serde_json::json!({"ok": false, "description": "chat_id not found"})) + } + }; let msg_id = uuid::Uuid::new_v4().to_string(); let doc_msg = serde_json::json!({ diff --git a/warzone/crates/warzone-server/src/routes/mod.rs b/warzone/crates/warzone-server/src/routes/mod.rs index bfc9161..56e5df8 100644 --- a/warzone/crates/warzone-server/src/routes/mod.rs +++ b/warzone/crates/warzone-server/src/routes/mod.rs @@ -1,6 +1,6 @@ mod aliases; pub mod auth; -mod bot; +pub mod bot; mod calls; mod devices; mod federation; diff --git a/warzone/crates/warzone-server/src/routes/resolve.rs b/warzone/crates/warzone-server/src/routes/resolve.rs index 9408511..8d3f629 100644 --- a/warzone/crates/warzone-server/src/routes/resolve.rs +++ b/warzone/crates/warzone-server/src/routes/resolve.rs @@ -7,6 +7,20 @@ use axum::{ use crate::errors::AppResult; use crate::state::AppState; +/// Convert a fingerprint hex string to a stable i64 ID (for Telegram compatibility). +/// Uses first 8 bytes of the fingerprint as a positive i64. +pub fn fp_to_numeric_id(fp: &str) -> i64 { + let clean: String = fp.chars().filter(|c| c.is_ascii_hexdigit()).take(16).collect(); + let bytes = hex::decode(&clean).unwrap_or_default(); + if bytes.len() >= 8 { + let mut arr = [0u8; 8]; + arr.copy_from_slice(&bytes[..8]); + i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF // ensure positive + } else { + 0 + } +} + pub fn routes() -> Router { Router::new().route("/resolve/:address", get(resolve_address)) } @@ -27,6 +41,7 @@ async fn resolve_address( return Ok(Json(serde_json::json!({ "address": address, "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), "type": "eth", }))); } @@ -40,6 +55,7 @@ async fn resolve_address( return Ok(Json(serde_json::json!({ "address": address, "fingerprint": fp, + "numeric_id": fp_to_numeric_id(fp), "type": "eth", "federated": true, }))); @@ -61,6 +77,7 @@ async fn resolve_address( return Ok(Json(serde_json::json!({ "address": address, "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), "type": "alias", }))); } @@ -70,6 +87,7 @@ async fn resolve_address( return Ok(Json(serde_json::json!({ "address": address, "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), "type": "alias", "federated": true, }))); @@ -93,6 +111,7 @@ async fn resolve_address( return Ok(Json(serde_json::json!({ "address": address, "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), "eth_address": eth, "type": "fingerprint", }))); diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 82a89f0..89b25db 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v7'; +const CACHE = 'wz-v9'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -241,7 +241,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.25'; +const VERSION = '0.0.27'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -547,7 +547,8 @@ function connectWebSocket() { msgText += '\\n'; } } - addMsg('@' + botName, msgText, false); + const useHtml = json.parse_mode === 'HTML'; + addMsg('@' + botName, msgText, false, null, useHtml); lastDmPeer = json.from ? normFP(json.from) : ''; return; } @@ -693,7 +694,8 @@ async function handleIncomingMessage(bytes) { msgText += '\\n'; } } - addMsg('@' + botName, msgText, false); + const useHtml = json.parse_mode === 'HTML'; + addMsg('@' + botName, msgText, false, null, useHtml); lastDmPeer = json.from ? normFP(json.from) : ''; return; } @@ -801,7 +803,7 @@ function formatSize(n) { return (n/1048576).toFixed(1) + ' MB'; } -function addMsg(from, text, isSelf, messageId) { +function addMsg(from, text, isSelf, messageId, rawHtml) { const d = document.createElement('div'); d.className = 'msg'; const color = isSelf ? '#4ade80' : peerColor(from); @@ -811,7 +813,8 @@ function addMsg(from, text, isSelf, messageId) { const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent'; receiptHtml = ' ' + receiptIndicator(status) + ''; } - d.innerHTML = '' + ts() + ' ' + lock + '' + makeAddressClickable(esc(from)) + ': ' + makeAddressClickable(esc(text)) + receiptHtml; + const bodyHtml = rawHtml ? text : makeAddressClickable(esc(text)); + d.innerHTML = '' + ts() + ' ' + lock + '' + makeAddressClickable(esc(from)) + ': ' + bodyHtml + receiptHtml; // Attach click handler for .addr spans d.querySelectorAll('.addr').forEach(el => { el.addEventListener('click', () => handleAddrClick(el.dataset.addr)); @@ -1202,6 +1205,22 @@ async function doSend() { localStorage.setItem('wz-peer', $peerInput.value.trim()); + // Check if peer is a bot — send plaintext instead of E2E + let isBotPeer = false; + try { + const wr = await fetch(SERVER + '/v1/alias/whois/' + normFP(peer)); + const wd = await wr.json(); + if (wd.alias && (wd.alias.endsWith('bot') || wd.alias.endsWith('Bot') || wd.alias.endsWith('_bot'))) isBotPeer = true; + } catch(e) {} + + if (isBotPeer) { + const msgId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(); + const botMsg = {type:'bot_message',id:msgId,from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:text,timestamp:Math.floor(Date.now()/1000)}; + await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:normFP(peer),from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(botMsg)))})}); + addMsg((myEthAddress ? myEthAddress.slice(0,12)+'...' : myFingerprint.slice(0,19)), text, true, msgId); + return; + } + try { const msgId = await sendEncrypted(peer, text); sentMsgReceipts[msgId] = { status: 'sent', el: null }; diff --git a/warzone/crates/warzone-server/src/state.rs b/warzone/crates/warzone-server/src/state.rs index 287fb70..d12ec90 100644 --- a/warzone/crates/warzone-server/src/state.rs +++ b/warzone/crates/warzone-server/src/state.rs @@ -88,6 +88,7 @@ pub struct AppState { pub dedup: DedupTracker, pub active_calls: Arc>>, pub federation: Option, + pub bots_enabled: bool, } impl AppState { @@ -99,6 +100,7 @@ impl AppState { dedup: DedupTracker::new(), active_calls: Arc::new(Mutex::new(HashMap::new())), federation: None, + bots_enabled: false, }) } @@ -188,6 +190,21 @@ impl AppState { // 3. Queue in local DB let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4()); let _ = self.db.messages.insert(key.as_bytes(), message); + + // 4. Try bot webhook delivery (async, does not block the caller) + { + let state = self.clone(); + let fp = to_fp.to_string(); + let queue_key = key.clone(); + let msg = message.to_vec(); + tokio::spawn(async move { + if crate::routes::bot::try_bot_webhook(&state, &fp, &msg).await { + // Webhook accepted -- remove from offline queue + let _ = state.db.messages.remove(queue_key.as_bytes()); + } + }); + } + false } diff --git a/warzone/tools/README.md b/warzone/tools/README.md new file mode 100644 index 0000000..a265bfc --- /dev/null +++ b/warzone/tools/README.md @@ -0,0 +1,38 @@ +# featherChat Bot Tools + +## bot-bridge.py + +Proxy server that makes featherChat compatible with Telegram bot libraries. + +### Quick Start + +```bash +# 1. Register a bot on featherChat +curl -X POST http://server:7700/v1/bot/register \ + -H 'Content-Type: application/json' \ + -d '{"name":"MyBot","fingerprint":"aabbccddaabbccddaabbccddaabbccdd"}' + +# 2. Start the bridge +python3 tools/bot-bridge.py --server http://server:7700 --token YOUR_TOKEN --port 8081 + +# 3. Point your TG bot at the bridge +# Python (python-telegram-bot): +# bot = Bot(token="TOKEN", base_url="http://localhost:8081/botTOKEN") +# Node (Telegraf): +# const bot = new Telegraf("TOKEN", { telegram: { apiRoot: "http://localhost:8081" } }) +``` + +### What it does + +- Translates Telegram API calls to featherChat Bot API +- Converts numeric chat_id <-> fingerprint hex strings +- Proxies getUpdates long-polling +- Passes through sendMessage, editMessageText, etc. + +### Future: E2E Mode + +When E2E bot support is complete, the bridge will: +- Hold the bot's seed/keypair +- Decrypt incoming E2E messages before forwarding to the TG bot +- Encrypt outgoing messages with the user's ratchet session +- The TG bot sees plaintext; the server sees only ciphertext diff --git a/warzone/tools/bot-bridge.py b/warzone/tools/bot-bridge.py new file mode 100755 index 0000000..9741f94 --- /dev/null +++ b/warzone/tools/bot-bridge.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +featherChat E2E Bot Bridge + +Runs a local Telegram-compatible API server that proxies to featherChat. +Your Telegram bot connects to this bridge instead of api.telegram.org. + +Usage: + python bot-bridge.py --server http://featherchat:7700 --token YOUR_BOT_TOKEN --port 8081 + +Your bot code: + # Instead of: bot = Bot(token="...", base_url="https://api.telegram.org") + # Use: bot = Bot(token="...", base_url="http://localhost:8081") + +Architecture: + [TG Bot] <--HTTP--> [Bridge :8081] <--HTTP--> [featherChat :7700] + +The bridge translates between Telegram API format and featherChat Bot API, +handling the chat_id type differences and other incompatibilities. +""" + +import argparse +import json +import sys +import time +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse +from urllib.request import Request, urlopen +from urllib.error import URLError + +class BotBridgeHandler(BaseHTTPRequestHandler): + server_url = "" + bot_token = "" + + def log_message(self, format, *args): + print(f"[bridge] {args[0]}" if args else "") + + def do_GET(self): + self._proxy() + + def do_POST(self): + self._proxy() + + def _proxy(self): + # Extract the method from URL: /bot/methodName + path = self.path + + # Strip /bot/ prefix if present (TG libraries send this) + if path.startswith(f'/bot{self.bot_token}/'): + method = path[len(f'/bot{self.bot_token}/'):] + elif path.startswith('/bot'): + # Library might send a different token format + parts = path.split('/', 3) + method = parts[3] if len(parts) > 3 else parts[-1] + else: + method = path.lstrip('/') + + # Read request body + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length) if content_length > 0 else b'' + + # Transform request for featherChat + fc_url = f"{self.server_url}/v1/bot/{self.bot_token}/{method}" + + # Transform body if needed + if body and method == 'sendMessage': + body = self._transform_send_message(body) + + try: + req = Request(fc_url, data=body if body else None, method=self.command) + req.add_header('Content-Type', 'application/json') + + with urlopen(req, timeout=60) as resp: + response_body = resp.read() + + # Transform response + if method == 'getUpdates': + response_body = self._transform_updates(response_body) + + self.send_response(resp.status) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(response_body) + + except URLError as e: + self.send_response(502) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({ + "ok": False, + "description": f"Bridge error: {e}" + }).encode()) + + def _transform_send_message(self, body): + """Transform sendMessage: convert numeric chat_id to string if needed.""" + try: + data = json.loads(body) + # chat_id: featherChat accepts both string and number now + # No transformation needed -- pass through + return json.dumps(data).encode() + except: + return body + + def _transform_updates(self, body): + """Transform getUpdates response: ensure chat_id is integer for TG libs.""" + try: + data = json.loads(body) + if data.get('ok') and data.get('result'): + for update in data['result']: + msg = update.get('message', {}) + # Convert string IDs to numeric for TG library compatibility + if 'from' in msg and isinstance(msg['from'].get('id'), str): + fp = msg['from']['id'] + msg['from']['id_str'] = fp + msg['from']['id'] = _fp_to_numeric(fp) + if 'chat' in msg and isinstance(msg['chat'].get('id'), str): + fp = msg['chat']['id'] + msg['chat']['id_str'] = fp + msg['chat']['id'] = _fp_to_numeric(fp) + return json.dumps(data).encode() + except: + return body + + +def _fp_to_numeric(fp: str) -> int: + """Convert fingerprint hex string to positive i64 (same as server's fp_to_numeric_id).""" + clean = ''.join(c for c in fp if c in '0123456789abcdefABCDEF')[:16] + if len(clean) >= 16: + return int(clean, 16) & 0x7FFFFFFFFFFFFFFF + return 0 + + +def main(): + parser = argparse.ArgumentParser(description='featherChat E2E Bot Bridge') + parser.add_argument('--server', required=True, help='featherChat server URL (e.g., http://localhost:7700)') + parser.add_argument('--token', required=True, help='Bot token from /v1/bot/register') + parser.add_argument('--port', type=int, default=8081, help='Local port for TG-compatible API (default: 8081)') + args = parser.parse_args() + + BotBridgeHandler.server_url = args.server.rstrip('/') + BotBridgeHandler.bot_token = args.token + + # Verify bot token + try: + req = Request(f"{args.server}/v1/bot/{args.token}/getMe") + with urlopen(req, timeout=5) as resp: + data = json.loads(resp.read()) + if not data.get('ok'): + print(f"ERROR: Invalid bot token") + sys.exit(1) + bot_name = data['result'].get('first_name', '?') + print(f"Bot: {bot_name}") + except Exception as e: + print(f"ERROR: Cannot reach server: {e}") + sys.exit(1) + + server = HTTPServer(('127.0.0.1', args.port), BotBridgeHandler) + print(f"Bridge running on http://127.0.0.1:{args.port}") + print(f"Proxying to {args.server}") + print(f"") + print(f"Configure your bot:") + print(f" base_url = 'http://127.0.0.1:{args.port}/bot{args.token}'") + print(f"") + print(f"Example (python-telegram-bot):") + print(f" from telegram import Bot") + print(f" bot = Bot(token='{args.token}', base_url='http://127.0.0.1:{args.port}/bot{args.token}')") + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nBridge stopped.") + + +if __name__ == '__main__': + main()