//! The human at a terminal (spec §19): `spectra say`, `spectra inbox //! [--follow]`, `spectra search`, and `spectra api` for anything else. Each //! signs with the owner's own key: through the daemon when it is serving, //! else straight into the ledger. use super::identity::{owner_home, recorded_owner}; use super::{ui, Ctx}; use anyhow::{bail, Context, Result}; use serde_json::{json, Value}; use spectra_core::conversation::{self as conv, Author, Post, Posted, Signer}; use spectra_core::keys::KeyPair; use spectra_core::ledger::Ledger; use spectra_core::principals::Principals; use spectra_mcp::client::Client; use std::path::PathBuf; /// A client signing as the owner, when the daemon is serving. async fn owner_client(ctx: &Ctx, home: &std::path::Path) -> Result> { if !Client::alive(&ctx.socket()).await { return Ok(None); } let ledger = Ledger::open_read_only(&ctx.db()?).await?; Ok(Some(Client::new(&ctx.socket(), Some(recorded_owner(&ledger, home).await?)))) } async fn api(c: &Client, method: &str, path: &str, body: Option) -> Result { let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?; let v: Value = r.json().unwrap_or(Value::Null); if r.status >= 400 { let why = v .get("detail") .or(v.get("title")) .and_then(Value::as_str) .map(String::from) .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into()); bail!("{why}"); } Ok(v) } fn encode(s: &str) -> String { s.bytes() .map(|b| match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), _ => format!("%{b:02X}"), }) .collect() } pub async fn say( ctx: &Ctx, channel: Option, reply_to: Option, body: String, owner_home_flag: Option, as_json: bool, ) -> Result { let home = owner_home(ctx, owner_home_flag); let (id, where_) = match owner_client(ctx, &home).await? { Some(c) => { let ch = channel.clone().unwrap_or_else(|| "general".into()); let v = api( &c, "POST", &format!("/api/v1/channels/{}/messages", encode(ch.trim_start_matches('#'))), Some(json!({ "body": body, "reply_to": reply_to })), ) .await?; if v["duplicate"] == true { bail!("you said exactly this in the last minute ({})", v["id"].as_str().unwrap_or("")); } (v["id"].as_str().unwrap_or("").to_string(), v["channel"].as_str().unwrap_or("").to_string()) } None => { let ledger = ctx.writer().await?; let owner = KeyPair::load_home(&home)?; let principal = Principals::load(&ledger) .await? .principal(&owner.key, spectra_core::ids::now_ms()) .map_err(anyhow::Error::msg) .context("run `spectra identity refresh` first")?; let author = Author { principal, signer: Signer::Cli(&owner) }; let post = Post { channel: if reply_to.is_some() { None } else { Some(channel.unwrap_or_else(|| "general".into())) }, body, reply_to, ..Default::default() }; let posted = conv::post(&ledger, &author, post, spectra_core::ids::now_ms()).await?; ledger.checkpoint().await?; match posted { Posted::Message { id, channel } => (id, channel), Posted::Duplicate { id } => bail!("you said exactly this in the last minute ({id})"), } } }; if as_json { println!("{}", json!({ "id": id, "channel": where_ })); } else { println!("{} {}{}{}", ui::done("Posted"), ui::subject(&where_), ui::sep(), ui::id(&id)); } Ok(0) } fn print_item(i: &Value) { let kind = i["kind"].as_str().unwrap_or(""); let mark = if kind == "dm" { ui::warn(ui::glyph("✉", "@")) } else { ui::ok(ui::glyph("●", "*")) }; let from = i["from_display"].as_str().or(i["from"].as_str()).unwrap_or(""); let at = i["at_ms"].as_u64().map(ui::ago).unwrap_or_default(); println!( "{mark} {} {}{}{}{}{}", ui::dim(kind), ui::author(from), ui::sep(), i["channel"].as_str().unwrap_or(""), ui::sep(), ui::dim(at) ); println!(" {}", i["summary"].as_str().unwrap_or("")); println!(" {}", ui::dim(format!("reply: spectra say --reply-to {} \"…\"", i["id"].as_str().unwrap_or("")))); } pub async fn inbox(ctx: &Ctx, follow: bool, owner_home_flag: Option, as_json: bool) -> Result { let home = owner_home(ctx, owner_home_flag); let ledger = Ledger::open_read_only(&ctx.db()?).await?; let mut items: Vec = Vec::new(); for i in conv::inbox(&ledger, "human", None).await? { let from_display = conv::display_name(&ledger, &i.from).await; let mut v = serde_json::to_value(&i)?; v["from_display"] = from_display.into(); items.push(v); } if as_json && !follow { println!("{}", Value::Array(items)); return Ok(0); } if items.is_empty() && !follow { println!("{} {}", ui::note(), ui::dim("nothing unread")); } let mut seen: std::collections::HashSet = std::collections::HashSet::new(); for i in &items { seen.insert(i["id"].as_str().unwrap_or("").to_string()); if as_json { println!("{i}"); } else { print_item(i); } } if !follow { return Ok(0); } let client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?; if !as_json { println!("{} {}", ui::note(), ui::dim("following; Ctrl-C stops")); } // Every new message is a cue to look at the inbox again. let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>(); let watcher = tokio::spawn(async move { client .stream("/api/v1/events?follow=1&kind=message.posted", |l| { if l.starts_with("data:") { let _ = tx.send(()); } true }) .await }); while rx.recv().await.is_some() { for i in conv::inbox(&ledger, "human", None).await? { if seen.insert(i.id.clone()) { let mut v = serde_json::to_value(&i)?; v["from_display"] = conv::display_name(&ledger, &i.from).await.into(); if as_json { println!("{v}"); } else { print_item(&v); } } } } watcher.await??; Ok(0) } pub async fn search( ctx: &Ctx, query: &str, channel: Option, author: Option, since: Option, limit: u32, as_json: bool, ) -> Result { let ledger = Ledger::open_read_only(&ctx.db()?).await?; let owner = Principals::load(&ledger).await?; let me = owner.owner().context("no owner is recorded")?; let principal = spectra_core::principals::Principal::Owner { key: me.hash(), name: me.name.clone() }; let since = since.as_deref().and_then(|s| spectra_mcp::tools::parse_since(s, spectra_core::ids::now_ms())); let hits = conv::search(&ledger, &principal, query, channel.as_deref(), author.as_deref(), since, limit).await?; if as_json { println!("{}", serde_json::to_string(&hits)?); return Ok(0); } if hits.is_empty() { println!("{} {}", ui::note(), ui::dim("no matches")); } for h in hits { let who = conv::display_name(&ledger, &h.author).await; println!( "{} {}{}{}{}{}", ui::id(&h.id[..12.min(h.id.len())]), ui::author(who), ui::sep(), h.channel, ui::sep(), ui::dim(ui::ago(h.at_ms as u64)) ); println!(" {}", h.snippet); } Ok(0) } /// `spectra api METHOD PATH [-d JSON]`: any API call, signed as the owner, /// JSON out (the signing proxy for scripts and the editor extensions). pub async fn raw(ctx: &Ctx, method: &str, path: &str, data: Option, owner_home_flag: Option) -> Result { let home = owner_home(ctx, owner_home_flag); let c = owner_client(ctx, &home).await?.context("`spectra api` needs `spectra serve` running")?; let r = c.send(&method.to_uppercase(), path, &[], data.map(String::into_bytes).unwrap_or_default()).await?; println!("{}", String::from_utf8_lossy(&r.body)); Ok(if r.status < 400 { 0 } else { 2 }) }