diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index b2495bc..b751509 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.30" +version = "0.0.31" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.30" +version = "0.0.31" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.30" +version = "0.0.31" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.30" +version = "0.0.31" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.30" +version = "0.0.31" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 2b922d9..43f7bfc 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.30" +version = "0.0.31" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index a22607d..9a744a0 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.30" +version = "0.0.31" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index 5cba6c7..0764a84 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -171,9 +171,9 @@ pub async fn try_bot_webhook(state: &AppState, to_fp: &str, message: &[u8]) -> b let update = if let Ok(wire) = bincode::deserialize::(message) { - wire_message_to_update(&wire, message) + wire_message_to_update(&wire, message, &token) } else if let Ok(bot_msg) = serde_json::from_slice::(message) { - bot_json_to_update(&bot_msg) + bot_json_to_update(&bot_msg, &token) } else { None }; @@ -367,8 +367,7 @@ async fn get_me( Json(serde_json::json!({ "ok": true, "result": { - "id": crate::routes::resolve::fp_to_numeric_id(fp), - "id_str": fp, + "id": crate::routes::resolve::fp_to_numeric_id_for_bot(fp, &token), "is_bot": true, "first_name": info["name"], "username": info["name"], @@ -423,7 +422,7 @@ async fn get_updates( let timeout = params.timeout.unwrap_or(0); // Step 1: Migrate raw queue entries into the persistent bot_queue. - migrate_raw_queue(&state, bot_fp); + 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 { @@ -462,7 +461,7 @@ async fn get_updates( loop { tokio::time::sleep(std::time::Duration::from_secs(1)).await; // Check for newly arrived raw messages. - migrate_raw_queue(&state, bot_fp); + migrate_raw_queue(&state, bot_fp, &token); let polled = collect_updates(&state, bot_fp, limit); if !polled.is_empty() { return Json(serde_json::json!({ @@ -486,7 +485,7 @@ async fn get_updates( /// /// 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) { +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(); @@ -499,9 +498,9 @@ fn migrate_raw_queue(state: &AppState, bot_fp: &str) { let update = if let Ok(wire) = bincode::deserialize::(&value) { - wire_message_to_update(&wire, &value) + wire_message_to_update(&wire, &value, bot_token) } else if let Ok(bot_msg) = serde_json::from_slice::(&value) { - bot_json_to_update(&bot_msg) + bot_json_to_update(&bot_msg, bot_token) } else { None }; @@ -521,6 +520,7 @@ fn migrate_raw_queue(state: &AppState, bot_fp: &str) { fn wire_message_to_update( wire: &warzone_protocol::message::WireMessage, raw_bytes: &[u8], + bot_token: &str, ) -> Option { match wire { warzone_protocol::message::WireMessage::Message { @@ -529,20 +529,18 @@ 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); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, - "id_str": sender_fingerprint, - "is_bot": false, + "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, - "id_str": sender_fingerprint, - "type": "private", + "type": "private", }, "date": chrono::Utc::now().timestamp(), "text": null, @@ -556,20 +554,18 @@ 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); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, - "id_str": sender_fingerprint, - "is_bot": false, + "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, - "id_str": sender_fingerprint, - "type": "private", + "type": "private", }, "date": chrono::Utc::now().timestamp(), "text": null, @@ -584,20 +580,18 @@ fn wire_message_to_update( payload, .. } => { - let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, - "id_str": sender_fingerprint, - "is_bot": false, + "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, - "id_str": sender_fingerprint, - "type": "private", + "type": "private", }, "date": chrono::Utc::now().timestamp(), "text": format!("/call_{:?}", signal_type), @@ -615,20 +609,18 @@ fn wire_message_to_update( file_size, .. } => { - let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, - "id_str": sender_fingerprint, - "is_bot": false, + "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, - "id_str": sender_fingerprint, - "type": "private", + "type": "private", }, "date": chrono::Utc::now().timestamp(), "document": { @@ -645,24 +637,22 @@ fn wire_message_to_update( } /// 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 { +fn bot_json_to_update(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(from_fp); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); 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, + "is_bot": true, }, "chat": { "id": numeric, - "id_str": from_fp, - "type": "private", + "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(""), @@ -671,14 +661,13 @@ fn bot_json_to_update(bot_msg: &serde_json::Value) -> Option } "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); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); 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, + "is_bot": false, }, "data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""), "message": bot_msg.get("message"), diff --git a/warzone/crates/warzone-server/src/routes/resolve.rs b/warzone/crates/warzone-server/src/routes/resolve.rs index 8d3f629..f8f057e 100644 --- a/warzone/crates/warzone-server/src/routes/resolve.rs +++ b/warzone/crates/warzone-server/src/routes/resolve.rs @@ -7,7 +7,22 @@ use axum::{ use crate::errors::AppResult; use crate::state::AppState; -/// Convert a fingerprint hex string to a stable i64 ID (for Telegram compatibility). +/// Convert a fingerprint to a per-bot unique numeric ID. +/// Hash(bot_token + user_fp) → i64. Different bots see different IDs for the same user. +/// This prevents cross-bot user correlation (same privacy model as Telegram). +pub fn fp_to_numeric_id_for_bot(fp: &str, bot_token: &str) -> i64 { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(bot_token.as_bytes()); + hasher.update(b":"); + hasher.update(fp.as_bytes()); + let hash = hasher.finalize(); + let mut arr = [0u8; 8]; + arr.copy_from_slice(&hash[..8]); + (i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF) // ensure positive +} + +/// Convert a fingerprint hex string to a stable i64 ID (non-bot contexts). /// 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(); diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index b8dd28f..813245c 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-v12'; +const CACHE = 'wz-v13'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -251,7 +251,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.30'; +const VERSION = '0.0.31'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── diff --git a/warzone/docs/LLM_BOT_DEV.md b/warzone/docs/LLM_BOT_DEV.md index 182dab2..5962a25 100644 --- a/warzone/docs/LLM_BOT_DEV.md +++ b/warzone/docs/LLM_BOT_DEV.md @@ -57,8 +57,8 @@ Response: {"ok":true,"result":[ {"update_id":1,"message":{ "message_id":"uuid", - "from":{"id":123456,"id_str":"sender_fp_hex","is_bot":false}, - "chat":{"id":123456,"id_str":"sender_fp_hex","type":"private"}, + "from":{"id":123456,"is_bot":false}, + "chat":{"id":123456,"type":"private"}, "date":1711612800, "text":"Hello bot!" }} @@ -69,8 +69,8 @@ Response: - `offset` — skip updates < offset (acknowledge processed). **Always use this.** - `timeout` — long-poll seconds (max 50, matches Telegram) - `limit` — max updates (default 100) -- `from.id` — numeric (i64 hash of fingerprint, for TG library compat) -- `from.id_str` — hex fingerprint string +- `from.id` — numeric (per-bot unique hash, different bots see different IDs for same user) +- No raw fingerprint exposed to bots (privacy: bots can't correlate users cross-bot) ### sendMessage ``` @@ -246,7 +246,7 @@ The bridge translates numeric chat_id ↔ fingerprints automatically. | User→bot messages | plaintext | plaintext (auto-detected by client) | | Bot creation | @BotFather chat | @botfather chat (same flow) | | getUpdates timeout | up to 50s | up to 50s | -| from.id | integer | integer (hash of fp) + id_str (hex fp) | +| from.id | integer | integer (per-bot unique hash, no raw fp exposed) | | File upload | multipart | JSON reference (v1) | | Inline keyboards | full | stored + delivered, no popup | | Webhooks | HTTPS POST | HTTP POST (delivered live) | @@ -256,7 +256,7 @@ The bridge translates numeric chat_id ↔ fingerprints automatically. ## Key Rules 1. **Always use offset** in getUpdates — without it you reprocess messages -2. **chat_id** — use `msg.chat.id` (numeric) or `msg.chat.id_str` (hex fingerprint) +2. **chat_id** — use `msg.chat.id` (numeric, per-bot unique) for replies 3. **Bot names** must end with `bot`, `Bot`, or `_bot` 4. **Only @botfather** can create bots — direct API registration requires botfather_token 5. **Server needs --enable-bots** — without it all bot endpoints return 403 diff --git a/warzone/docs/LLM_HELP.md b/warzone/docs/LLM_HELP.md index 70af2e2..e049f97 100644 --- a/warzone/docs/LLM_HELP.md +++ b/warzone/docs/LLM_HELP.md @@ -175,7 +175,7 @@ Bots can optionally participate in E2E encryption by registering with a seed and - Webhooks: updates are delivered live to the registered URL (POST with JSON body) - chat_id: accepts hex fingerprint or numeric ID (TG compatibility) - parse_mode: `HTML` renders basic HTML tags (, , , ) in clients -- from.id is numeric (integer), from.id_str contains the hex fingerprint +- from.id is per-bot unique numeric (bots can't correlate users cross-bot, no raw fingerprint exposed) Update types in getUpdates: - Encrypted msg: text=null, raw_encrypted=base64