Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 128 additions & 34 deletions crates/buzz-cli/src/commands/dms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<serde_json::Value> {
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<u32>) -> 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::Value> = serde_json::from_str(&resp).unwrap_or_default();
let dms: Vec<serde_json::Value> = events
.iter()
.map(|e| {
let dm_id = extract_d_tag(e);
let participants: Vec<String> = 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<serde_json::Value> = events.iter().filter_map(dm_summary).collect();
let output = serde_json::to_string(&dms).unwrap_or_default();
println!("{output}");
Ok(())
Expand Down Expand Up @@ -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());
}
}