From 897f847397bed8bb545f680e9e05ef3549c194d7 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 16 Jul 2026 15:37:56 +1000 Subject: [PATCH] fix(cli): report the presence subject, not the relay signer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buzz users presence misidentified who is online: the relay's /query bridge intercepts presence filters and synthesizes relay-signed kind:20001 events (bridge.rs synthesize_presence) with the subject in the p tag, but cmd_get_presence mapped event.pubkey — the relay keypair — so every row reported the relay signer's pubkey. Extract the row mapping into map_presence_events, which reports the p-tag subject when present and falls back to the event author otherwise. The fallback keeps real user-signed kind:20001 updates correct (build_presence_update emits no p tag — the author is the subject there) and guards against a malformed bare ["p"] tag blanking the pubkey. Regression tests cover all three shapes: synthesized (p-tag subject wins over the relay signer), user-signed (author reported), and malformed p tag (author fallback). Tested: cargo test -p buzz-cli --lib (149 passed, incl. the 3 new tests), cargo clippy -p buzz-cli --all-targets -D warnings, cargo fmt --check. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- crates/buzz-cli/src/commands/users.rs | 94 +++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index ae118cc4f..f941cd9be 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -261,19 +261,45 @@ pub async fn cmd_get_presence(client: &BuzzClient, pubkeys_csv: &str) -> Result< }); let resp = client.query(&filter).await?; let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); - let presence: Vec = events + let presence = map_presence_events(&events); + let output = serde_json::to_string(&presence).unwrap_or_default(); + println!("{output}"); + Ok(()) +} + +/// Map raw presence events to the CLI's `{pubkey, status, updated_at}` rows. +/// +/// Presence from the relay's `/query` bridge is synthesized: the event is +/// signed with the *relay* keypair and names the online user in a `p` tag, so +/// the subject must be read from that tag — `event.pubkey` there is the relay +/// signer, not who is online. Real user-signed kind:20001 updates carry no +/// `p` tag; for those the author is the subject. +fn map_presence_events(events: &[serde_json::Value]) -> Vec { + events .iter() .map(|e| { + let author = e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""); + let subject = e + .get("tags") + .and_then(|t| t.as_array()) + .and_then(|tags| { + tags.iter().find_map(|tag| { + let tag = tag.as_array()?; + if tag.first()?.as_str()? == "p" { + tag.get(1)?.as_str() + } else { + None + } + }) + }) + .unwrap_or(author); serde_json::json!({ - "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), + "pubkey": subject, "status": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), "updated_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), }) }) - .collect(); - let output = serde_json::to_string(&presence).unwrap_or_default(); - println!("{output}"); - Ok(()) + .collect() } /// Set presence status — sign and submit a kind:20001 presence update event via WebSocket. @@ -319,3 +345,59 @@ pub async fn dispatch( UsersCmd::SetPresence { status } => cmd_set_presence(client, &status.to_string()).await, } } + +#[cfg(test)] +mod tests { + use super::*; + + const RELAY_PK: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const SUBJECT_PK: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + #[test] + fn synthesized_presence_reports_p_tag_subject_not_relay_signer() { + // Shape produced by the relay's /query bridge (synthesize_presence): + // relay-signed, subject in the `p` tag. + let events = vec![serde_json::json!({ + "pubkey": RELAY_PK, + "kind": 20001, + "content": "online", + "created_at": 1700000000, + "tags": [["p", SUBJECT_PK]], + })]; + let rows = map_presence_events(&events); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["pubkey"], SUBJECT_PK); + assert_eq!(rows[0]["status"], "online"); + assert_eq!(rows[0]["updated_at"], 1700000000u64); + } + + #[test] + fn user_signed_presence_without_p_tag_reports_author() { + // Shape published by `buzz users set-presence` (build_presence_update): + // user-signed, no `p` tag, only a `status` tag. + let events = vec![serde_json::json!({ + "pubkey": SUBJECT_PK, + "kind": 20001, + "content": "away", + "created_at": 1700000001, + "tags": [["status", "away"]], + })]; + let rows = map_presence_events(&events); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["pubkey"], SUBJECT_PK); + assert_eq!(rows[0]["status"], "away"); + } + + #[test] + fn malformed_p_tag_falls_back_to_author() { + // A bare ["p"] tag with no value must not blank the pubkey. + let events = vec![serde_json::json!({ + "pubkey": SUBJECT_PK, + "content": "online", + "created_at": 1700000002, + "tags": [["p"]], + })]; + let rows = map_presence_events(&events); + assert_eq!(rows[0]["pubkey"], SUBJECT_PK); + } +}