Skip to content
Open
Show file tree
Hide file tree
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
3 changes: 3 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ buzz messages get --channel <uuid> --limit 20
buzz messages thread --channel <uuid> --event <event-id>
buzz messages search --query "architecture"
buzz messages search --author <pubkey|npub|name> --since <unix-ts>
buzz messages search --query "architecture" --channel <uuid> # scope to one channel
buzz messages search --query "architecture" --since <ts> --until <ts> # bound both ends
buzz messages search --query "architecture" --kinds 9,45001,45003 # override default kinds
buzz messages edit --event <event-id> --content "Updated text"
buzz messages delete --event <event-id>

Expand Down
159 changes: 146 additions & 13 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>,
until: Option<i64>,
channel: Option<&str>,
kinds: &[u16],
limit: u32,
) -> serde_json::Value {
let kind_list: Vec<u16> = 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<i64>,
until: Option<i64>,
channel: Option<&str>,
kinds: &[u16],
limit: Option<u32>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
Expand All @@ -350,26 +399,32 @@ 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 {
Some(a) => Some(resolve_author(client, a).await?),
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::Value> = serde_json::from_str(&resp).unwrap_or_default();
// The full-text path returns relevance order; a pure author/time query has
Expand Down Expand Up @@ -856,13 +911,19 @@ pub async fn dispatch(
query,
author,
since,
until,
channel,
kinds,
limit,
} => {
cmd_search(
client,
query.as_deref(),
author.as_deref(),
since,
until,
channel.as_deref(),
&kinds,
limit,
format,
)
Expand Down Expand Up @@ -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
})
);
}
}
11 changes: 10 additions & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <UUID>\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)
Expand All @@ -483,6 +483,15 @@ pub enum MessagesCmd {
/// Unix timestamp — return messages after this time
#[arg(long)]
since: Option<i64>,
/// Unix timestamp — return messages before this time
#[arg(long)]
until: Option<i64>,
/// Channel UUID — restrict results to one channel (from 'buzz channels list')
#[arg(long)]
channel: Option<String>,
/// Nostr event kinds to search (comma-separated) — defaults to chat, channel, and forum kinds
#[arg(long, value_delimiter = ',')]
kinds: Vec<u16>,
/// Maximum number of results to return
#[arg(long)]
limit: Option<u32>,
Expand Down