mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
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 ``; 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:
@@ -65,8 +65,9 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
|
||||
tag
|
||||
}
|
||||
|
||||
/// MIME types accepted for upload.
|
||||
const ALLOWED_MIMES: &[&str] = &[
|
||||
/// MIME types the relay stores as inline media; everything else is a generic
|
||||
/// file it validates, stores and serves as a download.
|
||||
const MEDIA_MIMES: &[&str] = &[
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
@@ -80,6 +81,9 @@ const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
|
||||
/// Maximum file size for video uploads (500 MB).
|
||||
const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
|
||||
|
||||
/// Maximum file size for generic file uploads (100 MB), the relay's default.
|
||||
const MAX_FILE_BYTES: u64 = 100 * 1024 * 1024;
|
||||
|
||||
fn relay_server_tag(relay_url: &str) -> Option<String> {
|
||||
let authority = buzz_core::tenant::relay_url_authority(relay_url);
|
||||
if authority.is_empty() {
|
||||
@@ -446,8 +450,21 @@ mod media_download_tests {
|
||||
}
|
||||
}
|
||||
|
||||
const QUERY_PAGE_SIZE: u32 = 500;
|
||||
pub(crate) const QUERY_PAGE_SIZE: u32 = 500;
|
||||
|
||||
/// The most pages one limited query reads before failing, so a relay that
|
||||
/// never runs out of events to skip cannot keep a command running forever.
|
||||
pub(crate) const MAX_QUERY_PAGES: usize = 100;
|
||||
|
||||
fn stalled_query(detail: &str) -> CliError {
|
||||
CliError::Relay {
|
||||
status: 200,
|
||||
body: format!("query pagination stopped: {detail}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the composite cursor to the page's last event, failing unless it
|
||||
/// lands strictly older than the current cursor.
|
||||
fn advance_query_cursor(
|
||||
filter: &mut serde_json::Value,
|
||||
page: &[serde_json::Value],
|
||||
@@ -464,6 +481,20 @@ fn advance_query_cursor(
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|id| id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()))
|
||||
.ok_or_else(|| CliError::Other("query event missing valid id".into()))?;
|
||||
if let (Some(until), Some(before_id)) = (
|
||||
filter.get("until").and_then(serde_json::Value::as_u64),
|
||||
filter.get("before_id").and_then(serde_json::Value::as_str),
|
||||
) {
|
||||
// The relay pages newest first, equal timestamps by ascending id:
|
||||
// the next page lies at `created_at < until`, or at `until` past `before_id`.
|
||||
let older = created_at < until
|
||||
|| (created_at == until && id.to_ascii_lowercase() > before_id.to_ascii_lowercase());
|
||||
if !older {
|
||||
return Err(stalled_query(
|
||||
"the relay did not move the cursor to older events",
|
||||
));
|
||||
}
|
||||
}
|
||||
filter["until"] = serde_json::json!(created_at);
|
||||
filter["before_id"] = serde_json::json!(id);
|
||||
Ok(())
|
||||
@@ -701,8 +732,7 @@ impl BuzzClient {
|
||||
///
|
||||
/// The relay's `{"error": …}` / `{"message": …}` field becomes the
|
||||
/// message. A 401 without a NIP-FI evidence source names the settings,
|
||||
/// since an enforcing relay answers every request that way; a 403 with an
|
||||
/// auth tag configured hints that the tag may be stale.
|
||||
/// since an enforcing relay answers every request that way.
|
||||
fn relay_error(&self, status: u16, body: String) -> CliError {
|
||||
let message = extract_relay_message_field(&body).unwrap_or(body);
|
||||
let message = message.trim_end().to_owned();
|
||||
@@ -725,18 +755,6 @@ impl BuzzClient {
|
||||
{FEDERATED_IDENTITY_ADAPTER_ENV} to its adapter, which mints the assertion"
|
||||
));
|
||||
}
|
||||
if status == 403 && self.credentials.auth_tag().is_some() {
|
||||
let hint = if desktop {
|
||||
"the Buzz desktop's NIP-OA auth tag may be stale or revoked; check this \
|
||||
identity's owner in Buzz"
|
||||
} else {
|
||||
"BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it"
|
||||
};
|
||||
return CliError::Relay {
|
||||
status,
|
||||
body: format!("{message} ({hint})"),
|
||||
};
|
||||
}
|
||||
CliError::Relay {
|
||||
status,
|
||||
body: message,
|
||||
@@ -805,15 +823,37 @@ impl BuzzClient {
|
||||
|
||||
async fn query_pages(
|
||||
&self,
|
||||
mut filter: serde_json::Value,
|
||||
filter: serde_json::Value,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Vec<serde_json::Value>, CliError> {
|
||||
self.query_pages_keeping(filter, limit, None).await
|
||||
}
|
||||
|
||||
/// Like `query_pages`, but with `keep`, only events it accepts count
|
||||
/// toward `limit`: paging continues past the ones it drops, always in
|
||||
/// full pages, for at most `MAX_QUERY_PAGES` pages.
|
||||
async fn query_pages_keeping(
|
||||
&self,
|
||||
mut filter: serde_json::Value,
|
||||
limit: Option<u32>,
|
||||
keep: Option<&dyn Fn(&serde_json::Value) -> bool>,
|
||||
) -> Result<Vec<serde_json::Value>, CliError> {
|
||||
let mut events = Vec::new();
|
||||
let mut pages = 0;
|
||||
|
||||
while limit.is_none_or(|limit| events.len() < limit as usize) {
|
||||
let page_limit = limit
|
||||
.map(|limit| (limit as usize - events.len()).min(QUERY_PAGE_SIZE as usize))
|
||||
.unwrap_or(QUERY_PAGE_SIZE as usize);
|
||||
if keep.is_some() && pages == MAX_QUERY_PAGES {
|
||||
return Err(stalled_query(&format!(
|
||||
"read {MAX_QUERY_PAGES} pages without reaching the end of the results"
|
||||
)));
|
||||
}
|
||||
pages += 1;
|
||||
let page_limit = match (limit, keep) {
|
||||
(Some(limit), None) => {
|
||||
(limit as usize - events.len()).min(QUERY_PAGE_SIZE as usize)
|
||||
}
|
||||
_ => QUERY_PAGE_SIZE as usize,
|
||||
};
|
||||
filter["limit"] = serde_json::json!(page_limit);
|
||||
|
||||
let raw = self.query(&filter).await?;
|
||||
@@ -824,15 +864,30 @@ impl BuzzClient {
|
||||
if !done {
|
||||
advance_query_cursor(&mut filter, &page)?;
|
||||
}
|
||||
events.extend(page);
|
||||
events.extend(page.into_iter().filter(|e| keep.is_none_or(|keep| keep(e))));
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(limit) = limit {
|
||||
events.truncate(limit as usize);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Query up to `limit` historical events that `keep` accepts, paging past
|
||||
/// the rest until the limit is met or the history runs out.
|
||||
pub async fn query_paginated_keeping(
|
||||
&self,
|
||||
filter: serde_json::Value,
|
||||
limit: u32,
|
||||
keep: impl Fn(&serde_json::Value) -> bool,
|
||||
) -> Result<Vec<serde_json::Value>, CliError> {
|
||||
self.query_pages_keeping(filter, Some(limit), Some(&keep))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Query up to `limit` historical events, following the relay bridge's
|
||||
/// composite `(until, before_id)` cursor across bounded result pages.
|
||||
pub async fn query_paginated(
|
||||
@@ -1387,28 +1442,26 @@ impl BuzzClient {
|
||||
pub async fn upload_file(&self, file_path: &str) -> Result<BlobDescriptor, CliError> {
|
||||
// 1. Read file — validate it exists and is a regular file
|
||||
let metadata = std::fs::metadata(file_path)
|
||||
.map_err(|e| CliError::Other(format!("cannot access {file_path}: {e}")))?;
|
||||
.map_err(|e| CliError::Usage(format!("cannot access {file_path}: {e}")))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(CliError::Usage(format!("{file_path} is not a file")));
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(file_path)
|
||||
.map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?;
|
||||
.map_err(|e| CliError::Usage(format!("failed to read {file_path}: {e}")))?;
|
||||
|
||||
// 2. Detect MIME from magic bytes
|
||||
let mime = infer::get(&bytes)
|
||||
.map(|t| t.mime_type().to_string())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
if !ALLOWED_MIMES.contains(&mime.as_str()) {
|
||||
return Err(CliError::Usage(format!("unsupported file type: {mime}")));
|
||||
}
|
||||
|
||||
// 3. Size check
|
||||
let max = if mime.starts_with("video/") {
|
||||
// 3. Size check. The relay decides which other types it accepts.
|
||||
let max = if mime == "video/mp4" {
|
||||
MAX_VIDEO_BYTES
|
||||
} else {
|
||||
} else if MEDIA_MIMES.contains(&mime.as_str()) {
|
||||
MAX_IMAGE_BYTES
|
||||
} else {
|
||||
MAX_FILE_BYTES
|
||||
};
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(CliError::Usage(format!(
|
||||
@@ -1423,7 +1476,7 @@ impl BuzzClient {
|
||||
|
||||
// 5. PUT request to the BUD-02 /upload endpoint with a generous timeout.
|
||||
// Auth is signed per attempt — matches the per-attempt signing pattern in download_media.
|
||||
let upload_timeout = if mime.starts_with("video/") {
|
||||
let upload_timeout = if max > MAX_IMAGE_BYTES {
|
||||
Duration::from_secs(600)
|
||||
} else {
|
||||
Duration::from_secs(120)
|
||||
@@ -2597,6 +2650,27 @@ mod retry_policy_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn an_unreadable_file_is_bad_input() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let file = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let client = BuzzClient::new(
|
||||
"http://127.0.0.1:9".into(),
|
||||
crate::credentials::Credentials::from_keys(nostr::Keys::generate()),
|
||||
)
|
||||
.unwrap();
|
||||
let err = client
|
||||
.upload_file(file.path().to_str().unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, crate::error::CliError::Usage(ref m) if m.contains("failed to read")),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
use super::{
|
||||
advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field,
|
||||
normalize_events, BuzzClient, Credentials, Proof,
|
||||
@@ -3497,7 +3571,7 @@ mod desktop_throttle_tests {
|
||||
let err = client.get_authed("/forbidden").await.unwrap_err();
|
||||
let text = err.to_string();
|
||||
assert!(
|
||||
text.contains("in Buzz") && !text.contains("BUZZ_AUTH_TAG"),
|
||||
!text.contains("stale") && !text.contains("BUZZ_AUTH_TAG"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
} else {
|
||||
media_content.push_str("\n;
|
||||
}
|
||||
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")),
|
||||
""
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -1049,6 +1049,36 @@ pub async fn discover(relay_url: &str) -> Result<Option<Arc<Provider>>, CliError
|
||||
discover_with(inherited()?, &Keychain, advertisements, relay_url).await
|
||||
}
|
||||
|
||||
/// The relay an agent's inherited credential acts on, for a command given
|
||||
/// none. `None` without an inherited token, or when `describe` fails (the
|
||||
/// credential chain reports that); bad input when it acts on several relays.
|
||||
pub async fn inherited_relay() -> Result<Option<String>, CliError> {
|
||||
let Some((endpoint, token)) = inherited()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(raw) = call(&endpoint, Some(&token), true, "describe", &[]).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let relays = serde_json::from_str::<Describe>(&raw)
|
||||
.map(|d| d.relays)
|
||||
.unwrap_or_default();
|
||||
let mut distinct: Vec<String> = Vec::new();
|
||||
for relay in relays {
|
||||
if !distinct.iter().any(|known| same_relay(known, &relay)) {
|
||||
distinct.push(provider_relay_url(&relay).unwrap_or(relay));
|
||||
}
|
||||
}
|
||||
match distinct.len() {
|
||||
0 => Ok(None),
|
||||
1 => Ok(distinct.pop()),
|
||||
_ => Err(CliError::Usage(format!(
|
||||
"this agent's Buzz credential acts on several relays ({}); pass --relay or set \
|
||||
BUZZ_RELAY_URL",
|
||||
distinct.join(", ")
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover_with(
|
||||
inherited: Option<(Endpoint, String)>,
|
||||
store: &dyn PairingStore,
|
||||
@@ -2022,16 +2052,21 @@ pub(crate) mod tests {
|
||||
|
||||
let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![TEAM.into()]).await;
|
||||
let cli = parse(&["buzz", "--relay", TEAM, "users", "get"]);
|
||||
let (_, client) = crate::connect(&cli.relay, cli.command, async |_, _| unreachable!(), {
|
||||
let store = &store;
|
||||
async move |relay| {
|
||||
let provider =
|
||||
discover_with(Some((endpoint, "agent".into())), store, Vec::new, relay)
|
||||
.await?
|
||||
.unwrap();
|
||||
crate::credentials::Credentials::from_desktop(provider).await
|
||||
}
|
||||
})
|
||||
let (_, client) = crate::connect(
|
||||
cli.relay.as_deref().unwrap(),
|
||||
cli.command,
|
||||
async |_, _| unreachable!(),
|
||||
{
|
||||
let store = &store;
|
||||
async move |relay| {
|
||||
let provider =
|
||||
discover_with(Some((endpoint, "agent".into())), store, Vec::new, relay)
|
||||
.await?
|
||||
.unwrap();
|
||||
crate::credentials::Credentials::from_desktop(provider).await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
@@ -2050,7 +2085,7 @@ pub(crate) mod tests {
|
||||
"buzz", "--relay", TEAM, "auth", "desktop", "pair", "--name", "cli",
|
||||
]);
|
||||
let paired = crate::connect(
|
||||
&cli.relay,
|
||||
cli.relay.as_deref().unwrap(),
|
||||
cli.command,
|
||||
async |sub, relay| {
|
||||
let crate::AuthCmd::Desktop(crate::DesktopAuthCmd::Pair { name, .. }) = sub else {
|
||||
|
||||
@@ -128,7 +128,8 @@ fn env_output_format(value: Option<&std::ffi::OsStr>) -> OutputFormat {
|
||||
Buzz CLI — interact with a Buzz relay
|
||||
|
||||
Configuration (flags override env vars):
|
||||
BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000]
|
||||
BUZZ_RELAY_URL Relay base URL [default: the relay an agent's inherited
|
||||
Buzz desktop credential acts on, else http://localhost:3000]
|
||||
BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [unless a Buzz desktop provides it]
|
||||
BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional]
|
||||
BUZZ_FEDERATED_IDENTITY_TOKEN Your NIP-FA sign-in session token [optional]
|
||||
@@ -145,8 +146,10 @@ Errors are JSON on stderr: {\"error\": \"<category>\", \"message\": \"<detail>\"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Relay URL (http:// or https://). Overrides BUZZ_RELAY_URL env var.
|
||||
#[arg(long, env = "BUZZ_RELAY_URL", default_value = "http://localhost:3000")]
|
||||
relay: String,
|
||||
/// Without either, the relay an agent's inherited Buzz desktop credential
|
||||
/// acts on; otherwise http://localhost:3000.
|
||||
#[arg(long, env = "BUZZ_RELAY_URL")]
|
||||
relay: Option<String>,
|
||||
|
||||
/// Nostr private key (hex or nsec). This is the CLI's identity.
|
||||
#[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)]
|
||||
@@ -372,6 +375,24 @@ enum DesktopAuthCmd {
|
||||
},
|
||||
}
|
||||
|
||||
/// The relay for a command given none: the one an agent's inherited Buzz
|
||||
/// desktop credential acts on, else `http://localhost:3000`. Local-only
|
||||
/// unpairing never asks the desktop.
|
||||
async fn default_relay(command: &Cmd) -> Result<String, CliError> {
|
||||
#[cfg(unix)]
|
||||
if !matches!(
|
||||
command,
|
||||
Cmd::Auth(AuthCmd::Desktop(DesktopAuthCmd::Unpair { .. }))
|
||||
) {
|
||||
if let Some(relay) = desktop_provider::inherited_relay().await? {
|
||||
return Ok(relay);
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = command;
|
||||
Ok("http://localhost:3000".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run_auth(cmd: AuthCmd, relay_url: &str) -> Result<(), CliError> {
|
||||
let AuthCmd::Desktop(cmd) = cmd;
|
||||
@@ -2392,7 +2413,11 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
..Default::default()
|
||||
}
|
||||
.with_env_evidence();
|
||||
let Some((command, client)) = connect(&cli.relay, cli.command, run_auth, async |relay| {
|
||||
let relay = match cli.relay {
|
||||
Some(relay) => relay,
|
||||
None => default_relay(&cli.command).await?,
|
||||
};
|
||||
let Some((command, client)) = connect(&relay, cli.command, run_auth, async |relay| {
|
||||
credentials::resolve(settings, relay).await
|
||||
})
|
||||
.await?
|
||||
|
||||
Reference in New Issue
Block a user