mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
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:
@@ -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 |
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user