From 5a53383897deddb64a15e88b385d1a8709c8458f Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 18 May 2026 18:36:26 -0400 Subject: [PATCH] fix(cli): normalize output and fix wrong Nostr kinds across all commands Comprehensive audit revealed the CLI queried wrong Nostr kinds (channels list used kind:39002 instead of 41, dms list used 41010 instead of 41001, workflow runs used 46020 instead of [46001,46002,46003]), used wrong tag filters (#h instead of #d), discarded generated IDs from create commands, and returned raw Nostr events with sig fields instead of structured data. Adds query_multi() for compound queries, normalize_write_response() for consistent {event_id, accepted, message} write output, normalize_events() for stripping sig and reshaping reads, and extract_d_tag/extract_p_tags helpers. All 25+ write handlers and 10+ read handlers now produce structured output matching the MCP server. --- crates/sprout-cli/src/client.rs | 112 +++++++++++++ crates/sprout-cli/src/commands/channels.rs | 168 +++++++++++++++----- crates/sprout-cli/src/commands/dms.rs | 67 ++++++-- crates/sprout-cli/src/commands/feed.rs | 9 +- crates/sprout-cli/src/commands/messages.rs | 39 +++-- crates/sprout-cli/src/commands/reactions.rs | 40 ++++- crates/sprout-cli/src/commands/social.rs | 6 +- crates/sprout-cli/src/commands/users.rs | 71 +++++++-- crates/sprout-cli/src/commands/workflows.rs | 53 ++++-- 9 files changed, 467 insertions(+), 98 deletions(-) diff --git a/crates/sprout-cli/src/client.rs b/crates/sprout-cli/src/client.rs index 4b4bf4cc4e..ac58066055 100644 --- a/crates/sprout-cli/src/client.rs +++ b/crates/sprout-cli/src/client.rs @@ -236,6 +236,23 @@ impl SproutClient { self.handle_response(resp).await } + /// Execute a one-shot query with multiple filters via the HTTP bridge. + /// Each filter is ORed by the relay (standard Nostr REQ behavior). + pub async fn query_multi(&self, filters: &[serde_json::Value]) -> Result { + let url = format!("{}/query", self.relay_url); + let body_bytes = serde_json::to_vec(filters) + .map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?; + let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; + let req = self + .http + .post(&url) + .header("Authorization", &auth) + .header("Content-Type", "application/json") + .body(body_bytes); + let resp = self.with_auth_tag(req).send().await?; + self.handle_response(resp).await + } + /// Execute a one-shot count via the HTTP bridge. /// Returns the count as a JSON string. #[allow(dead_code)] @@ -404,6 +421,15 @@ impl SproutClient { .map(|s| s.to_string()) }) .unwrap_or(body); + if status == 403 && std::env::var("SPROUT_AUTH_TAG").is_ok() { + let message = format!( + "{message} (SPROUT_AUTH_TAG is set — it may be stale or revoked; try unsetting it)" + ); + return Err(CliError::Relay { + status, + body: message, + }); + } return Err(CliError::Relay { status, body: message, @@ -425,3 +451,89 @@ pub fn normalize_relay_url(url: &str) -> String { .trim_end_matches('/') .to_string() } + +// --------------------------------------------------------------------------- +// Output normalization helpers +// --------------------------------------------------------------------------- + +/// Normalize raw event JSON array into consistent shape. +/// Each event becomes: {id, pubkey, kind, content, created_at, tags} +pub fn normalize_events(raw: &str) -> String { + let events: Vec = serde_json::from_str(raw).unwrap_or_default(); + let normalized: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), + "kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0), + "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + "tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])), + }) + }) + .collect(); + serde_json::to_string(&normalized).unwrap_or_default() +} + +/// Extract the d-tag value from a Nostr event JSON object. +pub fn extract_d_tag(event: &serde_json::Value) -> String { + event + .get("tags") + .and_then(|t| t.as_array()) + .and_then(|tags| { + tags.iter().find(|t| { + t.as_array() + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + == Some("d") + }) + }) + .and_then(|t| t.as_array()) + .and_then(|a| a.get(1)) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + +/// Extract all p-tags into [{pubkey, role}] from a Nostr event JSON object. +pub fn extract_p_tags(event: &serde_json::Value) -> Vec { + event + .get("tags") + .and_then(|t| t.as_array()) + .map(|tags| { + tags.iter() + .filter(|t| { + t.as_array() + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + == Some("p") + }) + .map(|t| { + let a = t.as_array().unwrap(); + serde_json::json!({ + "pubkey": a.get(1).and_then(|v| v.as_str()).unwrap_or(""), + "role": a.get(2).and_then(|v| v.as_str()).unwrap_or("member"), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Normalize a relay write-response into a consistent JSON object. +/// Relay returns: {"event_id": "...", "accepted": true, "message": "..."} +/// Falls back to raw text if parsing fails. +pub fn normalize_write_response(raw: &str) -> String { + if let Ok(v) = serde_json::from_str::(raw) { + if v.get("event_id").is_some() || v.get("accepted").is_some() { + return serde_json::json!({ + "event_id": v.get("event_id").and_then(|v| v.as_str()).unwrap_or(""), + "accepted": v.get("accepted").and_then(|v| v.as_bool()).unwrap_or(false), + "message": v.get("message").and_then(|v| v.as_str()).unwrap_or(""), + }) + .to_string(); + } + } + raw.to_string() +} diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs index 17018f8ab2..ae5e841d5a 100644 --- a/crates/sprout-cli/src/commands/channels.rs +++ b/crates/sprout-cli/src/commands/channels.rs @@ -1,6 +1,6 @@ use uuid::Uuid; -use crate::client::SproutClient; +use crate::client::{extract_d_tag, extract_p_tags, normalize_write_response, SproutClient}; use crate::error::CliError; use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid}; @@ -10,34 +10,112 @@ use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid}; pub async fn cmd_list_channels( client: &SproutClient, - _visibility: Option<&str>, - _member: Option, + visibility: Option<&str>, + member: Option, ) -> Result<(), CliError> { - // Query kind:39002 channel metadata events. - // If member=true, filter by #p tag containing our pubkey. - let my_pk = client.keys().public_key().to_hex(); - let mut filter = serde_json::json!({ - "kinds": [39002] - }); - // When member filter is requested, query channels where we're a participant - if _member == Some(true) { - filter["#p"] = serde_json::json!([my_pk]); - } - // Visibility filtering is done client-side from the returned events - let resp = client.query(&filter).await?; - println!("{resp}"); + let raw = if member == Some(true) { + // Step 1: find channel IDs where we're a member (kind:39002) + let my_pk = client.keys().public_key().to_hex(); + let member_filter = serde_json::json!({ + "kinds": [39002], + "#p": [my_pk], + "limit": 500 + }); + let member_resp = client.query(&member_filter).await?; + let member_events: Vec = + serde_json::from_str(&member_resp).unwrap_or_default(); + let channel_ids: Vec = member_events + .iter() + .map(extract_d_tag) + .filter(|id| !id.is_empty()) + .collect(); + if channel_ids.is_empty() { + println!("[]"); + return Ok(()); + } + // Step 2: fetch kind:41 metadata for those channels + let metadata_filter = serde_json::json!({ + "kinds": [41], + "#d": channel_ids, + "limit": 500 + }); + client.query(&metadata_filter).await? + } else { + let filter = serde_json::json!({ + "kinds": [41], + "limit": 500 + }); + client.query(&filter).await? + }; + + let events: Vec = serde_json::from_str(&raw).unwrap_or_default(); + let channels: Vec = events + .iter() + .filter(|e| { + if let Some(vis) = visibility { + // Client-side visibility filter: check for ["visibility", vis] in tags + e.get("tags") + .and_then(|t| t.as_array()) + .map(|tags| { + tags.iter().any(|tag| { + tag.as_array() + .map(|a| { + a.first().and_then(|v| v.as_str()) == Some("visibility") + && a.get(1).and_then(|v| v.as_str()) == Some(vis) + }) + .unwrap_or(false) + }) + }) + .unwrap_or(false) + } else { + true + } + }) + .map(|e| { + let content: serde_json::Value = e + .get("content") + .and_then(|c| c.as_str()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(serde_json::json!({})); + serde_json::json!({ + "channel_id": extract_d_tag(e), + "name": content.get("name").and_then(|v| v.as_str()).unwrap_or(""), + "description": content.get("about").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + }) + }) + .collect(); + let output = serde_json::to_string(&channels).unwrap_or_default(); + println!("{output}"); Ok(()) } pub async fn cmd_get_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - // Query kind:39002 with #h tag matching the channel UUID let filter = serde_json::json!({ - "kinds": [39002], - "#h": [channel_id] + "kinds": [41], + "#d": [channel_id], + "limit": 1 }); let resp = client.query(&filter).await?; - println!("{resp}"); + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + if let Some(e) = events.first() { + let content: serde_json::Value = e + .get("content") + .and_then(|c| c.as_str()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(serde_json::json!({})); + let normalized = serde_json::json!({ + "channel_id": extract_d_tag(e), + "name": content.get("name").and_then(|v| v.as_str()).unwrap_or(""), + "description": content.get("about").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), + }); + println!("{normalized}"); + } else { + println!("null"); + } Ok(()) } @@ -46,25 +124,35 @@ pub async fn cmd_list_channel_members( channel_id: &str, ) -> Result<(), CliError> { validate_uuid(channel_id)?; - // Query kind:39002 channel metadata — members are in the p-tags let filter = serde_json::json!({ "kinds": [39002], - "#h": [channel_id] + "#d": [channel_id], + "limit": 1 }); let resp = client.query(&filter).await?; - println!("{resp}"); + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + let members = events + .first() + .map(extract_p_tags) + .unwrap_or_default(); + let output = serde_json::to_string(&members).unwrap_or_default(); + println!("{output}"); Ok(()) } pub async fn cmd_get_canvas(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - // Canvas is kind:40100 with #h tag let filter = serde_json::json!({ "kinds": [40100], "#h": [channel_id] }); let resp = client.query(&filter).await?; - println!("{resp}"); + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + if let Some(content) = events.first().and_then(|e| e.get("content")).and_then(|c| c.as_str()) { + println!("{content}"); + } else { + println!("No canvas set for this channel."); + } Ok(()) } @@ -114,7 +202,13 @@ pub async fn cmd_create_channel( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + let mut normalized: serde_json::Value = + serde_json::from_str(&resp).unwrap_or(serde_json::json!({})); + normalized["channel_id"] = serde_json::json!(channel_uuid.to_string()); + if normalized.get("accepted").is_none() { + normalized["accepted"] = serde_json::json!(true); + } + println!("{normalized}"); Ok(()) } @@ -136,7 +230,7 @@ pub async fn cmd_update_channel( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -152,7 +246,7 @@ pub async fn cmd_set_channel_topic( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -168,7 +262,7 @@ pub async fn cmd_set_channel_purpose( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -180,7 +274,7 @@ pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -192,7 +286,7 @@ pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Resul let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -204,7 +298,7 @@ pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Res let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -219,7 +313,7 @@ pub async fn cmd_unarchive_channel( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -231,7 +325,7 @@ pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Resu let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -262,7 +356,7 @@ pub async fn cmd_add_channel_member( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -279,7 +373,7 @@ pub async fn cmd_remove_channel_member( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -296,7 +390,7 @@ pub async fn cmd_set_canvas( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } diff --git a/crates/sprout-cli/src/commands/dms.rs b/crates/sprout-cli/src/commands/dms.rs index 8d6a4c22c7..8192022994 100644 --- a/crates/sprout-cli/src/commands/dms.rs +++ b/crates/sprout-cli/src/commands/dms.rs @@ -1,32 +1,79 @@ -use crate::client::SproutClient; +use uuid::Uuid; + +use crate::client::{extract_d_tag, normalize_write_response, SproutClient}; use crate::error::CliError; use crate::validate::{parse_uuid, sdk_err}; -/// List DM conversations by querying kind:41010 (DM open) events authored by us. +/// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey. pub async fn cmd_list_dms(client: &SproutClient, limit: Option) -> Result<(), CliError> { let my_pk = client.keys().public_key().to_hex(); let limit = limit.unwrap_or(50).min(200); let filter = serde_json::json!({ - "kinds": [41010], - "authors": [my_pk], + "kinds": [41001], + "#p": [my_pk], "limit": limit }); let resp = client.query(&filter).await?; - println!("{resp}"); + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + let dms: Vec = events + .iter() + .map(|e| { + let dm_id = extract_d_tag(e); + let participants: Vec = e + .get("tags") + .and_then(|t| t.as_array()) + .map(|tags| { + tags.iter() + .filter_map(|tag| { + let arr = tag.as_array()?; + if arr.first()?.as_str()? == "p" { + arr.get(1)?.as_str().map(|s| s.to_string()) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + serde_json::json!({ + "dm_id": dm_id, + "participants": participants, + "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + }) + }) + .collect(); + let output = serde_json::to_string(&dms).unwrap_or_default(); + println!("{output}"); Ok(()) } -/// Open a DM with one or more users — sign and submit a kind:41010 event. +/// Open a DM with one or more users — sign and submit a kind:41010 event with a d-tag. pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<(), CliError> { if pubkeys.is_empty() || pubkeys.len() > 8 { - return Err(CliError::Usage("--pubkey: must provide 1–8 pubkeys".into())); + return Err(CliError::Usage("--pubkey: must provide 1-8 pubkeys".into())); } + let dm_id = Uuid::new_v4().to_string(); let refs: Vec<&str> = pubkeys.iter().map(String::as_str).collect(); - let builder = sprout_sdk::build_dm_open(&refs).map_err(sdk_err)?; + + // build_dm_open doesn't accept a d-tag, so we build the event manually + // using the SDK builder and add the d-tag ourselves. + use nostr::{EventBuilder, Kind, Tag}; + let mut tags: Vec = refs + .iter() + .map(|pk| Tag::parse(&["p", pk]).unwrap()) + .collect(); + tags.push(Tag::parse(&["d", &dm_id]).unwrap()); + let builder = EventBuilder::new(Kind::Custom(41010), "", tags); let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + let mut normalized: serde_json::Value = + serde_json::from_str(&resp).unwrap_or(serde_json::json!({})); + normalized["dm_id"] = serde_json::json!(dm_id); + if normalized.get("accepted").is_none() { + normalized["accepted"] = serde_json::json!(true); + } + println!("{normalized}"); Ok(()) } @@ -42,7 +89,7 @@ pub async fn cmd_add_dm_member( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } diff --git a/crates/sprout-cli/src/commands/feed.rs b/crates/sprout-cli/src/commands/feed.rs index 38079bc6de..322c5c1f70 100644 --- a/crates/sprout-cli/src/commands/feed.rs +++ b/crates/sprout-cli/src/commands/feed.rs @@ -1,4 +1,6 @@ -use crate::client::SproutClient; +use std::cmp::Reverse; + +use crate::client::{normalize_events, SproutClient}; use crate::error::CliError; /// Get activity feed — query events mentioning our pubkey (via p-tag). @@ -21,7 +23,10 @@ pub async fn cmd_get_feed( } let resp = client.query(&filter).await?; - println!("{resp}"); + let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + events.sort_by_key(|e| Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0))); + let raw_sorted = serde_json::to_string(&events).unwrap_or_default(); + println!("{}", normalize_events(&raw_sorted)); Ok(()) } diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs index 113932e731..22758fef56 100644 --- a/crates/sprout-cli/src/commands/messages.rs +++ b/crates/sprout-cli/src/commands/messages.rs @@ -2,7 +2,7 @@ use nostr::PublicKey; use sprout_sdk::{DiffMeta, ThreadRef, VoteDirection}; use uuid::Uuid; -use crate::client::SproutClient; +use crate::client::{normalize_events, normalize_write_response, SproutClient}; use crate::error::CliError; use crate::validate::{ infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, @@ -234,7 +234,7 @@ pub async fn cmd_get_messages( let limit = limit.unwrap_or(50).min(200); let mut filter = serde_json::json!({ - "kinds": [9, 40002], + "kinds": [9, 40002, 40008, 45001, 45003], "#h": [channel_id], "limit": limit }); @@ -255,7 +255,10 @@ pub async fn cmd_get_messages( } let resp = client.query(&filter).await?; - println!("{resp}"); + let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)); + let raw_sorted = serde_json::to_string(&events).unwrap_or_default(); + println!("{}", normalize_events(&raw_sorted)); Ok(()) } @@ -270,15 +273,23 @@ pub async fn cmd_get_thread( validate_hex64(event_id)?; let limit = limit.unwrap_or(100).min(500); - // Get the root event and all replies referencing it via e-tag - let filter = serde_json::json!({ - "kinds": [9, 40002], + // Two filters ORed in a single HTTP call: + // 1. Replies referencing this event via e-tag (no kind restriction) + // 2. The root event itself by ID + let reply_filter = serde_json::json!({ "#h": [channel_id], "#e": [event_id], "limit": limit }); - let resp = client.query(&filter).await?; - println!("{resp}"); + let root_filter = serde_json::json!({ + "ids": [event_id], + "limit": 1 + }); + let resp = client.query_multi(&[reply_filter, root_filter]).await?; + let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)); + let raw_sorted = serde_json::to_string(&events).unwrap_or_default(); + println!("{}", normalize_events(&raw_sorted)); Ok(()) } @@ -293,7 +304,7 @@ pub async fn cmd_search( "limit": limit }); let resp = client.query(&filter).await?; - println!("{resp}"); + println!("{}", normalize_events(&resp)); Ok(()) } @@ -383,7 +394,7 @@ pub async fn cmd_send_message( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -474,7 +485,7 @@ pub async fn cmd_send_diff_message( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -491,7 +502,7 @@ pub async fn cmd_delete_message(client: &SproutClient, event_id: &str) -> Result let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -514,7 +525,7 @@ pub async fn cmd_edit_message( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -545,7 +556,7 @@ pub async fn cmd_vote_on_post( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } diff --git a/crates/sprout-cli/src/commands/reactions.rs b/crates/sprout-cli/src/commands/reactions.rs index bf90ad8215..8d0d005e92 100644 --- a/crates/sprout-cli/src/commands/reactions.rs +++ b/crates/sprout-cli/src/commands/reactions.rs @@ -1,6 +1,8 @@ +use std::collections::HashMap; + use nostr::EventId; -use crate::client::SproutClient; +use crate::client::{normalize_write_response, SproutClient}; use crate::error::CliError; use crate::validate::validate_hex64; @@ -19,7 +21,7 @@ pub async fn cmd_add_reaction( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -65,19 +67,47 @@ pub async fn cmd_remove_reaction( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } pub async fn cmd_get_reactions(client: &SproutClient, event_id: &str) -> Result<(), CliError> { validate_hex64(event_id)?; - // Query kind:7 reactions referencing this event let filter = serde_json::json!({ "kinds": [7], "#e": [event_id] }); let resp = client.query(&filter).await?; - println!("{resp}"); + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + + let mut groups: HashMap> = HashMap::new(); + for e in &events { + let emoji = e + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("+") + .to_string(); + let pubkey = e + .get("pubkey") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + groups.entry(emoji).or_default().push(pubkey); + } + + let reactions: Vec = groups + .into_iter() + .map(|(emoji, pubkeys)| { + serde_json::json!({ + "emoji": emoji, + "count": pubkeys.len(), + "pubkeys": pubkeys, + }) + }) + .collect(); + + let output = serde_json::json!({ "reactions": reactions }); + println!("{}", serde_json::to_string(&output).unwrap_or_default()); Ok(()) } diff --git a/crates/sprout-cli/src/commands/social.rs b/crates/sprout-cli/src/commands/social.rs index a3bd66d4e2..6faac2d2eb 100644 --- a/crates/sprout-cli/src/commands/social.rs +++ b/crates/sprout-cli/src/commands/social.rs @@ -5,7 +5,7 @@ use sprout_sdk::kind::{ KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, }; -use crate::client::SproutClient; +use crate::client::{normalize_write_response, SproutClient}; use crate::error::CliError; use crate::validate::{parse_event_id, validate_hex64}; @@ -36,7 +36,7 @@ pub async fn cmd_publish_note( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -64,7 +64,7 @@ pub async fn cmd_set_contact_list( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } diff --git a/crates/sprout-cli/src/commands/users.rs b/crates/sprout-cli/src/commands/users.rs index 54db71943c..db7e94609c 100644 --- a/crates/sprout-cli/src/commands/users.rs +++ b/crates/sprout-cli/src/commands/users.rs @@ -1,4 +1,4 @@ -use crate::client::SproutClient; +use crate::client::{normalize_write_response, SproutClient}; use crate::error::CliError; use crate::validate::validate_hex64; @@ -43,7 +43,26 @@ pub async fn cmd_get_users( "limit": authors.len() }); let resp = client.query(&filter).await?; - println!("{resp}"); + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + let profiles: Vec = events + .iter() + .filter_map(|e| { + let content_str = e.get("content")?.as_str()?; + let mut profile: serde_json::Value = serde_json::from_str(content_str).ok()?; + if let Some(obj) = profile.as_object_mut() { + obj.insert( + "pubkey".to_string(), + serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")), + ); + } + Some(profile) + }) + .collect(); + if profiles.len() == 1 { + println!("{}", serde_json::to_string(&profiles[0]).unwrap_or_default()); + } else { + println!("{}", serde_json::to_string(&profiles).unwrap_or_default()); + } Ok(()) } @@ -72,26 +91,32 @@ async fn search_by_name(client: &SproutClient, query: &str) -> Result<(), CliErr }; let lower_query = query.to_ascii_lowercase(); - let matches: Vec<&serde_json::Value> = arr + let profiles: Vec = arr .iter() - .filter(|event| { - let Some(content_str) = event.get("content").and_then(|v| v.as_str()) else { - return false; - }; - let Ok(content) = serde_json::from_str::(content_str) else { - return false; - }; + .filter_map(|event| { + let content_str = event.get("content").and_then(|v| v.as_str())?; + let content: serde_json::Value = serde_json::from_str(content_str).ok()?; let display_name = content .get("display_name") .and_then(|v| v.as_str()) .unwrap_or(""); let name = content.get("name").and_then(|v| v.as_str()).unwrap_or(""); - display_name.to_ascii_lowercase().contains(&lower_query) - || name.to_ascii_lowercase().contains(&lower_query) + if !display_name.to_ascii_lowercase().contains(&lower_query) + && !name.to_ascii_lowercase().contains(&lower_query) + { + return None; + } + let mut profile = content; + if let Some(obj) = profile.as_object_mut() { + obj.insert( + "pubkey".to_string(), + serde_json::json!(event.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")), + ); + } + Some(profile) }) .collect(); - - let output = serde_json::to_string(&matches).expect("serializing parsed JSON values"); + let output = serde_json::to_string(&profiles).unwrap_or_default(); println!("{output}"); Ok(()) } @@ -158,7 +183,7 @@ pub async fn cmd_set_profile( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -209,7 +234,19 @@ pub async fn cmd_get_presence(client: &SproutClient, pubkeys_csv: &str) -> Resul "limit": pubkeys.len() }); let resp = client.query(&filter).await?; - println!("{resp}"); + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + let presence: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), + "status": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), + "updated_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + }) + }) + .collect(); + let output = serde_json::to_string(&presence).unwrap_or_default(); + println!("{output}"); Ok(()) } @@ -224,7 +261,7 @@ pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(), let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } diff --git a/crates/sprout-cli/src/commands/workflows.rs b/crates/sprout-cli/src/commands/workflows.rs index 064eb7cc29..e66a34457e 100644 --- a/crates/sprout-cli/src/commands/workflows.rs +++ b/crates/sprout-cli/src/commands/workflows.rs @@ -1,6 +1,6 @@ use sha2::{Digest, Sha256}; -use crate::client::SproutClient; +use crate::client::{extract_d_tag, normalize_write_response, SproutClient}; use crate::error::CliError; use crate::validate::{parse_uuid, read_or_stdin, sdk_err, validate_uuid}; @@ -18,7 +18,20 @@ pub async fn cmd_list_workflows(client: &SproutClient, channel_id: &str) -> Resu "#h": [channel_id] }); let resp = client.query(&filter).await?; - println!("{resp}"); + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + let workflows: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "workflow_id": extract_d_tag(e), + "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), + }) + }) + .collect(); + let output = serde_json::to_string(&workflows).unwrap_or_default(); + println!("{output}"); Ok(()) } @@ -34,7 +47,7 @@ pub async fn cmd_get_workflow(client: &SproutClient, workflow_id: &str) -> Resul Ok(()) } -/// Get workflow run history — query kind:46020 trigger events for this workflow. +/// Get workflow run history — query kinds [46001, 46002, 46003] (triggered, step_started, step_completed). pub async fn cmd_get_workflow_runs( client: &SproutClient, workflow_id: &str, @@ -43,12 +56,26 @@ pub async fn cmd_get_workflow_runs( validate_uuid(workflow_id)?; let limit = limit.unwrap_or(20).min(100); let filter = serde_json::json!({ - "kinds": [46020], + "kinds": [46001, 46002, 46003], "#d": [workflow_id], "limit": limit }); let resp = client.query(&filter).await?; - println!("{resp}"); + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + let normalized: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "event_id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0), + "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + "tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])), + }) + }) + .collect(); + let output = serde_json::to_string(&normalized).unwrap_or_default(); + println!("{output}"); Ok(()) } @@ -71,7 +98,13 @@ pub async fn cmd_create_workflow( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + let mut normalized: serde_json::Value = + serde_json::from_str(&resp).unwrap_or(serde_json::json!({})); + normalized["workflow_id"] = serde_json::json!(workflow_id.to_string()); + if normalized.get("accepted").is_none() { + normalized["accepted"] = serde_json::json!(true); + } + println!("{normalized}"); Ok(()) } @@ -91,7 +124,7 @@ pub async fn cmd_update_workflow( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -105,7 +138,7 @@ pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Re let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -120,7 +153,7 @@ pub async fn cmd_trigger_workflow( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) } @@ -142,7 +175,7 @@ pub async fn cmd_approve_step( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{resp}"); + println!("{}", normalize_write_response(&resp)); Ok(()) }