OXYGENHUB

Coverage / src/cli/human.rs 55.2%

116 of 210 lines run, 15 of 40 functions · run #28 on main · run missed

1
//! The human at a terminal (spec §19): `spectra say`, `spectra inbox
2
//! [--follow]`, `spectra search`, and `spectra api` for anything else. Each
3
//! signs with the owner's own key: through the daemon when it is serving,
4
//! else straight into the ledger.
5
6
use super::identity::owner_home;
7
use super::{ui, Ctx};
8
use anyhow::{bail, Context, Result};
9
use serde_json::{json, Value};
10
use spectra_core::conversation::{self as conv, Author, Post, Posted, Signer};
11
use spectra_core::keys::KeyPair;
12
use spectra_core::ledger::Ledger;
13
use spectra_core::principals::Principals;
14
use spectra_mcp::client::Client;
15
use std::path::PathBuf;
16
1710×
async fn owner_client(ctx: &Ctx, home: &std::path::Path) -> Result<Option<Client>> {
1810×
    ctx.owner_client(home).await
1910×
}
20
215×
async fn api(c: &Client, method: &str, path: &str, body: Option<Value>) -> Result<Value> {
225×
    super::call(c, method, path, body).await
235×
}
24
25
use super::encode;
26
273×
pub async fn say(
283×
    ctx: &Ctx,
293×
    channel: Option<String>,
303×
    reply_to: Option<String>,
313×
    body: String,
323×
    owner_home_flag: Option<PathBuf>,
333×
    as_json: bool,
343×
) -> Result<i32> {
353×
    let home = owner_home(ctx, owner_home_flag);
363×
    let (id, where_) = match owner_client(ctx, &home).await? {
372×
        Some(c) => {
382×
            let ch = channel.clone().unwrap_or_else(|| "general".into());
392×
            let v = api(
402×
                &c,
412×
                "POST",
422×
                &format!("/api/v1/channels/{}/messages", encode(ch.trim_start_matches('#'))),
432×
                Some(json!({ "body": body, "reply_to": reply_to })),
442×
            )
452×
            .await?;
462×
            if v["duplicate"] == true {
470×
                bail!("you said exactly this in the last minute ({})", v["id"].as_str().unwrap_or(""));
482×
            }
492×
            (v["id"].as_str().unwrap_or("").to_string(), v["channel"].as_str().unwrap_or("").to_string())
50
        }
51
        None => {
521×
            let ledger = ctx.writer().await?;
531×
            let owner = KeyPair::load_home(&home)?;
541×
            let principal = Principals::load(&ledger)
551×
                .await?
561×
                .principal(&owner.key, spectra_core::ids::now_ms())
571×
                .map_err(anyhow::Error::msg)
581×
                .context("run `spectra identity refresh` first")?;
591×
            let author = Author { principal, signer: Signer::Cli(&owner) };
601×
            let post = Post {
611×
                channel: if reply_to.is_some() { None } else { Some(channel.unwrap_or_else(|| "general".into())) },
621×
                body,
631×
                reply_to,
641×
                ..Default::default()
65
            };
661×
            let posted = conv::post(&ledger, &author, post, spectra_core::ids::now_ms()).await?;
671×
            ledger.checkpoint().await?;
681×
            match posted {
691×
                Posted::Message { id, channel } => (id, channel),
700×
                Posted::Duplicate { id } => bail!("you said exactly this in the last minute ({id})"),
71
            }
72
        }
73
    };
743×
    if as_json {
753×
        println!("{}", json!({ "id": id, "channel": where_ }));
763×
    } else {
770×
        println!("{} {}{}{}", ui::done("Posted"), ui::subject(&where_), ui::sep(), ui::id(&id));
780×
    }
793×
    Ok(0)
803×
}
81
820×
fn print_item(i: &Value) {
830×
    let kind = i["kind"].as_str().unwrap_or("");
840×
    let mark = if kind == "dm" { ui::warn(ui::glyph("✉", "@")) } else { ui::ok(ui::glyph("●", "*")) };
850×
    let from = i["from_display"].as_str().or(i["from"].as_str()).unwrap_or("");
860×
    let at = i["at_ms"].as_u64().map(ui::ago).unwrap_or_default();
870×
    println!(
88
        "{mark} {} {}{}{}{}{}",
890×
        ui::dim(kind),
900×
        ui::author(from),
910×
        ui::sep(),
920×
        i["channel"].as_str().unwrap_or(""),
930×
        ui::sep(),
940×
        ui::dim(at)
95
    );
960×
    println!("  {}", i["summary"].as_str().unwrap_or(""));
970×
    println!("  {}", ui::dim(format!("reply: spectra say --reply-to {} \"…\"", i["id"].as_str().unwrap_or(""))));
980×
}
99
100
/// The owner's unread items: from the daemon when one serves, else the ledger.
1012×
async fn inbox_items(ctx: &Ctx, client: Option<&Client>) -> Result<Vec<Value>> {
1022×
    if let Some(c) = client {
1031×
        return Ok(api(c, "GET", "/api/v1/inbox", None).await?.as_array().cloned().unwrap_or_default());
1041×
    }
1051×
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
1061×
    let mut items = Vec::new();
1071×
    for i in conv::inbox(&ledger, "human", None).await? {
1080×
        let from_display = conv::display_name(&ledger, &i.from).await;
1090×
        let mut v = serde_json::to_value(&i)?;
1100×
        v["from_display"] = from_display.into();
1110×
        items.push(v);
112
    }
1131×
    Ok(items)
1142×
}
115
1162×
pub async fn inbox(ctx: &Ctx, follow: bool, owner_home_flag: Option<PathBuf>, as_json: bool) -> Result<i32> {
1172×
    let home = owner_home(ctx, owner_home_flag);
1182×
    let client = owner_client(ctx, &home).await?;
119
    // Following: subscribe first, and read the inbox once the server says
120
    // the subscription holds, so nothing posted in between is missed.
1212×
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<bool>();
1222×
    let watcher = if follow {
1230×
        let stream_client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?;
1240×
        let w = tokio::spawn(async move {
1250×
            stream_client
1260×
                .stream("/api/v1/events?follow=1&kind=message.posted", |l| {
1270×
                    if l.starts_with(':') {
1280×
                        let _ = tx.send(false);
1290×
                    } else if l.starts_with("data:") {
1300×
                        let _ = tx.send(true);
1310×
                    }
1320×
                    true
1330×
                })
1340×
                .await
1350×
        });
1360×
        match tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv()).await {
1370×
            Ok(Some(_)) => {}
1380×
            _ => bail!("the server did not start the stream of new messages"),
139
        }
1400×
        Some(w)
141
    } else {
1422×
        None
143
    };
1442×
    let items = inbox_items(ctx, client.as_ref()).await?;
1452×
    if as_json && !follow {
1462×
        println!("{}", Value::Array(items));
1472×
        return Ok(0);
1480×
    }
1490×
    if items.is_empty() && !follow {
1500×
        println!("{} {}", ui::note(), ui::dim("nothing unread"));
1510×
    }
1520×
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1530×
    for i in &items {
1540×
        seen.insert(i["id"].as_str().unwrap_or("").to_string());
1550×
        if as_json {
1560×
            println!("{i}");
1570×
        } else {
1580×
            print_item(i);
1590×
        }
160
    }
1610×
    let Some(watcher) = watcher else { return Ok(0) };
1620×
    let client = client.context("--follow needs `spectra serve` running")?;
1630×
    if !as_json {
1640×
        println!("{} {}", ui::note(), ui::dim("following; Ctrl-C stops"));
1650×
    }
166
    // Every new message is a cue to look at the inbox again.
1670×
    while let Some(changed) = rx.recv().await {
1680×
        if !changed {
1690×
            continue;
1700×
        }
1710×
        for v in inbox_items(ctx, Some(&client)).await? {
1720×
            if seen.insert(v["id"].as_str().unwrap_or("").to_string()) {
1730×
                if as_json {
1740×
                    println!("{v}");
1750×
                } else {
1760×
                    print_item(&v);
1770×
                }
1780×
            }
179
        }
180
    }
1810×
    watcher.await??;
1820×
    Ok(0)
1832×
}
184
1853×
pub async fn search(
1863×
    ctx: &Ctx,
1873×
    query: &str,
1883×
    channel: Option<String>,
1893×
    author: Option<String>,
1903×
    since: Option<String>,
1913×
    limit: u32,
1923×
    as_json: bool,
1933×
) -> Result<i32> {
1943×
    let hits: Vec<Value> = match owner_client(ctx, &owner_home(ctx, None)).await? {
1952×
        Some(c) => {
1962×
            let mut q = format!("/api/v1/search?q={}&limit={limit}", encode(query));
1976×
            for (k, v) in [("channel", &channel), ("author", &author), ("since", &since)] {
1986×
                if let Some(v) = v {
1990×
                    q.push_str(&format!("&{k}={}", encode(v)));
2006×
                }
201
            }
2022×
            api(&c, "GET", &q, None).await?.as_array().cloned().unwrap_or_default()
203
        }
204
        None => {
2051×
            let ledger = Ledger::open_read_only(&ctx.db()?).await?;
2061×
            let owner = Principals::load(&ledger).await?;
2071×
            let me = owner.owner().context("no owner is recorded")?;
2081×
            let principal = spectra_core::principals::Principal::Owner { key: me.hash(), name: me.name.clone() };
2091×
            let since = since.as_deref().and_then(|s| spectra_mcp::tools::parse_since(s, spectra_core::ids::now_ms()));
2101×
            let mut out = Vec::new();
2111×
            for h in conv::search(&ledger, &principal, query, channel.as_deref(), author.as_deref(), since, limit).await? {
2121×
                let mut v = serde_json::to_value(&h)?;
2131×
                v["author_display"] = conv::display_name(&ledger, &h.author).await.into();
2141×
                out.push(v);
215
            }
2161×
            out
217
        }
218
    };
2193×
    if as_json {
2203×
        println!("{}", Value::Array(hits));
2213×
        return Ok(0);
2220×
    }
2230×
    if hits.is_empty() {
2240×
        println!("{} {}", ui::note(), ui::dim("no matches"));
2250×
    }
2260×
    for h in hits {
2270×
        let id = h["id"].as_str().unwrap_or("");
2280×
        println!(
2290×
            "{} {}{}{}{}{}",
2300×
            ui::id(&id[..12.min(id.len())]),
2310×
            ui::author(h["author_display"].as_str().or(h["author"].as_str()).unwrap_or("")),
2320×
            ui::sep(),
2330×
            h["channel"].as_str().unwrap_or(""),
2340×
            ui::sep(),
2350×
            ui::dim(ui::ago(h["at_ms"].as_u64().unwrap_or(0)))
2360×
        );
2370×
        println!("  {}", h["snippet"].as_str().unwrap_or(""));
2380×
    }
2390×
    Ok(0)
2403×
}
241
242
/// `spectra api METHOD PATH [-d JSON]`: any API call, signed as the owner,
243
/// JSON out (the signing proxy for scripts and the editor extensions).
2442×
pub async fn raw(ctx: &Ctx, method: &str, path: &str, data: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> {
2452×
    let home = owner_home(ctx, owner_home_flag);
2462×
    let c = owner_client(ctx, &home).await?.context("`spectra api` needs `spectra serve` running, or `spectra connect`")?;
2472×
    let r = c.send(&method.to_uppercase(), path, &[], data.map(String::into_bytes).unwrap_or_default()).await?;
2482×
    println!("{}", String::from_utf8_lossy(&r.body));
2492×
    Ok(if r.status < 400 { 0 } else { 2 })
2502×
}