10 Commits

Author SHA1 Message Date
Siavash Sameni
983afc5916 Merge feature/wzp-call-infrastructure: v0.0.22→v0.0.44
50 commits: call infrastructure, bot API, federation, web/TUI polish.

Key features:
- Voice calls via WZP audio bridge (signaling + audio)
- Telegram-compatible Bot API (BotFather, getUpdates, sendMessage)
- Server-to-server federation (persistent WS, presence sync)
- Web: markdown, call UI, ETH display, inline keyboards
- TUI: markdown, read receipts, call commands, tab completion
- Session versioning, wire envelope format, auto-backup
- 155 tests passing
2026-03-30 08:52:30 +04:00
Siavash Sameni
81954b1b0c v0.0.44: web UI polish — ETH display, peer input, call fixes, docs
Web UI:
- Peer input Enter key now resolves ETH/@alias (like /peer command)
- ETH address stored and shown everywhere instead of raw fingerprint
- Call UI shows ETH address: "Calling 0x0021...", "In call with 0x9D70..."
- Server URL color: #444#666 (readable on dark background)
- Peer input placeholder: "ETH address, fingerprint, or @alias"
- peerEthAddr persisted in localStorage across sessions

Server:
- WS binary header: strip zero-padding from 64-char to 32-char fingerprint
- Call routing now works (was failing due to padded fingerprint lookup)
- startCall() resolves ETH/alias before sending CallSignal::Offer
- Audio bridge sends auth token to wzp-web as first WS message
- Deterministic room name: sorted fingerprint pair (both peers same room)

Docs updated:
- SERVER.md: WZP integration section (components, running, TLS, auth flow)
- USAGE.md: voice call usage for web and TUI
- LLM_HELP.md: call architecture, key files, environment vars
- LLM_BOT_DEV.md: note that bots cannot participate in calls
- TESTING_E2E.md: updated WZP prerequisites with correct flags

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:32:31 +04:00
Siavash Sameni
7c4e6a1c1e fix: remove unnecessary parentheses warning in resolve.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:51:21 +04:00
Siavash Sameni
db88282bf6 fix: replace JS lookbehind regex (Safari compat) in markdown renderer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:49:31 +04:00
Siavash Sameni
5bbc197369 docs: comprehensive E2E testing guide (15 test scenarios + quick smoke test)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:34:56 +04:00
Siavash Sameni
87d7ab16c2 v0.0.43: FC-P3-T4 — voice calls via WZP audio bridge
Web client:
- After call goes "active", connects to WZP web bridge WS
- Mic capture: getUserMedia → ScriptProcessor → PCM int16 frames → WS
- Playback: WS → PCM int16 → Float32 → AudioContext.createBufferSource
- Room name derived from peer fingerprint (deterministic)
- Relay address fetched from /v1/wzp/relay-config
- Audio auto-starts on accept/answer, auto-stops on hangup/reject
- startAudio()/stopAudio() manage full lifecycle

TUI:
- /call shows "Audio: use web client for voice (TUI audio coming soon)"
- Signaling works, audio requires web client for now

This completes the last critical task — voice calls work end-to-end:
  User A calls → signaling via featherChat WS → User B accepts →
  both connect to WZP relay → audio flows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:29:44 +04:00
Siavash Sameni
6f1dbde7cc v0.0.42: markdown rendering in TUI messages
- **bold**, *italic*, \`code\` rendered with ratatui styles
- # headers, > blockquotes, - bullet lists
- Multi-line messages split and indented per line
- Code spans: cyan bold, headers: white bold, quotes: gray italic
- No external dependency (custom md_to_spans parser)
- tui-markdown had ratatui version mismatch, built our own

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:00:28 +04:00
Siavash Sameni
5bc59376f5 v0.0.41: FC-P6-T2 — read receipts when messages are visible
- ChatLine gains sender_fp field for tracking who sent each message
- App gains read_receipts_sent HashSet to avoid duplicate receipts
- After each draw(), visible received messages get a Read receipt sent
- Only fires once per message_id, skips system/self messages
- Sender sees blue ✓✓ (existing display logic already handles Read)
- All ChatLine literals across 6 files updated with sender_fp field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:50:47 +04:00
Siavash Sameni
1295f1c937 v0.0.40: reliability — call reload, ETH cache prefill, 10 server tests
Call state reload on restart:
- Loads Ringing/Active calls from sled into active_calls on startup
- Expires calls older than 24h automatically

TUI sender ETH cache prefill:
- prefill_eth_cache() resolves all known contacts on poll_loop start
- First message from known contacts now shows ETH address immediately

Server integration tests (10 new):
- push_to_client offline/online
- register_ws + connection cap (5 max)
- is_online + device_count
- kick_device + revoke_all_except
- deliver_or_queue offline/online
- call state lifecycle
- list_devices

155 tests passing (was 135)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:39:47 +04:00
Siavash Sameni
c37bd7934c 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>
2026-03-29 17:22:42 +04:00
24 changed files with 1535 additions and 204 deletions

11
warzone/Cargo.lock generated
View File

@@ -2956,7 +2956,7 @@ dependencies = [
[[package]]
name = "warzone-client"
version = "0.0.38"
version = "0.0.44"
dependencies = [
"anyhow",
"argon2",
@@ -2989,7 +2989,7 @@ dependencies = [
[[package]]
name = "warzone-mule"
version = "0.0.38"
version = "0.0.44"
dependencies = [
"anyhow",
"clap",
@@ -2998,7 +2998,7 @@ dependencies = [
[[package]]
name = "warzone-protocol"
version = "0.0.38"
version = "0.0.44"
dependencies = [
"base64",
"bincode",
@@ -3023,7 +3023,7 @@ dependencies = [
[[package]]
name = "warzone-server"
version = "0.0.38"
version = "0.0.44"
dependencies = [
"anyhow",
"axum",
@@ -3040,6 +3040,7 @@ dependencies = [
"serde_json",
"sha2",
"sled",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.21.0",
@@ -3053,7 +3054,7 @@ dependencies = [
[[package]]
name = "warzone-wasm"
version = "0.0.38"
version = "0.0.44"
dependencies = [
"base64",
"bincode",

View File

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

View File

@@ -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();

View File

@@ -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);

View File

@@ -35,9 +35,9 @@ impl App {
}
if text == "/info" {
if !self.our_eth.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: format!("Identity: {}", self.our_eth), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Identity: {}", self.our_eth), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
self.add_message(ChatLine { sender: "system".into(), text: format!("Fingerprint: {}", self.our_fp), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Fingerprint: {}", self.our_fp), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
if text == "/help" || text == "/?" {
@@ -87,7 +87,7 @@ impl App {
text: line.to_string(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
return;
@@ -109,12 +109,12 @@ impl App {
{
Ok(resp) => if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
},
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
return;
}
@@ -122,22 +122,30 @@ impl App {
match db.list_contacts() {
Ok(contacts) => {
if contacts.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No contacts yet".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No contacts yet".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
for c in &contacts {
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);
let label = match alias {
Some(a) => format!(" @{} ({}) — {} msgs", a, &fp[..fp.len().min(12)], count),
None => format!(" {}{} msgs", &fp[..fp.len().min(16)], count),
// 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,
};
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
let status = if online { "" } else { "" };
let label = match alias {
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, sender_fp: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
return;
}
@@ -145,14 +153,14 @@ impl App {
let peer = if text.starts_with("/h ") { text[3..].trim() } else if text.starts_with("/history ") { text[9..].trim() } else { "" };
let fp = if let Some(ref p) = self.peer_fp { if !p.starts_with('#') { p.as_str() } else { peer } } else { peer };
if fp.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /history or /h <fingerprint> (or set peer first)".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /history or /h <fingerprint> (or set peer first)".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
match db.get_history(fp, 50) {
Ok(msgs) => {
if msgs.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No history with this peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No history with this peer".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
for m in &msgs {
let sender = m.get("sender").and_then(|v| v.as_str()).unwrap_or("?");
let txt = m.get("text").and_then(|v| v.as_str()).unwrap_or("");
@@ -162,12 +170,12 @@ impl App {
text: txt.to_string(),
is_system: false,
is_self,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
}
return;
@@ -176,17 +184,17 @@ impl App {
// Show ethereum address from seed
if let Ok(seed) = crate::keystore::load_seed_raw() {
let eth = warzone_protocol::ethereum::derive_eth_identity(&seed);
self.add_message(ChatLine { sender: "system".into(), text: format!("ETH: {}", eth.address.to_checksum()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("ETH: {}", eth.address.to_checksum()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
return;
}
if text == "/seed" {
if let Ok(seed) = crate::keystore::load_seed_raw() {
let mnemonic = warzone_protocol::identity::Seed::from_bytes(seed).to_mnemonic();
self.add_message(ChatLine { sender: "system".into(), text: "Your recovery seed (keep secret!):".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: mnemonic, is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Your recovery seed (keep secret!):".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: mnemonic, is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Failed to load seed".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Failed to load seed".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
return;
}
@@ -194,10 +202,10 @@ impl App {
if let Ok(seed) = crate::keystore::load_seed_raw() {
match db.create_backup(&seed) {
Ok(path) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Backup saved: {}", path.display()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Backup saved: {}", path.display()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Backup failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Backup failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
@@ -216,9 +224,9 @@ impl App {
match warzone_protocol::friends::FriendList::decrypt(&seed, &blob) {
Ok(list) => {
if list.friends.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend <address> to add.".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend <address> to add.".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Friends ({}):", list.friends.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Friends ({}):", list.friends.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
for f in &list.friends {
// Check presence
let presence_url = format!("{}/v1/presence/{}", client.base_url, normfp(&f.address));
@@ -233,28 +241,28 @@ impl App {
Some(a) => format!(" @{} ({}) — {}", a, &f.address[..f.address.len().min(16)], status),
None => format!(" {}{}", &f.address[..f.address.len().min(16)], status),
};
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, sender_fp: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Failed to decrypt friend list: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Failed to decrypt friend list: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
}
}
_ => {
self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend <address> to add.".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend <address> to add.".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
return;
}
if text.starts_with("/friend ") {
let addr = text[8..].trim().to_string();
if addr.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /friend <address>".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /friend <address>".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
if let Ok(seed) = crate::keystore::load_seed_raw() {
@@ -279,14 +287,14 @@ impl App {
let encrypted = list.encrypt(&seed);
let blob_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted);
let _ = client.client.post(&url).json(&serde_json::json!({"data": blob_b64})).send().await;
self.add_message(ChatLine { sender: "system".into(), text: format!("Added {} to friends", addr), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Added {} to friends", addr), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
return;
}
if text.starts_with("/unfriend ") {
let addr = text[10..].trim().to_string();
if addr.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /unfriend <address>".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /unfriend <address>".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
if let Ok(seed) = crate::keystore::load_seed_raw() {
@@ -310,7 +318,7 @@ impl App {
let encrypted = list.encrypt(&seed);
let blob_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted);
let _ = client.client.post(&url).json(&serde_json::json!({"data": blob_b64})).send().await;
self.add_message(ChatLine { sender: "system".into(), text: format!("Removed {} from friends", addr), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Removed {} from friends", addr), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
return;
}
@@ -322,31 +330,31 @@ impl App {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(devices) = data.get("devices").and_then(|v| v.as_array()) {
if devices.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No active devices".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No active devices".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Active devices ({}):", devices.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Active devices ({}):", devices.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
for d in devices {
let id = d.get("device_id").and_then(|v| v.as_str()).unwrap_or("?");
let connected = d.get("connected_at").and_then(|v| v.as_i64()).unwrap_or(0);
let when = chrono::DateTime::from_timestamp(connected, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "?".to_string());
self.add_message(ChatLine { sender: "system".into(), text: format!(" {} — connected {}", id, when), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!(" {} — connected {}", id, when), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
} else if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
return;
}
if text.starts_with("/kick ") {
let device_id = text[6..].trim();
if device_id.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /kick <device_id>".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /kick <device_id>".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
let url = format!("{}/v1/devices/{}/kick", client.base_url, device_id);
@@ -354,13 +362,13 @@ impl App {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(kicked) = data.get("kicked").and_then(|v| v.as_str()) {
self.add_message(ChatLine { sender: "system".into(), text: format!("Device {} kicked", kicked), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Device {} kicked", kicked), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
return;
}
@@ -368,7 +376,7 @@ impl App {
let last = self.last_dm_peer.lock().unwrap().clone();
if let Some(ref peer) = last {
self.peer_fp = Some(peer.clone());
self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
// If there's a message after /r, mutate text and fall through to send
let reply_msg = if text.starts_with("/reply ") {
text[7..].trim().to_string()
@@ -382,7 +390,7 @@ impl App {
}
text = reply_msg; // Fall through to send logic below
} else {
self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
}
@@ -406,7 +414,7 @@ impl App {
raw
};
if normfp(&fp) == normfp(&self.our_fp) {
self.add_message(ChatLine { sender: "system".into(), text: "Cannot set yourself as peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Cannot set yourself as peer".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
// Resolve peer ETH for display
@@ -427,7 +435,7 @@ impl App {
text: format!("Peer set to {}", display),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
self.peer_fp = Some(fp);
return;
@@ -451,7 +459,7 @@ impl App {
text: format!("Switched to group #{}", name),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
self.peer_fp = Some(format!("#{}", name));
return;
@@ -462,7 +470,7 @@ impl App {
text: "Switched to DM mode. Use /peer <fp>".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
self.peer_fp = None;
return;
@@ -478,7 +486,7 @@ impl App {
self.group_leave(&name, client).await;
self.peer_fp = None;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group. Use /g <name> first".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group. Use /g <name> first".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
return;
@@ -490,7 +498,7 @@ impl App {
let target = text[7..].trim().to_string();
self.group_kick(&name, &target, client).await;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
return;
@@ -501,7 +509,7 @@ impl App {
let name = peer[1..].to_string();
self.group_members(&name, client).await;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
return;
@@ -532,7 +540,7 @@ impl App {
let peer = match peer {
Some(p) if !p.starts_with('#') => p,
_ => {
self.add_message(ChatLine { sender: "system".into(), text: "No peer to call. Use /call <fp> or set a peer first.".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No peer to call. Use /call <fp> or set a peer first.".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
};
@@ -551,7 +559,7 @@ impl App {
let encoded = match bincode::serialize(&wire) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Call failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Call failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
};
@@ -562,7 +570,8 @@ impl App {
.or(Some(&peer))
.map(|s| if s.len() > 16 { format!("{}...", &s[..16]) } else { s.to_string() })
.unwrap_or_default();
self.add_message(ChatLine { sender: "system".into(), text: format!("📞 Calling {}...", display), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("📞 Calling {}...", display), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Audio: use web client for voice (TUI audio coming soon)".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
self.call_state = Some(super::types::CallInfo {
peer_fp: peer_fp_clean.clone(),
peer_display: display.clone(),
@@ -571,7 +580,7 @@ impl App {
});
}
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Call failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Call failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
return;
@@ -581,7 +590,7 @@ impl App {
let peer = match self.last_dm_peer.lock().unwrap().clone() {
Some(p) => p,
None => {
self.add_message(ChatLine { sender: "system".into(), text: "No incoming call to accept".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No incoming call to accept".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
};
@@ -597,7 +606,7 @@ impl App {
};
if let Ok(encoded) = bincode::serialize(&wire) {
let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await;
self.add_message(ChatLine { sender: "system".into(), text: "✓ Call accepted".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "✓ Call accepted".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
self.call_state = Some(super::types::CallInfo {
peer_fp: normfp(&peer),
peer_display: peer[..peer.len().min(16)].to_string(),
@@ -612,7 +621,7 @@ impl App {
let peer = match self.last_dm_peer.lock().unwrap().clone() {
Some(p) => p,
None => {
self.add_message(ChatLine { sender: "system".into(), text: "No incoming call to reject".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No incoming call to reject".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
};
@@ -628,7 +637,7 @@ impl App {
};
if let Ok(encoded) = bincode::serialize(&wire) {
let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await;
self.add_message(ChatLine { sender: "system".into(), text: "✗ Call rejected".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "✗ Call rejected".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
self.call_state = None;
}
return;
@@ -639,7 +648,7 @@ impl App {
let peer = match peer {
Some(p) if !p.starts_with('#') => p,
_ => {
self.add_message(ChatLine { sender: "system".into(), text: "No active call".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No active call".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
};
@@ -655,7 +664,7 @@ impl App {
};
if let Ok(encoded) = bincode::serialize(&wire) {
let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await;
self.add_message(ChatLine { sender: "system".into(), text: "Call ended".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "Call ended".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
self.call_state = None;
}
return;
@@ -682,7 +691,7 @@ impl App {
text: "No peer set. Use /peer <fingerprint>".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -695,7 +704,7 @@ impl App {
text: "Cannot send messages to yourself".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -708,7 +717,7 @@ impl App {
text: "Invalid peer fingerprint".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -746,11 +755,11 @@ impl App {
text: text.clone(),
is_system: false,
is_self: true,
message_id: Some(msg_id), timestamp: Local::now(),
message_id: Some(msg_id), sender_fp: None, timestamp: Local::now(),
});
}
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
return;
@@ -776,7 +785,7 @@ impl App {
text: format!("Encrypt failed: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -791,7 +800,7 @@ impl App {
text: format!("Failed to fetch bundle: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -805,7 +814,7 @@ impl App {
text: format!("X3DH failed: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -832,7 +841,7 @@ impl App {
text: format!("Encrypt failed: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -847,7 +856,7 @@ impl App {
text: format!("Serialize failed: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -865,7 +874,7 @@ impl App {
text: text.clone(),
is_system: false,
is_self: true,
message_id: Some(msg_id), timestamp: Local::now(),
message_id: Some(msg_id), sender_fp: None, timestamp: Local::now(),
});
}
Err(e) => {
@@ -874,7 +883,7 @@ impl App {
text: format!("Send failed: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
}
@@ -889,13 +898,13 @@ impl App {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
}
@@ -908,14 +917,14 @@ impl App {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
let members = data.get("members").and_then(|v| v.as_u64()).unwrap_or(0);
self.add_message(ChatLine { sender: "system".into(), text: format!("Joined '{}' ({} members)", name, members), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Joined '{}' ({} members)", name, members), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
}
@@ -926,18 +935,18 @@ impl App {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(groups) = data.get("groups").and_then(|v| v.as_array()) {
if groups.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No groups".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No groups".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
for g in groups {
let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let members = g.get("members").and_then(|v| v.as_u64()).unwrap_or(0);
self.add_message(ChatLine { sender: "system".into(), text: format!(" #{} ({} members)", name, members), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!(" #{} ({} members)", name, members), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
}
@@ -950,13 +959,13 @@ impl App {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
}
@@ -969,14 +978,14 @@ impl App {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
let kicked = data.get("kicked").and_then(|v| v.as_str()).unwrap_or("?");
self.add_message(ChatLine { sender: "system".into(), text: format!("Kicked {} from '{}'", kicked, name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Kicked {} from '{}'", kicked, name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
}
@@ -986,7 +995,7 @@ impl App {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(members) = data.get("members").and_then(|v| v.as_array()) {
self.add_message(ChatLine { sender: "system".into(), text: format!("Members of #{}:", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Members of #{}:", name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
for m in members {
let fp = m.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
let alias = m.get("alias").and_then(|v| v.as_str());
@@ -995,12 +1004,12 @@ impl App {
Some(a) => format!(" @{} ({}{})", a, &fp[..fp.len().min(12)], if creator { "" } else { "" }),
None => format!(" {}...{}", &fp[..fp.len().min(12)], if creator { "" } else { "" }),
};
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, sender_fp: None, timestamp: Local::now() });
}
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
}
@@ -1017,9 +1026,9 @@ impl App {
let group_data = match client.client.get(&url).send().await {
Ok(resp) => match resp.json::<serde_json::Value>().await {
Ok(d) => d,
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; }
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; }
},
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; }
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; }
};
let my_fp = normfp(&self.our_fp);
@@ -1092,7 +1101,7 @@ impl App {
}
if wire_messages.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No members to send to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No members to send to".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return;
}
@@ -1110,11 +1119,11 @@ impl App {
text: text.to_string(),
is_system: false,
is_self: true,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
@@ -1128,14 +1137,14 @@ impl App {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
let alias = data.get("alias").and_then(|v| v.as_str()).unwrap_or(name);
self.add_message(ChatLine { sender: "system".into(), text: format!("Alias @{} registered", alias), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Alias @{} registered", alias), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
}
@@ -1145,17 +1154,17 @@ impl App {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) {
self.add_message(ChatLine { sender: "system".into(), text: format!("@{} → {}", name, fp), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("@{} → {}", name, fp), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return Some(fp.to_string());
}
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Unknown alias @{}: {}", name, err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Unknown alias @{}: {}", name, err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
None
}
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
None
}
}
@@ -1172,17 +1181,17 @@ impl App {
let formatted: String = fp.chars().enumerate()
.flat_map(|(i, c)| if i > 0 && i % 4 == 0 { vec![':', c] } else { vec![c] })
.collect();
self.add_message(ChatLine { sender: "system".into(), text: format!("{} → {}", addr, formatted), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("{} → {}", addr, formatted), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
return Some(fp.to_string());
}
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Cannot resolve {}: {}", addr, err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Cannot resolve {}: {}", addr, err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
None
}
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
None
}
}
@@ -1195,18 +1204,18 @@ impl App {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(aliases) = data.get("aliases").and_then(|v| v.as_array()) {
if aliases.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No aliases".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: "No aliases".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
} else {
for a in aliases {
let name = a.get("alias").and_then(|v| v.as_str()).unwrap_or("?");
let fp = a.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
self.add_message(ChatLine { sender: "system".into(), text: format!(" @{} → {}...", name, &fp[..fp.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
self.add_message(ChatLine { sender: "system".into(), text: format!(" @{} → {}...", name, &fp[..fp.len().min(16)]), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
}
}
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }),
}
}
}

View File

@@ -10,6 +10,60 @@ use chrono::Local;
use super::types::{App, ReceiptStatus};
/// Simple markdown-to-spans converter for TUI messages.
/// Handles: **bold**, *italic*, `code`, ```code blocks```.
fn md_to_spans<'a>(text: &'a str, base_style: Style) -> Vec<Span<'a>> {
let mut spans = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
// Code: `...`
if remaining.starts_with('`') && !remaining.starts_with("```") {
if let Some(end) = remaining[1..].find('`') {
spans.push(Span::styled(
&remaining[1..1 + end],
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
));
remaining = &remaining[2 + end..];
continue;
}
}
// Bold: **...**
if remaining.starts_with("**") {
if let Some(end) = remaining[2..].find("**") {
spans.push(Span::styled(
&remaining[2..2 + end],
base_style.add_modifier(Modifier::BOLD),
));
remaining = &remaining[4 + end..];
continue;
}
}
// Italic: *...*
if remaining.starts_with('*') && !remaining.starts_with("**") {
if let Some(end) = remaining[1..].find('*') {
spans.push(Span::styled(
&remaining[1..1 + end],
base_style.add_modifier(Modifier::ITALIC),
));
remaining = &remaining[2 + end..];
continue;
}
}
// Plain text until next special char
let next = remaining.find(|c: char| c == '*' || c == '`').unwrap_or(remaining.len());
if next > 0 {
spans.push(Span::styled(&remaining[..next], base_style));
remaining = &remaining[next..];
} else {
// Stuck on a special char that didn't match a pattern — emit it
spans.push(Span::styled(&remaining[..1], base_style));
remaining = &remaining[1..];
}
}
spans
}
impl App {
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
match message_id {
@@ -103,12 +157,12 @@ impl App {
]));
frame.render_widget(header, chunks[0]);
// Messages
// Messages — render markdown for message bodies via tui-markdown
let msgs = self.messages.lock().unwrap();
let items: Vec<ListItem> = msgs
.iter()
.map(|m| {
let style = if m.is_system {
.flat_map(|m| {
let base_style = if m.is_system {
Style::default().fg(Color::Cyan)
} else if m.is_self {
Style::default().fg(Color::Green)
@@ -117,7 +171,6 @@ impl App {
};
let timestamp = format!("[{}] ", m.timestamp.format("%H:%M"));
let prefix = if m.is_system {
"*** ".to_string()
} else {
@@ -131,12 +184,52 @@ impl App {
};
let receipt_color = self.receipt_color(&m.message_id);
ListItem::new(Line::from(vec![
// Split text into lines, render markdown per line
let text_lines: Vec<&str> = m.text.split('\n').collect();
let mut result_items = Vec::new();
for (i, line_text) in text_lines.iter().enumerate() {
let mut spans = Vec::new();
if i == 0 {
spans.push(Span::styled(timestamp.clone(), Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(prefix.clone(), base_style.add_modifier(Modifier::BOLD)));
} else {
let indent = " ".repeat(timestamp.len() + prefix.len());
spans.push(Span::raw(indent));
}
// Check for code block lines (```)
if line_text.starts_with("```") {
spans.push(Span::styled(*line_text, Style::default().fg(Color::DarkGray)));
} else if line_text.starts_with("# ") {
spans.push(Span::styled(&line_text[2..], Style::default().fg(Color::White).add_modifier(Modifier::BOLD)));
} else if line_text.starts_with("## ") {
spans.push(Span::styled(&line_text[3..], Style::default().fg(Color::White).add_modifier(Modifier::BOLD)));
} else if line_text.starts_with("> ") {
spans.push(Span::styled("", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(&line_text[2..], Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC)));
} else if line_text.starts_with("- ") || line_text.starts_with("* ") {
spans.push(Span::styled("", base_style));
spans.extend(md_to_spans(&line_text[2..], base_style));
} else {
spans.extend(md_to_spans(line_text, base_style));
}
// Receipt on last line
if i == text_lines.len() - 1 {
spans.push(Span::styled(receipt_str, Style::default().fg(receipt_color)));
}
result_items.push(ListItem::new(Line::from(spans)));
}
if result_items.is_empty() {
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)),
]))
Span::styled(prefix, base_style.add_modifier(Modifier::BOLD)),
]))]
} else {
result_items
}
})
.collect();
@@ -303,6 +396,7 @@ mod tests {
is_system: false,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
@@ -330,6 +424,7 @@ mod tests {
is_system: false,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
}
@@ -356,6 +451,7 @@ mod tests {
is_system: false,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
}

View File

@@ -25,7 +25,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File not found: {}", path_str),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -36,7 +36,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Cannot read file: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -47,7 +47,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File too large: {} bytes (max {} bytes)", file_size, MAX_FILE_SIZE),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -58,7 +58,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to read file: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -83,7 +83,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: "Set a peer or group first".into(),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -95,7 +95,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sending '{}' to group #{}...", filename, group_name),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
// Get members
@@ -147,7 +147,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File '{}' sent to group #{}", filename, group_name),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
};
@@ -158,7 +158,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: "Invalid peer fingerprint".into(),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -170,7 +170,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sending file '{}' ({} bytes, {} chunks)...", filename, file_size, total_chunks),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
// Send FileHeader (unencrypted metadata — the chunks carry ratchet-encrypted data)
@@ -189,7 +189,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize header failed: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -199,7 +199,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to send file header: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -222,7 +222,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize chunk failed: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -232,7 +232,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Encrypt chunk {} failed: {}", i, e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -241,7 +241,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: "No ratchet session. Send a text message first to establish one.".into(),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
};
@@ -261,7 +261,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize chunk {} failed: {}", i, e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -271,7 +271,7 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to send chunk {}/{}: {}", i + 1, total_chunks, e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
@@ -279,14 +279,14 @@ impl App {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sent chunk [{}/{}] of {}", i + 1, total_chunks, filename),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
self.add_message(ChatLine {
sender: self.our_fp[..12.min(self.our_fp.len())].to_string(),
text: format!("Sent file: {} ({} bytes)", filename, file_size),
is_system: false, is_self: true, message_id: None, timestamp: Local::now(),
is_system: false, is_self: true, message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
}

View File

@@ -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");
}
}

View File

@@ -83,6 +83,7 @@ pub async fn run_tui(
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: chrono::Local::now(),
});
@@ -91,22 +92,88 @@ pub async fn run_tui(
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(bots) = data.get("bots").and_then(|v| v.as_array()) {
if !bots.is_empty() {
app.add_message(types::ChatLine { sender: "system".into(), text: "Available bots:".into(), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() });
app.add_message(types::ChatLine { sender: "system".into(), text: "Available bots:".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() });
for b in bots {
let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let desc = b.get("description").and_then(|v| v.as_str()).unwrap_or("");
app.add_message(types::ChatLine { sender: "system".into(), text: format!(" @{} — {}", name, desc), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() });
app.add_message(types::ChatLine { sender: "system".into(), text: format!(" @{} — {}", name, desc), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() });
}
app.add_message(types::ChatLine { sender: "system".into(), text: "Message a bot: /peer @botname".into(), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() });
app.add_message(types::ChatLine { sender: "system".into(), text: "Message a bot: /peer @botname".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() });
}
}
}
}
}
// 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,
sender_fp: 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))?;
// Send Read receipts for visible messages
{
let msgs = app.messages.lock().unwrap();
let total = msgs.len();
let visible_end = total.saturating_sub(app.scroll_offset);
let visible_height = 20; // approximate
let visible_start = visible_end.saturating_sub(visible_height);
let mut sent = app.read_receipts_sent.lock().unwrap();
for msg in &msgs[visible_start..visible_end] {
if msg.is_system || msg.is_self { continue; }
if let (Some(ref msg_id), Some(ref sfp)) = (&msg.message_id, &msg.sender_fp) {
if sent.contains(msg_id) { continue; }
sent.insert(msg_id.clone());
// Fire-and-forget Read receipt
let receipt = warzone_protocol::message::WireMessage::Receipt {
sender_fingerprint: app.our_fp.clone(),
message_id: msg_id.clone(),
receipt_type: warzone_protocol::message::ReceiptType::Read,
};
if let Ok(encoded) = bincode::serialize(&receipt) {
let client = client.clone();
let to = sfp.clone();
let from = app.our_fp.clone();
tokio::spawn(async move {
let _ = client.send_message(&to, Some(&from), &encoded).await;
});
}
}
}
}
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Enter {

View File

@@ -75,6 +75,30 @@ fn cache_eth_lookup(fp: &str, client: &ServerClient, eth_cache: &EthCache) {
});
}
/// Pre-populate the ETH cache for all known contacts.
pub async fn prefill_eth_cache(
db: &crate::storage::LocalDb,
client: &ServerClient,
eth_cache: &EthCache,
) {
if let Ok(contacts) = db.list_contacts() {
for c in &contacts {
if let Some(fp) = c.get("fingerprint").and_then(|v| v.as_str()) {
let fp = fp.to_string();
if eth_cache.lock().unwrap().contains_key(&fp) { continue; }
let url = format!("{}/v1/resolve/{}", client.base_url, fp);
if let Ok(resp) = client.client.get(&url).send().await {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(eth) = data.get("eth_address").and_then(|v| v.as_str()) {
eth_cache.lock().unwrap().insert(fp, eth.to_string());
}
}
}
}
}
}
}
fn store_received(db: &LocalDb, sender_fp: &str, text: &str) {
let _ = db.touch_contact(sender_fp, None);
let _ = db.store_message(sender_fp, sender_fp, text, false);
@@ -155,7 +179,7 @@ fn process_wire_message(
text,
is_system: false,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: Some(id.clone()), sender_fp: Some(sender_fingerprint.clone()), timestamp: Local::now(),
});
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
// Terminal bell for incoming DM
@@ -172,7 +196,7 @@ fn process_wire_message(
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
}
@@ -204,7 +228,7 @@ fn process_wire_message(
text,
is_system: false,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: Some(id.clone()), sender_fp: Some(sender_fingerprint.clone()), timestamp: Local::now(),
});
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
// Terminal bell for incoming DM
@@ -221,7 +245,7 @@ fn process_wire_message(
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
}
@@ -266,7 +290,7 @@ fn process_wire_message(
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
let transfer = PendingFileTransfer {
@@ -327,7 +351,7 @@ fn process_wire_message(
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
// Check if all chunks received
@@ -353,7 +377,7 @@ fn process_wire_message(
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
} else {
// Save to data_dir/downloads/
@@ -370,7 +394,7 @@ fn process_wire_message(
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
Err(e) => {
@@ -379,7 +403,7 @@ fn process_wire_message(
text: format!("Failed to save file: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
}
@@ -426,6 +450,7 @@ fn process_wire_message(
is_system: false,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
}
@@ -441,6 +466,7 @@ fn process_wire_message(
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
}
@@ -457,6 +483,7 @@ fn process_wire_message(
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
}
@@ -486,6 +513,7 @@ fn process_wire_message(
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
}
@@ -505,7 +533,7 @@ fn process_wire_message(
text: format!("\u{1f4de} Incoming call from {} \u{2014} /accept or /reject", sender_short),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
// Terminal bell for incoming call
print!("\x07");
@@ -516,7 +544,7 @@ fn process_wire_message(
text: format!("\u{2713} {} accepted the call", sender_short),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Hangup => {
@@ -525,7 +553,7 @@ fn process_wire_message(
text: "Call ended".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Reject => {
@@ -534,7 +562,7 @@ fn process_wire_message(
text: format!("{} rejected the call", sender_short),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Ringing => {
@@ -543,7 +571,7 @@ fn process_wire_message(
text: "Ringing...".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Busy => {
@@ -552,7 +580,7 @@ fn process_wire_message(
text: format!("{} is busy", sender_short),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
_ => {
@@ -561,7 +589,7 @@ fn process_wire_message(
text: format!("\u{1f4de} Call signal: {:?}", signal_type),
is_system: false,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
}
@@ -584,6 +612,9 @@ pub async fn poll_loop(
let fp = normfp(&our_fp);
let eth_cache: EthCache = Arc::new(std::sync::Mutex::new(HashMap::new()));
// Pre-populate ETH cache for known contacts
prefill_eth_cache(&db, &client, &eth_cache).await;
// Try WebSocket first
let ws_url = client.base_url
.replace("http://", "ws://")
@@ -599,7 +630,7 @@ pub async fn poll_loop(
text: "Real-time connection established".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
use futures_util::StreamExt;
@@ -625,6 +656,7 @@ pub async fn poll_loop(
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
print!("\x07");
@@ -637,6 +669,7 @@ pub async fn poll_loop(
is_system: false,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
print!("\x07");
@@ -653,7 +686,7 @@ pub async fn poll_loop(
text: "Connection lost, reconnecting...".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
message_id: None, sender_fp: None, timestamp: Local::now(),
});
tokio::time::sleep(Duration::from_secs(3)).await;
}

View File

@@ -67,6 +67,8 @@ pub struct App {
pub connected: Arc<AtomicBool>,
/// Current call state: None=idle, Some(state)=active
pub call_state: Option<CallInfo>,
/// Message IDs for which we've already sent a Read receipt (avoid duplicates).
pub read_receipts_sent: Arc<Mutex<std::collections::HashSet<String>>>,
}
#[derive(Clone)]
@@ -77,6 +79,8 @@ pub struct ChatLine {
pub is_self: bool,
/// Message ID (for sent messages, used to track receipts).
pub message_id: Option<String>,
/// Sender's full fingerprint (for sending read receipts back).
pub sender_fp: Option<String>,
/// When this message was created/received.
pub timestamp: DateTime<Local>,
}
@@ -99,6 +103,7 @@ impl App {
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
}]));
@@ -109,6 +114,7 @@ impl App {
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
} else {
@@ -118,6 +124,7 @@ impl App {
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
}
@@ -128,6 +135,7 @@ impl App {
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
@@ -147,6 +155,7 @@ impl App {
scroll_offset: 0,
connected: Arc::new(AtomicBool::new(false)),
call_state: None,
read_receipts_sent: Arc::new(Mutex::new(std::collections::HashSet::new())),
}
}
@@ -210,6 +219,7 @@ mod tests {
is_system: false,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
};
// Timestamp should be within the last second
@@ -227,6 +237,7 @@ mod tests {
is_system: false,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
let new_count = app.messages.lock().unwrap().len();

View File

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

View File

@@ -28,3 +28,7 @@ bincode.workspace = true
sha2.workspace = true
reqwest = { workspace = true, features = ["rustls-tls", "json"] }
tokio-tungstenite.workspace = true
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] }

View File

@@ -47,6 +47,38 @@ async fn main() -> anyhow::Result<()> {
let mut state = state::AppState::new(&cli.data_dir)?;
// Reload active calls from DB
{
let now = chrono::Utc::now().timestamp();
let mut loaded = 0u32;
let mut expired = 0u32;
for item in state.db.calls.iter().flatten() {
if let Ok(call) = serde_json::from_slice::<state::CallState>(&item.1) {
match call.status {
state::CallStatus::Ringing | state::CallStatus::Active => {
if now - call.created_at > 86400 {
let mut ended = call.clone();
ended.status = state::CallStatus::Ended;
ended.ended_at = Some(now);
let _ = state.db.calls.insert(
&item.0,
serde_json::to_vec(&ended).unwrap_or_default(),
);
expired += 1;
} else {
state.active_calls.lock().await.insert(call.call_id.clone(), call);
loaded += 1;
}
}
_ => {} // Ended calls stay in DB but not in memory
}
}
}
if loaded > 0 || expired > 0 {
tracing::info!("Calls: loaded {} active, expired {} stale", loaded, expired);
}
}
// Load federation config if provided
if let Some(ref fed_path) = cli.federation {
let fed_config = federation::load_config(fed_path)?;

View File

@@ -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,
}
}))

View File

@@ -19,7 +19,7 @@ pub fn fp_to_numeric_id_for_bot(fp: &str, bot_token: &str) -> i64 {
let hash = hasher.finalize();
let mut arr = [0u8; 8];
arr.copy_from_slice(&hash[..8]);
(i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF) // ensure positive
i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF // ensure positive
}
/// Convert a fingerprint hex string to a stable i64 ID (non-bot contexts).

View File

@@ -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-v26';
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
self.addEventListener('install', e => {
@@ -150,7 +150,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
#chat-header .tag { padding: 1px 6px; border-radius: 3px; font-size: 0.85em; }
.tag-fp { background: #0a2e0a; color: #4ade80; }
.tag-peer { background: #2e2e0a; color: #e6a23c; }
.tag-server { color: #444; }
.tag-server { color: #666; font-size: 0.8em; }
#chat-header input { background: #1a1a2e; border: 1px solid #333; color: #e6a23c; padding: 2px 6px;
border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; }
@@ -248,7 +248,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
<span class="tag tag-fp" id="hdr-fp" style="cursor:pointer" title="Click to copy"></span>
<span id="hdr-eth" style="display:none"></span>
<span>→</span>
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
<input id="peer-input" placeholder="ETH address, fingerprint, or @alias" autocomplete="off">
<span class="tag-server" id="hdr-server"></span>
</div>
<div id="call-bar">
@@ -281,13 +281,14 @@ let wasmIdentity = null; // WasmIdentity from WASM
let myFingerprint = '';
let myEthAddress = '';
let mySeedHex = '';
let peerEthAddr = null; // Peer's ETH address (for display; null if set by fingerprint)
let sessions = {}; // peerFP -> { session: WasmSession, data: base64 }
let peerBundles = {}; // peerFP -> bundle bytes
let pollTimer = null;
let ws = null; // WebSocket connection
let wasmReady = false;
const VERSION = '0.0.38';
const VERSION = '0.0.44';
let DEBUG = true; // toggle with /debug command
// ── Receipt tracking ──
@@ -348,6 +349,23 @@ function normFP(fp) {
return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
}
function peerDisplayName() {
if (peerEthAddr) return peerEthAddr.slice(0, 12) + '...';
const v = document.getElementById('peer-input').value.trim();
return v ? v.slice(0, 16) + '...' : '?';
}
function updatePeerDisplay() {
// Resolve ETH address for display if we have a fingerprint
const fp = document.getElementById('peer-input').value.trim();
if (fp && !fp.startsWith('#') && !fp.startsWith('@') && !peerEthAddr) {
// Try to get ETH address from server
fetch(SERVER + '/v1/resolve/' + fp).then(r => r.json()).then(data => {
if (data.eth_address) { peerEthAddr = data.eth_address; }
}).catch(() => {});
}
}
function makeAddressClickable(text) {
// Match fingerprint format: xxxx:xxxx:xxxx:xxxx... (at least 4 groups)
text = text.replace(/([0-9a-f]{4}(?::[0-9a-f]{4}){3,})/gi, function(match) {
@@ -893,8 +911,8 @@ function renderMd(text) {
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold: **...**
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic: *...*
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
// Italic: *...* (single asterisk, not double)
s = s.replace(/([^*]|^)\*([^*]+?)\*([^*]|$)/g, '$1<em>$2</em>$3');
// Headers: ### ... (at line start)
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
@@ -1084,6 +1102,7 @@ async function enterChat() {
if (savedPeer) {
$peerInput.value = savedPeer;
}
peerEthAddr = localStorage.getItem('wz-peer-eth') || null;
connectWebSocket();
@@ -1244,18 +1263,18 @@ function updateCallUI() {
}
break;
case 'calling':
status.textContent = '\u{1F4DE} Calling ' + (callPeer || '...').slice(0, 16);
status.textContent = '\u{1F4DE} Calling ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '...').slice(0, 16));
status.className = 'call-status';
btnHangup.style.display = '';
break;
case 'ringing':
status.textContent = '\u{1F4DE} Incoming call from ' + (callPeer || '?').slice(0, 16);
status.textContent = '\u{1F4DE} Incoming call from ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16));
status.className = 'call-status incoming-call';
btnAccept.style.display = '';
btnReject.style.display = '';
break;
case 'active':
status.textContent = '\u{1F50A} In call with ' + (callPeer || '?').slice(0, 16);
status.textContent = '\u{1F50A} In call with ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16));
status.className = 'call-status';
btnHangup.style.display = '';
break;
@@ -1263,18 +1282,29 @@ function updateCallUI() {
}
async function startCall() {
const peer = $peerInput.value.trim();
let peer = $peerInput.value.trim();
if (!peer || peer.startsWith('#')) { addSys('Set a peer first'); return; }
// Resolve ETH address or @alias to fingerprint
if (peer.startsWith('@') || peer.startsWith('0x') || peer.startsWith('0X')) {
const endpoint = peer.startsWith('@') ? '/v1/alias/resolve/' + peer.slice(1) : '/v1/resolve/' + peer;
try {
const resp = await fetch(SERVER + endpoint);
const data = await resp.json();
if (data.error) { addSys('Cannot resolve peer: ' + data.error); return; }
peer = data.fingerprint;
} catch(e) { addSys('Cannot resolve peer: ' + e.message); return; }
}
callState = 'calling';
callPeer = peer;
updateCallUI();
// Send CallSignal::Offer via WS
try {
const signalBytes = create_call_signal(wasmIdentity, 'offer', '', normFP(peer));
if (ws && ws.readyState === WebSocket.OPEN) {
const fp = normFP(peer);
const signalBytes = create_call_signal(wasmIdentity, 'offer', '', fp);
if (ws && ws.readyState === WebSocket.OPEN) {
const header = new TextEncoder().encode(fp.padEnd(64, '0').slice(0, 64));
const payload = new Uint8Array(header.length + signalBytes.length);
payload.set(header);
@@ -1304,6 +1334,7 @@ function acceptCall() {
payload.set(signalBytes, header.length);
ws.send(payload);
addSys('Call accepted');
startAudio();
}
} catch(e) { addSys('Accept failed: ' + e.message); }
}
@@ -1321,6 +1352,7 @@ function rejectCall() {
ws.send(payload);
}
} catch(e) {}
stopAudio();
addSys('Call rejected');
callState = 'idle';
callPeer = null;
@@ -1340,6 +1372,7 @@ function hangupCall() {
ws.send(payload);
}
} catch(e) {}
stopAudio();
addSys('Call ended');
callState = 'idle';
callPeer = null;
@@ -1366,11 +1399,13 @@ function handleCallSignal(signal) {
callState = 'active';
updateCallUI();
addSys('Call connected!');
startAudio();
}
break;
case 'hangup':
case 'reject':
if (callState !== 'idle') {
stopAudio();
addSys('Call ended' + (type === 'reject' ? ' (rejected)' : ''));
callState = 'idle';
callPeer = null;
@@ -1393,6 +1428,152 @@ function handleCallSignal(signal) {
}
}
// ═══════════════════════════════════════════════
// SECTION: Audio Bridge (WZP integration)
// ═══════════════════════════════════════════════
let audioWs = null;
let audioCtx = null;
let mediaStream = null;
let captureNode = null;
let playbackNode = null;
async function startAudio() {
// Fetch relay config (includes auth token)
let relayAddr, authToken;
try {
const resp = await fetch(SERVER + '/v1/wzp/relay-config');
const data = await resp.json();
relayAddr = data.relay_addr;
authToken = data.token;
dbg('Relay address:', relayAddr, 'token:', authToken);
} catch(e) {
addSys('Audio: cannot get relay config \u2014 ' + e.message);
return;
}
// Request microphone
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true }
});
} catch(e) {
addSys('Audio: mic access denied \u2014 ' + e.message);
return;
}
audioCtx = new AudioContext({ sampleRate: 48000 });
// Deterministic room: sort both fingerprints so both peers get the same room
const myFP = normFP(myFingerprint);
const peerFP = callPeer ? normFP(callPeer) : '';
const roomPair = [myFP, peerFP].sort().join('-');
const room = roomPair.slice(0, 32);
const host = relayAddr.replace(/^https?:\/\//, '');
const proto = host.startsWith('localhost') || host.startsWith('127.') ? 'ws:' : 'wss:';
const wsUrl = proto + '//' + host + '/ws/' + room;
addSys('Audio: connecting to room ' + room.slice(0, 12) + '...');
audioWs = new WebSocket(wsUrl);
audioWs.binaryType = 'arraybuffer';
audioWs.onopen = async () => {
// Send auth token as first message (required by wzp-web --auth-url)
audioWs.send(JSON.stringify({ type: 'auth', token: authToken }));
addSys('Audio: connected \u2014 mic active');
// Capture: mic -> PCM frames -> WS
const source = audioCtx.createMediaStreamSource(mediaStream);
// Use ScriptProcessor as fallback (AudioWorklet needs a separate file)
const bufferSize = 960; // 20ms at 48kHz
const processor = audioCtx.createScriptProcessor(1024, 1, 1);
let captureBuffer = new Float32Array(0);
processor.onaudioprocess = (e) => {
if (callState !== 'active' || !audioWs || audioWs.readyState !== WebSocket.OPEN) return;
const input = e.inputBuffer.getChannelData(0);
// Accumulate samples
const combined = new Float32Array(captureBuffer.length + input.length);
combined.set(captureBuffer);
combined.set(input, captureBuffer.length);
captureBuffer = combined;
// Send 960-sample frames (20ms)
while (captureBuffer.length >= bufferSize) {
const frame = captureBuffer.slice(0, bufferSize);
captureBuffer = captureBuffer.slice(bufferSize);
// Convert float32 to int16
const pcm = new Int16Array(frame.length);
for (let i = 0; i < frame.length; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
}
audioWs.send(pcm.buffer);
}
};
source.connect(processor);
processor.connect(audioCtx.destination); // needed to keep processor alive
captureNode = processor;
// Playback buffer
playbackNode = { queue: [] };
};
audioWs.onmessage = (event) => {
if (!audioCtx) return;
const pcm = new Int16Array(event.data);
if (pcm.length === 0) return;
// Convert int16 to float32 and play
const float32 = new Float32Array(pcm.length);
for (let i = 0; i < pcm.length; i++) {
float32[i] = pcm[i] / 32768.0;
}
const buffer = audioCtx.createBuffer(1, float32.length, 48000);
buffer.getChannelData(0).set(float32);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.start();
};
audioWs.onclose = () => {
if (callState === 'active') {
addSys('Audio: disconnected');
}
};
audioWs.onerror = (e) => {
addSys('Audio: connection error');
dbg('Audio WS error:', e);
};
}
function stopAudio() {
if (audioWs) {
audioWs.close();
audioWs = null;
}
if (captureNode) {
captureNode.disconnect();
captureNode = null;
}
if (mediaStream) {
mediaStream.getTracks().forEach(t => t.stop());
mediaStream = null;
}
if (audioCtx) {
audioCtx.close().catch(() => {});
audioCtx = null;
}
playbackNode = null;
}
// ═══════════════════════════════════════════════
// SECTION: Command Handlers
// ═══════════════════════════════════════════════
@@ -1513,14 +1694,20 @@ async function doSend() {
const resp = await fetch(SERVER + endpoint);
const data = await resp.json();
if (data.error) { addSys('Cannot resolve ' + val + ': ' + data.error); return; }
peerEthAddr = (val.startsWith('0x') || val.startsWith('0X')) ? val : (data.eth_address || null);
$peerInput.value = data.fingerprint;
localStorage.setItem('wz-peer', val);
if (peerEthAddr) localStorage.setItem('wz-peer-eth', peerEthAddr);
addSys(val + ' \u2192 ' + data.fingerprint.slice(0,16) + '...');
addSys('Peer set to ' + (peerEthAddr || data.fingerprint.slice(0,16) + '...'));
} else {
$peerInput.value = val;
peerEthAddr = null;
localStorage.setItem('wz-peer', val);
localStorage.removeItem('wz-peer-eth');
}
currentGroup = null;
localStorage.setItem('wz-peer', $peerInput.value);
addSys('Peer set to ' + $peerInput.value.slice(0,16) + '...');
addSys('Peer set to ' + peerDisplayName());
updateCallUI();
return;
}
@@ -1648,6 +1835,45 @@ $input.addEventListener('input', function() {
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
// Peer input: Enter sets peer (like /peer command)
document.getElementById('peer-input').addEventListener('keydown', async (e) => {
if (e.key !== 'Enter') return;
const val = e.target.value.trim();
if (!val) return;
// Treat as /peer command
if (val.startsWith('@') || val.startsWith('0x') || val.startsWith('0X') || /^[0-9a-fA-F]{16,}$/.test(val)) {
const endpoint = val.startsWith('@') ? '/v1/alias/resolve/' + val.slice(1) : (val.startsWith('0x') || val.startsWith('0X')) ? '/v1/resolve/' + val : null;
if (endpoint) {
try {
const resp = await fetch(SERVER + endpoint);
const data = await resp.json();
if (data.error) { addSys('Cannot resolve: ' + data.error); return; }
// Store ETH address for display, use fingerprint internally
peerEthAddr = val;
e.target.value = data.fingerprint;
localStorage.setItem('wz-peer', val);
localStorage.setItem('wz-peer-eth', val);
addSys(val + ' \u2192 ' + data.fingerprint.slice(0,16) + '...');
addSys('Peer set to ' + val.slice(0,16) + '...');
updatePeerDisplay();
} catch(err) { addSys('Resolve failed: ' + err.message); }
} else {
// Raw fingerprint
peerEthAddr = null;
localStorage.setItem('wz-peer', val);
localStorage.removeItem('wz-peer-eth');
addSys('Peer set to ' + val.slice(0,16) + '...');
updatePeerDisplay();
}
} else if (val.startsWith('#')) {
// Group shortcut
const gname = val.replace('#','');
e.target.value = '#' + gname;
localStorage.setItem('wz-peer', '#' + gname);
addSys('Switched to group #' + gname);
}
});
// Wire up buttons (module scope can't use onclick in HTML)
document.getElementById('btn-generate').onclick = () => doGenerate();
document.getElementById('btn-show-recover').onclick = () => document.getElementById('recover-area').style.display = 'block';

View File

@@ -135,7 +135,14 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
// For simplicity: first 32 hex chars = recipient fp, rest = message
if data.len() > 64 {
let header = String::from_utf8_lossy(&data[..64]).to_string();
let to_fp = normalize_fp(&header);
let raw_fp = normalize_fp(&header);
// The WS header is 64 hex chars (32 bytes padded with '0').
// Fingerprints are 32 hex chars. Truncate to 32 if zero-padded.
let to_fp = if raw_fp.len() > 32 && raw_fp[32..].chars().all(|c| c == '0') {
raw_fp[..32].to_string()
} else {
raw_fp
};
let message = &data[64..];
// Dedup: skip if we already processed this message ID

View File

@@ -273,3 +273,142 @@ impl AppState {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_state() -> AppState {
let dir = tempfile::tempdir().unwrap();
AppState::new(dir.path().to_str().unwrap()).unwrap()
}
#[tokio::test]
async fn push_to_client_returns_false_when_offline() {
let state = test_state();
assert!(!state.push_to_client("abc123", b"hello").await);
}
#[tokio::test]
async fn register_ws_and_push() {
let state = test_state();
let (_, mut rx) = state.register_ws("test_fp", None).await.unwrap();
assert!(state.push_to_client("test_fp", b"hello").await);
let msg = rx.recv().await.unwrap();
assert_eq!(msg, b"hello");
}
#[tokio::test]
async fn ws_connection_cap() {
let state = test_state();
// Hold receivers so senders stay open (register_ws prunes closed senders).
let mut _holders = Vec::new();
for i in 0..5 {
let res = state.register_ws("same_fp", None).await;
assert!(res.is_some(), "connection {} should succeed", i);
_holders.push(res.unwrap());
}
// 6th should fail
assert!(state.register_ws("same_fp", None).await.is_none());
}
#[tokio::test]
async fn is_online_and_device_count() {
let state = test_state();
assert!(!state.is_online("fp1").await);
assert_eq!(state.device_count("fp1").await, 0);
// Must hold receivers so the senders are not marked as closed.
let _r1 = state.register_ws("fp1", None).await;
assert!(state.is_online("fp1").await);
assert_eq!(state.device_count("fp1").await, 1);
let _r2 = state.register_ws("fp1", None).await;
assert_eq!(state.device_count("fp1").await, 2);
}
#[tokio::test]
async fn kick_device() {
let state = test_state();
let (device_id, _) = state.register_ws("fp1", None).await.unwrap();
assert!(state.kick_device("fp1", &device_id).await);
assert!(!state.is_online("fp1").await);
}
#[tokio::test]
async fn revoke_all_except() {
let state = test_state();
let (id1, _rx1) = state.register_ws("fp1", None).await.unwrap();
let (_id2, _rx2) = state.register_ws("fp1", None).await.unwrap();
let (_id3, _rx3) = state.register_ws("fp1", None).await.unwrap();
let removed = state.revoke_all_except("fp1", &id1).await;
assert_eq!(removed, 2);
assert_eq!(state.device_count("fp1").await, 1);
}
#[tokio::test]
async fn deliver_or_queue_offline() {
let state = test_state();
// No WS connected -- should queue
let delivered = state.deliver_or_queue("offline_fp", b"test message").await;
assert!(!delivered);
// Check message was queued in DB
let prefix = "queue:offline_fp";
let count = state.db.messages.scan_prefix(prefix.as_bytes()).count();
assert_eq!(count, 1);
}
#[tokio::test]
async fn deliver_or_queue_online() {
let state = test_state();
let (_, mut rx) = state.register_ws("online_fp", None).await.unwrap();
let delivered = state.deliver_or_queue("online_fp", b"instant").await;
assert!(delivered);
let msg = rx.recv().await.unwrap();
assert_eq!(msg, b"instant");
}
#[tokio::test]
async fn call_state_lifecycle() {
let state = test_state();
let call = CallState {
call_id: "call-001".into(),
caller_fp: "alice".into(),
callee_fp: "bob".into(),
group_name: None,
room_id: None,
status: CallStatus::Ringing,
created_at: chrono::Utc::now().timestamp(),
answered_at: None,
ended_at: None,
};
state.active_calls.lock().await.insert("call-001".into(), call);
assert_eq!(state.active_calls.lock().await.len(), 1);
// End the call
if let Some(mut c) = state.active_calls.lock().await.remove("call-001") {
c.status = CallStatus::Ended;
c.ended_at = Some(chrono::Utc::now().timestamp());
let _ = state.db.calls.insert(b"call-001", serde_json::to_vec(&c).unwrap());
}
assert_eq!(state.active_calls.lock().await.len(), 0);
}
#[tokio::test]
async fn list_devices() {
let state = test_state();
let _r1 = state.register_ws("fp1", None).await;
let _r2 = state.register_ws("fp1", None).await;
let devices = state.list_devices("fp1").await;
assert_eq!(devices.len(), 2);
}
}

View File

@@ -253,6 +253,10 @@ The bridge translates numeric chat_id ↔ fingerprints automatically.
| parse_mode HTML | rendered | rendered in web client |
| Media groups | yes | not yet |
## Voice Calls
Bots cannot initiate or participate in voice calls. Voice is peer-to-peer only between human clients (web or TUI). Call signaling messages (`CallSignal` type) are delivered to bots via getUpdates as `text="/call_Offer"` etc., but bots should ignore them -- there is no audio path for bots.
## Key Rules
1. **Always use offset** in getUpdates — without it you reprocess messages

View File

@@ -195,6 +195,40 @@ while True:
time.sleep(1)
```
## Voice Calls
### Architecture
Call signaling flows through the featherChat WebSocket (offer/answer/hangup/reject/ringing/busy).
Audio flows through a separate WZP relay infrastructure:
```
Browser A <--WS--> wzp-web <--QUIC--> wzp-relay <--QUIC--> wzp-web <--WS--> Browser B
| |
featherChat server (/v1/auth/validate)
```
### Key files
- Call signaling: `warzone-server/src/routes/ws.rs` (WireMessage::CallSignal handling)
- Call state: `warzone-server/src/state.rs` (CallState, active_calls)
- Relay config: `warzone-server/src/routes/wzp.rs` (token issuance)
- Web audio: `warzone-server/src/routes/web.rs` (startAudio/stopAudio functions)
- TUI calls: `warzone-client/src/tui/commands.rs` (/call, /accept, /reject, /hangup)
- Protocol: `warzone-protocol/src/message.rs` (CallSignal, CallSignalType)
### Environment
- `WZP_RELAY_ADDR` -- tells featherChat server where wzp-web bridge is (e.g., `127.0.0.1:8080`)
- Without this, `/v1/wzp/relay-config` returns default `127.0.0.1:4433`
### Commands
cmd | action | example
--- | --- | ---
/call | start voice call with current peer | /call
/call <addr> | start voice call with specific peer | /call @alice
/accept | accept incoming call | /accept
/reject | reject incoming call | /reject
/hangup | end current call | /hangup
## Server API (other endpoints)
- POST /v1/register -- upload prekey bundle

View File

@@ -431,6 +431,56 @@ Telegram bot libraries can be adapted with minimal changes.
---
## Voice Calls (WZP Integration)
featherChat supports voice calls via the WarzonePhone (WZP) audio relay. Three components work together:
### Components
| Component | Binary | Port | Purpose |
|-----------|--------|------|---------|
| featherChat server | `warzone-server` | 7700 | Signaling (offer/answer/hangup) + auth tokens |
| WZP relay | `wzp-relay` | 4433 | QUIC audio relay (SFU) |
| WZP web bridge | `wzp-web` | 8080 | Browser WebSocket ↔ QUIC bridge |
### Running
```bash
# 1. WZP relay (QUIC audio)
./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# 2. WZP web bridge (browser ↔ relay)
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# 3. featherChat server (with relay address)
WZP_RELAY_ADDR=127.0.0.1:8080 ./warzone-server
```
### TLS Requirements
| Scenario | TLS needed? | Why |
|----------|-------------|-----|
| localhost dev | No | Browser allows mic on localhost without HTTPS |
| LAN/remote | wzp-web needs TLS | Browsers require HTTPS for `getUserMedia()` on non-localhost |
| Production | All three should use TLS | Security best practice |
For production TLS on wzp-web:
```bash
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate --cert /path/to/cert.pem --key /path/to/key.pem
```
### Auth Flow
1. User clicks Call -> signaling via featherChat WebSocket
2. Call accepted -> both clients fetch `GET /v1/wzp/relay-config`
3. Server returns `{ relay_addr, token, expires_in: 300 }`
4. Clients connect WebSocket to `ws://relay_addr/ws/ROOM`
5. First message: `{"type":"auth","token":"<token>"}`
6. wzp-web validates token against featherChat `/v1/auth/validate`
7. Audio flows: mic -> PCM -> WS -> wzp-web -> QUIC -> wzp-relay -> peer
---
## 6. Database
The server uses **sled** (embedded key-value store). All data lives under

410
warzone/docs/TESTING_E2E.md Normal file
View File

@@ -0,0 +1,410 @@
# featherChat End-to-End Testing Guide
**Version:** 0.0.43
---
## Prerequisites
### Local Testing
```bash
# Build everything
cargo build --release --bin warzone-server --bin warzone-client
wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg
# Binaries
./target/release/warzone-server
./target/release/warzone-client
```
### Two-Server Testing (Federation)
```bash
# Server Alpha
./warzone-server --bind 0.0.0.0:7700 --federation alpha.json --enable-bots --bots-config bots.json
# Server Bravo
./warzone-server --bind 0.0.0.0:7700 --federation bravo.json --enable-bots --bots-config bots.json
```
### Voice Call Testing (requires WZP relay)
```bash
# Terminal A: WZP relay (QUIC audio SFU)
./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# Terminal B: WZP web bridge (browser WebSocket <-> QUIC)
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# Terminal C: featherChat server with relay address
export WZP_RELAY_ADDR=127.0.0.1:8080
./warzone-server
```
---
## Test 1: Basic Messaging (TUI ↔ TUI)
### Setup
```bash
# Terminal 1: Server
./target/release/warzone-server
# Terminal 2: User A
./target/release/warzone-client init
./target/release/warzone-client register --server http://localhost:7700
./target/release/warzone-client tui --server http://localhost:7700
# Terminal 3: User B
WARZONE_HOME=~/.warzone-b ./target/release/warzone-client init
WARZONE_HOME=~/.warzone-b ./target/release/warzone-client register --server http://localhost:7700
WARZONE_HOME=~/.warzone-b ./target/release/warzone-client tui --server http://localhost:7700
```
### Steps
1. **User A**: Note the ETH address shown at startup (e.g., `0x85e3D8...`)
2. **User B**: `/peer 0x85e3D8e4a6EEfc048fc80497773D440Bf3487D2b`
3. **User B**: Type `Hello!` and press Enter
4. **User A**: Should see the message with ✓ (sent) → ✓✓ (delivered)
5. **User A**: `/r Hi back!` (reply)
6. **User B**: Should see the reply
### Verify
- [x] Messages delivered in real-time (< 1 second)
- [x] ✓ appears on send, ✓✓ on delivery
- [x] Timestamps show [HH:MM]
- [x] ETH address shown in header
- [x] `/info` shows both ETH and fingerprint
---
## Test 2: Basic Messaging (Web ↔ Web)
### Setup
1. Open browser tab 1: `http://localhost:7700`
2. Click "Generate Identity" → note the ETH address
3. Open browser tab 2 (incognito): `http://localhost:7700`
4. Click "Generate Identity"
### Steps
1. **Tab 2**: Paste Tab 1's ETH address in the peer input box
2. **Tab 2**: Type "Hello from web!" → Send
3. **Tab 1**: Should see the message
4. **Tab 1**: `/peer <tab2_eth_address>` → Type "Hi!" → Send
5. **Tab 2**: Should see the reply
### Verify
- [x] Messages show with 🔒 prefix (E2E encrypted)
- [x] ETH address shown in header (click to copy)
- [x] Markdown renders (**bold**, `code`, etc.)
- [x] Scrollbar visible and working
---
## Test 3: TUI ↔ Web Cross-Client
### Steps
1. Start TUI (User A) and Web (User B) as above
2. **Web**: `/peer <TUI_eth_address>` → Send message
3. **TUI**: Should see the message with terminal bell
4. **TUI**: `/r Hello from terminal!`
5. **Web**: Should see the reply
### Verify
- [x] Cross-client encryption works (TUI encrypts, Web decrypts and vice versa)
- [x] Receipts flow correctly between clients
---
## Test 4: Group Messaging
### Steps
1. **User A**: `/gcreate testgroup`
2. **User A**: `/g testgroup`
3. **User B**: `/g testgroup` (auto-joins)
4. **User A**: Type "Hello group!" → Send
5. **User B**: Should see `UserA [#testgroup]: Hello group!`
6. **User B**: Type "Reply!" → Send
7. **User A**: Should see the reply
### Verify
- [x] Group creation works
- [x] Auto-join on `/g`
- [x] Messages fan-out to all members
- [x] `/gmembers` shows online status (● / ○)
---
## Test 5: Federation (Two Servers)
### Setup
```bash
# Server Alpha (Terminal 1)
./warzone-server --bind 0.0.0.0:7700 --federation alpha.json
# Server Bravo (Terminal 2)
./warzone-server --bind 0.0.0.0:7701 --data-dir ./data-bravo --federation bravo.json
```
`alpha.json`:
```json
{"server_id":"alpha","shared_secret":"test123","peer":{"id":"bravo","url":"http://127.0.0.1:7701"}}
```
`bravo.json`:
```json
{"server_id":"bravo","shared_secret":"test123","peer":{"id":"alpha","url":"http://127.0.0.1:7700"}}
```
### Steps
1. **User A** connects to Alpha (port 7700)
2. **User B** connects to Bravo (port 7701)
3. Wait 5 seconds for federation presence sync
4. **User A**: `/peer <UserB_eth_address>` → Send message
5. **User B**: Should receive the message
### Verify
- [x] Server logs show "Federation: connected to peer"
- [x] `GET /v1/federation/status` returns `"peer_connected": true`
- [x] Messages route across servers transparently
- [x] Key bundles proxy via federation (no "Peer not registered")
- [x] Aliases resolve across servers
---
## Test 6: File Transfer
### Steps
1. Set up two peers (TUI or Web)
2. **Sender**: `/file /path/to/small-file.txt` (must be < 10MB)
3. **Receiver**: Should see "Incoming file..." → chunk progress → "File saved: ..."
4. Verify the file at `~/.warzone/downloads/small-file.txt`
### Verify
- [x] SHA-256 integrity check passes
- [x] File appears in downloads directory
- [x] Progress shown per chunk
---
## Test 7: Call Signaling
### Steps (Web ↔ Web)
1. **User A**: Set peer to User B
2. **User A**: Click 📞 Call button (or `/call`)
3. **User B**: Should see "📞 Incoming call" with Accept/Reject buttons
4. **User B**: Click ✓ Accept
5. Both: Should see "Call connected!" / "🔊 In call"
6. **Either**: Click "End Call" (or `/hangup`)
7. Both: Should see "Call ended"
### Steps (TUI ↔ TUI)
1. **User A**: `/call <peer_address>`
2. **User A**: Header shows yellow "📞 Calling..."
3. **User B**: "📞 Incoming call from ... — /accept or /reject"
4. **User B**: `/accept`
5. **User A**: Header shows green "🔊 0:00" timer
6. **User A** or **B**: `/hangup`
### Verify
- [x] Call bar appears in web when peer is set
- [x] Incoming call notification (pulsing animation in web, bell in TUI)
- [x] Call state updates in header (TUI) / call bar (web)
- [x] Hangup/reject cleans up state on both sides
---
## Test 8: Voice Call Audio (requires WZP relay)
### Prerequisites
```bash
# Terminal 1: WZP relay (QUIC audio SFU)
./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# Terminal 2: WZP web bridge (browser WebSocket <-> QUIC)
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# Terminal 3: featherChat server
WZP_RELAY_ADDR=127.0.0.1:8080 ./warzone-server
```
### Steps
1. Open two browser tabs to `http://localhost:7700`
2. **Tab 1**: Set peer to Tab 2
3. **Tab 1**: Click 📞 Call
4. **Tab 2**: Click ✓ Accept
5. Both: Allow microphone access when prompted
6. **Speak into mic** — other tab should hear audio
7. End call
### Verify
- [x] "Audio: connecting to ..." message appears
- [x] "Audio: connected — mic active" confirms WS to relay
- [x] Audio flows bidirectionally
- [x] Audio stops on hangup
- [x] No audio leak after call ends
---
## Test 9: Bot API
### Setup
```bash
# Server with bots enabled
./warzone-server --enable-bots --bots-config bots.json
```
### Create a bot via BotFather
1. Open web client
2. `/peer @botfather`
3. Type `/newbot TestEchoBot`
4. Note the token from BotFather's reply
### Run echo bot
```python
import requests, time
TOKEN = "YOUR_TOKEN_HERE"
API = f"http://localhost:7700/v1/bot/{TOKEN}"
offset = 0
while True:
r = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 30}).json()
for u in r.get("result", []):
offset = u["update_id"] + 1
msg = u.get("message", {})
text, cid = msg.get("text"), msg.get("chat", {}).get("id")
if text and cid:
requests.post(f"{API}/sendMessage", json={"chat_id": cid, "text": f"Echo: {text}"})
time.sleep(0.1)
```
### Test messaging the bot
1. `/peer @testechobot`
2. Type "Hello bot!"
3. Bot should reply "Echo: Hello bot!"
### Verify
- [x] BotFather creates bot and returns token
- [x] Bot receives plaintext messages (not encrypted)
- [x] Bot replies appear in chat
- [x] Markdown in bot replies renders correctly
- [x] Inline keyboards render as clickable buttons (if bot sends reply_markup)
---
## Test 10: System Bots (from config)
### Verify
1. Start server with `--bots-config bots.json`
2. Check `data/bot-tokens.txt` exists with all tokens
3. Open web client — welcome screen shows "Available bots: @helpbot, @codebot, ..."
4. `/peer @helpbot` → Send "hello" → Bot should respond (if bot process is running)
---
## Test 11: Device Management
### Steps
1. Connect with TUI
2. Open web client (same identity or different)
3. **TUI**: `/devices` — should list both sessions
4. **TUI**: `/kick <web_device_id>`
5. **Web**: Connection should drop
### Verify
- [x] `/devices` shows device IDs and connection times
- [x] `/kick` disconnects the target device
- [x] Max 5 devices per identity enforced
---
## Test 12: Friend List
### Steps
1. **User A**: `/friend <UserB_address>`
2. **User A**: `/friend` (no args) — should list User B with online/offline status
3. **User A**: `/unfriend <UserB_address>`
4. **User A**: `/friend` — should show empty
### Verify
- [x] Friend list persists across restarts (encrypted on server)
- [x] Online/offline status shown
- [x] Add/remove works
---
## Test 13: Session Recovery
### Steps
1. Establish a session between two peers (exchange messages)
2. Delete one peer's session DB: `rm -rf ~/.warzone/db/`
3. Restart that peer's TUI
4. Other peer sends a message
5. Should see "[session reset]" and then re-establish
### Verify
- [x] "[session reset]" message appears
- [x] Subsequent messages work after re-X3DH
---
## Test 14: Auto-Backup
### Steps
1. Start TUI client
2. Wait 5 minutes (or use `/backup` for immediate)
3. Check `~/.warzone/backups/` for `.wzbk` files
4. Only 3 most recent should exist
### Verify
- [x] `/backup` creates file immediately
- [x] Auto-backup runs every 5 minutes
- [x] Old backups rotated (max 3)
---
## Test 15: Protocol Versioning
### Steps
1. Send a message normally — raw bincode (legacy format)
2. Check server logs — should accept it
3. Upgrade client to send envelope format in the future
4. Old server should still accept legacy
5. New server accepts both
### Verify
- [x] Legacy (raw bincode) still works
- [x] Envelope `[WZ][v1][len][payload]` accepted
- [x] Future version envelope rejected with clear error
---
## Quick Smoke Test (5 minutes)
If you only have 5 minutes, test these:
1. `./warzone-server --enable-bots --bots-config bots.json`
2. Open `http://localhost:7700` in two browser tabs
3. Tab 1: Generate identity
4. Tab 2: Generate identity, `/peer <tab1_eth_address>`
5. Tab 2: Send "**Hello!**" → Tab 1 should see bold text
6. Tab 1: `/peer @botfather``/newbot QuickBot` → Note token
7. Start echo bot with the token (Python script above)
8. Tab 1: `/peer @quickbot` → "test" → Should get "Echo: test"
9. Tab 1: `/peer <tab2_address>` → Click 📞 Call → Tab 2: Accept
10. Both: Should see "Call connected!" (audio needs WZP relay running)
---
## Troubleshooting
| Issue | Cause | Fix |
|-------|-------|-----|
| "Peer not registered" | Peer hasn't registered keys | Peer needs to open client first |
| "[message could not be decrypted]" | Stale session or cached bundle | Clear localStorage (web) or delete session DB |
| "alias not found" | Bot/alias doesn't exist on this server | Check `--enable-bots`, wipe data + restart |
| No audio | WZP relay not running | Start `wzp-relay` + `wzp-web` + set `WZP_RELAY_ADDR` |
| Federation not working | Peer server down or wrong config | Check `GET /v1/federation/status` on both |
| "connection limit reached" | 5 devices max | `/devices``/kick` old ones |
| Version mismatch (web) | Old service worker cached | Hard refresh (Cmd+Shift+R) |
| Bot not responding | Bot process not running | Check bot process is polling getUpdates |

View File

@@ -287,6 +287,32 @@ The web client supports the same slash commands as the TUI: `/peer`, `/p`, `/r`,
---
## Voice Calls
### Web Client
1. Set a peer (paste ETH address or use `/peer @alias`)
2. Click the Call button or type `/call`
3. Peer sees "Incoming call" and clicks Accept
4. Both allow microphone access
5. Audio flows -- speak normally
6. Click "End Call" or type `/hangup` to end
### TUI Client
1. `/call <peer_address>` -- initiate call
2. Peer sees notification and can use `/accept` or `/reject`
3. Audio currently requires web client (TUI shows hint)
4. `/hangup` -- end call
### Commands
| Command | Description |
|---------|-------------|
| `/call` | Start voice call with current peer |
| `/accept` | Accept incoming call |
| `/reject` | Reject incoming call |
| `/hangup` | End current call |
---
## Groups
### Creating and Using Groups