fix(cli): list DMs, attach any file, validate kinds, default the relay (#9168)

Fixes five defects in the `buzz` CLI.

- **`dms list` returned `[]`.** It queried kind 41001, which the relay
no longer publishes. It now reads the viewer's DM group metadata (kind
39000, `t=dm`, `p=viewer`) and drops every DM listed in the viewer's
newest relay-signed visibility snapshot (kind 30622), the same hidden
set the desktop uses. It keeps paging until it has the requested number
of visible DMs or the history runs out, so hidden DMs never crowd out
visible ones. Hidden DMs stay hidden; reopening one brings it back.
- **Uploads accepted only images and MP4.** The CLI now leaves type
acceptance to the relay, as the desktop does: unsniffable files go up as
`application/octet-stream`, and the relay's own allow and deny rules
apply. Local size caps match the relay's defaults (50 MB images, 500 MB
MP4, 100 MB other files). `messages send --file` adds an imeta
`filename` (the basename, with control characters and both path
separators replaced by spaces) and writes non-media files as a plain
link (`[name](<url>)`) instead of `![image](…)`; inline media still
embeds.
- **`messages get --kinds` silently ignored bad values.** Any
non-numeric or out-of-range (above 65535) kind now fails with a
bad-input error, exit 1.
- **Upload errors lost their category.** A missing or unreadable file is
bad input (exit 1), and `messages send --file` returns the upload's own
error instead of wrapping every failure as `Other` (exit 4). The "auth
tag may be stale" hint on every 403 is gone: the relay answers 403 for
ordinary permission denials, such as a non-moderator reading moderation
data, so the hint misled.
- **Agents had to pass the relay by hand.** On macOS and Linux, with no
`--relay` and no `BUZZ_RELAY_URL`, the CLI uses the relay an agent's
inherited `BUZZ_CREDENTIAL_PROVIDER` token acts on, from its `describe`
answer. If that lists several relays, it fails with exit 1 and asks for
`--relay`. An explicit flag or env var still wins and is still checked
against the credential. Paired tokens never choose a relay, `auth
desktop unpair` never asks the desktop, and otherwise the default stays
`http://localhost:3000`. Help text says so.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: wpfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-11-06 11:20:47 -05:00
committed by GitHub
parent 962b3fdd88
commit b329e05c5b
5 changed files with 546 additions and 96 deletions

View File

@@ -4,47 +4,71 @@ 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.
/// Tag values named `name` on a relay event.
fn tag_values<'a>(event: &'a serde_json::Value, name: &'a str) -> impl Iterator<Item = &'a str> {
event
.get("tags")
.and_then(|t| t.as_array())
.into_iter()
.flatten()
.filter_map(move |tag| {
let arr = tag.as_array()?;
(arr.first()?.as_str()? == name)
.then(|| arr.get(1)?.as_str())
.flatten()
})
}
/// List visible DM conversations: the viewer's DM group metadata (kind:39000
/// `t=dm`, one `p` per participant), minus the DMs in the viewer's newest
/// relay-signed visibility snapshot (kind:30622, one `h` per hidden DM).
pub async fn cmd_list_dms(client: &BuzzClient, limit: Option<u32>) -> Result<(), CliError> {
let dms = list_dms(client, limit.unwrap_or(50).min(200)).await?;
println!("{}", serde_json::Value::Array(dms));
Ok(())
}
async fn list_dms(client: &BuzzClient, limit: u32) -> Result<Vec<serde_json::Value>, CliError> {
let my_pk = client.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 events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let dms: Vec<serde_json::Value> = events
let snapshots = client
.query_paginated(
serde_json::json!({
"kinds": [buzz_sdk::kind::KIND_DM_VISIBILITY],
"#p": [my_pk],
}),
10,
)
.await?;
let hidden: std::collections::HashSet<String> = snapshots
.iter()
.max_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0))
.map(|e| tag_values(e, "h").map(str::to_owned).collect())
.unwrap_or_default();
let events = client
.query_paginated_keeping(
serde_json::json!({
"kinds": [39000],
"#t": ["dm"],
"#p": [my_pk],
}),
limit,
|e| {
let dm_id = extract_d_tag(e);
!dm_id.is_empty() && !hidden.contains(&dm_id)
},
)
.await?;
Ok(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();
let participants: Vec<&str> = tag_values(e, "p").collect();
serde_json::json!({
"dm_id": dm_id,
"dm_id": extract_d_tag(e),
"participants": participants,
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
})
.collect();
let output = serde_json::to_string(&dms).unwrap_or_default();
println!("{output}");
Ok(())
.collect())
}
/// Open a DM with one or more users — sign and submit a kind:41010 event with a d-tag.
@@ -134,3 +158,212 @@ 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::*;
use std::sync::{Arc, Mutex};
/// A relay holding `events` (newest first) that pages `/query` by its
/// composite cursor, logging each filter it answers.
async fn relay(events: Vec<serde_json::Value>) -> (String, Arc<Mutex<Vec<serde_json::Value>>>) {
use axum::{routing::post, Json, Router};
let log = Arc::new(Mutex::new(Vec::new()));
let seen = log.clone();
let app = Router::new().route(
"/query",
post(move |Json(filters): Json<Vec<serde_json::Value>>| {
let events = events.clone();
let seen = seen.clone();
async move {
let f = filters[0].clone();
seen.lock().unwrap().push(f.clone());
let kind = f["kinds"][0].as_u64();
let limit = f["limit"].as_u64().unwrap_or(500) as usize;
let after_cursor = |e: &&serde_json::Value| match f["before_id"].as_str() {
None => true,
Some(id) => {
let at = f["until"].as_u64().unwrap();
let t = e["created_at"].as_u64().unwrap();
t < at || (t == at && e["id"].as_str().unwrap() > id)
}
};
let page: Vec<_> = events
.iter()
.filter(|e| e["kind"].as_u64() == kind)
.filter(after_cursor)
.take(limit)
.cloned()
.collect();
Json(page)
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(base, log)
}
fn dm(n: u64) -> serde_json::Value {
serde_json::json!({
"id": format!("{n:064x}"),
"kind": 39000,
"created_at": 1_000 + n,
"tags": [["d", format!("dm-{n}")], ["t", "dm"]],
})
}
#[tokio::test]
async fn hidden_dms_do_not_crowd_out_older_visible_ones() {
// 200 newer hidden DMs, then one older visible DM.
let mut events: Vec<_> = (1..=200).rev().map(dm).collect();
events.push(dm(0));
let hidden: Vec<_> = (1..=200)
.map(|n| serde_json::json!(["h", format!("dm-{n}")]))
.collect();
events.push(serde_json::json!({
"id": "f".repeat(64), "kind": 30622, "created_at": 5_000, "tags": hidden,
}));
let (base, _) = relay(events).await;
let client = BuzzClient::new(
base,
crate::credentials::Credentials::from_keys(nostr::Keys::generate()),
)
.unwrap();
let dms = list_dms(&client, 200).await.unwrap();
assert_eq!(dms.len(), 1, "{dms:?}");
assert_eq!(dms[0]["dm_id"], "dm-0");
}
#[tokio::test]
async fn a_small_limit_still_reads_full_pages_past_hidden_dms() {
// 150 newer hidden DMs, then one older visible DM, with limit 1.
let mut events: Vec<_> = (1..=150).rev().map(dm).collect();
events.push(dm(0));
let hidden: Vec<_> = (1..=150)
.map(|n| serde_json::json!(["h", format!("dm-{n}")]))
.collect();
events.push(serde_json::json!({
"id": "f".repeat(64), "kind": 30622, "created_at": 5_000, "tags": hidden,
}));
let (base, log) = relay(events).await;
let dms = list_dms(&client(base), 1).await.unwrap();
assert_eq!(dms.len(), 1, "{dms:?}");
assert_eq!(dms[0]["dm_id"], "dm-0");
let log = log.lock().unwrap();
let dm_pages: Vec<_> = log.iter().filter(|f| f["kinds"][0] == 39000).collect();
assert_eq!(dm_pages.len(), 1, "{dm_pages:?}");
assert_eq!(dm_pages[0]["limit"], crate::client::QUERY_PAGE_SIZE);
}
#[tokio::test]
async fn pages_past_a_hidden_dm_with_the_same_timestamp() {
// Equal timestamps come back in ascending id order.
let same = |n: u64| {
let mut e = dm(n);
e["created_at"] = serde_json::json!(1_000);
e
};
// A full page of hidden DMs, then a visible one with the same
// timestamp and a larger id, so the cursor must stay on that second.
let page = crate::client::QUERY_PAGE_SIZE as u64;
let mut events: Vec<_> = (1..=page + 1).map(same).collect();
let hidden: Vec<_> = (1..=page)
.map(|n| serde_json::json!(["h", format!("dm-{n}")]))
.collect();
events.push(serde_json::json!({
"id": "f".repeat(64), "kind": 30622, "created_at": 5_000, "tags": hidden,
}));
let (base, log) = relay(events).await;
let dms = list_dms(&client(base), 1).await.unwrap();
assert_eq!(dms.len(), 1, "{dms:?}");
assert_eq!(dms[0]["dm_id"], format!("dm-{}", page + 1));
assert!(log
.lock()
.unwrap()
.iter()
.any(|f| f["before_id"].is_string()));
}
/// A relay that answers every DM-metadata query with `page(filter)`.
async fn fixed_relay(
page: fn(&serde_json::Value) -> Vec<serde_json::Value>,
) -> (String, Arc<Mutex<usize>>) {
use axum::{routing::post, Json, Router};
let count = Arc::new(Mutex::new(0));
let seen = count.clone();
let app = Router::new().route(
"/query",
post(move |Json(filters): Json<Vec<serde_json::Value>>| {
let seen = seen.clone();
async move {
let f = &filters[0];
if f["kinds"][0].as_u64() != Some(39000) {
return Json(Vec::new());
}
*seen.lock().unwrap() += 1;
Json(page(f))
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(base, count)
}
/// A full page of DMs with no `d` tag, which `list_dms` drops, older
/// than the filter's cursor.
fn dropped_page(f: &serde_json::Value) -> Vec<serde_json::Value> {
let top = f["until"].as_u64().unwrap_or(1_000_000);
(1..=f["limit"].as_u64().unwrap())
.map(|n| {
serde_json::json!({
"id": "a".repeat(64), "kind": 39000, "created_at": top - n, "tags": [],
})
})
.collect()
}
fn client(base: String) -> BuzzClient {
BuzzClient::new(
base,
crate::credentials::Credentials::from_keys(nostr::Keys::generate()),
)
.unwrap()
}
#[tokio::test]
async fn a_relay_repeating_the_same_page_fails() {
let (base, count) = fixed_relay(|f| {
let mut f = f.clone();
f["until"] = serde_json::Value::Null;
dropped_page(&f)
})
.await;
let err = list_dms(&client(base), 10).await.unwrap_err();
assert_eq!(crate::error::exit_code(&err), 2, "{err}");
assert!(err.to_string().contains("cursor"), "{err}");
assert_eq!(*count.lock().unwrap(), 2);
}
#[tokio::test]
async fn a_relay_that_never_runs_out_fails_at_the_page_cap() {
let (base, count) = fixed_relay(dropped_page).await;
let err = list_dms(&client(base), 10).await.unwrap_err();
assert_eq!(crate::error::exit_code(&err), 2, "{err}");
assert!(err.to_string().contains("pages"), "{err}");
assert_eq!(*count.lock().unwrap(), crate::client::MAX_QUERY_PAGES);
}
}

View File

@@ -347,10 +347,7 @@ pub async fn cmd_get_messages(
// If specific kinds requested, override
if let Some(k) = kinds {
let kind_list: Vec<u64> = k.split(',').filter_map(|s| s.trim().parse().ok()).collect();
if !kind_list.is_empty() {
filter["kinds"] = serde_json::json!(kind_list);
}
filter["kinds"] = serde_json::json!(parse_kinds(k)?);
}
if let Some(b) = before {
@@ -674,18 +671,13 @@ pub async fn cmd_send_message(
let mut media_tags: Vec<Vec<String>> = Vec::new();
let mut media_content = String::new();
for file_path in &p.files {
let desc = client
.upload_file(file_path)
.await
.map_err(|e| CliError::Other(format!("upload failed for {file_path}: {e}")))?;
media_tags.push(crate::client::build_imeta_tag(&desc));
if desc.mime_type.starts_with("video/") {
media_content.push_str("\n![video](");
} else {
media_content.push_str("\n![image](");
}
media_content.push_str(&desc.url);
media_content.push(')');
let desc = client.upload_file(file_path).await?;
let name = attachment_name(file_path);
let mut tag = crate::client::build_imeta_tag(&desc);
tag.push(format!("filename {name}"));
media_tags.push(tag);
media_content.push('\n');
media_content.push_str(&attachment_markdown(&name, &desc));
}
let final_content = if media_content.is_empty() {
p.content.clone()
@@ -1129,8 +1121,99 @@ pub async fn dispatch(
}
}
/// A file's name as relay filename metadata takes it: its basename with
/// control characters and path separators as spaces, capped at 255 UTF-8 bytes.
fn attachment_name(file_path: &str) -> String {
let base = std::path::Path::new(file_path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let mut name = String::new();
for c in base.chars() {
let c = if c.is_control() || c == '/' || c == '\\' {
' '
} else {
c
};
if name.len() + c.len_utf8() > 255 {
break;
}
name.push(c);
}
match name.trim() {
"" => "File".to_string(),
trimmed => trimmed.to_string(),
}
}
/// An uploaded file's Markdown link: inline media embeds, other files link.
fn attachment_markdown(name: &str, desc: &crate::client::BlobDescriptor) -> String {
let mut label = String::new();
for c in name.chars() {
if "\\`*_{}[]()!<>&".contains(c) {
label.push('\\');
}
label.push(c);
}
let embed = matches!(
desc.mime_type.as_str(),
"image/jpeg" | "image/png" | "image/gif" | "image/webp" | "video/mp4"
);
format!("{}[{label}](<{}>)", if embed { "!" } else { "" }, desc.url)
}
/// A `--kinds` list: comma-separated event kinds, each 0–65535.
fn parse_kinds(list: &str) -> Result<Vec<u16>, CliError> {
list.split(',')
.map(|s| {
s.trim()
.parse()
.map_err(|_| CliError::Usage(format!("--kinds: invalid kind {s:?}")))
})
.collect()
}
#[cfg(test)]
mod tests {
#[test]
fn parse_kinds_rejects_any_invalid_kind() {
use super::{parse_kinds, CliError};
assert_eq!(parse_kinds("9, 40002").unwrap(), vec![9, 40002]);
for bad in ["bad", "65536", "9,typo", "", "-1"] {
assert!(matches!(parse_kinds(bad), Err(CliError::Usage(_))), "{bad}");
}
}
#[test]
fn attachments_embed_media_and_link_other_files() {
use super::{attachment_markdown, attachment_name};
let blob = |mime: &str| crate::client::BlobDescriptor {
url: "https://r.example/media/x.bin".into(),
sha256: "x".into(),
size: 1,
mime_type: mime.into(),
uploaded: 0,
dim: None,
blurhash: None,
thumb: None,
duration: None,
};
assert_eq!(
attachment_markdown("notes [v1].txt", &blob("application/octet-stream")),
"[notes \\[v1\\].txt](<https://r.example/media/x.bin>)"
);
assert_eq!(
attachment_markdown("a.png", &blob("image/png")),
"![a.png](<https://r.example/media/x.bin>)"
);
assert_eq!(attachment_name("/tmp/dir/report.pdf"), "report.pdf");
assert_eq!(attachment_name("/tmp/a\nb.txt"), "a b.txt");
assert_eq!(attachment_name("/"), "File");
// On Windows a backslash is a path separator, not part of the name.
#[cfg(unix)]
assert_eq!(attachment_name("/tmp/a\\b.txt"), "a b.txt");
}
use super::{
channel_id_from_event, cmd_get_thread, cmd_send_message, event_mention_pubkeys,
find_root_from_tags, match_profiles_by_name, merge_message_mentions, missing_members,