mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
5457c947a7
commit
60158fce3e
@@ -57,6 +57,8 @@ buzz users get # your own profile
|
||||
buzz users get --pubkey <hex> # single user
|
||||
buzz users get --pubkey <hex> --pubkey <hex> # 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 <hex>
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
|
||||
@@ -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<EventBuilder, SdkError> {
|
||||
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<EventBuilder, SdkError> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user