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
84 changes: 69 additions & 15 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,9 +427,42 @@ pub async fn cmd_get_thread(
Ok(())
}

/// Build the NIP-50 search filter sent to the relay.
///
/// `channel` becomes an `#h` tag. The relay intersects it with the caller's
/// accessible channels, so an unreadable id returns nothing rather than
/// widening access — scoping here narrows results, it is never the access
/// boundary.
fn build_search_filter(
query: Option<&str>,
channel: Option<&str>,
author_hex: Option<&str>,
since: Option<i64>,
limit: u32,
) -> serde_json::Value {
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(channel_id) = channel {
filter["#h"] = serde_json::json!([channel_id]);
}
if let Some(pk) = author_hex {
filter["authors"] = serde_json::json!([pk]);
}
if let Some(s) = since {
filter["since"] = serde_json::json!(s);
}
filter
}

pub async fn cmd_search(
client: &BuzzClient,
query: Option<&str>,
channel: Option<&str>,
author: Option<&str>,
since: Option<i64>,
limit: Option<u32>,
Expand All @@ -440,26 +473,17 @@ pub async fn cmd_search(
"at least one of --query or --author is required".into(),
));
}
if let Some(channel_id) = channel {
validate_uuid(channel_id)?;
}
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, channel, author_hex.as_deref(), since, 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 @@ -970,13 +994,15 @@ pub async fn dispatch(
} => cmd_get_thread(client, &channel, &event, limit, depth_limit, format).await,
MessagesCmd::Search {
query,
channel,
author,
since,
limit,
} => {
cmd_search(
client,
query.as_deref(),
channel.as_deref(),
author.as_deref(),
since,
limit,
Expand All @@ -993,8 +1019,8 @@ pub async fn dispatch(
#[cfg(test)]
mod tests {
use super::{
event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions,
missing_members, normalize_explicit_mentions, parse_member_pubkeys,
build_search_filter, event_mention_pubkeys, find_root_from_tags, match_profiles_by_name,
merge_message_mentions, missing_members, normalize_explicit_mentions, parse_member_pubkeys,
resolve_names_to_pubkeys,
};
use buzz_sdk::mentions::{
Expand All @@ -1012,6 +1038,34 @@ mod tests {
const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05";
const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68";

const CHANNEL_ID: &str = "0b3a4bd2-4d4a-4a0e-9c3f-1f2d3e4a5b6c";

#[test]
fn search_filter_scopes_to_one_channel() {
let filter = build_search_filter(Some("checkout"), Some(CHANNEL_ID), None, None, 20);
assert_eq!(filter["#h"], json!([CHANNEL_ID]));
assert_eq!(filter["search"], json!("checkout"));
}

#[test]
fn search_filter_omits_channel_tag_when_unscoped() {
let filter = build_search_filter(Some("checkout"), None, None, None, 20);
assert!(
filter.get("#h").is_none(),
"an unscoped search must not send an #h tag"
);
}

#[test]
fn search_filter_combines_channel_with_author_and_since() {
let filter = build_search_filter(None, Some(CHANNEL_ID), Some(PUBKEY), Some(1783497600), 5);
assert_eq!(filter["#h"], json!([CHANNEL_ID]));
assert_eq!(filter["authors"], json!([PUBKEY]));
assert_eq!(filter["since"], json!(1783497600));
assert_eq!(filter["limit"], json!(5));
assert!(filter.get("search").is_none());
}

#[test]
fn root_marker_wins_over_reply_marker() {
let tags = json!([
Expand Down
6 changes: 5 additions & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,12 +478,16 @@ 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 --channel <UUID> --query checkout"
)]
Search {
/// Search query string (optional when --author is given)
#[arg(long)]
query: Option<String>,
/// Restrict results to one channel (UUID, from 'buzz channels list').
/// Omit to search every channel you can read.
#[arg(long)]
channel: Option<String>,
/// Filter by author: 64-char hex pubkey, npub, or display name
#[arg(long)]
author: Option<String>,
Expand Down