v0.0.39: contacts online, message wrap, tab complete, multipart, OTPK

FC-P2-T6: /contacts shows online status (● online, ○ offline)
FC-P6-T6: Long messages word-wrap into multiple lines with aligned indent
FC-P6-T7: Tab completion for 33 slash commands (4 new tests)
FC-P8-T6: sendDocument accepts both JSON and multipart form data
OTPK: Auto-replenish on TUI startup when supply < 3 (generates 10 new)

135 tests passing (was 127)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-29 17:22:42 +04:00
parent 5764719375
commit c37bd7934c
11 changed files with 307 additions and 42 deletions

10
warzone/Cargo.lock generated
View File

@@ -2956,7 +2956,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-client" name = "warzone-client"
version = "0.0.38" version = "0.0.39"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -2989,7 +2989,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-mule" name = "warzone-mule"
version = "0.0.38" version = "0.0.39"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -2998,7 +2998,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-protocol" name = "warzone-protocol"
version = "0.0.38" version = "0.0.39"
dependencies = [ dependencies = [
"base64", "base64",
"bincode", "bincode",
@@ -3023,7 +3023,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-server" name = "warzone-server"
version = "0.0.38" version = "0.0.39"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -3053,7 +3053,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-wasm" name = "warzone-wasm"
version = "0.0.38" version = "0.0.39"
dependencies = [ dependencies = [
"base64", "base64",
"bincode", "bincode",

View File

@@ -9,7 +9,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.0.38" version = "0.0.39"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
rust-version = "1.75" rust-version = "1.75"

View File

@@ -113,6 +113,35 @@ impl ServerClient {
Ok(()) Ok(())
} }
/// Check how many one-time pre-keys remain on the server.
pub async fn otpk_count(&self, fingerprint: &str) -> Result<u64> {
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<serde_json::Value> = 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. /// Poll for messages addressed to us.
pub async fn poll_messages(&self, fingerprint: &str) -> Result<Vec<Vec<u8>>> { pub async fn poll_messages(&self, fingerprint: &str) -> Result<Vec<Vec<u8>>> {
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect(); let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();

View File

@@ -113,6 +113,22 @@ impl LocalDb {
Ok(()) Ok(())
} }
/// Return the next available OTPK ID (one past the highest stored).
pub fn next_otpk_id(&self) -> u32 {
let mut max_id: Option<u32> = 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::<u32>() {
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. /// Load and remove a one-time pre-key secret.
pub fn take_one_time_pre_key(&self, id: u32) -> Result<Option<StaticSecret>> { pub fn take_one_time_pre_key(&self, id: u32) -> Result<Option<StaticSecret>> {
let key = format!("otpk:{}", id); let key = format!("otpk:{}", id);

View File

@@ -129,9 +129,17 @@ impl App {
let fp = c.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?"); 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 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); 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::<serde_json::Value>().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 { let label = match alias {
Some(a) => format!(" @{} ({}) — {} msgs", a, &fp[..fp.len().min(12)], count), Some(a) => format!(" {} @{} ({}) — {} msgs", status, a, &fp[..fp.len().min(12)], count),
None => format!(" {}{} msgs", &fp[..fp.len().min(16)], 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() }); self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} }

View File

@@ -107,7 +107,7 @@ impl App {
let msgs = self.messages.lock().unwrap(); let msgs = self.messages.lock().unwrap();
let items: Vec<ListItem> = msgs let items: Vec<ListItem> = msgs
.iter() .iter()
.map(|m| { .flat_map(|m| {
let style = if m.is_system { let style = if m.is_system {
Style::default().fg(Color::Cyan) Style::default().fg(Color::Cyan)
} else if m.is_self { } else if m.is_self {
@@ -117,7 +117,6 @@ impl App {
}; };
let timestamp = format!("[{}] ", m.timestamp.format("%H:%M")); let timestamp = format!("[{}] ", m.timestamp.format("%H:%M"));
let prefix = if m.is_system { let prefix = if m.is_system {
"*** ".to_string() "*** ".to_string()
} else { } else {
@@ -131,12 +130,55 @@ impl App {
}; };
let receipt_color = self.receipt_color(&m.message_id); let receipt_color = self.receipt_color(&m.message_id);
ListItem::new(Line::from(vec![ // Calculate available width for text
Span::styled(timestamp, Style::default().fg(Color::DarkGray)), let ts_len = timestamp.len();
Span::styled(prefix, style.add_modifier(Modifier::BOLD)), let prefix_len = prefix.len();
Span::raw(&m.text), let receipt_len = receipt_str.len();
Span::styled(receipt_str, Style::default().fg(receipt_color)), 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(); .collect();

View File

@@ -2,6 +2,18 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::types::App; 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 { impl App {
/// Handle a single key event. Returns true if the event was consumed. /// Handle a single key event. Returns true if the event was consumed.
pub fn handle_key_event(&mut self, key: KeyEvent) { pub fn handle_key_event(&mut self, key: KeyEvent) {
@@ -107,6 +119,31 @@ impl App {
KeyCode::Down if self.input.is_empty() => { KeyCode::Down if self.input.is_empty() => {
self.scroll_offset = self.scroll_offset.saturating_sub(1); 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 // Regular char: insert at cursor
KeyCode::Char(c) => { KeyCode::Char(c) => {
self.input.insert(self.cursor_pos, c); self.input.insert(self.cursor_pos, c);
@@ -374,4 +411,44 @@ mod tests {
app.handle_key_event(key(KeyCode::End)); app.handle_key_event(key(KeyCode::End));
assert_eq!(app.scroll_offset, 0); 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");
}
} }

View File

@@ -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::<String>().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 { loop {
terminal.draw(|frame| app.draw(frame))?; terminal.draw(|frame| app.draw(frame))?;

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "warzone-protocol" name = "warzone-protocol"
version = "0.0.38" version = "0.0.39"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
description = "Core crypto & wire protocol for featherChat (Warzone messenger)" description = "Core crypto & wire protocol for featherChat (Warzone messenger)"

View File

@@ -41,7 +41,7 @@ pub fn routes() -> Router<AppState> {
.route("/bot/:token/setWebhook", post(set_webhook)) .route("/bot/:token/setWebhook", post(set_webhook))
.route("/bot/:token/deleteWebhook", post(delete_webhook)) .route("/bot/:token/deleteWebhook", post(delete_webhook))
.route("/bot/:token/getWebhookInfo", get(get_webhook_info)) .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<String>,
}
/// `POST /bot/:token/sendDocument` -- send a document reference to a user. /// `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<AppState>, State(state): State<AppState>,
Path(token): Path<String>, Path(token): Path<String>,
Json(req): Json<SendDocumentRequest>, headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Json<serde_json::Value> { ) -> Json<serde_json::Value> {
let bot_info = match validate_bot_token(&state, &token) { let bot_info = match validate_bot_token(&state, &token) {
Some(i) => i, Some(i) => i,
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
}; };
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
let to_fp = match resolve_chat_id(&state, &req.chat_id) { let bot_name = bot_info["name"].as_str().unwrap_or("bot");
Some(fp) => fp,
None => { let content_type = headers
return Json(serde_json::json!({"ok": false, "description": "chat_id not found"})) .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::<serde_json::Value>(&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!({ let doc_msg = serde_json::json!({
"type": "bot_document", "type": "bot_document",
"id": msg_id, "id": msg_id,
"from": bot_fp, "from": bot_fp,
"document": req.document, "from_name": bot_name,
"caption": req.caption, "document": document,
"caption": caption,
"timestamp": chrono::Utc::now().timestamp(), "timestamp": chrono::Utc::now().timestamp(),
}); });
let msg_bytes = serde_json::to_vec(&doc_msg).unwrap_or_default(); let msg_bytes = serde_json::to_vec(&doc_msg).unwrap_or_default();
@@ -1004,8 +1064,8 @@ async fn send_document(
"result": { "result": {
"message_id": msg_id, "message_id": msg_id,
"chat": {"id": to_fp}, "chat": {"id": to_fp},
"document": {"file_name": req.document}, "document": {"file_name": document},
"caption": req.caption, "caption": caption,
"delivered": delivered, "delivered": delivered,
} }
})) }))

View File

@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
async fn service_worker() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/javascript")], r##" ([(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']; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
self.addEventListener('install', e => { self.addEventListener('install', e => {
@@ -287,7 +287,7 @@ let pollTimer = null;
let ws = null; // WebSocket connection let ws = null; // WebSocket connection
let wasmReady = false; let wasmReady = false;
const VERSION = '0.0.38'; const VERSION = '0.0.39';
let DEBUG = true; // toggle with /debug command let DEBUG = true; // toggle with /debug command
// ── Receipt tracking ── // ── Receipt tracking ──