mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Fix CLI output normalization and wrong Nostr kinds (MCP parity) (#612)
This commit is contained in:
26
AGENTS.md
26
AGENTS.md
@@ -52,8 +52,8 @@ See CONTRIBUTING.md for full setup details and dependency requirements.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
Run `just ci` before every PR. It runs: Rust `fmt` + `clippy`, desktop lint
|
||||
(Biome), unit tests, desktop build, and Tauri check. All must pass.
|
||||
Run `just ci` before every PR — it runs `fmt` + `clippy` + desktop lint +
|
||||
unit tests + builds. Clippy passing does not mean fmt passes; run both.
|
||||
|
||||
Run `just test` for integration tests if you touched `sprout-relay`,
|
||||
`sprout-db`, or `sprout-auth` — these require a running Postgres and Redis.
|
||||
@@ -113,6 +113,19 @@ check existing reply handlers for the pattern.
|
||||
|
||||
---
|
||||
|
||||
## Agent CLI (`sprout-cli`)
|
||||
|
||||
`sprout` is the agent-first CLI replacing `sprout-mcp`. Auth env vars
|
||||
(`SPROUT_RELAY_URL`, `SPROUT_PRIVATE_KEY`) are auto-injected by the ACP
|
||||
harness into managed agent subprocesses.
|
||||
|
||||
All reads return sig-stripped JSON arrays; all writes return
|
||||
`{event_id, accepted, message}`; creates add the entity ID. Exit codes:
|
||||
0=ok, 1=input error, 3=auth missing. See `crates/sprout-cli/TESTING.md`
|
||||
for the full live-testing runbook.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
@@ -136,6 +149,15 @@ See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
|
||||
|
||||
---
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **Kind `39000` for channel metadata, not `41`** — kind 41 is NIP-01 (unused). All kinds defined in `sprout-core/src/kind.rs`.
|
||||
2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403). Always include explicit kind filters.
|
||||
3. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
|
||||
4. **Desktop fmt check fails in worktrees** — run `just desktop-tauri-fmt-check` from the main checkout. CI is unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Desktop App
|
||||
|
||||
The desktop app is Tauri 2 + React 19 + Vite + Tailwind CSS. Features are
|
||||
|
||||
14
TESTING.md
14
TESTING.md
@@ -140,18 +140,8 @@ export SPROUT_PRIVATE_KEY=$(echo "$GEN" | awk '/Secret key:/ {print $3}')
|
||||
PUBKEY=$(echo "$GEN" | awk '/Public key:/ {print $3}')
|
||||
echo "pubkey: $PUBKEY"
|
||||
|
||||
# Create a channel (the CLI generates the UUID client-side and embeds it in
|
||||
# the kind:9007 event; it does NOT return the UUID in the response yet)
|
||||
sprout channels create --name "smoke-$$" --type stream --visibility open
|
||||
|
||||
# Find your new channel's UUID. kind:39002 (channel metadata) lists you as
|
||||
# owner; the channel UUID is in the `d` tag.
|
||||
CHANNEL=$(sprout channels list --member \
|
||||
| jq -r --arg pk "$PUBKEY" '
|
||||
.[]
|
||||
| select(any(.tags[]; .[0]=="p" and .[1]==$pk and .[3]=="owner"))
|
||||
| (.tags[] | select(.[0]=="d") | .[1])' \
|
||||
| head -1)
|
||||
# Create a channel — the UUID is returned in the response
|
||||
CHANNEL=$(sprout channels create --name "smoke-$$" --type stream --visibility open | jq -r '.channel_id')
|
||||
echo "channel: $CHANNEL"
|
||||
|
||||
# Send a message and read it back
|
||||
|
||||
@@ -120,43 +120,57 @@ earlier ones create resources that later ones need.
|
||||
sprout channels create --name "test-stream" --type stream --visibility open \
|
||||
--description "CLI test channel" | jq .
|
||||
# Save the channel ID:
|
||||
CHANNEL_ID=$(sprout channels create --name "test-cli" --type stream --visibility open | jq -r '.id')
|
||||
CHANNEL_ID=$(sprout channels create --name "test-cli" --type stream --visibility open | jq -r '.channel_id')
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"...","channel_id":"<uuid>"}
|
||||
|
||||
# channels create (forum) — needed for messages vote later
|
||||
FORUM_ID=$(sprout channels create --name "test-forum" --type forum --visibility open | jq -r '.id')
|
||||
FORUM_ID=$(sprout channels create --name "test-forum" --type forum --visibility open | jq -r '.channel_id')
|
||||
|
||||
# channels list
|
||||
sprout channels list | jq .
|
||||
# Expected: [{"channel_id":"...","name":"...","description":"...","created_at":N}]
|
||||
sprout channels list --visibility open | jq .
|
||||
sprout channels list --member | jq .
|
||||
|
||||
# channels get
|
||||
sprout channels get --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"channel_id":"...","name":"...","description":"...","created_at":N,"pubkey":"..."} or null
|
||||
|
||||
# channels update
|
||||
sprout channels update --channel "$CHANNEL_ID" --name "test-cli-updated" \
|
||||
--description "Updated" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels topic
|
||||
sprout channels topic --channel "$CHANNEL_ID" --topic "Test topic" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels purpose
|
||||
sprout channels purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels join (may already be a member from create)
|
||||
sprout channels join --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels leave
|
||||
# NOTE: Fails with 400 "cannot remove the last owner" if this identity is the
|
||||
# sole owner (which it is after channels create). To test leave successfully,
|
||||
# first add-member a second pubkey as owner. The relay enforces ≥1 owner.
|
||||
sprout channels leave --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."} (or 400 if last owner)
|
||||
|
||||
# Re-join so we can send messages
|
||||
sprout channels join --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels archive (requires admin:channels scope)
|
||||
sprout channels archive --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels unarchive
|
||||
sprout channels unarchive --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
```
|
||||
|
||||
### 6.2 Canvas
|
||||
@@ -169,7 +183,8 @@ sprout canvas set --channel "$CHANNEL_ID" --content "# Test Canvas" | jq .
|
||||
echo "# Canvas from stdin" | sprout canvas set --channel "$CHANNEL_ID" --content - | jq .
|
||||
|
||||
# canvas get
|
||||
sprout canvas get --channel "$CHANNEL_ID" | jq .
|
||||
sprout canvas get --channel "$CHANNEL_ID"
|
||||
# Expected: raw markdown string, or: null
|
||||
```
|
||||
|
||||
### 6.3 Messages
|
||||
@@ -178,13 +193,13 @@ sprout canvas get --channel "$CHANNEL_ID" | jq .
|
||||
# messages send
|
||||
MSG=$(sprout messages send --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .)
|
||||
echo "$MSG"
|
||||
EVENT_ID=$(echo "$MSG" | jq -r '.id // .event_id')
|
||||
EVENT_ID=$(echo "$MSG" | jq -r '.event_id')
|
||||
|
||||
# messages send with reply + broadcast
|
||||
REPLY=$(sprout messages send --channel "$CHANNEL_ID" --content "Reply" \
|
||||
--reply-to "$EVENT_ID" --broadcast | jq .)
|
||||
echo "$REPLY"
|
||||
REPLY_ID=$(echo "$REPLY" | jq -r '.id // .event_id')
|
||||
REPLY_ID=$(echo "$REPLY" | jq -r '.event_id')
|
||||
|
||||
# messages send with mentions
|
||||
sprout messages send --channel "$CHANNEL_ID" --content "Hey @someone" \
|
||||
@@ -254,13 +269,14 @@ echo "diff content" | sprout messages send-diff \
|
||||
```bash
|
||||
# Send a message to react to
|
||||
REACT_MSG=$(sprout messages send --channel "$CHANNEL_ID" --content "React to this")
|
||||
REACT_ID=$(echo "$REACT_MSG" | jq -r '.id // .event_id')
|
||||
REACT_ID=$(echo "$REACT_MSG" | jq -r '.event_id')
|
||||
|
||||
# reactions add
|
||||
sprout reactions add --event "$REACT_ID" --emoji "👍" | jq .
|
||||
|
||||
# reactions get
|
||||
sprout reactions get --event "$REACT_ID" | jq .
|
||||
# Expected: {"reactions":[{"emoji":"...","count":N,"pubkeys":["..."]}]}
|
||||
|
||||
# reactions remove
|
||||
sprout reactions remove --event "$REACT_ID" --emoji "👍" | jq .
|
||||
@@ -271,16 +287,18 @@ sprout reactions remove --event "$REACT_ID" --emoji "👍" | jq .
|
||||
```bash
|
||||
# dms list
|
||||
sprout dms list | jq .
|
||||
# Expected: [{"dm_id":"...","participants":["..."],"created_at":N}]
|
||||
|
||||
# dms open (needs a real pubkey — use your own or a test one)
|
||||
# Get your own pubkey first:
|
||||
MY_PUBKEY=$(sprout users get | jq -r '.pubkey // .[0].pubkey // empty')
|
||||
MY_PUBKEY=$(sprout users get | jq -r '.[0].pubkey // empty')
|
||||
echo "My pubkey: $MY_PUBKEY"
|
||||
|
||||
# dms open with a synthetic pubkey (relay will create the user)
|
||||
DM_RESULT=$(sprout dms open --pubkey "0000000000000000000000000000000000000000000000000000000000000001")
|
||||
echo "$DM_RESULT" | jq .
|
||||
DM_ID=$(echo "$DM_RESULT" | jq -r '.channel_id // .id // empty')
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"...","dm_id":"<uuid>"}
|
||||
DM_ID=$(echo "$DM_RESULT" | jq -r '.dm_id')
|
||||
|
||||
# dms add-member (requires messages:write scope — NOT admin:channels)
|
||||
sprout dms add-member --channel "$DM_ID" \
|
||||
@@ -292,6 +310,7 @@ sprout dms add-member --channel "$DM_ID" \
|
||||
```bash
|
||||
# users get — own profile (0 pubkeys)
|
||||
sprout users get | jq .
|
||||
# Expected: [{...profile...}] — always returns an array, even for single results
|
||||
|
||||
# users get — single pubkey
|
||||
sprout users get --pubkey "$MY_PUBKEY" | jq .
|
||||
@@ -309,6 +328,7 @@ sprout users presence --pubkeys "$MY_PUBKEY" | jq .
|
||||
sprout users set-presence --status online | jq .
|
||||
sprout users set-presence --status away | jq .
|
||||
sprout users set-presence --status offline | jq .
|
||||
# Note: set-presence may fail — kind:20001 is ephemeral and rejected by the HTTP bridge
|
||||
```
|
||||
|
||||
### 6.8 Channel Members (add/remove require admin:channels)
|
||||
@@ -321,6 +341,7 @@ sprout channels add-member --channel "$CHANNEL_ID" \
|
||||
|
||||
# channels members
|
||||
sprout channels members --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: [{"pubkey":"...","role":"..."}]
|
||||
|
||||
# channels remove-member
|
||||
sprout channels remove-member --channel "$CHANNEL_ID" \
|
||||
@@ -343,16 +364,17 @@ steps:
|
||||
action: send_message
|
||||
text: "Hello from workflow"' | jq .)
|
||||
echo "$WF"
|
||||
WF_ID=$(echo "$WF" | jq -r '.id')
|
||||
WF_ID=$(echo "$WF" | jq -r '.workflow_id')
|
||||
|
||||
# workflows list
|
||||
sprout workflows list --channel "$CHANNEL_ID" | jq .
|
||||
|
||||
# workflows get
|
||||
sprout workflows get --workflow "$WF_ID" | jq .
|
||||
# Expected: {"workflow_id":"...","content":"<yaml>","created_at":N,"pubkey":"..."} or null
|
||||
|
||||
# workflows update
|
||||
sprout workflows update --workflow "$WF_ID" \
|
||||
# workflows update (requires --channel)
|
||||
sprout workflows update --channel "$CHANNEL_ID" --workflow "$WF_ID" \
|
||||
--yaml 'name: test-wf-updated
|
||||
trigger:
|
||||
on: webhook
|
||||
@@ -362,10 +384,14 @@ steps:
|
||||
text: "Updated"' | jq .
|
||||
|
||||
# workflows trigger
|
||||
# NOTE: May return 400 "workflow not found" — the relay indexes workflow
|
||||
# definitions into a DB table asynchronously. If the definition event hasn't
|
||||
# been indexed yet, the trigger handler won't find it.
|
||||
sprout workflows trigger --workflow "$WF_ID" | jq .
|
||||
|
||||
# workflows runs
|
||||
sprout workflows runs --workflow "$WF_ID" | jq .
|
||||
# Expected: [] — relay stores runs in DB, not as Nostr events; empty is normal
|
||||
|
||||
# workflows approve — requires a workflow run waiting for approval
|
||||
# This is hard to test ad-hoc without a workflow that has an approval gate.
|
||||
@@ -383,6 +409,7 @@ sprout workflows delete --workflow "$WF_ID" | jq .
|
||||
```bash
|
||||
sprout feed get | jq .
|
||||
sprout feed get --limit 5 | jq .
|
||||
# Expected: [{id,pubkey,kind,content,created_at,tags}] — sig-stripped, sorted newest-first
|
||||
```
|
||||
|
||||
### 6.11 Forum & Voting
|
||||
@@ -392,7 +419,7 @@ sprout feed get --limit 5 | jq .
|
||||
FORUM_POST=$(sprout messages send --channel "$FORUM_ID" \
|
||||
--content "Forum post for vote testing" --kind 45001 | jq .)
|
||||
echo "$FORUM_POST"
|
||||
FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.id // .event_id')
|
||||
FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.event_id')
|
||||
|
||||
# messages vote (up)
|
||||
sprout messages vote --event "$FORUM_EVENT_ID" --direction up | jq .
|
||||
@@ -456,9 +483,9 @@ sprout messages delete --event "not-hex" 2>&1; echo "exit: $?"
|
||||
# stderr: {"error":"user_error","message":"must be a 64-character hex string: not-hex"}
|
||||
# exit: 1
|
||||
|
||||
# Exit 1: Invalid --type value
|
||||
# Exit 1: Invalid --type value (clap validates the enum — multi-line error)
|
||||
sprout channels create --name x --type invalid --visibility open 2>&1; echo "exit: $?"
|
||||
# stderr: {"error":"user_error","message":"--type must be 'stream' or 'forum' (got: invalid)"}
|
||||
# stderr: {"error":"user_error","message":"error: invalid value 'invalid' for '--type <CHANNEL_TYPE>'\n [possible values: stream, forum]\n..."}
|
||||
# exit: 1
|
||||
|
||||
# Exit 1: Invalid --direction value
|
||||
@@ -473,13 +500,13 @@ sprout users set-profile 2>&1; echo "exit: $?"
|
||||
# Exit 3: No auth configured
|
||||
env -u SPROUT_PRIVATE_KEY \
|
||||
cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?"
|
||||
# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
|
||||
# stderr: {"error":"auth_error","message":"auth error: SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
|
||||
# exit: 3
|
||||
|
||||
# Exit 2: Non-existent channel (valid UUID)
|
||||
sprout channels get --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?"
|
||||
# stderr: {"error":"relay_error","message":"..."}
|
||||
# exit: 2
|
||||
# Not-found returns null, not an error (exit 0)
|
||||
sprout channels get --channel "00000000-0000-0000-0000-000000000000"
|
||||
# stdout: null
|
||||
# exit: 0
|
||||
```
|
||||
|
||||
---
|
||||
@@ -496,7 +523,7 @@ SPROUT_PRIVATE_KEY="nsec1..." sprout channels list | jq .
|
||||
# No auth → exit 3
|
||||
env -u SPROUT_PRIVATE_KEY \
|
||||
cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?"
|
||||
# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
|
||||
# stderr: {"error":"auth_error","message":"auth error: SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
|
||||
# exit: 3
|
||||
```
|
||||
|
||||
|
||||
@@ -220,11 +220,16 @@ impl SproutClient {
|
||||
/// `filter` is a Nostr filter object (will be wrapped in an array).
|
||||
/// Returns the raw JSON response (array of events).
|
||||
pub async fn query(&self, filter: &serde_json::Value) -> Result<String, CliError> {
|
||||
self.query_multi(std::slice::from_ref(filter)).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(&[filter])
|
||||
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)
|
||||
@@ -232,7 +237,6 @@ impl SproutClient {
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_bytes);
|
||||
let resp = self.with_auth_tag(req).send().await?;
|
||||
|
||||
self.handle_response(resp).await
|
||||
}
|
||||
|
||||
@@ -404,6 +408,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 +438,119 @@ 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(events: &[serde_json::Value]) -> String {
|
||||
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 a named tag's value from a Nostr event JSON object.
|
||||
/// Finds the first tag whose first element matches `key` and returns the second element.
|
||||
pub fn extract_tag_value(event: &serde_json::Value, key: &str) -> 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(key)
|
||||
})
|
||||
})
|
||||
.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(3).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).unwrap_or("member"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Print a create-command response, injecting the generated entity ID.
|
||||
pub fn print_create_response(resp: &str, id_key: &str, id_val: &str) {
|
||||
let mut v: serde_json::Value = serde_json::from_str(resp).unwrap_or(serde_json::json!({}));
|
||||
v[id_key] = serde_json::json!(id_val);
|
||||
if v.get("accepted").is_none() {
|
||||
v["accepted"] = serde_json::json!(true);
|
||||
}
|
||||
println!("{v}");
|
||||
}
|
||||
|
||||
/// 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,9 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::SproutClient;
|
||||
use crate::client::{
|
||||
extract_d_tag, extract_p_tags, extract_tag_value, normalize_write_response,
|
||||
print_create_response, SproutClient,
|
||||
};
|
||||
use crate::error::CliError;
|
||||
use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
|
||||
|
||||
@@ -8,36 +11,123 @@ use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
|
||||
// Read commands — POST /query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"channel_id": extract_d_tag(e),
|
||||
"name": extract_tag_value(e, "name"),
|
||||
"description": extract_tag_value(e, "about"),
|
||||
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn cmd_list_channels(
|
||||
client: &SproutClient,
|
||||
_visibility: Option<&str>,
|
||||
_member: Option<bool>,
|
||||
visibility: Option<&str>,
|
||||
member: Option<bool>,
|
||||
limit: Option<u32>,
|
||||
format: &crate::OutputFormat,
|
||||
) -> 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 effective_limit = limit.unwrap_or(500);
|
||||
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": effective_limit
|
||||
});
|
||||
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:39000 metadata for those channels
|
||||
let metadata_filter = serde_json::json!({
|
||||
"kinds": [39000],
|
||||
"#d": channel_ids,
|
||||
"limit": effective_limit
|
||||
});
|
||||
client.query(&metadata_filter).await?
|
||||
} else {
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [39000],
|
||||
"limit": effective_limit
|
||||
});
|
||||
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 {
|
||||
// NIP-29: relay emits ["public"] or ["private"] single-element tags
|
||||
let nip29_tag = match vis {
|
||||
"open" => "public",
|
||||
_ => vis,
|
||||
};
|
||||
e.get("tags")
|
||||
.and_then(|t| t.as_array())
|
||||
.map(|tags| {
|
||||
tags.iter().any(|tag| {
|
||||
tag.as_array()
|
||||
.map(|a| {
|
||||
a.len() == 1
|
||||
&& a.first().and_then(|v| v.as_str()) == Some(nip29_tag)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.map(extract_channel_metadata)
|
||||
.collect();
|
||||
let output = match format {
|
||||
crate::OutputFormat::Compact => {
|
||||
let compact: Vec<serde_json::Value> = channels
|
||||
.iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"channel_id": c.get("channel_id").cloned().unwrap_or_default(),
|
||||
"name": c.get("name").cloned().unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&compact).unwrap_or_default()
|
||||
}
|
||||
crate::OutputFormat::Json => 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": [39000],
|
||||
"#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 mut normalized = extract_channel_metadata(e);
|
||||
normalized["pubkey"] =
|
||||
serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""));
|
||||
println!("{normalized}");
|
||||
} else {
|
||||
println!("null");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -46,25 +136,36 @@ 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!("null");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -114,7 +215,7 @@ pub async fn cmd_create_channel(
|
||||
|
||||
let event = client.sign_event(builder)?;
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{resp}");
|
||||
print_create_response(&resp, "channel_id", &channel_uuid.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -136,7 +237,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 +253,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 +269,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 +281,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 +293,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 +305,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 +320,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 +332,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 +363,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 +380,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 +397,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(())
|
||||
}
|
||||
|
||||
@@ -304,12 +405,20 @@ pub async fn cmd_set_canvas(
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::ChannelsCmd, client: &SproutClient) -> Result<(), CliError> {
|
||||
pub async fn dispatch(
|
||||
cmd: crate::ChannelsCmd,
|
||||
client: &SproutClient,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
use crate::ChannelsCmd;
|
||||
match cmd {
|
||||
ChannelsCmd::List { visibility, member } => {
|
||||
ChannelsCmd::List {
|
||||
visibility,
|
||||
member,
|
||||
limit,
|
||||
} => {
|
||||
let vis_str = visibility.as_ref().map(|v| v.to_string());
|
||||
cmd_list_channels(client, vis_str.as_deref(), Some(member)).await
|
||||
cmd_list_channels(client, vis_str.as_deref(), Some(member), limit, format).await
|
||||
}
|
||||
ChannelsCmd::Get { channel } => cmd_get_channel(client, &channel).await,
|
||||
ChannelsCmd::Create {
|
||||
|
||||
@@ -1,32 +1,94 @@
|
||||
use crate::client::SproutClient;
|
||||
use crate::error::CliError;
|
||||
use crate::validate::{parse_uuid, sdk_err};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// List DM conversations by querying kind:41010 (DM open) events authored by us.
|
||||
use crate::client::{extract_d_tag, normalize_write_response, SproutClient};
|
||||
use crate::error::CliError;
|
||||
use crate::validate::{parse_uuid, sdk_err, validate_hex64};
|
||||
|
||||
/// 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()));
|
||||
}
|
||||
for pk in pubkeys {
|
||||
validate_hex64(pk)?;
|
||||
}
|
||||
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]).map_err(|e| CliError::Other(format!("tag error: {e}"))))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
tags.push(Tag::parse(&["d", &dm_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?);
|
||||
let builder = EventBuilder::new(Kind::Custom(41010), "", tags);
|
||||
let event = client.sign_event(builder)?;
|
||||
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{resp}");
|
||||
// Try to extract relay-assigned channel_id from response message.
|
||||
// Relay returns: {"event_id":"...","accepted":true,"message":"response:{\"channel_id\":\"...\",\"created\":true}"}
|
||||
let relay_dm_id = serde_json::from_str::<serde_json::Value>(&resp)
|
||||
.ok()
|
||||
.and_then(|v| v.get("message")?.as_str().map(|s| s.to_string()))
|
||||
.and_then(|msg| {
|
||||
let json_part = msg.strip_prefix("response:")?;
|
||||
serde_json::from_str::<serde_json::Value>(json_part).ok()
|
||||
})
|
||||
.and_then(|v| v.get("channel_id")?.as_str().map(|s| s.to_string()));
|
||||
let final_dm_id = relay_dm_id.unwrap_or(dm_id);
|
||||
|
||||
let mut normalized: serde_json::Value =
|
||||
serde_json::from_str(&resp).unwrap_or(serde_json::json!({}));
|
||||
normalized["dm_id"] = serde_json::json!(final_dm_id);
|
||||
if normalized.get("accepted").is_none() {
|
||||
normalized["accepted"] = serde_json::json!(true);
|
||||
}
|
||||
println!("{normalized}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -42,7 +104,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).
|
||||
@@ -6,7 +8,7 @@ pub async fn cmd_get_feed(
|
||||
client: &SproutClient,
|
||||
since: Option<i64>,
|
||||
limit: Option<u32>,
|
||||
_types: Option<&str>,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
let my_pk = client.keys().public_key().to_hex();
|
||||
let limit = limit.unwrap_or(20).min(50);
|
||||
@@ -21,7 +23,28 @@ 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 normalized = normalize_events(&events);
|
||||
let output = match format {
|
||||
crate::OutputFormat::Compact => {
|
||||
let evts: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&normalized).unwrap_or_default();
|
||||
let compact: Vec<serde_json::Value> = evts
|
||||
.iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"id": e.get("id").cloned().unwrap_or_default(),
|
||||
"content": e.get("content").cloned().unwrap_or_default(),
|
||||
"created_at": e.get("created_at").cloned().unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&compact).unwrap_or_default()
|
||||
}
|
||||
crate::OutputFormat::Json => normalized,
|
||||
};
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -29,13 +52,13 @@ pub async fn cmd_get_feed(
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::FeedCmd, client: &SproutClient) -> Result<(), CliError> {
|
||||
pub async fn dispatch(
|
||||
cmd: crate::FeedCmd,
|
||||
client: &SproutClient,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
use crate::FeedCmd;
|
||||
match cmd {
|
||||
FeedCmd::Get {
|
||||
since,
|
||||
limit,
|
||||
types,
|
||||
} => cmd_get_feed(client, since, limit, types.as_deref()).await,
|
||||
FeedCmd::Get { since, limit } => cmd_get_feed(client, since, limit, format).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -222,6 +222,27 @@ fn parse_member_pubkeys(event: &serde_json::Value) -> Vec<String> {
|
||||
// Read commands — POST /query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn format_events(normalized: &str, format: &crate::OutputFormat) -> String {
|
||||
match format {
|
||||
crate::OutputFormat::Compact => {
|
||||
let events: Vec<serde_json::Value> =
|
||||
serde_json::from_str(normalized).unwrap_or_default();
|
||||
let compact: Vec<serde_json::Value> = events
|
||||
.iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"id": e.get("id").cloned().unwrap_or_default(),
|
||||
"content": e.get("content").cloned().unwrap_or_default(),
|
||||
"created_at": e.get("created_at").cloned().unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&compact).unwrap_or_default()
|
||||
}
|
||||
crate::OutputFormat::Json => normalized.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cmd_get_messages(
|
||||
client: &SproutClient,
|
||||
channel_id: &str,
|
||||
@@ -229,12 +250,13 @@ pub async fn cmd_get_messages(
|
||||
before: Option<i64>,
|
||||
since: Option<i64>,
|
||||
kinds: Option<&str>,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
validate_uuid(channel_id)?;
|
||||
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 +277,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 normalized = normalize_events(&events);
|
||||
println!("{}", format_events(&normalized, format));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -263,22 +288,31 @@ pub async fn cmd_get_thread(
|
||||
client: &SproutClient,
|
||||
channel_id: &str,
|
||||
event_id: &str,
|
||||
_depth_limit: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
validate_uuid(channel_id)?;
|
||||
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!({
|
||||
"kinds": [9, 40002, 40003, 40008, 45003],
|
||||
"#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 normalized = normalize_events(&events);
|
||||
println!("{}", format_events(&normalized, format));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -286,14 +320,18 @@ pub async fn cmd_search(
|
||||
client: &SproutClient,
|
||||
query: &str,
|
||||
limit: Option<u32>,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
let limit = limit.unwrap_or(20).min(100);
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [9, 40002, 45001, 45003],
|
||||
"search": query,
|
||||
"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 = normalize_events(&events);
|
||||
println!("{}", format_events(&normalized, format));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -304,7 +342,6 @@ pub async fn cmd_search(
|
||||
pub struct SendMessageParams {
|
||||
pub channel_id: String,
|
||||
pub content: String,
|
||||
#[allow(dead_code)] // reserved for future kind routing
|
||||
pub kind: Option<u16>,
|
||||
pub reply_to: Option<String>,
|
||||
pub broadcast: bool,
|
||||
@@ -370,20 +407,44 @@ pub async fn cmd_send_message(
|
||||
merge_mentions(&mut merged, &auto_resolved, MENTION_CAP);
|
||||
let mention_refs: Vec<&str> = merged.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let builder = sprout_sdk::build_message(
|
||||
channel_uuid,
|
||||
&final_content,
|
||||
thread_ref.as_ref(),
|
||||
&mention_refs,
|
||||
p.broadcast,
|
||||
&media_tags,
|
||||
)
|
||||
.map_err(|e| CliError::Other(format!("build_message failed: {e}")))?;
|
||||
let builder = match p.kind {
|
||||
Some(45001) => {
|
||||
sprout_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags)
|
||||
.map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))?
|
||||
}
|
||||
Some(45003) => {
|
||||
let tr = thread_ref.as_ref().ok_or_else(|| {
|
||||
CliError::Usage("--reply-to is required for forum comments (kind 45003)".into())
|
||||
})?;
|
||||
sprout_sdk::build_forum_comment(
|
||||
channel_uuid,
|
||||
&final_content,
|
||||
tr,
|
||||
&mention_refs,
|
||||
&media_tags,
|
||||
)
|
||||
.map_err(|e| CliError::Other(format!("build_forum_comment failed: {e}")))?
|
||||
}
|
||||
None | Some(9) => sprout_sdk::build_message(
|
||||
channel_uuid,
|
||||
&final_content,
|
||||
thread_ref.as_ref(),
|
||||
&mention_refs,
|
||||
p.broadcast,
|
||||
&media_tags,
|
||||
)
|
||||
.map_err(|e| CliError::Other(format!("build_message failed: {e}")))?,
|
||||
Some(k) => {
|
||||
return Err(CliError::Usage(format!(
|
||||
"--kind {k} is not supported (use 9, 45001, or 45003)"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let event = client.sign_event(builder)?;
|
||||
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{resp}");
|
||||
println!("{}", normalize_write_response(&resp));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -474,7 +535,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 +552,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 +575,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 +606,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(())
|
||||
}
|
||||
|
||||
@@ -553,7 +614,11 @@ pub async fn cmd_vote_on_post(
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::MessagesCmd, client: &SproutClient) -> Result<(), CliError> {
|
||||
pub async fn dispatch(
|
||||
cmd: crate::MessagesCmd,
|
||||
client: &SproutClient,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
use crate::MessagesCmd;
|
||||
match cmd {
|
||||
MessagesCmd::Send {
|
||||
@@ -620,14 +685,24 @@ pub async fn dispatch(cmd: crate::MessagesCmd, client: &SproutClient) -> Result<
|
||||
before,
|
||||
since,
|
||||
kinds,
|
||||
} => cmd_get_messages(client, &channel, limit, before, since, kinds.as_deref()).await,
|
||||
} => {
|
||||
cmd_get_messages(
|
||||
client,
|
||||
&channel,
|
||||
limit,
|
||||
before,
|
||||
since,
|
||||
kinds.as_deref(),
|
||||
format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
MessagesCmd::Thread {
|
||||
channel,
|
||||
event,
|
||||
depth_limit,
|
||||
limit,
|
||||
} => cmd_get_thread(client, &channel, &event, depth_limit, limit).await,
|
||||
MessagesCmd::Search { query, limit } => cmd_search(client, &query, limit).await,
|
||||
} => cmd_get_thread(client, &channel, &event, limit, format).await,
|
||||
MessagesCmd::Search { query, limit } => cmd_search(client, &query, limit, format).await,
|
||||
MessagesCmd::Vote { event, direction } => {
|
||||
cmd_vote_on_post(client, &event, &direction).await
|
||||
}
|
||||
|
||||
@@ -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,54 @@ 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())
|
||||
.filter(|s| !s.is_empty())
|
||||
.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 mut reactions: Vec<serde_json::Value> = groups
|
||||
.into_iter()
|
||||
.map(|(emoji, pubkeys)| {
|
||||
serde_json::json!({
|
||||
"emoji": emoji,
|
||||
"count": pubkeys.len(),
|
||||
"pubkeys": pubkeys,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
reactions.sort_by(|a, b| {
|
||||
a.get("emoji")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.cmp(b.get("emoji").and_then(|v| v.as_str()).unwrap_or(""))
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -13,6 +13,7 @@ pub async fn cmd_get_users(
|
||||
client: &SproutClient,
|
||||
pubkeys: &[String],
|
||||
name: Option<&str>,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
if let Some(query) = name {
|
||||
if !pubkeys.is_empty() {
|
||||
@@ -20,7 +21,7 @@ pub async fn cmd_get_users(
|
||||
"--name and --pubkey are mutually exclusive".into(),
|
||||
));
|
||||
}
|
||||
return search_by_name(client, query).await;
|
||||
return search_by_name(client, query, format).await;
|
||||
}
|
||||
|
||||
for pk in pubkeys {
|
||||
@@ -43,13 +44,45 @@ 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();
|
||||
let output = match format {
|
||||
crate::OutputFormat::Compact => {
|
||||
let compact: Vec<serde_json::Value> = profiles
|
||||
.iter()
|
||||
.map(|p| serde_json::json!({
|
||||
"pubkey": p.get("pubkey").cloned().unwrap_or_default(),
|
||||
"display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(),
|
||||
}))
|
||||
.collect();
|
||||
serde_json::to_string(&compact).unwrap_or_default()
|
||||
}
|
||||
crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(),
|
||||
};
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search for users by display name via NIP-50 full-text search on kind:0 profiles.
|
||||
/// Returns [] if the relay does not implement NIP-50 search.
|
||||
async fn search_by_name(client: &SproutClient, query: &str) -> Result<(), CliError> {
|
||||
async fn search_by_name(
|
||||
client: &SproutClient,
|
||||
query: &str,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
if query.trim().is_empty() {
|
||||
return Err(CliError::Usage("--name cannot be empty".into()));
|
||||
}
|
||||
@@ -72,26 +105,44 @@ 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 = match format {
|
||||
crate::OutputFormat::Compact => {
|
||||
let compact: Vec<serde_json::Value> = profiles
|
||||
.iter()
|
||||
.map(|p| serde_json::json!({
|
||||
"pubkey": p.get("pubkey").cloned().unwrap_or_default(),
|
||||
"display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(),
|
||||
}))
|
||||
.collect();
|
||||
serde_json::to_string(&compact).unwrap_or_default()
|
||||
}
|
||||
crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(),
|
||||
};
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -158,7 +209,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 +260,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 +287,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(())
|
||||
}
|
||||
|
||||
@@ -232,10 +295,16 @@ pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(),
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn dispatch(cmd: crate::UsersCmd, client: &SproutClient) -> Result<(), CliError> {
|
||||
pub async fn dispatch(
|
||||
cmd: crate::UsersCmd,
|
||||
client: &SproutClient,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
use crate::UsersCmd;
|
||||
match cmd {
|
||||
UsersCmd::Get { pubkeys, name } => cmd_get_users(client, &pubkeys, name.as_deref()).await,
|
||||
UsersCmd::Get { pubkeys, name } => {
|
||||
cmd_get_users(client, &pubkeys, name.as_deref(), format).await
|
||||
}
|
||||
UsersCmd::SetProfile {
|
||||
name,
|
||||
avatar,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::client::SproutClient;
|
||||
use crate::client::{extract_d_tag, normalize_write_response, print_create_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(())
|
||||
}
|
||||
|
||||
@@ -30,11 +43,27 @@ pub async fn cmd_get_workflow(client: &SproutClient, workflow_id: &str) -> Resul
|
||||
"#d": [workflow_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(e) = events.first() {
|
||||
let normalized = 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(""),
|
||||
});
|
||||
println!("{normalized}");
|
||||
} else {
|
||||
println!("null");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get workflow run history — query kind:46020 trigger events for this workflow.
|
||||
/// Get workflow run history — query kinds [46001, 46002, 46003].
|
||||
///
|
||||
/// NOTE: The relay does not currently emit workflow execution events (46001-46003).
|
||||
/// Run history is stored in the workflow_runs DB table, not as Nostr events.
|
||||
/// This command will return an empty array until the relay adds event emission
|
||||
/// or a dedicated REST endpoint for run history.
|
||||
pub async fn cmd_get_workflow_runs(
|
||||
client: &SproutClient,
|
||||
workflow_id: &str,
|
||||
@@ -43,12 +72,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 +114,7 @@ pub async fn cmd_create_workflow(
|
||||
let event = client.sign_event(builder)?;
|
||||
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{resp}");
|
||||
print_create_response(&resp, "workflow_id", &workflow_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -91,7 +134,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 +148,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 +163,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 +185,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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,10 @@ struct Cli {
|
||||
#[arg(long, env = "SPROUT_AUTH_TAG")]
|
||||
auth_tag: Option<String>,
|
||||
|
||||
/// Output format: 'json' (default, full fields) or 'compact' (reduced fields).
|
||||
#[arg(long, value_enum, default_value = "json")]
|
||||
format: OutputFormat,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Cmd,
|
||||
}
|
||||
@@ -145,6 +149,18 @@ impl std::fmt::Display for PresenceStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Output format for read commands.
|
||||
#[derive(Clone, clap::ValueEnum, Default)]
|
||||
pub enum OutputFormat {
|
||||
/// Full normalized JSON (default)
|
||||
#[default]
|
||||
#[value(name = "json")]
|
||||
Json,
|
||||
/// Reduced fields for agent scanning
|
||||
#[value(name = "compact")]
|
||||
Compact,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subcommand groups
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -311,9 +327,6 @@ pub enum MessagesCmd {
|
||||
/// Root message event ID (64-char hex)
|
||||
#[arg(long)]
|
||||
event: String,
|
||||
/// Maximum reply depth to traverse
|
||||
#[arg(long)]
|
||||
depth_limit: Option<u32>,
|
||||
/// Maximum number of results to return
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
@@ -355,6 +368,9 @@ pub enum ChannelsCmd {
|
||||
/// Only show channels where the current identity is a member
|
||||
#[arg(long, default_value_t = false)]
|
||||
member: bool,
|
||||
/// Maximum number of channels to return [default: 500]
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// Get details for a single channel
|
||||
Get {
|
||||
@@ -693,9 +709,6 @@ pub enum FeedCmd {
|
||||
/// Maximum number of results to return
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
/// Comma-separated feed entry types to filter
|
||||
#[arg(long)]
|
||||
types: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1056,14 +1069,14 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
let client = SproutClient::new(relay_url, keys, auth_tag, auth_tag_json)?;
|
||||
|
||||
match cli.command {
|
||||
Cmd::Messages(sub) => commands::messages::dispatch(sub, &client).await,
|
||||
Cmd::Channels(sub) => commands::channels::dispatch(sub, &client).await,
|
||||
Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await,
|
||||
Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await,
|
||||
Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await,
|
||||
Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await,
|
||||
Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await,
|
||||
Cmd::Users(sub) => commands::users::dispatch(sub, &client).await,
|
||||
Cmd::Users(sub) => commands::users::dispatch(sub, &client, &cli.format).await,
|
||||
Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await,
|
||||
Cmd::Feed(sub) => commands::feed::dispatch(sub, &client).await,
|
||||
Cmd::Feed(sub) => commands::feed::dispatch(sub, &client, &cli.format).await,
|
||||
Cmd::Social(sub) => commands::social::dispatch(sub, &client).await,
|
||||
Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await,
|
||||
Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await,
|
||||
|
||||
Reference in New Issue
Block a user