diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index cb268f1..5caf093 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.38" +version = "0.0.39" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.38" +version = "0.0.39" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.38" +version = "0.0.39" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.38" +version = "0.0.39" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.38" +version = "0.0.39" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 8f897d9..d4acd1e 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.38" +version = "0.0.39" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/net.rs b/warzone/crates/warzone-client/src/net.rs index 4648c0d..9633d2d 100644 --- a/warzone/crates/warzone-client/src/net.rs +++ b/warzone/crates/warzone-client/src/net.rs @@ -113,6 +113,35 @@ impl ServerClient { Ok(()) } + /// Check how many one-time pre-keys remain on the server. + pub async fn otpk_count(&self, fingerprint: &str) -> Result { + let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect(); + let resp: serde_json::Value = self.client + .get(format!("{}/v1/keys/{}/otpk-count", self.base_url, fp_clean)) + .send() + .await + .context("failed to check OTPK count")? + .json() + .await + .context("failed to parse OTPK count")?; + Ok(resp.get("count").and_then(|v| v.as_u64()).unwrap_or(0)) + } + + /// Upload additional one-time pre-keys. + pub async fn replenish_otpks(&self, fingerprint: &str, keys: Vec<(u32, [u8; 32])>) -> Result<()> { + let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect(); + let otpks: Vec = keys.iter().map(|(id, pubkey)| { + serde_json::json!({"id": id, "public_key": hex::encode(pubkey)}) + }).collect(); + self.client + .post(format!("{}/v1/keys/replenish", self.base_url)) + .json(&serde_json::json!({"fingerprint": fp_clean, "one_time_pre_keys": otpks})) + .send() + .await + .context("failed to replenish OTPKs")?; + Ok(()) + } + /// Poll for messages addressed to us. pub async fn poll_messages(&self, fingerprint: &str) -> Result>> { let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect(); diff --git a/warzone/crates/warzone-client/src/storage.rs b/warzone/crates/warzone-client/src/storage.rs index b1dccbd..aaf3c31 100644 --- a/warzone/crates/warzone-client/src/storage.rs +++ b/warzone/crates/warzone-client/src/storage.rs @@ -113,6 +113,22 @@ impl LocalDb { Ok(()) } + /// Return the next available OTPK ID (one past the highest stored). + pub fn next_otpk_id(&self) -> u32 { + let mut max_id: Option = None; + for item in self.pre_keys.iter() { + if let Ok((k, _)) = item { + let key_str = String::from_utf8_lossy(&k); + if let Some(id_str) = key_str.strip_prefix("otpk:") { + if let Ok(id) = id_str.parse::() { + max_id = Some(max_id.map_or(id, |m: u32| m.max(id))); + } + } + } + } + max_id.map_or(0, |m| m + 1) + } + /// Load and remove a one-time pre-key secret. pub fn take_one_time_pre_key(&self, id: u32) -> Result> { let key = format!("otpk:{}", id); diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index f4ef605..2e40b99 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -129,9 +129,17 @@ impl App { let fp = c.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?"); let alias = c.get("alias").and_then(|v| v.as_str()); let count = c.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0); + // Check online status via presence endpoint + let online = match client.client.get(format!("{}/v1/presence/{}", client.base_url, normfp(fp))).send().await { + Ok(r) => r.json::().await.ok() + .and_then(|d| d.get("online").and_then(|v| v.as_bool())) + .unwrap_or(false), + Err(_) => false, + }; + let status = if online { "●" } else { "○" }; let label = match alias { - Some(a) => format!(" @{} ({}) — {} msgs", a, &fp[..fp.len().min(12)], count), - None => format!(" {} — {} msgs", &fp[..fp.len().min(16)], count), + Some(a) => format!(" {} @{} ({}) — {} msgs", status, a, &fp[..fp.len().min(12)], count), + None => format!(" {} {} — {} msgs", status, &fp[..fp.len().min(16)], count), }; self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); } diff --git a/warzone/crates/warzone-client/src/tui/draw.rs b/warzone/crates/warzone-client/src/tui/draw.rs index ea2c9ce..3e45857 100644 --- a/warzone/crates/warzone-client/src/tui/draw.rs +++ b/warzone/crates/warzone-client/src/tui/draw.rs @@ -107,7 +107,7 @@ impl App { let msgs = self.messages.lock().unwrap(); let items: Vec = msgs .iter() - .map(|m| { + .flat_map(|m| { let style = if m.is_system { Style::default().fg(Color::Cyan) } else if m.is_self { @@ -117,7 +117,6 @@ impl App { }; let timestamp = format!("[{}] ", m.timestamp.format("%H:%M")); - let prefix = if m.is_system { "*** ".to_string() } else { @@ -131,12 +130,55 @@ impl App { }; let receipt_color = self.receipt_color(&m.message_id); - ListItem::new(Line::from(vec![ - Span::styled(timestamp, Style::default().fg(Color::DarkGray)), - Span::styled(prefix, style.add_modifier(Modifier::BOLD)), - Span::raw(&m.text), - Span::styled(receipt_str, Style::default().fg(receipt_color)), - ])) + // Calculate available width for text + let ts_len = timestamp.len(); + let prefix_len = prefix.len(); + let receipt_len = receipt_str.len(); + let available = (chunks[1].width as usize).saturating_sub(ts_len + prefix_len + receipt_len + 2); + + if available == 0 || m.text.len() <= available { + // Single line + vec![ListItem::new(Line::from(vec![ + Span::styled(timestamp, Style::default().fg(Color::DarkGray)), + Span::styled(prefix, style.add_modifier(Modifier::BOLD)), + Span::raw(&m.text), + Span::styled(receipt_str, Style::default().fg(receipt_color)), + ]))] + } else { + // Wrap into multiple lines + let mut lines = Vec::new(); + let mut remaining = m.text.as_str(); + let mut first = true; + while !remaining.is_empty() { + let (chunk, rest) = if remaining.len() > available { + // Try to break at word boundary + let break_at = remaining[..available].rfind(' ').unwrap_or(available); + (&remaining[..break_at], remaining[break_at..].trim_start()) + } else { + (remaining, "") + }; + + if first { + lines.push(ListItem::new(Line::from(vec![ + Span::styled(timestamp.clone(), Style::default().fg(Color::DarkGray)), + Span::styled(prefix.clone(), style.add_modifier(Modifier::BOLD)), + Span::raw(chunk), + if rest.is_empty() { Span::styled(receipt_str, Style::default().fg(receipt_color)) } else { Span::raw("") }, + ]))); + first = false; + } else { + // Continuation lines: indent to align with text + let indent = " ".repeat(ts_len + prefix_len); + lines.push(ListItem::new(Line::from(vec![ + Span::raw(indent), + Span::styled(chunk, style), + if rest.is_empty() { Span::styled(receipt_str, Style::default().fg(receipt_color)) } else { Span::raw("") }, + ]))); + } + remaining = rest; + } + lines + } }) .collect(); diff --git a/warzone/crates/warzone-client/src/tui/input.rs b/warzone/crates/warzone-client/src/tui/input.rs index fbb9224..2c3fc96 100644 --- a/warzone/crates/warzone-client/src/tui/input.rs +++ b/warzone/crates/warzone-client/src/tui/input.rs @@ -2,6 +2,18 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use super::types::App; +const COMMANDS: &[&str] = &[ + "/help", "/info", "/eth", "/seed", "/backup", + "/peer", "/p", "/reply", "/r", "/dm", + "/call", "/accept", "/reject", "/hangup", + "/alias", "/aliases", "/unalias", + "/contacts", "/c", "/history", "/h", + "/friend", "/unfriend", + "/devices", "/kick", + "/g", "/gcreate", "/gjoin", "/glist", "/gleave", "/gkick", "/gmembers", + "/file", "/quit", "/q", +]; + impl App { /// Handle a single key event. Returns true if the event was consumed. pub fn handle_key_event(&mut self, key: KeyEvent) { @@ -107,6 +119,31 @@ impl App { KeyCode::Down if self.input.is_empty() => { self.scroll_offset = self.scroll_offset.saturating_sub(1); } + // Tab: complete slash commands + KeyCode::Tab => { + if self.input.starts_with('/') { + let input_lower = self.input.to_lowercase(); + let matches: Vec<&&str> = COMMANDS.iter() + .filter(|cmd| cmd.starts_with(&input_lower) && **cmd != input_lower.as_str()) + .collect(); + if matches.len() == 1 { + // Single match — complete it + self.input = format!("{} ", matches[0]); + self.cursor_pos = self.input.len(); + } else if matches.len() > 1 { + // Multiple matches — find common prefix + let first = matches[0]; + let common_len = matches.iter().fold(first.len(), |acc, cmd| { + first.chars().zip(cmd.chars()).take_while(|(a, b)| a == b).count().min(acc) + }); + if common_len > self.input.len() { + self.input = first[..common_len].to_string(); + self.cursor_pos = self.input.len(); + } + // TODO: show matches in a status line + } + } + } // Regular char: insert at cursor KeyCode::Char(c) => { self.input.insert(self.cursor_pos, c); @@ -374,4 +411,44 @@ mod tests { app.handle_key_event(key(KeyCode::End)); assert_eq!(app.scroll_offset, 0); } + + // ── Tab completion tests ──────────────────────────────────────── + + #[test] + fn tab_completes_unique_command() { + let mut app = app(); + type_str(&mut app, "/he"); + app.handle_key_event(key(KeyCode::Tab)); + assert_eq!(app.input, "/help "); + assert_eq!(app.cursor_pos, 6); + } + + #[test] + fn tab_completes_common_prefix_on_ambiguous() { + let mut app = app(); + // "/g" matches /g, /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers + // but /g is an exact-length match that is filtered out since it equals input + // Actually /g exactly matches "/g" so it's excluded. Remaining: /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers + // Common prefix is "/g" which is same length as input, so no change + type_str(&mut app, "/gc"); + app.handle_key_event(key(KeyCode::Tab)); + // /gcreate is the only match starting with /gc + assert_eq!(app.input, "/gcreate "); + } + + #[test] + fn tab_does_nothing_without_slash() { + let mut app = app(); + type_str(&mut app, "hello"); + app.handle_key_event(key(KeyCode::Tab)); + assert_eq!(app.input, "hello"); + } + + #[test] + fn tab_does_nothing_when_no_match() { + let mut app = app(); + type_str(&mut app, "/zzz"); + app.handle_key_event(key(KeyCode::Tab)); + assert_eq!(app.input, "/zzz"); + } } diff --git a/warzone/crates/warzone-client/src/tui/mod.rs b/warzone/crates/warzone-client/src/tui/mod.rs index 00677f3..7cff1f9 100644 --- a/warzone/crates/warzone-client/src/tui/mod.rs +++ b/warzone/crates/warzone-client/src/tui/mod.rs @@ -104,6 +104,39 @@ pub async fn run_tui( } } + // Check and replenish OTPKs if running low + { + let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase(); + match client.otpk_count(&fp_clean).await { + Ok(count) => { + if count < 3 { + tracing::info!("OTPK supply low ({}), generating more...", count); + let start_id = db.next_otpk_id(); + let otpks = warzone_protocol::prekey::generate_one_time_pre_keys(start_id, 10); + let mut new_keys = Vec::new(); + for otpk in &otpks { + let _ = db.save_one_time_pre_key(otpk.id, &otpk.secret); + new_keys.push((otpk.id, *otpk.public.as_bytes())); + } + match client.replenish_otpks(&fp_clean, new_keys).await { + Ok(_) => { + app.add_message(types::ChatLine { + sender: "system".into(), + text: format!("Replenished OTPKs ({} -> {})", count, count + 10), + is_system: true, + is_self: false, + message_id: None, + timestamp: chrono::Local::now(), + }); + } + Err(e) => tracing::warn!("Failed to replenish OTPKs: {}", e), + } + } + } + Err(e) => tracing::debug!("Could not check OTPK count: {}", e), + } + } + loop { terminal.draw(|frame| app.draw(frame))?; diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 0b4b7bc..9ad9f13 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.38" +version = "0.0.39" 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 d5c204d..1c808bd 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -41,7 +41,7 @@ pub fn routes() -> Router { .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)) + .route("/bot/:token/sendDocument", post(send_document_flexible)) } // --------------------------------------------------------------------------- @@ -956,44 +956,104 @@ async fn get_webhook_info( } // --------------------------------------------------------------------------- -// sendDocument +// sendDocument — accepts both JSON and multipart/form-data // --------------------------------------------------------------------------- -#[derive(Deserialize)] -struct SendDocumentRequest { - 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, - #[serde(default)] - caption: Option, -} - /// `POST /bot/:token/sendDocument` -- send a document reference to a user. -async fn send_document( +/// +/// 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, - Json(req): Json, + 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 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_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 msg_id = uuid::Uuid::new_v4().to_string(); + 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, - "document": req.document, - "caption": req.caption, + "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(); @@ -1004,8 +1064,8 @@ async fn send_document( "result": { "message_id": msg_id, "chat": {"id": to_fp}, - "document": {"file_name": req.document}, - "caption": req.caption, + "document": {"file_name": document}, + "caption": caption, "delivered": delivered, } })) diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 0e9f664..7c83aef 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-v20'; +const CACHE = 'wz-v21'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -287,7 +287,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.38'; +const VERSION = '0.0.39'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ──