Files
featherChat/warzone/crates/warzone-client/src/net.rs
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

173 lines
5.5 KiB
Rust

//! HTTP client for talking to warzone-server.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use warzone_protocol::prekey::PreKeyBundle;
#[derive(Clone)]
pub struct ServerClient {
pub base_url: String,
pub client: reqwest::Client,
}
#[derive(Serialize)]
struct RegisterRequest {
fingerprint: String,
bundle: Vec<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
eth_address: Option<String>,
}
#[derive(Serialize)]
struct SendRequest {
to: String,
from: Option<String>,
message: Vec<u8>,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct BundleResponse {
fingerprint: String,
bundle: String, // base64
}
impl ServerClient {
pub fn new(base_url: &str) -> Self {
ServerClient {
base_url: base_url.trim_end_matches('/').to_string(),
client: reqwest::Client::new(),
}
}
/// Register our pre-key bundle with the server.
pub async fn register_bundle(
&self,
fingerprint: &str,
bundle: &PreKeyBundle,
eth_address: Option<String>,
) -> Result<()> {
let encoded =
bincode::serialize(bundle).context("failed to serialize bundle")?;
self.client
.post(format!("{}/v1/keys/register", self.base_url))
.json(&RegisterRequest {
fingerprint: fingerprint.to_string(),
bundle: encoded,
eth_address,
})
.send()
.await
.context("failed to register bundle")?;
Ok(())
}
/// Fetch a user's pre-key bundle from the server.
pub async fn fetch_bundle(&self, fingerprint: &str) -> Result<PreKeyBundle> {
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let response = self
.client
.get(format!(
"{}/v1/keys/{}",
self.base_url, fp_clean
))
.send()
.await
.context("failed to fetch bundle")?;
if !response.status().is_success() {
anyhow::bail!(
"server returned {} — user {} may not be registered",
response.status(),
fingerprint
);
}
let resp: BundleResponse = response
.json()
.await
.context("failed to parse bundle response")?;
let bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&resp.bundle,
)
.context("failed to decode base64 bundle")?;
bincode::deserialize(&bytes).context("failed to deserialize bundle")
}
/// Send an encrypted message to the server for delivery.
pub async fn send_message(&self, to: &str, from: Option<&str>, message: &[u8]) -> Result<()> {
let to_clean: String = to.chars().filter(|c| c.is_ascii_hexdigit()).collect();
self.client
.post(format!("{}/v1/messages/send", self.base_url))
.json(&SendRequest {
to: to_clean,
from: from.map(|f| f.chars().filter(|c| c.is_ascii_hexdigit()).collect()),
message: message.to_vec(),
})
.send()
.await
.context("failed to send message")?;
Ok(())
}
/// Check how many one-time pre-keys remain on the server.
pub async fn otpk_count(&self, fingerprint: &str) -> Result<u64> {
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let resp: serde_json::Value = self.client
.get(format!("{}/v1/keys/{}/otpk-count", self.base_url, fp_clean))
.send()
.await
.context("failed to check OTPK count")?
.json()
.await
.context("failed to parse OTPK count")?;
Ok(resp.get("count").and_then(|v| v.as_u64()).unwrap_or(0))
}
/// Upload additional one-time pre-keys.
pub async fn replenish_otpks(&self, fingerprint: &str, keys: Vec<(u32, [u8; 32])>) -> Result<()> {
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let otpks: Vec<serde_json::Value> = keys.iter().map(|(id, pubkey)| {
serde_json::json!({"id": id, "public_key": hex::encode(pubkey)})
}).collect();
self.client
.post(format!("{}/v1/keys/replenish", self.base_url))
.json(&serde_json::json!({"fingerprint": fp_clean, "one_time_pre_keys": otpks}))
.send()
.await
.context("failed to replenish OTPKs")?;
Ok(())
}
/// Poll for messages addressed to us.
pub async fn poll_messages(&self, fingerprint: &str) -> Result<Vec<Vec<u8>>> {
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let resp: Vec<String> = self
.client
.get(format!(
"{}/v1/messages/poll/{}",
self.base_url, fp_clean
))
.send()
.await
.context("failed to poll messages")?
.json()
.await
.context("failed to parse poll response")?;
let mut messages = Vec::new();
for b64 in resp {
if let Ok(bytes) = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&b64,
) {
messages.push(bytes);
}
}
Ok(messages)
}
}