Compare commits
40 Commits
5764719375
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c248442c2 | ||
|
|
5ae87be316 | ||
|
|
f698b25fad | ||
|
|
7924871559 | ||
|
|
8a4f0ef8ee | ||
|
|
561f2d6978 | ||
|
|
da3cdd7234 | ||
|
|
cc76004655 | ||
|
|
9af5ec96b5 | ||
|
|
02471b28ba | ||
|
|
74af18463e | ||
|
|
b22200e3be | ||
|
|
850944944d | ||
|
|
47030a3b29 | ||
|
|
cac812665c | ||
|
|
f272a82faf | ||
|
|
11133cf968 | ||
|
|
8b00144b2f | ||
|
|
bf9594f1de | ||
|
|
366ab30988 | ||
|
|
fb29eb0fce | ||
|
|
33c39c6541 | ||
|
|
3d387e5821 | ||
|
|
38f992c284 | ||
|
|
59d68b2a5e | ||
|
|
f33ac1cad8 | ||
|
|
c2be68ca20 | ||
|
|
d7b75a6641 | ||
|
|
93923676a8 | ||
|
|
2612d46f5c | ||
|
|
983afc5916 | ||
|
|
81954b1b0c | ||
|
|
7c4e6a1c1e | ||
|
|
db88282bf6 | ||
|
|
5bbc197369 | ||
|
|
87d7ab16c2 | ||
|
|
6f1dbde7cc | ||
|
|
5bc59376f5 | ||
|
|
1295f1c937 | ||
|
|
c37bd7934c |
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
**/target
|
||||
**/node_modules
|
||||
**/.git
|
||||
**/.DS_Store
|
||||
**/.claude
|
||||
**/wasm-pkg
|
||||
apache
|
||||
nginx
|
||||
nginx.txt
|
||||
chat.py
|
||||
tunnel.py
|
||||
DESIGN.md
|
||||
Submodule warzone-phone updated: 6f4e8eb9f6...1d33f3ed4e
@@ -14,7 +14,7 @@ Never commit functional changes without bumping all four. The service worker cac
|
||||
|
||||
1. **Single seed, multiple identities** — Ed25519 (messaging), X25519 (encryption), secp256k1 (ETH address) all derived from one BIP39 seed via HKDF with domain-separated info strings.
|
||||
|
||||
2. **E2E by default** — All user messages are Double Ratchet encrypted. The server NEVER sees plaintext. Friend lists are client-side encrypted. Only bot messages are plaintext (v1).
|
||||
2. **E2E by default** — All user messages are Double Ratchet encrypted. The server NEVER sees plaintext. Friend lists are client-side encrypted. Only bot messages are plaintext (v1). Group calls are transport-encrypted only (QUIC/TLS); MLS (RFC 9420) E2E encryption for group calls is planned but not yet implemented.
|
||||
|
||||
3. **Server is semi-trusted** — Server sees metadata (who talks to whom, timing, groups) but cannot read message content. Design all features with this trust boundary in mind.
|
||||
|
||||
@@ -44,6 +44,7 @@ Never commit functional changes without bumping all four. The service worker cac
|
||||
- JS embedded in `routes/web.rs` as Rust raw string — careful with escaping
|
||||
- Service worker cache version must be bumped on WASM changes (`wz-vN`)
|
||||
- `WasmSession::initiate()` stores X3DH result — `encrypt_key_exchange` must NOT re-initiate
|
||||
- Ring tones use Web Audio API oscillators (no audio files) — see `startRingTone()`/`startRingbackTone()`/`stopRingTone()` in `web.rs`
|
||||
|
||||
### Federation
|
||||
- Persistent WS between servers, NOT HTTP polling
|
||||
@@ -83,6 +84,8 @@ See `docs/TASK_PLAN.md` for the full breakdown.
|
||||
| TUI commands | `warzone-client/src/tui/commands.rs` |
|
||||
| Web client | `warzone-server/src/routes/web.rs` |
|
||||
| WASM bridge | `warzone-wasm/src/lib.rs` |
|
||||
| Group signal endpoint | `warzone-server/src/routes/groups.rs` (`signal_group`) |
|
||||
| Ring tone functions | `warzone-server/src/routes/web.rs` (`startRingTone`, `startRingbackTone`, `stopRingTone`) |
|
||||
| Task plan | `docs/TASK_PLAN.md` |
|
||||
| Bot API docs | `docs/BOT_API.md` |
|
||||
| LLM help ref | `docs/LLM_HELP.md` |
|
||||
|
||||
11
warzone/Cargo.lock
generated
11
warzone/Cargo.lock
generated
@@ -2956,7 +2956,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-client"
|
||||
version = "0.0.38"
|
||||
version = "0.0.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -2989,7 +2989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-mule"
|
||||
version = "0.0.38"
|
||||
version = "0.0.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -2998,7 +2998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.38"
|
||||
version = "0.0.47"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
@@ -3023,7 +3023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-server"
|
||||
version = "0.0.38"
|
||||
version = "0.0.47"
|
||||
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.47"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.38"
|
||||
version = "0.0.47"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
@@ -6,8 +6,13 @@ End-to-end encrypted messenger with Signal protocol cryptography, voice/video ca
|
||||
|
||||
- **E2E Encrypted DMs** — X3DH key exchange + Double Ratchet (forward secrecy)
|
||||
- **Group Messaging** — Sender Key protocol (O(1) encryption, fan-out delivery)
|
||||
- **Voice Calls (WZP)** — DM and group calls via WarzonePhone audio bridge (QUIC SFU relay, ChaCha20-Poly1305 media)
|
||||
- **Ring Tones** — Audible ring on incoming calls (web client)
|
||||
- **Group Calls** — Multi-party audio via /gcall, /gjoin, /gleave-call, /gmute
|
||||
- **Read Receipts** — Sent, delivered, and read indicators (viewport-based)
|
||||
- **Markdown Rendering** — Bold, italic, inline code, headers, quotes, and lists in TUI and web
|
||||
- **File Transfer** — Chunked (64KB), SHA-256 verified, ratchet-encrypted
|
||||
- **Voice/Video Calls** — WarzonePhone integration (QUIC SFU relay, ChaCha20-Poly1305 media)
|
||||
- **Admin Commands** — /admin-calls, /admin-unalias for server administration
|
||||
- **Federation** — Two-server relay with HMAC-authenticated presence sync
|
||||
- **TUI Client** — Full-featured terminal UI (ratatui, timestamps, scrolling, receipts)
|
||||
- **Web Client** — Identical crypto via WASM (wasm-bindgen)
|
||||
@@ -62,6 +67,20 @@ cargo build --release
|
||||
./target/release/warzone-client tui --server http://localhost:7700
|
||||
```
|
||||
|
||||
### WZP Setup (Voice Calls)
|
||||
|
||||
To enable voice calls, run a WarzonePhone relay alongside the server:
|
||||
|
||||
```bash
|
||||
# Start the WZP QUIC relay (default port 7701)
|
||||
./target/release/wzp-relay --bind 0.0.0.0:7701
|
||||
|
||||
# Start the server with WZP integration
|
||||
./target/release/warzone-server --bind 0.0.0.0:7700 --wzp-relay http://localhost:7701
|
||||
```
|
||||
|
||||
DM calls use `/call @alias`, group calls use `/gcall` within a group context.
|
||||
|
||||
### Federation (Two Servers)
|
||||
|
||||
Create `alpha.json`:
|
||||
@@ -90,7 +109,13 @@ Messages automatically route across servers.
|
||||
|---------|-------------|
|
||||
| `/peer <fp>` or `/p @alias` | Set DM peer |
|
||||
| `/g <name>` | Switch to group (auto-join) |
|
||||
| `/call <fp>` | Initiate call |
|
||||
| `/call <fp>` | Initiate DM voice call |
|
||||
| `/accept` / `/reject` | Accept or reject incoming call |
|
||||
| `/hangup` | End current call |
|
||||
| `/gcall` | Start group call in current group |
|
||||
| `/gjoin` | Join active group call |
|
||||
| `/gleave-call` | Leave group call |
|
||||
| `/gmute` | Toggle mute in group call |
|
||||
| `/file <path>` | Send file (max 10MB) |
|
||||
| `/contacts` | List contacts with message counts |
|
||||
| `/history` | Show conversation history |
|
||||
@@ -132,9 +157,9 @@ See [docs/SECURITY.md](docs/SECURITY.md) for the full threat model.
|
||||
|
||||
## Test Suite
|
||||
|
||||
72 tests across protocol + client crates (all passing):
|
||||
- 28 protocol tests (X3DH, Double Ratchet, Sender Keys, crypto, identity)
|
||||
- 44 TUI tests (rendering, keyboard input, scrolling, state management)
|
||||
155 tests across protocol + client crates (all passing):
|
||||
- Protocol tests (X3DH, Double Ratchet, Sender Keys, crypto, identity, call signaling)
|
||||
- TUI tests (rendering, keyboard input, scrolling, state management, call UI, markdown, receipts)
|
||||
|
||||
```bash
|
||||
cargo test --workspace
|
||||
|
||||
@@ -113,6 +113,35 @@ impl ServerClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check how many one-time pre-keys remain on the server.
|
||||
pub async fn otpk_count(&self, fingerprint: &str) -> Result<u64> {
|
||||
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
let resp: serde_json::Value = self.client
|
||||
.get(format!("{}/v1/keys/{}/otpk-count", self.base_url, fp_clean))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to check OTPK count")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse OTPK count")?;
|
||||
Ok(resp.get("count").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Upload additional one-time pre-keys.
|
||||
pub async fn replenish_otpks(&self, fingerprint: &str, keys: Vec<(u32, [u8; 32])>) -> Result<()> {
|
||||
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
let otpks: Vec<serde_json::Value> = keys.iter().map(|(id, pubkey)| {
|
||||
serde_json::json!({"id": id, "public_key": hex::encode(pubkey)})
|
||||
}).collect();
|
||||
self.client
|
||||
.post(format!("{}/v1/keys/replenish", self.base_url))
|
||||
.json(&serde_json::json!({"fingerprint": fp_clean, "one_time_pre_keys": otpks}))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to replenish OTPKs")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll for messages addressed to us.
|
||||
pub async fn poll_messages(&self, fingerprint: &str) -> Result<Vec<Vec<u8>>> {
|
||||
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
|
||||
@@ -113,6 +113,22 @@ impl LocalDb {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the next available OTPK ID (one past the highest stored).
|
||||
pub fn next_otpk_id(&self) -> u32 {
|
||||
let mut max_id: Option<u32> = None;
|
||||
for item in self.pre_keys.iter() {
|
||||
if let Ok((k, _)) = item {
|
||||
let key_str = String::from_utf8_lossy(&k);
|
||||
if let Some(id_str) = key_str.strip_prefix("otpk:") {
|
||||
if let Ok(id) = id_str.parse::<u32>() {
|
||||
max_id = Some(max_id.map_or(id, |m: u32| m.max(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
max_id.map_or(0, |m| m + 1)
|
||||
}
|
||||
|
||||
/// Load and remove a one-time pre-key secret.
|
||||
pub fn take_one_time_pre_key(&self, id: u32) -> Result<Option<StaticSecret>> {
|
||||
let key = format!("otpk:{}", id);
|
||||
|
||||
@@ -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() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
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)),
|
||||
]))
|
||||
// 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, 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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ð_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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.38"
|
||||
version = "0.0.47"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)?;
|
||||
@@ -216,7 +248,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&cli.bind).await?;
|
||||
tracing::info!("Listening on {}", cli.bind);
|
||||
axum::serve(listener, app).await?;
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/groups/:name/leave", post(leave_group))
|
||||
.route("/groups/:name/kick", post(kick_member))
|
||||
.route("/groups/:name/members", get(get_members))
|
||||
.route("/groups/:name/signal", post(signal_group))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
@@ -305,3 +306,47 @@ async fn get_members(
|
||||
"online_count": online_count,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Broadcast a plaintext signal to all online group members via WS push.
|
||||
/// Used for group calls, typing indicators, etc. — NOT for encrypted messages.
|
||||
async fn signal_group(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let group = match load_group(&state.db.groups, &name) {
|
||||
Some(g) => g,
|
||||
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||
};
|
||||
|
||||
let from = req
|
||||
.get("from")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let from = normalize_fp(&from);
|
||||
if !group.members.contains(&from) {
|
||||
return Ok(Json(serde_json::json!({ "error": "not a member" })));
|
||||
}
|
||||
|
||||
// Broadcast the raw JSON payload to all online members except sender
|
||||
let payload = serde_json::to_vec(&req).unwrap_or_default();
|
||||
let mut pushed = 0;
|
||||
for member in &group.members {
|
||||
if *member == from {
|
||||
continue;
|
||||
}
|
||||
if state.push_to_client(member, &payload).await {
|
||||
pushed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Group '{}' signal from {}: pushed to {}/{} members",
|
||||
name,
|
||||
from,
|
||||
pushed,
|
||||
group.members.len() - 1
|
||||
);
|
||||
Ok(Json(serde_json::json!({ "ok": true, "pushed": pushed })))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,66 @@
|
||||
use axum::{routing::get, Json, Router};
|
||||
use axum::{extract::ConnectInfo, http::HeaderMap, routing::get, Json, Router};
|
||||
use serde_json::json;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/health", get(health))
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/whoami", get(whoami))
|
||||
}
|
||||
|
||||
async fn health() -> Json<serde_json::Value> {
|
||||
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
|
||||
}
|
||||
|
||||
async fn whoami(
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// Prefer X-Forwarded-For (set by Caddy/reverse proxy), then X-Real-Ip, then direct
|
||||
let forwarded = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.split(',').next().unwrap_or("").trim().to_string());
|
||||
|
||||
let real_ip = headers
|
||||
.get("x-real-ip")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let direct = connect_info.map(|ci| ci.0.ip().to_string());
|
||||
|
||||
let ip = forwarded.clone()
|
||||
.or(real_ip.clone())
|
||||
.or(direct.clone())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Classify as IPv4 or IPv6
|
||||
let is_v6 = ip.contains(':');
|
||||
|
||||
let via = headers.get("via").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
|
||||
let proto = headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
|
||||
let host = headers.get("x-forwarded-host").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
|
||||
let behind_proxy = forwarded.is_some() || real_ip.is_some() || via.is_some();
|
||||
|
||||
let mut result = json!({
|
||||
"ip": ip,
|
||||
"version": if is_v6 { "IPv6" } else { "IPv4" },
|
||||
"direct": direct,
|
||||
"behind_proxy": behind_proxy,
|
||||
});
|
||||
|
||||
if behind_proxy {
|
||||
let proxy = json!({
|
||||
"x_forwarded_for": forwarded,
|
||||
"x_real_ip": real_ip,
|
||||
"x_forwarded_proto": proto,
|
||||
"x_forwarded_host": host,
|
||||
"via": via,
|
||||
});
|
||||
result.as_object_mut().unwrap().insert("proxy".to_string(), proxy);
|
||||
}
|
||||
|
||||
Json(result)
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
12
warzone/deploy/docker/.env.example
Normal file
12
warzone/deploy/docker/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Copy to .env and fill in values
|
||||
|
||||
# Cloudflare API token (Zone:DNS:Edit permission for manko.yoga)
|
||||
# Also create cf_api_token.txt with the same token for Docker secrets
|
||||
# echo "YOUR_TOKEN" > cf_api_token.txt
|
||||
CF_API_TOKEN=
|
||||
|
||||
# DNS records to create:
|
||||
# voip.manko.yoga → A 172.16.81.135 (dev)
|
||||
# voip.manko.yoga → AAAA 2a0d:3344:692c:2500:14f2:5885:d73c:b0a1 (ipv6 test)
|
||||
# voip.manko.yoga → A 63.250.54.239 (production)
|
||||
# voip.manko.yoga → AAAA 2602:ff16:9:0:1:3d9:0:1 (production ipv6)
|
||||
30
warzone/deploy/docker/Caddyfile
Normal file
30
warzone/deploy/docker/Caddyfile
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
email admin@manko.yoga
|
||||
}
|
||||
|
||||
# Wildcard cert for all subdomains
|
||||
*.voip.manko.yoga {
|
||||
tls {
|
||||
dns cloudflare {$CF_API_TOKEN}
|
||||
}
|
||||
reverse_proxy wzp-web:8080
|
||||
}
|
||||
|
||||
# Main domain — featherChat server
|
||||
voip.manko.yoga {
|
||||
tls {
|
||||
dns cloudflare {$CF_API_TOKEN}
|
||||
}
|
||||
|
||||
handle_path /audio/* {
|
||||
reverse_proxy wzp-web:8080
|
||||
}
|
||||
|
||||
# WZP WASM module (needed by audio variants loaded from /audio/js/)
|
||||
handle /audio-wasm/* {
|
||||
uri strip_prefix /audio-wasm
|
||||
reverse_proxy wzp-web:8080
|
||||
}
|
||||
|
||||
reverse_proxy warzone-server:7700
|
||||
}
|
||||
42
warzone/deploy/docker/Caddyfile.test
Normal file
42
warzone/deploy/docker/Caddyfile.test
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
email admin@manko.yoga
|
||||
}
|
||||
|
||||
# Wildcard cert for all variant subdomains
|
||||
*.voip.manko.yoga {
|
||||
tls {
|
||||
dns cloudflare {$CF_API_TOKEN}
|
||||
}
|
||||
|
||||
# Route each subdomain to wzp-web with the right variant
|
||||
@v1 host v1.voip.manko.yoga
|
||||
@v2 host v2.voip.manko.yoga
|
||||
@v3 host v3.voip.manko.yoga
|
||||
@v4 host v4.voip.manko.yoga
|
||||
@v5 host v5.voip.manko.yoga
|
||||
@v6 host v6.voip.manko.yoga
|
||||
|
||||
# Rewrite root path to include variant param
|
||||
rewrite @v1 / /?variant=pure
|
||||
rewrite @v2 / /?variant=hybrid
|
||||
rewrite @v3 / /?variant=full
|
||||
rewrite @v4 / /?variant=ws
|
||||
rewrite @v5 / /?variant=ws-fec
|
||||
rewrite @v6 / /?variant=ws-full
|
||||
|
||||
# All subdomains proxy to wzp-web
|
||||
reverse_proxy wzp-web:8080
|
||||
}
|
||||
|
||||
# Main domain — featherChat server
|
||||
voip.manko.yoga {
|
||||
tls {
|
||||
dns cloudflare {$CF_API_TOKEN}
|
||||
}
|
||||
|
||||
handle_path /audio/* {
|
||||
reverse_proxy wzp-web:8080
|
||||
}
|
||||
|
||||
reverse_proxy warzone-server:7700
|
||||
}
|
||||
12
warzone/deploy/docker/Dockerfile.caddy
Normal file
12
warzone/deploy/docker/Dockerfile.caddy
Normal file
@@ -0,0 +1,12 @@
|
||||
# Caddy with Cloudflare DNS plugin — builds for any arch
|
||||
FROM caddy:2-builder AS builder
|
||||
|
||||
# Force IPv4-only for Go module downloads (Docker build may lack IPv6)
|
||||
ENV GOFLAGS="-mod=mod"
|
||||
RUN echo 'precedence ::ffff:0:0/96 100' > /etc/gai.conf && \
|
||||
xcaddy build \
|
||||
--with github.com/caddy-dns/cloudflare
|
||||
|
||||
FROM caddy:2
|
||||
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
30
warzone/deploy/docker/Dockerfile.server
Normal file
30
warzone/deploy/docker/Dockerfile.server
Normal file
@@ -0,0 +1,30 @@
|
||||
# featherChat server — multi-stage build
|
||||
# Build context: featherChat repo root (../../..)
|
||||
FROM rust:latest AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy warzone workspace
|
||||
COPY warzone/Cargo.toml warzone/Cargo.lock ./warzone/
|
||||
COPY warzone/crates ./warzone/crates
|
||||
|
||||
WORKDIR /build/warzone
|
||||
|
||||
# Build WASM first (server embeds it via include_str!/include_bytes!)
|
||||
RUN cargo install wasm-pack && \
|
||||
wasm-pack build crates/warzone-wasm --target web --out-dir /build/warzone/wasm-pkg 2>&1 || true
|
||||
|
||||
# Build server (now wasm-pkg exists at the expected relative path)
|
||||
RUN cargo build --release --bin warzone-server
|
||||
|
||||
# Runtime
|
||||
FROM debian:trixie-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /build/warzone/target/release/warzone-server /usr/local/bin/
|
||||
|
||||
WORKDIR /data
|
||||
EXPOSE 7700
|
||||
|
||||
ENTRYPOINT ["warzone-server"]
|
||||
CMD ["--bind", "0.0.0.0:7700"]
|
||||
30
warzone/deploy/docker/Dockerfile.wzp
Normal file
30
warzone/deploy/docker/Dockerfile.wzp
Normal file
@@ -0,0 +1,30 @@
|
||||
# WZP relay + web bridge — multi-stage build
|
||||
# Build context: featherChat repo root (../../..)
|
||||
FROM rust:latest AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y cmake pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy warzone-phone workspace (feature/wzp-web-variants branch)
|
||||
COPY warzone-phone/Cargo.toml warzone-phone/Cargo.lock ./warzone-phone/
|
||||
COPY warzone-phone/crates ./warzone-phone/crates
|
||||
|
||||
# wzp-crypto depends on warzone-protocol via deps/featherchat/warzone/...
|
||||
COPY warzone/crates/warzone-protocol ./warzone-phone/deps/featherchat/warzone/crates/warzone-protocol
|
||||
|
||||
# Build both binaries
|
||||
WORKDIR /build/warzone-phone
|
||||
RUN cargo build --release --bin wzp-relay --bin wzp-web
|
||||
|
||||
# Runtime — use same distro as builder to match glibc
|
||||
FROM debian:trixie-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /build/warzone-phone/target/release/wzp-relay /usr/local/bin/
|
||||
COPY --from=builder /build/warzone-phone/target/release/wzp-web /usr/local/bin/
|
||||
|
||||
# Copy static files for wzp-web (HTML, JS, WASM)
|
||||
COPY --from=builder /build/warzone-phone/crates/wzp-web/static /data/static
|
||||
|
||||
WORKDIR /data
|
||||
24
warzone/deploy/docker/docker-compose.ipv6.yml
Normal file
24
warzone/deploy/docker/docker-compose.ipv6.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
# IPv6 overlay — use with:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.ipv6.yml up -d
|
||||
#
|
||||
# Requires Docker daemon IPv6 support:
|
||||
# /etc/docker/daemon.json: {"ipv6": true, "fixed-cidr-v6": "fd00::/80"}
|
||||
|
||||
services:
|
||||
caddy:
|
||||
ports:
|
||||
- "[::]:80:80"
|
||||
- "[::]:443:443"
|
||||
- "[::]:443:443/udp"
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: fd00:cafe:1::/64
|
||||
backend:
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: fd00:cafe:2::/64
|
||||
97
warzone/deploy/docker/docker-compose.yml
Normal file
97
warzone/deploy/docker/docker-compose.yml
Normal file
@@ -0,0 +1,97 @@
|
||||
# featherChat + WZP full stack
|
||||
# Usage:
|
||||
# echo "YOUR_CF_API_TOKEN" > cf_api_token.txt
|
||||
# docker compose up -d
|
||||
#
|
||||
# DNS: voip.manko.yoga → your IP
|
||||
# Test: https://voip.manko.yoga
|
||||
|
||||
services:
|
||||
# ─── Caddy reverse proxy (TLS termination) ───
|
||||
caddy:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp" # HTTP/3 (QUIC)
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
secrets:
|
||||
- cf_api_token
|
||||
entrypoint: ["/bin/sh", "-c", "export CF_API_TOKEN=$(cat /run/secrets/cf_api_token) && caddy run --config /etc/caddy/Caddyfile --adapter caddyfile"]
|
||||
depends_on:
|
||||
- warzone-server
|
||||
- wzp-web
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
|
||||
# ─── featherChat server ───
|
||||
warzone-server:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: warzone/deploy/docker/Dockerfile.server
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
WZP_RELAY_ADDR: "voip.manko.yoga/audio"
|
||||
RUST_LOG: "info"
|
||||
volumes:
|
||||
- server_data:/data
|
||||
command: ["--bind", "0.0.0.0:7700", "--enable-bots"]
|
||||
networks:
|
||||
- backend
|
||||
|
||||
# ─── WZP QUIC relay (audio SFU) ───
|
||||
wzp-relay:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: warzone/deploy/docker/Dockerfile.wzp
|
||||
restart: unless-stopped
|
||||
entrypoint: ["wzp-relay"]
|
||||
command:
|
||||
- "--listen"
|
||||
- "0.0.0.0:4433"
|
||||
networks:
|
||||
backend:
|
||||
ipv4_address: 172.28.0.10
|
||||
|
||||
# ─── WZP web bridge (browser WS ↔ QUIC relay) ───
|
||||
# No --tls (Caddy handles TLS), no --auth-url (Caddy terminates)
|
||||
# Variants: ?variant=pure|hybrid|full
|
||||
wzp-web:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: warzone/deploy/docker/Dockerfile.wzp
|
||||
restart: unless-stopped
|
||||
entrypoint: ["wzp-web"]
|
||||
command:
|
||||
- "--port"
|
||||
- "8080"
|
||||
- "--relay"
|
||||
- "172.28.0.10:4433"
|
||||
depends_on:
|
||||
- wzp-relay
|
||||
networks:
|
||||
- backend
|
||||
|
||||
|
||||
secrets:
|
||||
cf_api_token:
|
||||
file: ./cf_api_token.txt
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
server_data:
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
backend:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/24
|
||||
58
warzone/deploy/docker/test-stack.sh
Executable file
58
warzone/deploy/docker/test-stack.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
HOST="${1:-voip.manko.yoga}"
|
||||
SCHEME="${2:-https}"
|
||||
|
||||
echo "=== featherChat Stack Test ==="
|
||||
echo "Host: $HOST ($SCHEME)"
|
||||
echo ""
|
||||
|
||||
# 1. Web UI
|
||||
echo -n "1. Web UI (GET /)... "
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/")
|
||||
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
|
||||
|
||||
# 2. API health
|
||||
echo -n "2. API health... "
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/v1/health")
|
||||
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
|
||||
|
||||
# 3. WASM module
|
||||
echo -n "3. WASM module... "
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/wasm/warzone_wasm.js")
|
||||
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
|
||||
|
||||
# 4. WZP relay config
|
||||
echo -n "4. WZP relay config... "
|
||||
RELAY=$(curl -s "$SCHEME://$HOST/v1/wzp/relay-config")
|
||||
echo "$RELAY" | grep -q "relay_addr" && echo "OK ($(echo $RELAY | python3 -c 'import sys,json; print(json.load(sys.stdin).get("relay_addr","?"))' 2>/dev/null))" || echo "FAIL"
|
||||
|
||||
# 5. Audio bridge (wzp-web via Caddy /audio path)
|
||||
echo -n "5. Audio bridge (GET /audio/)... "
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/audio/")
|
||||
# wzp-web returns 200 for its landing page
|
||||
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "WARN ($STATUS — wzp-web may not serve GET /)"
|
||||
|
||||
# 6. WebSocket upgrade test
|
||||
echo -n "6. WS upgrade test... "
|
||||
WS_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Upgrade: websocket" -H "Connection: Upgrade" "$SCHEME://$HOST/v1/ws/test")
|
||||
echo "($WS_STATUS)"
|
||||
|
||||
# 7. TLS cert check
|
||||
if [ "$SCHEME" = "https" ]; then
|
||||
echo -n "7. TLS cert... "
|
||||
ISSUER=$(echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null)
|
||||
echo "$ISSUER" | grep -q "Let's Encrypt\|Cloudflare\|R3\|E1" && echo "OK ($ISSUER)" || echo "$ISSUER"
|
||||
fi
|
||||
|
||||
# 8. IPv6 test
|
||||
echo -n "8. IPv6... "
|
||||
if curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 3 "$SCHEME://$HOST/" 2>/dev/null; then
|
||||
echo " (IPv6 reachable)"
|
||||
else
|
||||
echo "not available (IPv4 only)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
98
warzone/deploy/docker/update-dns.sh
Executable file
98
warzone/deploy/docker/update-dns.sh
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# Updates voip.manko.yoga DNS records with current public IPs.
|
||||
# Usage:
|
||||
# ./update-dns.sh Loop every 5 minutes
|
||||
# ./update-dns.sh --once Run once and exit
|
||||
#
|
||||
# Reads CF_API_TOKEN env var or deploy/docker/cf_api_token.txt
|
||||
|
||||
DOMAIN="voip.manko.yoga"
|
||||
ZONE="manko.yoga"
|
||||
INTERVAL="${DNS_UPDATE_INTERVAL:-300}"
|
||||
|
||||
get_token() {
|
||||
if [ -n "${CF_API_TOKEN:-}" ]; then
|
||||
echo "$CF_API_TOKEN"
|
||||
elif [ -f /run/secrets/cf_api_token ]; then
|
||||
cat /run/secrets/cf_api_token | tr -d '\n'
|
||||
else
|
||||
echo "ERROR: no CF token" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
get_zone_id() {
|
||||
curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$ZONE" \
|
||||
-H "Authorization: Bearer $(get_token)" | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])" 2>/dev/null
|
||||
}
|
||||
|
||||
get_public_ipv4() {
|
||||
curl -4 -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || \
|
||||
curl -4 -s --connect-timeout 5 https://ifconfig.me 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
get_public_ipv6() {
|
||||
curl -6 -s --connect-timeout 5 https://api6.ipify.org 2>/dev/null || \
|
||||
curl -6 -s --connect-timeout 5 https://ifconfig.co 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
upsert_record() {
|
||||
local zone_id="$1" type="$2" content="$3" token
|
||||
token=$(get_token)
|
||||
|
||||
local existing
|
||||
existing=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
|
||||
-H "Authorization: Bearer $token")
|
||||
|
||||
local rec_id current
|
||||
rec_id=$(echo "$existing" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null)
|
||||
current=$(echo "$existing" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['content'] if r else '')" 2>/dev/null)
|
||||
|
||||
if [ "$current" = "$content" ]; then
|
||||
echo " $type: $content (unchanged)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$rec_id" ]; then
|
||||
curl -4 -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||
echo " $type: $current -> $content (updated)"
|
||||
else
|
||||
curl -4 -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||
echo " $type: $content (created)"
|
||||
fi
|
||||
}
|
||||
|
||||
update() {
|
||||
echo "[$(date -u +%H:%M:%S)] Updating DNS for $DOMAIN..."
|
||||
local zone_id
|
||||
zone_id=$(get_zone_id)
|
||||
if [ -z "$zone_id" ]; then
|
||||
echo " ERROR: cannot get zone ID"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local ipv4 ipv6
|
||||
ipv4=$(get_public_ipv4)
|
||||
ipv6=$(get_public_ipv6)
|
||||
|
||||
[ -n "$ipv4" ] && upsert_record "$zone_id" "A" "$ipv4" || echo " A: no IPv4"
|
||||
[ -n "$ipv6" ] && upsert_record "$zone_id" "AAAA" "$ipv6" || echo " AAAA: no IPv6"
|
||||
}
|
||||
|
||||
# Main
|
||||
if [ "${1:-}" = "--once" ]; then
|
||||
update
|
||||
else
|
||||
update
|
||||
while true; do
|
||||
sleep "$INTERVAL"
|
||||
update
|
||||
done
|
||||
fi
|
||||
@@ -1,7 +1,9 @@
|
||||
# Warzone Messenger (featherChat) — Architecture
|
||||
|
||||
**Version:** 0.0.21
|
||||
**Status:** Phase 1 + Phase 2 + WZP Integration + Federation
|
||||
**Version:** 0.0.46
|
||||
**Status:** Phase 1 + Phase 2 + Phase 3 + WZP Integration + Federation + Bots + Admin
|
||||
|
||||
**Features:** E2E encrypted messaging (Double Ratchet), group messaging (Sender Keys), voice calls (DM E2E + group transport-encrypted), ring tones (Web Audio API), browser call notifications, group calls (`/gcall`, `/gjoin`, `/gleave-call`), read receipts (sent/delivered/read indicators), markdown rendering (TUI + Web), Telegram-compatible Bot API, admin commands, federation, device management, aliases, ETH address display, file transfer, friend lists, encrypted history backup
|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +50,7 @@ graph LR
|
||||
|
||||
```
|
||||
warzone/
|
||||
├── Cargo.toml # Workspace root (v0.0.21)
|
||||
├── Cargo.toml # Workspace root (v0.0.46)
|
||||
├── federation.example.json # Federation config template
|
||||
├── crates/
|
||||
│ ├── warzone-protocol/ # Core crypto & message types
|
||||
@@ -227,6 +229,7 @@ Auth-Protected (bearer token required):
|
||||
POST /v1/keys/register|replenish
|
||||
POST /v1/calls/initiate|:id/end
|
||||
POST /v1/groups/:name/call Group call initiation
|
||||
POST /v1/groups/:name/signal Group call signal broadcast
|
||||
POST /v1/devices/:id/kick Kick a device
|
||||
POST /v1/devices/revoke-all Panic button
|
||||
POST /v1/presence/batch Bulk online check
|
||||
@@ -428,9 +431,23 @@ sequenceDiagram
|
||||
| `GET /v1/calls/active` | List active calls |
|
||||
| `POST /v1/calls/missed` | Get & clear missed calls |
|
||||
| `POST /v1/groups/:name/call` | Group call (fan-out to members) |
|
||||
| `POST /v1/groups/:name/signal` | Broadcast call signal to group members |
|
||||
| `GET /v1/presence/:fp` | Check if peer is online |
|
||||
| `GET /v1/wzp/relay-config` | Get relay address + service token |
|
||||
|
||||
### Ring Tones
|
||||
|
||||
- **Incoming call:** Web Audio API oscillator playing a 440/480 Hz dual-tone pattern (classic North American ring cadence)
|
||||
- **Outgoing ringback:** 2 seconds on / 4 seconds off pattern until callee answers or rejects
|
||||
- **Browser notifications:** If the web client tab is in background, an incoming call triggers a system notification so the user does not miss it
|
||||
|
||||
### Group Calls
|
||||
|
||||
- `/gcall <group>` starts a group call room; `/gjoin <group>` joins an existing room; `/gleave-call` leaves
|
||||
- Group call signals are broadcast via `POST /v1/groups/:name/signal` (fan-out to all online members)
|
||||
- Room naming convention: DM calls use a sorted fingerprint pair as room ID; group calls use `gc-<groupname>`
|
||||
- **Encryption:** Group calls are transport-encrypted only (QUIC with TLS). They are NOT end-to-end encrypted. MLS (RFC 9420) key agreement for group call media is on the roadmap.
|
||||
|
||||
### Group Call Room ID
|
||||
|
||||
```
|
||||
@@ -482,12 +499,13 @@ sequenceDiagram
|
||||
S->>U: Deliver reply via WS
|
||||
```
|
||||
|
||||
- Bots register with a fingerprint and get a token
|
||||
- Bot aliases must end with `Bot`, `bot`, or `_bot` (enforced)
|
||||
- Non-bot users cannot register reserved aliases
|
||||
- `getUpdates` returns Telegram-compatible Update objects
|
||||
- `sendMessage` delivers plaintext (no E2E in v1)
|
||||
- **BotFather** creates bots and issues tokens; each bot gets an auto-registered alias
|
||||
- Bot aliases must end with `Bot`, `bot`, or `_bot` (enforced); non-bot users cannot register reserved aliases
|
||||
- **Per-bot numeric ID mapping:** Each user is assigned a unique numeric ID per bot, preventing cross-bot user correlation (privacy)
|
||||
- **Telegram-compatible endpoints:** `getUpdates` (long-poll), `sendMessage`, `editMessage`, `sendDocument`, inline keyboards
|
||||
- `sendMessage` delivers plaintext (no E2E in v1 — bot messages are not encrypted)
|
||||
- Messages from users arrive as encrypted blobs (base64) or plaintext bot messages
|
||||
- **System bots:** Configured via `--bots-config <file>` on server startup; auto-created on first run
|
||||
|
||||
### Addressing
|
||||
|
||||
@@ -519,6 +537,8 @@ ETH↔fingerprint mapping stored on key registration.
|
||||
| Inter-server | Authenticated | SHA-256(secret \|\| body) token |
|
||||
| WS connections | Rate-limited | 5 per fingerprint, 200 global |
|
||||
| WZP relay | Token-gated | featherChat bearer token validation |
|
||||
| DM calls (voice) | E2E encrypted | ChaCha20-Poly1305 over QUIC via WZP relay |
|
||||
| Group calls (voice) | Transport-encrypted only | QUIC/TLS — NOT E2E (MLS on roadmap) |
|
||||
|
||||
### What's NOT Protected (Phase 1 scope)
|
||||
|
||||
@@ -587,11 +607,14 @@ graph TB
|
||||
|
||||
| Crate | Tests | Coverage |
|
||||
|-------|------:|---------|
|
||||
| warzone-protocol | 34 | X3DH, Double Ratchet, Sender Keys, AEAD, HKDF, identity, ethereum, prekeys, mnemonic, friend list, x3dh web client |
|
||||
| warzone-protocol | 39 | X3DH, Double Ratchet, Sender Keys, AEAD, HKDF, identity, ethereum, prekeys, mnemonic, friend list, x3dh web client, receipts |
|
||||
| warzone-client (types) | 10 | App init, scroll, connected, timestamps, normfp |
|
||||
| warzone-client (input) | 25 | Text editing, cursor movement, scroll keys, quit |
|
||||
| warzone-client (draw) | 9 | Rendering, timestamps, connection dot, scroll, unread badge |
|
||||
| **Total** | **122** | All passing |
|
||||
| warzone-client (draw) | 13 | Rendering, timestamps, connection dot, scroll, unread badge, markdown |
|
||||
| warzone-server (integration) | 10 | Route handlers, auth middleware, group ops, call state |
|
||||
| warzone-server (bin) | 10 | CLI args, startup, federation init, bot config |
|
||||
| Other (e2e, misc) | 48 | Client-side E2E flows, file transfer, admin commands |
|
||||
| **Total** | **155** | All passing |
|
||||
|
||||
WZP side: 15 cross-project identity tests + 17 integration tests (separate repo).
|
||||
|
||||
@@ -667,6 +690,46 @@ sequenceDiagram
|
||||
|
||||
---
|
||||
|
||||
## Admin Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/admin-calls` | List all currently active calls on the server |
|
||||
| `/admin-unalias <alias> <pw>` | Force-remove an alias (requires admin password) |
|
||||
| `/admin-help` | Show available admin commands |
|
||||
|
||||
Admin commands are available in the TUI client and are authenticated server-side.
|
||||
|
||||
---
|
||||
|
||||
## Read Receipts
|
||||
|
||||
- **TUI:** Tracks which messages are visible in the viewport and sends `Receipt::Read` back to the sender when a message scrolls into view
|
||||
- **Web:** Sender sees delivery indicators: single check mark (sent) then double check mark (delivered) then blue double check mark (read)
|
||||
- **Deduplication:** Each message is receipted only once; the client tracks which message IDs have already been acknowledged to avoid redundant receipt traffic
|
||||
|
||||
---
|
||||
|
||||
## Markdown Rendering
|
||||
|
||||
- **TUI:** Custom `md_to_spans` parser converts markdown to ratatui `Span` objects supporting bold, italic, inline code, headers, blockquotes, and lists
|
||||
- **Web:** `renderMd()` function in the embedded JS handles code blocks, inline code, bold, italic, headers, links, blockquotes, and ordered/unordered lists
|
||||
- Both renderers are deliberately simple (no AST) to avoid pulling in heavy markdown dependencies
|
||||
|
||||
---
|
||||
|
||||
## Known Issues and Limitations
|
||||
|
||||
| Issue | Details |
|
||||
|-------|---------|
|
||||
| Group call signal delivery | Depends on members being online; there is no offline queue for call signals |
|
||||
| TUI voice calls | Require the web client; no native audio (cpal) integration yet |
|
||||
| Bot messages are plaintext | v1 limitation; bots cannot participate in E2E encryption |
|
||||
| `/gmembers` ETH resolution | Async resolution may briefly show the raw fingerprint before the ETH address loads |
|
||||
| Service worker cache staleness | Cache version in `web.rs` must be bumped on every change or browsers will serve stale WASM/JS content |
|
||||
|
||||
---
|
||||
|
||||
## Extensibility
|
||||
|
||||
### Adding New WireMessage Variants
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Warzone Client -- Operation Guide
|
||||
|
||||
**Version:** 0.0.21
|
||||
**Version:** 0.0.46
|
||||
|
||||
---
|
||||
|
||||
@@ -289,6 +289,48 @@ When decryption fails on an incoming message, the TUI automatically:
|
||||
The next incoming `KeyExchange` from that peer will create a fresh session
|
||||
without manual intervention.
|
||||
|
||||
### Voice Calls
|
||||
|
||||
The TUI supports DM and group call commands:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/call [peer]` | Initiate a voice call with the current or specified peer |
|
||||
| `/accept` | Accept an incoming call |
|
||||
| `/reject` | Reject an incoming call |
|
||||
| `/hangup` | End the current call |
|
||||
|
||||
**Call state display:** The TUI header bar shows call status with color coding:
|
||||
|
||||
- **Yellow "CALLING..."** — outgoing call ringing, waiting for peer to accept
|
||||
- **Green "IN CALL" + timer** — active call with elapsed duration (MM:SS)
|
||||
- No indicator when idle
|
||||
|
||||
**Note:** TUI audio requires the web client. When a call is active in the TUI, a hint is displayed directing the user to open the web client for actual audio. The TUI handles signaling (offer/answer/ICE) but does not capture or play audio.
|
||||
|
||||
### Read Receipts
|
||||
|
||||
Read receipts track message delivery through three states: sent, delivered, and read.
|
||||
|
||||
- **Sender fingerprint tracking:** Each outgoing message records the sender's fingerprint so the system can match incoming receipts to the correct message.
|
||||
- **Dedup set:** A per-conversation set prevents sending duplicate read receipts for the same message. Once a read receipt is sent for a message ID, it is not sent again.
|
||||
- **Viewport-based:** Read receipts are triggered when a message scrolls into the visible area of the chat. Messages that are never scrolled into view do not generate read receipts.
|
||||
|
||||
### Markdown Rendering
|
||||
|
||||
Messages support inline markdown formatting via the `md_to_spans` function, which converts markdown syntax into ratatui `Span` elements with appropriate styling:
|
||||
|
||||
| Syntax | TUI Rendering |
|
||||
|--------|---------------|
|
||||
| `**bold**` | Bold attribute |
|
||||
| `*italic*` | Italic attribute |
|
||||
| `` `code` `` | Dark gray background, monospace feel |
|
||||
| `# Header` | Bold + uppercase (line start only) |
|
||||
| `> quote` | Italic + gray foreground (line start only) |
|
||||
| `- list item` | Bullet prefix (line start only) |
|
||||
|
||||
Markdown is parsed per-message at render time. The web client renders the same syntax as HTML elements.
|
||||
|
||||
---
|
||||
|
||||
## 5. Full Command Reference
|
||||
|
||||
@@ -253,6 +253,31 @@ The bridge translates numeric chat_id ↔ fingerprints automatically.
|
||||
| parse_mode HTML | rendered | rendered in web client |
|
||||
| Media groups | yes | not yet |
|
||||
|
||||
## Voice Calls and Group Calls
|
||||
|
||||
Bots cannot initiate or participate in voice calls or group 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. Group call signals (`/gcall`, `/gjoin`, etc.) are similarly not actionable by bots.
|
||||
|
||||
## Markdown Rendering
|
||||
|
||||
Bot replies support inline markdown formatting in both the web and TUI clients:
|
||||
- `**bold**` or `<b>bold</b>` (with `parse_mode: "HTML"`)
|
||||
- `*italic*` or `<i>italic</i>`
|
||||
- `` `inline code` `` or `<code>code</code>`
|
||||
- `[link text](url)` or `<a href="url">text</a>`
|
||||
- ` ```block``` ` for code blocks
|
||||
|
||||
When using `parse_mode: "HTML"`, the HTML tags are rendered. Without `parse_mode`, the web client renders markdown syntax natively. Both paths produce styled output.
|
||||
|
||||
## Per-Bot Numeric IDs
|
||||
|
||||
Each bot sees a unique numeric ID for each user (`from.id` in updates). These IDs are:
|
||||
- Deterministic: the same user always maps to the same numeric ID for a given bot
|
||||
- Per-bot unique: different bots see different numeric IDs for the same user
|
||||
- Privacy-preserving: bots cannot correlate users across bots or recover raw fingerprints from the numeric ID
|
||||
- Derived via HMAC of the user's fingerprint keyed with the bot's token prefix
|
||||
|
||||
Use `from.id` (or `chat.id`) as-is for replies. Do not attempt to reverse it to a fingerprint.
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. **Always use offset** in getUpdates — without it you reprocess messages
|
||||
|
||||
@@ -30,6 +30,17 @@ cmd | action | example
|
||||
/gleave | leave current group | /gleave
|
||||
/gkick <fp> | kick member (creator only) | /gkick abc123
|
||||
/gmembers | list group members + status | /gmembers
|
||||
/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
|
||||
/gcall | start group voice call in current group | /gcall
|
||||
/gjoin | join active group call | /gjoin
|
||||
/gleave-call | leave group call (stay in group) | /gleave-call
|
||||
/gmute | toggle mute in group call | /gmute
|
||||
/admin-calls | list active calls on server (admin) | /admin-calls
|
||||
/admin-help | show admin commands (admin) | /admin-help
|
||||
/file <path> | send file (max 10MB, 64KB chunks) | /file ./doc.pdf
|
||||
/quit, /q | exit | /q
|
||||
|
||||
@@ -195,6 +206,80 @@ 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
|
||||
|
||||
### Relay Config Flow
|
||||
|
||||
1. Client calls `GET /v1/wzp/relay-config` with bearer token
|
||||
2. Server validates auth, issues a short-lived WZP token
|
||||
3. Response: `{"relay_addr":"host:port","token":"..."}`
|
||||
4. Client opens WebSocket to `ws://relay_addr` with the WZP token
|
||||
5. Audio frames flow over the WebSocket via the wzp-web bridge
|
||||
|
||||
### Ring Tones
|
||||
|
||||
Ring tones play automatically using the Web Audio API (oscillator-based, no audio files):
|
||||
- **Outgoing call**: caller hears a ringback tone (repeating double beep) while waiting for answer
|
||||
- **Incoming call**: callee hears a ringing tone (classic ring pattern) until they accept/reject
|
||||
- Both tones stop immediately on answer, reject, or hangup
|
||||
- TUI clients receive a terminal bell on incoming call (no audio playback)
|
||||
|
||||
### Group Calls
|
||||
|
||||
Group voice calls use the same WZP relay infrastructure but with room-based routing:
|
||||
|
||||
```
|
||||
Members A,B,C <--WS--> wzp-web <--QUIC--> wzp-relay (room: group:<group_name>)
|
||||
```
|
||||
|
||||
- `/gcall` signals all group members via the group signal endpoint (`POST /v1/groups/:name/signal`)
|
||||
- Room name format: `group:<group_name>` (e.g., `group:ops`)
|
||||
- Any member can `/gjoin` an active group call
|
||||
- `/gleave-call` leaves the audio room but stays in the text group
|
||||
- `/gmute` toggles local mic mute (no server-side mixing)
|
||||
- Group calls are transport-encrypted only; MLS (RFC 9420) E2E planned
|
||||
|
||||
### Admin Commands
|
||||
|
||||
cmd | action | example
|
||||
--- | --- | ---
|
||||
/admin-calls | show all active calls on the server | /admin-calls
|
||||
/admin-help | list available admin commands | /admin-help
|
||||
|
||||
Admin commands require server-side admin privilege (configured per-fingerprint).
|
||||
|
||||
## Server API (other endpoints)
|
||||
|
||||
- POST /v1/register -- upload prekey bundle
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Warzone Messenger (featherChat) — Progress Report
|
||||
|
||||
**Current Version:** 0.0.21
|
||||
**Last Updated:** 2026-03-28
|
||||
**Current Version:** 0.0.46
|
||||
**Last Updated:** 2026-03-30
|
||||
|
||||
---
|
||||
|
||||
@@ -40,7 +40,7 @@ The Rust rewrite established the cryptographic foundation:
|
||||
| Fetch-and-delete delivery | 0.0.7 | Done |
|
||||
| Aliases with TTL, recovery keys | 0.0.10 | Done |
|
||||
| 17 protocol tests | 0.0.10 | Done |
|
||||
| CLI ↔ Web interop verified | 0.0.10 | Done |
|
||||
| CLI <-> Web interop verified | 0.0.10 | Done |
|
||||
|
||||
### Phase 2 — Core Messaging
|
||||
|
||||
@@ -94,15 +94,41 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
||||
|
||||
---
|
||||
|
||||
## Current Version: v0.0.21
|
||||
## Version History
|
||||
|
||||
| Version | Date | Highlights |
|
||||
|---------|------|------------|
|
||||
| 0.0.22 | 2026-03-28 | ETH identity in web client |
|
||||
| 0.0.23-24 | 2026-03-28 | ETH display everywhere (TUI + Web) |
|
||||
| 0.0.25-26 | 2026-03-28 | Federation persistent WS, text selection |
|
||||
| 0.0.27-29 | 2026-03-29 | Bot API: BotFather, getUpdates, sendMessage |
|
||||
| 0.0.30-31 | 2026-03-29 | Bot numeric IDs, inline keyboards |
|
||||
| 0.0.32-33 | 2026-03-29 | System bots config, version bump |
|
||||
| 0.0.34 | 2026-03-29 | Bot sendMessage fix, per-bot ID mapping |
|
||||
| 0.0.35 | 2026-03-29 | WASM create_call_signal, selectable identity |
|
||||
| 0.0.36 | 2026-03-29 | Web call UI (call/accept/reject/hangup) |
|
||||
| 0.0.37 | 2026-03-29 | TUI call state UI, missed calls, inline keyboards |
|
||||
| 0.0.38 | 2026-03-29 | Session versioning, wire envelope, auto-backup |
|
||||
| 0.0.39 | 2026-03-30 | Contacts online, message wrap, tab complete, OTPK |
|
||||
| 0.0.40 | 2026-03-30 | Call reload, ETH cache prefill, 10 server tests |
|
||||
| 0.0.41 | 2026-03-30 | Read receipts (viewport tracking) |
|
||||
| 0.0.42 | 2026-03-30 | Markdown rendering in TUI messages |
|
||||
| 0.0.43 | 2026-03-30 | Voice calls via WZP audio bridge |
|
||||
| 0.0.44 | 2026-03-30 | Web UI polish, ETH display, call routing fixes |
|
||||
| 0.0.45 | 2026-03-30 | Call ring tones + group calls |
|
||||
| 0.0.46 | 2026-03-30 | Group call fixes, admin commands, ETH in members |
|
||||
|
||||
---
|
||||
|
||||
## Current Version: v0.0.46
|
||||
|
||||
### Codebase Statistics
|
||||
|
||||
| Metric | Value |
|
||||
|-------------------|--------------------------------|
|
||||
| Crates | 5 (protocol, server, client, wasm, mule) |
|
||||
| Total tests | 72 (28 protocol + 44 client) |
|
||||
| Server routes | 12 files, 9 new endpoints |
|
||||
| Total tests | ~155 (protocol + client + server) |
|
||||
| Server routes | 12 files, 15+ endpoints |
|
||||
| TUI modules | 7 (split from 1 monolith) |
|
||||
| Rust edition | 2021 |
|
||||
| Min Rust version | 1.75 |
|
||||
@@ -133,21 +159,29 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
||||
- Group messaging with Sender Keys
|
||||
- WebSocket real-time delivery + offline queue
|
||||
- File transfer (up to 10 MB, chunked, SHA-256 verified)
|
||||
- Delivery and read receipts
|
||||
- Delivery and read receipts (viewport tracking)
|
||||
- TUI client with full command set
|
||||
- Web client (WASM) with identical crypto
|
||||
- Alias system with TTL, recovery, admin
|
||||
- Challenge-response authentication
|
||||
- Ethereum address derivation from same seed
|
||||
- Encrypted backup and restore
|
||||
- Ethereum address derivation from same seed (displayed in TUI + Web)
|
||||
- Encrypted backup and restore (with auto-backup)
|
||||
- Contact list and message history
|
||||
- Multi-device support (basic)
|
||||
- Bot API with BotFather (Telegram-compatible)
|
||||
- Voice calls (1:1 via WZP, Web audio bridge)
|
||||
- Group calls (transport-encrypted, fan-out signaling)
|
||||
- Call ring tones (Web Audio API oscillators)
|
||||
- Markdown rendering in TUI + Web messages
|
||||
- Federation with persistent WebSocket
|
||||
- Admin commands
|
||||
- Session state versioning + wire envelope format
|
||||
|
||||
---
|
||||
|
||||
## Test Suite
|
||||
|
||||
72 tests across protocol + client crates:
|
||||
~155 tests across protocol + client + server crates:
|
||||
|
||||
### Protocol Tests (28)
|
||||
|
||||
@@ -171,6 +205,12 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
||||
| tui::input | 25 | 8 text editing, 7 cursor movement, 2 quit, 8 scroll keybindings |
|
||||
| tui::draw | 9 | Rendering smoke, header fingerprint, connection dot (red/green), timestamps, scroll show/hide, unread badge |
|
||||
|
||||
### Server Tests (10+)
|
||||
|
||||
| Area | Tests | Coverage |
|
||||
|---------------|-------|---------------------------------------------|
|
||||
| integration | 10+ | Call reload, ETH cache, presence, routing |
|
||||
|
||||
---
|
||||
|
||||
## Bugs Fixed
|
||||
@@ -184,91 +224,58 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
||||
| Dedup overflow | 0.0.16 | Dedup tracker grew unbounded. Fixed with FIFO eviction at 10,000 entries. |
|
||||
| Alias normalization | 0.0.18 | Fingerprints with colons caused lookup failures. Added `normalize_fp()` to strip non-hex characters. |
|
||||
| Receipt routing | 0.0.12 | Receipts sent to wrong fingerprint when switching peers in TUI. Fixed by including correct sender_fingerprint in Receipt wire messages. |
|
||||
| Lookbehind regex | 0.0.42 | JS lookbehind regex broke Safari markdown rendering. Replaced with forward-compatible pattern. |
|
||||
| Resolve parens warning | 0.0.43 | Unnecessary parentheses in resolve.rs caused compiler warning. Removed. |
|
||||
|
||||
---
|
||||
|
||||
## Known Issues and Limitations
|
||||
|
||||
### Current Limitations
|
||||
### Known Issues
|
||||
|
||||
1. **No perfect forward secrecy in groups:** Sender Keys provide forward secrecy within a chain but not per-message PFS like Double Ratchet. Acceptable for groups under 50 members.
|
||||
1. **Group call signals only reach online members:** Offline members do not receive group call join signals. They must be online when the call starts.
|
||||
|
||||
2. **No sealed sender:** The server sees sender and recipient fingerprints in message routing metadata. Planned for Phase 6.
|
||||
2. **TUI voice needs web client:** The TUI cannot capture/play audio natively; voice calls require the web client with WZP audio bridge. TUI voice via cpal is planned (FC-P7-T1).
|
||||
|
||||
3. **No server-at-rest encryption:** The sled database on the server is unencrypted. Message content is E2E encrypted, but metadata (fingerprints, timestamps, group membership) is visible to the server operator.
|
||||
3. **Bot messages are plaintext:** Bot API messages are not E2E encrypted (v1 design decision). Bots see and send cleartext.
|
||||
|
||||
4. **Auth tokens in memory:** Challenge-response tokens are partially stored in memory (challenges are in a static HashMap). Production deployment should use the DB for all auth state.
|
||||
4. **Group calls are transport-encrypted only:** Group call audio is encrypted by QUIC on the wire but the WZP relay can see plaintext audio. MLS E2E encryption is planned (FC-P5-T5).
|
||||
|
||||
5. **No rate limiting:** No protection against message flooding or registration spam. Planned for Phase 7.
|
||||
5. **Service worker cache must be bumped:** After WASM changes, the `wz-vN` cache version in web.rs must be incremented or browsers serve stale code.
|
||||
|
||||
6. **Single server only:** No federation between servers yet. Planned for Phase 3.
|
||||
### Existing Limitations
|
||||
|
||||
7. **No push notifications:** Users must keep a WebSocket connection open or poll. ntfy integration planned for Phase 7.
|
||||
6. **No perfect forward secrecy in groups:** Sender Keys provide forward secrecy within a chain but not per-message PFS like Double Ratchet. Acceptable for groups under 50 members.
|
||||
|
||||
8. **Web client: no OTPKs:** The web client does not generate one-time pre-keys (cannot reliably store secrets). X3DH works without DH4, but replay protection is slightly weaker.
|
||||
7. **No sealed sender:** The server sees sender and recipient fingerprints in message routing metadata.
|
||||
|
||||
9. **Web client: localStorage only:** Seed and session data stored in browser localStorage. Clearing browser data = lost identity.
|
||||
8. **No server-at-rest encryption:** The sled database on the server is unencrypted. Message content is E2E encrypted, but metadata (fingerprints, timestamps, group membership) is visible to the server operator.
|
||||
|
||||
10. **No message ordering guarantees:** Messages may arrive out of order. The Double Ratchet handles this for decryption, but the UI does not reorder displayed messages.
|
||||
9. **Auth tokens in memory:** Challenge-response tokens are partially stored in memory (challenges are in a static HashMap). Production deployment should use the DB for all auth state.
|
||||
|
||||
10. **Single server only:** No full federation between servers yet. Persistent WS relay exists but full DNS discovery is planned.
|
||||
|
||||
11. **No push notifications:** Users must keep a WebSocket connection open or poll.
|
||||
|
||||
12. **Web client: no OTPKs:** The web client does not generate one-time pre-keys (cannot reliably store secrets). X3DH works without DH4, but replay protection is slightly weaker.
|
||||
|
||||
13. **Web client: localStorage only:** Seed and session data stored in browser localStorage. Clearing browser data = lost identity.
|
||||
|
||||
14. **No message ordering guarantees:** Messages may arrive out of order. The Double Ratchet handles this for decryption, but the UI does not reorder displayed messages.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap: What's Next
|
||||
|
||||
### Phase 3 — Federation & Key Transparency (next priority)
|
||||
### Priority Order (Updated v0.0.46)
|
||||
|
||||
- DNS TXT record format for server discovery
|
||||
- User self-signed key publication to DNS
|
||||
- Key verification: server vs DNS cross-check
|
||||
- Server-to-server mutual TLS
|
||||
- Federated message delivery
|
||||
- Server key pinning (TOFU)
|
||||
- Gossip-based peer discovery
|
||||
|
||||
### Phase 4 — Warzone Delivery
|
||||
|
||||
- Mule protocol specification and implementation
|
||||
- Mule authentication and authorization
|
||||
- Message pickup with capacity declaration
|
||||
- Delivery receipt enforcement
|
||||
- Outer encryption layer (hide metadata from mule)
|
||||
- Bundle compression (zstd)
|
||||
- Mule CLI binary
|
||||
|
||||
### Phase 5 — Transport Fallbacks
|
||||
|
||||
- Bluetooth mule transfer (phone-to-phone)
|
||||
- LoRa transport layer (compact binary format)
|
||||
- mDNS / LAN discovery for local mesh
|
||||
- Wi-Fi Direct for nearby device sync
|
||||
|
||||
### Phase 6 — Metadata Protection
|
||||
|
||||
- Sealed sender (server doesn't know the sender)
|
||||
- Onion routing between federated servers (opt-in)
|
||||
- Padding and traffic shaping
|
||||
- Traffic analysis resistance
|
||||
|
||||
### Phase 7 — Polish & Operations
|
||||
|
||||
- ntfy push notification integration
|
||||
- DNS-over-HTTPS for censored networks
|
||||
- Admin CLI for server management
|
||||
- Rate limiting and abuse prevention
|
||||
- Monitoring and health checks
|
||||
- Audit logging
|
||||
- Server-at-rest encryption (optional `--encrypt-db` flag)
|
||||
- Cross-compilation CI (Linux x86/ARM, macOS, Windows, WASM)
|
||||
- PWA: service worker, offline shell, install prompt
|
||||
|
||||
### Priority Order (Updated v0.0.21)
|
||||
|
||||
1. **Security (FC-P1)** — auth enforcement, rate limiting, device revocation
|
||||
2. **TUI call integration (FC-P2)** — /call, /accept, /hangup commands
|
||||
3. **Web call integration (FC-P3)** — WASM CallSignal + browser call UI
|
||||
4. **Protocol hardening (FC-P4)** — session/message versioning
|
||||
5. Federation (Phase 3) — multi-server deployment
|
||||
6. Mule protocol (Phase 4) — physical delivery
|
||||
7. Polish (FC-P6) — search, reactions, typing indicators
|
||||
1. **TUI voice via cpal (FC-P7-T1)** — native audio capture/playback
|
||||
2. **Web extract (FC-P3-T5)** — extract web.rs monolith into separate files
|
||||
3. **MLS group E2E (FC-P5-T5)** — RFC 9420 for group call encryption
|
||||
4. **Sender Keys for DM call E2E (FC-P7-T2)** — encrypted call signaling
|
||||
5. **WebTransport (FC-P7-T3)** — replace wzp-web bridge
|
||||
6. Federation (Phase 3) — DNS discovery + multi-server
|
||||
7. Mule protocol (Phase 4) — physical delivery
|
||||
8. Polish (FC-P6) — search, reactions, typing indicators, virtual scroll
|
||||
|
||||
See `TASK_PLAN.md` for the detailed task breakdown with IDs and dependencies.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Warzone Messenger (featherChat) — Security Model & Threat Analysis
|
||||
|
||||
**Version:** 0.0.21
|
||||
**Last Updated:** 2026-03-29
|
||||
**Version:** 0.0.46
|
||||
**Last Updated:** 2026-03-30
|
||||
|
||||
---
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
| API write operations | Bearer token middleware on all POST routes |
|
||||
| Device sessions | Kick/revoke-all, max 5 WS per fingerprint |
|
||||
| Bot aliases | Reserved suffixes (Bot/bot/_bot) enforced |
|
||||
| DM call signaling | E2E encrypted via WireMessage::CallSignal |
|
||||
| Call room names | Hashed (not plaintext) on relay |
|
||||
|
||||
### What Is NOT Protected (Current)
|
||||
|
||||
@@ -37,6 +39,8 @@
|
||||
| Online/offline status | Server knows when clients connect via WebSocket|
|
||||
| IP addresses | Server sees client IP addresses |
|
||||
| Bot messages | Plaintext (not E2E) in v1 — bots don't hold ratchet sessions |
|
||||
| Group call media | Transport-only (QUIC TLS), not E2E — MLS planned |
|
||||
| Admin commands | No role-based auth yet (TODO: admin role system) |
|
||||
|
||||
### Trust Boundaries
|
||||
|
||||
@@ -374,6 +378,47 @@ The web client does not generate one-time pre-keys because `localStorage` cannot
|
||||
|
||||
---
|
||||
|
||||
## Bot API Security
|
||||
|
||||
Bot messages are **plaintext** in v1 — bots do not hold Double Ratchet sessions. This is a deliberate trade-off for simplicity.
|
||||
|
||||
- **Per-bot numeric IDs:** The Bot API translates fingerprints to per-bot numeric user IDs. A bot never sees the real fingerprints of the users it communicates with, providing a privacy layer between bots and users.
|
||||
- **BotFather token storage:** Bot tokens are stored in the server's sled database as `bot:<token>` entries. Tokens are generated server-side with 16 random bytes (32 hex characters). Treat tokens as secrets.
|
||||
- **Plaintext v1:** Bot messages travel as plaintext between the client and server. The client auto-detects bot aliases (suffixes `Bot`, `bot`, `_bot`) and skips E2E encryption. Future versions may support bot-side ratchet sessions.
|
||||
|
||||
---
|
||||
|
||||
## Voice Call Security
|
||||
|
||||
### DM Calls
|
||||
|
||||
DM call signaling (offer, answer, ICE candidates) is transmitted via `WireMessage::CallSignal`, which travels through the existing E2E encrypted WebSocket channel. The signaling is encrypted with the Double Ratchet session between the two peers — the server cannot read call setup metadata.
|
||||
|
||||
### Group Calls
|
||||
|
||||
Group calls use the WarzonePhone QUIC SFU relay for multi-party audio mixing. Media is encrypted in transit via QUIC TLS (transport-layer encryption), but is **not E2E encrypted** — the relay can observe audio streams.
|
||||
|
||||
**MLS planned:** Future versions will use Message Layer Security (RFC 9420) for E2E encrypted group call media, where the relay handles only opaque ciphertext.
|
||||
|
||||
### Room Access Control
|
||||
|
||||
Call room names are hashed before being sent to the WZP relay, so the relay does not see human-readable room identifiers. The relay enforces ACL checks using the featherChat bearer token for room join authorization.
|
||||
|
||||
---
|
||||
|
||||
## Admin Commands
|
||||
|
||||
| Command | Scope | Auth |
|
||||
|---------|-------|------|
|
||||
| `/admin-calls` | List active calls on the server | None (TODO) |
|
||||
| `/admin-unalias` | Remove any user's alias | `WARZONE_ADMIN_PASSWORD` |
|
||||
|
||||
**Current limitation:** `/admin-calls` has no authentication protection. Any connected client can invoke it. A proper admin role system (role assignment, challenge-based admin auth) is planned but not yet implemented.
|
||||
|
||||
`/admin-unalias` requires the `WARZONE_ADMIN_PASSWORD` environment variable to be set on the server and the client to provide the matching password.
|
||||
|
||||
---
|
||||
|
||||
## Known Weaknesses and Mitigations Planned
|
||||
|
||||
### 1. No Sealed Sender
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# featherChat Task Plan
|
||||
|
||||
**Version:** 0.0.21+
|
||||
**Last Updated:** 2026-03-28
|
||||
**Version:** 0.0.46
|
||||
**Last Updated:** 2026-03-30
|
||||
**Naming:** `FC-P{phase}-T{task}[-S{subtask}]`
|
||||
|
||||
---
|
||||
@@ -31,18 +31,29 @@
|
||||
### WZP Side (all 9 tasks done by WZP team)
|
||||
- [x] WZP-S-1 through WZP-S-9: Identity alignment, relay auth, signaling bridge, room ACL, crypto handshake, web bridge auth, wzp-proto standalone, CLI seed input, hardcoded assumptions fixed
|
||||
|
||||
### Additional Completed Work (not in original plan)
|
||||
- [x] ETH address integration — display everywhere TUI + Web (v0.0.22-0.0.24)
|
||||
- [x] Federation persistent WS + text selection (v0.0.25-0.0.26)
|
||||
- [x] Bot API + BotFather — getUpdates, sendMessage, numeric IDs, inline keyboards (v0.0.27-0.0.33)
|
||||
- [x] Bot sendMessage fix, per-bot ID mapping (v0.0.34)
|
||||
- [x] Markdown rendering in TUI + Web messages (v0.0.42)
|
||||
- [x] Call ring tones (v0.0.45)
|
||||
- [x] Group calls + group call fixes (v0.0.45-0.0.46)
|
||||
- [x] Admin commands (v0.0.46)
|
||||
- [x] Deploy scripts: build-linux.sh + build-bleeding.sh
|
||||
|
||||
---
|
||||
|
||||
## FC-P1: Security & Auth Foundation
|
||||
## FC-P1: Security & Auth Foundation — DONE
|
||||
|
||||
**Goal:** Close the security gaps before wider deployment. Auth enforcement is the critical path.
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P1-T1 | Auth enforcement middleware | 0.5d | — | TODO |
|
||||
| FC-P1-T2 | Session auto-recovery | 1d | — | TODO |
|
||||
| FC-P1-T3 | Rate limiting + connection guards | 0.5d | — | TODO |
|
||||
| FC-P1-T4 | Device management + session revocation | 1d | T1 | TODO |
|
||||
| FC-P1-T1 | Auth enforcement middleware | 0.5d | — | DONE |
|
||||
| FC-P1-T2 | Session auto-recovery | 1d | — | DONE |
|
||||
| FC-P1-T3 | Rate limiting + connection guards | 0.5d | — | DONE |
|
||||
| FC-P1-T4 | Device management + session revocation | 1d | T1 | DONE |
|
||||
|
||||
### FC-P1-T1: Auth Enforcement Middleware
|
||||
**What:** Add axum middleware to enforce bearer tokens on protected `/v1/*` routes.
|
||||
@@ -88,53 +99,53 @@
|
||||
|
||||
---
|
||||
|
||||
## FC-P2: TUI Call Integration
|
||||
## FC-P2: TUI Call Integration — DONE (v0.0.36-0.0.37)
|
||||
|
||||
**Goal:** Make call signaling work end-to-end in the TUI. Server infrastructure is ready (FC-2/3/5/6/7).
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P2-T1 | `/call <fp>` command — send CallSignal::Offer | 0.5d | — | TODO |
|
||||
| FC-P2-T2 | `/accept` + `/reject` commands | 0.5d | T1 | TODO |
|
||||
| FC-P2-T3 | `/hangup` command | 0.25d | T1 | TODO |
|
||||
| FC-P2-T4 | Call state machine (Idle/Ringing/Active/Ended) | 0.5d | T1 | TODO |
|
||||
| FC-P2-T4-S1 | Incoming call notification banner | 0.25d | T4 | TODO |
|
||||
| FC-P2-T4-S2 | In-call header indicator (duration, peer) | 0.25d | T4 | TODO |
|
||||
| FC-P2-T5 | Missed call display (parse WS JSON) | 0.25d | — | TODO |
|
||||
| FC-P2-T6 | `/contacts` online status via presence API | 0.25d | — | TODO |
|
||||
| FC-P2-T1 | `/call <fp>` command — send CallSignal::Offer | 0.5d | — | DONE (v0.0.36) |
|
||||
| FC-P2-T2 | `/accept` + `/reject` commands | 0.5d | T1 | DONE (v0.0.36) |
|
||||
| FC-P2-T3 | `/hangup` command | 0.25d | T1 | DONE (v0.0.36) |
|
||||
| FC-P2-T4 | Call state machine (Idle/Ringing/Active/Ended) | 0.5d | T1 | DONE (v0.0.37) |
|
||||
| FC-P2-T4-S1 | Incoming call notification banner | 0.25d | T4 | DONE (v0.0.37) |
|
||||
| FC-P2-T4-S2 | In-call header indicator (duration, peer) | 0.25d | T4 | DONE (v0.0.37) |
|
||||
| FC-P2-T5 | Missed call display (parse WS JSON) | 0.25d | — | DONE (v0.0.37) |
|
||||
| FC-P2-T6 | `/contacts` online status via presence API | 0.25d | — | DONE (v0.0.37) |
|
||||
|
||||
---
|
||||
|
||||
## FC-P3: Web Call Integration
|
||||
## FC-P3: Web Call Integration — DONE (v0.0.35-0.0.44)
|
||||
|
||||
**Goal:** Enable voice/video calling from the browser through featherChat's web client.
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P3-T1 | WASM: parse CallSignal in `decrypt_wire_message()` | 0.5d | — | TODO |
|
||||
| FC-P3-T2 | WASM: `create_call_signal()` export for JS | 0.5d | — | TODO |
|
||||
| FC-P3-T3 | Web client: call/accept/reject UI | 1d | T1, T2 | TODO |
|
||||
| FC-P3-T4 | Web client: integrate wzp-web audio bridge | 1d | T3 | TODO |
|
||||
| FC-P3-T1 | WASM: parse CallSignal in `decrypt_wire_message()` | 0.5d | — | DONE (v0.0.35) |
|
||||
| FC-P3-T2 | WASM: `create_call_signal()` export for JS | 0.5d | — | DONE (v0.0.35) |
|
||||
| FC-P3-T3 | Web client: call/accept/reject UI | 1d | T1, T2 | DONE (v0.0.36) |
|
||||
| FC-P3-T4 | Web client: integrate wzp-web audio bridge | 1d | T3 | DONE (v0.0.43) |
|
||||
| FC-P3-T5 | Extract web client from monolith (web.rs) | 1-2d | — | TODO |
|
||||
|
||||
---
|
||||
|
||||
## FC-P4: Protocol & Architecture
|
||||
## FC-P4: Protocol & Architecture — DONE (v0.0.38-0.0.39)
|
||||
|
||||
**Goal:** Harden the protocol for forward compatibility and resilience.
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P4-T1 | Session state versioning | 0.5d | — | TODO |
|
||||
| FC-P4-T2 | WireMessage versioning (envelope format) | 1d | — | TODO |
|
||||
| FC-P4-T3 | Periodic auto-backup | 0.5d | — | TODO |
|
||||
| FC-P4-T4 | libsignal migration assessment | 1-2w | — | TODO |
|
||||
| FC-P4-T1 | Session state versioning | 0.5d | — | DONE (v0.0.38) |
|
||||
| FC-P4-T2 | WireMessage versioning (envelope format) | 1d | — | DONE (v0.0.38) |
|
||||
| FC-P4-T3 | OTPK replenishment | 0.5d | — | DONE (v0.0.39) |
|
||||
| FC-P4-T4 | Periodic auto-backup | 0.5d | — | DONE (v0.0.38) |
|
||||
|
||||
---
|
||||
|
||||
## FC-P5: Major Features
|
||||
|
||||
**Goal:** Core differentiators — physical delivery, federation, identity provider.
|
||||
**Goal:** Core differentiators — physical delivery, federation, identity provider, E2E group calls.
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
@@ -142,6 +153,28 @@
|
||||
| FC-P5-T2 | DNS federation (server discovery + relay) | 2-3w | P4-T2 | TODO |
|
||||
| FC-P5-T3 | OIDC identity provider | 1-2w | P1-T1 | TODO |
|
||||
| FC-P5-T4 | Smart contract access control | 3-4w | P5-T3 | TODO |
|
||||
| FC-P5-T5 | MLS group call E2E encryption (RFC 9420) | 4-6w | — | TODO |
|
||||
|
||||
### FC-P5-T5: MLS for Group Call E2E (RFC 9420)
|
||||
|
||||
**Current state:** Group calls use transport encryption only (QUIC). Audio is encrypted on the wire but the WZP relay can see it. Direct 1:1 calls are E2E encrypted via existing Double Ratchet.
|
||||
|
||||
**Goal:** E2E encrypt group call audio using MLS (Messaging Layer Security, RFC 9420).
|
||||
|
||||
**Why MLS over alternatives:**
|
||||
- **Sender Keys** (Signal/WhatsApp): simpler but O(n) key distribution, no forward secrecy on member change
|
||||
- **MLS/TreeKEM**: O(log n) key updates, forward secrecy on every member change, designed for groups
|
||||
- **RFC 9420** is an IETF standard with multiple implementations (OpenMLS in Rust)
|
||||
|
||||
**Approach:**
|
||||
1. Integrate `openmls` crate for key agreement
|
||||
2. Each group call creates an MLS group (epoch 0)
|
||||
3. Members join via Welcome messages distributed through existing E2E channels
|
||||
4. Audio frames encrypted with the group's current epoch key (AES-GCM)
|
||||
5. Member leave triggers Commit + UpdatePath (O(log n) key rotation)
|
||||
6. WZP relay sees only ciphertext
|
||||
|
||||
**Dependencies:** OpenMLS crate, WASM compatibility for browser side
|
||||
|
||||
---
|
||||
|
||||
@@ -152,13 +185,27 @@
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P6-T1 | Message search (local history) | 1d | — | TODO |
|
||||
| FC-P6-T2 | Read receipts (viewport tracking) | 0.5d | — | TODO |
|
||||
| FC-P6-T2 | Read receipts (viewport tracking) | 0.5d | — | DONE (v0.0.41) |
|
||||
| FC-P6-T3 | Typing indicators | 0.5d | — | TODO |
|
||||
| FC-P6-T4 | Message reactions (emoji) | 1d | P4-T2 | TODO |
|
||||
| FC-P6-T5 | Voice messages as attachments | 1d | — | TODO |
|
||||
| FC-P6-T6 | Message wrapping for long text | 0.5d | — | TODO |
|
||||
| FC-P6-T7 | Tab completion for commands/aliases | 0.5d | — | TODO |
|
||||
| FC-P6-T6 | Message wrapping for long text | 0.5d | — | DONE (v0.0.39) |
|
||||
| FC-P6-T7 | Tab completion for commands/aliases | 0.5d | — | DONE (v0.0.39) |
|
||||
| FC-P6-T8 | File transfer progress gauge | 0.5d | — | TODO |
|
||||
| FC-P6-T9 | TUI address clipboard copy | 0.5d | — | TODO |
|
||||
| FC-P6-T10 | Web virtual scroll for large history | 0.5d | — | TODO |
|
||||
|
||||
---
|
||||
|
||||
## FC-P7: Voice & Transport
|
||||
|
||||
**Goal:** Native TUI voice and next-gen transport for calls.
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P7-T1 | TUI voice calls via cpal | 1-2d | — | TODO |
|
||||
| FC-P7-T2 | Sender Keys for DM call E2E | 1w | — | TODO |
|
||||
| FC-P7-T3 | WebTransport to replace wzp-web bridge | 2w | — | TODO |
|
||||
|
||||
---
|
||||
|
||||
@@ -166,7 +213,7 @@
|
||||
|
||||
Tasks with **no dependencies** that can run simultaneously:
|
||||
|
||||
**Sprint A (Security — P1):**
|
||||
**Sprint A (Security — P1):** DONE
|
||||
```
|
||||
FC-P1-T1 (auth middleware) — server only
|
||||
FC-P1-T2 (session recovery) — client only
|
||||
@@ -174,7 +221,7 @@ FC-P1-T3 (rate limiting) — server only
|
||||
→ then FC-P1-T4 (devices, needs T1)
|
||||
```
|
||||
|
||||
**Sprint B (TUI Calls — P2):**
|
||||
**Sprint B (TUI Calls — P2):** DONE
|
||||
```
|
||||
FC-P2-T1 (call command) → T2 (accept/reject) → T3 (hangup)
|
||||
FC-P2-T4 (state machine) → T4-S1 (banner) + T4-S2 (header)
|
||||
@@ -182,11 +229,11 @@ FC-P2-T5 (missed calls) — independent
|
||||
FC-P2-T6 (contacts online) — independent
|
||||
```
|
||||
|
||||
**Sprint C (Web — P3):**
|
||||
**Sprint C (Web — P3):** DONE (except T5)
|
||||
```
|
||||
FC-P3-T1 (WASM parse) — independent
|
||||
FC-P3-T2 (WASM create) — independent
|
||||
FC-P3-T5 (extract web.rs) — independent
|
||||
FC-P3-T5 (extract web.rs) — independent (TODO)
|
||||
→ then T3 (call UI) → T4 (audio)
|
||||
```
|
||||
|
||||
@@ -236,4 +283,5 @@ warzone-client/src/tui/
|
||||
| warzone-client (types) | 10 | App init, ChatLine, normfp |
|
||||
| warzone-client (input) | 25 | All keybindings, scroll, text editing |
|
||||
| warzone-client (draw) | 9 | Rendering, timestamps, scroll, connection dot, unread badge |
|
||||
| **Total** | **72** | All passing |
|
||||
| warzone-server | 10+ | Server integration tests |
|
||||
| **Total** | **~155** | All passing |
|
||||
|
||||
480
warzone/docs/TESTING_E2E.md
Normal file
480
warzone/docs/TESTING_E2E.md
Normal file
@@ -0,0 +1,480 @@
|
||||
# featherChat End-to-End Testing Guide
|
||||
|
||||
**Version:** 0.0.46
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## Test 16: Ring Tones
|
||||
|
||||
### Steps (Web ↔ Web)
|
||||
1. **User A**: Set peer to User B
|
||||
2. **User A**: Click Call button (or `/call`)
|
||||
3. **User A**: Listen for outgoing ringback tone (repeating double beep)
|
||||
4. **User B**: Listen for incoming ring tone (classic ring pattern)
|
||||
5. **User B**: Click Accept
|
||||
6. Both: Ring tones should stop immediately
|
||||
7. Repeat: User A calls, User B rejects — tones should stop on reject
|
||||
8. Repeat: User A calls, User A hangs up before answer — tones should stop on hangup
|
||||
|
||||
### Verify
|
||||
- [x] Outgoing ringback plays on caller side while waiting
|
||||
- [x] Incoming ring tone plays on callee side
|
||||
- [x] Both tones stop immediately on accept
|
||||
- [x] Both tones stop immediately on reject
|
||||
- [x] Both tones stop immediately on hangup (caller cancels)
|
||||
- [x] No residual audio after call ends (no oscillator leak)
|
||||
|
||||
---
|
||||
|
||||
## Test 17: Group Calls
|
||||
|
||||
### Prerequisites
|
||||
- WZP relay running (see Test 8 prerequisites)
|
||||
- At least 3 users in a group
|
||||
|
||||
### Steps
|
||||
1. **User A, B, C**: All join group via `/g testgroup`
|
||||
2. **User A**: `/gcall` — starts group voice call
|
||||
3. **User B**: Should see group call notification
|
||||
4. **User B**: `/gjoin` — joins the active group call
|
||||
5. Both A and B: Should hear each other's audio
|
||||
6. **User C**: `/gjoin` — joins, now 3 participants
|
||||
7. Verify participant count shows 3
|
||||
8. **User B**: `/gleave-call` — leaves call but stays in text group
|
||||
9. **User B**: Can still send text messages in the group
|
||||
10. **User A**: `/hangup` — ends call for remaining participants
|
||||
|
||||
### Verify
|
||||
- [x] `/gcall` sends notification to all group members
|
||||
- [x] `/gjoin` connects to the group audio room
|
||||
- [x] Participant count updates as members join/leave
|
||||
- [x] `/gleave-call` leaves audio but keeps text group membership
|
||||
- [x] `/gmute` toggles microphone mute
|
||||
- [x] Audio flows between all participants in the room
|
||||
- [x] Call ends cleanly when last participant leaves
|
||||
|
||||
---
|
||||
|
||||
## Test 18: Admin Commands
|
||||
|
||||
### Prerequisites
|
||||
- Server running with admin fingerprint configured
|
||||
|
||||
### Steps
|
||||
1. **Admin user**: `/admin-help` — should list available admin commands
|
||||
2. **Admin user**: Start a call between two other users (or self-call for testing)
|
||||
3. **Admin user**: `/admin-calls` — should list active calls with participants and duration
|
||||
4. **Non-admin user**: `/admin-calls` — should show "permission denied" or similar
|
||||
|
||||
### Verify
|
||||
- [x] `/admin-help` lists all admin commands
|
||||
- [x] `/admin-calls` shows active calls (caller, callee, duration, type)
|
||||
- [x] Non-admin users cannot execute admin commands
|
||||
- [x] Admin commands do not expose message content
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
@@ -1,6 +1,6 @@
|
||||
# featherChat Usage Guide
|
||||
|
||||
**Version:** 0.0.21
|
||||
**Version:** 0.0.46
|
||||
|
||||
---
|
||||
|
||||
@@ -287,6 +287,89 @@ 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 |
|
||||
|
||||
### Group Calls
|
||||
|
||||
Group calls allow multi-party audio within a group context. Any group member can initiate a call, and others can join at any time.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/gcall` | Start a group call in the current group |
|
||||
| `/gjoin` | Join an active group call |
|
||||
| `/gleave-call` | Leave the group call (call continues for others) |
|
||||
| `/gmute` | Toggle your microphone mute in the group call |
|
||||
|
||||
Group call audio is routed through the WZP QUIC SFU relay. Media is transport-encrypted (QUIC TLS) but not E2E encrypted -- the relay can observe audio streams. MLS-based E2E encryption for group calls is planned.
|
||||
|
||||
---
|
||||
|
||||
## Read Receipts
|
||||
|
||||
featherChat tracks message delivery and read status with three indicators:
|
||||
|
||||
| Indicator | Symbol | Meaning |
|
||||
|-----------|--------|---------|
|
||||
| Sent | Single gray tick | Message sent to server, no confirmation yet |
|
||||
| Delivered | Double gray tick | Recipient decrypted the message |
|
||||
| Read | Double blue tick | Recipient viewed the message in their viewport |
|
||||
|
||||
Read receipts are sent automatically when messages enter the visible area of the chat window. The system uses the sender's fingerprint for tracking and a dedup set to avoid sending duplicate read receipts for the same message.
|
||||
|
||||
---
|
||||
|
||||
## Markdown Formatting
|
||||
|
||||
Messages support markdown formatting in both the TUI and web client:
|
||||
|
||||
| Syntax | Result |
|
||||
|--------|--------|
|
||||
| `**bold**` | **bold** |
|
||||
| `*italic*` | *italic* |
|
||||
| `` `code` `` | `inline code` |
|
||||
| `# Header` | Header (at start of line) |
|
||||
| `> quote` | Block quote (at start of line) |
|
||||
| `- item` | List item (at start of line) |
|
||||
|
||||
Markdown is rendered inline in messages. In the TUI, bold, italic, and code spans use terminal attributes. In the web client, they render as HTML.
|
||||
|
||||
---
|
||||
|
||||
## Admin Commands
|
||||
|
||||
Server administration commands for operators:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/admin-calls` | List all active calls on the server |
|
||||
| `/admin-unalias <alias>` | Remove any user's alias (requires admin password) |
|
||||
|
||||
`/admin-unalias` prompts for the server's admin password (set via `WARZONE_ADMIN_PASSWORD` environment variable). `/admin-calls` currently has no auth protection -- an admin role system is planned.
|
||||
|
||||
---
|
||||
|
||||
## Groups
|
||||
|
||||
### Creating and Using Groups
|
||||
|
||||
345
warzone/scripts/deploy-chat.sh
Executable file
345
warzone/scripts/deploy-chat.sh
Executable file
@@ -0,0 +1,345 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Deploy featherChat + WZP to chat.manko.yoga on Hetzner cx23.
|
||||
# Clones from git, builds with Docker, sets up Caddy + TLS.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/deploy-chat.sh --create Create VPS + install Docker
|
||||
# ./scripts/deploy-chat.sh --dns Update CF DNS (A + AAAA)
|
||||
# ./scripts/deploy-chat.sh --deploy Clone repos + docker compose up
|
||||
# ./scripts/deploy-chat.sh --redeploy Git pull + rebuild
|
||||
# ./scripts/deploy-chat.sh --test Smoke test
|
||||
# ./scripts/deploy-chat.sh --ssh SSH into VPS
|
||||
# ./scripts/deploy-chat.sh --logs Tail logs
|
||||
# ./scripts/deploy-chat.sh --destroy Delete VPS + DNS
|
||||
# ./scripts/deploy-chat.sh --all create + dns + deploy + test
|
||||
|
||||
VM_NAME="fc-chat"
|
||||
SSH_KEY_NAME="wz"
|
||||
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
|
||||
SERVER_TYPE="cx23"
|
||||
IMAGE="debian-12"
|
||||
LOCATION="fsn1"
|
||||
REMOTE_USER="root"
|
||||
DOMAIN="chat.manko.yoga"
|
||||
CF_ZONE="manko.yoga"
|
||||
|
||||
# Git repos (public, HTTP)
|
||||
GIT_FC="https://git.manko.yoga/manawenuz/featherChat.git"
|
||||
GIT_WZP="https://git.manko.yoga/manawenuz/wz-phone.git"
|
||||
GIT_BRANCH="feature/call-ring-group"
|
||||
|
||||
DEPLOY_DIR="/root/featherChat"
|
||||
DOCKER_DIR="$DEPLOY_DIR/warzone/deploy/docker"
|
||||
|
||||
# Local paths
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
CF_TOKEN_FILE="$PROJECT_DIR/deploy/docker/cf_api_token.txt"
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -q"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
get_cf_token() {
|
||||
if [ -f "$CF_TOKEN_FILE" ]; then
|
||||
cat "$CF_TOKEN_FILE" | tr -d '\n'
|
||||
elif [ -n "${CF_API_TOKEN:-}" ]; then
|
||||
echo "$CF_API_TOKEN"
|
||||
else
|
||||
echo "ERROR: No CF token. Create deploy/docker/cf_api_token.txt" >&2; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
get_vm_ip() {
|
||||
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}'
|
||||
}
|
||||
|
||||
get_vm_ipv6() {
|
||||
hcloud server list -o columns=name,ipv6 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | sed 's|/64||'
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
local ip; ip=$(get_vm_ip)
|
||||
[ -z "$ip" ] && { echo "ERROR: No VM '$VM_NAME'. Run --create first." >&2; exit 1; }
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_create() {
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
|
||||
if [ -n "$existing" ]; then
|
||||
echo "VM already exists: $VM_NAME ($(get_vm_ip))"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[1/3] Creating Hetzner VPS: $VM_NAME ($SERVER_TYPE, $LOCATION)..."
|
||||
hcloud server create \
|
||||
--name "$VM_NAME" \
|
||||
--type "$SERVER_TYPE" \
|
||||
--image "$IMAGE" \
|
||||
--ssh-key "$SSH_KEY_NAME" \
|
||||
--location "$LOCATION" \
|
||||
--quiet
|
||||
|
||||
local ipv4 ipv6
|
||||
ipv4=$(get_vm_ip)
|
||||
ipv6=$(get_vm_ipv6)
|
||||
echo " IPv4: $ipv4"
|
||||
echo " IPv6: $ipv6"
|
||||
|
||||
echo "[2/3] Waiting for SSH..."
|
||||
for i in $(seq 1 30); do
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ipv4" "echo ok" &>/dev/null && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "[3/3] Installing Docker..."
|
||||
ssh_cmd 'export DEBIAN_FRONTEND=noninteractive && \
|
||||
apt-get update -qq > /dev/null && \
|
||||
apt-get install -y -qq ca-certificates curl gnupg git > /dev/null && \
|
||||
install -m 0755 -d /etc/apt/keyrings && \
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg && \
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list && \
|
||||
apt-get update -qq > /dev/null && \
|
||||
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null && \
|
||||
mkdir -p /etc/docker && echo "{\"ipv6\": true, \"fixed-cidr-v6\": \"fd00::/80\"}" > /etc/docker/daemon.json && \
|
||||
systemctl restart docker'
|
||||
|
||||
echo ""
|
||||
echo "=== VPS Ready ==="
|
||||
echo "IPv4: $ipv4"
|
||||
echo "IPv6: $ipv6"
|
||||
echo "SSH: ssh -i $SSH_KEY_PATH root@$ipv4"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --dns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_dns() {
|
||||
local ipv4 ipv6 cf_token zone_id
|
||||
ipv4=$(get_vm_ip)
|
||||
ipv6=$(get_vm_ipv6)
|
||||
cf_token=$(get_cf_token)
|
||||
|
||||
[ -z "$ipv4" ] && { echo "ERROR: No VM." >&2; exit 1; }
|
||||
|
||||
echo "Updating DNS: $DOMAIN"
|
||||
echo " A → $ipv4"
|
||||
echo " AAAA → ${ipv6}1"
|
||||
|
||||
zone_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
|
||||
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
|
||||
|
||||
for type_content in "A:$ipv4" "AAAA:${ipv6}1"; do
|
||||
local type="${type_content%%:*}" content="${type_content#*:}"
|
||||
local rec_id
|
||||
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
|
||||
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$rec_id" ]; then
|
||||
curl -4 -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
|
||||
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||
echo " $type updated"
|
||||
else
|
||||
curl -4 -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
|
||||
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||
echo " $type created"
|
||||
fi
|
||||
done
|
||||
|
||||
echo " Verify: dig $DOMAIN A +short && dig $DOMAIN AAAA +short"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --deploy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_deploy() {
|
||||
local ip cf_token
|
||||
ip=$(get_vm_ip)
|
||||
cf_token=$(get_cf_token)
|
||||
[ -z "$ip" ] && { echo "ERROR: No VM." >&2; exit 1; }
|
||||
|
||||
echo "[1/4] Cloning repos on VPS..."
|
||||
ssh_cmd "rm -rf $DEPLOY_DIR && \
|
||||
git clone --depth 1 -b $GIT_BRANCH $GIT_FC $DEPLOY_DIR && \
|
||||
git clone --depth 1 -b feature/wzp-web-variants $GIT_WZP $DEPLOY_DIR/warzone-phone"
|
||||
|
||||
echo "[2/4] Updating Caddyfile domain..."
|
||||
ssh_cmd "sed -i 's/voip.manko.yoga/$DOMAIN/g' $DOCKER_DIR/Caddyfile"
|
||||
|
||||
echo "[3/4] Setting up CF token..."
|
||||
ssh_cmd "echo '$cf_token' > $DOCKER_DIR/cf_api_token.txt && chmod 600 $DOCKER_DIR/cf_api_token.txt"
|
||||
|
||||
echo "[4/4] Building + starting stack (takes a few minutes on first run)..."
|
||||
ssh_cmd "cd $DOCKER_DIR && \
|
||||
sed -i 's|voip.manko.yoga/audio|$DOMAIN/audio|g' docker-compose.yml && \
|
||||
docker compose up -d --build 2>&1" | tail -30
|
||||
|
||||
echo ""
|
||||
echo "=== Deployed ==="
|
||||
echo "URL: https://$DOMAIN"
|
||||
echo "Logs: $0 --logs"
|
||||
echo "Test: $0 --test"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --redeploy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_redeploy() {
|
||||
local ip; ip=$(get_vm_ip)
|
||||
[ -z "$ip" ] && { echo "ERROR: No VM." >&2; exit 1; }
|
||||
|
||||
echo "[1/2] Pulling latest..."
|
||||
ssh_cmd "cd $DEPLOY_DIR && git pull && \
|
||||
cd $DEPLOY_DIR/warzone-phone && git pull"
|
||||
|
||||
echo "[2/2] Rebuilding..."
|
||||
ssh_cmd "cd $DOCKER_DIR && \
|
||||
sed -i 's/voip.manko.yoga/$DOMAIN/g' Caddyfile && \
|
||||
sed -i 's|voip.manko.yoga/audio|$DOMAIN/audio|g' docker-compose.yml && \
|
||||
docker compose up -d --build 2>&1" | tail -20
|
||||
|
||||
echo "=== Redeployed ==="
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_test() {
|
||||
echo "=== Smoke Test: $DOMAIN ==="
|
||||
local pass=0 fail=0
|
||||
|
||||
check() {
|
||||
local name="$1" url="$2" expect="$3"
|
||||
local status
|
||||
status=$(curl -4 -s -o /dev/null -w "%{http_code}" --connect-timeout 10 "$url" 2>/dev/null || echo "000")
|
||||
if [ "$status" = "$expect" ]; then
|
||||
echo " OK $name ($status)"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " FAIL $name (got $status, expected $expect)"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check "Web UI" "https://$DOMAIN/" "200"
|
||||
check "Health" "https://$DOMAIN/v1/health" "200"
|
||||
check "WASM" "https://$DOMAIN/wasm/warzone_wasm.js" "200"
|
||||
check "Relay config" "https://$DOMAIN/v1/wzp/relay-config" "200"
|
||||
check "Bot list" "https://$DOMAIN/v1/bot/list" "200"
|
||||
check "Whoami" "https://$DOMAIN/v1/whoami" "200"
|
||||
|
||||
# TLS
|
||||
local issuer
|
||||
issuer=$(echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null || echo "?")
|
||||
echo " TLS: $issuer"
|
||||
|
||||
# IPv4
|
||||
local v4; v4=$(dig +short "$DOMAIN" A 2>/dev/null || echo "?")
|
||||
echo " A: $v4"
|
||||
|
||||
# IPv6
|
||||
local v6; v6=$(dig +short "$DOMAIN" AAAA 2>/dev/null || echo "?")
|
||||
echo " AAAA: $v6"
|
||||
|
||||
# IPv6 connectivity
|
||||
local v6_status
|
||||
v6_status=$(curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://$DOMAIN/" 2>/dev/null || echo "000")
|
||||
[ "$v6_status" = "200" ] && echo " IPv6: reachable ($v6_status)" && pass=$((pass + 1)) || echo " IPv6: not reachable ($v6_status)"
|
||||
|
||||
# Whoami content
|
||||
local whoami
|
||||
whoami=$(curl -4 -s "https://$DOMAIN/v1/whoami" 2>/dev/null)
|
||||
echo " Whoami: $whoami"
|
||||
|
||||
echo ""
|
||||
echo "Results: $pass passed, $fail failed"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_ssh() {
|
||||
local ip; ip=$(get_vm_ip)
|
||||
[ -z "$ip" ] && { echo "No VM." >&2; exit 1; }
|
||||
exec ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip"
|
||||
}
|
||||
|
||||
do_logs() {
|
||||
ssh_cmd "cd $DOCKER_DIR && docker compose logs -f --tail=50"
|
||||
}
|
||||
|
||||
do_destroy() {
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
|
||||
if [ -z "$existing" ]; then
|
||||
echo "No VM '$VM_NAME'."
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Destroying: $VM_NAME"
|
||||
hcloud server delete "$VM_NAME"
|
||||
echo "VM deleted."
|
||||
|
||||
read -p "Remove DNS records for $DOMAIN? [y/N] " -n 1 -r; echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
local cf_token zone_id
|
||||
cf_token=$(get_cf_token)
|
||||
zone_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
|
||||
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
|
||||
for type in A AAAA; do
|
||||
local rec_id
|
||||
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
|
||||
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
|
||||
[ -n "$rec_id" ] && curl -4 -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
|
||||
-H "Authorization: Bearer $cf_token" > /dev/null && echo " Deleted $type"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
case "${1:-}" in
|
||||
--create) do_create ;;
|
||||
--dns) do_dns ;;
|
||||
--deploy) do_deploy ;;
|
||||
--redeploy) do_redeploy ;;
|
||||
--test) do_test ;;
|
||||
--ssh) do_ssh ;;
|
||||
--logs) do_logs ;;
|
||||
--destroy) do_destroy ;;
|
||||
--all) do_create; do_dns; do_deploy; echo ""; echo "Waiting 30s for TLS cert..."; sleep 30; do_test ;;
|
||||
*)
|
||||
echo "Deploy featherChat to chat.manko.yoga (Hetzner cx23)"
|
||||
echo ""
|
||||
echo "Usage: $0 <command>"
|
||||
echo ""
|
||||
echo " --create Create VPS + install Docker"
|
||||
echo " --dns Update Cloudflare A + AAAA records"
|
||||
echo " --deploy Clone repos + docker compose up"
|
||||
echo " --redeploy Git pull + rebuild"
|
||||
echo " --test Smoke test (6 checks + TLS + IPv6)"
|
||||
echo " --ssh SSH into VPS"
|
||||
echo " --logs Tail docker compose logs"
|
||||
echo " --destroy Delete VPS + DNS"
|
||||
echo " --all Full deploy (create + dns + deploy + test)"
|
||||
;;
|
||||
esac
|
||||
387
warzone/scripts/deploy-voip.sh
Executable file
387
warzone/scripts/deploy-voip.sh
Executable file
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Deploy featherChat + WZP stack to voip.manko.yoga on a Hetzner VPS.
|
||||
# Prerequisites: hcloud CLI authenticated, SSH key "wz", CF API token.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/deploy-voip.sh --create Create VPS, install Docker, deploy stack
|
||||
# ./scripts/deploy-voip.sh --deploy Upload source + docker compose up (on existing VPS)
|
||||
# ./scripts/deploy-voip.sh --dns Update Cloudflare DNS records
|
||||
# ./scripts/deploy-voip.sh --test Run smoke tests against voip.manko.yoga
|
||||
# ./scripts/deploy-voip.sh --ssh SSH into the VPS
|
||||
# ./scripts/deploy-voip.sh --destroy Delete VPS + DNS records
|
||||
# ./scripts/deploy-voip.sh --logs Tail docker compose logs
|
||||
# ./scripts/deploy-voip.sh --all Create + DNS + deploy + test
|
||||
|
||||
VM_NAME="fc-voip"
|
||||
SSH_KEY_NAME="wz"
|
||||
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
|
||||
SERVER_TYPE="cx23"
|
||||
IMAGE="debian-12"
|
||||
LOCATION="fsn1"
|
||||
REMOTE_USER="root"
|
||||
DOMAIN="voip.manko.yoga"
|
||||
CF_ZONE="manko.yoga"
|
||||
|
||||
PROJECT_ROOT="/Users/manwe/CascadeProjects/featherChat"
|
||||
DEPLOY_DIR="warzone/deploy/docker"
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -q"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CF Token — read from deploy/docker/cf_api_token.txt or env
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
get_cf_token() {
|
||||
if [ -f "$PROJECT_ROOT/$DEPLOY_DIR/cf_api_token.txt" ]; then
|
||||
cat "$PROJECT_ROOT/$DEPLOY_DIR/cf_api_token.txt" | tr -d '\n'
|
||||
elif [ -n "${CF_API_TOKEN:-}" ]; then
|
||||
echo "$CF_API_TOKEN"
|
||||
else
|
||||
echo "ERROR: No CF token. Create $DEPLOY_DIR/cf_api_token.txt or set CF_API_TOKEN" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
get_vm_ip() {
|
||||
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | tr -d ' '
|
||||
}
|
||||
|
||||
get_vm_ipv6() {
|
||||
hcloud server list -o columns=name,ipv6 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | sed 's|/64||'
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
[ -z "$ip" ] && { echo "ERROR: No VM '$VM_NAME'. Run --create first." >&2; exit 1; }
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
|
||||
}
|
||||
|
||||
scp_to() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$@" "$REMOTE_USER@$ip:/root/" 2>/dev/null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --create: Create VPS + install Docker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_create() {
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
|
||||
if [ -n "$existing" ]; then
|
||||
echo "VM already exists: $VM_NAME"
|
||||
echo " IPv4: $(get_vm_ip)"
|
||||
echo " IPv6: $(get_vm_ipv6)"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[1/4] Creating Hetzner VPS: $VM_NAME ($SERVER_TYPE, $LOCATION)..."
|
||||
hcloud server create \
|
||||
--name "$VM_NAME" \
|
||||
--type "$SERVER_TYPE" \
|
||||
--image "$IMAGE" \
|
||||
--ssh-key "$SSH_KEY_NAME" \
|
||||
--location "$LOCATION" \
|
||||
--quiet
|
||||
|
||||
local ipv4 ipv6
|
||||
ipv4=$(get_vm_ip)
|
||||
ipv6=$(get_vm_ipv6)
|
||||
echo " IPv4: $ipv4"
|
||||
echo " IPv6: $ipv6"
|
||||
|
||||
# Wait for SSH
|
||||
echo "[2/4] Waiting for SSH..."
|
||||
for i in $(seq 1 30); do
|
||||
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ipv4" "echo ok" &>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Install Docker
|
||||
echo "[3/4] Installing Docker..."
|
||||
ssh_cmd "apt-get update -qq > /dev/null 2>&1 && \
|
||||
apt-get install -y -qq ca-certificates curl gnupg > /dev/null 2>&1 && \
|
||||
install -m 0755 -d /etc/apt/keyrings && \
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg && \
|
||||
echo 'deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable' > /etc/apt/sources.list.d/docker.list && \
|
||||
apt-get update -qq > /dev/null 2>&1 && \
|
||||
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1"
|
||||
|
||||
# Enable IPv6 in Docker
|
||||
echo "[4/4] Configuring Docker IPv6..."
|
||||
ssh_cmd 'mkdir -p /etc/docker && echo "{\"ipv6\": true, \"fixed-cidr-v6\": \"fd00::/80\"}" > /etc/docker/daemon.json && systemctl restart docker'
|
||||
|
||||
echo ""
|
||||
echo "=== VPS Ready ==="
|
||||
echo "IPv4: $ipv4"
|
||||
echo "IPv6: $ipv6"
|
||||
echo "SSH: ssh -i $SSH_KEY_PATH root@$ipv4"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --dns: Update Cloudflare DNS records
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_dns() {
|
||||
local ipv4 ipv6 cf_token zone_id
|
||||
ipv4=$(get_vm_ip)
|
||||
ipv6=$(get_vm_ipv6)
|
||||
cf_token=$(get_cf_token)
|
||||
|
||||
[ -z "$ipv4" ] && { echo "ERROR: No VM. Run --create first." >&2; exit 1; }
|
||||
|
||||
echo "Updating DNS: $DOMAIN"
|
||||
echo " A → $ipv4"
|
||||
echo " AAAA → ${ipv6}1"
|
||||
|
||||
# Get zone ID
|
||||
zone_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
|
||||
-H "Authorization: Bearer $cf_token" \
|
||||
-H "Content-Type: application/json" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
|
||||
|
||||
echo " Zone: $zone_id"
|
||||
|
||||
# Upsert A record
|
||||
local a_id
|
||||
a_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=A" \
|
||||
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$a_id" ]; then
|
||||
curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$a_id" \
|
||||
-H "Authorization: Bearer $cf_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$ipv4\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||
echo " A record updated"
|
||||
else
|
||||
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
|
||||
-H "Authorization: Bearer $cf_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$ipv4\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||
echo " A record created"
|
||||
fi
|
||||
|
||||
# Upsert AAAA record (append ::1 to the /64 prefix)
|
||||
local aaaa_id aaaa_addr="${ipv6}1"
|
||||
aaaa_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=AAAA" \
|
||||
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$aaaa_id" ]; then
|
||||
curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$aaaa_id" \
|
||||
-H "Authorization: Bearer $cf_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"AAAA\",\"name\":\"$DOMAIN\",\"content\":\"$aaaa_addr\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||
echo " AAAA record updated"
|
||||
else
|
||||
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
|
||||
-H "Authorization: Bearer $cf_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"AAAA\",\"name\":\"$DOMAIN\",\"content\":\"$aaaa_addr\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||
echo " AAAA record created"
|
||||
fi
|
||||
|
||||
echo " Done. Verify: dig $DOMAIN A +short && dig $DOMAIN AAAA +short"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --deploy: Upload source + docker compose up
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_deploy() {
|
||||
local ip cf_token
|
||||
ip=$(get_vm_ip)
|
||||
cf_token=$(get_cf_token)
|
||||
[ -z "$ip" ] && { echo "ERROR: No VM. Run --create first." >&2; exit 1; }
|
||||
|
||||
echo "[1/4] Creating source tarball..."
|
||||
tar czf /tmp/fc-voip.tar.gz \
|
||||
--exclude='target' \
|
||||
--exclude='.git' \
|
||||
--exclude='.claude' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='notes' \
|
||||
-C "$PROJECT_ROOT" \
|
||||
warzone/Cargo.toml warzone/Cargo.lock warzone/crates \
|
||||
warzone/deploy/docker \
|
||||
warzone/wasm-pkg \
|
||||
warzone-phone/Cargo.toml warzone-phone/Cargo.lock warzone-phone/crates \
|
||||
.dockerignore \
|
||||
2>/dev/null || true
|
||||
|
||||
local size
|
||||
size=$(du -h /tmp/fc-voip.tar.gz | cut -f1)
|
||||
echo " Tarball: $size"
|
||||
|
||||
echo "[2/4] Uploading to $ip..."
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" /tmp/fc-voip.tar.gz "$REMOTE_USER@$ip:/root/fc-voip.tar.gz"
|
||||
ssh_cmd "rm -rf /root/featherChat && mkdir -p /root/featherChat && tar xzf /root/fc-voip.tar.gz -C /root/featherChat"
|
||||
rm -f /tmp/fc-voip.tar.gz
|
||||
|
||||
echo "[3/4] Setting up CF token + docker compose..."
|
||||
ssh_cmd "cd /root/featherChat/warzone/deploy/docker && echo '$cf_token' > cf_api_token.txt && chmod 600 cf_api_token.txt"
|
||||
|
||||
echo "[4/4] Building + starting stack (this takes a while on first run)..."
|
||||
ssh_cmd "cd /root/featherChat/warzone/deploy/docker && docker compose up -d --build 2>&1" | tail -20
|
||||
|
||||
echo ""
|
||||
echo "=== Deployed ==="
|
||||
echo "URL: https://$DOMAIN"
|
||||
echo "Logs: $0 --logs"
|
||||
echo "Test: $0 --test"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --test: Smoke test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_test() {
|
||||
echo "=== Smoke Test: $DOMAIN ==="
|
||||
echo ""
|
||||
|
||||
local pass=0 fail=0
|
||||
|
||||
check() {
|
||||
local name="$1" url="$2" expect="$3"
|
||||
local status
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$url" 2>/dev/null || echo "000")
|
||||
if [ "$status" = "$expect" ]; then
|
||||
echo " OK $name ($status)"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " FAIL $name (got $status, expected $expect)"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check "Web UI" "https://$DOMAIN/" "200"
|
||||
check "API health" "https://$DOMAIN/v1/health" "200"
|
||||
check "WASM module" "https://$DOMAIN/wasm/warzone_wasm.js" "200"
|
||||
check "Relay config" "https://$DOMAIN/v1/wzp/relay-config" "200"
|
||||
check "Audio bridge" "https://$DOMAIN/audio/" "200"
|
||||
check "Bot list" "https://$DOMAIN/v1/bot/list" "200"
|
||||
|
||||
# TLS check
|
||||
echo -n " "
|
||||
local issuer
|
||||
issuer=$(echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null || echo "unknown")
|
||||
echo "TLS: $issuer"
|
||||
|
||||
# IPv4
|
||||
echo -n " "
|
||||
local v4
|
||||
v4=$(dig +short "$DOMAIN" A 2>/dev/null || echo "?")
|
||||
echo "IPv4: $v4"
|
||||
|
||||
# IPv6
|
||||
echo -n " "
|
||||
local v6
|
||||
v6=$(dig +short "$DOMAIN" AAAA 2>/dev/null || echo "?")
|
||||
echo "IPv6: $v6"
|
||||
|
||||
# IPv6 connectivity
|
||||
echo -n " "
|
||||
local v6_status
|
||||
v6_status=$(curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://$DOMAIN/" 2>/dev/null || echo "000")
|
||||
if [ "$v6_status" = "200" ]; then
|
||||
echo "IPv6 reachable: OK ($v6_status)"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo "IPv6 reachable: no ($v6_status)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $pass passed, $fail failed"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --ssh / --logs / --destroy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_ssh() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
[ -z "$ip" ] && { echo "No VM." >&2; exit 1; }
|
||||
exec ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip"
|
||||
}
|
||||
|
||||
do_logs() {
|
||||
ssh_cmd "cd /root/featherChat/warzone/deploy/docker && docker compose logs -f --tail=50"
|
||||
}
|
||||
|
||||
do_destroy() {
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
|
||||
if [ -z "$existing" ]; then
|
||||
echo "No VM '$VM_NAME' to destroy."
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Destroying VM: $VM_NAME"
|
||||
hcloud server delete "$VM_NAME"
|
||||
echo "VM deleted."
|
||||
|
||||
# Optionally clean DNS
|
||||
echo ""
|
||||
read -p "Also remove DNS records for $DOMAIN? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
local cf_token zone_id
|
||||
cf_token=$(get_cf_token)
|
||||
zone_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
|
||||
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
|
||||
|
||||
for type in A AAAA; do
|
||||
local rec_id
|
||||
rec_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
|
||||
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
|
||||
if [ -n "$rec_id" ]; then
|
||||
curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
|
||||
-H "Authorization: Bearer $cf_token" > /dev/null
|
||||
echo " Deleted $type record"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
case "${1:-}" in
|
||||
--create) do_create ;;
|
||||
--dns) do_dns ;;
|
||||
--deploy) do_deploy ;;
|
||||
--test) do_test ;;
|
||||
--ssh) do_ssh ;;
|
||||
--logs) do_logs ;;
|
||||
--destroy) do_destroy ;;
|
||||
--all) do_create; do_dns; do_deploy; do_test ;;
|
||||
*)
|
||||
echo "Deploy featherChat stack to voip.manko.yoga"
|
||||
echo ""
|
||||
echo "Usage: $0 <command>"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " --create Create Hetzner cx23 VPS + install Docker"
|
||||
echo " --dns Update Cloudflare DNS (A + AAAA)"
|
||||
echo " --deploy Upload source + docker compose up"
|
||||
echo " --test Smoke test (6 HTTP checks + TLS + IPv6)"
|
||||
echo " --ssh SSH into the VPS"
|
||||
echo " --logs Tail docker compose logs"
|
||||
echo " --destroy Delete VPS + optionally DNS"
|
||||
echo " --all create + dns + deploy + test"
|
||||
echo ""
|
||||
echo "First run: $0 --all"
|
||||
echo "Redeploy: $0 --deploy && $0 --test"
|
||||
;;
|
||||
esac
|
||||
35
warzone/scripts/start-voip.sh
Executable file
35
warzone/scripts/start-voip.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Start featherChat Docker stack + update DNS.
|
||||
# Usage: ./scripts/start-voip.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
DOCKER_DIR="$PROJECT_DIR/deploy/docker"
|
||||
DNS_SCRIPT="$DOCKER_DIR/update-dns.sh"
|
||||
CF_TOKEN_FILE="$DOCKER_DIR/cf_api_token.txt"
|
||||
|
||||
# Check CF token
|
||||
if [ ! -f "$CF_TOKEN_FILE" ]; then
|
||||
echo "ERROR: $CF_TOKEN_FILE not found"
|
||||
echo " echo 'YOUR_CF_TOKEN' > $CF_TOKEN_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export CF_API_TOKEN=$(cat "$CF_TOKEN_FILE" | tr -d '\n')
|
||||
|
||||
# Update DNS first
|
||||
echo "=== Updating DNS ==="
|
||||
bash "$DNS_SCRIPT" --once
|
||||
|
||||
# Start Docker stack
|
||||
echo ""
|
||||
echo "=== Starting Docker stack ==="
|
||||
cd "$DOCKER_DIR"
|
||||
docker compose up -d
|
||||
|
||||
echo ""
|
||||
echo "=== Running ==="
|
||||
echo "URL: https://voip.manko.yoga"
|
||||
echo "Logs: docker compose -f $DOCKER_DIR/docker-compose.yml logs -f"
|
||||
171
warzone/scripts/test-variants.sh
Executable file
171
warzone/scripts/test-variants.sh
Executable file
@@ -0,0 +1,171 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Test all 6 WZP web client variants with dedicated subdomains.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-variants.sh --setup Create DNS + switch Caddyfile
|
||||
# ./scripts/test-variants.sh --teardown Remove DNS + restore Caddyfile
|
||||
# ./scripts/test-variants.sh --urls Print all test URLs
|
||||
# ./scripts/test-variants.sh --check Verify all 6 respond
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
DOCKER_DIR="$PROJECT_DIR/deploy/docker"
|
||||
CF_TOKEN_FILE="$DOCKER_DIR/cf_api_token.txt"
|
||||
CF_ZONE="manko.yoga"
|
||||
BASE_DOMAIN="voip.manko.yoga"
|
||||
|
||||
VARIANTS=(v1 v2 v3 v4 v5 v6)
|
||||
LABELS=("pure" "hybrid" "full" "ws" "ws-fec" "ws-full")
|
||||
|
||||
get_cf_token() {
|
||||
cat "$CF_TOKEN_FILE" | tr -d '\n'
|
||||
}
|
||||
|
||||
get_zone_id() {
|
||||
curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
|
||||
-H "Authorization: Bearer $(get_cf_token)" | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])"
|
||||
}
|
||||
|
||||
get_my_ip() {
|
||||
curl -4 -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || \
|
||||
ifconfig | grep "inet " | grep -v 127.0.0.1 | head -1 | awk '{print $2}'
|
||||
}
|
||||
|
||||
do_setup() {
|
||||
local ip zone_id cf_token
|
||||
ip=$(get_my_ip)
|
||||
cf_token=$(get_cf_token)
|
||||
zone_id=$(get_zone_id)
|
||||
|
||||
echo "Setting up variant testing"
|
||||
echo " IP: $ip"
|
||||
echo " Zone: $zone_id"
|
||||
echo ""
|
||||
|
||||
# Create A records for each subdomain
|
||||
for i in "${!VARIANTS[@]}"; do
|
||||
local sub="${VARIANTS[$i]}"
|
||||
local fqdn="${sub}.${BASE_DOMAIN}"
|
||||
local label="${LABELS[$i]}"
|
||||
|
||||
# Check existing
|
||||
local rec_id
|
||||
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$fqdn&type=A" \
|
||||
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$rec_id" ]; then
|
||||
curl -4 -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
|
||||
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"A\",\"name\":\"$fqdn\",\"content\":\"$ip\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||
echo " $fqdn → $ip (updated) [$label]"
|
||||
else
|
||||
curl -4 -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
|
||||
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"A\",\"name\":\"$fqdn\",\"content\":\"$ip\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||
echo " $fqdn → $ip (created) [$label]"
|
||||
fi
|
||||
done
|
||||
|
||||
# Switch Caddyfile
|
||||
echo ""
|
||||
echo "Switching Caddyfile to test mode..."
|
||||
cp "$DOCKER_DIR/Caddyfile" "$DOCKER_DIR/Caddyfile.backup"
|
||||
cp "$DOCKER_DIR/Caddyfile.test" "$DOCKER_DIR/Caddyfile"
|
||||
|
||||
echo "Restarting Caddy..."
|
||||
cd "$DOCKER_DIR" && docker compose restart caddy
|
||||
|
||||
echo ""
|
||||
echo "=== Ready ==="
|
||||
do_urls
|
||||
}
|
||||
|
||||
do_teardown() {
|
||||
local cf_token zone_id
|
||||
cf_token=$(get_cf_token)
|
||||
zone_id=$(get_zone_id)
|
||||
|
||||
echo "Tearing down variant testing..."
|
||||
|
||||
for sub in "${VARIANTS[@]}"; do
|
||||
local fqdn="${sub}.${BASE_DOMAIN}"
|
||||
local rec_id
|
||||
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$fqdn&type=A" \
|
||||
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
|
||||
if [ -n "$rec_id" ]; then
|
||||
curl -4 -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
|
||||
-H "Authorization: Bearer $cf_token" > /dev/null
|
||||
echo " Deleted $fqdn"
|
||||
fi
|
||||
done
|
||||
|
||||
# Restore Caddyfile
|
||||
if [ -f "$DOCKER_DIR/Caddyfile.backup" ]; then
|
||||
mv "$DOCKER_DIR/Caddyfile.backup" "$DOCKER_DIR/Caddyfile"
|
||||
echo "Restored original Caddyfile"
|
||||
cd "$DOCKER_DIR" && docker compose restart caddy
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
}
|
||||
|
||||
do_urls() {
|
||||
echo ""
|
||||
echo "Test URLs (open each in a browser tab, enter same room name):"
|
||||
echo ""
|
||||
echo " ┌────────┬──────────┬──────────────────────────────────────────┐"
|
||||
echo " │ Domain │ Variant │ URL │"
|
||||
echo " ├────────┼──────────┼──────────────────────────────────────────┤"
|
||||
for i in "${!VARIANTS[@]}"; do
|
||||
local sub="${VARIANTS[$i]}"
|
||||
local label="${LABELS[$i]}"
|
||||
printf " │ %-6s │ %-8s │ https://%s.%s/test-room?variant=%s │\n" "$sub" "$label" "$sub" "$BASE_DOMAIN" "$label"
|
||||
done
|
||||
echo " └────────┴──────────┴──────────────────────────────────────────┘"
|
||||
echo ""
|
||||
echo "All variants join the same room — test cross-variant audio."
|
||||
echo "featherChat: https://$BASE_DOMAIN (call via /call command)"
|
||||
}
|
||||
|
||||
do_check() {
|
||||
echo "Checking all variant endpoints..."
|
||||
local pass=0 fail=0
|
||||
|
||||
for i in "${!VARIANTS[@]}"; do
|
||||
local sub="${VARIANTS[$i]}"
|
||||
local label="${LABELS[$i]}"
|
||||
local url="https://${sub}.${BASE_DOMAIN}/test-room?variant=${label}"
|
||||
local status
|
||||
status=$(curl -4 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$url" 2>/dev/null || echo "000")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo " OK $sub ($label): $status"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " FAIL $sub ($label): $status"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Results: $pass passed, $fail failed"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--setup) do_setup ;;
|
||||
--teardown) do_teardown ;;
|
||||
--urls) do_urls ;;
|
||||
--check) do_check ;;
|
||||
*)
|
||||
echo "Test all 6 WZP web client variants"
|
||||
echo ""
|
||||
echo "Usage: $0 <command>"
|
||||
echo ""
|
||||
echo " --setup Create DNS records + switch Caddyfile"
|
||||
echo " --teardown Remove DNS records + restore Caddyfile"
|
||||
echo " --urls Print test URLs"
|
||||
echo " --check Verify all 6 respond with HTTP 200"
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user