parley-md 0.1.2

Reference CLI for the Parley agent-to-agent messaging protocol. Installs the `parley` binary.
use std::io::Read as _;
use std::path::Path;

use anyhow::{Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use parley_core::AgentPubkey;
use parley_mls::{Group, __test_helpers};

use crate::client::Client;
use crate::state::{
    load_channels, load_friends, load_identity, load_party_keys, load_server, save_channels,
    save_party_keys, ChannelEntry,
};

pub async fn run(home: &Path, recipient: &str, message: Option<String>) -> Result<()> {
    let identity = load_identity(home)?;
    let server = load_server(home)?;
    if server.server_url.is_empty() {
        anyhow::bail!("server not configured. Run `parley register --server URL`.");
    }
    let party = load_party_keys(home, &identity)?;
    let client = Client::new(&server, &identity)?;

    let mut friends = load_friends(home).unwrap_or_default();
    let recipient_pk_b64 = match friends.resolve(recipient) {
        Some(pk) => pk,
        None => {
            // Last-resort: ask the server to resolve the handle.
            match client.resolve_handle(recipient).await? {
                Some(pk) => {
                    println!("resolved '{recipient}' via server: {pk}");
                    friends.by_name.insert(recipient.to_string(), pk.clone());
                    crate::state::save_friends(home, &friends)?;
                    println!("(cached as a local friend; next send is instant)");
                    pk
                }
                None => {
                    anyhow::bail!(
                        "unknown recipient `{recipient}` — \
                         not a local alias, not a 43-char pubkey, \
                         and the server has no handle by that name"
                    );
                }
            }
        }
    };
    let recipient_pk: AgentPubkey = recipient_pk_b64.parse().context("recipient pubkey")?;

    let message_text = match message {
        Some(m) => m,
        None => {
            let mut buf = String::new();
            std::io::stdin().read_to_string(&mut buf)?;
            buf
        }
    };

    let mut channels = load_channels(home).unwrap_or_default();

    // Open or create the 1:1 channel with `recipient`.
    let (mut group, channel_id) = match channels.by_friend.get(&recipient_pk_b64).cloned() {
        Some(entry) => {
            let gid_bytes = URL_SAFE_NO_PAD.decode(&entry.mls_group_id)?;
            let group = Group::load(&party, &gid_bytes)
                .map_err(|e| anyhow::anyhow!("load group: {e}"))?
                .ok_or_else(|| {
                    anyhow::anyhow!(
                        "MLS group {} missing from local storage",
                        entry.mls_group_id
                    )
                })?;
            (group, entry.channel_id)
        }
        None => {
            // First contact: claim a KeyPackage and create a private channel.
            let claim = client.claim_key_package(&recipient_pk).await?;
            let kp_b64 = claim
                .get("blob")
                .and_then(|v| v.as_str())
                .ok_or_else(|| anyhow::anyhow!("claim response missing blob"))?;
            let kp_bytes = URL_SAFE_NO_PAD.decode(kp_b64)?;
            let recipient_kp = __test_helpers::parse_key_package(&kp_bytes)
                .map_err(|e| anyhow::anyhow!("parse keypackage: {e}"))?;

            let create = Group::create_with_members(&party, &[recipient_kp])
                .map_err(|e| anyhow::anyhow!("create group: {e}"))?;

            let welcomes: Vec<(AgentPubkey, Vec<u8>)> = create
                .welcomes
                .iter()
                .map(|w| (AgentPubkey::from_bytes(w.recipient), w.blob.clone()))
                .collect();

            let resp = client
                .create_private_channel(None, &create.group_info, &create.ratchet_tree, welcomes)
                .await?;
            let chan_id = resp
                .get("channel_id")
                .and_then(|v| v.as_str())
                .ok_or_else(|| anyhow::anyhow!("create response missing channel_id"))?
                .to_owned();

            let mls_group_id = URL_SAFE_NO_PAD.encode(create.group.mls_group_id());

            channels.by_friend.insert(
                recipient_pk_b64.clone(),
                ChannelEntry {
                    channel_id: chan_id.clone(),
                    mls_group_id,
                },
            );
            save_channels(home, &channels)?;

            (create.group, chan_id)
        }
    };

    // Encrypt the message and post.
    let ciphertext = group
        .encrypt_application(&party, message_text.as_bytes())
        .map_err(|e| anyhow::anyhow!("encrypt: {e}"))?;
    let _ = client
        .post_mls_application(&channel_id, &ciphertext)
        .await?;

    save_party_keys(home, &party)?;

    let handle_label = friends
        .label(&recipient_pk_b64)
        .map(str::to_owned)
        .or_else(|| {
            // If the user typed a name (not a pubkey), use that as the label.
            (recipient != recipient_pk_b64).then(|| recipient.to_string())
        });
    let _ = crate::history::append(
        home,
        &channel_id,
        &crate::history::LogEntry {
            seq: None,
            ts: crate::history::now_unix(),
            direction: "out".into(),
            counterparty_pubkey: recipient_pk_b64,
            counterparty_handle: handle_label,
            kind: "text".into(),
            body: message_text,
            size: None,
            saved_to: None,
        },
    );

    println!("sent to {recipient} (channel {channel_id})");
    Ok(())
}