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();