40 Commits

Author SHA1 Message Date
Siavash Sameni
7c248442c2 feat: /testcall /testecho /testmic — local audio diagnostics
- /testcall: plays 3 tones (440/880/660Hz) to test speaker
- /testecho: mic → speaker loopback with 100ms delay (/stopecho to end)
- /testmic: records 3 seconds, plays back (tests both mic + speaker)

No relay or peer needed — pure local browser audio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:38:26 +04:00
Siavash Sameni
5ae87be316 fix: remove --auth-url from wzp-relay (wzp-web doesn't send auth to relay)
wzp-web connects to relay via QUIC and does crypto handshake directly,
but relay with --auth-url expects AuthToken first → handshake fails.
Auth at relay level will be re-added when wzp-web learns to forward tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:29:50 +04:00
Siavash Sameni
f698b25fad fix: remove --auth-url from wzp-web (variants handle auth differently)
The 'full' variant sends a key exchange as first WS message, not auth.
The 'pure' variant sends raw PCM immediately, no auth.
Only ws/ws-fec/ws-full variants send auth JSON.

With auth removed, wzp-web accepts all WS connections. Auth is
still enforced on the relay (--auth-url) for direct connections.
Caddy provides access control at the TLS layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:19:43 +04:00
Siavash Sameni
7924871559 fix: set __WZP_BASE_URL before loading variant scripts (WASM path resolution)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:13:03 +04:00
Siavash Sameni
8a4f0ef8ee v0.0.47: integrate 6 WZP audio variants into featherChat calls
startAudio() now dynamically loads the selected WZP client variant:
- /audio-variant [pure|hybrid|full|ws|ws-fec|ws-full]
- Loads variant JS from wzp-web's /audio/js/ path via Caddy
- Falls back to inline pure implementation if variant fails to load
- Variant persisted in localStorage across sessions
- Call bar shows active variant: "In call [ws-fec] with 0x..."

Variants:
  pure     — raw PCM over WS (bridge needed, no WASM)
  hybrid   — raw PCM + WASM FEC over WS (bridge needed)
  full     — WebTransport + FEC + crypto (no bridge, future)
  ws       — WZP protocol over WS (relay direct)
  ws-fec   — WZP + WASM FEC over WS (relay direct)
  ws-full  — WZP + FEC + E2E crypto over WS (relay direct)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:02:43 +04:00
Siavash Sameni
561f2d6978 feat: variant testing — 6 subdomains + Caddy wildcard cert
- v1-v6.voip.manko.yoga → each maps to a WZP client variant
- Caddyfile.test: wildcard *.voip.manko.yoga with CF DNS cert
- scripts/test-variants.sh: --setup creates DNS + swaps Caddyfile
- --teardown cleans up DNS + restores original
- --check verifies all 6 respond HTTP 200
- All variants join same room for cross-variant audio testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:01:38 +04:00
Siavash Sameni
da3cdd7234 feat: integrate wzp-web-variants, remove --tls from wzp-web
- wzp-web runs plain HTTP behind Caddy (no --tls)
- deploy-chat.sh clones feature/wzp-web-variants for warzone-phone
- Three audio variants: ?variant=pure|hybrid|full
- Auth kept on both wzp-relay and wzp-web

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:07:21 +04:00
Siavash Sameni
cc76004655 fix: use public HTTPS git URLs, remove SSH key upload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:53:25 +04:00
Siavash Sameni
9af5ec96b5 feat: deploy-chat.sh — full Hetzner deploy for chat.manko.yoga
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:51:49 +04:00
Siavash Sameni
02471b28ba feat: start-voip.sh — update DNS locally + start Docker stack
- Removed dns-updater Docker sidecar (curl not available in alpine)
- scripts/start-voip.sh: updates DNS then docker compose up
- update-dns.sh: supports --once flag, runs locally with curl
- All CF API calls forced to IPv4 (-4 flag)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:39:23 +04:00
Siavash Sameni
74af18463e fix: install curl in dns-updater container
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:36:42 +04:00
Siavash Sameni
b22200e3be feat: dynamic DNS updater sidecar (auto-updates A + AAAA every 5min)
- update-dns.sh: detects public IPv4/IPv6, upserts CF records
- Runs on container start + every 5 minutes
- Only updates if IP actually changed (skips if unchanged)
- python:3-alpine container with curl

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:34:05 +04:00
Siavash Sameni
850944944d revert: Caddy back to bridge network (host mode breaks OrbStack)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:26:12 +04:00
Siavash Sameni
47030a3b29 fix: Caddy host network mode for real client IPs
- Caddy now uses network_mode: host (sees real IPv4/IPv6)
- All backend services on fixed IPs (172.28.0.10/20/30)
- Caddyfile uses IPs instead of Docker DNS names
- /myip now returns actual client IP, not Docker gateway

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:19:23 +04:00
Siavash Sameni
cac812665c fix: Caddyfile adds X-Real-IP header + trusted_proxies config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:12:57 +04:00
Siavash Sameni
f272a82faf feat: /myip command + /v1/whoami endpoint
Server:
- GET /v1/whoami returns client IP, IPv4/IPv6 classification
- Detects proxy via X-Forwarded-For, X-Real-IP, Via headers
- Shows proxy details when behind reverse proxy (Caddy etc)
- ConnectInfo enabled for direct socket address

Web client:
- /myip, /whatsmyip, /ip — shows your IP + proxy info
- Useful for testing IPv4/IPv6 connectivity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:11:06 +04:00
Siavash Sameni
11133cf968 fix: use fixed IP for wzp-relay (wzp-web can't resolve hostnames)
wzp-web --relay only accepts IP:port, not Docker hostnames.
Fixed IP 172.28.0.10 on backend network with explicit subnet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:03:43 +04:00
Siavash Sameni
8b00144b2f fix: force IPv4 in Caddy build (Docker lacks IPv6 during build)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:56:53 +04:00
Siavash Sameni
bf9594f1de fix: use debian:trixie-slim runtime (match rust:latest glibc)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:54:44 +04:00
Siavash Sameni
366ab30988 fix: install cmake in wzp Docker build (opus codec dependency)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:41:57 +04:00
Siavash Sameni
fb29eb0fce fix: build WASM before server (include_str! needs wasm-pkg)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:31:31 +04:00
Siavash Sameni
33c39c6541 deploy: add deploy-voip.sh + fix Rust version (use latest)
- scripts/deploy-voip.sh: full Hetzner cx23 + Docker + CF DNS deploy
  --create: provision VPS, install Docker
  --dns: update CF A + AAAA records
  --deploy: upload source, docker compose up
  --test: 6 HTTP checks + TLS + IPv6
  --all: end-to-end in one command
- Dockerfiles: use rust:latest (time crate needs 1.88+)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:26:46 +04:00
Siavash Sameni
3d387e5821 fix: copy warzone-protocol into wzp build (deps/featherchat path)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:21:40 +04:00
Siavash Sameni
38f992c284 fix: bump Docker Rust to 1.85 (edition 2024 support for wzp-proto)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:19:50 +04:00
Siavash Sameni
59d68b2a5e fix: build Caddy with CF plugin from source (ARM64 compat)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:14:33 +04:00
Siavash Sameni
f33ac1cad8 deploy: Docker Compose stack with Caddy + Cloudflare TLS
Full production stack via docker compose:
- Caddy reverse proxy with Cloudflare DNS-01 TLS certs
- warzone-server (featherChat API + web UI)
- wzp-relay (QUIC audio SFU)
- wzp-web (browser WS ↔ QUIC bridge)

Architecture:
  Internet → Caddy (443/TLS) → voip.manko.yoga
    /*       → warzone-server:7700
    /audio/* → wzp-web:8080

Files:
- docker-compose.yml: main stack (4 services)
- docker-compose.ipv6.yml: IPv6 overlay
- Caddyfile: Cloudflare DNS challenge + reverse proxy
- Dockerfile.server: featherChat multi-stage build
- Dockerfile.wzp: wzp-relay + wzp-web multi-stage build
- .env.example: DNS records for dev/staging/prod
- test-stack.sh: smoke test (8 checks)
- .dockerignore: excludes target/, .git/, etc.

Deployment targets:
  dev:  172.16.81.135
  ipv6: 2a0d:3344:692c:2500:14f2:5885:d73c:b0a1
  prod: 63.250.54.239 / 2602:ff16:9:0:1:3d9:0:1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:00:47 +04:00
Siavash Sameni
c2be68ca20 docs: comprehensive update all docs to v0.0.46
11 files updated to reflect current state (v0.0.22 → v0.0.46):

ARCHITECTURE.md:
- Ring tones, group calls, read receipts, markdown rendering sections
- Bot API expanded (BotFather, numeric IDs, Telegram compat)
- Admin commands, known issues, 155 tests

TASK_PLAN.md:
- All P1-P4 marked DONE with version numbers
- Additional completed work section (bots, ETH, ring tones, group calls)
- New FC-P7 (Voice & Transport): cpal, Sender Keys, WebTransport
- FC-P6-T9/T10 added

PROGRESS.md:
- Full version history table v0.0.22 through v0.0.46
- Known issues section

README.md:
- Voice calls, ring tones, group calls, read receipts, markdown, 155 tests

SECURITY.md:
- Bot API security, voice call security, admin commands sections
- Updated protection tables

USAGE.md:
- Group calls, read receipts, markdown formatting, admin commands

CLIENT.md:
- Call commands, read receipts, markdown rendering

LLM_HELP.md + LLM_BOT_DEV.md:
- Call/group call/admin commands, ring tones, per-bot numeric IDs

TESTING_E2E.md:
- Tests 16-18: ring tones, group calls, admin commands

CLAUDE.md:
- Ring tone notes, group signal endpoint, MLS roadmap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:47:13 +04:00
Siavash Sameni
d7b75a6641 roadmap: add MLS (RFC 9420) for E2E group call encryption
- FC-P5-T5 added to task plan with full design notes
- OpenMLS/TreeKEM approach: O(log n) key rotation, forward secrecy
- Current group calls marked as transport-encrypted only (QUIC)
- UI warning shown when starting group call
- Updated completed task statuses (read receipts, wrapping, tab complete)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:33:20 +04:00
Siavash Sameni
93923676a8 v0.0.46: fix group calls, admin commands, ETH in members
Server:
- POST /v1/groups/:name/signal — broadcast plaintext JSON to all
  online group members via WS push (for call signals, typing, etc.)
- No auth required (membership validated from 'from' field)

Web client:
- Group call signals now use /signal endpoint (was broken with /send)
- WS handler detects group_call JSON signals early
- Auto-join #ops now properly registers presence + fetches members
- /gmembers resolves ETH addresses via /v1/resolve
- /admin-calls — list all active calls in the system
- /admin-help — list all admin/debug commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:31:00 +04:00
Siavash Sameni
2612d46f5c v0.0.45: call ring tones + group calls
Direct call ringing:
- Incoming: oscillator ring tone (440/480Hz, on/off pattern)
- Outgoing: ringback tone (2s on, 4s off) while waiting for answer
- Browser Notification API for background incoming calls
- All tones stop on answer/reject/hangup
- Notification permission requested on first click

Group calls:
- /gcall — start a group call (notifies all group members)
- /gjoin — join an active group call
- /gleave-call — leave group call
- /gmute — toggle per-group call notification mute (localStorage)
- Participant tracking (joined/left messages with count)
- Group call signal via group message broadcast (JSON type: group_call)
- Call bar shows "Join Call" button when group call is active
- Audio via same WZP bridge (room = gc-<groupname>)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:12:25 +04:00
Siavash Sameni
983afc5916 Merge feature/wzp-call-infrastructure: v0.0.22→v0.0.44
50 commits: call infrastructure, bot API, federation, web/TUI polish.

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

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

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

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

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

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

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

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

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

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

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

155 tests passing (was 135)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:39:47 +04:00
Siavash Sameni
c37bd7934c v0.0.39: contacts online, message wrap, tab complete, multipart, OTPK
FC-P2-T6: /contacts shows online status (● online, ○ offline)
FC-P6-T6: Long messages word-wrap into multiple lines with aligned indent
FC-P6-T7: Tab completion for 33 slash commands (4 new tests)
FC-P8-T6: sendDocument accepts both JSON and multipart form data
OTPK: Auto-replenish on TUI startup when supply < 3 (generates 10 new)

135 tests passing (was 127)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:22:42 +04:00
49 changed files with 4223 additions and 346 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
**/target
**/node_modules
**/.git
**/.DS_Store
**/.claude
**/wasm-pkg
apache
nginx
nginx.txt
chat.py
tunnel.py
DESIGN.md

View File

@@ -14,7 +14,7 @@ Never commit functional changes without bumping all four. The service worker cac
1. **Single seed, multiple identities** — Ed25519 (messaging), X25519 (encryption), secp256k1 (ETH address) all derived from one BIP39 seed via HKDF with domain-separated info strings. 1. **Single seed, multiple identities** — Ed25519 (messaging), X25519 (encryption), secp256k1 (ETH address) all derived from one BIP39 seed via HKDF with domain-separated info strings.
2. **E2E by default** — All user messages are Double Ratchet encrypted. The server NEVER sees plaintext. Friend lists are client-side encrypted. Only bot messages are plaintext (v1). 2. **E2E by default** — All user messages are Double Ratchet encrypted. The server NEVER sees plaintext. Friend lists are client-side encrypted. Only bot messages are plaintext (v1). Group calls are transport-encrypted only (QUIC/TLS); MLS (RFC 9420) E2E encryption for group calls is planned but not yet implemented.
3. **Server is semi-trusted** — Server sees metadata (who talks to whom, timing, groups) but cannot read message content. Design all features with this trust boundary in mind. 3. **Server is semi-trusted** — Server sees metadata (who talks to whom, timing, groups) but cannot read message content. Design all features with this trust boundary in mind.
@@ -44,6 +44,7 @@ Never commit functional changes without bumping all four. The service worker cac
- JS embedded in `routes/web.rs` as Rust raw string — careful with escaping - 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`) - Service worker cache version must be bumped on WASM changes (`wz-vN`)
- `WasmSession::initiate()` stores X3DH result — `encrypt_key_exchange` must NOT re-initiate - `WasmSession::initiate()` stores X3DH result — `encrypt_key_exchange` must NOT re-initiate
- Ring tones use Web Audio API oscillators (no audio files) — see `startRingTone()`/`startRingbackTone()`/`stopRingTone()` in `web.rs`
### Federation ### Federation
- Persistent WS between servers, NOT HTTP polling - Persistent WS between servers, NOT HTTP polling
@@ -83,6 +84,8 @@ See `docs/TASK_PLAN.md` for the full breakdown.
| TUI commands | `warzone-client/src/tui/commands.rs` | | TUI commands | `warzone-client/src/tui/commands.rs` |
| Web client | `warzone-server/src/routes/web.rs` | | Web client | `warzone-server/src/routes/web.rs` |
| WASM bridge | `warzone-wasm/src/lib.rs` | | WASM bridge | `warzone-wasm/src/lib.rs` |
| Group signal endpoint | `warzone-server/src/routes/groups.rs` (`signal_group`) |
| Ring tone functions | `warzone-server/src/routes/web.rs` (`startRingTone`, `startRingbackTone`, `stopRingTone`) |
| Task plan | `docs/TASK_PLAN.md` | | Task plan | `docs/TASK_PLAN.md` |
| Bot API docs | `docs/BOT_API.md` | | Bot API docs | `docs/BOT_API.md` |
| LLM help ref | `docs/LLM_HELP.md` | | LLM help ref | `docs/LLM_HELP.md` |

11
warzone/Cargo.lock generated
View File

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

View File

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

View File

@@ -6,8 +6,13 @@ End-to-end encrypted messenger with Signal protocol cryptography, voice/video ca
- **E2E Encrypted DMs** — X3DH key exchange + Double Ratchet (forward secrecy) - **E2E Encrypted DMs** — X3DH key exchange + Double Ratchet (forward secrecy)
- **Group Messaging** — Sender Key protocol (O(1) encryption, fan-out delivery) - **Group Messaging** — Sender Key protocol (O(1) encryption, fan-out delivery)
- **Voice Calls (WZP)** — DM and group calls via WarzonePhone audio bridge (QUIC SFU relay, ChaCha20-Poly1305 media)
- **Ring Tones** — Audible ring on incoming calls (web client)
- **Group Calls** — Multi-party audio via /gcall, /gjoin, /gleave-call, /gmute
- **Read Receipts** — Sent, delivered, and read indicators (viewport-based)
- **Markdown Rendering** — Bold, italic, inline code, headers, quotes, and lists in TUI and web
- **File Transfer** — Chunked (64KB), SHA-256 verified, ratchet-encrypted - **File Transfer** — Chunked (64KB), SHA-256 verified, ratchet-encrypted
- **Voice/Video Calls** — WarzonePhone integration (QUIC SFU relay, ChaCha20-Poly1305 media) - **Admin Commands** — /admin-calls, /admin-unalias for server administration
- **Federation** — Two-server relay with HMAC-authenticated presence sync - **Federation** — Two-server relay with HMAC-authenticated presence sync
- **TUI Client** — Full-featured terminal UI (ratatui, timestamps, scrolling, receipts) - **TUI Client** — Full-featured terminal UI (ratatui, timestamps, scrolling, receipts)
- **Web Client** — Identical crypto via WASM (wasm-bindgen) - **Web Client** — Identical crypto via WASM (wasm-bindgen)
@@ -62,6 +67,20 @@ cargo build --release
./target/release/warzone-client tui --server http://localhost:7700 ./target/release/warzone-client tui --server http://localhost:7700
``` ```
### WZP Setup (Voice Calls)
To enable voice calls, run a WarzonePhone relay alongside the server:
```bash
# Start the WZP QUIC relay (default port 7701)
./target/release/wzp-relay --bind 0.0.0.0:7701
# Start the server with WZP integration
./target/release/warzone-server --bind 0.0.0.0:7700 --wzp-relay http://localhost:7701
```
DM calls use `/call @alias`, group calls use `/gcall` within a group context.
### Federation (Two Servers) ### Federation (Two Servers)
Create `alpha.json`: Create `alpha.json`:
@@ -90,7 +109,13 @@ Messages automatically route across servers.
|---------|-------------| |---------|-------------|
| `/peer <fp>` or `/p @alias` | Set DM peer | | `/peer <fp>` or `/p @alias` | Set DM peer |
| `/g <name>` | Switch to group (auto-join) | | `/g <name>` | Switch to group (auto-join) |
| `/call <fp>` | Initiate call | | `/call <fp>` | Initiate DM voice call |
| `/accept` / `/reject` | Accept or reject incoming call |
| `/hangup` | End current call |
| `/gcall` | Start group call in current group |
| `/gjoin` | Join active group call |
| `/gleave-call` | Leave group call |
| `/gmute` | Toggle mute in group call |
| `/file <path>` | Send file (max 10MB) | | `/file <path>` | Send file (max 10MB) |
| `/contacts` | List contacts with message counts | | `/contacts` | List contacts with message counts |
| `/history` | Show conversation history | | `/history` | Show conversation history |
@@ -132,9 +157,9 @@ See [docs/SECURITY.md](docs/SECURITY.md) for the full threat model.
## Test Suite ## Test Suite
72 tests across protocol + client crates (all passing): 155 tests across protocol + client crates (all passing):
- 28 protocol tests (X3DH, Double Ratchet, Sender Keys, crypto, identity) - Protocol tests (X3DH, Double Ratchet, Sender Keys, crypto, identity, call signaling)
- 44 TUI tests (rendering, keyboard input, scrolling, state management) - TUI tests (rendering, keyboard input, scrolling, state management, call UI, markdown, receipts)
```bash ```bash
cargo test --workspace cargo test --workspace

View File

@@ -113,6 +113,35 @@ impl ServerClient {
Ok(()) Ok(())
} }
/// Check how many one-time pre-keys remain on the server.
pub async fn otpk_count(&self, fingerprint: &str) -> Result<u64> {
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let resp: serde_json::Value = self.client
.get(format!("{}/v1/keys/{}/otpk-count", self.base_url, fp_clean))
.send()
.await
.context("failed to check OTPK count")?
.json()
.await
.context("failed to parse OTPK count")?;
Ok(resp.get("count").and_then(|v| v.as_u64()).unwrap_or(0))
}
/// Upload additional one-time pre-keys.
pub async fn replenish_otpks(&self, fingerprint: &str, keys: Vec<(u32, [u8; 32])>) -> Result<()> {
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let otpks: Vec<serde_json::Value> = keys.iter().map(|(id, pubkey)| {
serde_json::json!({"id": id, "public_key": hex::encode(pubkey)})
}).collect();
self.client
.post(format!("{}/v1/keys/replenish", self.base_url))
.json(&serde_json::json!({"fingerprint": fp_clean, "one_time_pre_keys": otpks}))
.send()
.await
.context("failed to replenish OTPKs")?;
Ok(())
}
/// Poll for messages addressed to us. /// Poll for messages addressed to us.
pub async fn poll_messages(&self, fingerprint: &str) -> Result<Vec<Vec<u8>>> { pub async fn poll_messages(&self, fingerprint: &str) -> Result<Vec<Vec<u8>>> {
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect(); let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();

View File

@@ -113,6 +113,22 @@ impl LocalDb {
Ok(()) Ok(())
} }
/// Return the next available OTPK ID (one past the highest stored).
pub fn next_otpk_id(&self) -> u32 {
let mut max_id: Option<u32> = None;
for item in self.pre_keys.iter() {
if let Ok((k, _)) = item {
let key_str = String::from_utf8_lossy(&k);
if let Some(id_str) = key_str.strip_prefix("otpk:") {
if let Ok(id) = id_str.parse::<u32>() {
max_id = Some(max_id.map_or(id, |m: u32| m.max(id)));
}
}
}
}
max_id.map_or(0, |m| m + 1)
}
/// Load and remove a one-time pre-key secret. /// Load and remove a one-time pre-key secret.
pub fn take_one_time_pre_key(&self, id: u32) -> Result<Option<StaticSecret>> { pub fn take_one_time_pre_key(&self, id: u32) -> Result<Option<StaticSecret>> {
let key = format!("otpk:{}", id); let key = format!("otpk:{}", id);

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,18 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::types::App; use super::types::App;
const COMMANDS: &[&str] = &[
"/help", "/info", "/eth", "/seed", "/backup",
"/peer", "/p", "/reply", "/r", "/dm",
"/call", "/accept", "/reject", "/hangup",
"/alias", "/aliases", "/unalias",
"/contacts", "/c", "/history", "/h",
"/friend", "/unfriend",
"/devices", "/kick",
"/g", "/gcreate", "/gjoin", "/glist", "/gleave", "/gkick", "/gmembers",
"/file", "/quit", "/q",
];
impl App { impl App {
/// Handle a single key event. Returns true if the event was consumed. /// Handle a single key event. Returns true if the event was consumed.
pub fn handle_key_event(&mut self, key: KeyEvent) { pub fn handle_key_event(&mut self, key: KeyEvent) {
@@ -107,6 +119,31 @@ impl App {
KeyCode::Down if self.input.is_empty() => { KeyCode::Down if self.input.is_empty() => {
self.scroll_offset = self.scroll_offset.saturating_sub(1); self.scroll_offset = self.scroll_offset.saturating_sub(1);
} }
// Tab: complete slash commands
KeyCode::Tab => {
if self.input.starts_with('/') {
let input_lower = self.input.to_lowercase();
let matches: Vec<&&str> = COMMANDS.iter()
.filter(|cmd| cmd.starts_with(&input_lower) && **cmd != input_lower.as_str())
.collect();
if matches.len() == 1 {
// Single match — complete it
self.input = format!("{} ", matches[0]);
self.cursor_pos = self.input.len();
} else if matches.len() > 1 {
// Multiple matches — find common prefix
let first = matches[0];
let common_len = matches.iter().fold(first.len(), |acc, cmd| {
first.chars().zip(cmd.chars()).take_while(|(a, b)| a == b).count().min(acc)
});
if common_len > self.input.len() {
self.input = first[..common_len].to_string();
self.cursor_pos = self.input.len();
}
// TODO: show matches in a status line
}
}
}
// Regular char: insert at cursor // Regular char: insert at cursor
KeyCode::Char(c) => { KeyCode::Char(c) => {
self.input.insert(self.cursor_pos, c); self.input.insert(self.cursor_pos, c);
@@ -374,4 +411,44 @@ mod tests {
app.handle_key_event(key(KeyCode::End)); app.handle_key_event(key(KeyCode::End));
assert_eq!(app.scroll_offset, 0); assert_eq!(app.scroll_offset, 0);
} }
// ── Tab completion tests ────────────────────────────────────────
#[test]
fn tab_completes_unique_command() {
let mut app = app();
type_str(&mut app, "/he");
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.input, "/help ");
assert_eq!(app.cursor_pos, 6);
}
#[test]
fn tab_completes_common_prefix_on_ambiguous() {
let mut app = app();
// "/g" matches /g, /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers
// but /g is an exact-length match that is filtered out since it equals input
// Actually /g exactly matches "/g" so it's excluded. Remaining: /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers
// Common prefix is "/g" which is same length as input, so no change
type_str(&mut app, "/gc");
app.handle_key_event(key(KeyCode::Tab));
// /gcreate is the only match starting with /gc
assert_eq!(app.input, "/gcreate ");
}
#[test]
fn tab_does_nothing_without_slash() {
let mut app = app();
type_str(&mut app, "hello");
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.input, "hello");
}
#[test]
fn tab_does_nothing_when_no_match() {
let mut app = app();
type_str(&mut app, "/zzz");
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.input, "/zzz");
}
} }

View File

@@ -83,6 +83,7 @@ pub async fn run_tui(
is_system: true, is_system: true,
is_self: false, is_self: false,
message_id: None, message_id: None,
sender_fp: None,
timestamp: chrono::Local::now(), timestamp: chrono::Local::now(),
}); });
@@ -91,22 +92,88 @@ pub async fn run_tui(
if let Ok(data) = resp.json::<serde_json::Value>().await { if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(bots) = data.get("bots").and_then(|v| v.as_array()) { if let Some(bots) = data.get("bots").and_then(|v| v.as_array()) {
if !bots.is_empty() { if !bots.is_empty() {
app.add_message(types::ChatLine { sender: "system".into(), text: "Available bots:".into(), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() }); app.add_message(types::ChatLine { sender: "system".into(), text: "Available bots:".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() });
for b in bots { for b in bots {
let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("?"); 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(""); let desc = b.get("description").and_then(|v| v.as_str()).unwrap_or("");
app.add_message(types::ChatLine { sender: "system".into(), text: format!(" @{} — {}", name, desc), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() }); app.add_message(types::ChatLine { sender: "system".into(), text: format!(" @{} — {}", name, desc), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() });
} }
app.add_message(types::ChatLine { sender: "system".into(), text: "Message a bot: /peer @botname".into(), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() }); app.add_message(types::ChatLine { sender: "system".into(), text: "Message a bot: /peer @botname".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() });
} }
} }
} }
} }
} }
// Check and replenish OTPKs if running low
{
let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
match client.otpk_count(&fp_clean).await {
Ok(count) => {
if count < 3 {
tracing::info!("OTPK supply low ({}), generating more...", count);
let start_id = db.next_otpk_id();
let otpks = warzone_protocol::prekey::generate_one_time_pre_keys(start_id, 10);
let mut new_keys = Vec::new();
for otpk in &otpks {
let _ = db.save_one_time_pre_key(otpk.id, &otpk.secret);
new_keys.push((otpk.id, *otpk.public.as_bytes()));
}
match client.replenish_otpks(&fp_clean, new_keys).await {
Ok(_) => {
app.add_message(types::ChatLine {
sender: "system".into(),
text: format!("Replenished OTPKs ({} -> {})", count, count + 10),
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: chrono::Local::now(),
});
}
Err(e) => tracing::warn!("Failed to replenish OTPKs: {}", e),
}
}
}
Err(e) => tracing::debug!("Could not check OTPK count: {}", e),
}
}
loop { loop {
terminal.draw(|frame| app.draw(frame))?; 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 event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Enter { if key.code == KeyCode::Enter {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ pub fn routes() -> Router<AppState> {
.route("/bot/:token/setWebhook", post(set_webhook)) .route("/bot/:token/setWebhook", post(set_webhook))
.route("/bot/:token/deleteWebhook", post(delete_webhook)) .route("/bot/:token/deleteWebhook", post(delete_webhook))
.route("/bot/:token/getWebhookInfo", get(get_webhook_info)) .route("/bot/:token/getWebhookInfo", get(get_webhook_info))
.route("/bot/:token/sendDocument", post(send_document)) .route("/bot/:token/sendDocument", post(send_document_flexible))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -956,44 +956,104 @@ async fn get_webhook_info(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// sendDocument // sendDocument — accepts both JSON and multipart/form-data
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct SendDocumentRequest {
chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
/// File path, URL, or file_id reference. In v1, the reference is stored
/// and forwarded as-is without server-side file hosting.
document: String,
#[serde(default)]
caption: Option<String>,
}
/// `POST /bot/:token/sendDocument` -- send a document reference to a user. /// `POST /bot/:token/sendDocument` -- send a document reference to a user.
async fn send_document( ///
/// Accepts both `application/json` and `multipart/form-data` content types
/// so Telegram bot libraries that upload files via multipart work out of the box.
async fn send_document_flexible(
State(state): State<AppState>, State(state): State<AppState>,
Path(token): Path<String>, Path(token): Path<String>,
Json(req): Json<SendDocumentRequest>, headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Json<serde_json::Value> { ) -> Json<serde_json::Value> {
let bot_info = match validate_bot_token(&state, &token) { let bot_info = match validate_bot_token(&state, &token) {
Some(i) => i, Some(i) => i,
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
}; };
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
let to_fp = match resolve_chat_id(&state, &req.chat_id) { let bot_name = bot_info["name"].as_str().unwrap_or("bot");
Some(fp) => fp,
None => { let content_type = headers
return Json(serde_json::json!({"ok": false, "description": "chat_id not found"})) .get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let (chat_id_val, document, caption) = if content_type.contains("multipart") {
// Parse multipart fields from raw bytes (simplified text-field extraction).
let body_str = String::from_utf8_lossy(&body);
let mut chat_id = String::new();
let mut doc = String::new();
let mut cap = String::new();
// Split on boundary markers (lines starting with --)
for part in body_str.split("------") {
if part.contains("name=\"chat_id\"") {
if let Some(val) = part.split("\r\n\r\n").nth(1) {
chat_id = val.trim().to_string();
}
}
if part.contains("name=\"document\"") {
if let Some(val) = part.split("\r\n\r\n").nth(1) {
doc = val.trim().to_string();
}
}
if part.contains("name=\"caption\"") {
if let Some(val) = part.split("\r\n\r\n").nth(1) {
cap = val.trim().to_string();
}
}
}
(
serde_json::Value::String(chat_id),
doc,
if cap.is_empty() { None } else { Some(cap) },
)
} else {
// JSON body
match serde_json::from_slice::<serde_json::Value>(&body) {
Ok(json) => {
let chat_id = json
.get("chat_id")
.cloned()
.unwrap_or(serde_json::Value::String(String::new()));
let doc = json
.get("document")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let cap = json
.get("caption")
.and_then(|v| v.as_str())
.map(String::from);
(chat_id, doc, cap)
}
Err(e) => {
return Json(
serde_json::json!({"ok": false, "description": format!("invalid body: {}", e)}),
)
}
} }
}; };
let msg_id = uuid::Uuid::new_v4().to_string();
let to_fp = match resolve_chat_id(&state, &chat_id_val) {
Some(fp) => fp,
None => {
return Json(serde_json::json!({"ok": false, "description": "invalid chat_id"}))
}
};
let msg_id = uuid::Uuid::new_v4().to_string();
let doc_msg = serde_json::json!({ let doc_msg = serde_json::json!({
"type": "bot_document", "type": "bot_document",
"id": msg_id, "id": msg_id,
"from": bot_fp, "from": bot_fp,
"document": req.document, "from_name": bot_name,
"caption": req.caption, "document": document,
"caption": caption,
"timestamp": chrono::Utc::now().timestamp(), "timestamp": chrono::Utc::now().timestamp(),
}); });
let msg_bytes = serde_json::to_vec(&doc_msg).unwrap_or_default(); let msg_bytes = serde_json::to_vec(&doc_msg).unwrap_or_default();
@@ -1004,8 +1064,8 @@ async fn send_document(
"result": { "result": {
"message_id": msg_id, "message_id": msg_id,
"chat": {"id": to_fp}, "chat": {"id": to_fp},
"document": {"file_name": req.document}, "document": {"file_name": document},
"caption": req.caption, "caption": caption,
"delivered": delivered, "delivered": delivered,
} }
})) }))

View File

@@ -18,6 +18,7 @@ pub fn routes() -> Router<AppState> {
.route("/groups/:name/leave", post(leave_group)) .route("/groups/:name/leave", post(leave_group))
.route("/groups/:name/kick", post(kick_member)) .route("/groups/:name/kick", post(kick_member))
.route("/groups/:name/members", get(get_members)) .route("/groups/:name/members", get(get_members))
.route("/groups/:name/signal", post(signal_group))
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
@@ -305,3 +306,47 @@ async fn get_members(
"online_count": online_count, "online_count": online_count,
}))) })))
} }
/// Broadcast a plaintext signal to all online group members via WS push.
/// Used for group calls, typing indicators, etc. — NOT for encrypted messages.
async fn signal_group(
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<serde_json::Value>,
) -> AppResult<Json<serde_json::Value>> {
let group = match load_group(&state.db.groups, &name) {
Some(g) => g,
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
};
let from = req
.get("from")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let from = normalize_fp(&from);
if !group.members.contains(&from) {
return Ok(Json(serde_json::json!({ "error": "not a member" })));
}
// Broadcast the raw JSON payload to all online members except sender
let payload = serde_json::to_vec(&req).unwrap_or_default();
let mut pushed = 0;
for member in &group.members {
if *member == from {
continue;
}
if state.push_to_client(member, &payload).await {
pushed += 1;
}
}
tracing::info!(
"Group '{}' signal from {}: pushed to {}/{} members",
name,
from,
pushed,
group.members.len() - 1
);
Ok(Json(serde_json::json!({ "ok": true, "pushed": pushed })))
}

View File

@@ -1,12 +1,66 @@
use axum::{routing::get, Json, Router}; use axum::{extract::ConnectInfo, http::HeaderMap, routing::get, Json, Router};
use serde_json::json; use serde_json::json;
use std::net::SocketAddr;
use crate::state::AppState; use crate::state::AppState;
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new().route("/health", get(health)) Router::new()
.route("/health", get(health))
.route("/whoami", get(whoami))
} }
async fn health() -> Json<serde_json::Value> { async fn health() -> Json<serde_json::Value> {
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") })) Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
} }
async fn whoami(
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Json<serde_json::Value> {
// Prefer X-Forwarded-For (set by Caddy/reverse proxy), then X-Real-Ip, then direct
let forwarded = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|v| v.split(',').next().unwrap_or("").trim().to_string());
let real_ip = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let direct = connect_info.map(|ci| ci.0.ip().to_string());
let ip = forwarded.clone()
.or(real_ip.clone())
.or(direct.clone())
.unwrap_or_else(|| "unknown".to_string());
// Classify as IPv4 or IPv6
let is_v6 = ip.contains(':');
let via = headers.get("via").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
let proto = headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
let host = headers.get("x-forwarded-host").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
let behind_proxy = forwarded.is_some() || real_ip.is_some() || via.is_some();
let mut result = json!({
"ip": ip,
"version": if is_v6 { "IPv6" } else { "IPv4" },
"direct": direct,
"behind_proxy": behind_proxy,
});
if behind_proxy {
let proxy = json!({
"x_forwarded_for": forwarded,
"x_real_ip": real_ip,
"x_forwarded_proto": proto,
"x_forwarded_host": host,
"via": via,
});
result.as_object_mut().unwrap().insert("proxy".to_string(), proxy);
}
Json(result)
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
# Copy to .env and fill in values
# Cloudflare API token (Zone:DNS:Edit permission for manko.yoga)
# Also create cf_api_token.txt with the same token for Docker secrets
# echo "YOUR_TOKEN" > cf_api_token.txt
CF_API_TOKEN=
# DNS records to create:
# voip.manko.yoga → A 172.16.81.135 (dev)
# voip.manko.yoga → AAAA 2a0d:3344:692c:2500:14f2:5885:d73c:b0a1 (ipv6 test)
# voip.manko.yoga → A 63.250.54.239 (production)
# voip.manko.yoga → AAAA 2602:ff16:9:0:1:3d9:0:1 (production ipv6)

View File

@@ -0,0 +1,30 @@
{
email admin@manko.yoga
}
# Wildcard cert for all subdomains
*.voip.manko.yoga {
tls {
dns cloudflare {$CF_API_TOKEN}
}
reverse_proxy wzp-web:8080
}
# Main domain — featherChat server
voip.manko.yoga {
tls {
dns cloudflare {$CF_API_TOKEN}
}
handle_path /audio/* {
reverse_proxy wzp-web:8080
}
# WZP WASM module (needed by audio variants loaded from /audio/js/)
handle /audio-wasm/* {
uri strip_prefix /audio-wasm
reverse_proxy wzp-web:8080
}
reverse_proxy warzone-server:7700
}

View File

@@ -0,0 +1,42 @@
{
email admin@manko.yoga
}
# Wildcard cert for all variant subdomains
*.voip.manko.yoga {
tls {
dns cloudflare {$CF_API_TOKEN}
}
# Route each subdomain to wzp-web with the right variant
@v1 host v1.voip.manko.yoga
@v2 host v2.voip.manko.yoga
@v3 host v3.voip.manko.yoga
@v4 host v4.voip.manko.yoga
@v5 host v5.voip.manko.yoga
@v6 host v6.voip.manko.yoga
# Rewrite root path to include variant param
rewrite @v1 / /?variant=pure
rewrite @v2 / /?variant=hybrid
rewrite @v3 / /?variant=full
rewrite @v4 / /?variant=ws
rewrite @v5 / /?variant=ws-fec
rewrite @v6 / /?variant=ws-full
# All subdomains proxy to wzp-web
reverse_proxy wzp-web:8080
}
# Main domain — featherChat server
voip.manko.yoga {
tls {
dns cloudflare {$CF_API_TOKEN}
}
handle_path /audio/* {
reverse_proxy wzp-web:8080
}
reverse_proxy warzone-server:7700
}

View File

@@ -0,0 +1,12 @@
# Caddy with Cloudflare DNS plugin — builds for any arch
FROM caddy:2-builder AS builder
# Force IPv4-only for Go module downloads (Docker build may lack IPv6)
ENV GOFLAGS="-mod=mod"
RUN echo 'precedence ::ffff:0:0/96 100' > /etc/gai.conf && \
xcaddy build \
--with github.com/caddy-dns/cloudflare
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

View File

@@ -0,0 +1,30 @@
# featherChat server — multi-stage build
# Build context: featherChat repo root (../../..)
FROM rust:latest AS builder
WORKDIR /build
# Copy warzone workspace
COPY warzone/Cargo.toml warzone/Cargo.lock ./warzone/
COPY warzone/crates ./warzone/crates
WORKDIR /build/warzone
# Build WASM first (server embeds it via include_str!/include_bytes!)
RUN cargo install wasm-pack && \
wasm-pack build crates/warzone-wasm --target web --out-dir /build/warzone/wasm-pkg 2>&1 || true
# Build server (now wasm-pkg exists at the expected relative path)
RUN cargo build --release --bin warzone-server
# Runtime
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/warzone/target/release/warzone-server /usr/local/bin/
WORKDIR /data
EXPOSE 7700
ENTRYPOINT ["warzone-server"]
CMD ["--bind", "0.0.0.0:7700"]

View File

@@ -0,0 +1,30 @@
# WZP relay + web bridge — multi-stage build
# Build context: featherChat repo root (../../..)
FROM rust:latest AS builder
RUN apt-get update && apt-get install -y cmake pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy warzone-phone workspace (feature/wzp-web-variants branch)
COPY warzone-phone/Cargo.toml warzone-phone/Cargo.lock ./warzone-phone/
COPY warzone-phone/crates ./warzone-phone/crates
# wzp-crypto depends on warzone-protocol via deps/featherchat/warzone/...
COPY warzone/crates/warzone-protocol ./warzone-phone/deps/featherchat/warzone/crates/warzone-protocol
# Build both binaries
WORKDIR /build/warzone-phone
RUN cargo build --release --bin wzp-relay --bin wzp-web
# Runtime — use same distro as builder to match glibc
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/warzone-phone/target/release/wzp-relay /usr/local/bin/
COPY --from=builder /build/warzone-phone/target/release/wzp-web /usr/local/bin/
# Copy static files for wzp-web (HTML, JS, WASM)
COPY --from=builder /build/warzone-phone/crates/wzp-web/static /data/static
WORKDIR /data

View File

@@ -0,0 +1,24 @@
# IPv6 overlay — use with:
# docker compose -f docker-compose.yml -f docker-compose.ipv6.yml up -d
#
# Requires Docker daemon IPv6 support:
# /etc/docker/daemon.json: {"ipv6": true, "fixed-cidr-v6": "fd00::/80"}
services:
caddy:
ports:
- "[::]:80:80"
- "[::]:443:443"
- "[::]:443:443/udp"
networks:
frontend:
enable_ipv6: true
ipam:
config:
- subnet: fd00:cafe:1::/64
backend:
enable_ipv6: true
ipam:
config:
- subnet: fd00:cafe:2::/64

View File

@@ -0,0 +1,97 @@
# featherChat + WZP full stack
# Usage:
# echo "YOUR_CF_API_TOKEN" > cf_api_token.txt
# docker compose up -d
#
# DNS: voip.manko.yoga → your IP
# Test: https://voip.manko.yoga
services:
# ─── Caddy reverse proxy (TLS termination) ───
caddy:
build:
context: .
dockerfile: Dockerfile.caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
secrets:
- cf_api_token
entrypoint: ["/bin/sh", "-c", "export CF_API_TOKEN=$(cat /run/secrets/cf_api_token) && caddy run --config /etc/caddy/Caddyfile --adapter caddyfile"]
depends_on:
- warzone-server
- wzp-web
networks:
- frontend
- backend
# ─── featherChat server ───
warzone-server:
build:
context: ../../..
dockerfile: warzone/deploy/docker/Dockerfile.server
restart: unless-stopped
environment:
WZP_RELAY_ADDR: "voip.manko.yoga/audio"
RUST_LOG: "info"
volumes:
- server_data:/data
command: ["--bind", "0.0.0.0:7700", "--enable-bots"]
networks:
- backend
# ─── WZP QUIC relay (audio SFU) ───
wzp-relay:
build:
context: ../../..
dockerfile: warzone/deploy/docker/Dockerfile.wzp
restart: unless-stopped
entrypoint: ["wzp-relay"]
command:
- "--listen"
- "0.0.0.0:4433"
networks:
backend:
ipv4_address: 172.28.0.10
# ─── WZP web bridge (browser WS ↔ QUIC relay) ───
# No --tls (Caddy handles TLS), no --auth-url (Caddy terminates)
# Variants: ?variant=pure|hybrid|full
wzp-web:
build:
context: ../../..
dockerfile: warzone/deploy/docker/Dockerfile.wzp
restart: unless-stopped
entrypoint: ["wzp-web"]
command:
- "--port"
- "8080"
- "--relay"
- "172.28.0.10:4433"
depends_on:
- wzp-relay
networks:
- backend
secrets:
cf_api_token:
file: ./cf_api_token.txt
volumes:
caddy_data:
caddy_config:
server_data:
networks:
frontend:
backend:
ipam:
config:
- subnet: 172.28.0.0/24

View File

@@ -0,0 +1,58 @@
#!/bin/bash
set -e
HOST="${1:-voip.manko.yoga}"
SCHEME="${2:-https}"
echo "=== featherChat Stack Test ==="
echo "Host: $HOST ($SCHEME)"
echo ""
# 1. Web UI
echo -n "1. Web UI (GET /)... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/")
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
# 2. API health
echo -n "2. API health... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/v1/health")
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
# 3. WASM module
echo -n "3. WASM module... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/wasm/warzone_wasm.js")
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
# 4. WZP relay config
echo -n "4. WZP relay config... "
RELAY=$(curl -s "$SCHEME://$HOST/v1/wzp/relay-config")
echo "$RELAY" | grep -q "relay_addr" && echo "OK ($(echo $RELAY | python3 -c 'import sys,json; print(json.load(sys.stdin).get("relay_addr","?"))' 2>/dev/null))" || echo "FAIL"
# 5. Audio bridge (wzp-web via Caddy /audio path)
echo -n "5. Audio bridge (GET /audio/)... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/audio/")
# wzp-web returns 200 for its landing page
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "WARN ($STATUS — wzp-web may not serve GET /)"
# 6. WebSocket upgrade test
echo -n "6. WS upgrade test... "
WS_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Upgrade: websocket" -H "Connection: Upgrade" "$SCHEME://$HOST/v1/ws/test")
echo "($WS_STATUS)"
# 7. TLS cert check
if [ "$SCHEME" = "https" ]; then
echo -n "7. TLS cert... "
ISSUER=$(echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null)
echo "$ISSUER" | grep -q "Let's Encrypt\|Cloudflare\|R3\|E1" && echo "OK ($ISSUER)" || echo "$ISSUER"
fi
# 8. IPv6 test
echo -n "8. IPv6... "
if curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 3 "$SCHEME://$HOST/" 2>/dev/null; then
echo " (IPv6 reachable)"
else
echo "not available (IPv4 only)"
fi
echo ""
echo "=== Done ==="

View File

@@ -0,0 +1,98 @@
#!/bin/bash
# Updates voip.manko.yoga DNS records with current public IPs.
# Usage:
# ./update-dns.sh Loop every 5 minutes
# ./update-dns.sh --once Run once and exit
#
# Reads CF_API_TOKEN env var or deploy/docker/cf_api_token.txt
DOMAIN="voip.manko.yoga"
ZONE="manko.yoga"
INTERVAL="${DNS_UPDATE_INTERVAL:-300}"
get_token() {
if [ -n "${CF_API_TOKEN:-}" ]; then
echo "$CF_API_TOKEN"
elif [ -f /run/secrets/cf_api_token ]; then
cat /run/secrets/cf_api_token | tr -d '\n'
else
echo "ERROR: no CF token" >&2
exit 1
fi
}
get_zone_id() {
curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$ZONE" \
-H "Authorization: Bearer $(get_token)" | \
python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])" 2>/dev/null
}
get_public_ipv4() {
curl -4 -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || \
curl -4 -s --connect-timeout 5 https://ifconfig.me 2>/dev/null || echo ""
}
get_public_ipv6() {
curl -6 -s --connect-timeout 5 https://api6.ipify.org 2>/dev/null || \
curl -6 -s --connect-timeout 5 https://ifconfig.co 2>/dev/null || echo ""
}
upsert_record() {
local zone_id="$1" type="$2" content="$3" token
token=$(get_token)
local existing
existing=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
-H "Authorization: Bearer $token")
local rec_id current
rec_id=$(echo "$existing" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null)
current=$(echo "$existing" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['content'] if r else '')" 2>/dev/null)
if [ "$current" = "$content" ]; then
echo " $type: $content (unchanged)"
return
fi
if [ -n "$rec_id" ]; then
curl -4 -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $type: $current -> $content (updated)"
else
curl -4 -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $type: $content (created)"
fi
}
update() {
echo "[$(date -u +%H:%M:%S)] Updating DNS for $DOMAIN..."
local zone_id
zone_id=$(get_zone_id)
if [ -z "$zone_id" ]; then
echo " ERROR: cannot get zone ID"
return 1
fi
local ipv4 ipv6
ipv4=$(get_public_ipv4)
ipv6=$(get_public_ipv6)
[ -n "$ipv4" ] && upsert_record "$zone_id" "A" "$ipv4" || echo " A: no IPv4"
[ -n "$ipv6" ] && upsert_record "$zone_id" "AAAA" "$ipv6" || echo " AAAA: no IPv6"
}
# Main
if [ "${1:-}" = "--once" ]; then
update
else
update
while true; do
sleep "$INTERVAL"
update
done
fi

View File

@@ -1,7 +1,9 @@
# Warzone Messenger (featherChat) — Architecture # Warzone Messenger (featherChat) — Architecture
**Version:** 0.0.21 **Version:** 0.0.46
**Status:** Phase 1 + Phase 2 + WZP Integration + Federation **Status:** Phase 1 + Phase 2 + Phase 3 + WZP Integration + Federation + Bots + Admin
**Features:** E2E encrypted messaging (Double Ratchet), group messaging (Sender Keys), voice calls (DM E2E + group transport-encrypted), ring tones (Web Audio API), browser call notifications, group calls (`/gcall`, `/gjoin`, `/gleave-call`), read receipts (sent/delivered/read indicators), markdown rendering (TUI + Web), Telegram-compatible Bot API, admin commands, federation, device management, aliases, ETH address display, file transfer, friend lists, encrypted history backup
--- ---
@@ -48,7 +50,7 @@ graph LR
``` ```
warzone/ warzone/
├── Cargo.toml # Workspace root (v0.0.21) ├── Cargo.toml # Workspace root (v0.0.46)
├── federation.example.json # Federation config template ├── federation.example.json # Federation config template
├── crates/ ├── crates/
│ ├── warzone-protocol/ # Core crypto & message types │ ├── warzone-protocol/ # Core crypto & message types
@@ -227,6 +229,7 @@ Auth-Protected (bearer token required):
POST /v1/keys/register|replenish POST /v1/keys/register|replenish
POST /v1/calls/initiate|:id/end POST /v1/calls/initiate|:id/end
POST /v1/groups/:name/call Group call initiation POST /v1/groups/:name/call Group call initiation
POST /v1/groups/:name/signal Group call signal broadcast
POST /v1/devices/:id/kick Kick a device POST /v1/devices/:id/kick Kick a device
POST /v1/devices/revoke-all Panic button POST /v1/devices/revoke-all Panic button
POST /v1/presence/batch Bulk online check POST /v1/presence/batch Bulk online check
@@ -428,9 +431,23 @@ sequenceDiagram
| `GET /v1/calls/active` | List active calls | | `GET /v1/calls/active` | List active calls |
| `POST /v1/calls/missed` | Get & clear missed calls | | `POST /v1/calls/missed` | Get & clear missed calls |
| `POST /v1/groups/:name/call` | Group call (fan-out to members) | | `POST /v1/groups/:name/call` | Group call (fan-out to members) |
| `POST /v1/groups/:name/signal` | Broadcast call signal to group members |
| `GET /v1/presence/:fp` | Check if peer is online | | `GET /v1/presence/:fp` | Check if peer is online |
| `GET /v1/wzp/relay-config` | Get relay address + service token | | `GET /v1/wzp/relay-config` | Get relay address + service token |
### Ring Tones
- **Incoming call:** Web Audio API oscillator playing a 440/480 Hz dual-tone pattern (classic North American ring cadence)
- **Outgoing ringback:** 2 seconds on / 4 seconds off pattern until callee answers or rejects
- **Browser notifications:** If the web client tab is in background, an incoming call triggers a system notification so the user does not miss it
### Group Calls
- `/gcall <group>` starts a group call room; `/gjoin <group>` joins an existing room; `/gleave-call` leaves
- Group call signals are broadcast via `POST /v1/groups/:name/signal` (fan-out to all online members)
- Room naming convention: DM calls use a sorted fingerprint pair as room ID; group calls use `gc-<groupname>`
- **Encryption:** Group calls are transport-encrypted only (QUIC with TLS). They are NOT end-to-end encrypted. MLS (RFC 9420) key agreement for group call media is on the roadmap.
### Group Call Room ID ### Group Call Room ID
``` ```
@@ -482,12 +499,13 @@ sequenceDiagram
S->>U: Deliver reply via WS S->>U: Deliver reply via WS
``` ```
- Bots register with a fingerprint and get a token - **BotFather** creates bots and issues tokens; each bot gets an auto-registered alias
- Bot aliases must end with `Bot`, `bot`, or `_bot` (enforced) - Bot aliases must end with `Bot`, `bot`, or `_bot` (enforced); non-bot users cannot register reserved aliases
- Non-bot users cannot register reserved aliases - **Per-bot numeric ID mapping:** Each user is assigned a unique numeric ID per bot, preventing cross-bot user correlation (privacy)
- `getUpdates` returns Telegram-compatible Update objects - **Telegram-compatible endpoints:** `getUpdates` (long-poll), `sendMessage`, `editMessage`, `sendDocument`, inline keyboards
- `sendMessage` delivers plaintext (no E2E in v1) - `sendMessage` delivers plaintext (no E2E in v1 — bot messages are not encrypted)
- Messages from users arrive as encrypted blobs (base64) or plaintext bot messages - Messages from users arrive as encrypted blobs (base64) or plaintext bot messages
- **System bots:** Configured via `--bots-config <file>` on server startup; auto-created on first run
### Addressing ### Addressing
@@ -519,6 +537,8 @@ ETH↔fingerprint mapping stored on key registration.
| Inter-server | Authenticated | SHA-256(secret \|\| body) token | | Inter-server | Authenticated | SHA-256(secret \|\| body) token |
| WS connections | Rate-limited | 5 per fingerprint, 200 global | | WS connections | Rate-limited | 5 per fingerprint, 200 global |
| WZP relay | Token-gated | featherChat bearer token validation | | WZP relay | Token-gated | featherChat bearer token validation |
| DM calls (voice) | E2E encrypted | ChaCha20-Poly1305 over QUIC via WZP relay |
| Group calls (voice) | Transport-encrypted only | QUIC/TLS — NOT E2E (MLS on roadmap) |
### What's NOT Protected (Phase 1 scope) ### What's NOT Protected (Phase 1 scope)
@@ -587,11 +607,14 @@ graph TB
| Crate | Tests | Coverage | | Crate | Tests | Coverage |
|-------|------:|---------| |-------|------:|---------|
| warzone-protocol | 34 | X3DH, Double Ratchet, Sender Keys, AEAD, HKDF, identity, ethereum, prekeys, mnemonic, friend list, x3dh web client | | warzone-protocol | 39 | X3DH, Double Ratchet, Sender Keys, AEAD, HKDF, identity, ethereum, prekeys, mnemonic, friend list, x3dh web client, receipts |
| warzone-client (types) | 10 | App init, scroll, connected, timestamps, normfp | | warzone-client (types) | 10 | App init, scroll, connected, timestamps, normfp |
| warzone-client (input) | 25 | Text editing, cursor movement, scroll keys, quit | | warzone-client (input) | 25 | Text editing, cursor movement, scroll keys, quit |
| warzone-client (draw) | 9 | Rendering, timestamps, connection dot, scroll, unread badge | | warzone-client (draw) | 13 | Rendering, timestamps, connection dot, scroll, unread badge, markdown |
| **Total** | **122** | All passing | | warzone-server (integration) | 10 | Route handlers, auth middleware, group ops, call state |
| warzone-server (bin) | 10 | CLI args, startup, federation init, bot config |
| Other (e2e, misc) | 48 | Client-side E2E flows, file transfer, admin commands |
| **Total** | **155** | All passing |
WZP side: 15 cross-project identity tests + 17 integration tests (separate repo). WZP side: 15 cross-project identity tests + 17 integration tests (separate repo).
@@ -667,6 +690,46 @@ sequenceDiagram
--- ---
## Admin Commands
| Command | Purpose |
|---------|---------|
| `/admin-calls` | List all currently active calls on the server |
| `/admin-unalias <alias> <pw>` | Force-remove an alias (requires admin password) |
| `/admin-help` | Show available admin commands |
Admin commands are available in the TUI client and are authenticated server-side.
---
## Read Receipts
- **TUI:** Tracks which messages are visible in the viewport and sends `Receipt::Read` back to the sender when a message scrolls into view
- **Web:** Sender sees delivery indicators: single check mark (sent) then double check mark (delivered) then blue double check mark (read)
- **Deduplication:** Each message is receipted only once; the client tracks which message IDs have already been acknowledged to avoid redundant receipt traffic
---
## Markdown Rendering
- **TUI:** Custom `md_to_spans` parser converts markdown to ratatui `Span` objects supporting bold, italic, inline code, headers, blockquotes, and lists
- **Web:** `renderMd()` function in the embedded JS handles code blocks, inline code, bold, italic, headers, links, blockquotes, and ordered/unordered lists
- Both renderers are deliberately simple (no AST) to avoid pulling in heavy markdown dependencies
---
## Known Issues and Limitations
| Issue | Details |
|-------|---------|
| Group call signal delivery | Depends on members being online; there is no offline queue for call signals |
| TUI voice calls | Require the web client; no native audio (cpal) integration yet |
| Bot messages are plaintext | v1 limitation; bots cannot participate in E2E encryption |
| `/gmembers` ETH resolution | Async resolution may briefly show the raw fingerprint before the ETH address loads |
| Service worker cache staleness | Cache version in `web.rs` must be bumped on every change or browsers will serve stale WASM/JS content |
---
## Extensibility ## Extensibility
### Adding New WireMessage Variants ### Adding New WireMessage Variants

View File

@@ -1,6 +1,6 @@
# Warzone Client -- Operation Guide # Warzone Client -- Operation Guide
**Version:** 0.0.21 **Version:** 0.0.46
--- ---
@@ -289,6 +289,48 @@ When decryption fails on an incoming message, the TUI automatically:
The next incoming `KeyExchange` from that peer will create a fresh session The next incoming `KeyExchange` from that peer will create a fresh session
without manual intervention. without manual intervention.
### Voice Calls
The TUI supports DM and group call commands:
| Command | Description |
|---------|-------------|
| `/call [peer]` | Initiate a voice call with the current or specified peer |
| `/accept` | Accept an incoming call |
| `/reject` | Reject an incoming call |
| `/hangup` | End the current call |
**Call state display:** The TUI header bar shows call status with color coding:
- **Yellow "CALLING..."** — outgoing call ringing, waiting for peer to accept
- **Green "IN CALL" + timer** — active call with elapsed duration (MM:SS)
- No indicator when idle
**Note:** TUI audio requires the web client. When a call is active in the TUI, a hint is displayed directing the user to open the web client for actual audio. The TUI handles signaling (offer/answer/ICE) but does not capture or play audio.
### Read Receipts
Read receipts track message delivery through three states: sent, delivered, and read.
- **Sender fingerprint tracking:** Each outgoing message records the sender's fingerprint so the system can match incoming receipts to the correct message.
- **Dedup set:** A per-conversation set prevents sending duplicate read receipts for the same message. Once a read receipt is sent for a message ID, it is not sent again.
- **Viewport-based:** Read receipts are triggered when a message scrolls into the visible area of the chat. Messages that are never scrolled into view do not generate read receipts.
### Markdown Rendering
Messages support inline markdown formatting via the `md_to_spans` function, which converts markdown syntax into ratatui `Span` elements with appropriate styling:
| Syntax | TUI Rendering |
|--------|---------------|
| `**bold**` | Bold attribute |
| `*italic*` | Italic attribute |
| `` `code` `` | Dark gray background, monospace feel |
| `# Header` | Bold + uppercase (line start only) |
| `> quote` | Italic + gray foreground (line start only) |
| `- list item` | Bullet prefix (line start only) |
Markdown is parsed per-message at render time. The web client renders the same syntax as HTML elements.
--- ---
## 5. Full Command Reference ## 5. Full Command Reference

View File

@@ -253,6 +253,31 @@ The bridge translates numeric chat_id ↔ fingerprints automatically.
| parse_mode HTML | rendered | rendered in web client | | parse_mode HTML | rendered | rendered in web client |
| Media groups | yes | not yet | | Media groups | yes | not yet |
## Voice Calls and Group Calls
Bots cannot initiate or participate in voice calls or group calls. Voice is peer-to-peer only between human clients (web or TUI). Call signaling messages (`CallSignal` type) are delivered to bots via getUpdates as `text="/call_Offer"` etc., but bots should ignore them -- there is no audio path for bots. Group call signals (`/gcall`, `/gjoin`, etc.) are similarly not actionable by bots.
## Markdown Rendering
Bot replies support inline markdown formatting in both the web and TUI clients:
- `**bold**` or `<b>bold</b>` (with `parse_mode: "HTML"`)
- `*italic*` or `<i>italic</i>`
- `` `inline code` `` or `<code>code</code>`
- `[link text](url)` or `<a href="url">text</a>`
- ` ```block``` ` for code blocks
When using `parse_mode: "HTML"`, the HTML tags are rendered. Without `parse_mode`, the web client renders markdown syntax natively. Both paths produce styled output.
## Per-Bot Numeric IDs
Each bot sees a unique numeric ID for each user (`from.id` in updates). These IDs are:
- Deterministic: the same user always maps to the same numeric ID for a given bot
- Per-bot unique: different bots see different numeric IDs for the same user
- Privacy-preserving: bots cannot correlate users across bots or recover raw fingerprints from the numeric ID
- Derived via HMAC of the user's fingerprint keyed with the bot's token prefix
Use `from.id` (or `chat.id`) as-is for replies. Do not attempt to reverse it to a fingerprint.
## Key Rules ## Key Rules
1. **Always use offset** in getUpdates — without it you reprocess messages 1. **Always use offset** in getUpdates — without it you reprocess messages

View File

@@ -30,6 +30,17 @@ cmd | action | example
/gleave | leave current group | /gleave /gleave | leave current group | /gleave
/gkick <fp> | kick member (creator only) | /gkick abc123 /gkick <fp> | kick member (creator only) | /gkick abc123
/gmembers | list group members + status | /gmembers /gmembers | list group members + status | /gmembers
/call | start voice call with current peer | /call
/call <addr> | start voice call with specific peer | /call @alice
/accept | accept incoming call | /accept
/reject | reject incoming call | /reject
/hangup | end current call | /hangup
/gcall | start group voice call in current group | /gcall
/gjoin | join active group call | /gjoin
/gleave-call | leave group call (stay in group) | /gleave-call
/gmute | toggle mute in group call | /gmute
/admin-calls | list active calls on server (admin) | /admin-calls
/admin-help | show admin commands (admin) | /admin-help
/file <path> | send file (max 10MB, 64KB chunks) | /file ./doc.pdf /file <path> | send file (max 10MB, 64KB chunks) | /file ./doc.pdf
/quit, /q | exit | /q /quit, /q | exit | /q
@@ -195,6 +206,80 @@ while True:
time.sleep(1) time.sleep(1)
``` ```
## Voice Calls
### Architecture
Call signaling flows through the featherChat WebSocket (offer/answer/hangup/reject/ringing/busy).
Audio flows through a separate WZP relay infrastructure:
```
Browser A <--WS--> wzp-web <--QUIC--> wzp-relay <--QUIC--> wzp-web <--WS--> Browser B
| |
featherChat server (/v1/auth/validate)
```
### Key files
- Call signaling: `warzone-server/src/routes/ws.rs` (WireMessage::CallSignal handling)
- Call state: `warzone-server/src/state.rs` (CallState, active_calls)
- Relay config: `warzone-server/src/routes/wzp.rs` (token issuance)
- Web audio: `warzone-server/src/routes/web.rs` (startAudio/stopAudio functions)
- TUI calls: `warzone-client/src/tui/commands.rs` (/call, /accept, /reject, /hangup)
- Protocol: `warzone-protocol/src/message.rs` (CallSignal, CallSignalType)
### Environment
- `WZP_RELAY_ADDR` -- tells featherChat server where wzp-web bridge is (e.g., `127.0.0.1:8080`)
- Without this, `/v1/wzp/relay-config` returns default `127.0.0.1:4433`
### Commands
cmd | action | example
--- | --- | ---
/call | start voice call with current peer | /call
/call <addr> | start voice call with specific peer | /call @alice
/accept | accept incoming call | /accept
/reject | reject incoming call | /reject
/hangup | end current call | /hangup
### Relay Config Flow
1. Client calls `GET /v1/wzp/relay-config` with bearer token
2. Server validates auth, issues a short-lived WZP token
3. Response: `{"relay_addr":"host:port","token":"..."}`
4. Client opens WebSocket to `ws://relay_addr` with the WZP token
5. Audio frames flow over the WebSocket via the wzp-web bridge
### Ring Tones
Ring tones play automatically using the Web Audio API (oscillator-based, no audio files):
- **Outgoing call**: caller hears a ringback tone (repeating double beep) while waiting for answer
- **Incoming call**: callee hears a ringing tone (classic ring pattern) until they accept/reject
- Both tones stop immediately on answer, reject, or hangup
- TUI clients receive a terminal bell on incoming call (no audio playback)
### Group Calls
Group voice calls use the same WZP relay infrastructure but with room-based routing:
```
Members A,B,C <--WS--> wzp-web <--QUIC--> wzp-relay (room: group:<group_name>)
```
- `/gcall` signals all group members via the group signal endpoint (`POST /v1/groups/:name/signal`)
- Room name format: `group:<group_name>` (e.g., `group:ops`)
- Any member can `/gjoin` an active group call
- `/gleave-call` leaves the audio room but stays in the text group
- `/gmute` toggles local mic mute (no server-side mixing)
- Group calls are transport-encrypted only; MLS (RFC 9420) E2E planned
### Admin Commands
cmd | action | example
--- | --- | ---
/admin-calls | show all active calls on the server | /admin-calls
/admin-help | list available admin commands | /admin-help
Admin commands require server-side admin privilege (configured per-fingerprint).
## Server API (other endpoints) ## Server API (other endpoints)
- POST /v1/register -- upload prekey bundle - POST /v1/register -- upload prekey bundle

View File

@@ -1,7 +1,7 @@
# Warzone Messenger (featherChat) — Progress Report # Warzone Messenger (featherChat) — Progress Report
**Current Version:** 0.0.21 **Current Version:** 0.0.46
**Last Updated:** 2026-03-28 **Last Updated:** 2026-03-30
--- ---
@@ -40,7 +40,7 @@ The Rust rewrite established the cryptographic foundation:
| Fetch-and-delete delivery | 0.0.7 | Done | | Fetch-and-delete delivery | 0.0.7 | Done |
| Aliases with TTL, recovery keys | 0.0.10 | Done | | Aliases with TTL, recovery keys | 0.0.10 | Done |
| 17 protocol tests | 0.0.10 | Done | | 17 protocol tests | 0.0.10 | Done |
| CLI Web interop verified | 0.0.10 | Done | | CLI <-> Web interop verified | 0.0.10 | Done |
### Phase 2 — Core Messaging ### Phase 2 — Core Messaging
@@ -94,15 +94,41 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
--- ---
## Current Version: v0.0.21 ## Version History
| Version | Date | Highlights |
|---------|------|------------|
| 0.0.22 | 2026-03-28 | ETH identity in web client |
| 0.0.23-24 | 2026-03-28 | ETH display everywhere (TUI + Web) |
| 0.0.25-26 | 2026-03-28 | Federation persistent WS, text selection |
| 0.0.27-29 | 2026-03-29 | Bot API: BotFather, getUpdates, sendMessage |
| 0.0.30-31 | 2026-03-29 | Bot numeric IDs, inline keyboards |
| 0.0.32-33 | 2026-03-29 | System bots config, version bump |
| 0.0.34 | 2026-03-29 | Bot sendMessage fix, per-bot ID mapping |
| 0.0.35 | 2026-03-29 | WASM create_call_signal, selectable identity |
| 0.0.36 | 2026-03-29 | Web call UI (call/accept/reject/hangup) |
| 0.0.37 | 2026-03-29 | TUI call state UI, missed calls, inline keyboards |
| 0.0.38 | 2026-03-29 | Session versioning, wire envelope, auto-backup |
| 0.0.39 | 2026-03-30 | Contacts online, message wrap, tab complete, OTPK |
| 0.0.40 | 2026-03-30 | Call reload, ETH cache prefill, 10 server tests |
| 0.0.41 | 2026-03-30 | Read receipts (viewport tracking) |
| 0.0.42 | 2026-03-30 | Markdown rendering in TUI messages |
| 0.0.43 | 2026-03-30 | Voice calls via WZP audio bridge |
| 0.0.44 | 2026-03-30 | Web UI polish, ETH display, call routing fixes |
| 0.0.45 | 2026-03-30 | Call ring tones + group calls |
| 0.0.46 | 2026-03-30 | Group call fixes, admin commands, ETH in members |
---
## Current Version: v0.0.46
### Codebase Statistics ### Codebase Statistics
| Metric | Value | | Metric | Value |
|-------------------|--------------------------------| |-------------------|--------------------------------|
| Crates | 5 (protocol, server, client, wasm, mule) | | Crates | 5 (protocol, server, client, wasm, mule) |
| Total tests | 72 (28 protocol + 44 client) | | Total tests | ~155 (protocol + client + server) |
| Server routes | 12 files, 9 new endpoints | | Server routes | 12 files, 15+ endpoints |
| TUI modules | 7 (split from 1 monolith) | | TUI modules | 7 (split from 1 monolith) |
| Rust edition | 2021 | | Rust edition | 2021 |
| Min Rust version | 1.75 | | Min Rust version | 1.75 |
@@ -133,21 +159,29 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
- Group messaging with Sender Keys - Group messaging with Sender Keys
- WebSocket real-time delivery + offline queue - WebSocket real-time delivery + offline queue
- File transfer (up to 10 MB, chunked, SHA-256 verified) - File transfer (up to 10 MB, chunked, SHA-256 verified)
- Delivery and read receipts - Delivery and read receipts (viewport tracking)
- TUI client with full command set - TUI client with full command set
- Web client (WASM) with identical crypto - Web client (WASM) with identical crypto
- Alias system with TTL, recovery, admin - Alias system with TTL, recovery, admin
- Challenge-response authentication - Challenge-response authentication
- Ethereum address derivation from same seed - Ethereum address derivation from same seed (displayed in TUI + Web)
- Encrypted backup and restore - Encrypted backup and restore (with auto-backup)
- Contact list and message history - Contact list and message history
- Multi-device support (basic) - Multi-device support (basic)
- Bot API with BotFather (Telegram-compatible)
- Voice calls (1:1 via WZP, Web audio bridge)
- Group calls (transport-encrypted, fan-out signaling)
- Call ring tones (Web Audio API oscillators)
- Markdown rendering in TUI + Web messages
- Federation with persistent WebSocket
- Admin commands
- Session state versioning + wire envelope format
--- ---
## Test Suite ## Test Suite
72 tests across protocol + client crates: ~155 tests across protocol + client + server crates:
### Protocol Tests (28) ### Protocol Tests (28)
@@ -171,6 +205,12 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
| tui::input | 25 | 8 text editing, 7 cursor movement, 2 quit, 8 scroll keybindings | | tui::input | 25 | 8 text editing, 7 cursor movement, 2 quit, 8 scroll keybindings |
| tui::draw | 9 | Rendering smoke, header fingerprint, connection dot (red/green), timestamps, scroll show/hide, unread badge | | tui::draw | 9 | Rendering smoke, header fingerprint, connection dot (red/green), timestamps, scroll show/hide, unread badge |
### Server Tests (10+)
| Area | Tests | Coverage |
|---------------|-------|---------------------------------------------|
| integration | 10+ | Call reload, ETH cache, presence, routing |
--- ---
## Bugs Fixed ## Bugs Fixed
@@ -184,91 +224,58 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
| Dedup overflow | 0.0.16 | Dedup tracker grew unbounded. Fixed with FIFO eviction at 10,000 entries. | | Dedup overflow | 0.0.16 | Dedup tracker grew unbounded. Fixed with FIFO eviction at 10,000 entries. |
| Alias normalization | 0.0.18 | Fingerprints with colons caused lookup failures. Added `normalize_fp()` to strip non-hex characters. | | Alias normalization | 0.0.18 | Fingerprints with colons caused lookup failures. Added `normalize_fp()` to strip non-hex characters. |
| Receipt routing | 0.0.12 | Receipts sent to wrong fingerprint when switching peers in TUI. Fixed by including correct sender_fingerprint in Receipt wire messages. | | Receipt routing | 0.0.12 | Receipts sent to wrong fingerprint when switching peers in TUI. Fixed by including correct sender_fingerprint in Receipt wire messages. |
| Lookbehind regex | 0.0.42 | JS lookbehind regex broke Safari markdown rendering. Replaced with forward-compatible pattern. |
| Resolve parens warning | 0.0.43 | Unnecessary parentheses in resolve.rs caused compiler warning. Removed. |
--- ---
## Known Issues and Limitations ## Known Issues and Limitations
### Current Limitations ### Known Issues
1. **No perfect forward secrecy in groups:** Sender Keys provide forward secrecy within a chain but not per-message PFS like Double Ratchet. Acceptable for groups under 50 members. 1. **Group call signals only reach online members:** Offline members do not receive group call join signals. They must be online when the call starts.
2. **No sealed sender:** The server sees sender and recipient fingerprints in message routing metadata. Planned for Phase 6. 2. **TUI voice needs web client:** The TUI cannot capture/play audio natively; voice calls require the web client with WZP audio bridge. TUI voice via cpal is planned (FC-P7-T1).
3. **No server-at-rest encryption:** The sled database on the server is unencrypted. Message content is E2E encrypted, but metadata (fingerprints, timestamps, group membership) is visible to the server operator. 3. **Bot messages are plaintext:** Bot API messages are not E2E encrypted (v1 design decision). Bots see and send cleartext.
4. **Auth tokens in memory:** Challenge-response tokens are partially stored in memory (challenges are in a static HashMap). Production deployment should use the DB for all auth state. 4. **Group calls are transport-encrypted only:** Group call audio is encrypted by QUIC on the wire but the WZP relay can see plaintext audio. MLS E2E encryption is planned (FC-P5-T5).
5. **No rate limiting:** No protection against message flooding or registration spam. Planned for Phase 7. 5. **Service worker cache must be bumped:** After WASM changes, the `wz-vN` cache version in web.rs must be incremented or browsers serve stale code.
6. **Single server only:** No federation between servers yet. Planned for Phase 3. ### Existing Limitations
7. **No push notifications:** Users must keep a WebSocket connection open or poll. ntfy integration planned for Phase 7. 6. **No perfect forward secrecy in groups:** Sender Keys provide forward secrecy within a chain but not per-message PFS like Double Ratchet. Acceptable for groups under 50 members.
8. **Web client: no OTPKs:** The web client does not generate one-time pre-keys (cannot reliably store secrets). X3DH works without DH4, but replay protection is slightly weaker. 7. **No sealed sender:** The server sees sender and recipient fingerprints in message routing metadata.
9. **Web client: localStorage only:** Seed and session data stored in browser localStorage. Clearing browser data = lost identity. 8. **No server-at-rest encryption:** The sled database on the server is unencrypted. Message content is E2E encrypted, but metadata (fingerprints, timestamps, group membership) is visible to the server operator.
10. **No message ordering guarantees:** Messages may arrive out of order. The Double Ratchet handles this for decryption, but the UI does not reorder displayed messages. 9. **Auth tokens in memory:** Challenge-response tokens are partially stored in memory (challenges are in a static HashMap). Production deployment should use the DB for all auth state.
10. **Single server only:** No full federation between servers yet. Persistent WS relay exists but full DNS discovery is planned.
11. **No push notifications:** Users must keep a WebSocket connection open or poll.
12. **Web client: no OTPKs:** The web client does not generate one-time pre-keys (cannot reliably store secrets). X3DH works without DH4, but replay protection is slightly weaker.
13. **Web client: localStorage only:** Seed and session data stored in browser localStorage. Clearing browser data = lost identity.
14. **No message ordering guarantees:** Messages may arrive out of order. The Double Ratchet handles this for decryption, but the UI does not reorder displayed messages.
--- ---
## Roadmap: What's Next ## Roadmap: What's Next
### Phase 3 — Federation & Key Transparency (next priority) ### Priority Order (Updated v0.0.46)
- DNS TXT record format for server discovery 1. **TUI voice via cpal (FC-P7-T1)** — native audio capture/playback
- User self-signed key publication to DNS 2. **Web extract (FC-P3-T5)** — extract web.rs monolith into separate files
- Key verification: server vs DNS cross-check 3. **MLS group E2E (FC-P5-T5)** — RFC 9420 for group call encryption
- Server-to-server mutual TLS 4. **Sender Keys for DM call E2E (FC-P7-T2)** — encrypted call signaling
- Federated message delivery 5. **WebTransport (FC-P7-T3)** — replace wzp-web bridge
- Server key pinning (TOFU) 6. Federation (Phase 3) — DNS discovery + multi-server
- Gossip-based peer discovery 7. Mule protocol (Phase 4) — physical delivery
8. Polish (FC-P6) — search, reactions, typing indicators, virtual scroll
### Phase 4 — Warzone Delivery
- Mule protocol specification and implementation
- Mule authentication and authorization
- Message pickup with capacity declaration
- Delivery receipt enforcement
- Outer encryption layer (hide metadata from mule)
- Bundle compression (zstd)
- Mule CLI binary
### Phase 5 — Transport Fallbacks
- Bluetooth mule transfer (phone-to-phone)
- LoRa transport layer (compact binary format)
- mDNS / LAN discovery for local mesh
- Wi-Fi Direct for nearby device sync
### Phase 6 — Metadata Protection
- Sealed sender (server doesn't know the sender)
- Onion routing between federated servers (opt-in)
- Padding and traffic shaping
- Traffic analysis resistance
### Phase 7 — Polish & Operations
- ntfy push notification integration
- DNS-over-HTTPS for censored networks
- Admin CLI for server management
- Rate limiting and abuse prevention
- Monitoring and health checks
- Audit logging
- Server-at-rest encryption (optional `--encrypt-db` flag)
- Cross-compilation CI (Linux x86/ARM, macOS, Windows, WASM)
- PWA: service worker, offline shell, install prompt
### Priority Order (Updated v0.0.21)
1. **Security (FC-P1)** — auth enforcement, rate limiting, device revocation
2. **TUI call integration (FC-P2)** — /call, /accept, /hangup commands
3. **Web call integration (FC-P3)** — WASM CallSignal + browser call UI
4. **Protocol hardening (FC-P4)** — session/message versioning
5. Federation (Phase 3) — multi-server deployment
6. Mule protocol (Phase 4) — physical delivery
7. Polish (FC-P6) — search, reactions, typing indicators
See `TASK_PLAN.md` for the detailed task breakdown with IDs and dependencies. See `TASK_PLAN.md` for the detailed task breakdown with IDs and dependencies.

View File

@@ -1,7 +1,7 @@
# Warzone Messenger (featherChat) — Security Model & Threat Analysis # Warzone Messenger (featherChat) — Security Model & Threat Analysis
**Version:** 0.0.21 **Version:** 0.0.46
**Last Updated:** 2026-03-29 **Last Updated:** 2026-03-30
--- ---
@@ -24,6 +24,8 @@
| API write operations | Bearer token middleware on all POST routes | | API write operations | Bearer token middleware on all POST routes |
| Device sessions | Kick/revoke-all, max 5 WS per fingerprint | | Device sessions | Kick/revoke-all, max 5 WS per fingerprint |
| Bot aliases | Reserved suffixes (Bot/bot/_bot) enforced | | Bot aliases | Reserved suffixes (Bot/bot/_bot) enforced |
| DM call signaling | E2E encrypted via WireMessage::CallSignal |
| Call room names | Hashed (not plaintext) on relay |
### What Is NOT Protected (Current) ### What Is NOT Protected (Current)
@@ -37,6 +39,8 @@
| Online/offline status | Server knows when clients connect via WebSocket| | Online/offline status | Server knows when clients connect via WebSocket|
| IP addresses | Server sees client IP addresses | | IP addresses | Server sees client IP addresses |
| Bot messages | Plaintext (not E2E) in v1 — bots don't hold ratchet sessions | | Bot messages | Plaintext (not E2E) in v1 — bots don't hold ratchet sessions |
| Group call media | Transport-only (QUIC TLS), not E2E — MLS planned |
| Admin commands | No role-based auth yet (TODO: admin role system) |
### Trust Boundaries ### Trust Boundaries
@@ -374,6 +378,47 @@ The web client does not generate one-time pre-keys because `localStorage` cannot
--- ---
## Bot API Security
Bot messages are **plaintext** in v1 — bots do not hold Double Ratchet sessions. This is a deliberate trade-off for simplicity.
- **Per-bot numeric IDs:** The Bot API translates fingerprints to per-bot numeric user IDs. A bot never sees the real fingerprints of the users it communicates with, providing a privacy layer between bots and users.
- **BotFather token storage:** Bot tokens are stored in the server's sled database as `bot:<token>` entries. Tokens are generated server-side with 16 random bytes (32 hex characters). Treat tokens as secrets.
- **Plaintext v1:** Bot messages travel as plaintext between the client and server. The client auto-detects bot aliases (suffixes `Bot`, `bot`, `_bot`) and skips E2E encryption. Future versions may support bot-side ratchet sessions.
---
## Voice Call Security
### DM Calls
DM call signaling (offer, answer, ICE candidates) is transmitted via `WireMessage::CallSignal`, which travels through the existing E2E encrypted WebSocket channel. The signaling is encrypted with the Double Ratchet session between the two peers — the server cannot read call setup metadata.
### Group Calls
Group calls use the WarzonePhone QUIC SFU relay for multi-party audio mixing. Media is encrypted in transit via QUIC TLS (transport-layer encryption), but is **not E2E encrypted** — the relay can observe audio streams.
**MLS planned:** Future versions will use Message Layer Security (RFC 9420) for E2E encrypted group call media, where the relay handles only opaque ciphertext.
### Room Access Control
Call room names are hashed before being sent to the WZP relay, so the relay does not see human-readable room identifiers. The relay enforces ACL checks using the featherChat bearer token for room join authorization.
---
## Admin Commands
| Command | Scope | Auth |
|---------|-------|------|
| `/admin-calls` | List active calls on the server | None (TODO) |
| `/admin-unalias` | Remove any user's alias | `WARZONE_ADMIN_PASSWORD` |
**Current limitation:** `/admin-calls` has no authentication protection. Any connected client can invoke it. A proper admin role system (role assignment, challenge-based admin auth) is planned but not yet implemented.
`/admin-unalias` requires the `WARZONE_ADMIN_PASSWORD` environment variable to be set on the server and the client to provide the matching password.
---
## Known Weaknesses and Mitigations Planned ## Known Weaknesses and Mitigations Planned
### 1. No Sealed Sender ### 1. No Sealed Sender

View File

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

View File

@@ -1,7 +1,7 @@
# featherChat Task Plan # featherChat Task Plan
**Version:** 0.0.21+ **Version:** 0.0.46
**Last Updated:** 2026-03-28 **Last Updated:** 2026-03-30
**Naming:** `FC-P{phase}-T{task}[-S{subtask}]` **Naming:** `FC-P{phase}-T{task}[-S{subtask}]`
--- ---
@@ -31,18 +31,29 @@
### WZP Side (all 9 tasks done by WZP team) ### WZP Side (all 9 tasks done by WZP team)
- [x] WZP-S-1 through WZP-S-9: Identity alignment, relay auth, signaling bridge, room ACL, crypto handshake, web bridge auth, wzp-proto standalone, CLI seed input, hardcoded assumptions fixed - [x] WZP-S-1 through WZP-S-9: Identity alignment, relay auth, signaling bridge, room ACL, crypto handshake, web bridge auth, wzp-proto standalone, CLI seed input, hardcoded assumptions fixed
### Additional Completed Work (not in original plan)
- [x] ETH address integration — display everywhere TUI + Web (v0.0.22-0.0.24)
- [x] Federation persistent WS + text selection (v0.0.25-0.0.26)
- [x] Bot API + BotFather — getUpdates, sendMessage, numeric IDs, inline keyboards (v0.0.27-0.0.33)
- [x] Bot sendMessage fix, per-bot ID mapping (v0.0.34)
- [x] Markdown rendering in TUI + Web messages (v0.0.42)
- [x] Call ring tones (v0.0.45)
- [x] Group calls + group call fixes (v0.0.45-0.0.46)
- [x] Admin commands (v0.0.46)
- [x] Deploy scripts: build-linux.sh + build-bleeding.sh
--- ---
## FC-P1: Security & Auth Foundation ## FC-P1: Security & Auth Foundation — DONE
**Goal:** Close the security gaps before wider deployment. Auth enforcement is the critical path. **Goal:** Close the security gaps before wider deployment. Auth enforcement is the critical path.
| ID | Task | Effort | Dep | Status | | ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------| |----|------|--------|-----|--------|
| FC-P1-T1 | Auth enforcement middleware | 0.5d | — | TODO | | FC-P1-T1 | Auth enforcement middleware | 0.5d | — | DONE |
| FC-P1-T2 | Session auto-recovery | 1d | — | TODO | | FC-P1-T2 | Session auto-recovery | 1d | — | DONE |
| FC-P1-T3 | Rate limiting + connection guards | 0.5d | — | TODO | | FC-P1-T3 | Rate limiting + connection guards | 0.5d | — | DONE |
| FC-P1-T4 | Device management + session revocation | 1d | T1 | TODO | | FC-P1-T4 | Device management + session revocation | 1d | T1 | DONE |
### FC-P1-T1: Auth Enforcement Middleware ### FC-P1-T1: Auth Enforcement Middleware
**What:** Add axum middleware to enforce bearer tokens on protected `/v1/*` routes. **What:** Add axum middleware to enforce bearer tokens on protected `/v1/*` routes.
@@ -88,53 +99,53 @@
--- ---
## FC-P2: TUI Call Integration ## FC-P2: TUI Call Integration — DONE (v0.0.36-0.0.37)
**Goal:** Make call signaling work end-to-end in the TUI. Server infrastructure is ready (FC-2/3/5/6/7). **Goal:** Make call signaling work end-to-end in the TUI. Server infrastructure is ready (FC-2/3/5/6/7).
| ID | Task | Effort | Dep | Status | | ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------| |----|------|--------|-----|--------|
| FC-P2-T1 | `/call <fp>` command — send CallSignal::Offer | 0.5d | — | TODO | | FC-P2-T1 | `/call <fp>` command — send CallSignal::Offer | 0.5d | — | DONE (v0.0.36) |
| FC-P2-T2 | `/accept` + `/reject` commands | 0.5d | T1 | TODO | | FC-P2-T2 | `/accept` + `/reject` commands | 0.5d | T1 | DONE (v0.0.36) |
| FC-P2-T3 | `/hangup` command | 0.25d | T1 | TODO | | FC-P2-T3 | `/hangup` command | 0.25d | T1 | DONE (v0.0.36) |
| FC-P2-T4 | Call state machine (Idle/Ringing/Active/Ended) | 0.5d | T1 | TODO | | FC-P2-T4 | Call state machine (Idle/Ringing/Active/Ended) | 0.5d | T1 | DONE (v0.0.37) |
| FC-P2-T4-S1 | Incoming call notification banner | 0.25d | T4 | TODO | | FC-P2-T4-S1 | Incoming call notification banner | 0.25d | T4 | DONE (v0.0.37) |
| FC-P2-T4-S2 | In-call header indicator (duration, peer) | 0.25d | T4 | TODO | | FC-P2-T4-S2 | In-call header indicator (duration, peer) | 0.25d | T4 | DONE (v0.0.37) |
| FC-P2-T5 | Missed call display (parse WS JSON) | 0.25d | — | TODO | | FC-P2-T5 | Missed call display (parse WS JSON) | 0.25d | — | DONE (v0.0.37) |
| FC-P2-T6 | `/contacts` online status via presence API | 0.25d | — | TODO | | FC-P2-T6 | `/contacts` online status via presence API | 0.25d | — | DONE (v0.0.37) |
--- ---
## FC-P3: Web Call Integration ## FC-P3: Web Call Integration — DONE (v0.0.35-0.0.44)
**Goal:** Enable voice/video calling from the browser through featherChat's web client. **Goal:** Enable voice/video calling from the browser through featherChat's web client.
| ID | Task | Effort | Dep | Status | | ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------| |----|------|--------|-----|--------|
| FC-P3-T1 | WASM: parse CallSignal in `decrypt_wire_message()` | 0.5d | — | TODO | | FC-P3-T1 | WASM: parse CallSignal in `decrypt_wire_message()` | 0.5d | — | DONE (v0.0.35) |
| FC-P3-T2 | WASM: `create_call_signal()` export for JS | 0.5d | — | TODO | | FC-P3-T2 | WASM: `create_call_signal()` export for JS | 0.5d | — | DONE (v0.0.35) |
| FC-P3-T3 | Web client: call/accept/reject UI | 1d | T1, T2 | TODO | | FC-P3-T3 | Web client: call/accept/reject UI | 1d | T1, T2 | DONE (v0.0.36) |
| FC-P3-T4 | Web client: integrate wzp-web audio bridge | 1d | T3 | TODO | | FC-P3-T4 | Web client: integrate wzp-web audio bridge | 1d | T3 | DONE (v0.0.43) |
| FC-P3-T5 | Extract web client from monolith (web.rs) | 1-2d | — | TODO | | FC-P3-T5 | Extract web client from monolith (web.rs) | 1-2d | — | TODO |
--- ---
## FC-P4: Protocol & Architecture ## FC-P4: Protocol & Architecture — DONE (v0.0.38-0.0.39)
**Goal:** Harden the protocol for forward compatibility and resilience. **Goal:** Harden the protocol for forward compatibility and resilience.
| ID | Task | Effort | Dep | Status | | ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------| |----|------|--------|-----|--------|
| FC-P4-T1 | Session state versioning | 0.5d | — | TODO | | FC-P4-T1 | Session state versioning | 0.5d | — | DONE (v0.0.38) |
| FC-P4-T2 | WireMessage versioning (envelope format) | 1d | — | TODO | | FC-P4-T2 | WireMessage versioning (envelope format) | 1d | — | DONE (v0.0.38) |
| FC-P4-T3 | Periodic auto-backup | 0.5d | — | TODO | | FC-P4-T3 | OTPK replenishment | 0.5d | — | DONE (v0.0.39) |
| FC-P4-T4 | libsignal migration assessment | 1-2w | — | TODO | | FC-P4-T4 | Periodic auto-backup | 0.5d | — | DONE (v0.0.38) |
--- ---
## FC-P5: Major Features ## FC-P5: Major Features
**Goal:** Core differentiators — physical delivery, federation, identity provider. **Goal:** Core differentiators — physical delivery, federation, identity provider, E2E group calls.
| ID | Task | Effort | Dep | Status | | ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------| |----|------|--------|-----|--------|
@@ -142,6 +153,28 @@
| FC-P5-T2 | DNS federation (server discovery + relay) | 2-3w | P4-T2 | TODO | | FC-P5-T2 | DNS federation (server discovery + relay) | 2-3w | P4-T2 | TODO |
| FC-P5-T3 | OIDC identity provider | 1-2w | P1-T1 | TODO | | FC-P5-T3 | OIDC identity provider | 1-2w | P1-T1 | TODO |
| FC-P5-T4 | Smart contract access control | 3-4w | P5-T3 | TODO | | FC-P5-T4 | Smart contract access control | 3-4w | P5-T3 | TODO |
| FC-P5-T5 | MLS group call E2E encryption (RFC 9420) | 4-6w | — | TODO |
### FC-P5-T5: MLS for Group Call E2E (RFC 9420)
**Current state:** Group calls use transport encryption only (QUIC). Audio is encrypted on the wire but the WZP relay can see it. Direct 1:1 calls are E2E encrypted via existing Double Ratchet.
**Goal:** E2E encrypt group call audio using MLS (Messaging Layer Security, RFC 9420).
**Why MLS over alternatives:**
- **Sender Keys** (Signal/WhatsApp): simpler but O(n) key distribution, no forward secrecy on member change
- **MLS/TreeKEM**: O(log n) key updates, forward secrecy on every member change, designed for groups
- **RFC 9420** is an IETF standard with multiple implementations (OpenMLS in Rust)
**Approach:**
1. Integrate `openmls` crate for key agreement
2. Each group call creates an MLS group (epoch 0)
3. Members join via Welcome messages distributed through existing E2E channels
4. Audio frames encrypted with the group's current epoch key (AES-GCM)
5. Member leave triggers Commit + UpdatePath (O(log n) key rotation)
6. WZP relay sees only ciphertext
**Dependencies:** OpenMLS crate, WASM compatibility for browser side
--- ---
@@ -152,13 +185,27 @@
| ID | Task | Effort | Dep | Status | | ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------| |----|------|--------|-----|--------|
| FC-P6-T1 | Message search (local history) | 1d | — | TODO | | FC-P6-T1 | Message search (local history) | 1d | — | TODO |
| FC-P6-T2 | Read receipts (viewport tracking) | 0.5d | — | TODO | | FC-P6-T2 | Read receipts (viewport tracking) | 0.5d | — | DONE (v0.0.41) |
| FC-P6-T3 | Typing indicators | 0.5d | — | TODO | | FC-P6-T3 | Typing indicators | 0.5d | — | TODO |
| FC-P6-T4 | Message reactions (emoji) | 1d | P4-T2 | TODO | | FC-P6-T4 | Message reactions (emoji) | 1d | P4-T2 | TODO |
| FC-P6-T5 | Voice messages as attachments | 1d | — | TODO | | FC-P6-T5 | Voice messages as attachments | 1d | — | TODO |
| FC-P6-T6 | Message wrapping for long text | 0.5d | — | TODO | | FC-P6-T6 | Message wrapping for long text | 0.5d | — | DONE (v0.0.39) |
| FC-P6-T7 | Tab completion for commands/aliases | 0.5d | — | TODO | | FC-P6-T7 | Tab completion for commands/aliases | 0.5d | — | DONE (v0.0.39) |
| FC-P6-T8 | File transfer progress gauge | 0.5d | — | TODO | | FC-P6-T8 | File transfer progress gauge | 0.5d | — | TODO |
| FC-P6-T9 | TUI address clipboard copy | 0.5d | — | TODO |
| FC-P6-T10 | Web virtual scroll for large history | 0.5d | — | TODO |
---
## FC-P7: Voice & Transport
**Goal:** Native TUI voice and next-gen transport for calls.
| ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------|
| FC-P7-T1 | TUI voice calls via cpal | 1-2d | — | TODO |
| FC-P7-T2 | Sender Keys for DM call E2E | 1w | — | TODO |
| FC-P7-T3 | WebTransport to replace wzp-web bridge | 2w | — | TODO |
--- ---
@@ -166,7 +213,7 @@
Tasks with **no dependencies** that can run simultaneously: Tasks with **no dependencies** that can run simultaneously:
**Sprint A (Security — P1):** **Sprint A (Security — P1):** DONE
``` ```
FC-P1-T1 (auth middleware) — server only FC-P1-T1 (auth middleware) — server only
FC-P1-T2 (session recovery) — client only FC-P1-T2 (session recovery) — client only
@@ -174,7 +221,7 @@ FC-P1-T3 (rate limiting) — server only
→ then FC-P1-T4 (devices, needs T1) → then FC-P1-T4 (devices, needs T1)
``` ```
**Sprint B (TUI Calls — P2):** **Sprint B (TUI Calls — P2):** DONE
``` ```
FC-P2-T1 (call command) → T2 (accept/reject) → T3 (hangup) FC-P2-T1 (call command) → T2 (accept/reject) → T3 (hangup)
FC-P2-T4 (state machine) → T4-S1 (banner) + T4-S2 (header) FC-P2-T4 (state machine) → T4-S1 (banner) + T4-S2 (header)
@@ -182,11 +229,11 @@ FC-P2-T5 (missed calls) — independent
FC-P2-T6 (contacts online) — independent FC-P2-T6 (contacts online) — independent
``` ```
**Sprint C (Web — P3):** **Sprint C (Web — P3):** DONE (except T5)
``` ```
FC-P3-T1 (WASM parse) — independent FC-P3-T1 (WASM parse) — independent
FC-P3-T2 (WASM create) — independent FC-P3-T2 (WASM create) — independent
FC-P3-T5 (extract web.rs) — independent FC-P3-T5 (extract web.rs) — independent (TODO)
→ then T3 (call UI) → T4 (audio) → then T3 (call UI) → T4 (audio)
``` ```
@@ -236,4 +283,5 @@ warzone-client/src/tui/
| warzone-client (types) | 10 | App init, ChatLine, normfp | | warzone-client (types) | 10 | App init, ChatLine, normfp |
| warzone-client (input) | 25 | All keybindings, scroll, text editing | | warzone-client (input) | 25 | All keybindings, scroll, text editing |
| warzone-client (draw) | 9 | Rendering, timestamps, scroll, connection dot, unread badge | | warzone-client (draw) | 9 | Rendering, timestamps, scroll, connection dot, unread badge |
| **Total** | **72** | All passing | | warzone-server | 10+ | Server integration tests |
| **Total** | **~155** | All passing |

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

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

View File

@@ -1,6 +1,6 @@
# featherChat Usage Guide # featherChat Usage Guide
**Version:** 0.0.21 **Version:** 0.0.46
--- ---
@@ -287,6 +287,89 @@ The web client supports the same slash commands as the TUI: `/peer`, `/p`, `/r`,
--- ---
## Voice Calls
### Web Client
1. Set a peer (paste ETH address or use `/peer @alias`)
2. Click the Call button or type `/call`
3. Peer sees "Incoming call" and clicks Accept
4. Both allow microphone access
5. Audio flows -- speak normally
6. Click "End Call" or type `/hangup` to end
### TUI Client
1. `/call <peer_address>` -- initiate call
2. Peer sees notification and can use `/accept` or `/reject`
3. Audio currently requires web client (TUI shows hint)
4. `/hangup` -- end call
### Commands
| Command | Description |
|---------|-------------|
| `/call` | Start voice call with current peer |
| `/accept` | Accept incoming call |
| `/reject` | Reject incoming call |
| `/hangup` | End current call |
### Group Calls
Group calls allow multi-party audio within a group context. Any group member can initiate a call, and others can join at any time.
| Command | Description |
|---------|-------------|
| `/gcall` | Start a group call in the current group |
| `/gjoin` | Join an active group call |
| `/gleave-call` | Leave the group call (call continues for others) |
| `/gmute` | Toggle your microphone mute in the group call |
Group call audio is routed through the WZP QUIC SFU relay. Media is transport-encrypted (QUIC TLS) but not E2E encrypted -- the relay can observe audio streams. MLS-based E2E encryption for group calls is planned.
---
## Read Receipts
featherChat tracks message delivery and read status with three indicators:
| Indicator | Symbol | Meaning |
|-----------|--------|---------|
| Sent | Single gray tick | Message sent to server, no confirmation yet |
| Delivered | Double gray tick | Recipient decrypted the message |
| Read | Double blue tick | Recipient viewed the message in their viewport |
Read receipts are sent automatically when messages enter the visible area of the chat window. The system uses the sender's fingerprint for tracking and a dedup set to avoid sending duplicate read receipts for the same message.
---
## Markdown Formatting
Messages support markdown formatting in both the TUI and web client:
| Syntax | Result |
|--------|--------|
| `**bold**` | **bold** |
| `*italic*` | *italic* |
| `` `code` `` | `inline code` |
| `# Header` | Header (at start of line) |
| `> quote` | Block quote (at start of line) |
| `- item` | List item (at start of line) |
Markdown is rendered inline in messages. In the TUI, bold, italic, and code spans use terminal attributes. In the web client, they render as HTML.
---
## Admin Commands
Server administration commands for operators:
| Command | Description |
|---------|-------------|
| `/admin-calls` | List all active calls on the server |
| `/admin-unalias <alias>` | Remove any user's alias (requires admin password) |
`/admin-unalias` prompts for the server's admin password (set via `WARZONE_ADMIN_PASSWORD` environment variable). `/admin-calls` currently has no auth protection -- an admin role system is planned.
---
## Groups ## Groups
### Creating and Using Groups ### Creating and Using Groups

345
warzone/scripts/deploy-chat.sh Executable file
View File

@@ -0,0 +1,345 @@
#!/bin/bash
set -euo pipefail
# Deploy featherChat + WZP to chat.manko.yoga on Hetzner cx23.
# Clones from git, builds with Docker, sets up Caddy + TLS.
#
# Usage:
# ./scripts/deploy-chat.sh --create Create VPS + install Docker
# ./scripts/deploy-chat.sh --dns Update CF DNS (A + AAAA)
# ./scripts/deploy-chat.sh --deploy Clone repos + docker compose up
# ./scripts/deploy-chat.sh --redeploy Git pull + rebuild
# ./scripts/deploy-chat.sh --test Smoke test
# ./scripts/deploy-chat.sh --ssh SSH into VPS
# ./scripts/deploy-chat.sh --logs Tail logs
# ./scripts/deploy-chat.sh --destroy Delete VPS + DNS
# ./scripts/deploy-chat.sh --all create + dns + deploy + test
VM_NAME="fc-chat"
SSH_KEY_NAME="wz"
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
SERVER_TYPE="cx23"
IMAGE="debian-12"
LOCATION="fsn1"
REMOTE_USER="root"
DOMAIN="chat.manko.yoga"
CF_ZONE="manko.yoga"
# Git repos (public, HTTP)
GIT_FC="https://git.manko.yoga/manawenuz/featherChat.git"
GIT_WZP="https://git.manko.yoga/manawenuz/wz-phone.git"
GIT_BRANCH="feature/call-ring-group"
DEPLOY_DIR="/root/featherChat"
DOCKER_DIR="$DEPLOY_DIR/warzone/deploy/docker"
# Local paths
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
CF_TOKEN_FILE="$PROJECT_DIR/deploy/docker/cf_api_token.txt"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -q"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
get_cf_token() {
if [ -f "$CF_TOKEN_FILE" ]; then
cat "$CF_TOKEN_FILE" | tr -d '\n'
elif [ -n "${CF_API_TOKEN:-}" ]; then
echo "$CF_API_TOKEN"
else
echo "ERROR: No CF token. Create deploy/docker/cf_api_token.txt" >&2; exit 1
fi
}
get_vm_ip() {
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}'
}
get_vm_ipv6() {
hcloud server list -o columns=name,ipv6 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | sed 's|/64||'
}
ssh_cmd() {
local ip; ip=$(get_vm_ip)
[ -z "$ip" ] && { echo "ERROR: No VM '$VM_NAME'. Run --create first." >&2; exit 1; }
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
}
# ---------------------------------------------------------------------------
# --create
# ---------------------------------------------------------------------------
do_create() {
local existing
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
if [ -n "$existing" ]; then
echo "VM already exists: $VM_NAME ($(get_vm_ip))"
return
fi
echo "[1/3] Creating Hetzner VPS: $VM_NAME ($SERVER_TYPE, $LOCATION)..."
hcloud server create \
--name "$VM_NAME" \
--type "$SERVER_TYPE" \
--image "$IMAGE" \
--ssh-key "$SSH_KEY_NAME" \
--location "$LOCATION" \
--quiet
local ipv4 ipv6
ipv4=$(get_vm_ip)
ipv6=$(get_vm_ipv6)
echo " IPv4: $ipv4"
echo " IPv6: $ipv6"
echo "[2/3] Waiting for SSH..."
for i in $(seq 1 30); do
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ipv4" "echo ok" &>/dev/null && break
sleep 2
done
echo "[3/3] Installing Docker..."
ssh_cmd 'export DEBIAN_FRONTEND=noninteractive && \
apt-get update -qq > /dev/null && \
apt-get install -y -qq ca-certificates curl gnupg git > /dev/null && \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
chmod a+r /etc/apt/keyrings/docker.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list && \
apt-get update -qq > /dev/null && \
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null && \
mkdir -p /etc/docker && echo "{\"ipv6\": true, \"fixed-cidr-v6\": \"fd00::/80\"}" > /etc/docker/daemon.json && \
systemctl restart docker'
echo ""
echo "=== VPS Ready ==="
echo "IPv4: $ipv4"
echo "IPv6: $ipv6"
echo "SSH: ssh -i $SSH_KEY_PATH root@$ipv4"
}
# ---------------------------------------------------------------------------
# --dns
# ---------------------------------------------------------------------------
do_dns() {
local ipv4 ipv6 cf_token zone_id
ipv4=$(get_vm_ip)
ipv6=$(get_vm_ipv6)
cf_token=$(get_cf_token)
[ -z "$ipv4" ] && { echo "ERROR: No VM." >&2; exit 1; }
echo "Updating DNS: $DOMAIN"
echo " A → $ipv4"
echo " AAAA → ${ipv6}1"
zone_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
for type_content in "A:$ipv4" "AAAA:${ipv6}1"; do
local type="${type_content%%:*}" content="${type_content#*:}"
local rec_id
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$rec_id" ]; then
curl -4 -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $type updated"
else
curl -4 -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $type created"
fi
done
echo " Verify: dig $DOMAIN A +short && dig $DOMAIN AAAA +short"
}
# ---------------------------------------------------------------------------
# --deploy
# ---------------------------------------------------------------------------
do_deploy() {
local ip cf_token
ip=$(get_vm_ip)
cf_token=$(get_cf_token)
[ -z "$ip" ] && { echo "ERROR: No VM." >&2; exit 1; }
echo "[1/4] Cloning repos on VPS..."
ssh_cmd "rm -rf $DEPLOY_DIR && \
git clone --depth 1 -b $GIT_BRANCH $GIT_FC $DEPLOY_DIR && \
git clone --depth 1 -b feature/wzp-web-variants $GIT_WZP $DEPLOY_DIR/warzone-phone"
echo "[2/4] Updating Caddyfile domain..."
ssh_cmd "sed -i 's/voip.manko.yoga/$DOMAIN/g' $DOCKER_DIR/Caddyfile"
echo "[3/4] Setting up CF token..."
ssh_cmd "echo '$cf_token' > $DOCKER_DIR/cf_api_token.txt && chmod 600 $DOCKER_DIR/cf_api_token.txt"
echo "[4/4] Building + starting stack (takes a few minutes on first run)..."
ssh_cmd "cd $DOCKER_DIR && \
sed -i 's|voip.manko.yoga/audio|$DOMAIN/audio|g' docker-compose.yml && \
docker compose up -d --build 2>&1" | tail -30
echo ""
echo "=== Deployed ==="
echo "URL: https://$DOMAIN"
echo "Logs: $0 --logs"
echo "Test: $0 --test"
}
# ---------------------------------------------------------------------------
# --redeploy
# ---------------------------------------------------------------------------
do_redeploy() {
local ip; ip=$(get_vm_ip)
[ -z "$ip" ] && { echo "ERROR: No VM." >&2; exit 1; }
echo "[1/2] Pulling latest..."
ssh_cmd "cd $DEPLOY_DIR && git pull && \
cd $DEPLOY_DIR/warzone-phone && git pull"
echo "[2/2] Rebuilding..."
ssh_cmd "cd $DOCKER_DIR && \
sed -i 's/voip.manko.yoga/$DOMAIN/g' Caddyfile && \
sed -i 's|voip.manko.yoga/audio|$DOMAIN/audio|g' docker-compose.yml && \
docker compose up -d --build 2>&1" | tail -20
echo "=== Redeployed ==="
}
# ---------------------------------------------------------------------------
# --test
# ---------------------------------------------------------------------------
do_test() {
echo "=== Smoke Test: $DOMAIN ==="
local pass=0 fail=0
check() {
local name="$1" url="$2" expect="$3"
local status
status=$(curl -4 -s -o /dev/null -w "%{http_code}" --connect-timeout 10 "$url" 2>/dev/null || echo "000")
if [ "$status" = "$expect" ]; then
echo " OK $name ($status)"
pass=$((pass + 1))
else
echo " FAIL $name (got $status, expected $expect)"
fail=$((fail + 1))
fi
}
check "Web UI" "https://$DOMAIN/" "200"
check "Health" "https://$DOMAIN/v1/health" "200"
check "WASM" "https://$DOMAIN/wasm/warzone_wasm.js" "200"
check "Relay config" "https://$DOMAIN/v1/wzp/relay-config" "200"
check "Bot list" "https://$DOMAIN/v1/bot/list" "200"
check "Whoami" "https://$DOMAIN/v1/whoami" "200"
# TLS
local issuer
issuer=$(echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null || echo "?")
echo " TLS: $issuer"
# IPv4
local v4; v4=$(dig +short "$DOMAIN" A 2>/dev/null || echo "?")
echo " A: $v4"
# IPv6
local v6; v6=$(dig +short "$DOMAIN" AAAA 2>/dev/null || echo "?")
echo " AAAA: $v6"
# IPv6 connectivity
local v6_status
v6_status=$(curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://$DOMAIN/" 2>/dev/null || echo "000")
[ "$v6_status" = "200" ] && echo " IPv6: reachable ($v6_status)" && pass=$((pass + 1)) || echo " IPv6: not reachable ($v6_status)"
# Whoami content
local whoami
whoami=$(curl -4 -s "https://$DOMAIN/v1/whoami" 2>/dev/null)
echo " Whoami: $whoami"
echo ""
echo "Results: $pass passed, $fail failed"
}
# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------
do_ssh() {
local ip; ip=$(get_vm_ip)
[ -z "$ip" ] && { echo "No VM." >&2; exit 1; }
exec ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip"
}
do_logs() {
ssh_cmd "cd $DOCKER_DIR && docker compose logs -f --tail=50"
}
do_destroy() {
local existing
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
if [ -z "$existing" ]; then
echo "No VM '$VM_NAME'."
return
fi
echo "Destroying: $VM_NAME"
hcloud server delete "$VM_NAME"
echo "VM deleted."
read -p "Remove DNS records for $DOMAIN? [y/N] " -n 1 -r; echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
local cf_token zone_id
cf_token=$(get_cf_token)
zone_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
for type in A AAAA; do
local rec_id
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
[ -n "$rec_id" ] && curl -4 -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $cf_token" > /dev/null && echo " Deleted $type"
done
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
case "${1:-}" in
--create) do_create ;;
--dns) do_dns ;;
--deploy) do_deploy ;;
--redeploy) do_redeploy ;;
--test) do_test ;;
--ssh) do_ssh ;;
--logs) do_logs ;;
--destroy) do_destroy ;;
--all) do_create; do_dns; do_deploy; echo ""; echo "Waiting 30s for TLS cert..."; sleep 30; do_test ;;
*)
echo "Deploy featherChat to chat.manko.yoga (Hetzner cx23)"
echo ""
echo "Usage: $0 <command>"
echo ""
echo " --create Create VPS + install Docker"
echo " --dns Update Cloudflare A + AAAA records"
echo " --deploy Clone repos + docker compose up"
echo " --redeploy Git pull + rebuild"
echo " --test Smoke test (6 checks + TLS + IPv6)"
echo " --ssh SSH into VPS"
echo " --logs Tail docker compose logs"
echo " --destroy Delete VPS + DNS"
echo " --all Full deploy (create + dns + deploy + test)"
;;
esac

387
warzone/scripts/deploy-voip.sh Executable file
View File

@@ -0,0 +1,387 @@
#!/usr/bin/env bash
set -euo pipefail
# Deploy featherChat + WZP stack to voip.manko.yoga on a Hetzner VPS.
# Prerequisites: hcloud CLI authenticated, SSH key "wz", CF API token.
#
# Usage:
# ./scripts/deploy-voip.sh --create Create VPS, install Docker, deploy stack
# ./scripts/deploy-voip.sh --deploy Upload source + docker compose up (on existing VPS)
# ./scripts/deploy-voip.sh --dns Update Cloudflare DNS records
# ./scripts/deploy-voip.sh --test Run smoke tests against voip.manko.yoga
# ./scripts/deploy-voip.sh --ssh SSH into the VPS
# ./scripts/deploy-voip.sh --destroy Delete VPS + DNS records
# ./scripts/deploy-voip.sh --logs Tail docker compose logs
# ./scripts/deploy-voip.sh --all Create + DNS + deploy + test
VM_NAME="fc-voip"
SSH_KEY_NAME="wz"
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
SERVER_TYPE="cx23"
IMAGE="debian-12"
LOCATION="fsn1"
REMOTE_USER="root"
DOMAIN="voip.manko.yoga"
CF_ZONE="manko.yoga"
PROJECT_ROOT="/Users/manwe/CascadeProjects/featherChat"
DEPLOY_DIR="warzone/deploy/docker"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -q"
# ---------------------------------------------------------------------------
# CF Token — read from deploy/docker/cf_api_token.txt or env
# ---------------------------------------------------------------------------
get_cf_token() {
if [ -f "$PROJECT_ROOT/$DEPLOY_DIR/cf_api_token.txt" ]; then
cat "$PROJECT_ROOT/$DEPLOY_DIR/cf_api_token.txt" | tr -d '\n'
elif [ -n "${CF_API_TOKEN:-}" ]; then
echo "$CF_API_TOKEN"
else
echo "ERROR: No CF token. Create $DEPLOY_DIR/cf_api_token.txt or set CF_API_TOKEN" >&2
exit 1
fi
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
get_vm_ip() {
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | tr -d ' '
}
get_vm_ipv6() {
hcloud server list -o columns=name,ipv6 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | sed 's|/64||'
}
ssh_cmd() {
local ip
ip=$(get_vm_ip)
[ -z "$ip" ] && { echo "ERROR: No VM '$VM_NAME'. Run --create first." >&2; exit 1; }
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
}
scp_to() {
local ip
ip=$(get_vm_ip)
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$@" "$REMOTE_USER@$ip:/root/" 2>/dev/null
}
# ---------------------------------------------------------------------------
# --create: Create VPS + install Docker
# ---------------------------------------------------------------------------
do_create() {
local existing
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
if [ -n "$existing" ]; then
echo "VM already exists: $VM_NAME"
echo " IPv4: $(get_vm_ip)"
echo " IPv6: $(get_vm_ipv6)"
return
fi
echo "[1/4] Creating Hetzner VPS: $VM_NAME ($SERVER_TYPE, $LOCATION)..."
hcloud server create \
--name "$VM_NAME" \
--type "$SERVER_TYPE" \
--image "$IMAGE" \
--ssh-key "$SSH_KEY_NAME" \
--location "$LOCATION" \
--quiet
local ipv4 ipv6
ipv4=$(get_vm_ip)
ipv6=$(get_vm_ipv6)
echo " IPv4: $ipv4"
echo " IPv6: $ipv6"
# Wait for SSH
echo "[2/4] Waiting for SSH..."
for i in $(seq 1 30); do
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ipv4" "echo ok" &>/dev/null; then
break
fi
sleep 2
done
# Install Docker
echo "[3/4] Installing Docker..."
ssh_cmd "apt-get update -qq > /dev/null 2>&1 && \
apt-get install -y -qq ca-certificates curl gnupg > /dev/null 2>&1 && \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
chmod a+r /etc/apt/keyrings/docker.gpg && \
echo 'deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable' > /etc/apt/sources.list.d/docker.list && \
apt-get update -qq > /dev/null 2>&1 && \
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1"
# Enable IPv6 in Docker
echo "[4/4] Configuring Docker IPv6..."
ssh_cmd 'mkdir -p /etc/docker && echo "{\"ipv6\": true, \"fixed-cidr-v6\": \"fd00::/80\"}" > /etc/docker/daemon.json && systemctl restart docker'
echo ""
echo "=== VPS Ready ==="
echo "IPv4: $ipv4"
echo "IPv6: $ipv6"
echo "SSH: ssh -i $SSH_KEY_PATH root@$ipv4"
}
# ---------------------------------------------------------------------------
# --dns: Update Cloudflare DNS records
# ---------------------------------------------------------------------------
do_dns() {
local ipv4 ipv6 cf_token zone_id
ipv4=$(get_vm_ip)
ipv6=$(get_vm_ipv6)
cf_token=$(get_cf_token)
[ -z "$ipv4" ] && { echo "ERROR: No VM. Run --create first." >&2; exit 1; }
echo "Updating DNS: $DOMAIN"
echo " A → $ipv4"
echo " AAAA → ${ipv6}1"
# Get zone ID
zone_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
-H "Authorization: Bearer $cf_token" \
-H "Content-Type: application/json" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
echo " Zone: $zone_id"
# Upsert A record
local a_id
a_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=A" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$a_id" ]; then
curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$a_id" \
-H "Authorization: Bearer $cf_token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$ipv4\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " A record updated"
else
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
-H "Authorization: Bearer $cf_token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$ipv4\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " A record created"
fi
# Upsert AAAA record (append ::1 to the /64 prefix)
local aaaa_id aaaa_addr="${ipv6}1"
aaaa_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=AAAA" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$aaaa_id" ]; then
curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$aaaa_id" \
-H "Authorization: Bearer $cf_token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"AAAA\",\"name\":\"$DOMAIN\",\"content\":\"$aaaa_addr\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " AAAA record updated"
else
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
-H "Authorization: Bearer $cf_token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"AAAA\",\"name\":\"$DOMAIN\",\"content\":\"$aaaa_addr\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " AAAA record created"
fi
echo " Done. Verify: dig $DOMAIN A +short && dig $DOMAIN AAAA +short"
}
# ---------------------------------------------------------------------------
# --deploy: Upload source + docker compose up
# ---------------------------------------------------------------------------
do_deploy() {
local ip cf_token
ip=$(get_vm_ip)
cf_token=$(get_cf_token)
[ -z "$ip" ] && { echo "ERROR: No VM. Run --create first." >&2; exit 1; }
echo "[1/4] Creating source tarball..."
tar czf /tmp/fc-voip.tar.gz \
--exclude='target' \
--exclude='.git' \
--exclude='.claude' \
--exclude='.DS_Store' \
--exclude='notes' \
-C "$PROJECT_ROOT" \
warzone/Cargo.toml warzone/Cargo.lock warzone/crates \
warzone/deploy/docker \
warzone/wasm-pkg \
warzone-phone/Cargo.toml warzone-phone/Cargo.lock warzone-phone/crates \
.dockerignore \
2>/dev/null || true
local size
size=$(du -h /tmp/fc-voip.tar.gz | cut -f1)
echo " Tarball: $size"
echo "[2/4] Uploading to $ip..."
scp $SSH_OPTS -i "$SSH_KEY_PATH" /tmp/fc-voip.tar.gz "$REMOTE_USER@$ip:/root/fc-voip.tar.gz"
ssh_cmd "rm -rf /root/featherChat && mkdir -p /root/featherChat && tar xzf /root/fc-voip.tar.gz -C /root/featherChat"
rm -f /tmp/fc-voip.tar.gz
echo "[3/4] Setting up CF token + docker compose..."
ssh_cmd "cd /root/featherChat/warzone/deploy/docker && echo '$cf_token' > cf_api_token.txt && chmod 600 cf_api_token.txt"
echo "[4/4] Building + starting stack (this takes a while on first run)..."
ssh_cmd "cd /root/featherChat/warzone/deploy/docker && docker compose up -d --build 2>&1" | tail -20
echo ""
echo "=== Deployed ==="
echo "URL: https://$DOMAIN"
echo "Logs: $0 --logs"
echo "Test: $0 --test"
}
# ---------------------------------------------------------------------------
# --test: Smoke test
# ---------------------------------------------------------------------------
do_test() {
echo "=== Smoke Test: $DOMAIN ==="
echo ""
local pass=0 fail=0
check() {
local name="$1" url="$2" expect="$3"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$url" 2>/dev/null || echo "000")
if [ "$status" = "$expect" ]; then
echo " OK $name ($status)"
pass=$((pass + 1))
else
echo " FAIL $name (got $status, expected $expect)"
fail=$((fail + 1))
fi
}
check "Web UI" "https://$DOMAIN/" "200"
check "API health" "https://$DOMAIN/v1/health" "200"
check "WASM module" "https://$DOMAIN/wasm/warzone_wasm.js" "200"
check "Relay config" "https://$DOMAIN/v1/wzp/relay-config" "200"
check "Audio bridge" "https://$DOMAIN/audio/" "200"
check "Bot list" "https://$DOMAIN/v1/bot/list" "200"
# TLS check
echo -n " "
local issuer
issuer=$(echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null || echo "unknown")
echo "TLS: $issuer"
# IPv4
echo -n " "
local v4
v4=$(dig +short "$DOMAIN" A 2>/dev/null || echo "?")
echo "IPv4: $v4"
# IPv6
echo -n " "
local v6
v6=$(dig +short "$DOMAIN" AAAA 2>/dev/null || echo "?")
echo "IPv6: $v6"
# IPv6 connectivity
echo -n " "
local v6_status
v6_status=$(curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://$DOMAIN/" 2>/dev/null || echo "000")
if [ "$v6_status" = "200" ]; then
echo "IPv6 reachable: OK ($v6_status)"
pass=$((pass + 1))
else
echo "IPv6 reachable: no ($v6_status)"
fi
echo ""
echo "Results: $pass passed, $fail failed"
}
# ---------------------------------------------------------------------------
# --ssh / --logs / --destroy
# ---------------------------------------------------------------------------
do_ssh() {
local ip
ip=$(get_vm_ip)
[ -z "$ip" ] && { echo "No VM." >&2; exit 1; }
exec ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip"
}
do_logs() {
ssh_cmd "cd /root/featherChat/warzone/deploy/docker && docker compose logs -f --tail=50"
}
do_destroy() {
local existing
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
if [ -z "$existing" ]; then
echo "No VM '$VM_NAME' to destroy."
return
fi
echo "Destroying VM: $VM_NAME"
hcloud server delete "$VM_NAME"
echo "VM deleted."
# Optionally clean DNS
echo ""
read -p "Also remove DNS records for $DOMAIN? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
local cf_token zone_id
cf_token=$(get_cf_token)
zone_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
for type in A AAAA; do
local rec_id
rec_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$rec_id" ]; then
curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $cf_token" > /dev/null
echo " Deleted $type record"
fi
done
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
case "${1:-}" in
--create) do_create ;;
--dns) do_dns ;;
--deploy) do_deploy ;;
--test) do_test ;;
--ssh) do_ssh ;;
--logs) do_logs ;;
--destroy) do_destroy ;;
--all) do_create; do_dns; do_deploy; do_test ;;
*)
echo "Deploy featherChat stack to voip.manko.yoga"
echo ""
echo "Usage: $0 <command>"
echo ""
echo "Commands:"
echo " --create Create Hetzner cx23 VPS + install Docker"
echo " --dns Update Cloudflare DNS (A + AAAA)"
echo " --deploy Upload source + docker compose up"
echo " --test Smoke test (6 HTTP checks + TLS + IPv6)"
echo " --ssh SSH into the VPS"
echo " --logs Tail docker compose logs"
echo " --destroy Delete VPS + optionally DNS"
echo " --all create + dns + deploy + test"
echo ""
echo "First run: $0 --all"
echo "Redeploy: $0 --deploy && $0 --test"
;;
esac

35
warzone/scripts/start-voip.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
set -euo pipefail
# Start featherChat Docker stack + update DNS.
# Usage: ./scripts/start-voip.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DOCKER_DIR="$PROJECT_DIR/deploy/docker"
DNS_SCRIPT="$DOCKER_DIR/update-dns.sh"
CF_TOKEN_FILE="$DOCKER_DIR/cf_api_token.txt"
# Check CF token
if [ ! -f "$CF_TOKEN_FILE" ]; then
echo "ERROR: $CF_TOKEN_FILE not found"
echo " echo 'YOUR_CF_TOKEN' > $CF_TOKEN_FILE"
exit 1
fi
export CF_API_TOKEN=$(cat "$CF_TOKEN_FILE" | tr -d '\n')
# Update DNS first
echo "=== Updating DNS ==="
bash "$DNS_SCRIPT" --once
# Start Docker stack
echo ""
echo "=== Starting Docker stack ==="
cd "$DOCKER_DIR"
docker compose up -d
echo ""
echo "=== Running ==="
echo "URL: https://voip.manko.yoga"
echo "Logs: docker compose -f $DOCKER_DIR/docker-compose.yml logs -f"

171
warzone/scripts/test-variants.sh Executable file
View File

@@ -0,0 +1,171 @@
#!/bin/bash
set -euo pipefail
# Test all 6 WZP web client variants with dedicated subdomains.
#
# Usage:
# ./scripts/test-variants.sh --setup Create DNS + switch Caddyfile
# ./scripts/test-variants.sh --teardown Remove DNS + restore Caddyfile
# ./scripts/test-variants.sh --urls Print all test URLs
# ./scripts/test-variants.sh --check Verify all 6 respond
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DOCKER_DIR="$PROJECT_DIR/deploy/docker"
CF_TOKEN_FILE="$DOCKER_DIR/cf_api_token.txt"
CF_ZONE="manko.yoga"
BASE_DOMAIN="voip.manko.yoga"
VARIANTS=(v1 v2 v3 v4 v5 v6)
LABELS=("pure" "hybrid" "full" "ws" "ws-fec" "ws-full")
get_cf_token() {
cat "$CF_TOKEN_FILE" | tr -d '\n'
}
get_zone_id() {
curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
-H "Authorization: Bearer $(get_cf_token)" | \
python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])"
}
get_my_ip() {
curl -4 -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || \
ifconfig | grep "inet " | grep -v 127.0.0.1 | head -1 | awk '{print $2}'
}
do_setup() {
local ip zone_id cf_token
ip=$(get_my_ip)
cf_token=$(get_cf_token)
zone_id=$(get_zone_id)
echo "Setting up variant testing"
echo " IP: $ip"
echo " Zone: $zone_id"
echo ""
# Create A records for each subdomain
for i in "${!VARIANTS[@]}"; do
local sub="${VARIANTS[$i]}"
local fqdn="${sub}.${BASE_DOMAIN}"
local label="${LABELS[$i]}"
# Check existing
local rec_id
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$fqdn&type=A" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$rec_id" ]; then
curl -4 -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"$fqdn\",\"content\":\"$ip\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $fqdn$ip (updated) [$label]"
else
curl -4 -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"$fqdn\",\"content\":\"$ip\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $fqdn$ip (created) [$label]"
fi
done
# Switch Caddyfile
echo ""
echo "Switching Caddyfile to test mode..."
cp "$DOCKER_DIR/Caddyfile" "$DOCKER_DIR/Caddyfile.backup"
cp "$DOCKER_DIR/Caddyfile.test" "$DOCKER_DIR/Caddyfile"
echo "Restarting Caddy..."
cd "$DOCKER_DIR" && docker compose restart caddy
echo ""
echo "=== Ready ==="
do_urls
}
do_teardown() {
local cf_token zone_id
cf_token=$(get_cf_token)
zone_id=$(get_zone_id)
echo "Tearing down variant testing..."
for sub in "${VARIANTS[@]}"; do
local fqdn="${sub}.${BASE_DOMAIN}"
local rec_id
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$fqdn&type=A" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$rec_id" ]; then
curl -4 -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $cf_token" > /dev/null
echo " Deleted $fqdn"
fi
done
# Restore Caddyfile
if [ -f "$DOCKER_DIR/Caddyfile.backup" ]; then
mv "$DOCKER_DIR/Caddyfile.backup" "$DOCKER_DIR/Caddyfile"
echo "Restored original Caddyfile"
cd "$DOCKER_DIR" && docker compose restart caddy
fi
echo "Done."
}
do_urls() {
echo ""
echo "Test URLs (open each in a browser tab, enter same room name):"
echo ""
echo " ┌────────┬──────────┬──────────────────────────────────────────┐"
echo " │ Domain │ Variant │ URL │"
echo " ├────────┼──────────┼──────────────────────────────────────────┤"
for i in "${!VARIANTS[@]}"; do
local sub="${VARIANTS[$i]}"
local label="${LABELS[$i]}"
printf " │ %-6s │ %-8s │ https://%s.%s/test-room?variant=%s │\n" "$sub" "$label" "$sub" "$BASE_DOMAIN" "$label"
done
echo " └────────┴──────────┴──────────────────────────────────────────┘"
echo ""
echo "All variants join the same room — test cross-variant audio."
echo "featherChat: https://$BASE_DOMAIN (call via /call command)"
}
do_check() {
echo "Checking all variant endpoints..."
local pass=0 fail=0
for i in "${!VARIANTS[@]}"; do
local sub="${VARIANTS[$i]}"
local label="${LABELS[$i]}"
local url="https://${sub}.${BASE_DOMAIN}/test-room?variant=${label}"
local status
status=$(curl -4 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$url" 2>/dev/null || echo "000")
if [ "$status" = "200" ]; then
echo " OK $sub ($label): $status"
pass=$((pass + 1))
else
echo " FAIL $sub ($label): $status"
fail=$((fail + 1))
fi
done
echo ""
echo "Results: $pass passed, $fail failed"
}
case "${1:-}" in
--setup) do_setup ;;
--teardown) do_teardown ;;
--urls) do_urls ;;
--check) do_check ;;
*)
echo "Test all 6 WZP web client variants"
echo ""
echo "Usage: $0 <command>"
echo ""
echo " --setup Create DNS records + switch Caddyfile"
echo " --teardown Remove DNS records + restore Caddyfile"
echo " --urls Print test URLs"
echo " --check Verify all 6 respond with HTTP 200"
;;
esac