OXYGENHUB
◆ v0.1.0 spectra / src / cli / human.rs
9.7 KiB · Rust · last changed Spectra stands alone: its own identities, signatures and CBOR; projects of any kind of work (#26, #27) 1 day ago source blame history raw
da84e98f Andy McCall ↶ 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
67bfa06a Andy McCall ↶ 6
use super::identity::owner_home;
da84e98f Andy McCall ↶ 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
async fn owner_client(ctx: &Ctx, home: &std::path::Path) -> Result<Option<Client>> {
67bfa06a Andy McCall ↶ 18
    ctx.owner_client(home).await
da84e98f Andy McCall ↶ 19
}
20
21
async fn api(c: &Client, method: &str, path: &str, body: Option<Value>) -> Result<Value> {
67bfa06a Andy McCall ↶ 22
    super::call(c, method, path, body).await
da84e98f Andy McCall ↶ 23
}
24
67bfa06a Andy McCall ↶ 25
use super::encode;
da84e98f Andy McCall ↶ 26
27
pub async fn say(
28
    ctx: &Ctx,
29
    channel: Option<String>,
30
    reply_to: Option<String>,
31
    body: String,
32
    owner_home_flag: Option<PathBuf>,
33
    as_json: bool,
34
) -> Result<i32> {
35
    let home = owner_home(ctx, owner_home_flag);
36
    let (id, where_) = match owner_client(ctx, &home).await? {
37
        Some(c) => {
38
            let ch = channel.clone().unwrap_or_else(|| "general".into());
39
            let v = api(
40
                &c,
41
                "POST",
42
                &format!("/api/v1/channels/{}/messages", encode(ch.trim_start_matches('#'))),
43
                Some(json!({ "body": body, "reply_to": reply_to })),
44
            )
45
            .await?;
46
            if v["duplicate"] == true {
47
                bail!("you said exactly this in the last minute ({})", v["id"].as_str().unwrap_or(""));
48
            }
49
            (v["id"].as_str().unwrap_or("").to_string(), v["channel"].as_str().unwrap_or("").to_string())
50
        }
51
        None => {
52
            let ledger = ctx.writer().await?;
53
            let owner = KeyPair::load_home(&home)?;
54
            let principal = Principals::load(&ledger)
55
                .await?
56
                .principal(&owner.key, spectra_core::ids::now_ms())
57
                .map_err(anyhow::Error::msg)
58
                .context("run `spectra identity refresh` first")?;
59
            let author = Author { principal, signer: Signer::Cli(&owner) };
60
            let post = Post {
61
                channel: if reply_to.is_some() { None } else { Some(channel.unwrap_or_else(|| "general".into())) },
62
                body,
63
                reply_to,
64
                ..Default::default()
65
            };
66
            let posted = conv::post(&ledger, &author, post, spectra_core::ids::now_ms()).await?;
67
            ledger.checkpoint().await?;
68
            match posted {
69
                Posted::Message { id, channel } => (id, channel),
70
                Posted::Duplicate { id } => bail!("you said exactly this in the last minute ({id})"),
71
            }
72
        }
73
    };
74
    if as_json {
75
        println!("{}", json!({ "id": id, "channel": where_ }));
76
    } else {
77
        println!("{} {}{}{}", ui::done("Posted"), ui::subject(&where_), ui::sep(), ui::id(&id));
78
    }
79
    Ok(0)
80
}
81
82
fn print_item(i: &Value) {
83
    let kind = i["kind"].as_str().unwrap_or("");
84
    let mark = if kind == "dm" { ui::warn(ui::glyph("✉", "@")) } else { ui::ok(ui::glyph("●", "*")) };
85
    let from = i["from_display"].as_str().or(i["from"].as_str()).unwrap_or("");
86
    let at = i["at_ms"].as_u64().map(ui::ago).unwrap_or_default();
87
    println!(
88
        "{mark} {} {}{}{}{}{}",
89
        ui::dim(kind),
90
        ui::author(from),
91
        ui::sep(),
92
        i["channel"].as_str().unwrap_or(""),
93
        ui::sep(),
94
        ui::dim(at)
95
    );
96
    println!("  {}", i["summary"].as_str().unwrap_or(""));
97
    println!("  {}", ui::dim(format!("reply: spectra say --reply-to {} \"…\"", i["id"].as_str().unwrap_or(""))));
98
}
99
67bfa06a Andy McCall ↶ 100
/// The owner's unread items: from the daemon when one serves, else the ledger.
101
async fn inbox_items(ctx: &Ctx, client: Option<&Client>) -> Result<Vec<Value>> {
102
    if let Some(c) = client {
103
        return Ok(api(c, "GET", "/api/v1/inbox", None).await?.as_array().cloned().unwrap_or_default());
104
    }
da84e98f Andy McCall ↶ 105
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
67bfa06a Andy McCall ↶ 106
    let mut items = Vec::new();
da84e98f Andy McCall ↶ 107
    for i in conv::inbox(&ledger, "human", None).await? {
108
        let from_display = conv::display_name(&ledger, &i.from).await;
109
        let mut v = serde_json::to_value(&i)?;
110
        v["from_display"] = from_display.into();
111
        items.push(v);
112
    }
67bfa06a Andy McCall ↶ 113
    Ok(items)
114
}
115
116
pub async fn inbox(ctx: &Ctx, follow: bool, owner_home_flag: Option<PathBuf>, as_json: bool) -> Result<i32> {
117
    let home = owner_home(ctx, owner_home_flag);
118
    let client = owner_client(ctx, &home).await?;
98a0fb9f Andy McCall ↶ 119
    // Following: subscribe first, and read the inbox once the server says
120
    // the subscription holds, so nothing posted in between is missed.
121
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<bool>();
122
    let watcher = if follow {
123
        let stream_client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?;
124
        let w = tokio::spawn(async move {
125
            stream_client
126
                .stream("/api/v1/events?follow=1&kind=message.posted", |l| {
127
                    if l.starts_with(':') {
128
                        let _ = tx.send(false);
129
                    } else if l.starts_with("data:") {
130
                        let _ = tx.send(true);
131
                    }
132
                    true
133
                })
134
                .await
135
        });
136
        match tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv()).await {
137
            Ok(Some(_)) => {}
138
            _ => bail!("the server did not start the stream of new messages"),
139
        }
140
        Some(w)
141
    } else {
142
        None
143
    };
67bfa06a Andy McCall ↶ 144
    let items = inbox_items(ctx, client.as_ref()).await?;
da84e98f Andy McCall ↶ 145
    if as_json && !follow {
146
        println!("{}", Value::Array(items));
147
        return Ok(0);
148
    }
149
    if items.is_empty() && !follow {
150
        println!("{} {}", ui::note(), ui::dim("nothing unread"));
151
    }
152
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
153
    for i in &items {
154
        seen.insert(i["id"].as_str().unwrap_or("").to_string());
155
        if as_json {
156
            println!("{i}");
157
        } else {
158
            print_item(i);
159
        }
160
    }
98a0fb9f Andy McCall ↶ 161
    let Some(watcher) = watcher else { return Ok(0) };
67bfa06a Andy McCall ↶ 162
    let client = client.context("--follow needs `spectra serve` running")?;
da84e98f Andy McCall ↶ 163
    if !as_json {
164
        println!("{} {}", ui::note(), ui::dim("following; Ctrl-C stops"));
165
    }
166
    // Every new message is a cue to look at the inbox again.
98a0fb9f Andy McCall ↶ 167
    while let Some(changed) = rx.recv().await {
168
        if !changed {
169
            continue;
170
        }
67bfa06a Andy McCall ↶ 171
        for v in inbox_items(ctx, Some(&client)).await? {
172
            if seen.insert(v["id"].as_str().unwrap_or("").to_string()) {
da84e98f Andy McCall ↶ 173
                if as_json {
174
                    println!("{v}");
175
                } else {
176
                    print_item(&v);
177
                }
178
            }
179
        }
180
    }
181
    watcher.await??;
182
    Ok(0)
183
}
184
185
pub async fn search(
186
    ctx: &Ctx,
187
    query: &str,
188
    channel: Option<String>,
189
    author: Option<String>,
190
    since: Option<String>,
191
    limit: u32,
192
    as_json: bool,
193
) -> Result<i32> {
67bfa06a Andy McCall ↶ 194
    let hits: Vec<Value> = match owner_client(ctx, &owner_home(ctx, None)).await? {
195
        Some(c) => {
196
            let mut q = format!("/api/v1/search?q={}&limit={limit}", encode(query));
197
            for (k, v) in [("channel", &channel), ("author", &author), ("since", &since)] {
198
                if let Some(v) = v {
199
                    q.push_str(&format!("&{k}={}", encode(v)));
200
                }
201
            }
202
            api(&c, "GET", &q, None).await?.as_array().cloned().unwrap_or_default()
203
        }
204
        None => {
205
            let ledger = Ledger::open_read_only(&ctx.db()?).await?;
206
            let owner = Principals::load(&ledger).await?;
207
            let me = owner.owner().context("no owner is recorded")?;
208
            let principal = spectra_core::principals::Principal::Owner { key: me.hash(), name: me.name.clone() };
209
            let since = since.as_deref().and_then(|s| spectra_mcp::tools::parse_since(s, spectra_core::ids::now_ms()));
210
            let mut out = Vec::new();
211
            for h in conv::search(&ledger, &principal, query, channel.as_deref(), author.as_deref(), since, limit).await? {
212
                let mut v = serde_json::to_value(&h)?;
213
                v["author_display"] = conv::display_name(&ledger, &h.author).await.into();
214
                out.push(v);
215
            }
216
            out
217
        }
218
    };
da84e98f Andy McCall ↶ 219
    if as_json {
67bfa06a Andy McCall ↶ 220
        println!("{}", Value::Array(hits));
da84e98f Andy McCall ↶ 221
        return Ok(0);
222
    }
223
    if hits.is_empty() {
224
        println!("{} {}", ui::note(), ui::dim("no matches"));
225
    }
226
    for h in hits {
67bfa06a Andy McCall ↶ 227
        let id = h["id"].as_str().unwrap_or("");
da84e98f Andy McCall ↶ 228
        println!(
229
            "{} {}{}{}{}{}",
67bfa06a Andy McCall ↶ 230
            ui::id(&id[..12.min(id.len())]),
231
            ui::author(h["author_display"].as_str().or(h["author"].as_str()).unwrap_or("")),
da84e98f Andy McCall ↶ 232
            ui::sep(),
67bfa06a Andy McCall ↶ 233
            h["channel"].as_str().unwrap_or(""),
da84e98f Andy McCall ↶ 234
            ui::sep(),
67bfa06a Andy McCall ↶ 235
            ui::dim(ui::ago(h["at_ms"].as_u64().unwrap_or(0)))
da84e98f Andy McCall ↶ 236
        );
67bfa06a Andy McCall ↶ 237
        println!("  {}", h["snippet"].as_str().unwrap_or(""));
da84e98f Andy McCall ↶ 238
    }
239
    Ok(0)
240
}
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).
244
pub async fn raw(ctx: &Ctx, method: &str, path: &str, data: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> {
245
    let home = owner_home(ctx, owner_home_flag);
67bfa06a Andy McCall ↶ 246
    let c = owner_client(ctx, &home).await?.context("`spectra api` needs `spectra serve` running, or `spectra connect`")?;
da84e98f Andy McCall ↶ 247
    let r = c.send(&method.to_uppercase(), path, &[], data.map(String::into_bytes).unwrap_or_default()).await?;
248
    println!("{}", String::from_utf8_lossy(&r.body));
249
    Ok(if r.status < 400 { 0 } else { 2 })
250
}