iris-chat 0.1.52

Iris Chat command line client and shared encrypted chat core
Documentation
use std::io::{self, BufRead};
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, Instant};

use anyhow::{bail, Context, Result};
use base64::Engine;
use iris_chat_core::stack_mesh_fixture::stack_mesh_fixture;
use iris_chat_core::{download_hashtree_attachment, AppAction, FfiApp};
use nostr::EventId;
use serde_json::json;
use sha2::{Digest, Sha256};

const READY_WAIT: Duration = Duration::from_secs(15);

fn main() -> Result<()> {
    let mut args = std::env::args_os().skip(1);
    let command = args
        .next()
        .context("usage: iris-chat-stack-fixture run <data-dir>")?;
    if command != "run" {
        bail!("usage: iris-chat-stack-fixture run <data-dir>");
    }
    let data_dir = PathBuf::from(
        args.next()
            .context("usage: iris-chat-stack-fixture run <data-dir>")?,
    );
    if args.next().is_some() {
        bail!("usage: iris-chat-stack-fixture run <data-dir>");
    }
    std::fs::create_dir_all(&data_dir).context("create Chat fixture data directory")?;

    let app = FfiApp::new(
        data_dir.to_string_lossy().into_owned(),
        String::new(),
        String::new(),
    );
    app.dispatch(AppAction::CreateAccount {
        name: "Iris Stack fixture".to_string(),
    });
    let deadline = Instant::now() + READY_WAIT;
    let account = loop {
        let state = app.state();
        if let Some(account) = state.account {
            break account;
        }
        if let Some(error) = state.toast {
            bail!("Chat fixture account setup failed: {error}");
        }
        if Instant::now() >= deadline {
            bail!("Chat fixture account setup timed out");
        }
        thread::sleep(Duration::from_millis(25));
    };
    let mesh = if std::env::var("IRIS_CHAT_FIPS_ROUTED_PEERS").is_ok() {
        let deadline = Instant::now() + READY_WAIT;
        loop {
            if let Some(mesh) = stack_mesh_fixture() {
                break Some(mesh);
            }
            if Instant::now() >= deadline {
                bail!("Chat shared mesh runtime did not start");
            }
            thread::sleep(Duration::from_millis(25));
        }
    } else {
        stack_mesh_fixture()
    };
    let runtime = tokio::runtime::Runtime::new()?;
    let mut subscription = mesh
        .as_ref()
        .map(|mesh| runtime.block_on(mesh.subscribe_public_events()))
        .transpose()?;
    emit(json!({
        "event": "ready",
        "npub": account.device_npub,
        "owner_npub": account.npub,
    }))?;

    for line in io::stdin().lock().lines() {
        let line = line.context("read Chat fixture command")?;
        if let Some(command) = line.strip_prefix("publish ") {
            let mesh = mesh.as_ref().context("Chat mesh runtime unavailable")?;
            let (kind, content) = command
                .split_once(' ')
                .context("expected publish <kind> <content>")?;
            emit(runtime.block_on(mesh.publish(kind.parse()?, content))?)?;
            continue;
        }
        if let Some(id) = line.strip_prefix("receive ") {
            let id = EventId::parse(id.trim())?;
            let subscription = subscription
                .as_mut()
                .context("Chat mesh subscription unavailable")?;
            let received = runtime.block_on(async {
                tokio::time::timeout(READY_WAIT, async {
                    while let Some(delivery) = subscription.recv().await {
                        if delivery.event.as_event().id == id {
                            return Some(delivery);
                        }
                    }
                    None
                })
                .await
            });
            emit(match received {
                Ok(Some(delivery)) => {
                    let event = delivery.event.into_event();
                    json!({"event": "received", "id": event.id.to_string(),
                        "pubkey": event.pubkey.to_hex(), "kind": event.kind.as_u16(),
                        "content": event.content, "verified": event.verify().is_ok(),
                        "origin_peer_id": delivery.source.id.0})
                }
                _ => {
                    json!({"event": "received", "id": id.to_string(), "error": "event receive timed out or stopped"})
                }
            })?;
            continue;
        }
        let mut parts = line.split_whitespace();
        match (parts.next(), parts.next(), parts.next()) {
            (Some("fetch"), Some(nhash), None) => emit(fetch_event(nhash))?,
            (Some("status"), None, None) => emit(match &mesh {
                Some(mesh) => runtime.block_on(mesh.status())?,
                None => json!({"event": "status", "npub": account.device_npub,
                    "owner_npub": account.npub}),
            })?,
            (Some("stop"), None, None) => {
                app.shutdown();
                emit(json!({ "event": "stopped" }))?;
                return Ok(());
            }
            _ => emit(json!({
                "event": "error",
                "error": "expected fetch <nhash>, publish <kind> <content>, receive <id>, status, or stop",
            }))?,
        }
    }
    app.shutdown();
    Ok(())
}

fn fetch_event(nhash: &str) -> serde_json::Value {
    let result = download_hashtree_attachment(nhash.to_string());
    match result.data_base64 {
        Some(encoded) => match base64::engine::general_purpose::STANDARD.decode(encoded) {
            Ok(bytes) => json!({
                "event": "fetch",
                "nhash": nhash,
                "fetched": bytes.len(),
                "sha256": format!("{:x}", Sha256::digest(&bytes)),
            }),
            Err(error) => json!({
                "event": "fetch",
                "nhash": nhash,
                "fetched": 0,
                "error": format!("invalid production base64: {error}"),
            }),
        },
        None => json!({
            "event": "fetch",
            "nhash": nhash,
            "fetched": 0,
            "error": result.error.unwrap_or_else(|| "attachment fetch failed".to_string()),
        }),
    }
}

fn emit(value: serde_json::Value) -> Result<()> {
    println!("{value}");
    use std::io::Write;
    io::stdout().flush().context("flush Chat fixture event")
}