From fcbf2d58590bdabbb9a60bdf9bde39ab7d1ea3b5 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 07:50:14 +0400 Subject: [PATCH] feat: complete Telegram-compatible Bot API + bot dev guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot API (routes/bot.rs — full rewrite): - getUpdates: persistent update_id counter, offset acknowledgement, limit (max 100), long-poll up to 30s with 1s intervals - sendMessage: parse_mode, reply_to_message_id, reply_markup (inline keyboards) - answerCallbackQuery: acknowledge button clicks - editMessageText: update sent messages - setWebhook / deleteWebhook / getWebhookInfo: webhook configuration - sendDocument: file reference with caption - Bot queue: raw messages migrated to bot_queue:: for ordering Web client (routes/web.rs): - Bot messages rendered properly (was showing "[message could not be decrypted]") - Handles bot_message, bot_edit, bot_document as both Text and Binary WS frames - Inline keyboard buttons rendered as bracketed text - Missed call notifications handled in Text frame path Docs: - LLM_BOT_DEV.md: token-optimized bot dev reference for coding assistant LLM (Python + Node.js examples, all endpoints, TG compatibility table) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../crates/warzone-server/src/routes/bot.rs | 716 ++++++++++++++---- .../crates/warzone-server/src/routes/web.rs | 68 +- warzone/docs/LLM_BOT_DEV.md | 214 ++++++ 3 files changed, 829 insertions(+), 169 deletions(-) create mode 100644 warzone/docs/LLM_BOT_DEV.md diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index 1dd71ab..969d54f 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -3,6 +3,18 @@ //! 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}, @@ -20,9 +32,15 @@ use crate::state::AppState; pub fn routes() -> Router { Router::new() .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/getMe", get(get_me)) + .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)) } // --------------------------------------------------------------------------- @@ -37,6 +55,43 @@ fn validate_bot_token(state: &AppState, token: &str) -> Option`. +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); + } +} + // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -145,15 +200,32 @@ async fn get_me( } } +// --------------------------------------------------------------------------- +// 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. /// -/// Reads from the `queue::*` key range in the messages sled tree, -/// converts each entry into a Telegram-style `Update` object, and deletes -/// consumed entries. +/// 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(params): Json, ) -> Json { let bot_info = match validate_bot_token(&state, &token) { Some(info) => info, @@ -165,178 +237,60 @@ async fn get_updates( } }; 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 limit = params.limit.unwrap_or(100).min(100); + let timeout = params.timeout.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; + // Step 1: Migrate raw queue entries into the persistent bot_queue. + migrate_raw_queue(&state, bot_fp); - 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::(&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; + // 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; } - 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::(&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); + for key in &to_delete { + let _ = state.db.messages.remove(key); + } } - // Remove consumed messages. - for key in &keys_to_delete { - let _ = state.db.messages.remove(key); - } + // Step 3: Collect remaining updates up to `limit`. + let updates = collect_updates(&state, bot_fp, limit); - // 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. + // Step 4: Long-poll if empty. if updates.is_empty() && timeout > 0 { - let wait = std::cmp::min(timeout, 5); - tokio::time::sleep(std::time::Duration::from_secs(wait)).await; + let wait = std::cmp::min(timeout, 30); + // 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); + 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!({ @@ -345,10 +299,219 @@ async fn get_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) { + 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(&wire, &value) + } else if let Ok(bot_msg) = serde_json::from_slice::(&value) { + bot_json_to_update(&bot_msg) + } 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); + } +} + +/// Convert a `WireMessage` into a Telegram-style update JSON (without update_id). +fn wire_message_to_update( + wire: &warzone_protocol::message::WireMessage, + raw_bytes: &[u8], +) -> Option { + match wire { + warzone_protocol::message::WireMessage::Message { + id, + sender_fingerprint, + .. + } => { + let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); + 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": null, + "raw_encrypted": raw_b64, + } + })) + } + warzone_protocol::message::WireMessage::KeyExchange { + id, + sender_fingerprint, + .. + } => { + let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); + 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": null, + "raw_encrypted": raw_b64, + } + })) + } + warzone_protocol::message::WireMessage::CallSignal { + id, + sender_fingerprint, + 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, + }, + } + })), + 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, + }, + } + })), + // 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(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"), + } + })), + _ => 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: String, 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. @@ -383,7 +546,11 @@ async fn send_message( "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(); @@ -401,3 +568,216 @@ async fn send_message( } })) } + +// --------------------------------------------------------------------------- +// 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: String, + 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 = req + .chat_id + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + + 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 +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct SendDocumentRequest { + chat_id: String, + /// File path, URL, or file_id reference. In v1, the reference is stored + /// and forwarded as-is without server-side file hosting. + document: String, + #[serde(default)] + caption: Option, +} + +/// `POST /bot/:token/sendDocument` -- send a document reference to a user. +async fn send_document( + 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 = req + .chat_id + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + let msg_id = uuid::Uuid::new_v4().to_string(); + + let doc_msg = serde_json::json!({ + "type": "bot_document", + "id": msg_id, + "from": bot_fp, + "document": req.document, + "caption": req.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": req.document}, + "caption": req.caption, + "delivered": delivered, + } + })) +} diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 9775e9f..9b775b2 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -527,6 +527,45 @@ function connectWebSocket() { }; ws.onmessage = async (event) => { + if (typeof event.data === 'string') { + // Text frame — could be a bot message or missed call notification + try { + const json = JSON.parse(event.data); + if (json.type === 'missed_call') { + addSys('Missed call from ' + (json.data?.caller_fp || 'unknown')); + return; + } + if (json.type === 'bot_message') { + const botName = json.from_name || json.from || 'bot'; + let msgText = json.text || ''; + if (json.reply_markup && json.reply_markup.inline_keyboard) { + msgText += '\\n'; + for (const row of json.reply_markup.inline_keyboard) { + for (const btn of row) { + msgText += ' [' + btn.text + '] '; + } + msgText += '\\n'; + } + } + addMsg('@' + botName, msgText, false); + lastDmPeer = json.from ? normFP(json.from) : ''; + return; + } + if (json.type === 'bot_edit') { + addSys('[bot updated: ' + (json.text || '') + ']'); + return; + } + if (json.type === 'bot_document') { + addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + ']', false); + return; + } + } catch(e) {} + // If not JSON or unrecognized, try treating as binary + const bytes = new TextEncoder().encode(event.data); + dbg('WS text frame treated as bytes,', bytes.length, 'bytes'); + await handleIncomingMessage(bytes); + return; + } const bytes = new Uint8Array(event.data); dbg('WS received', bytes.length, 'bytes'); await handleIncomingMessage(bytes); @@ -628,12 +667,39 @@ async function handleIncomingMessage(bytes) { } } - // Last try: raw JSON file messages (from web file upload) + // Last try: raw JSON file messages (from web file upload) or bot messages try { const str = new TextDecoder().decode(bytes); const json = JSON.parse(str); if (json.type === 'file_header') { handleFileHeader(json); return; } if (json.type === 'file_chunk') { handleFileChunk(json); return; } + // Handle bot messages (plaintext JSON from bot API) + if (json.type === 'bot_message') { + const botName = json.from_name || json.from || 'bot'; + let msgText = json.text || ''; + // Handle inline keyboard if present + if (json.reply_markup && json.reply_markup.inline_keyboard) { + msgText += '\\n'; + for (const row of json.reply_markup.inline_keyboard) { + for (const btn of row) { + msgText += ' [' + btn.text + '] '; + } + msgText += '\\n'; + } + } + addMsg('@' + botName, msgText, false); + lastDmPeer = json.from ? normFP(json.from) : ''; + return; + } + if (json.type === 'bot_edit') { + addSys('[bot updated message: ' + (json.text || '') + ']'); + return; + } + if (json.type === 'bot_document') { + const caption = json.caption ? ' \u2014 ' + json.caption : ''; + addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + caption + ']', false); + return; + } } catch(e) {} dbg('ALL decrypt attempts failed'); diff --git a/warzone/docs/LLM_BOT_DEV.md b/warzone/docs/LLM_BOT_DEV.md new file mode 100644 index 0000000..b2275d5 --- /dev/null +++ b/warzone/docs/LLM_BOT_DEV.md @@ -0,0 +1,214 @@ +# featherChat Bot Development Reference + +## Setup + +Server: `http://HOST:7700` +All bot endpoints: `/v1/bot//METHOD` + +Register bot: +``` +POST /v1/bot/register +{"name":"MyBot","fingerprint":"any_32_hex_chars"} +→ {"ok":true,"result":{"token":"TOKEN","alias":"@mybot_bot"}} +``` + +Bot names must end with Bot/bot/_bot. Token format: `:`. + +## Endpoints + +### getMe +``` +GET /v1/bot/TOKEN/getMe +→ {"ok":true,"result":{"id":"fp","is_bot":true,"first_name":"MyBot","username":"MyBot"}} +``` + +### getUpdates (long-poll) +``` +POST /v1/bot/TOKEN/getUpdates +{"offset":LAST_UPDATE_ID+1,"timeout":30,"limit":100} +→ {"ok":true,"result":[{"update_id":N,"message":{...}}]} +``` + +offset: skip updates with id < offset (acknowledge processed) +timeout: long-poll seconds (max 30) +limit: max updates to return (default 100) + +### sendMessage +``` +POST /v1/bot/TOKEN/sendMessage +{ + "chat_id": "FINGERPRINT", + "text": "Hello!", + "parse_mode": "HTML", // optional + "reply_to_message_id": "MSG_ID", // optional + "reply_markup": { // optional, inline keyboard + "inline_keyboard": [ + [{"text":"Yes","callback_data":"yes"},{"text":"No","callback_data":"no"}] + ] + } +} +→ {"ok":true,"result":{"message_id":"UUID","delivered":true}} +``` + +### answerCallbackQuery +``` +POST /v1/bot/TOKEN/answerCallbackQuery +{"callback_query_id":"ID","text":"Done!","show_alert":false} +→ {"ok":true,"result":true} +``` + +### editMessageText +``` +POST /v1/bot/TOKEN/editMessageText +{"chat_id":"FP","message_id":"MSG_ID","text":"Updated text","reply_markup":{...}} +``` + +### sendDocument +``` +POST /v1/bot/TOKEN/sendDocument +{"chat_id":"FP","document":"filename_or_url","caption":"optional"} +``` + +### setWebhook / deleteWebhook / getWebhookInfo +``` +POST /v1/bot/TOKEN/setWebhook {"url":"https://mybot.example.com/webhook"} +POST /v1/bot/TOKEN/deleteWebhook +GET /v1/bot/TOKEN/getWebhookInfo +``` + +## Update Types + +Messages from users arrive in getUpdates as: + +**Plaintext (from other bots):** +```json +{"update_id":1,"message":{"message_id":"id","from":{"id":"fp","is_bot":true},"chat":{"id":"fp","type":"private"},"text":"Hello"}} +``` + +**Encrypted (from users with E2E sessions):** +```json +{"update_id":2,"message":{"message_id":"id","from":{"id":"fp","is_bot":false},"chat":{"id":"fp"},"text":null,"raw_encrypted":"base64..."}} +``` +Note: v1 bots cannot decrypt E2E messages. They see text=null + raw_encrypted blob. + +**Call signal:** +```json +{"update_id":3,"message":{"text":"/call_Offer","call_signal":{"type":"Offer","payload":"..."}}} +``` + +**File:** +```json +{"update_id":4,"message":{"document":{"file_name":"report.pdf","file_size":1234}}} +``` + +## Python Examples + +### Echo Bot +```python +import requests, time + +TOKEN = "YOUR_TOKEN" +API = f"http://localhost:7700/v1/bot/{TOKEN}" +offset = 0 + +while True: + resp = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 30}).json() + for update in resp.get("result", []): + offset = update["update_id"] + 1 + msg = update.get("message", {}) + chat_id = msg.get("chat", {}).get("id", "") + text = msg.get("text") + if text and chat_id: + requests.post(f"{API}/sendMessage", json={"chat_id": chat_id, "text": f"Echo: {text}"}) +``` + +### Inline Keyboard Bot +```python +import requests + +TOKEN = "YOUR_TOKEN" +API = f"http://localhost:7700/v1/bot/{TOKEN}" +offset = 0 + +def send_menu(chat_id): + requests.post(f"{API}/sendMessage", json={ + "chat_id": chat_id, + "text": "Choose an option:", + "reply_markup": { + "inline_keyboard": [ + [{"text": "Option A", "callback_data": "a"}, {"text": "Option B", "callback_data": "b"}], + [{"text": "Help", "callback_data": "help"}] + ] + } + }) + +while True: + resp = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 30}).json() + for update in resp.get("result", []): + offset = update["update_id"] + 1 + msg = update.get("message", {}) + text = msg.get("text", "") + chat_id = msg.get("chat", {}).get("id", "") + if text == "/start": + send_menu(chat_id) + elif text: + requests.post(f"{API}/sendMessage", json={"chat_id": chat_id, "text": f"You said: {text}"}) +``` + +### Node.js Echo Bot +```javascript +const API = `http://localhost:7700/v1/bot/${process.env.BOT_TOKEN}`; +let offset = 0; + +async function poll() { + while (true) { + try { + const res = await fetch(`${API}/getUpdates`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({offset, timeout: 30}) + }); + const data = await res.json(); + for (const update of data.result || []) { + offset = update.update_id + 1; + const msg = update.message; + if (msg?.text && msg?.chat?.id) { + await fetch(`${API}/sendMessage`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({chat_id: msg.chat.id, text: `Echo: ${msg.text}`}) + }); + } + } + } catch (e) { console.error(e); await new Promise(r => setTimeout(r, 3000)); } + } +} +poll(); +``` + +## Differences from Telegram + +| Feature | Telegram | featherChat | +|---------|----------|------------| +| chat_id | numeric | hex fingerprint string | +| getUpdates timeout | up to 50s | up to 30s | +| User messages | plaintext | E2E encrypted (text=null in v1) | +| Bot messages | plaintext | plaintext (no E2E) | +| File upload | multipart form | JSON reference (v1) | +| Inline keyboards | full support | stored + delivered, no popup | +| Callback queries | full popup | acknowledged, no popup | +| Webhooks | full HTTPS | URL stored, delivery planned | +| Media groups | supported | not yet | +| parse_mode | renders HTML/MD | stored, not rendered (v1) | + +## Key Patterns + +**Always use offset** — without it, the same messages are returned every poll. + +**chat_id is the sender's fingerprint** — use `msg.chat.id` or `msg.from.id`. + +**Bot alias** — users message bots via `@mybot_bot` which resolves to the bot's fingerprint. + +**Error handling** — all responses have `{"ok": bool}`. Check `ok` before accessing `result`. + +**Rate limits** — 200 concurrent server requests, no per-bot limit (be reasonable).