mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(cli): --author and --since filters on buzz messages search (#1665)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
Tyler Longwell
parent
e15f068571
commit
19919e1810
@@ -35,6 +35,7 @@ buzz messages send --channel <uuid> --content - < message.md # read body from
|
||||
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 edit --event <event-id> --content "Updated text"
|
||||
buzz messages delete --event <event-id>
|
||||
|
||||
@@ -95,7 +96,7 @@ buzz channels list | jq '.[].name'
|
||||
| | `delete` | Delete a message |
|
||||
| | `get` | List messages in a channel |
|
||||
| | `thread` | Get a message thread |
|
||||
| | `search` | Full-text search |
|
||||
| | `search` | Full-text search, filterable by author |
|
||||
| | `vote` | Vote on a forum post |
|
||||
| `channels` | `list` | List channels |
|
||||
| | `get` | Get channel details |
|
||||
|
||||
@@ -339,23 +339,138 @@ pub async fn cmd_get_thread(
|
||||
|
||||
pub async fn cmd_search(
|
||||
client: &BuzzClient,
|
||||
query: &str,
|
||||
query: Option<&str>,
|
||||
author: Option<&str>,
|
||||
since: Option<i64>,
|
||||
limit: Option<u32>,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
if query.is_none() && author.is_none() {
|
||||
return Err(CliError::Usage(
|
||||
"at least one of --query or --author is required".into(),
|
||||
));
|
||||
}
|
||||
let limit = limit.unwrap_or(20).min(100);
|
||||
let filter = serde_json::json!({
|
||||
|
||||
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],
|
||||
"search": query,
|
||||
"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 resp = client.query(&filter).await?;
|
||||
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
||||
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
|
||||
// no relevance, so present newest-first like `messages get`.
|
||||
if query.is_none() {
|
||||
events.sort_by_key(|e| {
|
||||
std::cmp::Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||
});
|
||||
}
|
||||
let normalized = normalize_events(&events);
|
||||
println!("{}", format_events(&normalized, format));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve an `--author` value to a 64-char hex pubkey.
|
||||
///
|
||||
/// Accepts, in order of precedence: 64-char hex (validated), an `npub1…`
|
||||
/// bech32 key, or a display name resolved via NIP-50 profile search. A name
|
||||
/// must match exactly one user (case-insensitive, on `display_name` or
|
||||
/// `name`) — ambiguity is an error listing the candidates rather than a
|
||||
/// silent mix of authors.
|
||||
async fn resolve_author(client: &BuzzClient, author: &str) -> Result<String, CliError> {
|
||||
let author = author.trim();
|
||||
if author.len() == 64 && author.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Ok(author.to_ascii_lowercase());
|
||||
}
|
||||
if author.starts_with("npub1") {
|
||||
return nostr::PublicKey::parse(author)
|
||||
.map(|pk| pk.to_hex())
|
||||
.map_err(|_| CliError::Usage(format!("invalid npub: {author}")));
|
||||
}
|
||||
|
||||
// Display name → NIP-50 search on kind:0, exact case-insensitive match.
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [0],
|
||||
"search": author,
|
||||
"limit": 100
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap_or_default();
|
||||
let mut matches = match_profiles_by_name(&events, author);
|
||||
match matches.len() {
|
||||
0 => Err(CliError::Usage(format!(
|
||||
"no user found with name '{author}' — pass a hex pubkey or npub instead"
|
||||
))),
|
||||
1 => Ok(matches.remove(0).0),
|
||||
_ => {
|
||||
// Cap the candidate listing — some names are shared by dozens of
|
||||
// users, and an unbounded list turns the error into a wall of text.
|
||||
let shown = 5.min(matches.len());
|
||||
let mut listing: Vec<String> = matches[..shown]
|
||||
.iter()
|
||||
.map(|(pk, name)| format!("{name} ({pk})"))
|
||||
.collect();
|
||||
if matches.len() > shown {
|
||||
listing.push(format!("… and {} more", matches.len() - shown));
|
||||
}
|
||||
Err(CliError::Usage(format!(
|
||||
"name '{author}' is ambiguous — matches: {}. Pass a pubkey instead",
|
||||
listing.join(", ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact case-insensitive profile match on `display_name` or `name` across
|
||||
/// kind:0 events. Returns deduped `(pubkey, shown name)` pairs. Pure so the
|
||||
/// name-resolution semantics are unit-testable without a relay.
|
||||
fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(String, String)> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let mut matches: Vec<(String, String)> = Vec::new();
|
||||
for e in events {
|
||||
let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(content) = e
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let display_name = content
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let plain_name = content.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if display_name.to_ascii_lowercase() == lower || plain_name.to_ascii_lowercase() == lower {
|
||||
let shown = if display_name.is_empty() {
|
||||
plain_name
|
||||
} else {
|
||||
display_name
|
||||
};
|
||||
matches.push((pubkey.to_string(), shown.to_string()));
|
||||
}
|
||||
}
|
||||
matches.sort();
|
||||
matches.dedup();
|
||||
matches
|
||||
}
|
||||
|
||||
pub struct SendMessageParams {
|
||||
pub channel_id: String,
|
||||
pub content: String,
|
||||
@@ -737,7 +852,22 @@ pub async fn dispatch(
|
||||
limit,
|
||||
depth_limit,
|
||||
} => cmd_get_thread(client, &channel, &event, limit, depth_limit, format).await,
|
||||
MessagesCmd::Search { query, limit } => cmd_search(client, &query, limit, format).await,
|
||||
MessagesCmd::Search {
|
||||
query,
|
||||
author,
|
||||
since,
|
||||
limit,
|
||||
} => {
|
||||
cmd_search(
|
||||
client,
|
||||
query.as_deref(),
|
||||
author.as_deref(),
|
||||
since,
|
||||
limit,
|
||||
format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
MessagesCmd::Vote { event, direction } => {
|
||||
cmd_vote_on_post(client, &event, &direction).await
|
||||
}
|
||||
@@ -746,7 +876,7 @@ pub async fn dispatch(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{find_root_from_tags, parse_member_pubkeys};
|
||||
use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys};
|
||||
use buzz_sdk::mentions::{
|
||||
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
|
||||
};
|
||||
@@ -972,4 +1102,66 @@ mod tests {
|
||||
});
|
||||
assert_eq!(parse_member_pubkeys(&event), vec![PK_VALID_A, PK_VALID_A]);
|
||||
}
|
||||
|
||||
// ---- match_profiles_by_name (author resolution for `messages search --author`) ----
|
||||
|
||||
fn profile_event(
|
||||
pubkey: &str,
|
||||
display_name: Option<&str>,
|
||||
name: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let mut content = serde_json::Map::new();
|
||||
if let Some(d) = display_name {
|
||||
content.insert("display_name".into(), json!(d));
|
||||
}
|
||||
if let Some(n) = name {
|
||||
content.insert("name".into(), json!(n));
|
||||
}
|
||||
json!({
|
||||
"pubkey": pubkey,
|
||||
"content": serde_json::Value::Object(content).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_name_match_is_exact_case_insensitive() {
|
||||
let events = vec![
|
||||
profile_event(PK_VALID_A, Some("Aaron"), Some("aaron")),
|
||||
// Substring only — NIP-50 may return it, but it must not match.
|
||||
profile_event(PK_VALID_B, Some("Aaronson"), None),
|
||||
];
|
||||
let matches = match_profiles_by_name(&events, "aArOn");
|
||||
assert_eq!(matches, vec![(PK_VALID_A.to_string(), "Aaron".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_name_ambiguity_returns_all_candidates() {
|
||||
let events = vec![
|
||||
profile_event(PK_VALID_A, Some("Sam"), None),
|
||||
profile_event(PK_VALID_B, None, Some("sam")),
|
||||
];
|
||||
let matches = match_profiles_by_name(&events, "sam");
|
||||
assert_eq!(matches.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_name_no_match_and_malformed_content() {
|
||||
let events = vec![
|
||||
profile_event(PK_VALID_A, Some("Aaron"), None),
|
||||
json!({"pubkey": PK_VALID_B, "content": "not-json"}),
|
||||
json!({"content": "{}"}), // missing pubkey
|
||||
];
|
||||
assert!(match_profiles_by_name(&events, "Zoe").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_name_dedups_replaceable_event_copies() {
|
||||
// Same (pubkey, name) appearing twice (e.g. duplicate kind:0 rows)
|
||||
// must resolve unambiguously.
|
||||
let events = vec![
|
||||
profile_event(PK_VALID_A, Some("Aaron"), None),
|
||||
profile_event(PK_VALID_A, Some("Aaron"), None),
|
||||
];
|
||||
assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,10 +345,19 @@ pub enum MessagesCmd {
|
||||
depth_limit: Option<u32>,
|
||||
},
|
||||
/// 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"
|
||||
)]
|
||||
Search {
|
||||
/// Search query string
|
||||
/// Search query string (optional when --author is given)
|
||||
#[arg(long)]
|
||||
query: String,
|
||||
query: Option<String>,
|
||||
/// Filter by author: 64-char hex pubkey, npub, or display name
|
||||
#[arg(long)]
|
||||
author: Option<String>,
|
||||
/// Unix timestamp — return messages after this time
|
||||
#[arg(long)]
|
||||
since: Option<i64>,
|
||||
/// Maximum number of results to return
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
|
||||
Reference in New Issue
Block a user