//! Telegram Bot API compatibility layer. //! //! Bots register with a fingerprint and get a token. //! They use `/bot/getUpdates` and `/bot/sendMessage` //! to communicate with featherChat users. //! //! Supported endpoints (Telegram-compatible): //! - `POST /bot/register` (featherChat-specific) //! - `GET /bot/:token/getMe` //! - `POST /bot/:token/getUpdates` //! - `POST /bot/:token/sendMessage` //! - `POST /bot/:token/answerCallbackQuery` //! - `POST /bot/:token/editMessageText` //! - `POST /bot/:token/setWebhook` //! - `POST /bot/:token/deleteWebhook` //! - `GET /bot/:token/getWebhookInfo` //! - `POST /bot/:token/sendDocument` 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 { Router::new() .route("/bot/list", get(list_system_bots)) .route("/bot/register", post(register_bot)) .route("/bot/:token/getMe", get(get_me)) .route("/bot/:token/getUpdates", post(get_updates)) .route("/bot/:token/sendMessage", post(send_message)) .route("/bot/:token/answerCallbackQuery", post(answer_callback_query)) .route("/bot/:token/editMessageText", post(edit_message_text)) .route("/bot/:token/setWebhook", post(set_webhook)) .route("/bot/:token/deleteWebhook", post(delete_webhook)) .route("/bot/:token/getWebhookInfo", get(get_webhook_info)) .route("/bot/:token/sendDocument", post(send_document_flexible)) } // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // System bot list (public, no auth needed) // --------------------------------------------------------------------------- /// GET /v1/bot/list — returns system bots for welcome screen. async fn list_system_bots( State(state): State, ) -> Json { let bots = state.db.tokens.get(b"system:bot_list") .ok().flatten() .and_then(|v| serde_json::from_slice::>(&v).ok()) .unwrap_or_default(); Json(serde_json::json!({ "ok": true, "bots": bots })) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// 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); // Look up per-bot numeric ID reverse mapping let numid_key = format!("numid:{}", num); if let Some(fp_bytes) = state.db.tokens.get(numid_key.as_bytes()).ok().flatten() { return Some(String::from_utf8_lossy(&fp_bytes).to_string()); } // Fallback: scan all keys with global hash (legacy) 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:`. fn next_update_id(state: &AppState, bot_fp: &str) -> u64 { let key = format!("bot_update_id:{}", bot_fp); let current = state .db .tokens .get(key.as_bytes()) .ok() .flatten() .and_then(|v| { let bytes: [u8; 8] = v.as_ref().try_into().ok()?; Some(u64::from_be_bytes(bytes)) }) .unwrap_or(1); let next = current + 1; let _ = state .db .tokens .insert(key.as_bytes(), &next.to_be_bytes()); current } /// Store an update in the bot's persistent queue with an assigned update_id. /// /// Key format: `bot_queue::` to ensure lexicographic ordering. fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value) { let uid = next_update_id(state, bot_fp); let queue_key = format!("bot_queue:{}:{:020}", bot_fp, uid); let mut enriched = update; enriched["update_id"] = serde_json::json!(uid); if let Ok(bytes) = serde_json::to_vec(&enriched) { let _ = state.db.messages.insert(queue_key.as_bytes(), bytes); } } // --------------------------------------------------------------------------- // 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(state, &wire, message, &token) } else if let Ok(bot_msg) = serde_json::from_slice::(message) { bot_json_to_update(state, &bot_msg, &token) } 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 // --------------------------------------------------------------------------- #[derive(Deserialize)] 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 #[serde(default)] botfather_token: Option, } /// Register a bot and receive a token. /// /// `POST /v1/bot/register` /// /// ```json /// { "name": "mybot", "fingerprint": "aabbccdd..." } /// ``` async fn register_bot( State(state): State, Json(req): Json, ) -> AppResult> { 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"}))); } // Only BotFather can register bots // Require botfather_token field matching the stored BotFather token if let Some(ref bf_token) = req.botfather_token { let botfather_fp = "00000000000000000b0ffa00e000000f"; let bf_key = format!("bot_fp:{}", botfather_fp); let stored_token = state.db.tokens.get(bf_key.as_bytes()) .ok().flatten() .map(|v| String::from_utf8_lossy(&v).to_string()); if stored_token.as_deref() != Some(bf_token.as_str()) { return Ok(Json(serde_json::json!({"ok": false, "description": "invalid BotFather token"}))); } } else { return Ok(Json(serde_json::json!({"ok": false, "description": "bot registration requires BotFather authorization. Message @botfather to create a bot."}))); } let fp = req .fingerprint .chars() .filter(|c| c.is_ascii_hexdigit()) .collect::() .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, "owner": req.owner.as_deref().unwrap_or(&fp), "e2e": req.e2e.unwrap_or(false), "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())?; // 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={}... e2e={}", req.name, fp, &token[..token.len().min(20)], req.e2e.unwrap_or(false), ); // Auto-register bot alias (name must end with Bot or _bot) let bot_alias = if req.name.ends_with("Bot") || req.name.ends_with("_bot") || req.name.ends_with("bot") { req.name.to_lowercase() } else { format!("{}_bot", req.name.to_lowercase()) }; let alias_key = format!("a:{}", bot_alias); let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes()); let fp_key = format!("fp:{}", fp); let _ = state.db.aliases.insert(fp_key.as_bytes(), bot_alias.as_bytes()); tracing::info!("Bot alias @{} registered for {}", bot_alias, fp); Ok(Json(serde_json::json!({ "ok": true, "result": { "token": token, "name": req.name, "fingerprint": fp, "alias": format!("@{}", bot_alias), "e2e": req.e2e.unwrap_or(false), } }))) } /// `GET /bot/:token/getMe` -- returns bot info (Telegram-compatible shape). async fn get_me( State(state): State, Path(token): Path, ) -> Json { match validate_bot_token(&state, &token) { 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_for_bot(fp, &token), "is_bot": true, "first_name": info["name"], "username": info["name"], } })) } None => Json(serde_json::json!({ "ok": false, "description": "invalid token", })), } } // --------------------------------------------------------------------------- // getUpdates — with offset/limit/timeout support // --------------------------------------------------------------------------- #[derive(Deserialize)] struct GetUpdatesParams { #[serde(default)] offset: Option, #[serde(default)] limit: Option, #[serde(default)] timeout: Option, } /// `POST /bot/:token/getUpdates` -- long-poll for messages sent to this bot. /// /// Migrates raw queue entries (from `queue::*`) into the persistent /// bot update queue (`bot_queue::`) on each call, then /// returns updates filtered by `offset` and capped by `limit`. /// /// When `offset` is provided, all updates with `update_id < offset` are /// acknowledged (deleted), matching Telegram Bot API semantics. async fn get_updates( State(state): State, Path(token): Path, Json(params): Json, ) -> Json { 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 limit = params.limit.unwrap_or(100).min(100); let timeout = params.timeout.unwrap_or(0); // Step 1: Migrate raw queue entries into the persistent bot_queue. migrate_raw_queue(&state, bot_fp, &token); // Step 2: If offset is provided, delete all acknowledged updates (update_id < offset). if let Some(offset) = params.offset { let prefix = format!("bot_queue:{}:", bot_fp); let mut to_delete = Vec::new(); for item in state.db.messages.scan_prefix(prefix.as_bytes()) { let (key, value) = match item { Ok(pair) => pair, Err(_) => continue, }; if let Ok(update) = serde_json::from_slice::(&value) { let uid = update["update_id"].as_i64().unwrap_or(0); if uid < offset { to_delete.push(key); } else { // Keys are ordered, so once we pass offset we can stop scanning // for deletions. break; } } } for key in &to_delete { let _ = state.db.messages.remove(key); } } // Step 3: Collect remaining updates up to `limit`. let updates = collect_updates(&state, bot_fp, limit); // Step 4: Long-poll if empty. Minimum 1s delay to prevent tight-loop abuse. if updates.is_empty() { let timeout = if timeout == 0 { 1 } else { timeout }; // force min 1s 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 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; // Check for newly arrived raw messages. migrate_raw_queue(&state, bot_fp, &token); let polled = collect_updates(&state, bot_fp, limit); if !polled.is_empty() { return Json(serde_json::json!({ "ok": true, "result": polled, })); } if tokio::time::Instant::now() >= deadline { break; } } } Json(serde_json::json!({ "ok": true, "result": updates, })) } /// Migrate raw `queue::*` entries into `bot_queue::`. /// /// Each raw entry is converted into a Telegram-style Update JSON object, assigned /// a persistent update_id, and stored. The original raw key is deleted. fn migrate_raw_queue(state: &AppState, bot_fp: &str, bot_token: &str) { let prefix = format!("queue:{}", bot_fp); let mut keys_to_delete = Vec::new(); for item in state.db.messages.scan_prefix(prefix.as_bytes()) { let (key, value) = match item { Ok(pair) => pair, Err(_) => continue, }; let update = if let Ok(wire) = bincode::deserialize::(&value) { wire_message_to_update(state, &wire, &value, bot_token) } else if let Ok(bot_msg) = serde_json::from_slice::(&value) { bot_json_to_update(state, &bot_msg, bot_token) } else { None }; if let Some(upd) = update { enqueue_bot_update(state, bot_fp, upd); } keys_to_delete.push(key); } for key in &keys_to_delete { let _ = state.db.messages.remove(key); } } /// Store a per-bot numeric ID → fingerprint reverse mapping. fn store_numid_mapping(state: &AppState, numeric_id: i64, fingerprint: &str) { let key = format!("numid:{}", numeric_id); let _ = state.db.tokens.insert(key.as_bytes(), fingerprint.as_bytes()); } /// Convert a `WireMessage` into a Telegram-style update JSON (without update_id). fn wire_message_to_update( state: &AppState, wire: &warzone_protocol::message::WireMessage, raw_bytes: &[u8], bot_token: &str, ) -> Option { match wire { warzone_protocol::message::WireMessage::Message { id, sender_fingerprint, .. } => { let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, "type": "private", }, "date": chrono::Utc::now().timestamp(), "text": null, "raw_encrypted": raw_b64, } })) } warzone_protocol::message::WireMessage::KeyExchange { id, sender_fingerprint, .. } => { let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, "type": "private", }, "date": chrono::Utc::now().timestamp(), "text": null, "raw_encrypted": raw_b64, } })) } warzone_protocol::message::WireMessage::CallSignal { id, sender_fingerprint, signal_type, payload, .. } => { let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, "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, .. } => { let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, "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, } } /// Convert a plaintext bot JSON message into a Telegram-style update (without update_id). fn bot_json_to_update(state: &AppState, bot_msg: &serde_json::Value, bot_token: &str) -> Option { let msg_type = bot_msg.get("type").and_then(|v| v.as_str())?; match msg_type { "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_for_bot(from_fp, bot_token); store_numid_mapping(state, numeric, from_fp); Some(serde_json::json!({ "message": { "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), "from": { "id": numeric, "is_bot": true, }, "chat": { "id": numeric, "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_for_bot(from_fp, bot_token); store_numid_mapping(state, numeric, from_fp); Some(serde_json::json!({ "callback_query": { "id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), "from": { "id": numeric, "is_bot": false, }, "data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""), "message": bot_msg.get("message"), } })) } _ => None, } } /// Collect up to `limit` updates from `bot_queue::*`, preserving order. fn collect_updates(state: &AppState, bot_fp: &str, limit: usize) -> Vec { let prefix = format!("bot_queue:{}:", bot_fp); let mut updates = Vec::new(); for item in state.db.messages.scan_prefix(prefix.as_bytes()) { let (_key, value) = match item { Ok(pair) => pair, Err(_) => continue, }; if let Ok(update) = serde_json::from_slice::(&value) { updates.push(update); if updates.len() >= limit { break; } } } updates } // --------------------------------------------------------------------------- // sendMessage — enhanced with parse_mode, reply_to, reply_markup // --------------------------------------------------------------------------- #[derive(Deserialize)] struct SendMessageRequest { chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID) text: String, #[serde(default)] parse_mode: Option, #[serde(default)] reply_to_message_id: Option, #[serde(default)] reply_markup: Option, } /// `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, Path(token): Path, Json(req): Json, ) -> Json { 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 = 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(); let bot_msg = serde_json::json!({ "type": "bot_message", "id": msg_id, "from": bot_fp, "from_name": bot_info["name"], "text": req.text, "parse_mode": req.parse_mode, "reply_to_message_id": req.reply_to_message_id, "reply_markup": req.reply_markup, "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, } })) } // --------------------------------------------------------------------------- // answerCallbackQuery // --------------------------------------------------------------------------- #[derive(Deserialize)] struct AnswerCallbackRequest { callback_query_id: String, #[serde(default)] text: Option, #[serde(default)] show_alert: Option, } /// `POST /bot/:token/answerCallbackQuery` -- acknowledge a callback query. /// /// In v1 this is a no-op acknowledgement; no popup is delivered to the client. async fn answer_callback_query( State(state): State, Path(token): Path, Json(req): Json, ) -> Json { if validate_bot_token(&state, &token).is_none() { return Json(serde_json::json!({"ok": false, "description": "invalid token"})); } tracing::debug!( "answerCallbackQuery id={} text={:?} alert={:?}", req.callback_query_id, req.text, req.show_alert, ); Json(serde_json::json!({"ok": true, "result": true})) } // --------------------------------------------------------------------------- // editMessageText // --------------------------------------------------------------------------- #[derive(Deserialize)] struct EditMessageRequest { chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID) message_id: String, text: String, #[serde(default)] reply_markup: Option, } /// `POST /bot/:token/editMessageText` -- edit a previously sent message. async fn edit_message_text( State(state): State, Path(token): Path, Json(req): Json, ) -> Json { let bot_info = match validate_bot_token(&state, &token) { Some(i) => i, 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 = 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", "id": req.message_id, "from": bot_fp, "text": req.text, "reply_markup": req.reply_markup, "timestamp": chrono::Utc::now().timestamp(), }); let msg_bytes = serde_json::to_vec(&edit_msg).unwrap_or_default(); state.deliver_or_queue(&to_fp, &msg_bytes).await; Json(serde_json::json!({ "ok": true, "result": { "message_id": req.message_id, "chat": {"id": to_fp}, "text": req.text, "date": chrono::Utc::now().timestamp(), } })) } // --------------------------------------------------------------------------- // setWebhook / deleteWebhook / getWebhookInfo // --------------------------------------------------------------------------- #[derive(Deserialize)] struct SetWebhookRequest { url: String, } /// `POST /bot/:token/setWebhook` -- register a webhook URL for push delivery. async fn set_webhook( State(state): State, Path(token): Path, Json(req): Json, ) -> Json { let mut bot_info = match validate_bot_token(&state, &token) { Some(i) => i, None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), }; bot_info["webhook_url"] = serde_json::json!(req.url); let key = format!("bot:{}", token); let _ = state .db .tokens .insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default()); tracing::info!("Bot webhook set: {}", req.url); Json(serde_json::json!({"ok": true, "result": true, "description": "Webhook was set"})) } /// `POST /bot/:token/deleteWebhook` -- remove a previously set webhook. async fn delete_webhook( State(state): State, Path(token): Path, ) -> Json { let mut bot_info = match validate_bot_token(&state, &token) { Some(i) => i, None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), }; bot_info.as_object_mut().map(|o| o.remove("webhook_url")); let key = format!("bot:{}", token); let _ = state .db .tokens .insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default()); Json(serde_json::json!({"ok": true, "result": true, "description": "Webhook was deleted"})) } /// `GET /bot/:token/getWebhookInfo` -- return current webhook configuration. async fn get_webhook_info( State(state): State, Path(token): Path, ) -> Json { match validate_bot_token(&state, &token) { Some(info) => { let url = info .get("webhook_url") .and_then(|v| v.as_str()) .unwrap_or(""); Json(serde_json::json!({ "ok": true, "result": { "url": url, "has_custom_certificate": false, "pending_update_count": 0, } })) } None => Json(serde_json::json!({"ok": false, "description": "invalid token"})), } } // --------------------------------------------------------------------------- // sendDocument — accepts both JSON and multipart/form-data // --------------------------------------------------------------------------- /// `POST /bot/:token/sendDocument` -- send a document reference to a user. /// /// Accepts both `application/json` and `multipart/form-data` content types /// so Telegram bot libraries that upload files via multipart work out of the box. async fn send_document_flexible( State(state): State, Path(token): Path, headers: axum::http::HeaderMap, body: axum::body::Bytes, ) -> Json { let bot_info = match validate_bot_token(&state, &token) { Some(i) => i, None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), }; let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); let bot_name = bot_info["name"].as_str().unwrap_or("bot"); let content_type = headers .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); let (chat_id_val, document, caption) = if content_type.contains("multipart") { // Parse multipart fields from raw bytes (simplified text-field extraction). let body_str = String::from_utf8_lossy(&body); let mut chat_id = String::new(); let mut doc = String::new(); let mut cap = String::new(); // Split on boundary markers (lines starting with --) for part in body_str.split("------") { if part.contains("name=\"chat_id\"") { if let Some(val) = part.split("\r\n\r\n").nth(1) { chat_id = val.trim().to_string(); } } if part.contains("name=\"document\"") { if let Some(val) = part.split("\r\n\r\n").nth(1) { doc = val.trim().to_string(); } } if part.contains("name=\"caption\"") { if let Some(val) = part.split("\r\n\r\n").nth(1) { cap = val.trim().to_string(); } } } ( serde_json::Value::String(chat_id), doc, if cap.is_empty() { None } else { Some(cap) }, ) } else { // JSON body match serde_json::from_slice::(&body) { Ok(json) => { let chat_id = json .get("chat_id") .cloned() .unwrap_or(serde_json::Value::String(String::new())); let doc = json .get("document") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let cap = json .get("caption") .and_then(|v| v.as_str()) .map(String::from); (chat_id, doc, cap) } Err(e) => { return Json( serde_json::json!({"ok": false, "description": format!("invalid body: {}", e)}), ) } } }; let to_fp = match resolve_chat_id(&state, &chat_id_val) { Some(fp) => fp, None => { return Json(serde_json::json!({"ok": false, "description": "invalid chat_id"})) } }; let msg_id = uuid::Uuid::new_v4().to_string(); let doc_msg = serde_json::json!({ "type": "bot_document", "id": msg_id, "from": bot_fp, "from_name": bot_name, "document": document, "caption": caption, "timestamp": chrono::Utc::now().timestamp(), }); let msg_bytes = serde_json::to_vec(&doc_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}, "document": {"file_name": document}, "caption": caption, "delivered": delivered, } })) }