From ac4aa4b3cf1760db2d410b8ec0756ee52df97c04 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 29 Jul 2026 06:54:54 -0700 Subject: [PATCH] feat(cli): scope buzz messages search by channel, kind, and upper time bound buzz messages search accepted --query, --author, --since, and --limit, and built its relay filter with a hardcoded kinds array and no channel constraint. The relay-side SearchQuery already declares until and kinds, and every other channel-scoped read path in the crate already sends #h, so the capability existed server-side but was unreachable from the CLI. Add three optional flags that map onto NIP-01 filter fields the relay already honors on this code path: --channel -> filter["#h"] = [uuid] --until -> filter["until"] = n --kinds -> replaces the default kinds array AGENTS.md already tells agents to pass --kinds 9,45001,45003 to avoid the relay p-gate; this makes that documented invocation real. Omitting all three emits a filter byte-identical to the previous one, covered by a regression test. --since later than --until and a malformed --channel both fail locally with CliError::Usage before any network call. The filter build moves into build_search_filter so the shape is testable without a relay. Signed-off-by: Matt Van Horn --- crates/buzz-cli/README.md | 3 + crates/buzz-cli/src/commands/messages.rs | 159 +++++++++++++++++++++-- crates/buzz-cli/src/lib.rs | 11 +- 3 files changed, 159 insertions(+), 14 deletions(-) diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 40699459fc..9d05fee424 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -36,6 +36,9 @@ buzz messages get --channel --limit 20 buzz messages thread --channel --event buzz messages search --query "architecture" buzz messages search --author --since +buzz messages search --query "architecture" --channel # scope to one channel +buzz messages search --query "architecture" --since --until # bound both ends +buzz messages search --query "architecture" --kinds 9,45001,45003 # override default kinds buzz messages edit --event --content "Updated text" buzz messages delete --event diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 290cc59fa8..cf396e82b6 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -337,11 +337,60 @@ pub async fn cmd_get_thread( Ok(()) } +/// Kinds searched when `--kind` is not supplied: chat, channel metadata, +/// forum posts, and forum comments. +const DEFAULT_SEARCH_KINDS: &[u16] = &[9, 40002, 45001, 45003]; + +/// Build the relay filter for `messages search`. +/// +/// Split out of [`cmd_search`] so the filter shape is testable without a +/// relay. Omitting every optional argument produces the same object the +/// command sent before `--channel`, `--until`, and `--kind` existed. +fn build_search_filter( + query: Option<&str>, + author_hex: Option<&str>, + since: Option, + until: Option, + channel: Option<&str>, + kinds: &[u16], + limit: u32, +) -> serde_json::Value { + let kind_list: Vec = if kinds.is_empty() { + DEFAULT_SEARCH_KINDS.to_vec() + } else { + kinds.to_vec() + }; + let mut filter = serde_json::json!({ + "kinds": kind_list, + "limit": limit + }); + if let Some(q) = query { + filter["search"] = serde_json::json!(q); + } + if let Some(pk) = author_hex { + filter["authors"] = serde_json::json!([pk]); + } + if let Some(s) = since { + filter["since"] = serde_json::json!(s); + } + if let Some(u) = until { + filter["until"] = serde_json::json!(u); + } + if let Some(c) = channel { + filter["#h"] = serde_json::json!([c]); + } + filter +} + +#[allow(clippy::too_many_arguments)] pub async fn cmd_search( client: &BuzzClient, query: Option<&str>, author: Option<&str>, since: Option, + until: Option, + channel: Option<&str>, + kinds: &[u16], limit: Option, format: &crate::OutputFormat, ) -> Result<(), CliError> { @@ -350,6 +399,16 @@ pub async fn cmd_search( "at least one of --query or --author is required".into(), )); } + if let (Some(s), Some(u)) = (since, until) { + if s > u { + return Err(CliError::Usage( + "--since must not be later than --until".into(), + )); + } + } + if let Some(c) = channel { + crate::validate::validate_uuid(c)?; + } let limit = limit.unwrap_or(20).min(100); let author_hex = match author { @@ -357,19 +416,15 @@ pub async fn cmd_search( None => None, }; - let mut filter = serde_json::json!({ - "kinds": [9, 40002, 45001, 45003], - "limit": limit - }); - if let Some(q) = query { - filter["search"] = serde_json::json!(q); - } - if let Some(ref pk) = author_hex { - filter["authors"] = serde_json::json!([pk]); - } - if let Some(s) = since { - filter["since"] = serde_json::json!(s); - } + let filter = build_search_filter( + query, + author_hex.as_deref(), + since, + until, + channel, + kinds, + limit, + ); let resp = client.query(&filter).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); // The full-text path returns relevance order; a pure author/time query has @@ -856,6 +911,9 @@ pub async fn dispatch( query, author, since, + until, + channel, + kinds, limit, } => { cmd_search( @@ -863,6 +921,9 @@ pub async fn dispatch( query.as_deref(), author.as_deref(), since, + until, + channel.as_deref(), + &kinds, limit, format, ) @@ -1165,3 +1226,75 @@ mod tests { assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } } + +#[cfg(test)] +mod search_filter_tests { + use super::build_search_filter; + use serde_json::json; + + const UUID: &str = "0b7f9c2e-3d4a-4b1c-8e5f-6a7b8c9d0e1f"; + const PUBKEY: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + #[test] + fn defaults_to_the_four_message_kinds_when_no_kind_flag_is_given() { + let f = build_search_filter(Some("deploy"), None, None, None, None, &[], 20); + assert_eq!(f["kinds"], json!([9, 40002, 45001, 45003])); + } + + #[test] + fn explicit_kinds_replace_the_defaults() { + let f = build_search_filter(Some("deploy"), None, None, None, None, &[9, 40002], 20); + assert_eq!(f["kinds"], json!([9, 40002])); + } + + #[test] + fn channel_becomes_an_h_tag_filter() { + let f = build_search_filter(Some("deploy"), None, None, None, Some(UUID), &[], 20); + assert_eq!(f["#h"], json!([UUID])); + } + + #[test] + fn no_h_key_at_all_when_channel_is_absent() { + let f = build_search_filter(Some("deploy"), None, None, None, None, &[], 20); + assert!(f.get("#h").is_none(), "unscoped search must not send #h"); + } + + #[test] + fn until_is_passed_through_when_present_and_absent_otherwise() { + let with = build_search_filter( + Some("deploy"), + None, + None, + Some(1_784_102_400), + None, + &[], + 20, + ); + assert_eq!(with["until"], json!(1_784_102_400)); + let without = build_search_filter(Some("deploy"), None, None, None, None, &[], 20); + assert!(without.get("until").is_none()); + } + + #[test] + fn omitting_every_new_flag_reproduces_the_previous_filter_exactly() { + let f = build_search_filter( + Some("checkout"), + Some(PUBKEY), + Some(1_783_497_600), + None, + None, + &[], + 20, + ); + assert_eq!( + f, + json!({ + "kinds": [9, 40002, 45001, 45003], + "limit": 20, + "search": "checkout", + "authors": [PUBKEY], + "since": 1_783_497_600 + }) + ); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0b46734584..70ef89ce89 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -471,7 +471,7 @@ pub enum MessagesCmd { }, /// Full-text search across messages #[command( - after_help = "Examples:\n buzz messages search --query checkout\n buzz messages search --author npub1... --since 1783497600\n buzz messages search --author Aaron --query checkout --limit 20" + after_help = "Examples:\n buzz messages search --query checkout\n buzz messages search --author npub1... --since 1783497600\n buzz messages search --author Aaron --query checkout --limit 20\n buzz messages search --query deploy --channel \n buzz messages search --query deploy --since 1783497600 --until 1784102400\n buzz messages search --query deploy --kinds 9,45001,45003" )] Search { /// Search query string (optional when --author is given) @@ -483,6 +483,15 @@ pub enum MessagesCmd { /// Unix timestamp — return messages after this time #[arg(long)] since: Option, + /// Unix timestamp — return messages before this time + #[arg(long)] + until: Option, + /// Channel UUID — restrict results to one channel (from 'buzz channels list') + #[arg(long)] + channel: Option, + /// Nostr event kinds to search (comma-separated) — defaults to chat, channel, and forum kinds + #[arg(long, value_delimiter = ',')] + kinds: Vec, /// Maximum number of results to return #[arg(long)] limit: Option,