feat(cli): user status expiry and status read (#8777)

> 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 <hex|npub|name>
   ```

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 <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Blake McAnally
2026-11-05 17:28:24 -06:00
committed by GitHub
parent 303157f53d
commit b0f68a06ad
5 changed files with 649 additions and 16 deletions

View File

@@ -79,7 +79,10 @@ buzz users get --pubkey <hex> --pubkey <hex> # 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 <hex|npub|name> # repeatable, max 100; expired or cleared users are omitted
# DMs
buzz dms open --pubkey <hex>
@@ -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 |

View File

@@ -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<String, CliError> {
pub(crate) async fn resolve_author(client: &BuzzClient, author: &str) -> Result<String, CliError> {
let author = author.trim();
if author.len() == 64 && author.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(author.to_ascii_lowercase());

View File

@@ -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<u64> {
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<u64, CliError> {
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<String, CliError> {
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<i64> {
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<serde_json::Value> {
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<Vec<serde_json::Value>, CliError> {
if pubkeys.len() > MAX_STATUS_PUBKEYS {
return Err(CliError::Usage(format!(
"--pubkey: maximum {MAX_STATUS_PUBKEYS} pubkeys"
)));
}
let mut authors: Vec<String> = 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::Value> = 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<Value>,
queries: Vec<Value>,
raw_query_response: Option<String>,
}
type Shared = Arc<Mutex<Relay>>;
/// 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<Shared>, body: Bytes| async move {
let filters: Vec<Value> = 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<Value> = 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<Shared>, 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<Vec<&str>>, at: u64) {
let tags: Vec<Tag> = 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<Vec<&str>>, 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<Vec<String>> {
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::<Value>(&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<String> = [&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<String> = (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() {

View File

@@ -1094,8 +1094,18 @@ pub enum UsersCmd {
#[arg(long)]
emoji: Option<String>,
/// 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<String>,
},
/// 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<String>,
},
}
@@ -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),
];

View File

@@ -2001,12 +2001,33 @@ pub fn build_presence_update(status: &str) -> Result<EventBuilder, SdkError> {
/// 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> {
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<u64>,
) -> Result<EventBuilder, SdkError> {
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();