From b0f68a06ad25889fb304a88889a679a7ef15b862 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 5 Nov 2026 17:28:24 -0600 Subject: [PATCH] feat(cli): user status expiry and status read (#8777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > Replaces #8768, which was opened from a fork. ## Summary This change adds two things to `buzz-cli`. `buzz users set-status` now accepts `--expires`. The status then clears itself at that time. `buzz users status` is a new command. It reads the visible status of one or more users. ## Design `--expires` takes a unix timestamp or a duration. A duration is a number with a unit: `s`, `m`, `h`, `d`, or `w`. A bare number is a unix timestamp. The CLI had no duration parser to reuse. The only one is private to `buzz-workflow`. A small parser now lives in `users.rs`. The CLI rejects an expiry that is not in the future. It does this before it signs anything. The error text says that a bare number is a timestamp. `--expires` cannot combine with `--clear`. A status with no text and no emoji cannot have an expiry. The SDK rejects that shape. `buzz-sdk` has a new builder, `build_user_status_expiring`. It adds `["expiration", ts]` after the emoji tag. This is the tag order desktop uses. `build_user_status` now calls the new builder with no expiry. Its output is unchanged. Existing callers need no change. `users status` queries kind 30315 with `#d: general`. It picks the newest event for each author. A higher `created_at` wins. On a tie, the lower event id wins. Desktop uses the same rule. The CLI then drops a user when the newest event has no text and no emoji. It also drops a user when the status has expired. An expired status means `expiration <= now`. An older status never shows behind a newer cleared or expired one. The expiration tag is read like desktop's `parseInt`. A value such as `90abc` reads as 90. A value such as `soon` reads as no expiry. The output is an array: `[{pubkey, text, emoji, expires_at, updated_at}]`. `emoji` is an empty string when there is none. `expires_at` is `null` when the status does not expire. A user with no visible status has no row. `--pubkey` takes hex, npub, or an exact display name. It reuses the name resolver of `messages`. It is repeatable. The limit is 100, because each name costs one profile search. With no `--pubkey`, the command reads your own status. A relay response that is not valid JSON is an error. It is never an empty result. ## Testing What ran: - `cargo test -p buzz-cli -p buzz-sdk` passes. - `cargo clippy -p buzz-cli -p buzz-sdk --all-targets -- -D warnings` passes. - `cargo fmt --all --check` passes. - `just file-size-check` passes. The CLI tests use an axum fake relay. They call the production functions `publish_status` and `read_statuses`. These are the same functions that the commands call. The tests cover these cases: - One publish carries the right tags. - Bad expiries cause no publish. - Expired, cleared, and malformed statuses. - Newest-event choice and tie-break. - Hex, npub, and name input. - The 100-pubkey limit. - An invalid relay response. - A publish followed by a read. Interop evidence: I ran the real `buzz` binary against a local fake relay. No live relay was used. I fed the published event to desktop's own `parseUserStatusEvent` and `fetchUserStatusLookup` with node. Desktop showed the same text, emoji, and `expiresAt` that `buzz users status` printed. The check printed `INTEROP OK`. Mutation checks: I broke each guard once. Each break made a test fail. The guards were: - the expiry comparison, `<=` changed to `<` - the future-expiry check - the `d=general` filter - the newest-first order - the tie-break - the 100-pubkey limit - the author de-duplication - the invalid-response error - the hide-cleared-status check - the duration overflow check - the SDK expiration tag - the SDK empty-status check I restored each guard after the check. One note on process: I wrote the code and the tests in the same pass. I did not write the tests first. The mutation checks cover the gap. ## Manual test steps Use a test identity on a relay that you may write to. Set `BUZZ_RELAY_URL` and `BUZZ_PRIVATE_KEY` first. 1. Set a status that expires in two minutes. ```bash buzz users set-status --text "testing expiry" --emoji "๐Ÿงช" --expires 2m ``` Success: the output has `"accepted":true`. 2. Read the status. ```bash buzz users status ``` Success: the output is one row. `text` is `testing expiry`. `emoji` is `๐Ÿงช`. `expires_at` is about two minutes from now. 3. Open the desktop app as the same user. Success: your status shows on your profile. 4. Wait for the expiry. Read again. ```bash buzz users status ``` Success: the output is `[]`. Desktop also hides the status. 5. Try a bad expiry. ```bash buzz users set-status --text x --expires 3600 ``` Success: exit code 1. The error says that the value must be in the future. Nothing is published. 6. Try the clear conflict. ```bash buzz users set-status --clear --expires 1h ``` Success: exit code 1. The error says `--clear` cannot be used with `--expires`. 7. Read another user. ```bash buzz users status --pubkey ``` Success: you get that user's visible status. You get `[]` if they have none. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: bmcanally Co-authored-by: Claude Opus 5.5 --- crates/buzz-cli/README.md | 6 +- crates/buzz-cli/src/commands/messages.rs | 2 +- crates/buzz-cli/src/commands/users.rs | 592 ++++++++++++++++++++++- crates/buzz-cli/src/lib.rs | 17 +- crates/buzz-sdk/src/builders.rs | 48 +- 5 files changed, 649 insertions(+), 16 deletions(-) diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 104e566c98..7aa760aebc 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -79,7 +79,10 @@ buzz users get --pubkey --pubkey # batch (max 200) buzz users get --name Honey --owner me # exact-name lookup in your managed agents buzz users set-presence --status online buzz users set-status --text "heads down on the CLI" --emoji "๐Ÿš€" +buzz users set-status --text "lunch" --emoji "๐Ÿฅช" --expires 1h # clears itself; also a unix time buzz users set-status --clear # remove your status +buzz users status # your visible status: [{pubkey, text, emoji, expires_at, updated_at}] +buzz users status --pubkey # repeatable, max 100; expired or cleared users are omitted # DMs buzz dms open --pubkey @@ -168,7 +171,8 @@ The table below mirrors that tree for readers who are not at a terminal. | | `set-profile` | Update your profile | | | `presence` | Get presence status | | | `set-presence` | Set presence status | -| | `set-status` | Set or clear your NIP-38 profile status | +| | `set-status` | Set or clear your NIP-38 profile status; `--expires` takes a unix time or `30m`/`2h`/`1d`/`1w` | +| | `status` | Read NIP-38 statuses (default: yours); expired or cleared are omitted | | `workflows` | `list` | List workflows | | | `get` | Get workflow definition | | | `create` | Create a workflow | diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 6babbcc61f..65dfdbe3ca 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -492,7 +492,7 @@ pub async fn cmd_search( /// must match exactly one user (case-insensitive, on `display_name` or /// `name`) โ€” ambiguity is an error listing the candidates rather than a /// silent mix of authors. -async fn resolve_author(client: &BuzzClient, author: &str) -> Result { +pub(crate) async fn resolve_author(client: &BuzzClient, author: &str) -> Result { let author = author.trim(); if author.len() == 64 && author.chars().all(|c| c.is_ascii_hexdigit()) { return Ok(author.to_ascii_lowercase()); diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 165de6185a..997cec4912 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -517,19 +517,210 @@ 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. +/// Seconds in each `--expires` duration unit. +fn expiry_unit_secs(unit: char) -> Option { + match unit { + 's' => Some(1), + 'm' => Some(60), + 'h' => Some(3_600), + 'd' => Some(86_400), + 'w' => Some(604_800), + _ => None, + } +} + +/// Resolve `--expires` to an absolute unix time that is after `now`. +/// +/// A bare integer is a unix timestamp; an integer with an `s`, `m`, `h`, `d`, +/// or `w` suffix is a duration from `now`. +fn parse_expiry(value: &str, now: u64) -> Result { + let value = value.trim(); + let bad = || { + CliError::Usage(format!( + "--expires must be a unix timestamp or a duration like 30m, 2h, 1d (got: {value})" + )) + }; + let split = value + .char_indices() + .find(|(_, c)| !c.is_ascii_digit()) + .map_or(value.len(), |(i, _)| i); + let (digits, unit) = value.split_at(split); + let number: u64 = digits.parse().map_err(|_| bad())?; + let expires_at = match unit { + "" => number, + _ => { + let mut chars = unit.chars(); + let secs = chars.next().and_then(expiry_unit_secs).ok_or_else(bad)?; + if chars.next().is_some() { + return Err(bad()); + } + number + .checked_mul(secs) + .and_then(|delta| now.checked_add(delta)) + .ok_or_else(bad)? + } + }; + if expires_at <= now { + return Err(CliError::Usage(format!( + "--expires must be in the future (got {expires_at}, now is {now}); a bare number is a unix timestamp, so add a unit such as 1h for a duration" + ))); + } + Ok(expires_at) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// 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. +/// `expires` is validated before anything is signed. +async fn publish_status( + client: &BuzzClient, + text: &str, + emoji: Option<&str>, + expires: Option<&str>, + now: u64, +) -> Result { + let expires_at = expires.map(|value| parse_expiry(value, now)).transpose()?; + let builder = buzz_sdk::build_user_status_expiring(text, emoji, expires_at) + .map_err(crate::validate::sdk_err)?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + Ok(normalize_write_response(&resp)) +} + +/// Set user status and print the write response. pub async fn cmd_set_status( client: &BuzzClient, text: &str, emoji: Option<&str>, + expires: 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)); + println!( + "{}", + publish_status(client, text, emoji, expires, now_secs()).await? + ); + Ok(()) +} + +/// Most pubkeys one `users status` call reads; each name costs a profile search. +const MAX_STATUS_PUBKEYS: usize = 100; + +/// Desktop's `parseUserStatusEvent` reads `expiration` with `parseInt`: an +/// optional sign and leading digits, ignoring any trailing text. +fn parse_expiration_tag(value: &str) -> Option { + let value = value.trim_start(); + let unsigned = value.strip_prefix(['+', '-']).unwrap_or(value); + let digits = unsigned + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(unsigned.len()); + let end = value.len() - unsigned.len() + digits; + value[..end].parse().ok() +} + +fn tag_value<'a>(event: &'a serde_json::Value, name: &str) -> Option<&'a str> { + event + .get("tags")? + .as_array()? + .iter() + .filter_map(|tag| tag.as_array()) + .find(|tag| tag.len() >= 2 && tag[0].as_str() == Some(name)) + .and_then(|tag| tag[1].as_str()) +} + +/// The visible status per requested author, in request order. The newest +/// event per author wins, as on desktop (higher `created_at`, then lower id); +/// a newest event that is empty or expired hides the author, and an older +/// status never resurfaces behind it. +fn visible_statuses( + events: &[serde_json::Value], + authors: &[String], + now: u64, +) -> Vec { + fn key(event: &serde_json::Value) -> (u64, &str) { + ( + event + .get("created_at") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + event.get("id").and_then(|v| v.as_str()).unwrap_or(""), + ) + } + authors + .iter() + .filter_map(|author| { + let event = events + .iter() + .filter(|e| { + e.get("pubkey").and_then(|v| v.as_str()) == Some(author.as_str()) + && tag_value(e, "d") == Some("general") + }) + .min_by(|a, b| { + let (a_at, a_id) = key(a); + let (b_at, b_id) = key(b); + b_at.cmp(&a_at).then_with(|| a_id.cmp(b_id)) + })?; + let text = event.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let emoji = tag_value(event, "emoji").unwrap_or(""); + let expires_at = tag_value(event, "expiration").and_then(parse_expiration_tag); + let expired = expires_at.is_some_and(|at| at <= now as i64); + if (text.is_empty() && emoji.is_empty()) || expired { + return None; + } + Some(serde_json::json!({ + "pubkey": author, + "text": text, + "emoji": emoji, + "expires_at": expires_at, + "updated_at": key(event).0, + })) + }) + .collect() +} + +/// Read the visible `d:general` statuses for `pubkeys` (default: you). +async fn read_statuses( + client: &BuzzClient, + pubkeys: &[String], + now: u64, +) -> Result, CliError> { + if pubkeys.len() > MAX_STATUS_PUBKEYS { + return Err(CliError::Usage(format!( + "--pubkey: maximum {MAX_STATUS_PUBKEYS} pubkeys" + ))); + } + let mut authors: Vec = Vec::new(); + for pubkey in pubkeys { + let resolved = crate::commands::messages::resolve_author(client, pubkey).await?; + if !authors.contains(&resolved) { + authors.push(resolved); + } + } + if authors.is_empty() { + authors.push(client.keys().public_key().to_hex()); + } + let filter = serde_json::json!({ + "kinds": [buzz_core::kind::KIND_USER_STATUS], + "authors": authors, + "#d": ["general"], + "limit": authors.len(), + }); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse status query: {e}")))?; + Ok(visible_statuses(&events, &authors, now)) +} + +/// Print the visible statuses for `pubkeys` as a JSON array. +pub async fn cmd_get_status(client: &BuzzClient, pubkeys: &[String]) -> Result<(), CliError> { + let statuses = read_statuses(client, pubkeys, now_secs()).await?; + println!("{}", serde_json::Value::Array(statuses)); Ok(()) } @@ -562,7 +753,12 @@ 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 } => { + UsersCmd::SetStatus { + text, + emoji, + clear, + expires, + } => { // `--clear` is mutually exclusive with `--text`/`--emoji`: publish the // empty `d:general` event that clients read as "no status". let (text, emoji) = if clear { @@ -570,8 +766,9 @@ pub async fn dispatch( } else { (text.as_deref().unwrap_or_default(), emoji.as_deref()) }; - cmd_set_status(client, text, emoji).await + cmd_set_status(client, text, emoji, expires.as_deref()).await } + UsersCmd::Status { pubkeys } => cmd_get_status(client, &pubkeys).await, } } @@ -579,10 +776,385 @@ pub async fn dispatch( mod tests { use super::{ owned_agent_pubkeys_from_events, owner_scoped_profiles, owner_verification, - presence_subject, + parse_expiration_tag, parse_expiry, presence_subject, publish_status, read_statuses, }; - use nostr::Keys; - use serde_json::json; + use crate::client::BuzzClient; + use crate::error::CliError; + use axum::{body::Bytes, extract::State, routing::post, Json, Router}; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + use serde_json::{json, Value}; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + const NOW: u64 = 1_800_000_000; + + #[derive(Default)] + struct Relay { + events: Vec, + queries: Vec, + raw_query_response: Option, + } + type Shared = Arc>; + + /// Fake relay: `/query` records the filter and returns stored events of the + /// requested kind and authors (kind 0 also honors `search`); `/events` + /// stores one. + async fn fake_relay(relay: Shared) -> String { + let app = Router::new() + .route( + "/query", + post(|State(relay): State, body: Bytes| async move { + let filters: Vec = serde_json::from_slice(&body).unwrap(); + let [filter] = filters.as_slice() else { + panic!("one filter expected: {filters:?}") + }; + let mut relay = relay.lock().unwrap(); + relay.queries.push(filter.clone()); + if let Some(raw) = relay.raw_query_response.clone() { + return raw; + } + let kind = &filter["kinds"][0]; + let matching: Vec = relay + .events + .iter() + .filter(|e| &e["kind"] == kind) + .filter(|e| match filter.get("authors") { + Some(authors) => authors.as_array().unwrap().contains(&e["pubkey"]), + None => true, + }) + .filter(|e| match filter.get("search").and_then(|s| s.as_str()) { + Some(q) => e["content"] + .as_str() + .unwrap() + .to_lowercase() + .contains(&q.to_lowercase()), + None => true, + }) + .cloned() + .collect(); + serde_json::to_string(&matching).unwrap() + }), + ) + .route( + "/events", + post(|State(relay): State, body: Bytes| async move { + let event: Value = serde_json::from_slice(&body).unwrap(); + relay.lock().unwrap().events.push(event.clone()); + Json(json!({"event_id": event["id"], "accepted": true, "message": ""})) + }), + ) + .with_state(relay); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") + } + + async fn setup() -> (BuzzClient, Shared) { + let relay = Shared::default(); + let url = fake_relay(relay.clone()).await; + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + (client, relay) + } + + fn seed(relay: &Shared, keys: &Keys, kind: u16, content: &str, tags: Vec>, at: u64) { + let tags: Vec = tags.into_iter().map(|t| Tag::parse(t).unwrap()).collect(); + let event = EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .custom_created_at(Timestamp::from(at)) + .sign_with_keys(keys) + .unwrap(); + relay + .lock() + .unwrap() + .events + .push(serde_json::to_value(event).unwrap()); + } + + fn seed_status(relay: &Shared, keys: &Keys, text: &str, extra: Vec>, at: u64) { + let mut tags = vec![vec!["d", "general"]]; + tags.extend(extra); + seed(relay, keys, 30315, text, tags, at); + } + + fn event_count(relay: &Shared) -> usize { + relay.lock().unwrap().events.len() + } + + fn last_tags(relay: &Shared) -> Vec> { + let event = relay.lock().unwrap().events.last().cloned().unwrap(); + serde_json::from_value(event["tags"].clone()).unwrap() + } + + #[tokio::test] + async fn set_status_publishes_one_expiring_event_and_rejects_bad_expiry_before_signing() { + let (client, relay) = setup().await; + let resp = publish_status(&client, "lunch", Some("๐Ÿฅช"), Some("30m"), NOW) + .await + .unwrap(); + assert_eq!( + serde_json::from_str::(&resp).unwrap()["accepted"], + true + ); + assert_eq!(event_count(&relay), 1); + assert_eq!( + last_tags(&relay), + vec![ + vec!["d", "general"], + vec!["emoji", "๐Ÿฅช"], + vec!["expiration", &(NOW + 1_800).to_string()], + ] + ); + + publish_status(&client, "later", None, Some(&(NOW + 5).to_string()), NOW) + .await + .unwrap(); + assert_eq!( + last_tags(&relay).last().unwrap(), + &vec!["expiration".to_string(), (NOW + 5).to_string()] + ); + + publish_status(&client, "no expiry", None, None, NOW) + .await + .unwrap(); + assert_eq!(last_tags(&relay), vec![vec!["d", "general"]]); + + let published = event_count(&relay); + for expires in [ + &NOW.to_string(), + "0", + "1700000000", + "0m", + "soon", + "", + "1x", + "1hh", + "-5m", + "99999999999999999999d", + "999999999999999d", + ] { + let err = publish_status(&client, "x", None, Some(expires), NOW) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{expires}: {err:?}"); + } + let err = publish_status(&client, "", None, Some("1h"), NOW) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{err:?}"); + assert_eq!(event_count(&relay), published); + } + + #[test] + fn expiry_parses_timestamps_and_durations() { + for (input, expected) in [ + ("30s", NOW + 30), + ("30m", NOW + 1_800), + ("2h", NOW + 7_200), + ("1d", NOW + 86_400), + (" 1w ", NOW + 604_800), + ("1800000001", 1_800_000_001), + ] { + assert_eq!(parse_expiry(input, NOW).unwrap(), expected, "{input}"); + } + } + + #[test] + fn expiration_tag_reads_like_desktop_parse_int() { + for (input, expected) in [ + ("1800000060", Some(1_800_000_060)), + (" 42", Some(42)), + ("+42", Some(42)), + ("-5", Some(-5)), + ("90abc", Some(90)), + ("abc", None), + ("", None), + ("-", None), + ] { + assert_eq!(parse_expiration_tag(input), expected, "{input:?}"); + } + } + + #[tokio::test] + async fn status_read_hides_expired_and_cleared_and_reports_the_rest() { + let (client, relay) = setup().await; + let me = client.keys().clone(); + let (expired, cleared, emoji_only, odd, timed) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + let silent = Keys::generate(); + seed_status(&relay, &me, "writing docs", vec![vec!["emoji", "๐Ÿ“"]], 100); + seed_status( + &relay, + &expired, + "gone", + vec![vec!["expiration", &NOW.to_string()]], + 100, + ); + seed_status(&relay, &cleared, "old text", vec![], 100); + seed_status(&relay, &cleared, "", vec![], 200); + seed_status(&relay, &emoji_only, "", vec![vec!["emoji", "๐ŸŽง"]], 100); + seed_status(&relay, &odd, "odd", vec![vec!["expiration", "soon"]], 100); + seed_status( + &relay, + &timed, + "back soon", + vec![vec!["expiration", &format!("{}xyz", NOW + 60)]], + 100, + ); + // Wrong coordinate and wrong kind are not statuses. + seed( + &relay, + &silent, + 30315, + "music", + vec![vec!["d", "music"]], + 100, + ); + seed(&relay, &silent, 1, "note", vec![vec!["d", "general"]], 100); + + let pubkeys: Vec = [&expired, &cleared, &emoji_only, &odd, &timed, &silent] + .iter() + .map(|k| k.public_key().to_hex()) + .collect(); + let statuses = read_statuses(&client, &pubkeys, NOW).await.unwrap(); + assert_eq!( + statuses, + vec![ + json!({"pubkey": pubkeys[2], "text": "", "emoji": "๐ŸŽง", "expires_at": null, "updated_at": 100}), + json!({"pubkey": pubkeys[3], "text": "odd", "emoji": "", "expires_at": null, "updated_at": 100}), + json!({"pubkey": pubkeys[4], "text": "back soon", "emoji": "", "expires_at": NOW + 60, "updated_at": 100}), + ] + ); + + let filter = relay.lock().unwrap().queries.last().cloned().unwrap(); + assert_eq!(filter["kinds"], json!([30315])); + assert_eq!(filter["#d"], json!(["general"])); + assert_eq!(filter["limit"], json!(6)); + + // Default author is the caller. + let own = read_statuses(&client, &[], NOW).await.unwrap(); + assert_eq!( + own, + vec![json!({ + "pubkey": me.public_key().to_hex(), + "text": "writing docs", + "emoji": "๐Ÿ“", + "expires_at": null, + "updated_at": 100, + })] + ); + // The same status is hidden once its expiry passes. + seed_status( + &relay, + &me, + "meeting", + vec![vec!["expiration", &(NOW + 10).to_string()]], + 300, + ); + let before = read_statuses(&client, &[], NOW).await.unwrap(); + assert_eq!(before[0]["text"], "meeting"); + assert_eq!(before[0]["expires_at"], json!(NOW + 10)); + assert!(read_statuses(&client, &[], NOW + 10) + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn status_read_picks_the_newest_event_and_breaks_ties_by_lower_id() { + let (client, relay) = setup().await; + let keys = Keys::generate(); + seed_status(&relay, &keys, "older", vec![], 100); + seed_status(&relay, &keys, "tie a", vec![], 200); + seed_status(&relay, &keys, "tie b", vec![], 200); + let events = relay.lock().unwrap().events.clone(); + let winner = events[1..] + .iter() + .min_by_key(|e| e["id"].as_str().unwrap().to_string()) + .unwrap()["content"] + .clone(); + let statuses = read_statuses(&client, &[keys.public_key().to_hex()], NOW) + .await + .unwrap(); + assert_eq!(statuses.len(), 1); + assert_eq!(statuses[0]["text"], winner); + assert_eq!(statuses[0]["updated_at"], 200); + } + + #[tokio::test] + async fn status_read_resolves_npub_and_name_and_bounds_input() { + let (client, relay) = setup().await; + let by_npub = Keys::generate(); + let by_name = Keys::generate(); + seed_status(&relay, &by_npub, "via npub", vec![], 100); + seed_status(&relay, &by_name, "via name", vec![], 100); + seed( + &relay, + &by_name, + 0, + r#"{"display_name":"Honey"}"#, + vec![], + 50, + ); + let npub = nostr::ToBech32::to_bech32(&by_npub.public_key()).unwrap(); + let statuses = read_statuses( + &client, + &[ + npub, + "honey".to_string(), + by_name.public_key().to_hex().to_uppercase(), + ], + NOW, + ) + .await + .unwrap(); + let texts: Vec<&str> = statuses + .iter() + .map(|s| s["text"].as_str().unwrap()) + .collect(); + assert_eq!(texts, vec!["via npub", "via name"]); + + let err = read_statuses(&client, &["nobody".to_string()], NOW) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{err:?}"); + + let queries = relay.lock().unwrap().queries.len(); + let too_many: Vec = (0..101).map(|_| by_npub.public_key().to_hex()).collect(); + let err = read_statuses(&client, &too_many, NOW).await.unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{err:?}"); + assert_eq!(relay.lock().unwrap().queries.len(), queries); + } + + #[tokio::test] + async fn status_read_fails_on_an_unreadable_relay_response() { + let (client, relay) = setup().await; + relay.lock().unwrap().raw_query_response = Some("not json".into()); + let err = read_statuses(&client, &[], NOW).await.unwrap_err(); + assert!(matches!(err, CliError::Other(_)), "{err:?}"); + } + + #[tokio::test] + async fn a_published_status_reads_back_with_its_expiry() { + let (client, _relay) = setup().await; + publish_status(&client, "lunch", Some("๐Ÿฅช"), Some("1h"), NOW) + .await + .unwrap(); + let statuses = read_statuses(&client, &[], NOW + 60).await.unwrap(); + assert_eq!(statuses.len(), 1); + assert_eq!(statuses[0]["text"], "lunch"); + assert_eq!(statuses[0]["emoji"], "๐Ÿฅช"); + assert_eq!(statuses[0]["expires_at"], json!(NOW + 3_600)); + assert!(read_statuses(&client, &[], NOW + 3_600) + .await + .unwrap() + .is_empty()); + } #[test] fn owned_agent_lookup_matches_exact_name_case_insensitively() { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 663bc5cea3..ceea5af059 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1094,8 +1094,18 @@ pub enum UsersCmd { #[arg(long)] emoji: Option, /// Remove your status entirely - #[arg(long, conflicts_with_all = ["text", "emoji"])] + #[arg(long, conflicts_with_all = ["text", "emoji", "expires"])] clear: bool, + /// Clear the status automatically: a future unix timestamp or a + /// duration such as 30m, 2h, 1d, 1w + #[arg(long)] + expires: Option, + }, + /// Read user statuses (NIP-38 kind:30315); expired or cleared statuses are omitted + Status { + /// Pubkey (hex or npub) or exact display name (repeatable, max 100; default: you) + #[arg(long = "pubkey")] + pubkeys: Vec, }, } @@ -2681,7 +2691,8 @@ mod tests { "presence", "set-presence", "set-profile", - "set-status" + "set-status", + "status" ] ); assert_eq!( @@ -2785,7 +2796,7 @@ mod tests { ("repos", 6), ("social", 7), ("upload", 1), - ("users", 5), + ("users", 6), ("workflows", 8), ]; diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 3d0d1072d5..9970e2f000 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -2001,12 +2001,33 @@ pub fn build_presence_update(status: &str) -> Result { /// 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 { + build_user_status_expiring(text, emoji, None) +} + +/// Like [`build_user_status`], with an optional NIP-40 `["expiration", unix_secs]` +/// tag after the emoji tag. Clients read the status as cleared once that time +/// passes. An expiry on a status with neither text nor emoji is rejected, +/// because that shape is the clear. +pub fn build_user_status_expiring( + text: &str, + emoji: Option<&str>, + expires_at: Option, +) -> Result { let text = text.trim(); check_content(text, 64 * 1024)?; + let emoji = emoji.map(str::trim).filter(|e| !e.is_empty()); + if expires_at.is_some() && text.is_empty() && emoji.is_none() { + return Err(SdkError::InvalidInput( + "an expiring status needs text or an emoji".into(), + )); + } let mut tags = vec![tag(&["d", "general"])?]; - if let Some(emoji) = emoji.map(str::trim).filter(|e| !e.is_empty()) { + if let Some(emoji) = emoji { tags.push(tag(&["emoji", emoji])?); } + if let Some(expires_at) = expires_at { + tags.push(tag(&["expiration", &expires_at.to_string()])?); + } Ok(EventBuilder::new(Kind::Custom(KIND_USER_STATUS as u16), text).tags(tags)) } @@ -5131,6 +5152,31 @@ mod tests { assert_eq!(ev.tags.len(), 1); } + #[test] + fn expiring_user_status_adds_expiration_after_emoji() { + let ev = + sign(build_user_status_expiring("lunch", Some("๐Ÿฅช"), Some(1_900_000_000)).unwrap()); + assert_eq!(ev.content, "lunch"); + assert_eq!(tag_values(&ev, "expiration"), vec!["1900000000"]); + let names: Vec<&str> = ev.tags.iter().map(|t| t.as_slice()[0].as_str()).collect(); + assert_eq!(names, vec!["d", "emoji", "expiration"]); + } + + #[test] + fn expiring_user_status_without_expiry_matches_build_user_status() { + let plain = sign(build_user_status("same", Some("๐Ÿš€")).unwrap()); + let none = sign(build_user_status_expiring("same", Some("๐Ÿš€"), None).unwrap()); + assert_eq!(plain.tags, none.tags); + assert!(tag_values(&plain, "expiration").is_empty()); + } + + #[test] + fn expiring_user_status_requires_text_or_emoji() { + let err = build_user_status_expiring(" ", Some(" "), Some(1_900_000_000)).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!(build_user_status_expiring("", Some("๐ŸŽถ"), Some(1_900_000_000)).is_ok()); + } + #[test] fn user_status_rejects_oversize_text() { let err = build_user_status(&"x".repeat(64 * 1024 + 1), None).unwrap_err();