feat(cli): add users set-status command for NIP-38 profile status (#3253)

## Summary

The desktop client renders a persistent user status (NIP-38 kind:30315,
`d:general`) as the status line on profiles, but the CLI had no way to
set it — only ephemeral presence (`set-presence`, kind:20001).
Integrations that want a scriptable, durable status line (for example a
now-playing music bridge that shows the current TIDAL track on a
profile) had no entry point.

## Screenshots

<img width="1455" height="960" alt="1"
src="https://github.com/user-attachments/assets/f1669ec6-212b-4f6e-ad53-07df9aacffc9"
/>
<img width="1455" height="960" alt="2"
src="https://github.com/user-attachments/assets/5bf70f47-e5b5-4eb0-a426-b5f1ef90d2ec"
/>


This adds:

```bash
buzz users set-status --text "Working on the relay" --emoji "🔧"
buzz users set-status --text "" --emoji "🎶"   # intentional emoji-only status
buzz users set-status --clear                  # removes the status
```

- Signs and submits the replaceable kind:30315 event via the HTTP bridge
(no WS needed — unlike presence, user status is a stored event).
- Uses the `d:general` coordinate the desktop client already reads for
the profile status line, and the same `emoji` tag shape
`SetStatusDialog` publishes.
- Event construction lives in `buzz_sdk::build_user_status()`, keyed off
`buzz_core::kind::KIND_USER_STATUS`, so the CLI command is a thin
sign/submit wrapper. Text and emoji are trimmed; a blank emoji is
omitted rather than emitted as an empty tag.
- Clearing is the explicit `--clear` flag, mutually exclusive with
`--text`/`--emoji`. It publishes an empty-content event carrying only
`d:general`, which the desktop treats as no status. `--text ""` with an
`--emoji` is an emoji-only status, not a clear.

---------

Signed-off-by: Kagan Yaldizkaya <kagan@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
kagan yaldizkaya
2026-07-28 19:44:57 +02:00
committed by GitHub
parent 5457c947a7
commit 60158fce3e
5 changed files with 155 additions and 5 deletions

View File

@@ -304,6 +304,22 @@ 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. A blank `text` with no `emoji` clears the status.
pub async fn cmd_set_status(
client: &BuzzClient,
text: &str,
emoji: Option<&str>,
) -> Result<(), CliError> {
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));
Ok(())
}
pub async fn dispatch(
cmd: crate::UsersCmd,
client: &BuzzClient,
@@ -331,6 +347,16 @@ 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, 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
}
}
}

View File

@@ -838,6 +838,19 @@ 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 (required unless --clear)
#[arg(long, required_unless_present = "clear")]
text: Option<String>,
/// Optional emoji shown before the status text
#[arg(long)]
emoji: Option<String>,
/// Remove your status entirely
#[arg(long, conflicts_with_all = ["text", "emoji"])]
clear: bool,
},
}
#[derive(Subcommand)]
@@ -1803,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![
@@ -1924,7 +1961,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 +2054,7 @@ mod tests {
("repos", 4),
("social", 7),
("upload", 1),
("users", 4),
("users", 5),
("workflows", 8),
];