mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user