diff --git a/warzone-phone b/warzone-phone index 237adbb..6f4e8eb 160000 --- a/warzone-phone +++ b/warzone-phone @@ -1 +1 @@ -Subproject commit 237adbbf21f41198b397c8169af69f8d328c83ca +Subproject commit 6f4e8eb9f6df1c498cf4c0befbf9f2db5fb4c0d5 diff --git a/warzone/CLAUDE.md b/warzone/CLAUDE.md new file mode 100644 index 0000000..0a3c885 --- /dev/null +++ b/warzone/CLAUDE.md @@ -0,0 +1,88 @@ +# featherChat — Design Principles & Conventions + +## MANDATORY: Version Bumping + +**After every set of changes that modifies functionality, bump the version:** +1. `Cargo.toml` workspace version (e.g. `0.0.22` → `0.0.23`) +2. `crates/warzone-protocol/Cargo.toml` standalone version (same) +3. `crates/warzone-server/src/routes/web.rs` JS `VERSION` constant +4. `crates/warzone-server/src/routes/web.rs` service worker `CACHE` version (`wz-vN` → `wz-v(N+1)`) + +Never commit functional changes without bumping all four. The service worker cache MUST be bumped or browsers will serve stale WASM. + +## Architecture Principles + +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). + +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. + +4. **Federation is transparent** — Users don't need to know which server their peer is on. Key lookup, alias resolution, and message delivery automatically proxy through federation. + +5. **Telegram Bot API compatibility** — Bot API follows Telegram conventions (getUpdates, sendMessage, token-in-URL). Bot aliases must end with Bot/bot/_bot. + +6. **Auth on writes, open reads** — All POST/write endpoints require bearer tokens. GET/read endpoints are public (needed for key exchange before auth is possible). + +## Coding Conventions + +### Rust +- Workspace crates: protocol (no I/O), server (axum), client (ratatui), wasm (wasm-bindgen), mule (future) +- Error handling: `AppResult` in server, `anyhow::Result` in client, `ProtocolError` in protocol +- State: `AppState` with `Arc>` for shared state, `Arc` for sled +- Auth: `AuthFingerprint` extractor as first handler param for protected routes +- Fingerprints: always normalize with `normfp()` (strip non-hex, lowercase) +- New routes: create `routes/.rs`, add `pub fn routes() -> Router`, merge in `routes/mod.rs` + +### TUI +- 7 modules in `tui/`: types, draw, commands, input, file_transfer, network, mod +- All ChatLine must include `timestamp: Local::now()` +- Add new commands to both the handler chain AND `/help` text +- Self-messaging prevention: check `normfp(&peer) != normfp(&self.our_fp)` + +### Web (WASM) +- 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 + +### Federation +- Persistent WS between servers, NOT HTTP polling +- Presence re-pushed every 10s + on connect +- Key lookup: proxy to peer for non-local fingerprints (never cache remote bundles) +- Alias resolution: fall back to peer if not found locally +- Registration: check peer to enforce global uniqueness + +### Bot API +- Token stored as `bot:` in tokens tree +- Reverse lookup: `bot_fp:` → token +- Alias auto-registered on bot creation with `_bot` suffix +- Reserved aliases: `*Bot`, `*bot`, `*_bot` blocked for non-bots + +## Task Naming + +`FC-P{phase}-T{task}[-S{subtask}]` + +See `docs/TASK_PLAN.md` for the full breakdown. + +## Testing + +- Protocol: unit tests in each module's `#[cfg(test)]` +- TUI: unit tests for types, input, draw (using ratatui TestBackend) +- WASM: can't test natively (js-sys dependency) — test equivalent logic in protocol crate +- Server: no integration tests yet (planned) + +## Key Files + +| What | Where | +|------|-------| +| Wire format | `warzone-protocol/src/message.rs` | +| Crypto primitives | `warzone-protocol/src/crypto.rs` | +| Server state | `warzone-server/src/state.rs` | +| All routes | `warzone-server/src/routes/mod.rs` | +| Federation | `warzone-server/src/federation.rs` | +| TUI commands | `warzone-client/src/tui/commands.rs` | +| Web client | `warzone-server/src/routes/web.rs` | +| WASM bridge | `warzone-wasm/src/lib.rs` | +| Task plan | `docs/TASK_PLAN.md` | +| Bot API docs | `docs/BOT_API.md` | +| LLM help ref | `docs/LLM_HELP.md` | diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index a892fe0..1a43439 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -163,7 +163,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tower 0.5.3", "tower-layer", "tower-service", @@ -317,6 +317,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -529,7 +535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -541,7 +547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -680,7 +686,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -706,7 +712,7 @@ dependencies = [ "generic-array", "group", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "serdect", "subtle", @@ -750,7 +756,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -897,6 +903,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -905,7 +925,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -917,7 +937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1078,6 +1098,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -1433,6 +1454,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1624,7 +1651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1716,6 +1743,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1725,6 +1807,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1738,8 +1826,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1749,7 +1847,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1761,6 +1869,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "ratatui" version = "0.28.1" @@ -1841,6 +1958,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -1848,6 +1967,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower 0.5.3", "tower-http 0.6.8", "tower-service", @@ -1855,6 +1975,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -1881,6 +2002,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1923,6 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1935,6 +2063,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -2171,7 +2300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2464,6 +2593,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.21.0", +] + [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -2475,7 +2618,7 @@ dependencies = [ "native-tls", "tokio", "tokio-native-tls", - "tungstenite", + "tungstenite 0.24.0", ] [[package]] @@ -2497,6 +2640,10 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -2633,6 +2780,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.24.0" @@ -2646,7 +2813,7 @@ dependencies = [ "httparse", "log", "native-tls", - "rand", + "rand 0.8.5", "sha1", "thiserror 1.0.69", "utf-8", @@ -2789,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.21" +version = "0.0.44" dependencies = [ "anyhow", "argon2", @@ -2802,7 +2969,7 @@ dependencies = [ "futures-util", "hex", "libc", - "rand", + "rand 0.8.5", "ratatui", "reqwest", "serde", @@ -2810,7 +2977,7 @@ dependencies = [ "sha2", "sled", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tracing", "tracing-subscriber", "url", @@ -2822,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.21" +version = "0.0.44" dependencies = [ "anyhow", "clap", @@ -2831,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.21" +version = "0.0.44" dependencies = [ "base64", "bincode", @@ -2843,7 +3010,7 @@ dependencies = [ "hex", "hkdf", "k256", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -2856,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.21" +version = "0.0.44" dependencies = [ "anyhow", "axum", @@ -2867,12 +3034,16 @@ dependencies = [ "ed25519-dalek", "futures-util", "hex", - "rand", + "rand 0.8.5", + "reqwest", "serde", "serde_json", + "sha2", "sled", + "tempfile", "thiserror 2.0.18", "tokio", + "tokio-tungstenite 0.21.0", "tower 0.4.13", "tower-http 0.5.2", "tracing", @@ -2883,7 +3054,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.21" +version = "0.0.44" dependencies = [ "base64", "bincode", @@ -2891,7 +3062,7 @@ dependencies = [ "getrandom 0.2.17", "hex", "js-sys", - "rand", + "rand 0.8.5", "serde", "serde_json", "uuid", @@ -3028,6 +3199,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3312,7 +3502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 06442ad..075c793 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.21" +version = "0.0.44" edition = "2021" license = "MIT" rust-version = "1.75" @@ -42,7 +42,7 @@ tokio = { version = "1", features = ["full"] } # Server axum = { version = "0.7", features = ["ws"] } -tower = "0.4" +tower = { version = "0.4", features = ["limit"] } tower-http = { version = "0.5", features = ["cors", "trace"] } # Client HTTP @@ -78,5 +78,8 @@ base64 = "0.22" # UUID uuid = { version = "1", features = ["v4", "serde"] } +# WebSocket client +tokio-tungstenite = { version = "0.21", features = ["native-tls"] } + # Zero secrets in memory zeroize = { version = "1", features = ["derive"] } diff --git a/warzone/README.md b/warzone/README.md new file mode 100644 index 0000000..6adc2ee --- /dev/null +++ b/warzone/README.md @@ -0,0 +1,165 @@ +# Warzone Messenger (featherChat) + +End-to-end encrypted messenger with Signal protocol cryptography, voice/video call integration, and server federation. + +## Features + +- **E2E Encrypted DMs** — X3DH key exchange + Double Ratchet (forward secrecy) +- **Group Messaging** — Sender Key protocol (O(1) encryption, fan-out delivery) +- **File Transfer** — Chunked (64KB), SHA-256 verified, ratchet-encrypted +- **Voice/Video Calls** — WarzonePhone integration (QUIC SFU relay, ChaCha20-Poly1305 media) +- **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) +- **Ethereum Identity** — Same seed derives messaging keypair + Ethereum address (secp256k1) +- **BIP39 Seed** — 24-word mnemonic for identity backup/recovery + +## Architecture + +``` +Clients (CLI / TUI / Web) + | + | E2E encrypted (ChaCha20-Poly1305) + | +warzone-server (axum + sled) + | + | Federation (HTTP + HMAC) + | +warzone-server (peer) + | + | Call signaling + | +WarzonePhone Relay (QUIC SFU) +``` + +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for full architecture with Mermaid diagrams. + +## Quick Start + +### Build + +```bash +cd warzone +cargo build --release +``` + +### Generate Identity + +```bash +./target/release/warzone-client init +# Outputs: 24-word BIP39 mnemonic + fingerprint +``` + +### Start Server + +```bash +./target/release/warzone-server --bind 0.0.0.0:7700 +``` + +### Start TUI + +```bash +./target/release/warzone-client tui --server http://localhost:7700 +``` + +### Federation (Two Servers) + +Create `alpha.json`: +```json +{ + "server_id": "alpha", + "shared_secret": "your-shared-secret", + "peer": { "id": "bravo", "url": "http://server-b:7700" }, + "presence_interval_secs": 5 +} +``` + +```bash +# Server A +warzone-server --bind 0.0.0.0:7700 --federation alpha.json + +# Server B +warzone-server --bind 0.0.0.0:7700 --federation bravo.json +``` + +Messages automatically route across servers. + +## TUI Commands + +| Command | Description | +|---------|-------------| +| `/peer ` or `/p @alias` | Set DM peer | +| `/g ` | Switch to group (auto-join) | +| `/call ` | Initiate call | +| `/file ` | Send file (max 10MB) | +| `/contacts` | List contacts with message counts | +| `/history` | Show conversation history | +| `/devices` | List active device sessions | +| `/kick ` | Revoke a device session | +| `/help` | Full command list | + +## Crates + +| Crate | Purpose | +|-------|---------| +| `warzone-protocol` | Crypto & message types (X3DH, Double Ratchet, Sender Keys) | +| `warzone-server` | HTTP/WS server with sled DB, federation, call state | +| `warzone-client` | CLI + TUI client | +| `warzone-wasm` | WASM bridge for web client | +| `warzone-mule` | Physical message delivery (planned) | + +## Cryptographic Stack + +| Primitive | Purpose | +|-----------|---------| +| Ed25519 | Identity signing | +| X25519 | Diffie-Hellman key exchange | +| ChaCha20-Poly1305 | AEAD encryption | +| HKDF-SHA256 | Key derivation | +| Argon2id | Seed encryption at rest | +| secp256k1 | Ethereum-compatible identity | + +## Security + +- Auth enforcement on all write routes (bearer token middleware) +- Session auto-recovery on ratchet corruption +- Per-fingerprint WS connection cap (5 devices) +- Global request concurrency limit (200) +- Device management (list, kick, revoke-all panic button) +- Federation auth: SHA-256(secret || body) on every inter-server request + +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) + +```bash +cargo test --workspace +``` + +## WarzonePhone Integration + +All 9 WZP-side integration tasks are complete: +- Shared identity (HKDF alignment, 15 cross-project tests) +- Relay auth (featherChat bearer token validation) +- Signaling bridge (CallSignal through E2E encrypted WS) +- Room access control (hashed room names, ACL) +- Mandatory crypto handshake on all paths + +## Documentation + +| Document | Content | +|----------|---------| +| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Full system architecture with Mermaid diagrams | +| [TASK_PLAN.md](docs/TASK_PLAN.md) | Phase-by-phase task plan (FC-P1 through P6) | +| [PROGRESS.md](docs/PROGRESS.md) | Version history and feature timeline | +| [PROTOCOL.md](docs/PROTOCOL.md) | Wire protocol specification | +| [SECURITY.md](docs/SECURITY.md) | Threat model and security analysis | +| [FUTURE_TASKS.md](docs/FUTURE_TASKS.md) | Backlog with questions-before-starting | + +## License + +MIT diff --git a/warzone/bots.example.json b/warzone/bots.example.json new file mode 100644 index 0000000..f2c3c22 --- /dev/null +++ b/warzone/bots.example.json @@ -0,0 +1,12 @@ +[ + {"name": "helpbot", "description": "featherChat help & FAQ"}, + {"name": "codebot", "description": "Coding assistant"}, + {"name": "survivalbot", "description": "War/emergency/survival guide"}, + {"name": "farsibot", "description": "Farsi → English translation"}, + {"name": "engbot", "description": "English → Farsi translation"}, + {"name": "mathbot", "description": "Math helper"}, + {"name": "medbot", "description": "First aid & health info"}, + {"name": "writebot", "description": "Writing assistant"}, + {"name": "cookbot", "description": "Cooking with limited ingredients"}, + {"name": "mindbot", "description": "Mental health & stress support"} +] diff --git a/warzone/crates/warzone-client/src/cli/init.rs b/warzone/crates/warzone-client/src/cli/init.rs index ee2292d..b571431 100644 --- a/warzone/crates/warzone-client/src/cli/init.rs +++ b/warzone/crates/warzone-client/src/cli/init.rs @@ -85,8 +85,16 @@ pub async fn register_with_server_identity( .map_err(|_| anyhow::anyhow!("No bundle found. Run `warzone init` first."))?; let bundle: PreKeyBundle = bincode::deserialize(&bundle_bytes)?; + // Derive ETH address from seed + let eth_address = crate::keystore::load_seed_raw() + .map(|seed| { + let eth = warzone_protocol::ethereum::derive_eth_identity(&seed); + eth.address.to_checksum() + }) + .ok(); + let client = ServerClient::new(server_url); - client.register_bundle(&fp, &bundle).await?; + client.register_bundle(&fp, &bundle, eth_address).await?; println!("Bundle registered with {}", server_url); Ok(()) diff --git a/warzone/crates/warzone-client/src/net.rs b/warzone/crates/warzone-client/src/net.rs index 79184f5..9633d2d 100644 --- a/warzone/crates/warzone-client/src/net.rs +++ b/warzone/crates/warzone-client/src/net.rs @@ -14,6 +14,8 @@ pub struct ServerClient { struct RegisterRequest { fingerprint: String, bundle: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + eth_address: Option, } #[derive(Serialize)] @@ -43,6 +45,7 @@ impl ServerClient { &self, fingerprint: &str, bundle: &PreKeyBundle, + eth_address: Option, ) -> Result<()> { let encoded = bincode::serialize(bundle).context("failed to serialize bundle")?; @@ -51,6 +54,7 @@ impl ServerClient { .json(&RegisterRequest { fingerprint: fingerprint.to_string(), bundle: encoded, + eth_address, }) .send() .await @@ -109,6 +113,35 @@ impl ServerClient { Ok(()) } + /// Check how many one-time pre-keys remain on the server. + pub async fn otpk_count(&self, fingerprint: &str) -> Result { + let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect(); + let resp: serde_json::Value = self.client + .get(format!("{}/v1/keys/{}/otpk-count", self.base_url, fp_clean)) + .send() + .await + .context("failed to check OTPK count")? + .json() + .await + .context("failed to parse OTPK count")?; + Ok(resp.get("count").and_then(|v| v.as_u64()).unwrap_or(0)) + } + + /// Upload additional one-time pre-keys. + pub async fn replenish_otpks(&self, fingerprint: &str, keys: Vec<(u32, [u8; 32])>) -> Result<()> { + let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect(); + let otpks: Vec = keys.iter().map(|(id, pubkey)| { + serde_json::json!({"id": id, "public_key": hex::encode(pubkey)}) + }).collect(); + self.client + .post(format!("{}/v1/keys/replenish", self.base_url)) + .json(&serde_json::json!({"fingerprint": fp_clean, "one_time_pre_keys": otpks})) + .send() + .await + .context("failed to replenish OTPKs")?; + Ok(()) + } + /// Poll for messages addressed to us. pub async fn poll_messages(&self, fingerprint: &str) -> Result>> { let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect(); diff --git a/warzone/crates/warzone-client/src/storage.rs b/warzone/crates/warzone-client/src/storage.rs index 5c992e5..aaf3c31 100644 --- a/warzone/crates/warzone-client/src/storage.rs +++ b/warzone/crates/warzone-client/src/storage.rs @@ -10,6 +10,7 @@ pub struct LocalDb { pre_keys: sled::Tree, contacts: sled::Tree, history: sled::Tree, + sender_keys: sled::Tree, _db: sled::Db, } @@ -39,11 +40,13 @@ impl LocalDb { let pre_keys = db.open_tree("pre_keys")?; let contacts = db.open_tree("contacts")?; let history = db.open_tree("history")?; + let sender_keys = db.open_tree("sender_keys")?; Ok(LocalDb { sessions, pre_keys, contacts, history, + sender_keys, _db: db, }) } @@ -51,19 +54,28 @@ impl LocalDb { /// Save a ratchet session for a peer. pub fn save_session(&self, peer: &Fingerprint, state: &RatchetState) -> Result<()> { let key = peer.to_hex(); - let data = bincode::serialize(state).context("failed to serialize session")?; + let data = state.serialize_versioned() + .map_err(|e| anyhow::anyhow!("{}", e))?; self.sessions.insert(key.as_bytes(), data)?; self.sessions.flush()?; Ok(()) } + /// Delete a ratchet session for a peer (used for session recovery). + pub fn delete_session(&self, peer: &Fingerprint) -> Result<()> { + let key = peer.to_hex(); + self.sessions.remove(key.as_bytes())?; + self.sessions.flush()?; + Ok(()) + } + /// Load a ratchet session for a peer. pub fn load_session(&self, peer: &Fingerprint) -> Result> { let key = peer.to_hex(); match self.sessions.get(key.as_bytes())? { Some(data) => { - let state = bincode::deserialize(&data) - .context("failed to deserialize session")?; + let state = RatchetState::deserialize_versioned(&data) + .map_err(|e| anyhow::anyhow!("{}", e))?; Ok(Some(state)) } None => Ok(None), @@ -101,6 +113,22 @@ impl LocalDb { Ok(()) } + /// Return the next available OTPK ID (one past the highest stored). + pub fn next_otpk_id(&self) -> u32 { + let mut max_id: Option = None; + for item in self.pre_keys.iter() { + if let Ok((k, _)) = item { + let key_str = String::from_utf8_lossy(&k); + if let Some(id_str) = key_str.strip_prefix("otpk:") { + if let Ok(id) = id_str.parse::() { + max_id = Some(max_id.map_or(id, |m: u32| m.max(id))); + } + } + } + } + max_id.map_or(0, |m| m + 1) + } + /// Load and remove a one-time pre-key secret. pub fn take_one_time_pre_key(&self, id: u32) -> Result> { let key = format!("otpk:{}", id); @@ -115,6 +143,39 @@ impl LocalDb { } } + // ── Sender Keys ── + + /// Save a sender key for a (sender, group) pair. + pub fn save_sender_key( + &self, + sender_fp: &str, + group_name: &str, + key: &warzone_protocol::sender_keys::SenderKey, + ) -> Result<()> { + let db_key = format!("sk:{}:{}", sender_fp, group_name); + let data = bincode::serialize(key).context("failed to serialize sender key")?; + self.sender_keys.insert(db_key.as_bytes(), data)?; + self.sender_keys.flush()?; + Ok(()) + } + + /// Load a sender key for a (sender, group) pair. + pub fn load_sender_key( + &self, + sender_fp: &str, + group_name: &str, + ) -> Result> { + let db_key = format!("sk:{}:{}", sender_fp, group_name); + match self.sender_keys.get(db_key.as_bytes())? { + Some(data) => { + let key = bincode::deserialize(&data) + .context("failed to deserialize sender key")?; + Ok(Some(key)) + } + None => Ok(None), + } + } + // ── Contacts ── /// Add or update a contact. Called on send/receive. @@ -228,6 +289,87 @@ impl LocalDb { })) } + /// Create an encrypted backup of all session data. + /// Returns the backup file path. + pub fn create_backup(&self, seed: &[u8; 32]) -> Result { + use std::io::Write; + + let backup_dir = crate::keystore::data_dir().join("backups"); + std::fs::create_dir_all(&backup_dir)?; + + // Collect all data + let mut data = serde_json::Map::new(); + + // Sessions + let mut sessions = serde_json::Map::new(); + for item in self.sessions.iter() { + if let Ok((key, value)) = item { + let k = String::from_utf8_lossy(&key).to_string(); + sessions.insert(k, serde_json::Value::String(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, &value + ))); + } + } + data.insert("sessions".into(), serde_json::Value::Object(sessions)); + + // Contacts + let mut contacts = serde_json::Map::new(); + for item in self.contacts.iter() { + if let Ok((key, value)) = item { + let k = String::from_utf8_lossy(&key).to_string(); + if let Ok(v) = serde_json::from_slice::(&value) { + contacts.insert(k, v); + } + } + } + data.insert("contacts".into(), serde_json::Value::Object(contacts)); + + // Sender keys + let mut sender_keys = serde_json::Map::new(); + for item in self.sender_keys.iter() { + if let Ok((key, value)) = item { + let k = String::from_utf8_lossy(&key).to_string(); + sender_keys.insert(k, serde_json::Value::String(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, &value + ))); + } + } + data.insert("sender_keys".into(), serde_json::Value::Object(sender_keys)); + + // Serialize and encrypt + let plaintext = serde_json::to_vec(&serde_json::Value::Object(data))?; + let key_bytes = warzone_protocol::crypto::hkdf_derive(seed, b"", b"warzone-backup", 32); + let mut key = [0u8; 32]; + key.copy_from_slice(&key_bytes); + let encrypted = warzone_protocol::crypto::aead_encrypt(&key, &plaintext, b"warzone-backup-aad"); + + // Write to temp file then rename (atomic) + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string(); + let filename = format!("backup_{}.wzbk", timestamp); + let path = backup_dir.join(&filename); + let tmp_path = backup_dir.join(format!(".{}.tmp", filename)); + + let mut file = std::fs::File::create(&tmp_path)?; + file.write_all(&encrypted)?; + file.sync_all()?; + std::fs::rename(&tmp_path, &path)?; + + // Rotate: keep last 3 backups + let mut backups: Vec<_> = std::fs::read_dir(&backup_dir)? + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".wzbk")) + .collect(); + backups.sort_by_key(|e| e.file_name()); + while backups.len() > 3 { + if let Some(old) = backups.first() { + let _ = std::fs::remove_file(old.path()); + backups.remove(0); + } + } + + Ok(path) + } + /// Import data from JSON backup (merges, doesn't overwrite existing). pub fn import_all(&self, data: &serde_json::Value) -> Result { let mut count = 0; diff --git a/warzone/crates/warzone-client/src/tui/app.rs b/warzone/crates/warzone-client/src/tui/app.rs deleted file mode 100644 index 357875e..0000000 --- a/warzone/crates/warzone-client/src/tui/app.rs +++ /dev/null @@ -1,1756 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use anyhow::Result; -use crossterm::event::{self, Event, KeyCode, KeyModifiers}; -use ratatui::layout::{Constraint, Direction, Layout}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; -use ratatui::Frame; -use sha2::{Sha256, Digest}; -use warzone_protocol::identity::IdentityKeyPair; -use warzone_protocol::message::{ReceiptType, WireMessage}; -use warzone_protocol::ratchet::RatchetState; -use warzone_protocol::types::Fingerprint; -use warzone_protocol::x3dh; -use x25519_dalek::PublicKey; - -use crate::net::ServerClient; -use crate::storage::LocalDb; - -/// Maximum file size: 10 MB. -const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; -/// Chunk size: 64 KB. -const CHUNK_SIZE: usize = 64 * 1024; - -/// State for tracking an incoming chunked file transfer. -#[derive(Clone)] -pub struct PendingFileTransfer { - pub filename: String, - pub total_chunks: u32, - pub received: u32, - pub chunks: Vec>>, - pub sha256: String, - pub file_size: u64, -} - -/// Receipt status for a sent message. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ReceiptStatus { - Sent, - Delivered, - Read, -} - -pub struct App { - pub input: String, - pub messages: Arc>>, - pub our_fp: String, - pub peer_fp: Option, - pub server_url: String, - pub should_quit: bool, - pub cursor_pos: usize, - pub last_dm_peer: Arc>>, - /// Track receipt status for messages we sent, keyed by message ID. - pub receipts: Arc>>, - /// Pending incoming file transfers, keyed by file ID. - pub pending_files: Arc>>, -} - -#[derive(Clone)] -pub struct ChatLine { - pub sender: String, - pub text: String, - pub is_system: bool, - pub is_self: bool, - /// Message ID (for sent messages, used to track receipts). - pub message_id: Option, -} - -impl App { - pub fn new(our_fp: String, peer_fp: Option, server_url: String) -> Self { - let messages = Arc::new(Mutex::new(vec![ChatLine { - sender: "system".into(), - text: format!("You are {}", our_fp), - is_system: true, - is_self: false, - message_id: None, - }])); - - if let Some(ref peer) = peer_fp { - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: format!("Chatting with {}", peer), - is_system: true, - is_self: false, - message_id: None, - }); - } else { - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: "No peer set. Use /peer , /peer @alias, or /g ".into(), - is_system: true, - is_self: false, - message_id: None, - }); - } - - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: "/alias /peer /g /gleave /gkick /gmembers /file /info /quit".into(), - is_system: true, - is_self: false, - message_id: None, - }); - - App { - input: String::new(), - messages, - our_fp, - peer_fp, - server_url, - should_quit: false, - last_dm_peer: Arc::new(Mutex::new(None)), - cursor_pos: 0, - receipts: Arc::new(Mutex::new(HashMap::new())), - pending_files: Arc::new(Mutex::new(HashMap::new())), - } - } - - pub fn add_message(&self, line: ChatLine) { - self.messages.lock().unwrap().push(line); - } - - fn receipt_indicator(&self, message_id: &Option) -> &'static str { - match message_id { - Some(id) => { - let receipts = self.receipts.lock().unwrap(); - match receipts.get(id) { - Some(ReceiptStatus::Read) => " \u{2713}\u{2713}", // ✓✓ (read) - Some(ReceiptStatus::Delivered) => " \u{2713}\u{2713}", // ✓✓ (delivered) - Some(ReceiptStatus::Sent) | None => " \u{2713}", // ✓ (sent) - } - } - None => "", - } - } - - fn receipt_color(&self, message_id: &Option) -> Color { - match message_id { - Some(id) => { - let receipts = self.receipts.lock().unwrap(); - match receipts.get(id) { - Some(ReceiptStatus::Read) => Color::Blue, - Some(ReceiptStatus::Delivered) => Color::White, - Some(ReceiptStatus::Sent) | None => Color::DarkGray, - } - } - None => Color::DarkGray, - } - } - - pub fn draw(&self, frame: &mut Frame) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // header - Constraint::Min(5), // messages - Constraint::Length(3), // input - ]) - .split(frame.area()); - - // Header - let peer_str = self - .peer_fp - .as_deref() - .unwrap_or("no peer"); - let header = Paragraph::new(Line::from(vec![ - Span::styled("WZ ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), - Span::styled(&self.our_fp, Style::default().fg(Color::Green)), - Span::raw(" → "), - Span::styled(peer_str, Style::default().fg(Color::Yellow)), - Span::styled( - format!(" [{}]", self.server_url), - Style::default().fg(Color::DarkGray), - ), - ])); - frame.render_widget(header, chunks[0]); - - // Messages - let msgs = self.messages.lock().unwrap(); - let items: Vec = msgs - .iter() - .map(|m| { - let style = if m.is_system { - Style::default().fg(Color::Cyan) - } else if m.is_self { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::Yellow) - }; - - let prefix = if m.is_system { - "*** ".to_string() - } else { - format!("{}: ", &m.sender[..m.sender.len().min(12)]) - }; - - let receipt_str = if m.is_self && m.message_id.is_some() { - self.receipt_indicator(&m.message_id) - } else { - "" - }; - let receipt_color = self.receipt_color(&m.message_id); - - ListItem::new(Line::from(vec![ - Span::styled(prefix, style.add_modifier(Modifier::BOLD)), - Span::raw(&m.text), - Span::styled(receipt_str, Style::default().fg(receipt_color)), - ])) - }) - .collect(); - - let messages_widget = List::new(items) - .block(Block::default().borders(Borders::TOP)); - frame.render_widget(messages_widget, chunks[1]); - - // Input - let input_widget = Paragraph::new(self.input.as_str()) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) - .title(" message "), - ) - .wrap(Wrap { trim: false }); - frame.render_widget(input_widget, chunks[2]); - - // Cursor - let x = (self.cursor_pos as u16 + 1).min(chunks[2].width - 2); - frame.set_cursor_position((chunks[2].x + x, chunks[2].y + 1)); - } - - pub async fn handle_send( - &mut self, - identity: &IdentityKeyPair, - db: &LocalDb, - client: &ServerClient, - ) { - let text = self.input.trim().to_string(); - self.input.clear(); - self.cursor_pos = 0; - - if text.is_empty() { - return; - } - - // Commands - if text == "/quit" || text == "/q" { - self.should_quit = true; - return; - } - if text == "/info" { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Your fingerprint: {}", self.our_fp), - is_system: true, - is_self: false, - message_id: None, - }); - return; - } - if text.starts_with("/alias ") { - let name = text[7..].trim(); - self.register_alias(name, client).await; - return; - } - if text == "/aliases" { - self.list_aliases(client).await; - return; - } - if text == "/unalias" { - let url = format!("{}/v1/alias/unregister", client.base_url); - match client.client.post(&url) - .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)})) - .send().await - { - Ok(resp) => if let Ok(data) = resp.json::().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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: None }); - } - }, - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - return; - } - if text == "/contacts" || text == "/c" { - 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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None }); - 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), - }; - self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - return; - } - if text.starts_with("/history") || text.starts_with("/h ") { - 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 (or set peer first)".into(), is_system: true, is_self: false, message_id: None }); - } 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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None }); - 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(""); - let is_self = m.get("is_self").and_then(|v| v.as_bool()).unwrap_or(false); - self.add_message(ChatLine { - sender: sender[..sender.len().min(12)].to_string(), - text: txt.to_string(), - is_system: false, - is_self, - message_id: None, - }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - return; - } - if text == "/eth" { - // 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 }); - } - return; - } - if text == "/r" || text == "/reply" { - 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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None }); - } - return; - } - if text.starts_with("/peer ") || text.starts_with("/p ") { - let text = if text.starts_with("/p ") { format!("/peer {}", &text[3..]) } else { text.clone() }; - let raw = text[6..].trim().to_string(); - let fp = if raw.starts_with('@') { - match self.resolve_alias(&raw[1..], client).await { - Some(resolved) => resolved, - None => return, - } - } else { - raw - }; - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Peer set to {}", fp), - is_system: true, - is_self: false, - message_id: None, - }); - self.peer_fp = Some(fp); - return; - } - if text.starts_with("/gcreate ") { - let name = text[9..].trim(); - self.group_create(name, client).await; - return; - } - if text.starts_with("/gjoin ") { - let name = text[7..].trim(); - self.group_join(name, client).await; - return; - } - if text.starts_with("/g ") { - let name = text[3..].trim().to_string(); - // Auto-join - self.group_join(&name, client).await; - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Switched to group #{}", name), - is_system: true, - is_self: false, - message_id: None, - }); - self.peer_fp = Some(format!("#{}", name)); - return; - } - if text == "/dm" { - self.add_message(ChatLine { - sender: "system".into(), - text: "Switched to DM mode. Use /peer ".into(), - is_system: true, - is_self: false, - message_id: None, - }); - self.peer_fp = None; - return; - } - if text == "/glist" { - self.group_list(client).await; - return; - } - if text == "/gleave" { - if let Some(ref peer) = self.peer_fp { - if peer.starts_with('#') { - let name = peer[1..].to_string(); - 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 first".into(), is_system: true, is_self: false, message_id: None }); - } - } - return; - } - if text.starts_with("/gkick ") { - if let Some(ref peer) = self.peer_fp { - if peer.starts_with('#') { - let name = peer[1..].to_string(); - 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 }); - } - } - return; - } - if text == "/gmembers" { - if let Some(ref peer) = self.peer_fp { - if peer.starts_with('#') { - 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 }); - } - } - return; - } - if text.starts_with("/file ") { - let path_str = text[6..].trim(); - self.handle_file_send(path_str, identity, db, client).await; - return; - } - - // Send message (group or DM) - let peer = match &self.peer_fp { - Some(p) if p.starts_with('#') => { - // Group mode - let group_name = p[1..].to_string(); - self.group_send(&group_name, &text, identity, db, client).await; - return; - } - Some(p) => p.clone(), - None => { - self.add_message(ChatLine { - sender: "system".into(), - text: "No peer set. Use /peer ".into(), - is_system: true, - is_self: false, - message_id: None, - }); - return; - } - }; - - let peer_fp = match Fingerprint::from_hex(&peer) { - Ok(fp) => fp, - Err(_) => { - self.add_message(ChatLine { - sender: "system".into(), - text: "Invalid peer fingerprint".into(), - is_system: true, - is_self: false, - message_id: None, - }); - return; - } - }; - - let msg_id = uuid::Uuid::new_v4().to_string(); - let our_pub = identity.public_identity(); - let mut ratchet = db.load_session(&peer_fp).ok().flatten(); - - let wire_msg = if let Some(ref mut state) = ratchet { - match state.encrypt(text.as_bytes()) { - Ok(encrypted) => { - let _ = db.save_session(&peer_fp, state); - WireMessage::Message { - id: msg_id.clone(), - sender_fingerprint: our_pub.fingerprint.to_string(), - ratchet_message: encrypted, - } - } - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Encrypt failed: {}", e), - is_system: true, - is_self: false, - message_id: None, - }); - return; - } - } - } else { - // X3DH - let bundle = match client.fetch_bundle(&peer).await { - Ok(b) => b, - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Failed to fetch bundle: {}", e), - is_system: true, - is_self: false, - message_id: None, - }); - return; - } - }; - - let x3dh_result = match x3dh::initiate(identity, &bundle) { - Ok(r) => r, - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("X3DH failed: {}", e), - is_system: true, - is_self: false, - message_id: None, - }); - return; - } - }; - - let their_spk = PublicKey::from(bundle.signed_pre_key.public_key); - let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); - - match state.encrypt(text.as_bytes()) { - Ok(encrypted) => { - let _ = db.save_session(&peer_fp, &state); - WireMessage::KeyExchange { - id: msg_id.clone(), - sender_fingerprint: our_pub.fingerprint.to_string(), - sender_identity_encryption_key: *our_pub.encryption.as_bytes(), - ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), - used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id, - ratchet_message: encrypted, - } - } - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Encrypt failed: {}", e), - is_system: true, - is_self: false, - message_id: None, - }); - return; - } - } - }; - - let encoded = match bincode::serialize(&wire_msg) { - Ok(e) => e, - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Serialize failed: {}", e), - is_system: true, - is_self: false, - message_id: None, - }); - return; - } - }; - - match client.send_message(&peer, Some(&self.our_fp), &encoded).await { - Ok(_) => { - // Track receipt status - self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent); - // Store in contacts + history - let _ = db.touch_contact(&peer, None); - let _ = db.store_message(&peer, &self.our_fp, &text, true); - self.add_message(ChatLine { - sender: self.our_fp[..12].to_string(), - text: text.clone(), - is_system: false, - is_self: true, - message_id: Some(msg_id), - }); - } - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Send failed: {}", e), - is_system: true, - is_self: false, - message_id: None, - }); - } - } - } - - async fn handle_file_send( - &mut self, - path_str: &str, - identity: &IdentityKeyPair, - db: &LocalDb, - client: &ServerClient, - ) { - let path = PathBuf::from(path_str); - if !path.exists() { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("File not found: {}", path_str), - is_system: true, is_self: false, message_id: None, - }); - return; - } - - let metadata = match std::fs::metadata(&path) { - Ok(m) => m, - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Cannot read file: {}", e), - is_system: true, is_self: false, message_id: None, - }); - return; - } - }; - - let file_size = metadata.len(); - if file_size > MAX_FILE_SIZE { - 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, - }); - return; - } - - let file_data = match std::fs::read(&path) { - Ok(d) => d, - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Failed to read file: {}", e), - is_system: true, is_self: false, message_id: None, - }); - return; - } - }; - - let filename = path.file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "unnamed".to_string()); - - // SHA-256 of the complete file - let mut hasher = Sha256::new(); - hasher.update(&file_data); - let sha256 = format!("{:x}", hasher.finalize()); - - let file_id = uuid::Uuid::new_v4().to_string(); - let total_chunks = ((file_data.len() + CHUNK_SIZE - 1) / CHUNK_SIZE) as u32; - - // Resolve peer (or group members) - let peer = match &self.peer_fp { - Some(p) => p.clone(), - None => { - self.add_message(ChatLine { - sender: "system".into(), - text: "Set a peer or group first".into(), - is_system: true, is_self: false, message_id: None, - }); - return; - } - }; - - // Group file transfer: send to each member - if peer.starts_with('#') { - let group_name = &peer[1..]; - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Sending '{}' to group #{}...", filename, group_name), - is_system: true, is_self: false, message_id: None, - }); - - // Get members - let url = format!("{}/v1/groups/{}", client.base_url, group_name); - let group_data = match client.client.get(&url).send().await { - Ok(resp) => match resp.json::().await { - Ok(d) => d, - Err(_) => return, - }, - Err(_) => return, - }; - let my_fp = normfp(&self.our_fp); - let members: Vec = group_data.get("members") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) - .unwrap_or_default(); - - for member in &members { - if *member == my_fp { continue; } - // Send file header + chunks to each member via HTTP - let header = WireMessage::FileHeader { - id: file_id.clone(), - sender_fingerprint: self.our_fp.clone(), - filename: filename.clone(), - file_size, - total_chunks, - sha256: sha256.clone(), - }; - if let Ok(encoded) = bincode::serialize(&header) { - let _ = client.send_message(member, Some(&self.our_fp), &encoded).await; - } - for i in 0..total_chunks { - let start = i as usize * CHUNK_SIZE; - let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len()); - let chunk_msg = WireMessage::FileChunk { - id: file_id.clone(), - sender_fingerprint: self.our_fp.clone(), - filename: filename.clone(), - chunk_index: i, - total_chunks, - data: file_data[start..end].to_vec(), - }; - if let Ok(encoded) = bincode::serialize(&chunk_msg) { - let _ = client.send_message(member, Some(&self.our_fp), &encoded).await; - } - } - } - - 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, - }); - return; - }; - - let peer_fp = match Fingerprint::from_hex(&peer) { - Ok(fp) => fp, - Err(_) => { - self.add_message(ChatLine { - sender: "system".into(), - text: "Invalid peer fingerprint".into(), - is_system: true, is_self: false, message_id: None, - }); - return; - } - }; - - let our_pub = identity.public_identity(); - let our_fp_str = our_pub.fingerprint.to_string(); - - 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, - }); - - // Send FileHeader (unencrypted metadata — the chunks carry ratchet-encrypted data) - let header = WireMessage::FileHeader { - id: file_id.clone(), - sender_fingerprint: our_fp_str.clone(), - filename: filename.clone(), - file_size, - total_chunks, - sha256: sha256.clone(), - }; - - let encoded_header = match bincode::serialize(&header) { - Ok(e) => e, - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Serialize header failed: {}", e), - is_system: true, is_self: false, message_id: None, - }); - return; - } - }; - - if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded_header).await { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Failed to send file header: {}", e), - is_system: true, is_self: false, message_id: None, - }); - return; - } - - // Send each chunk: encrypt chunk data with ratchet, wrap in FileChunk - for i in 0..total_chunks { - let start = i as usize * CHUNK_SIZE; - let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len()); - let chunk_data = &file_data[start..end]; - - // Encrypt chunk data with ratchet - let mut ratchet = db.load_session(&peer_fp).ok().flatten(); - let encrypted_data = if let Some(ref mut state) = ratchet { - match state.encrypt(chunk_data) { - Ok(encrypted) => { - let _ = db.save_session(&peer_fp, state); - match bincode::serialize(&encrypted) { - Ok(e) => e, - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Serialize chunk failed: {}", e), - is_system: true, is_self: false, message_id: None, - }); - return; - } - } - } - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Encrypt chunk {} failed: {}", i, e), - is_system: true, is_self: false, message_id: None, - }); - return; - } - } - } else { - 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, - }); - return; - }; - - let chunk_msg = WireMessage::FileChunk { - id: file_id.clone(), - sender_fingerprint: our_fp_str.clone(), - filename: filename.clone(), - chunk_index: i, - total_chunks, - data: encrypted_data, - }; - - let encoded = match bincode::serialize(&chunk_msg) { - Ok(e) => e, - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Serialize chunk {} failed: {}", i, e), - is_system: true, is_self: false, message_id: None, - }); - return; - } - }; - - if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded).await { - 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, - }); - return; - } - - 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, - }); - } - - 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, - }); - } - - async fn group_create(&self, name: &str, client: &ServerClient) { - let url = format!("{}/v1/groups/create", client.base_url); - match client.client.post(&url) - .json(&serde_json::json!({"name": name, "creator": normfp(&self.our_fp)})) - .send().await - { - Ok(resp) => { - if let Ok(data) = resp.json::().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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false, message_id: None }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - async fn group_join(&self, name: &str, client: &ServerClient) { - let url = format!("{}/v1/groups/{}/join", client.base_url, name); - match client.client.post(&url) - .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)})) - .send().await - { - Ok(resp) => { - if let Ok(data) = resp.json::().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 }); - } 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 }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - async fn group_list(&self, client: &ServerClient) { - let url = format!("{}/v1/groups", client.base_url); - match client.client.get(&url).send().await { - Ok(resp) => { - if let Ok(data) = resp.json::().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 }); - } 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 }); - } - } - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - async fn group_leave(&self, name: &str, client: &ServerClient) { - let url = format!("{}/v1/groups/{}/leave", client.base_url, name); - match client.client.post(&url) - .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)})) - .send().await - { - Ok(resp) => { - if let Ok(data) = resp.json::().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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - async fn group_kick(&self, name: &str, target: &str, client: &ServerClient) { - let url = format!("{}/v1/groups/{}/kick", client.base_url, name); - match client.client.post(&url) - .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp), "target": target})) - .send().await - { - Ok(resp) => { - if let Ok(data) = resp.json::().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 }); - } 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 }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - async fn group_members(&self, name: &str, client: &ServerClient) { - let url = format!("{}/v1/groups/{}/members", client.base_url, name); - match client.client.get(&url).send().await { - Ok(resp) => { - if let Ok(data) = resp.json::().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 }); - 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()); - let creator = m.get("is_creator").and_then(|v| v.as_bool()).unwrap_or(false); - let label = match alias { - 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 }); - } - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - async fn group_send( - &self, - group_name: &str, - text: &str, - identity: &IdentityKeyPair, - db: &LocalDb, - client: &ServerClient, - ) { - // Get members - let url = format!("{}/v1/groups/{}", client.base_url, group_name); - let group_data = match client.client.get(&url).send().await { - Ok(resp) => match resp.json::().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 }); return; } - }, - Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }); return; } - }; - - let my_fp = normfp(&self.our_fp); - let members: Vec = group_data.get("members") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) - .unwrap_or_default(); - - let our_pub = identity.public_identity(); - let mut wire_messages: Vec = Vec::new(); - - for member in &members { - if *member == my_fp { continue; } - let member_fp = match Fingerprint::from_hex(member) { - Ok(fp) => fp, - Err(_) => continue, - }; - - let mut ratchet = db.load_session(&member_fp).ok().flatten(); - - let wire_msg = if let Some(ref mut state) = ratchet { - match state.encrypt(text.as_bytes()) { - Ok(encrypted) => { - let _ = db.save_session(&member_fp, state); - WireMessage::Message { - id: uuid::Uuid::new_v4().to_string(), - sender_fingerprint: our_pub.fingerprint.to_string(), - ratchet_message: encrypted, - } - } - Err(_) => continue, - } - } else { - // Need X3DH — fetch bundle - let bundle = match client.fetch_bundle(member).await { - Ok(b) => b, - Err(_) => continue, - }; - let x3dh_result = match x3dh::initiate(identity, &bundle) { - Ok(r) => r, - Err(_) => continue, - }; - let their_spk = PublicKey::from(bundle.signed_pre_key.public_key); - let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); - match state.encrypt(text.as_bytes()) { - Ok(encrypted) => { - let _ = db.save_session(&member_fp, &state); - WireMessage::KeyExchange { - id: uuid::Uuid::new_v4().to_string(), - sender_fingerprint: our_pub.fingerprint.to_string(), - sender_identity_encryption_key: *our_pub.encryption.as_bytes(), - ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), - used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id, - ratchet_message: encrypted, - } - } - Err(_) => continue, - } - }; - - let encoded = match bincode::serialize(&wire_msg) { - Ok(e) => e, - Err(_) => continue, - }; - - wire_messages.push(serde_json::json!({ - "to": member, - "message": encoded, - })); - } - - 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 }); - return; - } - - let send_url = format!("{}/v1/groups/{}/send", client.base_url, group_name); - match client.client.post(&send_url) - .json(&serde_json::json!({ - "from": my_fp, - "messages": wire_messages, - })) - .send().await - { - Ok(_) => { - self.add_message(ChatLine { - sender: format!("{} [#{}]", &self.our_fp[..12], group_name), - text: text.to_string(), - is_system: false, - is_self: true, - message_id: None, - }); - } - Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None }); - } - } - } - - async fn register_alias(&self, name: &str, client: &ServerClient) { - let url = format!("{}/v1/alias/register", client.base_url); - match client.client.post(&url) - .json(&serde_json::json!({"alias": name, "fingerprint": normfp(&self.our_fp)})) - .send().await - { - Ok(resp) => { - if let Ok(data) = resp.json::().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 }); - } 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 }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - async fn resolve_alias(&self, name: &str, client: &ServerClient) -> Option { - let url = format!("{}/v1/alias/resolve/{}", client.base_url, name); - match client.client.get(&url).send().await { - Ok(resp) => { - if let Ok(data) = resp.json::().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 }); - 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 }); - } - } - None - } - Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }); - None - } - } - } - - async fn list_aliases(&self, client: &ServerClient) { - let url = format!("{}/v1/alias/list", client.base_url); - match client.client.get(&url).send().await { - Ok(resp) => { - if let Ok(data) = resp.json::().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 }); - } 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 }); - } - } - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } -} - -fn normfp(fp: &str) -> String { - fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase() -} - -/// Send a delivery receipt for a message back to its sender. -fn send_receipt( - our_fp: &str, - sender_fp: &str, - message_id: &str, - receipt_type: ReceiptType, - client: &ServerClient, -) { - let receipt = WireMessage::Receipt { - sender_fingerprint: our_fp.to_string(), - message_id: message_id.to_string(), - receipt_type, - }; - let encoded = match bincode::serialize(&receipt) { - Ok(e) => e, - Err(_) => return, - }; - let client = client.clone(); - let to = sender_fp.to_string(); - let from = our_fp.to_string(); - tokio::spawn(async move { - let _ = client.send_message(&to, Some(&from), &encoded).await; - }); -} - -/// Process a single incoming raw message (shared by WS and HTTP paths). -fn process_incoming( - raw: &[u8], - identity: &IdentityKeyPair, - db: &LocalDb, - messages: &Arc>>, - receipts: &Arc>>, - pending_files: &Arc>>, - our_fp: &str, - client: &ServerClient, - last_dm_peer: &Arc>>, -) { - match bincode::deserialize::(raw) { - Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer), - Err(_) => {} - } -} - -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); -} - -fn process_wire_message( - wire: WireMessage, - identity: &IdentityKeyPair, - db: &LocalDb, - messages: &Arc>>, - receipts: &Arc>>, - pending_files: &Arc>>, - our_fp: &str, - client: &ServerClient, - last_dm_peer: &Arc>>, -) { - match wire { - WireMessage::KeyExchange { - id, - sender_fingerprint, - sender_identity_encryption_key, - ephemeral_public, - used_one_time_pre_key_id, - ratchet_message, - } => { - let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) { - Ok(fp) => fp, - Err(_) => return, - }; - let spk_secret = match db.load_signed_pre_key(1) { - Ok(Some(s)) => s, - _ => return, - }; - let otpk_secret = if let Some(otpk_id) = used_one_time_pre_key_id { - db.take_one_time_pre_key(otpk_id).ok().flatten() - } else { - None - }; - let their_id_x25519 = PublicKey::from(sender_identity_encryption_key); - let their_eph = PublicKey::from(ephemeral_public); - let shared_secret = match x3dh::respond( - identity, &spk_secret, otpk_secret.as_ref(), &their_id_x25519, &their_eph, - ) { - Ok(s) => s, - Err(_) => return, - }; - let mut state = RatchetState::init_bob(shared_secret, spk_secret); - match state.decrypt(&ratchet_message) { - Ok(plaintext) => { - let text = String::from_utf8_lossy(&plaintext).to_string(); - let _ = db.save_session(&sender_fp, &state); - *last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone()); - store_received(db, &sender_fingerprint, &text); - messages.lock().unwrap().push(ChatLine { - sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), - text, - is_system: false, - is_self: false, - message_id: None, - }); - send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client); - } - Err(_) => {} - } - } - WireMessage::Message { - id, - sender_fingerprint, - ratchet_message, - } => { - let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) { - Ok(fp) => fp, - Err(_) => return, - }; - let mut state = match db.load_session(&sender_fp) { - Ok(Some(s)) => s, - _ => return, - }; - match state.decrypt(&ratchet_message) { - Ok(plaintext) => { - let text = String::from_utf8_lossy(&plaintext).to_string(); - let _ = db.save_session(&sender_fp, &state); - *last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone()); - store_received(db, &sender_fingerprint, &text); - messages.lock().unwrap().push(ChatLine { - sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), - text, - is_system: false, - is_self: false, - message_id: None, - }); - send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client); - } - Err(_) => {} - } - } - WireMessage::Receipt { - sender_fingerprint: _, - message_id, - receipt_type, - } => { - // Update receipt status for the referenced message - let mut r = receipts.lock().unwrap(); - let current = r.get(&message_id); - let should_update = match (&receipt_type, current) { - (ReceiptType::Read, _) => true, - (ReceiptType::Delivered, Some(ReceiptStatus::Sent)) => true, - (ReceiptType::Delivered, None) => true, - _ => false, - }; - if should_update { - let new_status = match receipt_type { - ReceiptType::Delivered => ReceiptStatus::Delivered, - ReceiptType::Read => ReceiptStatus::Read, - }; - r.insert(message_id, new_status); - } - } - WireMessage::FileHeader { - id, - sender_fingerprint, - filename, - file_size, - total_chunks, - sha256, - } => { - let short_sender = &sender_fingerprint[..sender_fingerprint.len().min(12)]; - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: format!( - "Incoming file '{}' from {} ({} bytes, {} chunks)", - filename, short_sender, file_size, total_chunks - ), - is_system: true, - is_self: false, - message_id: None, - }); - - let transfer = PendingFileTransfer { - filename, - total_chunks, - received: 0, - chunks: vec![None; total_chunks as usize], - sha256, - file_size, - }; - pending_files.lock().unwrap().insert(id, transfer); - } - WireMessage::FileChunk { - id, - sender_fingerprint, - filename: _, - chunk_index, - total_chunks: _, - data, - } => { - // Decrypt the chunk data using our ratchet session with the sender - let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) { - Ok(fp) => fp, - Err(_) => return, - }; - let mut state = match db.load_session(&sender_fp) { - Ok(Some(s)) => s, - _ => return, - }; - - // The data field is a bincode-serialized RatchetMessage - let ratchet_msg = match bincode::deserialize(&data) { - Ok(m) => m, - Err(_) => return, - }; - - let plaintext = match state.decrypt(&ratchet_msg) { - Ok(pt) => { - let _ = db.save_session(&sender_fp, &state); - pt - } - Err(_) => return, - }; - - let mut pf = pending_files.lock().unwrap(); - if let Some(transfer) = pf.get_mut(&id) { - if (chunk_index as usize) < transfer.chunks.len() { - if transfer.chunks[chunk_index as usize].is_none() { - transfer.chunks[chunk_index as usize] = Some(plaintext); - transfer.received += 1; - } - - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: format!( - "Receiving {} [{}/{}]...", - transfer.filename, transfer.received, transfer.total_chunks - ), - is_system: true, - is_self: false, - message_id: None, - }); - - // Check if all chunks received - if transfer.received == transfer.total_chunks { - let mut assembled = Vec::with_capacity(transfer.file_size as usize); - for chunk in &transfer.chunks { - if let Some(data) = chunk { - assembled.extend_from_slice(data); - } - } - - // Verify SHA-256 - let mut hasher = Sha256::new(); - hasher.update(&assembled); - let computed_hash = format!("{:x}", hasher.finalize()); - - if computed_hash != transfer.sha256 { - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: format!( - "File '{}' integrity check FAILED (hash mismatch)", - transfer.filename - ), - is_system: true, - is_self: false, - message_id: None, - }); - } else { - // Save to data_dir/downloads/ - let download_dir = crate::keystore::data_dir().join("downloads"); - let _ = std::fs::create_dir_all(&download_dir); - let save_path = download_dir.join(&transfer.filename); - match std::fs::write(&save_path, &assembled) { - Ok(_) => { - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: format!( - "File saved: {}", - save_path.display() - ), - is_system: true, - is_self: false, - message_id: None, - }); - } - Err(e) => { - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: format!("Failed to save file: {}", e), - is_system: true, - is_self: false, - message_id: None, - }); - } - } - } - - // Remove completed transfer - pf.remove(&id); - } - } - } else { - // Received chunk without header — ignore - } - } - WireMessage::GroupSenderKey { - id: _, - sender_fingerprint, - group_name, - generation: _, - counter: _, - ciphertext: _, - } => { - // TODO: decrypt with stored sender key for this sender+group - messages.lock().unwrap().push(ChatLine { - sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), - text: format!("[group #{} sender-key message — key setup needed]", group_name), - is_system: false, - is_self: false, - message_id: None, - }); - } - WireMessage::SenderKeyDistribution { - sender_fingerprint, - group_name, - chain_key: _, - generation: _, - } => { - // TODO: store this sender key for future group decryption - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: format!("Received sender key from {} for #{}", &sender_fingerprint[..sender_fingerprint.len().min(12)], group_name), - is_system: true, - is_self: false, - message_id: None, - }); - } - WireMessage::CallSignal { - id: _, - sender_fingerprint, - signal_type, - payload: _, - target: _, - } => { - let type_str = format!("{:?}", signal_type); - messages.lock().unwrap().push(ChatLine { - sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), - text: format!("📞 Call signal: {}", type_str), - is_system: false, - is_self: false, - message_id: None, - }); - } - } -} - -/// Real-time message loop via WebSocket (falls back to HTTP polling). -pub async fn poll_loop( - messages: Arc>>, - receipts: Arc>>, - pending_files: Arc>>, - our_fp: String, - identity: IdentityKeyPair, - db: Arc, - client: ServerClient, - last_dm_peer: Arc>>, -) { - let fp = normfp(&our_fp); - - // Try WebSocket first - let ws_url = client.base_url - .replace("http://", "ws://") - .replace("https://", "wss://"); - let ws_url = format!("{}/v1/ws/{}", ws_url, fp); - - loop { - match tokio_tungstenite::connect_async(&ws_url).await { - Ok((ws_stream, _)) => { - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: "Real-time connection established".into(), - is_system: true, - is_self: false, - message_id: None, - }); - - use futures_util::StreamExt; - let (_, mut read) = ws_stream.split(); - - while let Some(Ok(msg)) = read.next().await { - if let tokio_tungstenite::tungstenite::Message::Binary(data) = msg { - process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &last_dm_peer); - } - } - - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: "Connection lost, reconnecting...".into(), - is_system: true, - is_self: false, - message_id: None, - }); - tokio::time::sleep(Duration::from_secs(3)).await; - } - Err(_) => { - // Fallback to HTTP polling - tokio::time::sleep(Duration::from_secs(2)).await; - let raw_msgs = match client.poll_messages(&our_fp).await { - Ok(m) => m, - Err(_) => continue, - }; - for raw in &raw_msgs { - process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &last_dm_peer); - } - } - } - } -} - -/// Run the TUI event loop. -pub async fn run_tui( - our_fp: String, - peer_fp: Option, - server_url: String, - identity: IdentityKeyPair, - poll_seed: warzone_protocol::identity::Seed, - db: LocalDb, -) -> Result<()> { - let mut terminal = ratatui::init(); - let client = ServerClient::new(&server_url); - let db = Arc::new(db); - - let mut app = App::new(our_fp.clone(), peer_fp, server_url); - - // Derive a second identity for the poll loop (can't clone IdentityKeyPair) - let poll_identity = poll_seed.derive_identity(); - let poll_messages = app.messages.clone(); - let poll_receipts = app.receipts.clone(); - let poll_pending_files = app.pending_files.clone(); - let poll_last_dm = app.last_dm_peer.clone(); - let poll_client = client.clone(); - let poll_db = db.clone(); - let poll_fp = our_fp.clone(); - - tokio::spawn(async move { - poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm).await; - }); - - loop { - terminal.draw(|frame| app.draw(frame))?; - - if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Enter => { - app.handle_send(&identity, &db, &client).await; - } - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.should_quit = true; - } - // Alt+Backspace / Ctrl+W: delete word before cursor - KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { - if app.cursor_pos > 0 { - let before = &app.input[..app.cursor_pos]; - let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0); - app.input.drain(new_pos..app.cursor_pos); - app.cursor_pos = new_pos; - } - } - // Backspace: delete char before cursor - KeyCode::Backspace => { - if app.cursor_pos > 0 { - app.input.remove(app.cursor_pos - 1); - app.cursor_pos -= 1; - } - } - // Delete: delete char at cursor - KeyCode::Delete => { - if app.cursor_pos < app.input.len() { - app.input.remove(app.cursor_pos); - } - } - // Left arrow - KeyCode::Left => { - if key.modifiers.contains(KeyModifiers::ALT) { - // Alt+Left: word left - let before = &app.input[..app.cursor_pos]; - app.cursor_pos = before.rfind(' ').map(|i| i).unwrap_or(0); - } else if app.cursor_pos > 0 { - app.cursor_pos -= 1; - } - } - // Right arrow - KeyCode::Right => { - if key.modifiers.contains(KeyModifiers::ALT) { - // Alt+Right: word right - let after = &app.input[app.cursor_pos..]; - app.cursor_pos += after.find(' ').map(|i| i + 1).unwrap_or(after.len()); - } else if app.cursor_pos < app.input.len() { - app.cursor_pos += 1; - } - } - // Home / Ctrl+A - KeyCode::Home => { app.cursor_pos = 0; } - KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.cursor_pos = 0; - } - // End / Ctrl+E - KeyCode::End => { app.cursor_pos = app.input.len(); } - KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.cursor_pos = app.input.len(); - } - // Ctrl+U: clear line - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.input.clear(); - app.cursor_pos = 0; - } - // Ctrl+K: kill to end of line - KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.input.truncate(app.cursor_pos); - } - // Ctrl+W: delete word back - KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let before = &app.input[..app.cursor_pos]; - let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0); - app.input.drain(new_pos..app.cursor_pos); - app.cursor_pos = new_pos; - } - // Regular char: insert at cursor - KeyCode::Char(c) => { - app.input.insert(app.cursor_pos, c); - app.cursor_pos += 1; - } - KeyCode::Esc => { - app.should_quit = true; - } - _ => {} - } - } - } - - if app.should_quit { - break; - } - } - - ratatui::restore(); - Ok(()) -} diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs new file mode 100644 index 0000000..4fa3651 --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -0,0 +1,1221 @@ +use warzone_protocol::identity::IdentityKeyPair; +use warzone_protocol::message::WireMessage; +use warzone_protocol::ratchet::RatchetState; +use warzone_protocol::types::Fingerprint; +use warzone_protocol::x3dh; +use x25519_dalek::PublicKey; + +use crate::net::ServerClient; +use crate::storage::LocalDb; + +use base64::Engine; +use chrono::Local; + +use super::types::{App, ChatLine, ReceiptStatus, normfp}; + +impl App { + pub async fn handle_send( + &mut self, + identity: &IdentityKeyPair, + db: &LocalDb, + client: &ServerClient, + ) { + let mut text = self.input.trim().to_string(); + self.input.clear(); + self.cursor_pos = 0; + + if text.is_empty() { + return; + } + + // Commands + if text == "/quit" || text == "/q" { + self.should_quit = true; + return; + } + 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, 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, sender_fp: None, timestamp: Local::now() }); + return; + } + if text == "/help" || text == "/?" { + let help_lines = [ + "Commands:", + " /help, /? Show this help", + " /info Show your fingerprint", + " /eth Show Ethereum address", + " /seed Show recovery mnemonic (24 words)", + " /backup Create encrypted backup now", + " /peer , /p Set DM peer by fingerprint", + " /peer @alias Set DM peer by alias", + " /reply, /r Reply to last DM sender", + " /dm Switch to DM mode (clear peer)", + " /contacts, /c List contacts with message counts", + " /history, /h [fp] Show conversation history", + " /alias Register an alias for yourself", + " /aliases List all registered aliases", + " /unalias Remove your alias", + " /friend List friends with online status", + " /friend
Add a friend", + " /unfriend
Remove a friend", + " /devices List your active device sessions", + " /kick Kick a specific device session", + " /g Switch to group (auto-join)", + " /gcreate Create a new group", + " /gjoin Join a group", + " /glist List all groups", + " /gleave Leave current group", + " /gkick Kick member from group", + " /gmembers List group members", + " /file Send a file (max 10MB)", + " /call [fp|@alias] Call current peer (or specified peer)", + " /accept Accept incoming call", + " /reject Reject incoming call", + " /hangup End current call", + " /quit, /q Exit", + "", + "Navigation:", + " PageUp/PageDown Scroll messages", + " Up/Down Scroll by 1 (when input empty)", + " Ctrl+C, Esc Quit", + ]; + for line in &help_lines { + self.add_message(ChatLine { + sender: "system".into(), + text: line.to_string(), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + } + return; + } + if text.starts_with("/alias ") { + let name = text[7..].trim(); + self.register_alias(name, client).await; + return; + } + if text == "/aliases" { + self.list_aliases(client).await; + return; + } + if text == "/unalias" { + let url = format!("{}/v1/alias/unregister", client.base_url); + match client.client.post(&url) + .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)})) + .send().await + { + Ok(resp) => if let Ok(data) = resp.json::().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, 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, 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, sender_fp: None, timestamp: Local::now() }), + } + return; + } + if text == "/contacts" || text == "/c" { + 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, 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, 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); + // Check online status via presence endpoint + let online = match client.client.get(format!("{}/v1/presence/{}", client.base_url, normfp(fp))).send().await { + Ok(r) => r.json::().await.ok() + .and_then(|d| d.get("online").and_then(|v| v.as_bool())) + .unwrap_or(false), + Err(_) => false, + }; + let status = if online { "●" } else { "○" }; + let label = match alias { + Some(a) => format!(" {} @{} ({}) — {} msgs", 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, sender_fp: None, timestamp: Local::now() }), + } + return; + } + if text.starts_with("/history") || text.starts_with("/h ") { + 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 (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, 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, 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(""); + let is_self = m.get("is_self").and_then(|v| v.as_bool()).unwrap_or(false); + self.add_message(ChatLine { + sender: sender[..sender.len().min(12)].to_string(), + text: txt.to_string(), + is_system: false, + is_self, + 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, sender_fp: None, timestamp: Local::now() }), + } + } + return; + } + if text == "/eth" { + // 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, 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, 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, sender_fp: None, timestamp: Local::now() }); + } + return; + } + if text == "/backup" { + 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, 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, sender_fp: None, timestamp: Local::now() }); + } + } + } + return; + } + if text == "/friend" || text == "/friends" { + // Fetch encrypted friend list from server, decrypt locally + let url = format!("{}/v1/friends", client.base_url); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + match data.get("data").and_then(|v| v.as_str()) { + Some(blob_b64) => { + if let Ok(seed) = crate::keystore::load_seed_raw() { + let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); + 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
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, 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)); + let online = match client.client.get(&presence_url).send().await { + Ok(r) => r.json::().await.ok() + .and_then(|d| d.get("online").and_then(|v| v.as_bool())) + .unwrap_or(false), + Err(_) => false, + }; + let status = if online { "online" } else { "offline" }; + let label = match &f.alias { + 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, 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, sender_fp: None, timestamp: Local::now() }), + } + } + } + _ => { + self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend
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, 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
".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() { + // Fetch existing list + let url = format!("{}/v1/friends", client.base_url); + let mut list = match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(blob_b64) = data.get("data").and_then(|v| v.as_str()) { + let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); + warzone_protocol::friends::FriendList::decrypt(&seed, &blob).unwrap_or_default() + } else { + warzone_protocol::friends::FriendList::new() + } + } else { + warzone_protocol::friends::FriendList::new() + } + } + Err(_) => warzone_protocol::friends::FriendList::new(), + }; + list.add(&addr, None); + 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, 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
".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() { + let url = format!("{}/v1/friends", client.base_url); + let mut list = match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(blob_b64) = data.get("data").and_then(|v| v.as_str()) { + let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); + warzone_protocol::friends::FriendList::decrypt(&seed, &blob).unwrap_or_default() + } else { + warzone_protocol::friends::FriendList::new() + } + } else { + warzone_protocol::friends::FriendList::new() + } + } + Err(_) => warzone_protocol::friends::FriendList::new(), + }; + list.remove(&addr); + 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, sender_fp: None, timestamp: Local::now() }); + } + return; + } + if text == "/devices" { + let url = format!("{}/v1/devices", client.base_url); + // Try to get bearer token from a recent auth (for now, make unauthenticated GET) + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().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, 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, 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, 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, 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, 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 ".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); + match client.client.post(&url).json(&serde_json::json!({})).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().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, 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, 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, sender_fp: None, timestamp: Local::now() }), + } + return; + } + if text == "/r" || text == "/reply" || text.starts_with("/r ") || text.starts_with("/reply ") { + 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, 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() + } else if text.starts_with("/r ") { + text[3..].trim().to_string() + } else { + String::new() + }; + if reply_msg.is_empty() { + return; // Just switch peer + } + 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, sender_fp: None, timestamp: Local::now() }); + return; + } + } + if text.starts_with("/peer ") || text.starts_with("/p ") { + let text = if text.starts_with("/p ") { format!("/peer {}", &text[3..]) } else { text.clone() }; + let raw = text[6..].trim().to_string(); + let is_eth_input = raw.starts_with("0x") || raw.starts_with("0X"); + let eth_input = if is_eth_input { Some(raw.clone()) } else { None }; + let fp = if raw.starts_with('@') { + match self.resolve_alias(&raw[1..], client).await { + Some(resolved) => resolved, + None => return, + } + } else if raw.starts_with("0x") || raw.starts_with("0X") { + // Resolve ETH address via server + match self.resolve_address(&raw, client).await { + Some(resolved) => resolved, + None => return, + } + } else { + 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, sender_fp: None, timestamp: Local::now() }); + return; + } + // Resolve peer ETH for display + if is_eth_input { + self.peer_eth = eth_input; + } else { + // Try to look up ETH for this fingerprint + let resolve_url = format!("{}/v1/resolve/{}", client.base_url, normfp(&fp)); + if let Ok(resp) = client.client.get(&resolve_url).send().await { + if let Ok(data) = resp.json::().await { + self.peer_eth = data.get("eth_address").and_then(|v| v.as_str()).map(String::from); + } + } + } + let display = self.peer_eth.as_deref().unwrap_or(&fp); + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Peer set to {}", display), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + self.peer_fp = Some(fp); + return; + } + if text.starts_with("/gcreate ") { + let name = text[9..].trim(); + self.group_create(name, client).await; + return; + } + if text.starts_with("/gjoin ") { + let name = text[7..].trim(); + self.group_join(name, client).await; + return; + } + if text.starts_with("/g ") { + let name = text[3..].trim().to_string(); + // Auto-join + self.group_join(&name, client).await; + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Switched to group #{}", name), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + self.peer_fp = Some(format!("#{}", name)); + return; + } + if text == "/dm" { + self.add_message(ChatLine { + sender: "system".into(), + text: "Switched to DM mode. Use /peer ".into(), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + self.peer_fp = None; + return; + } + if text == "/glist" { + self.group_list(client).await; + return; + } + if text == "/gleave" { + if let Some(ref peer) = self.peer_fp { + if peer.starts_with('#') { + let name = peer[1..].to_string(); + 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 first".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); + } + } + return; + } + if text.starts_with("/gkick ") { + if let Some(ref peer) = self.peer_fp { + if peer.starts_with('#') { + let name = peer[1..].to_string(); + 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, sender_fp: None, timestamp: Local::now() }); + } + } + return; + } + if text == "/gmembers" { + if let Some(ref peer) = self.peer_fp { + if peer.starts_with('#') { + 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, sender_fp: None, timestamp: Local::now() }); + } + } + return; + } + if text == "/call" || text.starts_with("/call ") { + let target = if text.starts_with("/call ") { + let arg = text[6..].trim(); + if arg.starts_with('@') { + match self.resolve_alias(&arg[1..], client).await { + Some(fp) => Some(fp), + None => return, + } + } else if arg.starts_with("0x") || arg.starts_with("0X") { + match self.resolve_address(arg, client).await { + Some(fp) => Some(fp), + None => return, + } + } else if !arg.is_empty() { + Some(arg.to_string()) + } else { + None + } + } else { + None + }; + + let peer = target.or_else(|| self.peer_fp.clone()); + 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 or set a peer first.".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); + return; + } + }; + + let peer_fp_clean = normfp(&peer); + let msg_id = uuid::Uuid::new_v4().to_string(); + let our_pub = identity.public_identity(); + + let wire = warzone_protocol::message::WireMessage::CallSignal { + id: msg_id.clone(), + sender_fingerprint: our_pub.fingerprint.to_string(), + signal_type: warzone_protocol::message::CallSignalType::Offer, + payload: String::new(), + target: peer_fp_clean.clone(), + }; + 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, sender_fp: None, timestamp: Local::now() }); + return; + } + }; + + match client.send_message(&peer_fp_clean, Some(&self.our_fp), &encoded).await { + Ok(_) => { + let display = self.peer_eth.as_deref() + .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, 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(), + state: super::types::CallPhase::Calling, + started_at: Local::now(), + }); + } + Err(e) => { + 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; + } + + if text == "/accept" { + 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, sender_fp: None, timestamp: Local::now() }); + return; + } + }; + + let msg_id = uuid::Uuid::new_v4().to_string(); + let our_pub = identity.public_identity(); + let wire = warzone_protocol::message::WireMessage::CallSignal { + id: msg_id, + sender_fingerprint: our_pub.fingerprint.to_string(), + signal_type: warzone_protocol::message::CallSignalType::Answer, + payload: String::new(), + target: normfp(&peer), + }; + 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, 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(), + state: super::types::CallPhase::Active, + started_at: Local::now(), + }); + } + return; + } + + if text == "/reject" { + 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, sender_fp: None, timestamp: Local::now() }); + return; + } + }; + + let msg_id = uuid::Uuid::new_v4().to_string(); + let our_pub = identity.public_identity(); + let wire = warzone_protocol::message::WireMessage::CallSignal { + id: msg_id, + sender_fingerprint: our_pub.fingerprint.to_string(), + signal_type: warzone_protocol::message::CallSignalType::Reject, + payload: String::new(), + target: normfp(&peer), + }; + 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, sender_fp: None, timestamp: Local::now() }); + self.call_state = None; + } + return; + } + + if text == "/hangup" { + let peer = self.peer_fp.clone().or_else(|| self.last_dm_peer.lock().unwrap().clone()); + 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, sender_fp: None, timestamp: Local::now() }); + return; + } + }; + + let msg_id = uuid::Uuid::new_v4().to_string(); + let our_pub = identity.public_identity(); + let wire = warzone_protocol::message::WireMessage::CallSignal { + id: msg_id, + sender_fingerprint: our_pub.fingerprint.to_string(), + signal_type: warzone_protocol::message::CallSignalType::Hangup, + payload: String::new(), + target: normfp(&peer), + }; + 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, sender_fp: None, timestamp: Local::now() }); + self.call_state = None; + } + return; + } + + if text.starts_with("/file ") { + let path_str = text[6..].trim(); + self.handle_file_send(path_str, identity, db, client).await; + return; + } + + // Send message (group or DM) + let peer = match &self.peer_fp { + Some(p) if p.starts_with('#') => { + // Group mode + let group_name = p[1..].to_string(); + self.group_send(&group_name, &text, identity, db, client).await; + return; + } + Some(p) => p.clone(), + None => { + self.add_message(ChatLine { + sender: "system".into(), + text: "No peer set. Use /peer ".into(), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + }; + + // Prevent self-messaging (causes ratchet corruption) + if normfp(&peer) == normfp(&self.our_fp) { + self.add_message(ChatLine { + sender: "system".into(), + text: "Cannot send messages to yourself".into(), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + + let peer_fp = match Fingerprint::from_hex(&peer) { + Ok(fp) => fp, + Err(_) => { + self.add_message(ChatLine { + sender: "system".into(), + text: "Invalid peer fingerprint".into(), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + }; + + // If peer is a bot alias, send plaintext (no E2E) + let is_bot_peer = { + let url = format!("{}/v1/alias/whois/{}", client.base_url, normfp(&peer)); + match client.client.get(&url).send().await { + Ok(resp) => resp.json::().await.ok() + .and_then(|d| d.get("alias").and_then(|a| a.as_str().map(|s| s.ends_with("bot") || s.ends_with("Bot") || s.ends_with("_bot") || s == "botfather"))) + .unwrap_or(false), + Err(_) => false, + } + }; + + if is_bot_peer { + let msg_id = uuid::Uuid::new_v4().to_string(); + let bot_msg = serde_json::json!({ + "type": "bot_message", + "id": msg_id, + "from": normfp(&self.our_fp), + "from_name": if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { self.our_eth.clone() }, + "text": text, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default(); + match client.send_message(&peer, Some(&self.our_fp), &msg_bytes).await { + Ok(_) => { + self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent); + let _ = db.touch_contact(&peer, None); + let _ = db.store_message(&peer, &self.our_fp, &text, true); + self.add_message(ChatLine { + sender: if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { format!("{}...", &self.our_eth[..self.our_eth.len().min(12)]) }, + text: text.clone(), + is_system: false, + is_self: true, + 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, sender_fp: None, timestamp: Local::now() }); + } + } + return; + } + + let msg_id = uuid::Uuid::new_v4().to_string(); + let our_pub = identity.public_identity(); + let mut ratchet = db.load_session(&peer_fp).ok().flatten(); + + let wire_msg = if let Some(ref mut state) = ratchet { + match state.encrypt(text.as_bytes()) { + Ok(encrypted) => { + let _ = db.save_session(&peer_fp, state); + WireMessage::Message { + id: msg_id.clone(), + sender_fingerprint: our_pub.fingerprint.to_string(), + ratchet_message: encrypted, + } + } + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Encrypt failed: {}", e), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + } + } else { + // X3DH + let bundle = match client.fetch_bundle(&peer).await { + Ok(b) => b, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Failed to fetch bundle: {}", e), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + }; + + let x3dh_result = match x3dh::initiate(identity, &bundle) { + Ok(r) => r, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("X3DH failed: {}", e), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + }; + + let their_spk = PublicKey::from(bundle.signed_pre_key.public_key); + let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); + + match state.encrypt(text.as_bytes()) { + Ok(encrypted) => { + let _ = db.save_session(&peer_fp, &state); + WireMessage::KeyExchange { + id: msg_id.clone(), + sender_fingerprint: our_pub.fingerprint.to_string(), + sender_identity_encryption_key: *our_pub.encryption.as_bytes(), + ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), + used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id, + ratchet_message: encrypted, + } + } + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Encrypt failed: {}", e), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + } + }; + + let encoded = match bincode::serialize(&wire_msg) { + Ok(e) => e, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Serialize failed: {}", e), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + }; + + match client.send_message(&peer, Some(&self.our_fp), &encoded).await { + Ok(_) => { + // Track receipt status + self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent); + // Store in contacts + history + let _ = db.touch_contact(&peer, None); + let _ = db.store_message(&peer, &self.our_fp, &text, true); + self.add_message(ChatLine { + sender: if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { format!("{}...", &self.our_eth[..self.our_eth.len().min(12)]) }, + text: text.clone(), + is_system: false, + is_self: true, + 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, sender_fp: None, timestamp: Local::now(), + }); + } + } + } + + pub(crate) async fn group_create(&self, name: &str, client: &ServerClient) { + let url = format!("{}/v1/groups/create", client.base_url); + match client.client.post(&url) + .json(&serde_json::json!({"name": name, "creator": normfp(&self.our_fp)})) + .send().await + { + Ok(resp) => { + if let Ok(data) = resp.json::().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, 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, 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, sender_fp: None, timestamp: Local::now() }), + } + } + + pub(crate) async fn group_join(&self, name: &str, client: &ServerClient) { + let url = format!("{}/v1/groups/{}/join", client.base_url, name); + match client.client.post(&url) + .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)})) + .send().await + { + Ok(resp) => { + if let Ok(data) = resp.json::().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, 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, 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, sender_fp: None, timestamp: Local::now() }), + } + } + + async fn group_list(&self, client: &ServerClient) { + let url = format!("{}/v1/groups", client.base_url); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().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, 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, 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, sender_fp: None, timestamp: Local::now() }), + } + } + + async fn group_leave(&self, name: &str, client: &ServerClient) { + let url = format!("{}/v1/groups/{}/leave", client.base_url, name); + match client.client.post(&url) + .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)})) + .send().await + { + Ok(resp) => { + if let Ok(data) = resp.json::().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, 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, 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, sender_fp: None, timestamp: Local::now() }), + } + } + + async fn group_kick(&self, name: &str, target: &str, client: &ServerClient) { + let url = format!("{}/v1/groups/{}/kick", client.base_url, name); + match client.client.post(&url) + .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp), "target": target})) + .send().await + { + Ok(resp) => { + if let Ok(data) = resp.json::().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, 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, 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, sender_fp: None, timestamp: Local::now() }), + } + } + + async fn group_members(&self, name: &str, client: &ServerClient) { + let url = format!("{}/v1/groups/{}/members", client.base_url, name); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().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, 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()); + let creator = m.get("is_creator").and_then(|v| v.as_bool()).unwrap_or(false); + let label = match alias { + 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, 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, sender_fp: None, timestamp: Local::now() }), + } + } + + pub(crate) async fn group_send( + &self, + group_name: &str, + text: &str, + identity: &IdentityKeyPair, + db: &LocalDb, + client: &ServerClient, + ) { + // Get members + let url = format!("{}/v1/groups/{}", client.base_url, group_name); + let group_data = match client.client.get(&url).send().await { + Ok(resp) => match resp.json::().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, 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, sender_fp: None, timestamp: Local::now() }); return; } + }; + + let my_fp = normfp(&self.our_fp); + let members: Vec = group_data.get("members") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + + let our_pub = identity.public_identity(); + let mut wire_messages: Vec = Vec::new(); + + for member in &members { + if *member == my_fp { continue; } + let member_fp = match Fingerprint::from_hex(member) { + Ok(fp) => fp, + Err(_) => continue, + }; + + let mut ratchet = db.load_session(&member_fp).ok().flatten(); + + let wire_msg = if let Some(ref mut state) = ratchet { + match state.encrypt(text.as_bytes()) { + Ok(encrypted) => { + let _ = db.save_session(&member_fp, state); + WireMessage::Message { + id: uuid::Uuid::new_v4().to_string(), + sender_fingerprint: our_pub.fingerprint.to_string(), + ratchet_message: encrypted, + } + } + Err(_) => continue, + } + } else { + // Need X3DH — fetch bundle + let bundle = match client.fetch_bundle(member).await { + Ok(b) => b, + Err(_) => continue, + }; + let x3dh_result = match x3dh::initiate(identity, &bundle) { + Ok(r) => r, + Err(_) => continue, + }; + let their_spk = PublicKey::from(bundle.signed_pre_key.public_key); + let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); + match state.encrypt(text.as_bytes()) { + Ok(encrypted) => { + let _ = db.save_session(&member_fp, &state); + WireMessage::KeyExchange { + id: uuid::Uuid::new_v4().to_string(), + sender_fingerprint: our_pub.fingerprint.to_string(), + sender_identity_encryption_key: *our_pub.encryption.as_bytes(), + ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), + used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id, + ratchet_message: encrypted, + } + } + Err(_) => continue, + } + }; + + let encoded = match bincode::serialize(&wire_msg) { + Ok(e) => e, + Err(_) => continue, + }; + + wire_messages.push(serde_json::json!({ + "to": member, + "message": encoded, + })); + } + + 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, sender_fp: None, timestamp: Local::now() }); + return; + } + + let send_url = format!("{}/v1/groups/{}/send", client.base_url, group_name); + match client.client.post(&send_url) + .json(&serde_json::json!({ + "from": my_fp, + "messages": wire_messages, + })) + .send().await + { + Ok(_) => { + self.add_message(ChatLine { + sender: format!("{} [#{}]", if self.our_eth.is_empty() { &self.our_fp[..12] } else { &self.our_eth[..self.our_eth.len().min(12)] }, group_name), + text: text.to_string(), + is_system: false, + is_self: true, + 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, sender_fp: None, timestamp: Local::now() }); + } + } + } + + async fn register_alias(&self, name: &str, client: &ServerClient) { + let url = format!("{}/v1/alias/register", client.base_url); + match client.client.post(&url) + .json(&serde_json::json!({"alias": name, "fingerprint": normfp(&self.our_fp)})) + .send().await + { + Ok(resp) => { + if let Ok(data) = resp.json::().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, 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, 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, sender_fp: None, timestamp: Local::now() }), + } + } + + pub(crate) async fn resolve_alias(&self, name: &str, client: &ServerClient) -> Option { + let url = format!("{}/v1/alias/resolve/{}", client.base_url, name); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().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, 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, 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, sender_fp: None, timestamp: Local::now() }); + None + } + } + } + + /// Resolve an ETH address (0x...) or any address format via the server. + pub(crate) async fn resolve_address(&self, addr: &str, client: &ServerClient) -> Option { + let url = format!("{}/v1/resolve/{}", client.base_url, addr); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) { + // Format fingerprint with colons: xxxx:xxxx:xxxx:... + 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, 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, 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, sender_fp: None, timestamp: Local::now() }); + None + } + } + } + + async fn list_aliases(&self, client: &ServerClient) { + let url = format!("{}/v1/alias/list", client.base_url); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().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, 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, 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, sender_fp: None, timestamp: Local::now() }), + } + } +} diff --git a/warzone/crates/warzone-client/src/tui/draw.rs b/warzone/crates/warzone-client/src/tui/draw.rs new file mode 100644 index 0000000..a22eaf8 --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/draw.rs @@ -0,0 +1,506 @@ +use std::sync::atomic::Ordering; + +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; +use ratatui::Frame; + +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> { + 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) -> &'static str { + match message_id { + Some(id) => { + let receipts = self.receipts.lock().unwrap(); + match receipts.get(id) { + Some(ReceiptStatus::Read) => " \u{2713}\u{2713}", // ✓✓ (read) + Some(ReceiptStatus::Delivered) => " \u{2713}\u{2713}", // ✓✓ (delivered) + Some(ReceiptStatus::Sent) | None => " \u{2713}", // ✓ (sent) + } + } + None => "", + } + } + + fn receipt_color(&self, message_id: &Option) -> Color { + match message_id { + Some(id) => { + let receipts = self.receipts.lock().unwrap(); + match receipts.get(id) { + Some(ReceiptStatus::Read) => Color::Blue, + Some(ReceiptStatus::Delivered) => Color::White, + Some(ReceiptStatus::Sent) | None => Color::DarkGray, + } + } + None => Color::DarkGray, + } + } + + pub fn draw(&self, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // header + Constraint::Min(5), // messages + Constraint::Length(3), // input + ]) + .split(frame.area()); + + // Header + let peer_str = match (&self.peer_eth, &self.peer_fp) { + (Some(eth), _) => format!("{}...", ð[..eth.len().min(12)]), + (None, Some(fp)) => fp.clone(), + (None, None) => "no peer".to_string(), + }; + let peer_str = peer_str.as_str(); + let is_connected = self.connected.load(Ordering::Relaxed); + let (conn_indicator, conn_color) = if is_connected { + (" \u{25CF}", Color::Green) // ● + } else { + (" \u{25CF}", Color::Red) // ● + }; + let identity_display = if self.our_eth.is_empty() { + self.our_fp.clone() + } else { + format!("{}", &self.our_eth[..self.our_eth.len().min(12)]) + }; + // Call indicator + let call_span = match &self.call_state { + Some(info) => { + let label = match info.state { + super::types::CallPhase::Calling => format!(" \u{1f4de} Calling {}...", &info.peer_display[..info.peer_display.len().min(12)]), + super::types::CallPhase::Ringing => format!(" \u{1f4de} Incoming from {}", &info.peer_display[..info.peer_display.len().min(12)]), + super::types::CallPhase::Active => { + let elapsed = Local::now().signed_duration_since(info.started_at); + let mins = elapsed.num_minutes(); + let secs = elapsed.num_seconds() % 60; + format!(" \u{1f50a} {}:{:02}", mins, secs) + } + }; + let color = match info.state { + super::types::CallPhase::Calling => Color::Yellow, + super::types::CallPhase::Ringing => Color::Magenta, + super::types::CallPhase::Active => Color::Green, + }; + Span::styled(label, Style::default().fg(color)) + } + None => Span::raw(""), + }; + let header = Paragraph::new(Line::from(vec![ + Span::styled("WZ ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled(identity_display, Style::default().fg(Color::Green)), + Span::raw(" \u{2192} "), + Span::styled(peer_str, Style::default().fg(Color::Yellow)), + Span::styled( + format!(" [{}]", self.server_url), + Style::default().fg(Color::DarkGray), + ), + Span::styled(conn_indicator, Style::default().fg(conn_color)), + call_span, + ])); + frame.render_widget(header, chunks[0]); + + // Messages — render markdown for message bodies via tui-markdown + let msgs = self.messages.lock().unwrap(); + let items: Vec = msgs + .iter() + .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) + } else { + Style::default().fg(Color::Yellow) + }; + + let timestamp = format!("[{}] ", m.timestamp.format("%H:%M")); + let prefix = if m.is_system { + "*** ".to_string() + } else { + format!("{}: ", &m.sender[..m.sender.len().min(12)]) + }; + + let receipt_str = if m.is_self && m.message_id.is_some() { + self.receipt_indicator(&m.message_id) + } else { + "" + }; + let receipt_color = self.receipt_color(&m.message_id); + + // 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(); + + // Scroll support: compute the visible window of items + let visible_height = chunks[1].height.saturating_sub(1) as usize; // minus top border + let total = items.len(); + let end = total.saturating_sub(self.scroll_offset); + let start = end.saturating_sub(visible_height); + let visible_items = if total == 0 { + vec![] + } else { + items[start..end].to_vec() + }; + + let messages_widget = List::new(visible_items) + .block(Block::default().borders(Borders::TOP)); + frame.render_widget(messages_widget, chunks[1]); + + // Input + let input_title = if self.scroll_offset > 0 { + format!(" [{} new \u{2193}] ", self.scroll_offset) + } else { + " message ".to_string() + }; + let input_widget = Paragraph::new(self.input.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(input_title), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(input_widget, chunks[2]); + + // Cursor + let x = (self.cursor_pos as u16 + 1).min(chunks[2].width - 2); + frame.set_cursor_position((chunks[2].x + x, chunks[2].y + 1)); + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use chrono::Local; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + use super::super::types::{App, ChatLine}; + + /// Helper: collect the entire terminal buffer into a single String. + fn full_buffer_text(terminal: &Terminal) -> String { + let buf = terminal.backend().buffer(); + (0..buf.area().height) + .flat_map(|y| { + (0..buf.area().width).map(move |x| { + buf.cell((x, y)) + .map(|c| c.symbol().chars().next().unwrap_or(' ')) + .unwrap_or(' ') + }) + }) + .collect() + } + + /// Helper: check whether the buffer contains `needle`. + fn buffer_contains(terminal: &Terminal, needle: &str) -> bool { + full_buffer_text(terminal).contains(needle) + } + + /// Helper: collect a single row into a String. + fn row_text(terminal: &Terminal, row: u16) -> String { + let buf = terminal.backend().buffer(); + (0..buf.area().width) + .map(|x| { + buf.cell((x, row)) + .map(|c| c.symbol().chars().next().unwrap_or(' ')) + .unwrap_or(' ') + }) + .collect() + } + + fn make_app() -> App { + App::new("aabbcc".into(), Some("ddeeff".into()), "localhost:7700".into()) + } + + fn make_terminal() -> Terminal { + let backend = TestBackend::new(80, 24); + Terminal::new(backend).expect("terminal creation should succeed") + } + + // ---------------------------------------------------------------- + // 1. draw_does_not_panic + // ---------------------------------------------------------------- + #[test] + fn draw_does_not_panic() { + let app = make_app(); + let mut terminal = make_terminal(); + terminal.draw(|f| app.draw(f)).expect("draw should not fail"); + } + + // ---------------------------------------------------------------- + // 2. header_contains_fingerprint + // ---------------------------------------------------------------- + #[test] + fn header_contains_identity() { + let app = make_app(); + let mut terminal = make_terminal(); + terminal.draw(|f| app.draw(f)).unwrap(); + + let header = row_text(&terminal, 0); + // Header shows ETH address (if seed exists) or fingerprint + assert!( + header.contains("aabbcc") || header.contains("0x"), + "header should contain fingerprint or ETH address, got: {header}" + ); + } + + // ---------------------------------------------------------------- + // 3. connection_indicator_red_when_disconnected + // ---------------------------------------------------------------- + #[test] + fn connection_indicator_red_when_disconnected() { + let app = make_app(); + // connected defaults to false + assert!(!app.connected.load(Ordering::Relaxed)); + + let mut terminal = make_terminal(); + terminal.draw(|f| app.draw(f)).unwrap(); + + let header = row_text(&terminal, 0); + assert!( + header.contains('\u{25CF}'), + "header should contain the dot character when disconnected, got: {header}" + ); + } + + // ---------------------------------------------------------------- + // 4. connection_indicator_green_when_connected + // ---------------------------------------------------------------- + #[test] + fn connection_indicator_green_when_connected() { + let app = make_app(); + app.connected.store(true, Ordering::Relaxed); + + let mut terminal = make_terminal(); + terminal.draw(|f| app.draw(f)).unwrap(); + + let header = row_text(&terminal, 0); + assert!( + header.contains('\u{25CF}'), + "header should contain the dot character when connected, got: {header}" + ); + } + + // ---------------------------------------------------------------- + // 5. timestamp_format_in_messages + // ---------------------------------------------------------------- + #[test] + fn timestamp_format_in_messages() { + let app = make_app(); + app.add_message(ChatLine { + sender: "alice".into(), + text: "hello world".into(), + is_system: false, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + + let mut terminal = make_terminal(); + terminal.draw(|f| app.draw(f)).unwrap(); + + let text = full_buffer_text(&terminal); + // Timestamps are rendered as [HH:MM] — look for the bracket pattern. + assert!( + text.contains('[') && text.contains(']'), + "buffer should contain timestamp brackets, got: {text}" + ); + } + + // ---------------------------------------------------------------- + // 6. scroll_offset_zero_shows_latest_messages + // ---------------------------------------------------------------- + #[test] + fn scroll_offset_zero_shows_latest_messages() { + let app = make_app(); + for i in 0..30 { + app.add_message(ChatLine { + sender: "bot".into(), + text: format!("msg-{i:03}"), + is_system: false, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + } + // scroll_offset defaults to 0 — pinned to bottom. + let mut terminal = make_terminal(); + terminal.draw(|f| app.draw(f)).unwrap(); + + assert!( + buffer_contains(&terminal, "msg-029"), + "the last message should be visible when scroll_offset is 0" + ); + } + + // ---------------------------------------------------------------- + // 7. scroll_offset_hides_latest_messages + // ---------------------------------------------------------------- + #[test] + fn scroll_offset_hides_latest_messages() { + let mut app = make_app(); + for i in 0..30 { + app.add_message(ChatLine { + sender: "bot".into(), + text: format!("msg-{i:03}"), + is_system: false, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + } + app.scroll_offset = 10; + + let mut terminal = make_terminal(); + terminal.draw(|f| app.draw(f)).unwrap(); + + assert!( + !buffer_contains(&terminal, "msg-029"), + "the last message should NOT be visible when scroll_offset=10" + ); + } + + // ---------------------------------------------------------------- + // 8. unread_badge_shows_when_scrolled + // ---------------------------------------------------------------- + #[test] + fn unread_badge_shows_when_scrolled() { + let mut app = make_app(); + app.scroll_offset = 5; + + let mut terminal = make_terminal(); + terminal.draw(|f| app.draw(f)).unwrap(); + + assert!( + buffer_contains(&terminal, "new"), + "buffer should contain 'new' from the unread badge when scrolled" + ); + } + + // ---------------------------------------------------------------- + // 9. no_unread_badge_at_bottom + // ---------------------------------------------------------------- + #[test] + fn no_unread_badge_at_bottom() { + let app = make_app(); + // scroll_offset is 0 by default + + let mut terminal = make_terminal(); + terminal.draw(|f| app.draw(f)).unwrap(); + + assert!( + buffer_contains(&terminal, "message"), + "buffer should contain the default title 'message' when not scrolled" + ); + assert!( + !full_buffer_text(&terminal).contains("new \u{2193}"), + "buffer should NOT contain 'new ↓' when scroll_offset is 0" + ); + } +} diff --git a/warzone/crates/warzone-client/src/tui/file_transfer.rs b/warzone/crates/warzone-client/src/tui/file_transfer.rs new file mode 100644 index 0000000..fad37b6 --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/file_transfer.rs @@ -0,0 +1,292 @@ +use std::path::PathBuf; + +use sha2::{Sha256, Digest}; +use warzone_protocol::identity::IdentityKeyPair; +use warzone_protocol::message::WireMessage; +use warzone_protocol::types::Fingerprint; + +use crate::net::ServerClient; +use crate::storage::LocalDb; + +use chrono::Local; + +use super::types::{App, ChatLine, normfp, MAX_FILE_SIZE, CHUNK_SIZE}; + +impl App { + pub async fn handle_file_send( + &mut self, + path_str: &str, + identity: &IdentityKeyPair, + db: &LocalDb, + client: &ServerClient, + ) { + let path = PathBuf::from(path_str); + if !path.exists() { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("File not found: {}", path_str), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + + let metadata = match std::fs::metadata(&path) { + Ok(m) => m, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Cannot read file: {}", e), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + }; + + let file_size = metadata.len(); + if file_size > MAX_FILE_SIZE { + 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, sender_fp: None, timestamp: Local::now(), + }); + return; + } + + let file_data = match std::fs::read(&path) { + Ok(d) => d, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Failed to read file: {}", e), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + }; + + let filename = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unnamed".to_string()); + + // SHA-256 of the complete file + let mut hasher = Sha256::new(); + hasher.update(&file_data); + let sha256 = format!("{:x}", hasher.finalize()); + + let file_id = uuid::Uuid::new_v4().to_string(); + let total_chunks = ((file_data.len() + CHUNK_SIZE - 1) / CHUNK_SIZE) as u32; + + // Resolve peer (or group members) + let peer = match &self.peer_fp { + Some(p) => p.clone(), + None => { + self.add_message(ChatLine { + sender: "system".into(), + text: "Set a peer or group first".into(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + }; + + // Group file transfer: send to each member + if peer.starts_with('#') { + let group_name = &peer[1..]; + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Sending '{}' to group #{}...", filename, group_name), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), + }); + + // Get members + let url = format!("{}/v1/groups/{}", client.base_url, group_name); + let group_data = match client.client.get(&url).send().await { + Ok(resp) => match resp.json::().await { + Ok(d) => d, + Err(_) => return, + }, + Err(_) => return, + }; + let my_fp = normfp(&self.our_fp); + let members: Vec = group_data.get("members") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + + for member in &members { + if *member == my_fp { continue; } + // Send file header + chunks to each member via HTTP + let header = WireMessage::FileHeader { + id: file_id.clone(), + sender_fingerprint: self.our_fp.clone(), + filename: filename.clone(), + file_size, + total_chunks, + sha256: sha256.clone(), + }; + if let Ok(encoded) = bincode::serialize(&header) { + let _ = client.send_message(member, Some(&self.our_fp), &encoded).await; + } + for i in 0..total_chunks { + let start = i as usize * CHUNK_SIZE; + let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len()); + let chunk_msg = WireMessage::FileChunk { + id: file_id.clone(), + sender_fingerprint: self.our_fp.clone(), + filename: filename.clone(), + chunk_index: i, + total_chunks, + data: file_data[start..end].to_vec(), + }; + if let Ok(encoded) = bincode::serialize(&chunk_msg) { + let _ = client.send_message(member, Some(&self.our_fp), &encoded).await; + } + } + } + + 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, sender_fp: None, timestamp: Local::now(), + }); + return; + }; + + let peer_fp = match Fingerprint::from_hex(&peer) { + Ok(fp) => fp, + Err(_) => { + self.add_message(ChatLine { + sender: "system".into(), + text: "Invalid peer fingerprint".into(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + }; + + let our_pub = identity.public_identity(); + let our_fp_str = our_pub.fingerprint.to_string(); + + 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, sender_fp: None, timestamp: Local::now(), + }); + + // Send FileHeader (unencrypted metadata — the chunks carry ratchet-encrypted data) + let header = WireMessage::FileHeader { + id: file_id.clone(), + sender_fingerprint: our_fp_str.clone(), + filename: filename.clone(), + file_size, + total_chunks, + sha256: sha256.clone(), + }; + + let encoded_header = match bincode::serialize(&header) { + Ok(e) => e, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Serialize header failed: {}", e), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + }; + + if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded_header).await { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Failed to send file header: {}", e), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + + // Send each chunk: encrypt chunk data with ratchet, wrap in FileChunk + for i in 0..total_chunks { + let start = i as usize * CHUNK_SIZE; + let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len()); + let chunk_data = &file_data[start..end]; + + // Encrypt chunk data with ratchet + let mut ratchet = db.load_session(&peer_fp).ok().flatten(); + let encrypted_data = if let Some(ref mut state) = ratchet { + match state.encrypt(chunk_data) { + Ok(encrypted) => { + let _ = db.save_session(&peer_fp, state); + match bincode::serialize(&encrypted) { + Ok(e) => e, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Serialize chunk failed: {}", 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!("Encrypt chunk {} failed: {}", i, e), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + } + } else { + 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, sender_fp: None, timestamp: Local::now(), + }); + return; + }; + + let chunk_msg = WireMessage::FileChunk { + id: file_id.clone(), + sender_fingerprint: our_fp_str.clone(), + filename: filename.clone(), + chunk_index: i, + total_chunks, + data: encrypted_data, + }; + + let encoded = match bincode::serialize(&chunk_msg) { + Ok(e) => e, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Serialize chunk {} failed: {}", i, e), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), + }); + return; + } + }; + + if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded).await { + 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, sender_fp: None, timestamp: Local::now(), + }); + return; + } + + 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, 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, sender_fp: None, timestamp: Local::now(), + }); + } +} diff --git a/warzone/crates/warzone-client/src/tui/input.rs b/warzone/crates/warzone-client/src/tui/input.rs new file mode 100644 index 0000000..2c3fc96 --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/input.rs @@ -0,0 +1,454 @@ +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) { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + // Alt+Backspace: delete word before cursor + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { + if self.cursor_pos > 0 { + let before = &self.input[..self.cursor_pos]; + let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0); + self.input.drain(new_pos..self.cursor_pos); + self.cursor_pos = new_pos; + } + } + // Backspace: delete char before cursor + KeyCode::Backspace => { + if self.cursor_pos > 0 { + self.input.remove(self.cursor_pos - 1); + self.cursor_pos -= 1; + } + } + // Delete: delete char at cursor + KeyCode::Delete => { + if self.cursor_pos < self.input.len() { + self.input.remove(self.cursor_pos); + } + } + // Left arrow + KeyCode::Left => { + if key.modifiers.contains(KeyModifiers::ALT) { + // Alt+Left: word left + let before = &self.input[..self.cursor_pos]; + self.cursor_pos = before.rfind(' ').unwrap_or(0); + } else if self.cursor_pos > 0 { + self.cursor_pos -= 1; + } + } + // Right arrow + KeyCode::Right => { + if key.modifiers.contains(KeyModifiers::ALT) { + // Alt+Right: word right + let after = &self.input[self.cursor_pos..]; + self.cursor_pos += after.find(' ').map(|i| i + 1).unwrap_or(after.len()); + } else if self.cursor_pos < self.input.len() { + self.cursor_pos += 1; + } + } + // Home / Ctrl+A / Cmd+A (macOS) + KeyCode::Home => { self.cursor_pos = 0; } + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => { + self.cursor_pos = 0; + } + // End: cursor to end of input when typing, snap to bottom when input is empty. + // Ctrl+End always snaps to bottom. + KeyCode::End => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + // Ctrl+End: always snap scroll to bottom + self.scroll_offset = 0; + } else if self.input.is_empty() { + // Plain End with empty input: snap scroll to bottom + self.scroll_offset = 0; + } else { + // Plain End with text: move cursor to end of input + self.cursor_pos = self.input.len(); + } + } + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => { + self.cursor_pos = self.input.len(); + } + // Ctrl+U / Cmd+U: clear line + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => { + self.input.clear(); + self.cursor_pos = 0; + } + // Ctrl+K / Cmd+K: kill to end of line + KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => { + self.input.truncate(self.cursor_pos); + } + // Ctrl+W / Cmd+W: delete word back + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => { + let before = &self.input[..self.cursor_pos]; + let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0); + self.input.drain(new_pos..self.cursor_pos); + self.cursor_pos = new_pos; + } + // PageUp: scroll up by 10 messages + KeyCode::PageUp => { + let max = self.messages.lock().unwrap().len().saturating_sub(1); + self.scroll_offset = (self.scroll_offset + 10).min(max); + } + // PageDown: scroll down by 10 messages + KeyCode::PageDown => { + self.scroll_offset = self.scroll_offset.saturating_sub(10); + } + // Up arrow: scroll up by 1 (only when input is empty) + KeyCode::Up if self.input.is_empty() => { + let max = self.messages.lock().unwrap().len().saturating_sub(1); + self.scroll_offset = (self.scroll_offset + 1).min(max); + } + // Down arrow: scroll down by 1 (only when input is empty) + 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); + self.cursor_pos += 1; + } + KeyCode::Esc => { + self.should_quit = true; + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + use crate::tui::types::App; + + /// Helper: create a key event with no modifiers. + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + /// Helper: create a key event with modifiers. + fn key_mod(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) + } + + /// Helper: create a fresh App for testing. + fn app() -> App { + App::new("aabbcc".into(), None, "http://localhost:7700".into()) + } + + /// Helper: type a string into the app one character at a time. + fn type_str(app: &mut App, s: &str) { + for c in s.chars() { + app.handle_key_event(key(KeyCode::Char(c))); + } + } + + // ── Text editing tests ────────────────────────────────────────── + + #[test] + fn char_insert() { + let mut app = app(); + type_str(&mut app, "abc"); + assert_eq!(app.input, "abc"); + assert_eq!(app.cursor_pos, 3); + } + + #[test] + fn backspace_deletes_char() { + let mut app = app(); + type_str(&mut app, "abc"); + app.handle_key_event(key(KeyCode::Backspace)); + assert_eq!(app.input, "ab"); + assert_eq!(app.cursor_pos, 2); + } + + #[test] + fn backspace_at_start_does_nothing() { + let mut app = app(); + assert!(app.input.is_empty()); + assert_eq!(app.cursor_pos, 0); + app.handle_key_event(key(KeyCode::Backspace)); + assert!(app.input.is_empty()); + assert_eq!(app.cursor_pos, 0); + } + + #[test] + fn delete_at_cursor() { + let mut app = app(); + type_str(&mut app, "abc"); + app.handle_key_event(key(KeyCode::Left)); + app.handle_key_event(key(KeyCode::Delete)); + assert_eq!(app.input, "ab"); + assert_eq!(app.cursor_pos, 2); + } + + #[test] + fn ctrl_u_clears_line() { + let mut app = app(); + type_str(&mut app, "hello"); + app.handle_key_event(key_mod(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert!(app.input.is_empty()); + assert_eq!(app.cursor_pos, 0); + } + + #[test] + fn ctrl_k_kills_to_end() { + let mut app = app(); + type_str(&mut app, "hello"); + app.handle_key_event(key(KeyCode::Home)); + app.handle_key_event(key(KeyCode::Right)); + app.handle_key_event(key(KeyCode::Right)); + app.handle_key_event(key_mod(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(app.input, "he"); + assert_eq!(app.cursor_pos, 2); + } + + #[test] + fn ctrl_w_deletes_word() { + let mut app = app(); + type_str(&mut app, "hello world"); + app.handle_key_event(key_mod(KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert_eq!(app.input, "hello "); + assert_eq!(app.cursor_pos, 6); + } + + #[test] + fn alt_backspace_deletes_word() { + let mut app = app(); + type_str(&mut app, "hello world"); + app.handle_key_event(key_mod(KeyCode::Backspace, KeyModifiers::ALT)); + assert_eq!(app.input, "hello "); + assert_eq!(app.cursor_pos, 6); + } + + // ── Cursor movement tests ─────────────────────────────────────── + + #[test] + fn left_arrow_moves_cursor() { + let mut app = app(); + type_str(&mut app, "abc"); + app.handle_key_event(key(KeyCode::Left)); + assert_eq!(app.cursor_pos, 2); + } + + #[test] + fn right_arrow_moves_cursor() { + let mut app = app(); + type_str(&mut app, "abc"); + app.handle_key_event(key(KeyCode::Home)); + app.handle_key_event(key(KeyCode::Right)); + assert_eq!(app.cursor_pos, 1); + } + + #[test] + fn home_moves_to_start() { + let mut app = app(); + type_str(&mut app, "abc"); + app.handle_key_event(key(KeyCode::Home)); + assert_eq!(app.cursor_pos, 0); + } + + #[test] + fn end_moves_to_end() { + let mut app = app(); + type_str(&mut app, "abc"); + app.handle_key_event(key(KeyCode::Home)); + app.handle_key_event(key(KeyCode::End)); + assert_eq!(app.cursor_pos, 3); + } + + #[test] + fn ctrl_a_moves_to_start() { + let mut app = app(); + type_str(&mut app, "abc"); + app.handle_key_event(key_mod(KeyCode::Char('a'), KeyModifiers::CONTROL)); + assert_eq!(app.cursor_pos, 0); + } + + #[test] + fn ctrl_e_moves_to_end() { + let mut app = app(); + type_str(&mut app, "abc"); + app.handle_key_event(key(KeyCode::Home)); + app.handle_key_event(key_mod(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!(app.cursor_pos, 3); + } + + #[test] + fn left_at_start_does_nothing() { + let mut app = app(); + assert_eq!(app.cursor_pos, 0); + app.handle_key_event(key(KeyCode::Left)); + assert_eq!(app.cursor_pos, 0); + } + + // ── Quit tests ────────────────────────────────────────────────── + + #[test] + fn ctrl_c_quits() { + let mut app = app(); + app.handle_key_event(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(app.should_quit); + } + + #[test] + fn esc_quits() { + let mut app = app(); + app.handle_key_event(key(KeyCode::Esc)); + assert!(app.should_quit); + } + + // ── Scroll tests ──────────────────────────────────────────────── + + #[test] + fn page_up_increases_scroll_offset() { + let mut app = app(); + // App::new creates 3 system messages, so max = 3 - 1 = 2 + let msg_count = app.messages.lock().unwrap().len(); + app.handle_key_event(key(KeyCode::PageUp)); + // scroll_offset = min(10, msg_count - 1) + let expected = 10usize.min(msg_count.saturating_sub(1)); + assert_eq!(app.scroll_offset, expected); + } + + #[test] + fn page_down_decreases_scroll_offset() { + let mut app = app(); + app.scroll_offset = 15; + app.handle_key_event(key(KeyCode::PageDown)); + assert_eq!(app.scroll_offset, 5); + } + + #[test] + fn page_down_clamps_to_zero() { + let mut app = app(); + app.scroll_offset = 3; + app.handle_key_event(key(KeyCode::PageDown)); + assert_eq!(app.scroll_offset, 0); + } + + #[test] + fn up_arrow_scrolls_when_input_empty() { + let mut app = app(); + assert!(app.input.is_empty()); + app.handle_key_event(key(KeyCode::Up)); + assert_eq!(app.scroll_offset, 1); + } + + #[test] + fn up_arrow_ignored_when_input_not_empty() { + let mut app = app(); + type_str(&mut app, "hi"); + app.handle_key_event(key(KeyCode::Up)); + assert_eq!(app.scroll_offset, 0); + } + + #[test] + fn down_arrow_scrolls_when_input_empty() { + let mut app = app(); + app.scroll_offset = 5; + assert!(app.input.is_empty()); + app.handle_key_event(key(KeyCode::Down)); + assert_eq!(app.scroll_offset, 4); + } + + #[test] + fn down_arrow_at_zero_stays_zero() { + let mut app = app(); + assert!(app.input.is_empty()); + assert_eq!(app.scroll_offset, 0); + app.handle_key_event(key(KeyCode::Down)); + assert_eq!(app.scroll_offset, 0); + } + + #[test] + fn end_snaps_to_bottom_when_input_empty() { + let mut app = app(); + app.scroll_offset = 10; + assert!(app.input.is_empty()); + app.handle_key_event(key(KeyCode::End)); + assert_eq!(app.scroll_offset, 0); + } + + // ── Tab completion tests ──────────────────────────────────────── + + #[test] + fn tab_completes_unique_command() { + let mut app = app(); + type_str(&mut app, "/he"); + app.handle_key_event(key(KeyCode::Tab)); + assert_eq!(app.input, "/help "); + assert_eq!(app.cursor_pos, 6); + } + + #[test] + fn tab_completes_common_prefix_on_ambiguous() { + let mut app = app(); + // "/g" matches /g, /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers + // but /g is an exact-length match that is filtered out since it equals input + // Actually /g exactly matches "/g" so it's excluded. Remaining: /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers + // Common prefix is "/g" which is same length as input, so no change + type_str(&mut app, "/gc"); + app.handle_key_event(key(KeyCode::Tab)); + // /gcreate is the only match starting with /gc + assert_eq!(app.input, "/gcreate "); + } + + #[test] + fn tab_does_nothing_without_slash() { + let mut app = app(); + type_str(&mut app, "hello"); + app.handle_key_event(key(KeyCode::Tab)); + assert_eq!(app.input, "hello"); + } + + #[test] + fn tab_does_nothing_when_no_match() { + let mut app = app(); + type_str(&mut app, "/zzz"); + app.handle_key_event(key(KeyCode::Tab)); + assert_eq!(app.input, "/zzz"); + } +} diff --git a/warzone/crates/warzone-client/src/tui/mod.rs b/warzone/crates/warzone-client/src/tui/mod.rs index de3e58d..2f6ef75 100644 --- a/warzone/crates/warzone-client/src/tui/mod.rs +++ b/warzone/crates/warzone-client/src/tui/mod.rs @@ -1,3 +1,195 @@ -pub mod app; +mod types; +mod draw; +mod commands; +mod file_transfer; +mod input; +mod network; -pub use app::run_tui; +pub use types::App; + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode}; + +use warzone_protocol::identity::{IdentityKeyPair, Seed}; + +use crate::net::ServerClient; +use crate::storage::LocalDb; + +/// Run the TUI event loop. +pub async fn run_tui( + our_fp: String, + peer_fp: Option, + server_url: String, + identity: IdentityKeyPair, + poll_seed: Seed, + db: LocalDb, +) -> Result<()> { + let mut terminal = ratatui::init(); + let client = ServerClient::new(&server_url); + let db = Arc::new(db); + + let mut app = App::new(our_fp.clone(), peer_fp, server_url); + + // Derive a second identity for the poll loop (can't clone IdentityKeyPair) + let poll_identity = poll_seed.derive_identity(); + let poll_messages = app.messages.clone(); + let poll_receipts = app.receipts.clone(); + let poll_pending_files = app.pending_files.clone(); + let poll_last_dm = app.last_dm_peer.clone(); + let poll_connected = app.connected.clone(); + let poll_client = client.clone(); + let poll_db = db.clone(); + let poll_fp = our_fp.clone(); + + tokio::spawn(async move { + network::poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm, poll_connected).await; + }); + + // Spawn periodic backup task (every 5 minutes) + { + let backup_db = db.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(300)); + loop { + interval.tick().await; + if let Ok(seed) = crate::keystore::load_seed_raw() { + match backup_db.create_backup(&seed) { + Ok(path) => tracing::debug!("Auto-backup created: {}", path.display()), + Err(e) => tracing::warn!("Auto-backup failed: {}", e), + } + } + } + }); + } + + // Auto-join #ops if no peer set (create if needed) + if app.peer_fp.is_none() { + let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase(); + // Create #ops if it doesn't exist + let _ = client.client.post(format!("{}/v1/groups/create", client.base_url)) + .json(&serde_json::json!({"name": "ops", "creator": fp_clean})) + .send().await; + // Join + let _ = client.client.post(format!("{}/v1/groups/ops/join", client.base_url)) + .json(&serde_json::json!({"fingerprint": fp_clean})) + .send().await; + app.peer_fp = Some("#ops".to_string()); + app.add_message(types::ChatLine { + sender: "system".into(), + text: "Welcome! You have been added to #ops".into(), + is_system: true, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: chrono::Local::now(), + }); + + // Show system bots + if let Ok(resp) = client.client.get(format!("{}/v1/bot/list", client.base_url)).send().await { + if let Ok(data) = resp.json::().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, 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, 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, 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::().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 { + app.handle_send(&identity, &db, &client).await; + app.scroll_offset = 0; + } else { + app.handle_key_event(key); + } + } + } + + if app.should_quit { + break; + } + } + + ratatui::restore(); + Ok(()) +} diff --git a/warzone/crates/warzone-client/src/tui/network.rs b/warzone/crates/warzone-client/src/tui/network.rs new file mode 100644 index 0000000..570c665 --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/network.rs @@ -0,0 +1,707 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use sha2::{Sha256, Digest}; +use warzone_protocol::identity::IdentityKeyPair; +use warzone_protocol::message::{ReceiptType, WireMessage}; +use warzone_protocol::ratchet::RatchetState; +use warzone_protocol::types::Fingerprint; +use warzone_protocol::x3dh; +use x25519_dalek::PublicKey; + +use crate::net::ServerClient; +use crate::storage::LocalDb; + +use chrono::Local; + +use super::types::{ChatLine, PendingFileTransfer, ReceiptStatus, normfp}; + +/// Send a delivery receipt for a message back to its sender. +fn send_receipt( + our_fp: &str, + sender_fp: &str, + message_id: &str, + receipt_type: ReceiptType, + client: &ServerClient, +) { + let receipt = WireMessage::Receipt { + sender_fingerprint: our_fp.to_string(), + message_id: message_id.to_string(), + receipt_type, + }; + let encoded = match bincode::serialize(&receipt) { + Ok(e) => e, + Err(_) => return, + }; + let client = client.clone(); + let to = sender_fp.to_string(); + let from = our_fp.to_string(); + tokio::spawn(async move { + let _ = client.send_message(&to, Some(&from), &encoded).await; + }); +} + +/// ETH address cache: fingerprint → ETH address (populated async, read sync). +pub type EthCache = Arc>>; + +/// Display a fingerprint as short ETH address if cached, otherwise truncated fingerprint. +fn display_sender(fp: &str, eth_cache: &EthCache) -> String { + let cache = eth_cache.lock().unwrap(); + if let Some(eth) = cache.get(fp) { + format!("{}...", ð[..eth.len().min(12)]) + } else { + fp[..fp.len().min(12)].to_string() + } +} + +/// Async: look up ETH address for a fingerprint and cache it. +fn cache_eth_lookup(fp: &str, client: &ServerClient, eth_cache: &EthCache) { + let fp = fp.to_string(); + let client = client.clone(); + let cache = eth_cache.clone(); + // Check if already cached + if cache.lock().unwrap().contains_key(&fp) { return; } + tokio::spawn(async move { + 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::().await { + if let Some(eth) = data.get("eth_address").and_then(|v| v.as_str()) { + cache.lock().unwrap().insert(fp, eth.to_string()); + } + } + } + }); +} + +/// 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::().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); +} + +/// Process a single incoming raw message (shared by WS and HTTP paths). +pub fn process_incoming( + raw: &[u8], + identity: &IdentityKeyPair, + db: &LocalDb, + messages: &Arc>>, + receipts: &Arc>>, + pending_files: &Arc>>, + our_fp: &str, + client: &ServerClient, + eth_cache: &EthCache, + last_dm_peer: &Arc>>, +) { + match warzone_protocol::message::deserialize_envelope(raw) { + Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer, eth_cache), + Err(_) => {} + } +} + +fn process_wire_message( + wire: WireMessage, + identity: &IdentityKeyPair, + db: &LocalDb, + messages: &Arc>>, + receipts: &Arc>>, + pending_files: &Arc>>, + our_fp: &str, + client: &ServerClient, + last_dm_peer: &Arc>>, + eth_cache: &EthCache, +) { + match wire { + WireMessage::KeyExchange { + id, + sender_fingerprint, + sender_identity_encryption_key, + ephemeral_public, + used_one_time_pre_key_id, + ratchet_message, + } => { + let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) { + Ok(fp) => fp, + Err(_) => return, + }; + let spk_secret = match db.load_signed_pre_key(1) { + Ok(Some(s)) => s, + _ => return, + }; + let otpk_secret = if let Some(otpk_id) = used_one_time_pre_key_id { + db.take_one_time_pre_key(otpk_id).ok().flatten() + } else { + None + }; + let their_id_x25519 = PublicKey::from(sender_identity_encryption_key); + let their_eph = PublicKey::from(ephemeral_public); + let shared_secret = match x3dh::respond( + identity, &spk_secret, otpk_secret.as_ref(), &their_id_x25519, &their_eph, + ) { + Ok(s) => s, + Err(_) => return, + }; + let mut state = RatchetState::init_bob(shared_secret, spk_secret); + match state.decrypt(&ratchet_message) { + Ok(plaintext) => { + let text = String::from_utf8_lossy(&plaintext).to_string(); + let _ = db.save_session(&sender_fp, &state); + if normfp(&sender_fingerprint) != normfp(our_fp) { + *last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone()); + } + store_received(db, &sender_fingerprint, &text); + messages.lock().unwrap().push(ChatLine { + sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) }, + text, + is_system: false, + is_self: false, + 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 + print!("\x07"); + } + Err(e) => { + // Session auto-recovery: delete corrupted session, show warning + let _ = db.delete_session(&sender_fp); + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "[session reset] Decryption failed for {}. Session cleared — next message will re-establish.", + &sender_fingerprint[..sender_fingerprint.len().min(12)] + ), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e); + } + } + } + WireMessage::Message { + id, + sender_fingerprint, + ratchet_message, + } => { + let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) { + Ok(fp) => fp, + Err(_) => return, + }; + let mut state = match db.load_session(&sender_fp) { + Ok(Some(s)) => s, + _ => return, + }; + match state.decrypt(&ratchet_message) { + Ok(plaintext) => { + let text = String::from_utf8_lossy(&plaintext).to_string(); + let _ = db.save_session(&sender_fp, &state); + if normfp(&sender_fingerprint) != normfp(our_fp) { + *last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone()); + } + store_received(db, &sender_fingerprint, &text); + messages.lock().unwrap().push(ChatLine { + sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) }, + text, + is_system: false, + is_self: false, + 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 + print!("\x07"); + } + Err(e) => { + // Session auto-recovery: delete corrupted session, show warning + let _ = db.delete_session(&sender_fp); + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "[session reset] Decryption failed for {}. Session cleared — next message will re-establish.", + &sender_fingerprint[..sender_fingerprint.len().min(12)] + ), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e); + } + } + } + WireMessage::Receipt { + sender_fingerprint: _, + message_id, + receipt_type, + } => { + // Update receipt status for the referenced message + let mut r = receipts.lock().unwrap(); + let current = r.get(&message_id); + let should_update = match (&receipt_type, current) { + (ReceiptType::Read, _) => true, + (ReceiptType::Delivered, Some(ReceiptStatus::Sent)) => true, + (ReceiptType::Delivered, None) => true, + _ => false, + }; + if should_update { + let new_status = match receipt_type { + ReceiptType::Delivered => ReceiptStatus::Delivered, + ReceiptType::Read => ReceiptStatus::Read, + }; + r.insert(message_id, new_status); + } + } + WireMessage::FileHeader { + id, + sender_fingerprint, + filename, + file_size, + total_chunks, + sha256, + } => { + let short_sender = &sender_fingerprint[..sender_fingerprint.len().min(12)]; + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "Incoming file '{}' from {} ({} bytes, {} chunks)", + filename, short_sender, file_size, total_chunks + ), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + + let transfer = PendingFileTransfer { + filename, + total_chunks, + received: 0, + chunks: vec![None; total_chunks as usize], + sha256, + file_size, + }; + pending_files.lock().unwrap().insert(id, transfer); + } + WireMessage::FileChunk { + id, + sender_fingerprint, + filename: _, + chunk_index, + total_chunks: _, + data, + } => { + // Decrypt the chunk data using our ratchet session with the sender + let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) { + Ok(fp) => fp, + Err(_) => return, + }; + let mut state = match db.load_session(&sender_fp) { + Ok(Some(s)) => s, + _ => return, + }; + + // The data field is a bincode-serialized RatchetMessage + let ratchet_msg = match bincode::deserialize(&data) { + Ok(m) => m, + Err(_) => return, + }; + + let plaintext = match state.decrypt(&ratchet_msg) { + Ok(pt) => { + let _ = db.save_session(&sender_fp, &state); + pt + } + Err(_) => return, + }; + + let mut pf = pending_files.lock().unwrap(); + if let Some(transfer) = pf.get_mut(&id) { + if (chunk_index as usize) < transfer.chunks.len() { + if transfer.chunks[chunk_index as usize].is_none() { + transfer.chunks[chunk_index as usize] = Some(plaintext); + transfer.received += 1; + } + + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "Receiving {} [{}/{}]...", + transfer.filename, transfer.received, transfer.total_chunks + ), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + + // Check if all chunks received + if transfer.received == transfer.total_chunks { + let mut assembled = Vec::with_capacity(transfer.file_size as usize); + for chunk in &transfer.chunks { + if let Some(data) = chunk { + assembled.extend_from_slice(data); + } + } + + // Verify SHA-256 + let mut hasher = Sha256::new(); + hasher.update(&assembled); + let computed_hash = format!("{:x}", hasher.finalize()); + + if computed_hash != transfer.sha256 { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "File '{}' integrity check FAILED (hash mismatch)", + transfer.filename + ), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + } else { + // Save to data_dir/downloads/ + let download_dir = crate::keystore::data_dir().join("downloads"); + let _ = std::fs::create_dir_all(&download_dir); + let save_path = download_dir.join(&transfer.filename); + match std::fs::write(&save_path, &assembled) { + Ok(_) => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "File saved: {}", + save_path.display() + ), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + } + Err(e) => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("Failed to save file: {}", e), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + } + } + } + + // Remove completed transfer + pf.remove(&id); + } + } + } else { + // Received chunk without header — ignore + } + } + WireMessage::GroupSenderKey { + id: _, + sender_fingerprint, + group_name, + generation, + counter, + ciphertext, + } => { + match db.load_sender_key(&sender_fingerprint, &group_name) { + Ok(Some(mut sender_key)) => { + let msg = warzone_protocol::sender_keys::SenderKeyMessage { + sender_fingerprint: sender_fingerprint.clone(), + group_name: group_name.clone(), + generation, + counter, + ciphertext, + }; + match sender_key.decrypt(&msg) { + Ok(plaintext) => { + let text = String::from_utf8_lossy(&plaintext).to_string(); + // Save updated sender key (counter advanced) + let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key); + store_received(db, &sender_fingerprint, &text); + messages.lock().unwrap().push(ChatLine { + sender: format!( + "{} [#{}]", + &sender_fingerprint[..sender_fingerprint.len().min(12)], + group_name + ), + text, + is_system: false, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + } + Err(e) => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "[group #{}] decrypt failed from {}: {}", + group_name, + &sender_fingerprint[..sender_fingerprint.len().min(12)], + e + ), + is_system: true, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + } + } + } + _ => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "[group #{}] no sender key for {} — key distribution needed", + group_name, + &sender_fingerprint[..sender_fingerprint.len().min(12)] + ), + is_system: true, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + } + } + } + WireMessage::SenderKeyDistribution { + sender_fingerprint, + group_name, + chain_key, + generation, + } => { + let dist = warzone_protocol::sender_keys::SenderKeyDistribution { + sender_fingerprint: sender_fingerprint.clone(), + group_name: group_name.clone(), + chain_key, + generation, + }; + let sender_key = dist.into_sender_key(); + let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key); + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "Received sender key from {} for #{}", + &sender_fingerprint[..sender_fingerprint.len().min(12)], + group_name + ), + is_system: true, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + } + WireMessage::CallSignal { + id: _, + sender_fingerprint, + signal_type, + payload: _, + target: _, + } => { + use warzone_protocol::message::CallSignalType; + let sender_short = { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) }; + match signal_type { + CallSignalType::Offer => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("\u{1f4de} Incoming call from {} \u{2014} /accept or /reject", sender_short), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + // Terminal bell for incoming call + print!("\x07"); + } + CallSignalType::Answer => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("\u{2713} {} accepted the call", sender_short), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + } + CallSignalType::Hangup => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: "Call ended".into(), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + } + CallSignalType::Reject => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("{} rejected the call", sender_short), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + } + CallSignalType::Ringing => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: "Ringing...".into(), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + } + CallSignalType::Busy => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("{} is busy", sender_short), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + } + _ => { + messages.lock().unwrap().push(ChatLine { + sender: sender_short, + text: format!("\u{1f4de} Call signal: {:?}", signal_type), + is_system: false, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + } + } + } + } +} + +/// Real-time message loop via WebSocket (falls back to HTTP polling). +pub async fn poll_loop( + messages: Arc>>, + receipts: Arc>>, + pending_files: Arc>>, + our_fp: String, + identity: IdentityKeyPair, + db: Arc, + client: ServerClient, + last_dm_peer: Arc>>, + connected: Arc, +) { + 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://") + .replace("https://", "wss://"); + let ws_url = format!("{}/v1/ws/{}", ws_url, fp); + + loop { + match tokio_tungstenite::connect_async(&ws_url).await { + Ok((ws_stream, _)) => { + connected.store(true, Ordering::Relaxed); + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: "Real-time connection established".into(), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + + use futures_util::StreamExt; + let (_, mut read) = ws_stream.split(); + + while let Some(Ok(msg)) = read.next().await { + match msg { + tokio_tungstenite::tungstenite::Message::Binary(data) => { + process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, ð_cache, &last_dm_peer); + } + tokio_tungstenite::tungstenite::Message::Text(text) => { + if let Ok(json) = serde_json::from_str::(&text) { + if json.get("type").and_then(|v| v.as_str()) == Some("missed_call") { + let data = json.get("data").cloned().unwrap_or_default(); + let caller = data.get("caller_fp").and_then(|v| v.as_str()).unwrap_or("unknown"); + let ts = data.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0); + let when = chrono::DateTime::from_timestamp(ts, 0) + .map(|dt| dt.with_timezone(&Local).format("%H:%M").to_string()) + .unwrap_or_else(|| "?".to_string()); + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("\u{1f4de} Missed call from {} at {}", &caller[..caller.len().min(12)], when), + is_system: true, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + print!("\x07"); + } else if json.get("type").and_then(|v| v.as_str()) == Some("bot_message") { + let from = json.get("from_name").or(json.get("from")).and_then(|v| v.as_str()).unwrap_or("bot"); + let text_content = json.get("text").and_then(|v| v.as_str()).unwrap_or(""); + messages.lock().unwrap().push(ChatLine { + sender: format!("@{}", from), + text: text_content.to_string(), + is_system: false, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + print!("\x07"); + } + } + } + _ => {} + } + } + + connected.store(false, Ordering::Relaxed); + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: "Connection lost, reconnecting...".into(), + is_system: true, + is_self: false, + message_id: None, sender_fp: None, timestamp: Local::now(), + }); + tokio::time::sleep(Duration::from_secs(3)).await; + } + Err(_) => { + connected.store(false, Ordering::Relaxed); + // Fallback to HTTP polling + tokio::time::sleep(Duration::from_secs(2)).await; + let raw_msgs = match client.poll_messages(&our_fp).await { + Ok(m) => m, + Err(_) => continue, + }; + for raw in &raw_msgs { + process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, ð_cache, &last_dm_peer); + } + } + } + } +} diff --git a/warzone/crates/warzone-client/src/tui/types.rs b/warzone/crates/warzone-client/src/tui/types.rs new file mode 100644 index 0000000..a253eb8 --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/types.rs @@ -0,0 +1,267 @@ +use std::collections::HashMap; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; + +use chrono::{DateTime, Local}; + +/// Maximum file size: 10 MB. +pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; +/// Chunk size: 64 KB. +pub const CHUNK_SIZE: usize = 64 * 1024; + +/// State for tracking an incoming chunked file transfer. +#[derive(Clone)] +pub struct PendingFileTransfer { + pub filename: String, + pub total_chunks: u32, + pub received: u32, + pub chunks: Vec>>, + pub sha256: String, + pub file_size: u64, +} + +/// Receipt status for a sent message. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReceiptStatus { + Sent, + Delivered, + Read, +} + +/// Active call information. +#[derive(Clone)] +pub struct CallInfo { + pub peer_fp: String, + pub peer_display: String, + pub state: CallPhase, + pub started_at: DateTime, +} + +#[derive(Clone, PartialEq)] +pub enum CallPhase { + Calling, // we initiated, waiting for answer + Ringing, // incoming call, waiting for user to accept/reject + Active, // call connected +} + +pub struct App { + pub input: String, + pub messages: Arc>>, + pub our_fp: String, + pub peer_fp: Option, + pub server_url: String, + pub should_quit: bool, + pub cursor_pos: usize, + pub last_dm_peer: Arc>>, + /// Track receipt status for messages we sent, keyed by message ID. + pub receipts: Arc>>, + /// Pending incoming file transfers, keyed by file ID. + pub pending_files: Arc>>, + /// Our ETH address (derived from seed). + pub our_eth: String, + /// Current peer's ETH address (resolved on /peer set). + pub peer_eth: Option, + /// Scroll offset from bottom (0 = pinned to newest). + pub scroll_offset: usize, + /// Whether the WebSocket connection is active. + pub connected: Arc, + /// Current call state: None=idle, Some(state)=active + pub call_state: Option, + /// Message IDs for which we've already sent a Read receipt (avoid duplicates). + pub read_receipts_sent: Arc>>, +} + +#[derive(Clone)] +pub struct ChatLine { + pub sender: String, + pub text: String, + pub is_system: bool, + pub is_self: bool, + /// Message ID (for sent messages, used to track receipts). + pub message_id: Option, + /// Sender's full fingerprint (for sending read receipts back). + pub sender_fp: Option, + /// When this message was created/received. + pub timestamp: DateTime, +} + +impl App { + pub fn new(our_fp: String, peer_fp: Option, server_url: String) -> Self { + // Derive ETH address from seed first (used in welcome messages) + let our_eth = crate::keystore::load_seed_raw() + .map(|seed| { + let eth = warzone_protocol::ethereum::derive_eth_identity(&seed); + eth.address.to_checksum() + }) + .unwrap_or_default(); + + let identity_display = if our_eth.is_empty() { our_fp.clone() } else { our_eth.clone() }; + + let messages = Arc::new(Mutex::new(vec![ChatLine { + sender: "system".into(), + text: format!("You are {}", identity_display), + is_system: true, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }])); + + if let Some(ref peer) = peer_fp { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("Chatting with {}", peer), + is_system: true, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + } else { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: "No peer set. Use /peer , /peer @alias, or /g ".into(), + is_system: true, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + } + + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: "/alias /peer /g /gleave /gkick /gmembers /file /info /quit".into(), + is_system: true, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + + App { + input: String::new(), + messages, + our_fp, + peer_fp, + server_url, + should_quit: false, + last_dm_peer: Arc::new(Mutex::new(None)), + cursor_pos: 0, + receipts: Arc::new(Mutex::new(HashMap::new())), + pending_files: Arc::new(Mutex::new(HashMap::new())), + our_eth, + peer_eth: None, + scroll_offset: 0, + connected: Arc::new(AtomicBool::new(false)), + call_state: None, + read_receipts_sent: Arc::new(Mutex::new(std::collections::HashSet::new())), + } + } + + pub fn add_message(&self, line: ChatLine) { + self.messages.lock().unwrap().push(line); + } +} + +pub fn normfp(fp: &str) -> String { + fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::Ordering; + + #[test] + fn app_new_initializes_scroll_offset_to_zero() { + let app = App::new("aabbcc".into(), None, "http://localhost:7700".into()); + assert_eq!(app.scroll_offset, 0); + } + + #[test] + fn app_new_initializes_connected_to_false() { + let app = App::new("aabbcc".into(), None, "http://localhost:7700".into()); + assert!(!app.connected.load(Ordering::Relaxed)); + } + + #[test] + fn app_new_creates_system_messages() { + let app = App::new("aabbcc".into(), None, "http://localhost:7700".into()); + let msgs = app.messages.lock().unwrap(); + assert!(msgs.len() >= 2); + assert!(msgs[0].is_system); + // First message shows ETH address (if seed exists) or fingerprint + assert!(msgs[0].text.contains("You are")); + } + + #[test] + fn app_new_with_peer_shows_chatting_message() { + let app = App::new("aabbcc".into(), Some("ddeeff".into()), "http://localhost:7700".into()); + let msgs = app.messages.lock().unwrap(); + let has_chatting = msgs.iter().any(|m| m.text.contains("Chatting with") && m.text.contains("ddeeff")); + assert!(has_chatting); + } + + #[test] + fn app_new_without_peer_shows_no_peer_message() { + let app = App::new("aabbcc".into(), None, "http://localhost:7700".into()); + let msgs = app.messages.lock().unwrap(); + let has_no_peer = msgs.iter().any(|m| m.text.contains("No peer set")); + assert!(has_no_peer); + } + + #[test] + fn chatline_has_timestamp() { + let line = ChatLine { + sender: "test".into(), + text: "hello".into(), + is_system: false, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }; + // Timestamp should be within the last second + let elapsed = Local::now().signed_duration_since(line.timestamp); + assert!(elapsed.num_seconds() < 2); + } + + #[test] + fn add_message_appends_to_list() { + let app = App::new("aabbcc".into(), None, "http://localhost:7700".into()); + let initial_count = app.messages.lock().unwrap().len(); + app.add_message(ChatLine { + sender: "test".into(), + text: "new message".into(), + is_system: false, + is_self: false, + message_id: None, + sender_fp: None, + timestamp: Local::now(), + }); + let new_count = app.messages.lock().unwrap().len(); + assert_eq!(new_count, initial_count + 1); + } + + #[test] + fn normfp_strips_non_hex_and_lowercases() { + assert_eq!(normfp("AA-BB-CC"), "aabbcc"); + assert_eq!(normfp("0x1234ABCD"), "01234abcd"); + assert_eq!(normfp("hello"), "e"); // only 'e' is hex + assert_eq!(normfp("AABB"), "aabb"); + } + + #[test] + fn app_new_cursor_pos_zero() { + let app = App::new("aabbcc".into(), None, "http://localhost:7700".into()); + assert_eq!(app.cursor_pos, 0); + assert!(app.input.is_empty()); + } + + #[test] + fn app_new_should_quit_false() { + let app = App::new("aabbcc".into(), None, "http://localhost:7700".into()); + assert!(!app.should_quit); + } +} diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 5a525de..c23f065 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.21" +version = "0.0.44" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-protocol/src/friends.rs b/warzone/crates/warzone-protocol/src/friends.rs new file mode 100644 index 0000000..1522f6b --- /dev/null +++ b/warzone/crates/warzone-protocol/src/friends.rs @@ -0,0 +1,113 @@ +//! Encrypted friend list — stored on server as opaque blob. + +use serde::{Deserialize, Serialize}; +use crate::crypto::{aead_encrypt, aead_decrypt, hkdf_derive}; + +/// A friend entry. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Friend { + /// ETH address or fingerprint + pub address: String, + /// Optional display name / alias + pub alias: Option, + /// When this friend was added (unix timestamp) + pub added_at: i64, +} + +/// The full friend list (plaintext, before encryption). +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct FriendList { + pub friends: Vec, +} + +impl FriendList { + pub fn new() -> Self { + FriendList { friends: vec![] } + } + + pub fn add(&mut self, address: &str, alias: Option<&str>) { + // Don't add duplicates + if self.friends.iter().any(|f| f.address == address) { + return; + } + self.friends.push(Friend { + address: address.to_string(), + alias: alias.map(String::from), + added_at: chrono::Utc::now().timestamp(), + }); + } + + pub fn remove(&mut self, address: &str) { + self.friends.retain(|f| f.address != address); + } + + /// Encrypt the friend list for server storage. + /// Key is derived from the user's seed: HKDF(seed, info="warzone-friends"). + pub fn encrypt(&self, seed: &[u8; 32]) -> Vec { + let key_bytes = hkdf_derive(seed, b"", b"warzone-friends", 32); + let mut key = [0u8; 32]; + key.copy_from_slice(&key_bytes); + let plaintext = serde_json::to_vec(self).unwrap_or_default(); + aead_encrypt(&key, &plaintext, b"warzone-friends-aad") + } + + /// Decrypt a friend list blob from the server. + pub fn decrypt(seed: &[u8; 32], ciphertext: &[u8]) -> Result { + let key_bytes = hkdf_derive(seed, b"", b"warzone-friends", 32); + let mut key = [0u8; 32]; + key.copy_from_slice(&key_bytes); + let plaintext = aead_decrypt(&key, ciphertext, b"warzone-friends-aad")?; + serde_json::from_slice(&plaintext) + .map_err(|e| crate::errors::ProtocolError::RatchetError(format!("friend list json: {}", e))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_roundtrip() { + let seed = [42u8; 32]; + let mut list = FriendList::new(); + list.add("0x1234abcd", Some("alice")); + list.add("0xdeadbeef", None); + + let encrypted = list.encrypt(&seed); + let decrypted = FriendList::decrypt(&seed, &encrypted).unwrap(); + + assert_eq!(decrypted.friends.len(), 2); + assert_eq!(decrypted.friends[0].address, "0x1234abcd"); + assert_eq!(decrypted.friends[0].alias.as_deref(), Some("alice")); + assert_eq!(decrypted.friends[1].address, "0xdeadbeef"); + } + + #[test] + fn wrong_seed_fails() { + let seed = [42u8; 32]; + let wrong_seed = [99u8; 32]; + let mut list = FriendList::new(); + list.add("0x1234", None); + + let encrypted = list.encrypt(&seed); + assert!(FriendList::decrypt(&wrong_seed, &encrypted).is_err()); + } + + #[test] + fn no_duplicate_add() { + let mut list = FriendList::new(); + list.add("0x1234", None); + list.add("0x1234", Some("alice")); + assert_eq!(list.friends.len(), 1); + } + + #[test] + fn remove_works() { + let mut list = FriendList::new(); + list.add("0x1234", None); + list.add("0x5678", None); + list.remove("0x1234"); + assert_eq!(list.friends.len(), 1); + assert_eq!(list.friends[0].address, "0x5678"); + } +} diff --git a/warzone/crates/warzone-protocol/src/lib.rs b/warzone/crates/warzone-protocol/src/lib.rs index 71b8ce2..1cb9654 100644 --- a/warzone/crates/warzone-protocol/src/lib.rs +++ b/warzone/crates/warzone-protocol/src/lib.rs @@ -12,3 +12,4 @@ pub mod store; pub mod history; pub mod sender_keys; pub mod ethereum; +pub mod friends; diff --git a/warzone/crates/warzone-protocol/src/message.rs b/warzone/crates/warzone-protocol/src/message.rs index 7dddc6c..ee0e829 100644 --- a/warzone/crates/warzone-protocol/src/message.rs +++ b/warzone/crates/warzone-protocol/src/message.rs @@ -43,7 +43,7 @@ pub enum ReceiptType { /// Wire message format for transport between clients. /// Used by both CLI and WASM — MUST be identical for interop. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub enum WireMessage { /// First message to a peer: X3DH key exchange + first ratchet message. KeyExchange { @@ -132,3 +132,104 @@ pub enum CallSignalType { /// Peer is busy. Busy, } + +/// Current wire protocol version. +pub const WIRE_VERSION: u8 = 1; +/// Magic bytes to identify versioned envelope: "WZ" +pub const WIRE_MAGIC: [u8; 2] = [0x57, 0x5A]; + +/// Serialize a WireMessage with version envelope. +/// Format: [0x57][0x5A][version: u8][length: u32 BE][bincode payload] +pub fn serialize_envelope(msg: &WireMessage) -> Result, String> { + let payload = + bincode::serialize(msg).map_err(|e| format!("serialize: {}", e))?; + let len = payload.len() as u32; + let mut out = Vec::with_capacity(7 + payload.len()); + out.extend_from_slice(&WIRE_MAGIC); + out.push(WIRE_VERSION); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&payload); + Ok(out) +} + +/// Deserialize a WireMessage, handling both envelope and legacy formats. +/// - Envelope: [0x57][0x5A][version][length][payload] +/// - Legacy: raw bincode (no envelope) +pub fn deserialize_envelope(data: &[u8]) -> Result { + if data.len() >= 7 && data[0] == WIRE_MAGIC[0] && data[1] == WIRE_MAGIC[1] { + let version = data[2]; + let len = + u32::from_be_bytes([data[3], data[4], data[5], data[6]]) as usize; + if version > WIRE_VERSION { + return Err(format!( + "unsupported wire version {} (max {}). Please update your client.", + version, WIRE_VERSION + )); + } + if data.len() < 7 + len { + return Err("truncated envelope".to_string()); + } + bincode::deserialize(&data[7..7 + len]) + .map_err(|e| format!("v{} deserialize: {}", version, e)) + } else { + // Legacy: raw bincode + bincode::deserialize(data) + .map_err(|e| format!("legacy deserialize: {}", e)) + } +} + +#[cfg(test)] +mod envelope_tests { + use super::*; + + #[test] + fn envelope_roundtrip() { + let msg = WireMessage::Receipt { + sender_fingerprint: "abc123".to_string(), + message_id: "msg-001".to_string(), + receipt_type: ReceiptType::Delivered, + }; + let envelope = serialize_envelope(&msg).unwrap(); + assert_eq!(&envelope[..2], &WIRE_MAGIC); + assert_eq!(envelope[2], WIRE_VERSION); + + let decoded = deserialize_envelope(&envelope).unwrap(); + match decoded { + WireMessage::Receipt { message_id, .. } => { + assert_eq!(message_id, "msg-001") + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn legacy_still_works() { + let msg = WireMessage::Receipt { + sender_fingerprint: "abc123".to_string(), + message_id: "msg-002".to_string(), + receipt_type: ReceiptType::Read, + }; + let raw = bincode::serialize(&msg).unwrap(); + let decoded = deserialize_envelope(&raw).unwrap(); + match decoded { + WireMessage::Receipt { message_id, .. } => { + assert_eq!(message_id, "msg-002") + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn future_version_rejected() { + let mut envelope = serialize_envelope(&WireMessage::Receipt { + sender_fingerprint: "x".into(), + message_id: "y".into(), + receipt_type: ReceiptType::Delivered, + }) + .unwrap(); + envelope[2] = 99; // fake future version + let result = deserialize_envelope(&envelope); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("unsupported wire version")); + } +} diff --git a/warzone/crates/warzone-protocol/src/ratchet.rs b/warzone/crates/warzone-protocol/src/ratchet.rs index a5fa213..b64daf0 100644 --- a/warzone/crates/warzone-protocol/src/ratchet.rs +++ b/warzone/crates/warzone-protocol/src/ratchet.rs @@ -11,15 +11,20 @@ use crate::errors::ProtocolError; const MAX_SKIP: u32 = 1000; +/// Current serialization version for [`RatchetState`]. +const RATCHET_VERSION: u8 = 1; +/// Magic byte to distinguish versioned from unversioned (legacy) data. +const RATCHET_MAGIC: u8 = 0xFC; + /// A message produced by the ratchet. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct RatchetMessage { pub header: RatchetHeader, pub ciphertext: Vec, } /// Header included with each ratchet message. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct RatchetHeader { /// Current DH ratchet public key. pub dh_public: [u8; 32], @@ -208,6 +213,37 @@ impl RatchetState { Ok(()) } + /// Serialize with version prefix: `[MAGIC][VERSION][bincode data]`. + /// + /// Use [`deserialize_versioned`](Self::deserialize_versioned) to restore. + pub fn serialize_versioned(&self) -> Result, String> { + let data = bincode::serialize(self) + .map_err(|e| format!("serialize: {}", e))?; + let mut out = Vec::with_capacity(2 + data.len()); + out.push(RATCHET_MAGIC); + out.push(RATCHET_VERSION); + out.extend_from_slice(&data); + Ok(out) + } + + /// Deserialize with version awareness. Handles: + /// - Versioned format: `[0xFC][version][bincode]` + /// - Legacy format: raw bincode (no prefix) + pub fn deserialize_versioned(data: &[u8]) -> Result { + if data.len() >= 2 && data[0] == RATCHET_MAGIC { + let version = data[1]; + match version { + 1 => bincode::deserialize(&data[2..]) + .map_err(|e| format!("v1 deserialize: {}", e)), + _ => Err(format!("unknown ratchet version: {}", version)), + } + } else { + // Legacy: try raw bincode (pre-versioning data) + bincode::deserialize(data) + .map_err(|e| format!("legacy deserialize: {}", e)) + } + } + fn dh_ratchet_step(&mut self) -> Result<(), ProtocolError> { let their_pub = self .dh_remote @@ -312,6 +348,35 @@ mod tests { assert_eq!(bob.decrypt(&m2).unwrap(), b"two"); } + #[test] + fn versioned_serialize_roundtrip() { + let (mut alice, mut bob) = make_pair(); + let msg = alice.encrypt(b"test versioning").unwrap(); + + // Save alice with versioned format + let serialized = alice.serialize_versioned().unwrap(); + assert_eq!(serialized[0], 0xFC); // magic byte + assert_eq!(serialized[1], 1); // version 1 + + // Restore and use + let mut restored = RatchetState::deserialize_versioned(&serialized).unwrap(); + let msg2 = restored.encrypt(b"after restore").unwrap(); + let plain = bob.decrypt(&msg).unwrap(); + assert_eq!(plain, b"test versioning"); + let plain2 = bob.decrypt(&msg2).unwrap(); + assert_eq!(plain2, b"after restore"); + } + + #[test] + fn legacy_deserialize_works() { + let (alice, _) = make_pair(); + // Serialize with raw bincode (legacy format) + let legacy = bincode::serialize(&alice).unwrap(); + // Should still deserialize with versioned reader + let restored = RatchetState::deserialize_versioned(&legacy).unwrap(); + assert_eq!(bincode::serialize(&restored).unwrap(), legacy); + } + #[test] fn many_messages() { let (mut alice, mut bob) = make_pair(); diff --git a/warzone/crates/warzone-protocol/src/x3dh.rs b/warzone/crates/warzone-protocol/src/x3dh.rs index 4bc1b39..f66ddbd 100644 --- a/warzone/crates/warzone-protocol/src/x3dh.rs +++ b/warzone/crates/warzone-protocol/src/x3dh.rs @@ -163,4 +163,141 @@ mod tests { assert_eq!(alice_result.shared_secret, bob_secret); } + + /// Simulate the EXACT web client (WASM) flow: + /// 1. Alice: generate identity + SPK, create bundle, register + /// 2. Bob: same + /// 3. Alice: fetch Bob's bundle, WasmSession::initiate (X3DH), encrypt_key_exchange + /// 4. Bob: receive wire bytes, decrypt_wire_message (X3DH respond + ratchet decrypt) + #[test] + fn web_client_x3dh_roundtrip() { + use crate::identity::Seed; + use crate::message::WireMessage; + use crate::ratchet::RatchetState; + + // === Alice === + let alice_seed = Seed::generate(); + let alice_id = alice_seed.derive_identity(); + let alice_pub = alice_id.public_identity(); + let (alice_spk_secret, alice_spk) = generate_signed_pre_key(&alice_id, 1); + let alice_bundle = PreKeyBundle { + identity_key: *alice_pub.signing.as_bytes(), + identity_encryption_key: *alice_pub.encryption.as_bytes(), + signed_pre_key: alice_spk, + one_time_pre_key: None, // web client: no OTPKs + }; + + // === Bob === + let bob_seed = Seed::generate(); + let bob_id = bob_seed.derive_identity(); + let bob_pub = bob_id.public_identity(); + let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1); + let bob_spk_secret_bytes = bob_spk_secret.to_bytes(); + let bob_bundle = PreKeyBundle { + identity_key: *bob_pub.signing.as_bytes(), + identity_encryption_key: *bob_pub.encryption.as_bytes(), + signed_pre_key: bob_spk, + one_time_pre_key: None, + }; + let bob_bundle_bytes = bincode::serialize(&bob_bundle).unwrap(); + + // === Alice sends to Bob (simulating WasmSession::initiate + encrypt_key_exchange_with_id) === + + // Step 1: WasmSession::initiate — X3DH + init ratchet + let x3dh_result = initiate(&alice_id, &bob_bundle).unwrap(); + let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key); + let mut alice_ratchet = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); + + // Step 2: encrypt_key_exchange_with_id — use SAME x3dh_result (NOT re-initiate!) + let encrypted = alice_ratchet.encrypt(b"hello bob").unwrap(); + let wire = WireMessage::KeyExchange { + id: "test-msg-001".to_string(), + sender_fingerprint: alice_pub.fingerprint.to_string(), + sender_identity_encryption_key: *alice_pub.encryption.as_bytes(), + ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), + used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id, + ratchet_message: encrypted, + }; + let wire_bytes = bincode::serialize(&wire).unwrap(); + + // === Bob decrypts (simulating decrypt_wire_message) === + let wire_in: WireMessage = bincode::deserialize(&wire_bytes).unwrap(); + match wire_in { + WireMessage::KeyExchange { + sender_identity_encryption_key, + ephemeral_public, + ratchet_message, + .. + } => { + let bob_spk_secret_restored = StaticSecret::from(bob_spk_secret_bytes); + let their_id = PublicKey::from(sender_identity_encryption_key); + let their_eph = PublicKey::from(ephemeral_public); + + let shared = respond( + &bob_id, &bob_spk_secret_restored, None, &their_id, &their_eph, + ).unwrap(); + + let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes); + let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet); + let plaintext = bob_ratchet.decrypt(&ratchet_message).unwrap(); + + assert_eq!(plaintext, b"hello bob"); + } + _ => panic!("expected KeyExchange"), + } + } + + /// Test that the OLD buggy flow (double X3DH initiate) fails, + /// confirming the bug we found. + #[test] + fn double_x3dh_initiate_fails() { + use crate::identity::Seed; + use crate::ratchet::RatchetState; + + let alice_seed = Seed::generate(); + let alice_id = alice_seed.derive_identity(); + let alice_pub = alice_id.public_identity(); + + let bob_seed = Seed::generate(); + let bob_id = bob_seed.derive_identity(); + let bob_pub = bob_id.public_identity(); + let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1); + let bob_spk_secret_bytes = bob_spk_secret.to_bytes(); + let bob_bundle = PreKeyBundle { + identity_key: *bob_pub.signing.as_bytes(), + identity_encryption_key: *bob_pub.encryption.as_bytes(), + signed_pre_key: bob_spk, + one_time_pre_key: None, + }; + + // FIRST X3DH — used for ratchet + let result1 = initiate(&alice_id, &bob_bundle).unwrap(); + let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key); + let mut alice_ratchet = RatchetState::init_alice(result1.shared_secret, their_spk); + let encrypted = alice_ratchet.encrypt(b"test").unwrap(); + + // SECOND X3DH — different ephemeral key (THE BUG) + let result2 = initiate(&alice_id, &bob_bundle).unwrap(); + // result2.ephemeral_public != result1.ephemeral_public + assert_ne!( + result1.ephemeral_public.as_bytes(), + result2.ephemeral_public.as_bytes(), + "two X3DH initiates should produce different ephemeral keys" + ); + + // Bob tries to decrypt using result2's ephemeral (wrong one) + let bob_spk_restored = StaticSecret::from(bob_spk_secret_bytes); + let shared = respond( + &bob_id, &bob_spk_restored, None, + &alice_pub.encryption, &result2.ephemeral_public, + ).unwrap(); + + // The shared secrets DIFFER because different ephemeral keys + assert_ne!(result1.shared_secret, shared, "mismatched ephemeral should produce different shared secret"); + + // Decryption should FAIL + let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes); + let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet); + assert!(bob_ratchet.decrypt(&encrypted).is_err(), "decrypt should fail with wrong shared secret"); + } } diff --git a/warzone/crates/warzone-server/Cargo.toml b/warzone/crates/warzone-server/Cargo.toml index d1e6dd1..9884419 100644 --- a/warzone/crates/warzone-server/Cargo.toml +++ b/warzone/crates/warzone-server/Cargo.toml @@ -25,3 +25,10 @@ rand.workspace = true futures-util = "0.3" ed25519-dalek.workspace = true 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"] } diff --git a/warzone/crates/warzone-server/src/auth_middleware.rs b/warzone/crates/warzone-server/src/auth_middleware.rs new file mode 100644 index 0000000..7f31e72 --- /dev/null +++ b/warzone/crates/warzone-server/src/auth_middleware.rs @@ -0,0 +1,84 @@ +//! Auth enforcement middleware: axum extractor that validates bearer tokens. +//! +//! Reads `Authorization: Bearer ` from request headers, validates via +//! [`crate::routes::auth::validate_token`], and returns the authenticated +//! fingerprint or a 401 rejection. + +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, + response::{IntoResponse, Response}, +}; + +use crate::state::AppState; + +/// Extractor that validates a bearer token and provides the authenticated fingerprint. +/// +/// Place this as the **first** parameter in any handler that requires authentication. +/// The extractor will reject the request with 401 if the token is missing or invalid. +/// +/// # Example +/// +/// ```ignore +/// async fn my_handler( +/// auth: AuthFingerprint, +/// State(state): State, +/// ) -> impl IntoResponse { +/// let fp = auth.fingerprint; // guaranteed valid +/// // ... +/// } +/// ``` +pub struct AuthFingerprint { + pub fingerprint: String, +} + +#[axum::async_trait] +impl FromRequestParts for AuthFingerprint { + type Rejection = AuthError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let header = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .map(|s| s.trim().to_string()); + + let token = match header { + Some(t) if !t.is_empty() => t, + _ => return Err(AuthError::MissingToken), + }; + + match crate::routes::auth::validate_token(&state.db.tokens, &token) { + Some(fingerprint) => Ok(AuthFingerprint { fingerprint }), + None => Err(AuthError::InvalidToken), + } + } +} + +/// Rejection type for [`AuthFingerprint`] extractor failures. +pub enum AuthError { + /// No `Authorization: Bearer ` header was present (or it was empty). + MissingToken, + /// The token was present but did not pass validation (expired or unknown). + InvalidToken, +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + let (status, msg) = match self { + AuthError::MissingToken => ( + StatusCode::UNAUTHORIZED, + "missing or empty Authorization: Bearer header", + ), + AuthError::InvalidToken => ( + StatusCode::UNAUTHORIZED, + "invalid or expired token", + ), + }; + (status, axum::Json(serde_json::json!({ "error": msg }))).into_response() + } +} diff --git a/warzone/crates/warzone-server/src/botfather.rs b/warzone/crates/warzone-server/src/botfather.rs new file mode 100644 index 0000000..db48c08 --- /dev/null +++ b/warzone/crates/warzone-server/src/botfather.rs @@ -0,0 +1,282 @@ +//! Built-in BotFather: processes messages to @botfather and manages bot lifecycle. +//! +//! Supports: /start, /newbot, /mybots, /deletebot, /help +//! Runs as a server-side handler — no external process needed. + +use crate::state::AppState; + +const BOTFATHER_FP: &str = "00000000000000000b0ffa00e000000f"; + +/// Check if a message is destined for BotFather and handle it. +/// Called from deliver_or_queue when the recipient is the BotFather fingerprint. +/// Returns true if handled (message consumed). +pub async fn handle_botfather_message(state: &AppState, from_fp: &str, message: &[u8]) -> bool { + if !state.bots_enabled { + return false; + } + + // Try to parse as plaintext bot_message JSON + let bot_msg: serde_json::Value = match serde_json::from_slice(message) { + Ok(v) => v, + Err(_) => return false, // Encrypted messages can't be processed by built-in handler + }; + + if bot_msg.get("type").and_then(|v| v.as_str()) != Some("bot_message") { + return false; + } + + let text = bot_msg + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let from_name = bot_msg + .get("from_name") + .and_then(|v| v.as_str()) + .unwrap_or(from_fp); + + tracing::info!( + "BotFather: message from {} ({}): {}", + from_fp, + from_name, + text + ); + + let response = match text { + "/start" | "/help" => { + "Welcome to BotFather! I can help you create and manage bots.\n\n\ + Commands:\n\ + /newbot - Create a new bot\n\ + /mybots - List your bots\n\ + /deletebot - Delete a bot\n\ + /token - Get bot token\n\ + /help - Show this message" + .to_string() + } + t if t.starts_with("/newbot") => handle_newbot(state, from_fp, t).await, + t if t.starts_with("/deletebot") => handle_deletebot(state, from_fp, t).await, + "/mybots" => handle_mybots(state, from_fp).await, + t if t.starts_with("/token") => handle_token(state, from_fp, t).await, + _ => "I don't understand that command. Try /help".to_string(), + }; + + // Send response back to the user + send_botfather_reply(state, from_fp, &response).await; + true +} + +async fn handle_newbot(state: &AppState, owner_fp: &str, text: &str) -> String { + // Parse: /newbot + let name = text.strip_prefix("/newbot").unwrap_or("").trim(); + if name.is_empty() { + return "Usage: /newbot \n\nExample: /newbot WeatherBot\n\n\ + The name must end with 'bot' or 'Bot'." + .to_string(); + } + + // Validate name + if name.len() > 32 || name.len() < 3 { + return "Bot name must be 3-32 characters.".to_string(); + } + + let name_lower = name.to_lowercase(); + if !name_lower.ends_with("bot") { + return "Bot name must end with 'bot' or 'Bot'. Example: WeatherBot, my_bot".to_string(); + } + + // Check if alias is taken + let alias_key = format!("a:{}", name_lower); + if state + .db + .aliases + .get(alias_key.as_bytes()) + .ok() + .flatten() + .is_some() + { + return format!( + "Sorry, @{} is already taken. Try a different name.", + name_lower + ); + } + + // Generate fingerprint and token + let fp_bytes: [u8; 16] = rand::random(); + let fp = hex::encode(fp_bytes); + let token_rand: [u8; 16] = rand::random(); + let token = format!("{}:{}", &fp[..16], hex::encode(token_rand)); + + // Store bot info + let bot_info = serde_json::json!({ + "name": name, + "fingerprint": fp, + "token": token, + "owner": owner_fp, + "e2e": false, + "created_at": chrono::Utc::now().timestamp(), + }); + + let bot_key = format!("bot:{}", token); + let _ = state.db.tokens.insert( + bot_key.as_bytes(), + serde_json::to_vec(&bot_info).unwrap_or_default(), + ); + let fp_key = format!("bot_fp:{}", fp); + let _ = state.db.tokens.insert(fp_key.as_bytes(), token.as_bytes()); + + // Register alias (all 3 keys needed for resolve_alias to work) + let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes()); + let _ = state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), name_lower.as_bytes()); + let alias_record = serde_json::json!({ + "alias": name_lower, + "fingerprint": fp, + "recovery_key": "", + "registered_at": chrono::Utc::now().timestamp(), + "last_active": chrono::Utc::now().timestamp(), + }); + let _ = state.db.aliases.insert(format!("rec:{}", name_lower).as_bytes(), serde_json::to_vec(&alias_record).unwrap_or_default()); + + tracing::info!( + "BotFather: created bot @{} for owner {}", + name_lower, + owner_fp + ); + + format!( + "Done! Your new bot @{} is ready.\n\n\ + Token: {}\n\n\ + Keep this token secret! Use it to access the Bot API.\n\n\ + API endpoint: /v1/bot/{}/getUpdates", + name_lower, token, token + ) +} + +async fn handle_deletebot(state: &AppState, owner_fp: &str, text: &str) -> String { + let name = text + .strip_prefix("/deletebot") + .unwrap_or("") + .trim() + .to_lowercase(); + if name.is_empty() { + return "Usage: /deletebot ".to_string(); + } + + // Find the bot + let alias_key = format!("a:{}", name); + let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Bot @{} not found.", name), + }; + + // Get bot info to verify ownership + let token_key = format!("bot_fp:{}", bot_fp); + let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Bot @{} not found in registry.", name), + }; + + let bot_key = format!("bot:{}", token); + if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() { + if let Ok(info) = serde_json::from_slice::(&info_bytes) { + let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or(""); + if owner != owner_fp && owner != "system" { + return format!("You don't own @{}. Only the owner can delete it.", name); + } + } + } + + // Delete everything + let _ = state.db.tokens.remove(bot_key.as_bytes()); + let _ = state.db.tokens.remove(token_key.as_bytes()); + let _ = state.db.aliases.remove(alias_key.as_bytes()); + let _ = state + .db + .aliases + .remove(format!("fp:{}", bot_fp).as_bytes()); + let _ = state.db.keys.remove(bot_fp.as_bytes()); + + tracing::info!("BotFather: deleted bot @{} by owner {}", name, owner_fp); + format!("Bot @{} has been deleted.", name) +} + +async fn handle_mybots(state: &AppState, owner_fp: &str) -> String { + let mut bots = Vec::new(); + + for item in state.db.tokens.iter().flatten() { + let key = String::from_utf8_lossy(&item.0).to_string(); + if !key.starts_with("bot:") { + continue; + } + if let Ok(info) = serde_json::from_slice::(&item.1) { + let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or(""); + if owner == owner_fp { + let name = info.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let e2e = info.get("e2e").and_then(|v| v.as_bool()).unwrap_or(false); + let mode = if e2e { "E2E" } else { "plaintext" }; + bots.push(format!(" @{} ({})", name.to_lowercase(), mode)); + } + } + } + + if bots.is_empty() { + "You have no bots. Use /newbot to create one.".to_string() + } else { + format!("Your bots ({}):\n{}", bots.len(), bots.join("\n")) + } +} + +async fn handle_token(state: &AppState, owner_fp: &str, text: &str) -> String { + let name = text + .strip_prefix("/token") + .unwrap_or("") + .trim() + .to_lowercase(); + if name.is_empty() { + return "Usage: /token ".to_string(); + } + + let alias_key = format!("a:{}", name); + let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Bot @{} not found.", name), + }; + + let token_key = format!("bot_fp:{}", bot_fp); + let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Token not found for @{}.", name), + }; + + // Verify ownership + let bot_key = format!("bot:{}", token); + if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() { + if let Ok(info) = serde_json::from_slice::(&info_bytes) { + let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or(""); + if owner != owner_fp { + return format!("You don't own @{}.", name); + } + } + } + + format!("Token for @{}:\n{}", name, token) +} + +/// Send a reply from BotFather to a user. +async fn send_botfather_reply(state: &AppState, to_fp: &str, text: &str) { + let msg = serde_json::json!({ + "type": "bot_message", + "id": uuid::Uuid::new_v4().to_string(), + "from": BOTFATHER_FP, + "from_name": "BotFather", + "text": text, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&msg).unwrap_or_default(); + + // Deliver directly (don't go through deliver_or_queue to avoid recursion) + if !state.push_to_client(to_fp, &msg_bytes).await { + // Queue for offline pickup + let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4()); + let _ = state.db.messages.insert(key.as_bytes(), msg_bytes); + } +} diff --git a/warzone/crates/warzone-server/src/db.rs b/warzone/crates/warzone-server/src/db.rs index 369eb26..1db7b4d 100644 --- a/warzone/crates/warzone-server/src/db.rs +++ b/warzone/crates/warzone-server/src/db.rs @@ -6,6 +6,10 @@ pub struct Database { pub groups: sled::Tree, pub aliases: sled::Tree, pub tokens: sled::Tree, + pub calls: sled::Tree, + pub missed_calls: sled::Tree, + pub friends: sled::Tree, + pub eth_addresses: sled::Tree, _db: sled::Db, } @@ -17,12 +21,20 @@ impl Database { let groups = db.open_tree("groups")?; let aliases = db.open_tree("aliases")?; let tokens = db.open_tree("tokens")?; + let calls = db.open_tree("calls")?; + let missed_calls = db.open_tree("missed_calls")?; + let friends = db.open_tree("friends")?; + let eth_addresses = db.open_tree("eth_addresses")?; Ok(Database { keys, messages, groups, aliases, tokens, + calls, + missed_calls, + friends, + eth_addresses, _db: db, }) } diff --git a/warzone/crates/warzone-server/src/federation.rs b/warzone/crates/warzone-server/src/federation.rs new file mode 100644 index 0000000..19b33cb --- /dev/null +++ b/warzone/crates/warzone-server/src/federation.rs @@ -0,0 +1,340 @@ +//! Federation: two-server message relay via persistent WebSocket. +//! +//! Each server maintains a WS connection to its peer. Presence updates +//! and message forwards flow over this single connection. Reconnects +//! automatically on failure. + +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Federation configuration loaded from JSON. +#[derive(Clone, Debug, serde::Deserialize)] +pub struct FederationConfig { + pub server_id: String, + pub shared_secret: String, + pub peer: PeerConfig, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct PeerConfig { + pub id: String, + pub url: String, +} + +/// Load federation config from a JSON file. +pub fn load_config(path: &str) -> anyhow::Result { + let data = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("failed to read federation config '{}': {}", path, e))?; + let config: FederationConfig = serde_json::from_str(&data) + .map_err(|e| anyhow::anyhow!("invalid federation config: {}", e))?; + Ok(config) +} + +/// Remote presence: which fingerprints are on the peer server. +#[derive(Clone, Debug)] +pub struct RemotePresence { + pub peer_id: String, + pub fingerprints: HashSet, + pub last_updated: i64, + pub connected: bool, +} + +impl RemotePresence { + pub fn new(peer_id: String) -> Self { + RemotePresence { + peer_id, + fingerprints: HashSet::new(), + last_updated: 0, + connected: false, + } + } + + pub fn contains(&self, fp: &str) -> bool { + self.connected && self.fingerprints.contains(fp) + } +} + +/// Sender for outgoing federation messages over the WS. +pub type FederationSender = Arc>>>; + +/// Handle for communicating with the federation peer. +#[derive(Clone)] +pub struct FederationHandle { + pub config: FederationConfig, + pub remote_presence: Arc>, + /// Channel to send messages over the outgoing WS to the peer. + pub outgoing: FederationSender, + /// HTTP client for one-shot requests (key fetch, etc.) + pub client: reqwest::Client, +} + +impl FederationHandle { + pub fn new(config: FederationConfig) -> Self { + let remote_presence = Arc::new(Mutex::new(RemotePresence::new( + config.peer.id.clone(), + ))); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .expect("failed to build HTTP client"); + FederationHandle { + config, + remote_presence, + outgoing: Arc::new(Mutex::new(None)), + client, + } + } + + /// Check if a fingerprint is known to be on the peer server. + pub async fn is_remote(&self, fp: &str) -> bool { + let rp = self.remote_presence.lock().await; + rp.contains(fp) + } + + /// Forward a message to the peer server via the persistent WS. + pub async fn forward_message(&self, to_fp: &str, message: &[u8]) -> bool { + let msg = serde_json::json!({ + "type": "forward", + "to": to_fp, + "message": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, message), + "from_server": self.config.server_id, + }); + self.send_json(msg).await + } + + /// Fetch a pre-key bundle from the peer server (HTTP GET fallback). + /// Used when a local key lookup fails and the fingerprint is on the remote. + pub async fn fetch_remote_bundle(&self, fingerprint: &str) -> Option> { + let url = format!("{}/v1/keys/{}", self.config.peer.url, fingerprint); + let resp = self.client.get(&url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + let data: serde_json::Value = resp.json().await.ok()?; + let bundle_b64 = data.get("bundle")?.as_str()?; + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, bundle_b64).ok() + } + + /// Resolve an alias on the peer server. + /// Returns Some(fingerprint) if the peer knows this alias. + pub async fn resolve_remote_alias(&self, alias: &str) -> Option { + let url = format!("{}/v1/alias/resolve/{}", self.config.peer.url, alias); + let resp = self.client.get(&url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + let data: serde_json::Value = resp.json().await.ok()?; + // Check for error (alias not found on peer) + if data.get("error").is_some() { + return None; + } + data.get("fingerprint").and_then(|v| v.as_str()).map(String::from) + } + + /// Check if an alias is already taken on the peer server. + /// Returns true if the alias exists on the peer (taken). + pub async fn is_alias_taken_remote(&self, alias: &str) -> bool { + self.resolve_remote_alias(alias).await.is_some() + } + + /// Push local presence to peer via the persistent WS. + pub async fn push_presence(&self, fingerprints: Vec) -> bool { + let msg = serde_json::json!({ + "type": "presence", + "server_id": self.config.server_id, + "fingerprints": fingerprints, + }); + self.send_json(msg).await + } + + /// Send a JSON message over the outgoing WS channel. + async fn send_json(&self, msg: serde_json::Value) -> bool { + let guard = self.outgoing.lock().await; + if let Some(ref tx) = *guard { + let json_str = serde_json::to_string(&msg).unwrap_or_default(); + tx.send(json_str).is_ok() + } else { + false + } + } +} + +/// Background task: connect to peer's WS endpoint, send auth, then loop. +/// Handles reconnection on failure. +pub async fn outgoing_ws_loop( + handle: FederationHandle, + state: crate::state::AppState, +) { + let ws_url = handle.config.peer.url + .replace("http://", "ws://") + .replace("https://", "wss://"); + let ws_url = format!("{}/v1/federation/ws", ws_url); + + loop { + tracing::info!("Federation: connecting to peer {} at {}", handle.config.peer.id, ws_url); + + match tokio_tungstenite::connect_async(&ws_url).await { + Ok((ws_stream, _)) => { + tracing::info!("Federation: connected to peer {}", handle.config.peer.id); + + use futures_util::{SinkExt, StreamExt}; + let (mut ws_tx, mut ws_rx) = ws_stream.split(); + + // Send auth as first message + let auth_msg = serde_json::json!({ + "type": "auth", + "secret": handle.config.shared_secret, + "server_id": handle.config.server_id, + }); + if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text( + serde_json::to_string(&auth_msg).unwrap_or_default() + )).await.is_err() { + tracing::warn!("Federation: failed to send auth to peer"); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + continue; + } + + // Set up outgoing channel + let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::(); + { + let mut guard = handle.outgoing.lock().await; + *guard = Some(out_tx); + } + { + let mut rp = handle.remote_presence.lock().await; + rp.connected = true; + } + + // Send initial presence + let fps: Vec = { + let conns = state.connections.lock().await; + conns.keys().cloned().collect() + }; + let _ = handle.push_presence(fps).await; + + // Spawn task to forward outgoing channel + periodic ping to WS + let send_task = tokio::spawn(async move { + let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15)); + loop { + tokio::select! { + msg = out_rx.recv() => { + match msg { + Some(text) => { + if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(text)).await.is_err() { + break; + } + } + None => break, + } + } + _ = ping_interval.tick() => { + if ws_tx.send(tokio_tungstenite::tungstenite::Message::Ping(vec![])).await.is_err() { + break; + } + } + } + } + }); + + // Spawn task to periodically re-push presence + let presence_handle = handle.clone(); + let presence_conns = state.connections.clone(); + let presence_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(10)); + loop { + interval.tick().await; + let fps: Vec = { + let conns = presence_conns.lock().await; + conns.keys().cloned().collect() + }; + if !presence_handle.push_presence(fps).await { + break; + } + } + }); + + // Read incoming messages from peer + while let Some(Ok(msg)) = ws_rx.next().await { + match msg { + tokio_tungstenite::tungstenite::Message::Text(text) => { + handle_incoming_federation_msg(&text, &handle, &state).await; + } + tokio_tungstenite::tungstenite::Message::Pong(_) => {} // keepalive response + tokio_tungstenite::tungstenite::Message::Close(_) => break, + _ => {} + } + } + + // Connection lost + send_task.abort(); + presence_task.abort(); + { + let mut guard = handle.outgoing.lock().await; + *guard = None; + } + { + let mut rp = handle.remote_presence.lock().await; + rp.connected = false; + rp.fingerprints.clear(); + } + tracing::warn!("Federation: lost connection to peer {}, reconnecting...", handle.config.peer.id); + } + Err(e) => { + tracing::warn!("Federation: failed to connect to peer {}: {}", handle.config.peer.id, e); + } + } + + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } +} + +/// Process a single incoming JSON message from the federated peer WS. +async fn handle_incoming_federation_msg( + text: &str, + handle: &FederationHandle, + state: &crate::state::AppState, +) { + let parsed: serde_json::Value = match serde_json::from_str(text) { + Ok(v) => v, + Err(_) => return, + }; + + let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + match msg_type { + "presence" => { + let fingerprints: Vec = parsed.get("fingerprints") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("?"); + let count = fingerprints.len(); + + let mut rp = handle.remote_presence.lock().await; + rp.fingerprints = fingerprints.into_iter().collect(); + rp.last_updated = chrono::Utc::now().timestamp(); + tracing::debug!("Federation: received {} fingerprints from {}", count, server_id); + } + "forward" => { + let to = parsed.get("to").and_then(|v| v.as_str()).unwrap_or(""); + let message_b64 = parsed.get("message").and_then(|v| v.as_str()).unwrap_or(""); + let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("?"); + + if let Ok(message) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message_b64) { + let delivered = state.push_to_client(to, &message).await; + if !delivered { + let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4()); + let _ = state.db.messages.insert(key.as_bytes(), message.as_slice()); + tracing::info!("Federation: queued message from {} for offline {}", from_server, to); + } else { + tracing::debug!("Federation: delivered message from {} to {}", from_server, to); + } + } + } + _ => { + tracing::debug!("Federation: unknown message type '{}'", msg_type); + } + } +} + diff --git a/warzone/crates/warzone-server/src/lib.rs b/warzone/crates/warzone-server/src/lib.rs index 1c0b582..9da2ec7 100644 --- a/warzone/crates/warzone-server/src/lib.rs +++ b/warzone/crates/warzone-server/src/lib.rs @@ -1,5 +1,8 @@ +pub mod auth_middleware; +pub mod botfather; pub mod config; pub mod db; pub mod errors; +pub mod federation; pub mod routes; pub mod state; diff --git a/warzone/crates/warzone-server/src/main.rs b/warzone/crates/warzone-server/src/main.rs index 3c98027..467c671 100644 --- a/warzone/crates/warzone-server/src/main.rs +++ b/warzone/crates/warzone-server/src/main.rs @@ -1,8 +1,11 @@ use clap::Parser; +mod botfather; +pub mod auth_middleware; mod config; mod db; mod errors; +mod federation; mod routes; mod state; @@ -16,6 +19,18 @@ struct Cli { /// Database directory #[arg(short, long, default_value = "./warzone-data")] data_dir: String, + + /// Federation config file (JSON). Enables server-to-server message relay. + #[arg(short, long)] + federation: Option, + + /// Enable bot API (disabled by default) + #[arg(long, default_value = "false")] + enable_bots: bool, + + /// System bots config file (JSON array). Bots are auto-created on startup. + #[arg(long)] + bots_config: Option, } #[tokio::main] @@ -30,11 +45,204 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); tracing::info!("Warzone server starting on {}", cli.bind); - let state = state::AppState::new(&cli.data_dir)?; + 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::(&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)?; + tracing::info!( + "Federation enabled: server_id={}, peer={}@{}", + fed_config.server_id, fed_config.peer.id, fed_config.peer.url + ); + let handle = federation::FederationHandle::new(fed_config); + state.federation = Some(handle); + } + + // Enable bot API if requested + state.bots_enabled = cli.enable_bots; + if cli.enable_bots { + tracing::info!("Bot API enabled"); + + // Auto-create BotFather if it doesn't exist + let botfather_fp = "00000000000000000b0ffa00e000000f"; + let botfather_key = format!("bot_fp:{}", botfather_fp); + if state.db.tokens.get(botfather_key.as_bytes()).ok().flatten().is_none() { + let token = format!("botfather:{}", hex::encode(rand::random::<[u8; 16]>())); + let bot_info = serde_json::json!({ + "name": "BotFather", + "fingerprint": botfather_fp, + "token": token, + "owner": "system", + "e2e": false, + "created_at": chrono::Utc::now().timestamp(), + }); + let key = format!("bot:{}", token); + let _ = state.db.tokens.insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default()); + let _ = state.db.tokens.insert(botfather_key.as_bytes(), token.as_bytes()); + // Register alias + let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes()); + let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather"); + tracing::info!("BotFather created: @botfather (token: {})", token); + } else { + tracing::info!("BotFather already exists"); + } + // Always ensure alias exists (may have been lost on data wipe) + let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes()); + let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather"); + // Store proper AliasRecord so resolve_alias works + let bf_record = serde_json::json!({ + "alias": "botfather", + "fingerprint": botfather_fp, + "recovery_key": "", + "registered_at": chrono::Utc::now().timestamp(), + "last_active": chrono::Utc::now().timestamp(), + }); + let _ = state.db.aliases.insert(b"rec:botfather", serde_json::to_vec(&bf_record).unwrap_or_default()); + + // Load system bots from config file + if let Some(ref bots_path) = cli.bots_config { + match std::fs::read_to_string(bots_path) { + Ok(data) => { + if let Ok(bots) = serde_json::from_str::>(&data) { + for bot in &bots { + let name = bot.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let desc = bot.get("description").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() { continue; } + + let alias = name.to_lowercase(); + let alias_key = format!("a:{}", alias); + + // Check if already exists + let existing_fp = state.db.aliases.get(alias_key.as_bytes()) + .ok().flatten() + .map(|v| String::from_utf8_lossy(&v).to_string()); + + let fp = if let Some(ref efp) = existing_fp { + // Bot exists — just ensure alias record is intact + efp.clone() + } else { + // Create new bot + let fp_bytes: [u8; 16] = rand::random(); + let fp = hex::encode(fp_bytes); + let token_rand: [u8; 16] = rand::random(); + let token = format!("{}:{}", &fp[..16], hex::encode(token_rand)); + + let bot_info = serde_json::json!({ + "name": name, + "fingerprint": fp, + "token": token, + "owner": "system", + "description": desc, + "system_bot": true, + "e2e": false, + "created_at": chrono::Utc::now().timestamp(), + }); + let _ = state.db.tokens.insert(format!("bot:{}", token).as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default()); + let _ = state.db.tokens.insert(format!("bot_fp:{}", fp).as_bytes(), token.as_bytes()); + let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes()); + let _ = state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), alias.as_bytes()); + tracing::info!("System bot @{} created (token: {})", alias, token); + fp + }; + + // Always ensure alias record exists + let rec = serde_json::json!({ + "alias": alias, + "fingerprint": fp, + "recovery_key": "", + "registered_at": chrono::Utc::now().timestamp(), + "last_active": chrono::Utc::now().timestamp(), + }); + let _ = state.db.aliases.insert(format!("rec:{}", alias).as_bytes(), serde_json::to_vec(&rec).unwrap_or_default()); + } + tracing::info!("Loaded {} system bots from {}", bots.len(), bots_path); + + // Write tokens to file for easy access + let tokens_path = format!("{}/bot-tokens.txt", cli.data_dir); + let mut token_lines = Vec::new(); + for bot in &bots { + let name = bot.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() { continue; } + let alias = name.to_lowercase(); + if let Some(fp_bytes) = state.db.aliases.get(format!("a:{}", alias).as_bytes()).ok().flatten() { + let fp = String::from_utf8_lossy(&fp_bytes).to_string(); + if let Some(tok_bytes) = state.db.tokens.get(format!("bot_fp:{}", fp).as_bytes()).ok().flatten() { + let tok = String::from_utf8_lossy(&tok_bytes).to_string(); + token_lines.push(format!("{}={}", alias.to_uppercase(), tok)); + } + } + } + if !token_lines.is_empty() { + let _ = std::fs::write(&tokens_path, token_lines.join("\n") + "\n"); + tracing::info!("Bot tokens written to {}", tokens_path); + } + + // Store bot list in DB for welcome screen + let bot_list: Vec = bots.iter().map(|b| { + serde_json::json!({ + "name": b.get("name").and_then(|v| v.as_str()).unwrap_or(""), + "description": b.get("description").and_then(|v| v.as_str()).unwrap_or(""), + }) + }).collect(); + let _ = state.db.tokens.insert(b"system:bot_list", serde_json::to_vec(&bot_list).unwrap_or_default()); + } + } + Err(e) => tracing::warn!("Failed to load bots config '{}': {}", bots_path, e), + } + } + } + + // Spawn federation outgoing WS connection if enabled + if let Some(ref fed) = state.federation { + let handle = fed.clone(); + let fed_state = state.clone(); + tokio::spawn(async move { + federation::outgoing_ws_loop(handle, fed_state).await; + }); + } + + let cors = tower_http::cors::CorsLayer::new() + .allow_origin(tower_http::cors::Any) + .allow_methods(tower_http::cors::Any) + .allow_headers(tower_http::cors::Any); let app = axum::Router::new() .merge(routes::web_router()) .nest("/v1", routes::router()) + .layer(cors) + .layer(tower::limit::ConcurrencyLimitLayer::new(200)) .layer(tower_http::trace::TraceLayer::new_for_http()) .with_state(state); diff --git a/warzone/crates/warzone-server/src/routes/aliases.rs b/warzone/crates/warzone-server/src/routes/aliases.rs index 884c2a9..5fd84b7 100644 --- a/warzone/crates/warzone-server/src/routes/aliases.rs +++ b/warzone/crates/warzone-server/src/routes/aliases.rs @@ -112,6 +112,7 @@ struct RegisterRequest { /// - Expired aliases (past grace period) can be reclaimed by anyone /// - Expired aliases (within grace period) can only be reclaimed by recovery key async fn register_alias( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { @@ -122,6 +123,18 @@ async fn register_alias( return Ok(Json(serde_json::json!({ "error": "alias must be 1-32 alphanumeric chars" }))); } + // Reserve *Bot and *_bot suffixes for bots only + let is_bot_name = alias.ends_with("bot") || alias.ends_with("_bot"); + if is_bot_name { + // Check if this fingerprint is registered as a bot + let bot_key = format!("bot_fp:{}", fp); + let is_registered_bot = state.db.tokens.get(bot_key.as_bytes()) + .ok().flatten().is_some(); + if !is_registered_bot { + return Ok(Json(serde_json::json!({ "error": "aliases ending with 'Bot' or '_bot' are reserved for bots — register via /v1/bot/register first" }))); + } + } + // Check existing record for this alias if let Some(existing) = load_alias_record(&state.db.aliases, &alias) { if existing.fingerprint == fp { @@ -151,6 +164,13 @@ async fn register_alias( delete_alias_record(&state.db.aliases, &existing)?; } + // Check if alias is taken on federation peer (globally unique) + if let Some(ref federation) = state.federation { + if federation.is_alias_taken_remote(&alias).await { + return Ok(Json(serde_json::json!({ "error": "alias already taken on federated server" }))); + } + } + // Remove old alias for this fingerprint (one alias per person) if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string(); @@ -190,6 +210,7 @@ struct RecoverRequest { /// Recover an alias using the recovery key. Works even if expired (within or past grace). async fn recover_alias( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { @@ -244,6 +265,7 @@ struct RenewRequest { /// Renew/heartbeat — resets the TTL. Called automatically on activity. async fn renew_alias( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { @@ -289,7 +311,20 @@ async fn resolve_alias( }))) } } - None => Ok(Json(serde_json::json!({ "error": "alias not found" }))), + None => { + // Try federation peer + if let Some(ref federation) = state.federation { + if let Some(fp) = federation.resolve_remote_alias(&alias).await { + tracing::info!("Alias @{} resolved via federation: {}", alias, fp); + return Ok(Json(serde_json::json!({ + "alias": alias, + "fingerprint": fp, + "federated": true, + }))); + } + } + Ok(Json(serde_json::json!({ "error": "alias not found" }))) + } } } @@ -347,6 +382,7 @@ struct UnregisterRequest { /// Remove your own alias. async fn unregister_alias( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { @@ -381,6 +417,7 @@ struct AdminRemoveRequest { /// Admin: remove any alias. async fn admin_remove_alias( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs new file mode 100644 index 0000000..1c808bd --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -0,0 +1,1072 @@ +//! Telegram Bot API compatibility layer. +//! +//! Bots register with a fingerprint and get a token. +//! They use `/bot/getUpdates` and `/bot/sendMessage` +//! to communicate with featherChat users. +//! +//! Supported endpoints (Telegram-compatible): +//! - `POST /bot/register` (featherChat-specific) +//! - `GET /bot/:token/getMe` +//! - `POST /bot/:token/getUpdates` +//! - `POST /bot/:token/sendMessage` +//! - `POST /bot/:token/answerCallbackQuery` +//! - `POST /bot/:token/editMessageText` +//! - `POST /bot/:token/setWebhook` +//! - `POST /bot/:token/deleteWebhook` +//! - `GET /bot/:token/getWebhookInfo` +//! - `POST /bot/:token/sendDocument` + +use axum::{ + extract::{Path, State}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; + +use base64::Engine; + +use crate::errors::AppResult; +use crate::state::AppState; + +/// Build the bot API routes (nested under `/v1`). +pub fn routes() -> Router { + Router::new() + .route("/bot/list", get(list_system_bots)) + .route("/bot/register", post(register_bot)) + .route("/bot/:token/getMe", get(get_me)) + .route("/bot/:token/getUpdates", post(get_updates)) + .route("/bot/:token/sendMessage", post(send_message)) + .route("/bot/:token/answerCallbackQuery", post(answer_callback_query)) + .route("/bot/:token/editMessageText", post(edit_message_text)) + .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_flexible)) +} + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// System bot list (public, no auth needed) +// --------------------------------------------------------------------------- + +/// GET /v1/bot/list — returns system bots for welcome screen. +async fn list_system_bots( + State(state): State, +) -> Json { + let bots = state.db.tokens.get(b"system:bot_list") + .ok().flatten() + .and_then(|v| serde_json::from_slice::>(&v).ok()) + .unwrap_or_default(); + Json(serde_json::json!({ "ok": true, "bots": bots })) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Validate a bot token against the `tokens` sled tree. +/// Returns the stored bot info JSON if the token is valid. +/// Returns `None` if bots are disabled on this server instance. +fn validate_bot_token(state: &AppState, token: &str) -> Option { + if !state.bots_enabled { + return None; + } + let key = format!("bot:{}", token); + let ivec = state.db.tokens.get(key.as_bytes()).ok()??; + serde_json::from_slice(&ivec).ok() +} + +/// Resolve a `chat_id` that may be a string fingerprint, ETH address, or numeric ID. +fn resolve_chat_id(state: &AppState, chat_id: &serde_json::Value) -> Option { + match chat_id { + serde_json::Value::String(s) => { + let clean: String = s + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + if clean.len() >= 16 { + Some(clean) + } else if s.starts_with("0x") { + // ETH address -- resolve + state + .db + .eth_addresses + .get(s.to_lowercase().as_bytes()) + .ok()? + .map(|v| String::from_utf8_lossy(&v).to_string()) + } else { + Some(s.clone()) + } + } + serde_json::Value::Number(n) => { + let num = n.as_i64().unwrap_or(0); + // Look up per-bot numeric ID reverse mapping + let numid_key = format!("numid:{}", num); + if let Some(fp_bytes) = state.db.tokens.get(numid_key.as_bytes()).ok().flatten() { + return Some(String::from_utf8_lossy(&fp_bytes).to_string()); + } + // Fallback: scan all keys with global hash (legacy) + for item in state.db.keys.iter().flatten() { + let key_str = String::from_utf8_lossy(&item.0).to_string(); + if !key_str.contains(':') && key_str.len() == 32 { + if crate::routes::resolve::fp_to_numeric_id(&key_str) == num { + return Some(key_str); + } + } + } + None + } + _ => None, + } +} + +/// Get the next update_id for a bot and atomically increment the counter. +/// +/// The counter is stored in the `tokens` tree under `bot_update_id:`. +fn next_update_id(state: &AppState, bot_fp: &str) -> u64 { + let key = format!("bot_update_id:{}", bot_fp); + let current = state + .db + .tokens + .get(key.as_bytes()) + .ok() + .flatten() + .and_then(|v| { + let bytes: [u8; 8] = v.as_ref().try_into().ok()?; + Some(u64::from_be_bytes(bytes)) + }) + .unwrap_or(1); + let next = current + 1; + let _ = state + .db + .tokens + .insert(key.as_bytes(), &next.to_be_bytes()); + current +} + +/// Store an update in the bot's persistent queue with an assigned update_id. +/// +/// Key format: `bot_queue::` to ensure lexicographic ordering. +fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value) { + let uid = next_update_id(state, bot_fp); + let queue_key = format!("bot_queue:{}:{:020}", bot_fp, uid); + let mut enriched = update; + enriched["update_id"] = serde_json::json!(uid); + if let Ok(bytes) = serde_json::to_vec(&enriched) { + let _ = state.db.messages.insert(queue_key.as_bytes(), bytes); + } +} + +// --------------------------------------------------------------------------- +// Webhook delivery (public -- called from state.rs deliver_or_queue) +// --------------------------------------------------------------------------- + +/// Check if a fingerprint belongs to a bot with a webhook, and deliver the message. +/// +/// Called from `AppState::deliver_or_queue` after queueing. Returns `true` if +/// the webhook accepted the update (HTTP 2xx), meaning the queued entry can be +/// removed. +pub async fn try_bot_webhook(state: &AppState, to_fp: &str, message: &[u8]) -> bool { + // 1. Check if this fingerprint is a bot + let token_key = format!("bot_fp:{}", to_fp); + let token = match state.db.tokens.get(token_key.as_bytes()) { + Ok(Some(v)) => String::from_utf8_lossy(&v).to_string(), + _ => return false, + }; + + // 2. Load bot info and check for webhook URL + let bot_info: serde_json::Value = + match state.db.tokens.get(format!("bot:{}", token).as_bytes()) { + Ok(Some(v)) => match serde_json::from_slice(&v) { + Ok(v) => v, + Err(_) => return false, + }, + _ => return false, + }; + + let webhook_url = match bot_info.get("webhook_url").and_then(|v| v.as_str()) { + Some(url) if !url.is_empty() => url.to_string(), + _ => return false, + }; + + // 3. Build Telegram-style update from the raw message bytes + let update = if let Ok(wire) = + bincode::deserialize::(message) + { + wire_message_to_update(state, &wire, message, &token) + } else if let Ok(bot_msg) = serde_json::from_slice::(message) { + bot_json_to_update(state, &bot_msg, &token) + } else { + None + }; + + let mut update = match update { + Some(u) => u, + None => return false, + }; + + // Assign a real update_id so the webhook consumer can track ordering + let uid = next_update_id(state, to_fp); + update["update_id"] = serde_json::json!(uid); + + // 4. POST to webhook URL with a short timeout + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap_or_default(); + + match client + .post(&webhook_url) + .header("Content-Type", "application/json") + .json(&update) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + tracing::info!("Webhook delivered to {} for bot {}", webhook_url, to_fp); + true + } + Ok(resp) => { + tracing::warn!( + "Webhook {} returned {} for bot {}", + webhook_url, + resp.status(), + to_fp + ); + false + } + Err(e) => { + tracing::warn!("Webhook {} failed for bot {}: {}", webhook_url, to_fp, e); + false + } + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct RegisterBotRequest { + name: String, + fingerprint: String, + #[serde(default)] + bundle: Option>, // bincode PreKeyBundle for E2E bots + #[serde(default)] + eth_address: Option, + #[serde(default)] + e2e: Option, // true = E2E bot, false/None = plaintext bot + #[serde(default)] + owner: Option, // fingerprint of the bot creator + #[serde(default)] + botfather_token: Option, +} + +/// Register a bot and receive a token. +/// +/// `POST /v1/bot/register` +/// +/// ```json +/// { "name": "mybot", "fingerprint": "aabbccdd..." } +/// ``` +async fn register_bot( + State(state): State, + Json(req): Json, +) -> AppResult> { + if !state.bots_enabled { + return Ok(Json(serde_json::json!({"ok": false, "description": "Bot API is disabled on this server. Use a server with --enable-bots"}))); + } + + // Only BotFather can register bots + // Require botfather_token field matching the stored BotFather token + if let Some(ref bf_token) = req.botfather_token { + let botfather_fp = "00000000000000000b0ffa00e000000f"; + let bf_key = format!("bot_fp:{}", botfather_fp); + let stored_token = state.db.tokens.get(bf_key.as_bytes()) + .ok().flatten() + .map(|v| String::from_utf8_lossy(&v).to_string()); + if stored_token.as_deref() != Some(bf_token.as_str()) { + return Ok(Json(serde_json::json!({"ok": false, "description": "invalid BotFather token"}))); + } + } else { + return Ok(Json(serde_json::json!({"ok": false, "description": "bot registration requires BotFather authorization. Message @botfather to create a bot."}))); + } + + let fp = req + .fingerprint + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + + let random_bytes: [u8; 16] = rand::random(); + let token = format!( + "{}:{}", + &fp[..fp.len().min(16)], + hex::encode(random_bytes), + ); + + let bot_info = serde_json::json!({ + "name": req.name, + "fingerprint": fp, + "token": token, + "owner": req.owner.as_deref().unwrap_or(&fp), + "e2e": req.e2e.unwrap_or(false), + "created_at": chrono::Utc::now().timestamp(), + }); + + // Store bot info keyed by token. + let key = format!("bot:{}", token); + state + .db + .tokens + .insert(key.as_bytes(), serde_json::to_vec(&bot_info)?.as_slice())?; + + // Reverse lookup: fingerprint -> token. + let fp_key = format!("bot_fp:{}", fp); + state + .db + .tokens + .insert(fp_key.as_bytes(), token.as_bytes())?; + + // If E2E bot, register pre-key bundle (bot can receive encrypted messages) + if req.e2e.unwrap_or(false) { + if let Some(ref bundle_bytes) = req.bundle { + let _ = state.db.keys.insert(fp.as_bytes(), bundle_bytes.as_slice()); + let device_key = format!("device:{}:bot", fp); + let _ = state.db.keys.insert(device_key.as_bytes(), bundle_bytes.as_slice()); + tracing::info!("E2E bot: registered pre-key bundle for {}", fp); + } + } + + // Store ETH address mapping if provided + if let Some(ref eth) = req.eth_address { + let eth_lower = eth.to_lowercase(); + let _ = state.db.eth_addresses.insert(eth_lower.as_bytes(), fp.as_bytes()); + let _ = state.db.eth_addresses.insert(format!("rev:{}", fp).as_bytes(), eth_lower.as_bytes()); + } + + tracing::info!( + "Bot registered: {} ({}) token={}... e2e={}", + req.name, + fp, + &token[..token.len().min(20)], + req.e2e.unwrap_or(false), + ); + + // Auto-register bot alias (name must end with Bot or _bot) + let bot_alias = if req.name.ends_with("Bot") || req.name.ends_with("_bot") || req.name.ends_with("bot") { + req.name.to_lowercase() + } else { + format!("{}_bot", req.name.to_lowercase()) + }; + let alias_key = format!("a:{}", bot_alias); + let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes()); + let fp_key = format!("fp:{}", fp); + let _ = state.db.aliases.insert(fp_key.as_bytes(), bot_alias.as_bytes()); + tracing::info!("Bot alias @{} registered for {}", bot_alias, fp); + + Ok(Json(serde_json::json!({ + "ok": true, + "result": { + "token": token, + "name": req.name, + "fingerprint": fp, + "alias": format!("@{}", bot_alias), + "e2e": req.e2e.unwrap_or(false), + } + }))) +} + +/// `GET /bot/:token/getMe` -- returns bot info (Telegram-compatible shape). +async fn get_me( + State(state): State, + Path(token): Path, +) -> Json { + match validate_bot_token(&state, &token) { + Some(info) => { + let fp = info["fingerprint"].as_str().unwrap_or(""); + Json(serde_json::json!({ + "ok": true, + "result": { + "id": crate::routes::resolve::fp_to_numeric_id_for_bot(fp, &token), + "is_bot": true, + "first_name": info["name"], + "username": info["name"], + } + })) + } + None => Json(serde_json::json!({ + "ok": false, + "description": "invalid token", + })), + } +} + +// --------------------------------------------------------------------------- +// getUpdates — with offset/limit/timeout support +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct GetUpdatesParams { + #[serde(default)] + offset: Option, + #[serde(default)] + limit: Option, + #[serde(default)] + timeout: Option, +} + +/// `POST /bot/:token/getUpdates` -- long-poll for messages sent to this bot. +/// +/// Migrates raw queue entries (from `queue::*`) into the persistent +/// bot update queue (`bot_queue::`) on each call, then +/// returns updates filtered by `offset` and capped by `limit`. +/// +/// When `offset` is provided, all updates with `update_id < offset` are +/// acknowledged (deleted), matching Telegram Bot API semantics. +async fn get_updates( + State(state): State, + Path(token): Path, + Json(params): Json, +) -> Json { + let bot_info = match validate_bot_token(&state, &token) { + Some(info) => info, + None => { + return Json(serde_json::json!({ + "ok": false, + "description": "invalid token", + })) + } + }; + let bot_fp = bot_info["fingerprint"].as_str().unwrap_or(""); + let limit = params.limit.unwrap_or(100).min(100); + let timeout = params.timeout.unwrap_or(0); + + // Step 1: Migrate raw queue entries into the persistent bot_queue. + migrate_raw_queue(&state, bot_fp, &token); + + // Step 2: If offset is provided, delete all acknowledged updates (update_id < offset). + if let Some(offset) = params.offset { + let prefix = format!("bot_queue:{}:", bot_fp); + let mut to_delete = Vec::new(); + for item in state.db.messages.scan_prefix(prefix.as_bytes()) { + let (key, value) = match item { + Ok(pair) => pair, + Err(_) => continue, + }; + if let Ok(update) = serde_json::from_slice::(&value) { + let uid = update["update_id"].as_i64().unwrap_or(0); + if uid < offset { + to_delete.push(key); + } else { + // Keys are ordered, so once we pass offset we can stop scanning + // for deletions. + break; + } + } + } + for key in &to_delete { + let _ = state.db.messages.remove(key); + } + } + + // Step 3: Collect remaining updates up to `limit`. + let updates = collect_updates(&state, bot_fp, limit); + + // Step 4: Long-poll if empty. Minimum 1s delay to prevent tight-loop abuse. + if updates.is_empty() { + let timeout = if timeout == 0 { 1 } else { timeout }; // force min 1s + let wait = std::cmp::min(timeout, 50); + // Poll in 1-second intervals so new messages are picked up promptly. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait); + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + // Check for newly arrived raw messages. + migrate_raw_queue(&state, bot_fp, &token); + let polled = collect_updates(&state, bot_fp, limit); + if !polled.is_empty() { + return Json(serde_json::json!({ + "ok": true, + "result": polled, + })); + } + if tokio::time::Instant::now() >= deadline { + break; + } + } + } + + Json(serde_json::json!({ + "ok": true, + "result": updates, + })) +} + +/// Migrate raw `queue::*` entries into `bot_queue::`. +/// +/// Each raw entry is converted into a Telegram-style Update JSON object, assigned +/// a persistent update_id, and stored. The original raw key is deleted. +fn migrate_raw_queue(state: &AppState, bot_fp: &str, bot_token: &str) { + let prefix = format!("queue:{}", bot_fp); + let mut keys_to_delete = Vec::new(); + + for item in state.db.messages.scan_prefix(prefix.as_bytes()) { + let (key, value) = match item { + Ok(pair) => pair, + Err(_) => continue, + }; + + let update = if let Ok(wire) = + bincode::deserialize::(&value) + { + wire_message_to_update(state, &wire, &value, bot_token) + } else if let Ok(bot_msg) = serde_json::from_slice::(&value) { + bot_json_to_update(state, &bot_msg, bot_token) + } else { + None + }; + + if let Some(upd) = update { + enqueue_bot_update(state, bot_fp, upd); + } + keys_to_delete.push(key); + } + + for key in &keys_to_delete { + let _ = state.db.messages.remove(key); + } +} + +/// Store a per-bot numeric ID → fingerprint reverse mapping. +fn store_numid_mapping(state: &AppState, numeric_id: i64, fingerprint: &str) { + let key = format!("numid:{}", numeric_id); + let _ = state.db.tokens.insert(key.as_bytes(), fingerprint.as_bytes()); +} + +/// Convert a `WireMessage` into a Telegram-style update JSON (without update_id). +fn wire_message_to_update( + state: &AppState, + wire: &warzone_protocol::message::WireMessage, + raw_bytes: &[u8], + bot_token: &str, +) -> Option { + match wire { + warzone_protocol::message::WireMessage::Message { + id, + sender_fingerprint, + .. + } => { + let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); + Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": numeric, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": numeric, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": null, + "raw_encrypted": raw_b64, + } + })) + } + warzone_protocol::message::WireMessage::KeyExchange { + id, + sender_fingerprint, + .. + } => { + let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); + Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": numeric, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": numeric, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": null, + "raw_encrypted": raw_b64, + } + })) + } + warzone_protocol::message::WireMessage::CallSignal { + id, + sender_fingerprint, + signal_type, + payload, + .. + } => { + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); + Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": numeric, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": numeric, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": format!("/call_{:?}", signal_type), + "call_signal": { + "type": format!("{:?}", signal_type), + "payload": payload, + }, + } + })) + } + warzone_protocol::message::WireMessage::FileHeader { + id, + sender_fingerprint, + filename, + file_size, + .. + } => { + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); + Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": numeric, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": numeric, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "document": { + "file_name": filename, + "file_size": file_size, + }, + } + })) + } + // Skip receipts and other variants. + warzone_protocol::message::WireMessage::Receipt { .. } => None, + _ => None, + } +} + +/// Convert a plaintext bot JSON message into a Telegram-style update (without update_id). +fn bot_json_to_update(state: &AppState, bot_msg: &serde_json::Value, bot_token: &str) -> Option { + let msg_type = bot_msg.get("type").and_then(|v| v.as_str())?; + match msg_type { + "bot_message" => { + let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); store_numid_mapping(state, numeric, from_fp); + Some(serde_json::json!({ + "message": { + "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "from": { + "id": numeric, + "is_bot": true, + }, + "chat": { + "id": numeric, + "type": "private", + }, + "date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0), + "text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""), + } + })) + } + "callback_query" => { + let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); store_numid_mapping(state, numeric, from_fp); + Some(serde_json::json!({ + "callback_query": { + "id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "from": { + "id": numeric, + "is_bot": false, + }, + "data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""), + "message": bot_msg.get("message"), + } + })) + } + _ => None, + } +} + +/// Collect up to `limit` updates from `bot_queue::*`, preserving order. +fn collect_updates(state: &AppState, bot_fp: &str, limit: usize) -> Vec { + let prefix = format!("bot_queue:{}:", bot_fp); + let mut updates = Vec::new(); + for item in state.db.messages.scan_prefix(prefix.as_bytes()) { + let (_key, value) = match item { + Ok(pair) => pair, + Err(_) => continue, + }; + if let Ok(update) = serde_json::from_slice::(&value) { + updates.push(update); + if updates.len() >= limit { + break; + } + } + } + updates +} + +// --------------------------------------------------------------------------- +// sendMessage — enhanced with parse_mode, reply_to, reply_markup +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct SendMessageRequest { + chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID) + text: String, + #[serde(default)] + parse_mode: Option, + #[serde(default)] + reply_to_message_id: Option, + #[serde(default)] + reply_markup: Option, +} + +/// `POST /bot/:token/sendMessage` -- send a plaintext message to a user. +/// +/// In v1, bot messages are **not** E2E-encrypted; they are delivered as +/// plain JSON envelopes through the normal routing layer. +async fn send_message( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> Json { + let bot_info = match validate_bot_token(&state, &token) { + Some(info) => info, + None => { + return Json(serde_json::json!({ + "ok": false, + "description": "invalid token", + })) + } + }; + + 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_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); + + let msg_id = uuid::Uuid::new_v4().to_string(); + let bot_msg = serde_json::json!({ + "type": "bot_message", + "id": msg_id, + "from": bot_fp, + "from_name": bot_info["name"], + "text": req.text, + "parse_mode": req.parse_mode, + "reply_to_message_id": req.reply_to_message_id, + "reply_markup": req.reply_markup, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default(); + + let delivered = state.deliver_or_queue(&to_fp, &msg_bytes).await; + + Json(serde_json::json!({ + "ok": true, + "result": { + "message_id": msg_id, + "chat": { "id": to_fp, "type": "private" }, + "text": req.text, + "date": chrono::Utc::now().timestamp(), + "delivered": delivered, + } + })) +} + +// --------------------------------------------------------------------------- +// answerCallbackQuery +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct AnswerCallbackRequest { + callback_query_id: String, + #[serde(default)] + text: Option, + #[serde(default)] + show_alert: Option, +} + +/// `POST /bot/:token/answerCallbackQuery` -- acknowledge a callback query. +/// +/// In v1 this is a no-op acknowledgement; no popup is delivered to the client. +async fn answer_callback_query( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> Json { + if validate_bot_token(&state, &token).is_none() { + return Json(serde_json::json!({"ok": false, "description": "invalid token"})); + } + tracing::debug!( + "answerCallbackQuery id={} text={:?} alert={:?}", + req.callback_query_id, + req.text, + req.show_alert, + ); + Json(serde_json::json!({"ok": true, "result": true})) +} + +// --------------------------------------------------------------------------- +// editMessageText +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct EditMessageRequest { + chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID) + message_id: String, + text: String, + #[serde(default)] + reply_markup: Option, +} + +/// `POST /bot/:token/editMessageText` -- edit a previously sent message. +async fn edit_message_text( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> Json { + let bot_info = match validate_bot_token(&state, &token) { + Some(i) => i, + None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), + }; + let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); + let to_fp = match resolve_chat_id(&state, &req.chat_id) { + Some(fp) => fp, + None => { + return Json(serde_json::json!({"ok": false, "description": "chat_id not found"})) + } + }; + + let edit_msg = serde_json::json!({ + "type": "bot_edit", + "id": req.message_id, + "from": bot_fp, + "text": req.text, + "reply_markup": req.reply_markup, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&edit_msg).unwrap_or_default(); + state.deliver_or_queue(&to_fp, &msg_bytes).await; + + Json(serde_json::json!({ + "ok": true, + "result": { + "message_id": req.message_id, + "chat": {"id": to_fp}, + "text": req.text, + "date": chrono::Utc::now().timestamp(), + } + })) +} + +// --------------------------------------------------------------------------- +// setWebhook / deleteWebhook / getWebhookInfo +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct SetWebhookRequest { + url: String, +} + +/// `POST /bot/:token/setWebhook` -- register a webhook URL for push delivery. +async fn set_webhook( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> Json { + let mut bot_info = match validate_bot_token(&state, &token) { + Some(i) => i, + None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), + }; + bot_info["webhook_url"] = serde_json::json!(req.url); + let key = format!("bot:{}", token); + let _ = state + .db + .tokens + .insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default()); + tracing::info!("Bot webhook set: {}", req.url); + Json(serde_json::json!({"ok": true, "result": true, "description": "Webhook was set"})) +} + +/// `POST /bot/:token/deleteWebhook` -- remove a previously set webhook. +async fn delete_webhook( + State(state): State, + Path(token): Path, +) -> Json { + let mut bot_info = match validate_bot_token(&state, &token) { + Some(i) => i, + None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), + }; + bot_info.as_object_mut().map(|o| o.remove("webhook_url")); + let key = format!("bot:{}", token); + let _ = state + .db + .tokens + .insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default()); + Json(serde_json::json!({"ok": true, "result": true, "description": "Webhook was deleted"})) +} + +/// `GET /bot/:token/getWebhookInfo` -- return current webhook configuration. +async fn get_webhook_info( + State(state): State, + Path(token): Path, +) -> Json { + match validate_bot_token(&state, &token) { + Some(info) => { + let url = info + .get("webhook_url") + .and_then(|v| v.as_str()) + .unwrap_or(""); + Json(serde_json::json!({ + "ok": true, + "result": { + "url": url, + "has_custom_certificate": false, + "pending_update_count": 0, + } + })) + } + None => Json(serde_json::json!({"ok": false, "description": "invalid token"})), + } +} + +// --------------------------------------------------------------------------- +// sendDocument — accepts both JSON and multipart/form-data +// --------------------------------------------------------------------------- + +/// `POST /bot/:token/sendDocument` -- send a document reference to a user. +/// +/// Accepts both `application/json` and `multipart/form-data` content types +/// so Telegram bot libraries that upload files via multipart work out of the box. +async fn send_document_flexible( + State(state): State, + Path(token): Path, + headers: axum::http::HeaderMap, + body: axum::body::Bytes, +) -> Json { + let bot_info = match validate_bot_token(&state, &token) { + Some(i) => i, + None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), + }; + let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); + let bot_name = bot_info["name"].as_str().unwrap_or("bot"); + + let content_type = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let (chat_id_val, document, caption) = if content_type.contains("multipart") { + // Parse multipart fields from raw bytes (simplified text-field extraction). + let body_str = String::from_utf8_lossy(&body); + let mut chat_id = String::new(); + let mut doc = String::new(); + let mut cap = String::new(); + + // Split on boundary markers (lines starting with --) + for part in body_str.split("------") { + if part.contains("name=\"chat_id\"") { + if let Some(val) = part.split("\r\n\r\n").nth(1) { + chat_id = val.trim().to_string(); + } + } + if part.contains("name=\"document\"") { + if let Some(val) = part.split("\r\n\r\n").nth(1) { + doc = val.trim().to_string(); + } + } + if part.contains("name=\"caption\"") { + if let Some(val) = part.split("\r\n\r\n").nth(1) { + cap = val.trim().to_string(); + } + } + } + + ( + serde_json::Value::String(chat_id), + doc, + if cap.is_empty() { None } else { Some(cap) }, + ) + } else { + // JSON body + match serde_json::from_slice::(&body) { + Ok(json) => { + let chat_id = json + .get("chat_id") + .cloned() + .unwrap_or(serde_json::Value::String(String::new())); + let doc = json + .get("document") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let cap = json + .get("caption") + .and_then(|v| v.as_str()) + .map(String::from); + (chat_id, doc, cap) + } + Err(e) => { + return Json( + serde_json::json!({"ok": false, "description": format!("invalid body: {}", e)}), + ) + } + } + }; + + let 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, + "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(); + let delivered = state.deliver_or_queue(&to_fp, &msg_bytes).await; + + Json(serde_json::json!({ + "ok": true, + "result": { + "message_id": msg_id, + "chat": {"id": to_fp}, + "document": {"file_name": document}, + "caption": caption, + "delivered": delivered, + } + })) +} diff --git a/warzone/crates/warzone-server/src/routes/calls.rs b/warzone/crates/warzone-server/src/routes/calls.rs new file mode 100644 index 0000000..e12f5e8 --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/calls.rs @@ -0,0 +1,233 @@ +use axum::{ + extract::{Path, Query, State}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use sha2::{Sha256, Digest}; + +use crate::errors::AppResult; +use crate::state::{AppState, CallState, CallStatus}; + +pub fn routes() -> Router { + Router::new() + .route("/calls/initiate", post(initiate_call)) + .route("/calls/:id", get(get_call)) + .route("/calls/:id/end", post(end_call)) + .route("/calls/active", get(active_calls)) + .route("/calls/missed", post(get_missed_calls)) + .route("/groups/:name/call", post(initiate_group_call)) +} + +fn normalize_fp(fp: &str) -> String { + fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase() +} + +#[derive(Deserialize)] +struct InitiateRequest { + caller: String, + callee: String, +} + +async fn initiate_call( + _auth: crate::auth_middleware::AuthFingerprint, + State(state): State, + Json(req): Json, +) -> AppResult> { + let call_id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().timestamp(); + let call = CallState { + call_id: call_id.clone(), + caller_fp: normalize_fp(&req.caller), + callee_fp: normalize_fp(&req.callee), + group_name: None, + room_id: None, + status: CallStatus::Ringing, + created_at: now, + answered_at: None, + ended_at: None, + }; + state.active_calls.lock().await.insert(call_id.clone(), call.clone()); + state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?; + tracing::info!("Call initiated: {} -> {}", call.caller_fp, call.callee_fp); + Ok(Json(serde_json::json!({ + "call_id": call_id, + "status": "ringing", + }))) +} + +async fn get_call( + State(state): State, + Path(id): Path, +) -> AppResult> { + // Try in-memory first, then DB + if let Some(call) = state.active_calls.lock().await.get(&id) { + return Ok(Json(serde_json::to_value(call)?)); + } + if let Some(data) = state.db.calls.get(id.as_bytes())? { + let call: CallState = serde_json::from_slice(&data)?; + return Ok(Json(serde_json::to_value(&call)?)); + } + Ok(Json(serde_json::json!({ "error": "call not found" }))) +} + +#[derive(Deserialize)] +struct EndCallRequest { + fingerprint: String, +} + +async fn end_call( + _auth: crate::auth_middleware::AuthFingerprint, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + let now = chrono::Utc::now().timestamp(); + let _fp = normalize_fp(&req.fingerprint); + let mut calls = state.active_calls.lock().await; + if let Some(mut call) = calls.remove(&id) { + call.status = CallStatus::Ended; + call.ended_at = Some(now); + state.db.calls.insert(id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?; + return Ok(Json(serde_json::json!({ "ok": true, "call_id": id }))); + } + Ok(Json(serde_json::json!({ "error": "call not found or already ended" }))) +} + +#[derive(Deserialize)] +struct ActiveQuery { + fingerprint: Option, +} + +async fn active_calls( + State(state): State, + Query(q): Query, +) -> AppResult> { + let calls = state.active_calls.lock().await; + let filtered: Vec<&CallState> = match q.fingerprint { + Some(ref fp) => { + let fp = normalize_fp(fp); + calls.values().filter(|c| c.caller_fp == fp || c.callee_fp == fp).collect() + } + None => calls.values().collect(), + }; + Ok(Json(serde_json::json!({ "calls": filtered }))) +} + +#[derive(Deserialize)] +struct MissedRequest { + fingerprint: String, +} + +async fn get_missed_calls( + State(state): State, + Json(req): Json, +) -> AppResult> { + let fp = normalize_fp(&req.fingerprint); + let prefix = format!("missed:{}", fp); + let mut missed = Vec::new(); + let mut keys = Vec::new(); + for (key, value) in state.db.missed_calls.scan_prefix(prefix.as_bytes()).flatten() { + if let Ok(record) = serde_json::from_slice::(&value) { + missed.push(record); + keys.push(key); + } + } + // Delete after reading + for key in &keys { + let _ = state.db.missed_calls.remove(key); + } + Ok(Json(serde_json::json!({ "missed_calls": missed }))) +} + +// --- FC-5: Group call --- + +#[derive(Deserialize)] +struct GroupCallRequest { + fingerprint: String, +} + +/// Deterministic room ID from group name: hex(SHA-256("featherchat-group:" + name)[:16]) +fn hash_room_name(group_name: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(format!("featherchat-group:{}", group_name).as_bytes()); + let hash = hasher.finalize(); + hex::encode(&hash[..16]) +} + +async fn initiate_group_call( + _auth: crate::auth_middleware::AuthFingerprint, + State(state): State, + Path(name): Path, + Json(req): Json, +) -> AppResult> { + let caller_fp = normalize_fp(&req.fingerprint); + + // Load group + let group_data = match state.db.groups.get(name.as_bytes())? { + Some(d) => d, + None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), + }; + let group: serde_json::Value = serde_json::from_slice(&group_data)?; + let members: Vec = group.get("members") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + + // Verify caller is a member + if !members.contains(&caller_fp) { + return Ok(Json(serde_json::json!({ "error": "not a member of this group" }))); + } + + let room_id = hash_room_name(&name); + let call_id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().timestamp(); + + // Create call state + let call = CallState { + call_id: call_id.clone(), + caller_fp: caller_fp.clone(), + callee_fp: "group".to_string(), + group_name: Some(name.clone()), + room_id: Some(room_id.clone()), + status: CallStatus::Ringing, + created_at: now, + answered_at: None, + ended_at: None, + }; + state.active_calls.lock().await.insert(call_id.clone(), call.clone()); + state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?; + + // Fan out CallSignal::Offer to all online members (except caller) + let offer = warzone_protocol::message::WireMessage::CallSignal { + id: call_id.clone(), + sender_fingerprint: caller_fp.clone(), + signal_type: warzone_protocol::message::CallSignalType::Offer, + payload: serde_json::json!({ "room_id": room_id, "group": name }).to_string(), + target: format!("#{}", name), + }; + let encoded = bincode::serialize(&offer)?; + + let mut delivered = 0; + for member in &members { + if *member == caller_fp { continue; } + if state.push_to_client(member, &encoded).await { + delivered += 1; + } else { + // Queue for offline members + let key = format!("queue:{}:{}", member, uuid::Uuid::new_v4()); + state.db.messages.insert(key.as_bytes(), encoded.as_slice())?; + } + } + + tracing::info!("Group call #{}: room={}, caller={}, notified={}/{}", + name, room_id, caller_fp, delivered, members.len() - 1); + + Ok(Json(serde_json::json!({ + "call_id": call_id, + "room_id": room_id, + "group": name, + "members_notified": delivered, + "members_total": members.len() - 1, + }))) +} diff --git a/warzone/crates/warzone-server/src/routes/devices.rs b/warzone/crates/warzone-server/src/routes/devices.rs new file mode 100644 index 0000000..d143aa0 --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/devices.rs @@ -0,0 +1,102 @@ +use axum::{ + extract::State, + routing::{get, post}, + Json, Router, +}; + +use crate::auth_middleware::AuthFingerprint; +use crate::errors::AppResult; +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/devices", get(list_devices)) + .route("/devices/:id/kick", post(kick_device)) + .route("/devices/revoke-all", post(revoke_all)) +} + +/// List active WS connections for the authenticated user. +async fn list_devices( + auth: AuthFingerprint, + State(state): State, +) -> AppResult> { + let devices = state.list_devices(&auth.fingerprint).await; + let list: Vec = devices + .iter() + .map(|(id, connected_at)| { + serde_json::json!({ + "device_id": id, + "connected_at": connected_at, + }) + }) + .collect(); + let count = list.len(); + Ok(Json(serde_json::json!({ + "fingerprint": auth.fingerprint, + "devices": list, + "count": count, + }))) +} + +/// Kick a specific device by ID. Requires auth -- only the device owner can kick. +async fn kick_device( + auth: AuthFingerprint, + State(state): State, + axum::extract::Path(device_id): axum::extract::Path, +) -> AppResult> { + let kicked = state.kick_device(&auth.fingerprint, &device_id).await; + if kicked { + tracing::info!("Device {} kicked by {}", device_id, auth.fingerprint); + Ok(Json(serde_json::json!({ "ok": true, "kicked": device_id }))) + } else { + Ok(Json(serde_json::json!({ "error": "device not found" }))) + } +} + +/// Revoke all sessions except the current one. Panic button. +async fn revoke_all( + auth: AuthFingerprint, + State(state): State, + Json(req): Json, +) -> AppResult> { + let keep_device = req + .get("keep_device_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let removed = state + .revoke_all_except(&auth.fingerprint, keep_device) + .await; + + // Also clear all tokens for this fingerprint except the current one + // Scan tokens tree for this fingerprint + let mut tokens_to_remove = Vec::new(); + for item in state.db.tokens.iter().flatten() { + if let Ok(val) = serde_json::from_slice::(&item.1) { + if val.get("fingerprint").and_then(|v| v.as_str()) == Some(&auth.fingerprint) { + tokens_to_remove.push(item.0.clone()); + } + } + } + // Only remove tokens if we actually revoked devices + let tokens_cleared = if removed > 0 { + let count = tokens_to_remove.len(); + for key in &tokens_to_remove { + let _ = state.db.tokens.remove(key); + } + count + } else { + 0 + }; + + tracing::info!( + "Revoke-all for {}: {} devices removed, {} tokens cleared", + auth.fingerprint, + removed, + tokens_cleared, + ); + Ok(Json(serde_json::json!({ + "ok": true, + "devices_removed": removed, + "tokens_cleared": tokens_cleared, + }))) +} diff --git a/warzone/crates/warzone-server/src/routes/federation.rs b/warzone/crates/warzone-server/src/routes/federation.rs new file mode 100644 index 0000000..a9b34eb --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/federation.rs @@ -0,0 +1,161 @@ +//! Federation route handlers: WS endpoint for peer servers + status. + +use axum::{ + extract::{State, WebSocketUpgrade, ws::{Message, WebSocket}}, + response::IntoResponse, + routing::get, + Json, Router, +}; +use futures_util::{SinkExt, StreamExt}; + +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/federation/ws", get(federation_ws_handler)) + .route("/federation/status", get(federation_status)) +} + +/// WebSocket endpoint for incoming peer server connections. +async fn federation_ws_handler( + ws: WebSocketUpgrade, + State(state): State, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_peer_ws(socket, state)) +} + +/// Handle an incoming federation WS connection from the peer server. +async fn handle_peer_ws(socket: WebSocket, state: AppState) { + let (mut ws_tx, mut ws_rx) = socket.split(); + + // First message must be auth + let secret = match state.federation { + Some(ref f) => f.config.shared_secret.clone(), + None => { + tracing::warn!("Federation: WS connection rejected -- federation not configured"); + return; + } + }; + + // Wait for auth message (5 second timeout) + let auth_msg = tokio::time::timeout( + std::time::Duration::from_secs(5), + ws_rx.next(), + ).await; + + let peer_id = match auth_msg { + Ok(Some(Ok(Message::Text(text)))) => { + if let Ok(parsed) = serde_json::from_str::(&text) { + let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let msg_secret = parsed.get("secret").and_then(|v| v.as_str()).unwrap_or(""); + let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("unknown"); + + if msg_type != "auth" || msg_secret != secret { + tracing::warn!("Federation: WS auth failed from {}", server_id); + return; + } + tracing::info!("Federation: peer {} authenticated via WS", server_id); + server_id.to_string() + } else { + tracing::warn!("Federation: invalid auth JSON"); + return; + } + } + _ => { + tracing::warn!("Federation: no auth message received within timeout"); + return; + } + }; + + // Process incoming messages from the authenticated peer + while let Some(Ok(msg)) = ws_rx.next().await { + if let Message::Text(text) = msg { + let parsed: serde_json::Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + + let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + match msg_type { + "presence" => { + let fingerprints: Vec = parsed.get("fingerprints") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + let count = fingerprints.len(); + + if let Some(ref federation) = state.federation { + let mut rp = federation.remote_presence.lock().await; + rp.fingerprints = fingerprints.into_iter().collect(); + rp.last_updated = chrono::Utc::now().timestamp(); + rp.connected = true; + } + tracing::debug!("Federation WS: {} announced {} fingerprints", peer_id, count); + + // Send our presence back + if let Some(ref federation) = state.federation { + let fps: Vec = { + let conns = state.connections.lock().await; + conns.keys().cloned().collect() + }; + let reply = serde_json::json!({ + "type": "presence", + "server_id": federation.config.server_id, + "fingerprints": fps, + }); + let _ = ws_tx.send(Message::Text(serde_json::to_string(&reply).unwrap_or_default())).await; + } + } + "forward" => { + let to = parsed.get("to").and_then(|v| v.as_str()).unwrap_or(""); + let message_b64 = parsed.get("message").and_then(|v| v.as_str()).unwrap_or(""); + let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("?"); + + if let Ok(message) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message_b64) { + let delivered = state.push_to_client(to, &message).await; + if !delivered { + let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4()); + let _ = state.db.messages.insert(key.as_bytes(), message.as_slice()); + tracing::info!("Federation WS: queued from {} for offline {}", from_server, to); + } else { + tracing::debug!("Federation WS: delivered from {} to {}", from_server, to); + } + } + } + _ => {} + } + } + } + + // Peer disconnected + if let Some(ref federation) = state.federation { + let mut rp = federation.remote_presence.lock().await; + rp.connected = false; + rp.fingerprints.clear(); + } + tracing::info!("Federation WS: peer {} disconnected", peer_id); +} + +/// Federation health status. +async fn federation_status( + State(state): State, +) -> Json { + match state.federation { + Some(ref federation) => { + let rp = federation.remote_presence.lock().await; + Json(serde_json::json!({ + "enabled": true, + "server_id": federation.config.server_id, + "peer_id": federation.config.peer.id, + "peer_url": federation.config.peer.url, + "peer_connected": rp.connected, + "remote_clients": rp.fingerprints.len(), + "last_sync": rp.last_updated, + })) + } + None => { + Json(serde_json::json!({ "enabled": false })) + } + } +} diff --git a/warzone/crates/warzone-server/src/routes/friends.rs b/warzone/crates/warzone-server/src/routes/friends.rs new file mode 100644 index 0000000..dbb158b --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/friends.rs @@ -0,0 +1,54 @@ +use axum::{ + extract::State, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; + +use crate::auth_middleware::AuthFingerprint; +use crate::errors::AppResult; +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/friends", get(get_friends)) + .route("/friends", post(save_friends)) +} + +/// Get the encrypted friend list blob for the authenticated user. +async fn get_friends( + auth: AuthFingerprint, + State(state): State, +) -> AppResult> { + match state.db.friends.get(auth.fingerprint.as_bytes())? { + Some(data) => { + let blob = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data); + Ok(Json(serde_json::json!({ + "fingerprint": auth.fingerprint, + "data": blob, + }))) + } + None => Ok(Json(serde_json::json!({ + "fingerprint": auth.fingerprint, + "data": null, + }))), + } +} + +#[derive(Deserialize)] +struct SaveFriendsRequest { + data: String, // base64-encoded encrypted blob +} + +/// Save the encrypted friend list blob. +async fn save_friends( + auth: AuthFingerprint, + State(state): State, + Json(req): Json, +) -> AppResult> { + let blob = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &req.data) + .map_err(|e| anyhow::anyhow!("invalid base64: {}", e))?; + state.db.friends.insert(auth.fingerprint.as_bytes(), blob)?; + tracing::info!("Saved friend list for {} ({} bytes)", auth.fingerprint, req.data.len()); + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/warzone/crates/warzone-server/src/routes/groups.rs b/warzone/crates/warzone-server/src/routes/groups.rs index 73ceef5..036728a 100644 --- a/warzone/crates/warzone-server/src/routes/groups.rs +++ b/warzone/crates/warzone-server/src/routes/groups.rs @@ -169,6 +169,7 @@ async fn list_groups( /// queue infrastructure — group messages look like 1:1 messages to the /// recipient, but with a group tag. async fn send_to_group( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Path(name): Path, Json(req): Json, @@ -210,6 +211,7 @@ async fn send_to_group( } async fn leave_group( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Path(name): Path, Json(req): Json, @@ -235,6 +237,7 @@ struct KickRequest { } async fn kick_member( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Path(name): Path, Json(req): Json, @@ -276,16 +279,22 @@ async fn get_members( None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), }; - // Resolve aliases for each member + // Resolve aliases and online status for each member let mut members_info: Vec = Vec::new(); + let mut online_count: usize = 0; for fp in &group.members { let alias = state.db.aliases.get(format!("fp:{}", fp).as_bytes()) .ok().flatten() .map(|v| String::from_utf8_lossy(&v).to_string()); + let online = state.is_online(fp).await; + if online { + online_count += 1; + } members_info.push(serde_json::json!({ "fingerprint": fp, "alias": alias, "is_creator": *fp == group.creator, + "online": online, })); } @@ -293,5 +302,6 @@ async fn get_members( "name": group.name, "members": members_info, "count": members_info.len(), + "online_count": online_count, }))) } diff --git a/warzone/crates/warzone-server/src/routes/keys.rs b/warzone/crates/warzone-server/src/routes/keys.rs index 87b5ebb..8a778fb 100644 --- a/warzone/crates/warzone-server/src/routes/keys.rs +++ b/warzone/crates/warzone-server/src/routes/keys.rs @@ -46,6 +46,8 @@ struct RegisterRequest { #[serde(default)] device_id: Option, bundle: Vec, + #[serde(default)] + eth_address: Option, } #[derive(Serialize)] @@ -54,6 +56,7 @@ struct RegisterResponse { } async fn register_keys( + State(state): State, Json(req): Json, ) -> Json { @@ -67,6 +70,16 @@ async fn register_keys( let device_key = format!("device:{}:{}", fp, device_id); let _ = state.db.keys.insert(device_key.as_bytes(), req.bundle); + // Store ETH address mapping if provided + if let Some(ref eth) = req.eth_address { + let eth_lower = eth.to_lowercase(); + // eth -> fp + let _ = state.db.eth_addresses.insert(eth_lower.as_bytes(), fp.as_bytes()); + // fp -> eth (reverse lookup) + let _ = state.db.eth_addresses.insert(format!("rev:{}", fp).as_bytes(), eth_lower.as_bytes()); + tracing::info!("ETH address mapped: {} -> {}", eth_lower, fp); + } + tracing::info!("Registered bundle for {} (device: {})", fp, device_id); Json(RegisterResponse { ok: true }) } @@ -84,9 +97,26 @@ async fn get_bundle( .collect(); tracing::info!("get_bundle: DB contains {} keys: {:?}", all_keys.len(), all_keys); + // Check if this fingerprint registered locally (has a device: entry) + let device_prefix = format!("device:{}:", key); + let is_local = state.db.keys.scan_prefix(device_prefix.as_bytes()).next().is_some(); + + // For remote clients, always proxy from the federation peer (bundles may change) + if !is_local { + if let Some(ref federation) = state.federation { + if let Some(bundle_bytes) = federation.fetch_remote_bundle(&key).await { + tracing::info!("get_bundle: PROXIED from federation peer for {}", key); + return Ok(Json(serde_json::json!({ + "fingerprint": fingerprint, + "bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bundle_bytes), + }))); + } + } + } + match state.db.keys.get(key.as_bytes()) { Ok(Some(data)) => { - tracing::info!("get_bundle: FOUND {} bytes for {}", data.len(), key); + tracing::info!("get_bundle: FOUND {} bytes for {} (local={})", data.len(), key, is_local); Ok(Json(serde_json::json!({ "fingerprint": fingerprint, "bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data), @@ -129,6 +159,7 @@ struct OtpkEntry { /// Upload additional one-time pre-keys. async fn replenish_otpks( + State(state): State, Json(req): Json, ) -> Json { diff --git a/warzone/crates/warzone-server/src/routes/messages.rs b/warzone/crates/warzone-server/src/routes/messages.rs index f9b6d57..21f82b8 100644 --- a/warzone/crates/warzone-server/src/routes/messages.rs +++ b/warzone/crates/warzone-server/src/routes/messages.rs @@ -9,9 +9,9 @@ use warzone_protocol::message::WireMessage; use crate::errors::AppResult; use crate::state::AppState; -/// Try to extract the message ID from raw bincode-serialized WireMessage bytes. +/// Try to extract the message ID from raw WireMessage bytes (envelope or legacy). fn extract_message_id(data: &[u8]) -> Option { - if let Ok(wire) = bincode::deserialize::(data) { + if let Ok(wire) = warzone_protocol::message::deserialize_envelope(data) { match wire { WireMessage::KeyExchange { id, .. } => Some(id), WireMessage::Message { id, .. } => Some(id), @@ -71,6 +71,7 @@ fn normalize_fp(fp: &str) -> String { } async fn send_message( + State(state): State, Json(req): Json, ) -> AppResult> { @@ -84,14 +85,11 @@ async fn send_message( } } - // Try WebSocket push first (instant delivery) - if state.push_to_client(&to, &req.message).await { - tracing::info!("Pushed message to {} via WS ({} bytes)", to, req.message.len()); + let delivered = state.deliver_or_queue(&to, &req.message).await; + if delivered { + tracing::info!("Delivered message to {} ({} bytes)", to, req.message.len()); } else { - // Queue in DB (offline delivery) - let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4()); - tracing::info!("Queuing message for {} ({} bytes)", to, req.message.len()); - state.db.messages.insert(key.as_bytes(), req.message)?; + tracing::info!("Queued message for {} ({} bytes)", to, req.message.len()); } // Renew sender's alias TTL (sending = authenticated action) diff --git a/warzone/crates/warzone-server/src/routes/mod.rs b/warzone/crates/warzone-server/src/routes/mod.rs index 3a8ddbf..56e5df8 100644 --- a/warzone/crates/warzone-server/src/routes/mod.rs +++ b/warzone/crates/warzone-server/src/routes/mod.rs @@ -1,11 +1,19 @@ mod aliases; pub mod auth; +pub mod bot; +mod calls; +mod devices; +mod federation; +mod friends; mod groups; mod health; mod keys; pub mod messages; +mod presence; +mod resolve; mod web; mod ws; +mod wzp; use axum::Router; @@ -20,6 +28,14 @@ pub fn router() -> Router { .merge(aliases::routes()) .merge(auth::routes()) .merge(ws::routes()) + .merge(calls::routes()) + .merge(devices::routes()) + .merge(presence::routes()) + .merge(wzp::routes()) + .merge(friends::routes()) + .merge(federation::routes()) + .merge(bot::routes()) + .merge(resolve::routes()) } /// Web UI router (served at root, outside /v1) diff --git a/warzone/crates/warzone-server/src/routes/presence.rs b/warzone/crates/warzone-server/src/routes/presence.rs new file mode 100644 index 0000000..4ac717d --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/presence.rs @@ -0,0 +1,57 @@ +use axum::{ + extract::{Path, State}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; + +use crate::errors::AppResult; +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/presence/:fingerprint", get(get_presence)) + .route("/presence/batch", post(batch_presence)) +} + +fn normalize_fp(fp: &str) -> String { + fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase() +} + +async fn get_presence( + State(state): State, + Path(fingerprint): Path, +) -> AppResult> { + let fp = normalize_fp(&fingerprint); + let online = state.is_online(&fp).await; + let devices = state.device_count(&fp).await; + Ok(Json(serde_json::json!({ + "fingerprint": fp, + "online": online, + "devices": devices, + }))) +} + +#[derive(Deserialize)] +struct BatchRequest { + fingerprints: Vec, +} + +async fn batch_presence( + _auth: crate::auth_middleware::AuthFingerprint, + State(state): State, + Json(req): Json, +) -> AppResult> { + let mut results = Vec::new(); + for fp in &req.fingerprints { + let fp = normalize_fp(fp); + let online = state.is_online(&fp).await; + let devices = state.device_count(&fp).await; + results.push(serde_json::json!({ + "fingerprint": fp, + "online": online, + "devices": devices, + })); + } + Ok(Json(serde_json::json!({ "results": results }))) +} diff --git a/warzone/crates/warzone-server/src/routes/resolve.rs b/warzone/crates/warzone-server/src/routes/resolve.rs new file mode 100644 index 0000000..dcf8f50 --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/resolve.rs @@ -0,0 +1,136 @@ +use axum::{ + extract::{Path, State}, + routing::get, + Json, Router, +}; + +use crate::errors::AppResult; +use crate::state::AppState; + +/// Convert a fingerprint to a per-bot unique numeric ID. +/// Hash(bot_token + user_fp) → i64. Different bots see different IDs for the same user. +/// This prevents cross-bot user correlation (same privacy model as Telegram). +pub fn fp_to_numeric_id_for_bot(fp: &str, bot_token: &str) -> i64 { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(bot_token.as_bytes()); + hasher.update(b":"); + hasher.update(fp.as_bytes()); + let hash = hasher.finalize(); + let mut arr = [0u8; 8]; + arr.copy_from_slice(&hash[..8]); + i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF // ensure positive +} + +/// Convert a fingerprint hex string to a stable i64 ID (non-bot contexts). +/// Uses first 8 bytes of the fingerprint as a positive i64. +pub fn fp_to_numeric_id(fp: &str) -> i64 { + let clean: String = fp.chars().filter(|c| c.is_ascii_hexdigit()).take(16).collect(); + let bytes = hex::decode(&clean).unwrap_or_default(); + if bytes.len() >= 8 { + let mut arr = [0u8; 8]; + arr.copy_from_slice(&bytes[..8]); + i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF // ensure positive + } else { + 0 + } +} + +pub fn routes() -> Router { + Router::new().route("/resolve/:address", get(resolve_address)) +} + +/// Resolve an address to a fingerprint. +/// +/// Accepts: ETH address (`0x...`), alias (`@name`), or raw fingerprint. +async fn resolve_address( + State(state): State, + Path(address): Path, +) -> AppResult> { + let addr = address.trim().to_lowercase(); + + // ETH address: 0x... + if addr.starts_with("0x") { + if let Some(fp_bytes) = state.db.eth_addresses.get(addr.as_bytes())? { + let fp = String::from_utf8_lossy(&fp_bytes).to_string(); + return Ok(Json(serde_json::json!({ + "address": address, + "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), + "type": "eth", + }))); + } + // Try federation + if let Some(ref federation) = state.federation { + let url = format!("{}/v1/resolve/{}", federation.config.peer.url, addr); + if let Ok(resp) = federation.client.get(&url).send().await { + if resp.status().is_success() { + if let Ok(data) = resp.json::().await { + if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) { + return Ok(Json(serde_json::json!({ + "address": address, + "fingerprint": fp, + "numeric_id": fp_to_numeric_id(fp), + "type": "eth", + "federated": true, + }))); + } + } + } + } + } + return Ok(Json(serde_json::json!({ "error": "address not found" }))); + } + + // Alias: @name + if addr.starts_with('@') { + let alias = &addr[1..]; + // Try local alias resolution + let alias_key = format!("a:{}", alias); + if let Some(fp_bytes) = state.db.aliases.get(alias_key.as_bytes())? { + let fp = String::from_utf8_lossy(&fp_bytes).to_string(); + return Ok(Json(serde_json::json!({ + "address": address, + "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), + "type": "alias", + }))); + } + // Try federation + if let Some(ref federation) = state.federation { + if let Some(fp) = federation.resolve_remote_alias(alias).await { + return Ok(Json(serde_json::json!({ + "address": address, + "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), + "type": "alias", + "federated": true, + }))); + } + } + return Ok(Json(serde_json::json!({ "error": "alias not found" }))); + } + + // Raw fingerprint: just echo back with optional reverse ETH lookup + let fp = addr + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::(); + if fp.len() == 32 { + let rev_key = format!("rev:{}", fp); + let eth = state + .db + .eth_addresses + .get(rev_key.as_bytes())? + .map(|v| String::from_utf8_lossy(&v).to_string()); + return Ok(Json(serde_json::json!({ + "address": address, + "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), + "eth_address": eth, + "type": "fingerprint", + }))); + } + + Ok(Json(serde_json::json!({ "error": "unrecognized address format" }))) +} diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 8f2894b..2742672 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v1'; +const CACHE = 'wz-v26'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -71,18 +71,17 @@ self.addEventListener('fetch', e => { if (url.pathname.startsWith('/v1/')) return; // WS: skip if (url.protocol === 'ws:' || url.protocol === 'wss:') return; - // Shell: cache first, network fallback + // Network first, cache fallback (ensures updates are picked up immediately) e.respondWith( - caches.match(e.request).then(cached => cached || fetch(e.request).then(resp => { + fetch(e.request).then(resp => { if (resp.ok && SHELL.includes(url.pathname)) { const clone = resp.clone(); caches.open(CACHE).then(c => c.put(e.request, clone)); } return resp; - }).catch(() => { - if (e.request.mode === 'navigate') { - return caches.match('/'); - } + }).catch(() => caches.match(e.request).then(cached => { + if (cached) return cached; + if (e.request.mode === 'navigate') return caches.match('/'); })) ); }); @@ -151,15 +150,31 @@ const WEB_HTML: &str = r##" #chat-header .tag { padding: 1px 6px; border-radius: 3px; font-size: 0.85em; } .tag-fp { background: #0a2e0a; color: #4ade80; } .tag-peer { background: #2e2e0a; color: #e6a23c; } - .tag-server { color: #444; } + .tag-server { color: #666; font-size: 0.8em; } #chat-header input { background: #1a1a2e; border: 1px solid #333; color: #e6a23c; padding: 2px 6px; border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; } - #messages { flex: 1; overflow-y: auto; padding: 8px 10px; -webkit-overflow-scrolling: touch; } + #messages { flex: 1; overflow-y: scroll; padding: 8px 10px; -webkit-overflow-scrolling: touch; min-height: 0; max-height: calc(100dvh - 100px); } + #messages::-webkit-scrollbar { width: 8px; } + #messages::-webkit-scrollbar-track { background: #0a0a1a; } + #messages::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; } + #messages::-webkit-scrollbar-thumb:hover { background: #555; } .msg { padding: 2px 0; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; } + .msg code { background: #1a1a3e; padding: 1px 4px; border-radius: 3px; font-size: 0.95em; color: #4fc3f7; } + .msg pre { background: #0d0d20; padding: 8px; border-radius: 4px; margin: 4px 0; overflow-x: auto; border: 1px solid #222; } + .msg pre code { background: none; padding: 0; } + .msg strong, .msg b { color: #fff; } + .msg em, .msg i { color: #e6a23c; } + .msg a { color: #4fc3f7; } + .msg blockquote { border-left: 3px solid #444; padding-left: 8px; color: #888; margin: 4px 0; } + .msg ul, .msg ol { padding-left: 20px; margin: 4px 0; } + .msg h1, .msg h2, .msg h3 { color: #fff; margin: 6px 0 2px; } + .msg h1 { font-size: 1.2em; } .msg h2 { font-size: 1.1em; } .msg h3 { font-size: 1em; } .msg .ts { color: #333; margin-right: 4px; } .msg .from-self { color: #4ade80; font-weight: bold; } .msg .from-sys { color: #5e9ca0; font-style: italic; } + .identity-code { user-select: all; cursor: pointer; background: #1a1a3e; padding: 2px 6px; border-radius: 3px; color: #4ade80; font-family: monospace; } + .identity-code:hover { background: #252550; } .msg .lock { color: #ff6b9d; } #bottom { display: flex; padding: 6px; gap: 6px; border-top: 1px solid #222; background: #111; @@ -171,6 +186,30 @@ const WEB_HTML: &str = r##" cursor: pointer; font-size: 14px; min-height: 40px; } #send-btn:hover { background: #c73e54; } + .addr { color: #4fc3f7; cursor: pointer; text-decoration: underline; } + .addr:hover { color: #81d4fa; } + + /* Call UI */ + #call-bar { display: none; padding: 6px 10px; background: #1a0a2e; border-bottom: 1px solid #4a1a5c; + align-items: center; gap: 8px; font-size: 0.85em; } + #call-bar.active { display: flex; } + #call-bar .call-status { flex: 1; color: #ce93d8; } + .call-btn { padding: 4px 12px; border: none; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 0.8em; } + .call-btn-green { background: #2d5016; color: #4ade80; } + .call-btn-green:hover { background: #3d6020; } + .call-btn-red { background: #5c1a1a; color: #ff6b6b; } + .call-btn-red:hover { background: #6c2a2a; } + .call-btn-blue { background: #1a1a5c; color: #4fc3f7; } + .call-btn-blue:hover { background: #2a2a6c; } + .incoming-call { animation: pulse 1.5s infinite; } + @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } + + .inline-kbd { margin: 4px 0; display: flex; flex-wrap: wrap; gap: 4px; } + .inline-kbd-row { display: flex; gap: 4px; width: 100%; } + .kbd-btn { padding: 4px 12px; background: #1a1a3e; border: 1px solid #333; border-radius: 4px; + color: #4fc3f7; cursor: pointer; font-family: inherit; font-size: 0.8em; flex: 1; text-align: center; } + .kbd-btn:hover { background: #252550; border-color: #4fc3f7; } + @media (max-width: 500px) { .msg { font-size: 0.8em; } #chat-header input { width: 180px; } @@ -206,11 +245,19 @@ const WEB_HTML: &str = r##"
- + + - +
+
+ No active call + + + + +
@@ -220,24 +267,28 @@ const WEB_HTML: &str = r##"