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.
This commit is contained in:
Will Pfleger
2026-05-18 18:36:26 -04:00
parent 811798b405
commit 5a53383897
9 changed files with 467 additions and 98 deletions

View File

@@ -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<String, CliError> {
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::Value> = serde_json::from_str(raw).unwrap_or_default();
let normalized: Vec<serde_json::Value> = 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<serde_json::Value> {
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::<serde_json::Value>(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()
}

View File

@@ -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<bool>,
visibility: Option<&str>,
member: Option<bool>,
) -> 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::Value> =
serde_json::from_str(&member_resp).unwrap_or_default();
let channel_ids: Vec<String> = 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::Value> = serde_json::from_str(&raw).unwrap_or_default();
let channels: Vec<serde_json::Value> = 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::Value> = 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::Value> = 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::Value> = 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(())
}

View File

@@ -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<u32>) -> 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::Value> = serde_json::from_str(&resp).unwrap_or_default();
let dms: Vec<serde_json::Value> = events
.iter()
.map(|e| {
let dm_id = extract_d_tag(e);
let participants: Vec<String> = 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<Tag> = 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(())
}

View File

@@ -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::Value> = 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(())
}

View File

@@ -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::Value> = 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::Value> = 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(())
}

View File

@@ -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::Value> = serde_json::from_str(&resp).unwrap_or_default();
let mut groups: HashMap<String, Vec<String>> = 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<serde_json::Value> = 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(())
}

View File

@@ -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(())
}

View File

@@ -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::Value> = serde_json::from_str(&resp).unwrap_or_default();
let profiles: Vec<serde_json::Value> = 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<serde_json::Value> = 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::<serde_json::Value>(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::Value> = serde_json::from_str(&resp).unwrap_or_default();
let presence: Vec<serde_json::Value> = 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(())
}

View File

@@ -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::Value> = serde_json::from_str(&resp).unwrap_or_default();
let workflows: Vec<serde_json::Value> = 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::Value> = serde_json::from_str(&resp).unwrap_or_default();
let normalized: Vec<serde_json::Value> = 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(())
}