OXYGENHUB

Coverage / src/cli/human.rs 54.0%

109 of 202 lines run, 16 of 44 functions · run #16 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, recorded_owner};
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
17
/// A client signing as the owner, when the daemon is serving.
183×
async fn owner_client(ctx: &Ctx, home: &std::path::Path) -> Result<Option<Client>> {
193×
    if !Client::alive(&ctx.socket()).await {
201×
        return Ok(None);
212×
    }
222×
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
232×
    Ok(Some(Client::new(&ctx.socket(), Some(recorded_owner(&ledger, home).await?))))
243×
}
25
261×
async fn api(c: &Client, method: &str, path: &str, body: Option<Value>) -> Result<Value> {
271×
    let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?;
281×
    let v: Value = r.json().unwrap_or(Value::Null);
291×
    if r.status >= 400 {
300×
        let why = v
310×
            .get("detail")
320×
            .or(v.get("title"))
330×
            .and_then(Value::as_str)
340×
            .map(String::from)
350×
            .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
360×
        bail!("{why}");
371×
    }
381×
    Ok(v)
391×
}
40
411×
fn encode(s: &str) -> String {
421×
    s.bytes()
437×
        .map(|b| match b {
447×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
450×
            _ => format!("%{b:02X}"),
467×
        })
471×
        .collect()
481×
}
49
502×
pub async fn say(
512×
    ctx: &Ctx,
522×
    channel: Option<String>,
532×
    reply_to: Option<String>,
542×
    body: String,
552×
    owner_home_flag: Option<PathBuf>,
562×
    as_json: bool,
572×
) -> Result<i32> {
582×
    let home = owner_home(ctx, owner_home_flag);
592×
    let (id, where_) = match owner_client(ctx, &home).await? {
601×
        Some(c) => {
611×
            let ch = channel.clone().unwrap_or_else(|| "general".into());
621×
            let v = api(
631×
                &c,
641×
                "POST",
651×
                &format!("/api/v1/channels/{}/messages", encode(ch.trim_start_matches('#'))),
661×
                Some(json!({ "body": body, "reply_to": reply_to })),
671×
            )
681×
            .await?;
691×
            if v["duplicate"] == true {
700×
                bail!("you said exactly this in the last minute ({})", v["id"].as_str().unwrap_or(""));
711×
            }
721×
            (v["id"].as_str().unwrap_or("").to_string(), v["channel"].as_str().unwrap_or("").to_string())
73
        }
74
        None => {
751×
            let ledger = ctx.writer().await?;
761×
            let owner = KeyPair::load_home(&home)?;
771×
            let principal = Principals::load(&ledger)
781×
                .await?
791×
                .principal(&owner.key, spectra_core::ids::now_ms())
801×
                .map_err(anyhow::Error::msg)
811×
                .context("run `spectra identity refresh` first")?;
821×
            let author = Author { principal, signer: Signer::Cli(&owner) };
831×
            let post = Post {
841×
                channel: if reply_to.is_some() { None } else { Some(channel.unwrap_or_else(|| "general".into())) },
851×
                body,
861×
                reply_to,
871×
                ..Default::default()
88
            };
891×
            let posted = conv::post(&ledger, &author, post, spectra_core::ids::now_ms()).await?;
901×
            ledger.checkpoint().await?;
911×
            match posted {
921×
                Posted::Message { id, channel } => (id, channel),
930×
                Posted::Duplicate { id } => bail!("you said exactly this in the last minute ({id})"),
94
            }
95
        }
96
    };
972×
    if as_json {
982×
        println!("{}", json!({ "id": id, "channel": where_ }));
992×
    } else {
1000×
        println!("{} {}{}{}", ui::done("Posted"), ui::subject(&where_), ui::sep(), ui::id(&id));
1010×
    }
1022×
    Ok(0)
1032×
}
104
1050×
fn print_item(i: &Value) {
1060×
    let kind = i["kind"].as_str().unwrap_or("");
1070×
    let mark = if kind == "dm" { ui::warn(ui::glyph("✉", "@")) } else { ui::ok(ui::glyph("●", "*")) };
1080×
    let from = i["from_display"].as_str().or(i["from"].as_str()).unwrap_or("");
1090×
    let at = i["at_ms"].as_u64().map(ui::ago).unwrap_or_default();
1100×
    println!(
111
        "{mark} {} {}{}{}{}{}",
1120×
        ui::dim(kind),
1130×
        ui::author(from),
1140×
        ui::sep(),
1150×
        i["channel"].as_str().unwrap_or(""),
1160×
        ui::sep(),
1170×
        ui::dim(at)
118
    );
1190×
    println!("  {}", i["summary"].as_str().unwrap_or(""));
1200×
    println!("  {}", ui::dim(format!("reply: spectra say --reply-to {} \"…\"", i["id"].as_str().unwrap_or(""))));
1210×
}
122
1231×
pub async fn inbox(ctx: &Ctx, follow: bool, owner_home_flag: Option<PathBuf>, as_json: bool) -> Result<i32> {
1241×
    let home = owner_home(ctx, owner_home_flag);
1251×
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
1261×
    let mut items: Vec<Value> = Vec::new();
1271×
    for i in conv::inbox(&ledger, "human", None).await? {
1280×
        let from_display = conv::display_name(&ledger, &i.from).await;
1290×
        let mut v = serde_json::to_value(&i)?;
1300×
        v["from_display"] = from_display.into();
1310×
        items.push(v);
132
    }
1331×
    if as_json && !follow {
1341×
        println!("{}", Value::Array(items));
1351×
        return Ok(0);
1360×
    }
1370×
    if items.is_empty() && !follow {
1380×
        println!("{} {}", ui::note(), ui::dim("nothing unread"));
1390×
    }
1400×
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1410×
    for i in &items {
1420×
        seen.insert(i["id"].as_str().unwrap_or("").to_string());
1430×
        if as_json {
1440×
            println!("{i}");
1450×
        } else {
1460×
            print_item(i);
1470×
        }
148
    }
1490×
    if !follow {
1500×
        return Ok(0);
1510×
    }
1520×
    let client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?;
1530×
    if !as_json {
1540×
        println!("{} {}", ui::note(), ui::dim("following; Ctrl-C stops"));
1550×
    }
156
    // Every new message is a cue to look at the inbox again.
1570×
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>();
1580×
    let watcher = tokio::spawn(async move {
1590×
        client
1600×
            .stream("/api/v1/events?follow=1&kind=message.posted", |l| {
1610×
                if l.starts_with("data:") {
1620×
                    let _ = tx.send(());
1630×
                }
1640×
                true
1650×
            })
1660×
            .await
1670×
    });
1680×
    while rx.recv().await.is_some() {
1690×
        for i in conv::inbox(&ledger, "human", None).await? {
1700×
            if seen.insert(i.id.clone()) {
1710×
                let mut v = serde_json::to_value(&i)?;
1720×
                v["from_display"] = conv::display_name(&ledger, &i.from).await.into();
1730×
                if as_json {
1740×
                    println!("{v}");
1750×
                } else {
1760×
                    print_item(&v);
1770×
                }
1780×
            }
179
        }
180
    }
1810×
    watcher.await??;
1820×
    Ok(0)
1831×
}
184
1851×
pub async fn search(
1861×
    ctx: &Ctx,
1871×
    query: &str,
1881×
    channel: Option<String>,
1891×
    author: Option<String>,
1901×
    since: Option<String>,
1911×
    limit: u32,
1921×
    as_json: bool,
1931×
) -> Result<i32> {
1941×
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
1951×
    let owner = Principals::load(&ledger).await?;
1961×
    let me = owner.owner().context("no owner is recorded")?;
1971×
    let principal = spectra_core::principals::Principal::Owner { key: me.hash(), name: me.name.clone() };
1981×
    let since = since.as_deref().and_then(|s| spectra_mcp::tools::parse_since(s, spectra_core::ids::now_ms()));
1991×
    let hits = conv::search(&ledger, &principal, query, channel.as_deref(), author.as_deref(), since, limit).await?;
2001×
    if as_json {
2011×
        println!("{}", serde_json::to_string(&hits)?);
2021×
        return Ok(0);
2030×
    }
2040×
    if hits.is_empty() {
2050×
        println!("{} {}", ui::note(), ui::dim("no matches"));
2060×
    }
2070×
    for h in hits {
2080×
        let who = conv::display_name(&ledger, &h.author).await;
2090×
        println!(
210
            "{} {}{}{}{}{}",
2110×
            ui::id(&h.id[..12.min(h.id.len())]),
2120×
            ui::author(who),
2130×
            ui::sep(),
214
            h.channel,
2150×
            ui::sep(),
2160×
            ui::dim(ui::ago(h.at_ms as u64))
217
        );
2180×
        println!("  {}", h.snippet);
219
    }
2200×
    Ok(0)
2211×
}
222
223
/// `spectra api METHOD PATH [-d JSON]`: any API call, signed as the owner,
224
/// JSON out (the signing proxy for scripts and the editor extensions).
2251×
pub async fn raw(ctx: &Ctx, method: &str, path: &str, data: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> {
2261×
    let home = owner_home(ctx, owner_home_flag);
2271×
    let c = owner_client(ctx, &home).await?.context("`spectra api` needs `spectra serve` running")?;
2281×
    let r = c.send(&method.to_uppercase(), path, &[], data.map(String::into_bytes).unwrap_or_default()).await?;
2291×
    println!("{}", String::from_utf8_lossy(&r.body));
2301×
    Ok(if r.status < 400 { 0 } else { 2 })
2311×
}