Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions crates/sprout-cli/src/commands/social.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
use nostr::{EventBuilder, Kind, Tag};
use serde::Deserialize;
use sprout_sdk::kind::{
KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_FOLLOW_SET, KIND_MUTE_LIST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST,
};

use crate::client::SproutClient;
use crate::error::CliError;
Expand Down Expand Up @@ -112,6 +117,90 @@ pub async fn cmd_get_contact_list(client: &SproutClient, pubkey: &str) -> Result
Ok(())
}

fn validate_social_list_kind(kind: u32) -> Result<(), CliError> {
match kind {
KIND_MUTE_LIST
| KIND_PIN_LIST
| KIND_NIP65_RELAY_LIST_METADATA
| KIND_BOOKMARK_LIST
| KIND_FOLLOW_SET
| KIND_BOOKMARK_SET => Ok(()),
_ => Err(CliError::Usage(format!(
"unsupported social list kind {kind}; supported kinds: 10000, 10001, 10002, 10003, 30000, 30003"
))),
}
}

fn is_parameterized_social_list_kind(kind: u32) -> bool {
matches!(kind, KIND_FOLLOW_SET | KIND_BOOKMARK_SET)
}

fn parse_tags_json(tags_json: &str) -> Result<Vec<Tag>, CliError> {
let raw_tags: Vec<Vec<String>> = serde_json::from_str(tags_json)
.map_err(|e| CliError::Usage(format!("invalid tags JSON: {e}")))?;
raw_tags
.iter()
.map(|parts| {
let refs: Vec<&str> = parts.iter().map(String::as_str).collect();
Tag::parse(&refs).map_err(|e| CliError::Usage(format!("invalid tag {parts:?}: {e}")))
})
.collect::<Result<_, _>>()
}

fn has_d_tag(tags: &[Tag]) -> bool {
tags.iter()
.any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("d"))
}

pub async fn cmd_set_list(
client: &SproutClient,
kind: u16,
tags_json: &str,
content: &str,
) -> Result<(), CliError> {
let kind_u32 = u32::from(kind);
validate_social_list_kind(kind_u32)?;
let tags = parse_tags_json(tags_json)?;
if is_parameterized_social_list_kind(kind_u32) && !has_d_tag(&tags) {
return Err(CliError::Usage(format!(
"kind {kind} is parameterized replaceable and requires a d tag"
)));
}

let builder = EventBuilder::new(Kind::Custom(kind), content, tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}

pub async fn cmd_get_list(
client: &SproutClient,
pubkey: &str,
kind: u32,
d_tag: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
validate_social_list_kind(kind)?;
if !is_parameterized_social_list_kind(kind) && d_tag.is_some() {
return Err(CliError::Usage(format!(
"kind {kind} is not parameterized; omit --d-tag"
)));
}

let mut filter = serde_json::json!({
"kinds": [kind],
"authors": [pubkey],
"limit": 10
});
if let Some(d) = d_tag {
filter["#d"] = serde_json::json!([d]);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}

// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
Expand All @@ -130,5 +219,62 @@ pub async fn dispatch(cmd: crate::SocialCmd, client: &SproutClient) -> Result<()
before,
} => cmd_get_user_notes(client, &pubkey, limit, before).await,
SocialCmd::GetContactList { pubkey } => cmd_get_contact_list(client, &pubkey).await,
SocialCmd::SetList {
kind,
tags,
content,
} => cmd_set_list(client, kind, &tags, &content).await,
SocialCmd::GetList {
pubkey,
kind,
d_tag,
} => cmd_get_list(client, &pubkey, kind, d_tag.as_deref()).await,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn social_list_kind_validation_accepts_supported_kinds() {
for kind in [
KIND_MUTE_LIST,
KIND_PIN_LIST,
KIND_NIP65_RELAY_LIST_METADATA,
KIND_BOOKMARK_LIST,
KIND_FOLLOW_SET,
KIND_BOOKMARK_SET,
] {
assert!(validate_social_list_kind(kind).is_ok(), "kind {kind}");
}
}

#[test]
fn social_list_kind_validation_rejects_unsupported_kinds() {
let err = validate_social_list_kind(30002).unwrap_err();
assert!(
matches!(err, CliError::Usage(msg) if msg.contains("unsupported social list kind 30002"))
);
}

#[test]
fn parses_tags_json_and_detects_d_tag() {
let tags = parse_tags_json(r#"[["d","friends"],["p","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]]"#)
.expect("tags parse");
assert!(has_d_tag(&tags));
}

#[test]
fn malformed_tags_json_is_usage_error() {
let err = parse_tags_json("not json").unwrap_err();
assert!(matches!(err, CliError::Usage(msg) if msg.contains("invalid tags JSON")));
}

#[test]
fn parameterized_social_list_kind_detection() {
assert!(is_parameterized_social_list_kind(KIND_FOLLOW_SET));
assert!(is_parameterized_social_list_kind(KIND_BOOKMARK_SET));
assert!(!is_parameterized_social_list_kind(KIND_MUTE_LIST));
}
}
38 changes: 36 additions & 2 deletions crates/sprout-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,32 @@ pub enum SocialCmd {
#[arg(long)]
pubkey: String,
},
/// Publish a NIP-51/NIP-65 social list or set.
#[command(name = "set-list")]
SetList {
/// Supported kind: 10000, 10001, 10002, 10003, 30000, or 30003.
#[arg(long)]
kind: u16,
/// JSON array of Nostr tags, e.g. [["p","<hex>"],["d","friends"]].
#[arg(long)]
tags: String,
/// Event content.
#[arg(long, default_value = "")]
content: String,
},
/// Get NIP-51/NIP-65 social lists or sets by author and kind.
#[command(name = "list")]
GetList {
/// 64-char hex pubkey of the author.
#[arg(long)]
pubkey: String,
/// Supported kind: 10000, 10001, 10002, 10003, 30000, or 30003.
#[arg(long)]
kind: u32,
/// Optional d-tag for parameterized replaceable sets.
#[arg(long)]
d_tag: Option<String>,
},
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1041,7 +1067,15 @@ mod tests {
assert_eq!(names(&cmd, "feed"), vec!["get"]);
assert_eq!(
names(&cmd, "social"),
vec!["contacts", "event", "notes", "publish", "set-contacts"]
vec![
"contacts",
"event",
"list",
"notes",
"publish",
"set-contacts",
"set-list"
]
);
assert_eq!(names(&cmd, "repos"), vec!["create", "get", "list"]);
assert_eq!(names(&cmd, "upload"), vec!["file"]);
Expand All @@ -1059,7 +1093,7 @@ mod tests {
("pack", 2),
("reactions", 3),
("repos", 3),
("social", 5),
("social", 7),
("upload", 1),
("users", 4),
("workflows", 8),
Expand Down
34 changes: 34 additions & 0 deletions crates/sprout-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ pub const KIND_PROFILE: u32 = 0;
pub const KIND_TEXT_NOTE: u32 = 1;
/// NIP-02: Contact list / follow list.
pub const KIND_CONTACT_LIST: u32 = 3;
/// NIP-51: Mute list (replaceable, 10000–19999 range) — pubkeys/events/threads/words a user has muted.
///
/// User-owned global state, keyed by `(pubkey, kind)`. Same ownership/scope shape as kind:3.
pub const KIND_MUTE_LIST: u32 = 10000;
/// NIP-51: Pin list (replaceable) — events the user has pinned to their profile.
///
/// User-owned global state, keyed by `(pubkey, kind)`. The events referenced may live in
/// channels, but the pin list itself is profile-level state.
pub const KIND_PIN_LIST: u32 = 10001;
/// NIP-65: Relay list metadata (replaceable) — read/write relay preferences for the outbox model.
///
/// User-owned global state, keyed by `(pubkey, kind)`. Tags are `["r", url]` or
/// `["r", url, "read"]` / `["r", url, "write"]`.
pub const KIND_NIP65_RELAY_LIST_METADATA: u32 = 10002;
/// NIP-51: Bookmark list (replaceable) — events/articles/hashtags/URLs the user has bookmarked.
///
/// User-owned global state, keyed by `(pubkey, kind)`. References content but is not itself
/// channel-scoped content.
pub const KIND_BOOKMARK_LIST: u32 = 10003;
/// NIP-51: Follow set (parameterized replaceable, 30000–39999 range) — named curated lists of pubkeys.
///
/// User-owned, keyed by `(pubkey, kind, d_tag)`. Allows multiple named follow lists on top of
/// the single kind:3 contact list (e.g. "close-friends", "news", "devs").
pub const KIND_FOLLOW_SET: u32 = 30000;
/// NIP-51: Bookmark set (parameterized replaceable) — named curated bookmark collections.
///
/// User-owned, keyed by `(pubkey, kind, d_tag)`.
pub const KIND_BOOKMARK_SET: u32 = 30003;
/// NIP-01: Channel metadata (replaceable). Not used by Sprout today.
pub const KIND_CHANNEL_METADATA: u32 = 41;
/// NIP-09: Event deletion request.
Expand Down Expand Up @@ -322,6 +350,12 @@ pub const ALL_KINDS: &[u32] = &[
KIND_PROFILE,
KIND_TEXT_NOTE,
KIND_CONTACT_LIST,
KIND_MUTE_LIST,
KIND_PIN_LIST,
KIND_NIP65_RELAY_LIST_METADATA,
KIND_BOOKMARK_LIST,
KIND_FOLLOW_SET,
KIND_BOOKMARK_SET,
KIND_CHANNEL_METADATA,
KIND_DELETION,
KIND_REACTION,
Expand Down
Loading
Loading