diff --git a/crates/buzz-cli/src/commands/dms.rs b/crates/buzz-cli/src/commands/dms.rs index 589e411827..891833b9ae 100644 --- a/crates/buzz-cli/src/commands/dms.rs +++ b/crates/buzz-cli/src/commands/dms.rs @@ -4,44 +4,69 @@ use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::{parse_uuid, sdk_err, validate_hex64}; -/// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey. +/// Build the filter that finds the caller's DM conversations. +/// +/// DMs are discoverable through their kind:39000 channel metadata, which the +/// relay signs on creation with `t=dm`, `hidden`, and one `p` tag per +/// participant (`emit_group_discovery_events`). Filtering that kind by our own +/// pubkey therefore returns exactly the DMs we are a party to — `p` tags are +/// only attached to DM discovery, and channel-scoped storage keeps other +/// people's DMs unreadable regardless. +fn dm_discovery_filter(my_pubkey: &str, limit: u32) -> serde_json::Value { + serde_json::json!({ + "kinds": [39000], + "#p": [my_pubkey], + "limit": limit + }) +} + +/// Read every value of a single-letter tag off an event. +fn tag_values(event: &serde_json::Value, name: &str) -> Vec { + event + .get("tags") + .and_then(|t| t.as_array()) + .map(|tags| { + tags.iter() + .filter_map(|tag| { + let arr = tag.as_array()?; + if arr.first()?.as_str()? == name { + arr.get(1)?.as_str().map(str::to_string) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default() +} + +/// Project a kind:39000 discovery event into a `dms list` row. +/// +/// Returns `None` for anything that is not a DM. The filter cannot express +/// "has a `p` tag *and* is a DM" any more precisely than `#p`, so the type +/// check happens here against the `t` tag the relay always writes. +fn dm_summary(event: &serde_json::Value) -> Option { + if !tag_values(event, "t").iter().any(|t| t == "dm") { + return None; + } + let dm_id = extract_d_tag(event); + if dm_id.is_empty() { + return None; + } + Some(serde_json::json!({ + "dm_id": dm_id, + "participants": tag_values(event, "p"), + "created_at": event.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + })) +} + +/// List the DM conversations the authenticated identity is a party to. pub async fn cmd_list_dms(client: &BuzzClient, limit: Option) -> Result<(), CliError> { let my_pk = client.keys().public_key().to_hex(); let limit = limit.unwrap_or(50).min(200); - let filter = serde_json::json!({ - "kinds": [41001], - "#p": [my_pk], - "limit": limit - }); - let resp = client.query(&filter).await?; + let resp = client.query(&dm_discovery_filter(&my_pk, limit)).await?; let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); - let dms: Vec = events - .iter() - .map(|e| { - let dm_id = extract_d_tag(e); - let participants: Vec = e - .get("tags") - .and_then(|t| t.as_array()) - .map(|tags| { - tags.iter() - .filter_map(|tag| { - let arr = tag.as_array()?; - if arr.first()?.as_str()? == "p" { - arr.get(1)?.as_str().map(|s| s.to_string()) - } else { - None - } - }) - .collect() - }) - .unwrap_or_default(); - serde_json::json!({ - "dm_id": dm_id, - "participants": participants, - "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), - }) - }) - .collect(); + let dms: Vec = events.iter().filter_map(dm_summary).collect(); let output = serde_json::to_string(&dms).unwrap_or_default(); println!("{output}"); Ok(()) @@ -134,3 +159,72 @@ pub async fn dispatch(cmd: crate::DmsCmd, client: &BuzzClient) -> Result<(), Cli DmsCmd::Hide { channel } => cmd_hide_dm(client, &channel).await, } } + +#[cfg(test)] +mod tests { + use super::{dm_discovery_filter, dm_summary}; + use serde_json::json; + + const ME: &str = "4c619afde8bb468423c541af450121aec954e9adc8e8d33daea957e281272125"; + const THEM: &str = "f95f47023f13e605778566950e2ce795676930c5db06a2cb7f6e35cae73c2f84"; + const DM_ID: &str = "1da3e67e-3626-4ec1-8e13-59345cafcb93"; + + fn dm_discovery_event() -> serde_json::Value { + // Shape written by emit_group_discovery_events for a DM channel. + json!({ + "kind": 39000, + "created_at": 1_754_000_000u64, + "tags": [ + ["d", DM_ID], + ["public"], + ["hidden"], + ["p", ME], + ["p", THEM], + ["closed"], + ["t", "dm"], + ], + }) + } + + #[test] + fn the_filter_asks_for_dm_discovery_by_participant() { + // kind:41001 is never published by the relay, so the old filter could + // only ever return an empty list. + let filter = dm_discovery_filter(ME, 50); + assert_eq!(filter["kinds"], json!([39000])); + assert_eq!(filter["#p"], json!([ME])); + assert_eq!(filter["limit"], json!(50)); + } + + #[test] + fn a_dm_projects_to_its_id_and_participants() { + let row = dm_summary(&dm_discovery_event()).expect("a dm must be listed"); + assert_eq!(row["dm_id"], json!(DM_ID)); + assert_eq!(row["participants"], json!([ME, THEM])); + assert_eq!(row["created_at"], json!(1_754_000_000u64)); + } + + #[test] + fn a_non_dm_channel_is_not_listed() { + // A stream channel we are p-tagged on must not appear in dms list. + let mut event = dm_discovery_event(); + event["tags"] = json!([["d", DM_ID], ["p", ME], ["t", "stream"]]); + assert!(dm_summary(&event).is_none()); + } + + #[test] + fn an_untyped_event_is_not_listed() { + let mut event = dm_discovery_event(); + event["tags"] = json!([["d", DM_ID], ["p", ME]]); + assert!(dm_summary(&event).is_none()); + } + + #[test] + fn a_dm_with_no_d_tag_is_skipped_rather_than_listed_without_an_id() { + // An id-less row is worse than a missing one: the caller cannot act on + // it and cannot tell it apart from a real DM. + let mut event = dm_discovery_event(); + event["tags"] = json!([["p", ME], ["t", "dm"]]); + assert!(dm_summary(&event).is_none()); + } +}