Skip to content
Open
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
3 changes: 3 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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 |
Expand Down
17 changes: 16 additions & 1 deletion crates/buzz-cli/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
26 changes: 26 additions & 0 deletions crates/buzz-cli/src/commands/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
}

Expand Down
47 changes: 45 additions & 2 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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![
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -2011,7 +2054,7 @@ mod tests {
("repos", 4),
("social", 7),
("upload", 1),
("users", 4),
("users", 5),
("workflows", 8),
];

Expand Down
67 changes: 65 additions & 2 deletions crates/buzz-sdk/src/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
//
Expand Down Expand Up @@ -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 {
Expand Down
Loading