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:
10
warzone/Cargo.lock
generated
10
warzone/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.38"
|
||||
version = "0.0.39"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
@@ -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<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.
|
||||
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();
|
||||
|
||||
@@ -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<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.
|
||||
pub fn take_one_time_pre_key(&self, id: u32) -> Result<Option<StaticSecret>> {
|
||||
let key = format!("otpk:{}", id);
|
||||
|
||||
@@ -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::<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 {
|
||||
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() });
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ impl App {
|
||||
let msgs = self.messages.lock().unwrap();
|
||||
let items: Vec<ListItem> = 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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
terminal.draw(|frame| app.draw(frame))?;
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.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<String>,
|
||||
}
|
||||
|
||||
/// `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>,
|
||||
Path(token): Path<String>,
|
||||
Json(req): Json<SendDocumentRequest>,
|
||||
headers: axum::http::HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> Json<serde_json::Value> {
|
||||
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::<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!({
|
||||
"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,
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
Reference in New Issue
Block a user