From 2fed49411d989a7812ea21cf9660c399154fec8b Mon Sep 17 00:00:00 2001 From: Kagan Yaldizkaya Date: Tue, 28 Jul 2026 02:51:28 +0200 Subject: [PATCH 1/2] Add `buzz users set-status` for the NIP-38 profile status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop client renders a persistent user status (kind:30315, d:general) on profiles, but the CLI had no way to set it β€” only ephemeral presence. Integrations such as a now-playing music bridge need a scriptable way to update the status line. `buzz users set-status --text "..." [--emoji "🎢"]` signs and submits the replaceable status event via the HTTP bridge. Empty text clears the status. Signed-off-by: Kagan Yaldizkaya --- crates/buzz-cli/src/commands/users.rs | 33 +++++++++++++++++++++++++++ crates/buzz-cli/src/lib.rs | 20 ++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 3f8325b4b9..099a92b869 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -304,6 +304,36 @@ pub async fn cmd_set_presence(client: &BuzzClient, status: &str) -> Result<(), C Ok(()) } +/// Set user status β€” sign and submit a NIP-38 kind:30315 user status event. +/// +/// Uses the `d:general` coordinate that the desktop client reads for the +/// profile status line. Empty text clears the status (replaceable event with +/// empty content). +pub async fn cmd_set_status( + client: &BuzzClient, + text: &str, + emoji: Option<&str>, +) -> Result<(), CliError> { + use nostr::{EventBuilder, Kind, Tag}; + + const KIND_USER_STATUS: u16 = 30315; + let text = text.trim(); + let mut tags = vec![Tag::parse(["d", "general"]) + .map_err(|e| CliError::Usage(format!("internal tag error: {e}")))?]; + if let Some(emoji) = emoji.map(str::trim).filter(|e| !e.is_empty()) { + tags.push( + Tag::parse(["emoji", emoji]) + .map_err(|e| CliError::Usage(format!("invalid emoji tag: {e}")))?, + ); + } + + let builder = EventBuilder::new(Kind::Custom(KIND_USER_STATUS), text).tags(tags); + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + pub async fn dispatch( cmd: crate::UsersCmd, client: &BuzzClient, @@ -331,6 +361,9 @@ pub async fn dispatch( } UsersCmd::Presence { pubkeys } => cmd_get_presence(client, &pubkeys).await, UsersCmd::SetPresence { status } => cmd_set_presence(client, &status.to_string()).await, + UsersCmd::SetStatus { text, emoji } => { + cmd_set_status(client, &text, emoji.as_deref()).await + } } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082d..bc1c64989f 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -838,6 +838,16 @@ pub enum UsersCmd { #[arg(long, value_enum)] status: PresenceStatus, }, + /// Set your user status (NIP-38 kind:30315 β€” the "status" line on your profile) + #[command(name = "set-status")] + SetStatus { + /// Status text. Empty text clears the status. + #[arg(long)] + text: String, + /// Optional emoji shown before the status text + #[arg(long)] + emoji: Option, + }, } #[derive(Subcommand)] @@ -1924,7 +1934,13 @@ mod tests { ); assert_eq!( names(&cmd, "users"), - vec!["get", "presence", "set-presence", "set-profile"] + vec![ + "get", + "presence", + "set-presence", + "set-profile", + "set-status" + ] ); assert_eq!( names(&cmd, "workflows"), @@ -2011,7 +2027,7 @@ mod tests { ("repos", 4), ("social", 7), ("upload", 1), - ("users", 4), + ("users", 5), ("workflows", 8), ]; From c446507973134985e6c0fc83a7bfe28bd0d4281a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 28 Jul 2026 10:45:50 -0400 Subject: [PATCH 2/2] refactor(cli): move set-status event construction into buzz-sdk The command built its kind:30315 event inline with a raw EventBuilder and a duplicated 30315 literal, bypassing buzz-sdk as the typed write boundary and leaving the event shape untested. build_user_status() now owns the d:general coordinate, trimming, and emoji omission, keyed off buzz-core's KIND_USER_STATUS. An empty --text no longer implies clearing: it left an emoji-only status whenever --emoji was supplied, which contradicted the help text. Clearing is now the explicit --clear flag, mutually exclusive with --text/--emoji, and --text "" --emoji "..." is an intentional emoji-only status. Signed-off-by: Will Pfleger --- crates/buzz-cli/README.md | 3 ++ crates/buzz-cli/TESTING.md | 17 ++++++- crates/buzz-cli/src/commands/users.rs | 29 +++++------- crates/buzz-cli/src/lib.rs | 33 +++++++++++-- crates/buzz-sdk/src/builders.rs | 67 ++++++++++++++++++++++++++- 5 files changed, 125 insertions(+), 24 deletions(-) diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a8c668cf06..40699459fc 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -57,6 +57,8 @@ buzz users get # your own profile buzz users get --pubkey # single user buzz users get --pubkey --pubkey # batch (max 200) buzz users set-presence --status online +buzz users set-status --text "heads down on the CLI" --emoji "πŸš€" +buzz users set-status --clear # remove your status # DMs buzz dms open --pubkey @@ -133,6 +135,7 @@ stored rules in `validation_error` so an owner can remove and repair them. | | `set-profile` | Update your profile | | | `presence` | Get presence status | | | `set-presence` | Set presence status | +| | `set-status` | Set or clear your NIP-38 profile status | | `workflows` | `list` | List workflows | | | `get` | Get workflow definition | | | `create` | Create a workflow | diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 4b7257aba7..77234b7faa 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -87,7 +87,7 @@ export BUZZ_PRIVATE_KEY="nsec1..." # from the mint output | `channels:read` | βœ… | `channels list`, `channels get`, `channels members` | | `channels:write` | βœ… | `channels create`, `channels update`, `channels join`, `channels leave`, `channels topic`, `channels purpose` | | `users:read` | βœ… | `users get`, `users presence` | -| `users:write` | βœ… | `users set-profile`, `users set-presence` | +| `users:write` | βœ… | `users set-profile`, `users set-presence`, `users set-status` | | `files:read` | βœ… | β€” | | `files:write` | βœ… | β€” | | `admin:channels` | ❌ | `channels archive`, `channels unarchive`, `channels delete`, `channels add-member`, `channels remove-member` | @@ -331,6 +331,20 @@ buzz users set-presence --status online | jq . buzz users set-presence --status away | jq . buzz users set-presence --status offline | jq . # Note: set-presence may fail β€” kind:20001 is ephemeral and rejected by the HTTP bridge + +# users set-status β€” NIP-38 kind:30315 on the d:general coordinate +buzz users set-status --text "reviewing PRs" --emoji "πŸ”" | jq . +buzz users set-status --text "no emoji this time" | jq . + +# users set-status β€” emoji-only status (intentional: text is blank, emoji is kept) +buzz users set-status --text "" --emoji "🎢" | jq . + +# users set-status --clear β€” removes the status (empty content, d:general only) +buzz users set-status --clear | jq . + +# --clear is mutually exclusive with --text/--emoji +buzz users set-status --clear --text "nope" 2>&1; echo "exit: $?" +# Expected: exit 1 β€” clap conflict error ``` ### 6.8 Channel Members (add/remove require admin:channels) @@ -606,3 +620,4 @@ buzz channels delete --channel "$FORUM_ID" | jq . | 59 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous β†’ exit 1 | | 60 | `notes ls` | ☐ | Own, --author all, --tag, --limit | | 61 | `notes rm` | ☐ | Deleteβ†’get 404, double-delete idempotent, missing slug β†’ NotFound | +| 62 | `users set-status` | ☐ | Text+emoji, text only, emoji-only (`--text ""`), `--clear`, `--clear` + `--text` β†’ exit 1 | diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 099a92b869..f5a0bee879 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -307,27 +307,13 @@ pub async fn cmd_set_presence(client: &BuzzClient, status: &str) -> Result<(), C /// Set user status β€” sign and submit a NIP-38 kind:30315 user status event. /// /// Uses the `d:general` coordinate that the desktop client reads for the -/// profile status line. Empty text clears the status (replaceable event with -/// empty content). +/// profile status line. A blank `text` with no `emoji` clears the status. pub async fn cmd_set_status( client: &BuzzClient, text: &str, emoji: Option<&str>, ) -> Result<(), CliError> { - use nostr::{EventBuilder, Kind, Tag}; - - const KIND_USER_STATUS: u16 = 30315; - let text = text.trim(); - let mut tags = vec![Tag::parse(["d", "general"]) - .map_err(|e| CliError::Usage(format!("internal tag error: {e}")))?]; - if let Some(emoji) = emoji.map(str::trim).filter(|e| !e.is_empty()) { - tags.push( - Tag::parse(["emoji", emoji]) - .map_err(|e| CliError::Usage(format!("invalid emoji tag: {e}")))?, - ); - } - - let builder = EventBuilder::new(Kind::Custom(KIND_USER_STATUS), text).tags(tags); + let builder = buzz_sdk::build_user_status(text, emoji).map_err(crate::validate::sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -361,8 +347,15 @@ pub async fn dispatch( } UsersCmd::Presence { pubkeys } => cmd_get_presence(client, &pubkeys).await, UsersCmd::SetPresence { status } => cmd_set_presence(client, &status.to_string()).await, - UsersCmd::SetStatus { text, emoji } => { - cmd_set_status(client, &text, emoji.as_deref()).await + UsersCmd::SetStatus { text, emoji, clear } => { + // `--clear` is mutually exclusive with `--text`/`--emoji`: publish the + // empty `d:general` event that clients read as "no status". + let (text, emoji) = if clear { + ("", None) + } else { + (text.as_deref().unwrap_or_default(), emoji.as_deref()) + }; + cmd_set_status(client, text, emoji).await } } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index bc1c64989f..0b46734584 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -841,12 +841,15 @@ pub enum UsersCmd { /// Set your user status (NIP-38 kind:30315 β€” the "status" line on your profile) #[command(name = "set-status")] SetStatus { - /// Status text. Empty text clears the status. - #[arg(long)] - text: String, + /// Status text (required unless --clear) + #[arg(long, required_unless_present = "clear")] + text: Option, /// Optional emoji shown before the status text #[arg(long)] emoji: Option, + /// Remove your status entirely + #[arg(long, conflicts_with_all = ["text", "emoji"])] + clear: bool, }, } @@ -1813,6 +1816,30 @@ mod tests { Cli::command().debug_assert(); } + #[test] + fn set_status_clear_rejects_text_and_emoji() { + for extra in [["--text", "busy"], ["--emoji", "🎢"]] { + let args = ["buzz", "users", "set-status", "--clear"] + .into_iter() + .chain(extra); + assert!( + Cli::try_parse_from(args).is_err(), + "--clear must conflict with {}", + extra[0] + ); + } + } + + #[test] + fn set_status_requires_text_or_clear() { + assert!(Cli::try_parse_from(["buzz", "users", "set-status"]).is_err()); + assert!( + Cli::try_parse_from(["buzz", "users", "set-status", "--emoji", "🎢"]).is_err(), + "--emoji alone must not imply a status" + ); + assert!(Cli::try_parse_from(["buzz", "users", "set-status", "--clear"]).is_ok()); + } + #[test] fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f9e54de9c5..8cc9c8650a 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -11,8 +11,8 @@ use buzz_core::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, - KIND_WORKFLOW_TRIGGER, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1580,6 +1580,22 @@ pub fn build_presence_update(status: &str) -> Result { Ok(EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status).tags(tags)) } +/// Build a NIP-38 user status event (kind 30315) on the `d:general` coordinate. +/// +/// `text` becomes the event content and `emoji`, when non-blank, an +/// `["emoji", ...]` tag; both are trimmed. Blank text with no emoji clears the +/// status β€” kind 30315 is parameterized-replaceable, so an event carrying +/// neither is what clients read as "no status". +pub fn build_user_status(text: &str, emoji: Option<&str>) -> Result { + let text = text.trim(); + check_content(text, 64 * 1024)?; + let mut tags = vec![tag(&["d", "general"])?]; + if let Some(emoji) = emoji.map(str::trim).filter(|e| !e.is_empty()) { + tags.push(tag(&["emoji", emoji])?); + } + Ok(EventBuilder::new(Kind::Custom(KIND_USER_STATUS as u16), text).tags(tags)) +} + // --------------------------------------------------------------------------- // Community moderation commands (kinds 9040–9044). // @@ -3391,6 +3407,53 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + // ── build_user_status ───────────────────────────────────────────────────── + + #[test] + fn user_status_carries_text_and_emoji_on_d_general() { + let ev = sign(build_user_status("shipping the CLI", Some("πŸš€")).unwrap()); + assert_eq!(ev.kind.as_u16(), 30315); + assert_eq!(ev.content, "shipping the CLI"); + assert_eq!(tag_values(&ev, "d"), vec!["general"]); + assert_eq!(tag_values(&ev, "emoji"), vec!["πŸš€"]); + } + + #[test] + fn user_status_trims_text_and_emoji() { + let ev = sign(build_user_status(" heads down ", Some(" 🎧 ")).unwrap()); + assert_eq!(ev.content, "heads down"); + assert_eq!(tag_values(&ev, "emoji"), vec!["🎧"]); + } + + #[test] + fn user_status_omits_blank_emoji_tag() { + let ev = sign(build_user_status("on call", Some(" ")).unwrap()); + assert_eq!(ev.content, "on call"); + assert!(tag_values(&ev, "emoji").is_empty()); + } + + #[test] + fn user_status_keeps_emoji_when_text_is_blank() { + let ev = sign(build_user_status("", Some("🎢")).unwrap()); + assert_eq!(ev.content, ""); + assert_eq!(tag_values(&ev, "emoji"), vec!["🎢"]); + } + + #[test] + fn user_status_clear_shape_is_empty_content_and_d_tag_only() { + let ev = sign(build_user_status("", None).unwrap()); + assert_eq!(ev.kind.as_u16(), 30315); + assert_eq!(ev.content, ""); + assert_eq!(tag_values(&ev, "d"), vec!["general"]); + assert_eq!(ev.tags.len(), 1); + } + + #[test] + fn user_status_rejects_oversize_text() { + let err = build_user_status(&"x".repeat(64 * 1024 + 1), None).unwrap_err(); + assert!(matches!(err, SdkError::ContentTooLarge { .. })); + } + // ── build_git_pull_request / build_git_pr_update ────────────────────────── fn pr_repo() -> GitRepoCoord {