mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(cli): find people by name through the relay's name-only profile search
`buzz users get --name`, `messages search --author <name>` and `notes --author <name>` searched kind:0 with NIP-50, which matches the whole profile JSON. Agent profiles that share a default avatar whose data contains "Wes" filled all 100 results, so `--name wes` never found Wes. All three now use the relay's POST /profiles/search, which matches only name and display_name and ranks exact names first. A relay without the endpoint answers 404 or 405, and the lookup uses the old NIP-50 search. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -514,7 +514,7 @@ pub async fn cmd_search(
|
||||
/// 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
|
||||
/// bech32 key, or a display name resolved via profile name 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.
|
||||
@@ -529,13 +529,8 @@ async fn resolve_author(client: &BuzzClient, author: &str) -> Result<String, Cli
|
||||
.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?;
|
||||
// Display name → profile name search, exact case-insensitive match.
|
||||
let raw = crate::profile_search::search_profiles(client, author, 100).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() {
|
||||
|
||||
@@ -209,13 +209,8 @@ pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result<Pu
|
||||
return PublicKey::from_hex(author_flag)
|
||||
.map_err(|e| CliError::Usage(format!("invalid pubkey: {e}")));
|
||||
}
|
||||
// Petname lookup: NIP-50 search on kind:0, then exact-name filter.
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [0],
|
||||
"search": author_flag,
|
||||
"limit": 100,
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
// Petname lookup: profile name search, then exact-name filter.
|
||||
let raw = crate::profile_search::search_profiles(client, author_flag, 100).await?;
|
||||
let events = parse_events(&raw)?;
|
||||
let lower = author_flag.to_ascii_lowercase();
|
||||
let matches: Vec<&Event> = events
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::validate::validate_hex64;
|
||||
///
|
||||
/// - 0 pubkeys, no name → query our own profile
|
||||
/// - 1+ pubkeys → query those users' profiles
|
||||
/// - --name "foo" → NIP-50 search on kind:0, then client-side filter
|
||||
/// - --name "foo" → profile name search, then client-side filter
|
||||
pub async fn cmd_get_users(
|
||||
client: &BuzzClient,
|
||||
pubkeys: &[String],
|
||||
@@ -283,7 +283,7 @@ fn owner_scoped_profiles(
|
||||
}
|
||||
|
||||
/// Search for users by display name. Owner-scoped searches resolve managed-agent records
|
||||
/// and verify their profiles; unscoped searches use NIP-50 and return [] if unsupported.
|
||||
/// and verify their profiles; unscoped searches use the relay's profile name search.
|
||||
async fn search_by_name(
|
||||
client: &BuzzClient,
|
||||
query: &str,
|
||||
@@ -319,12 +319,7 @@ async fn search_by_name(
|
||||
};
|
||||
owner_scoped_profiles(&events, &pubkeys, &owner, &effective_owner(client))
|
||||
} else {
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [0],
|
||||
"search": query,
|
||||
"limit": 100
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
let raw = crate::profile_search::search_profiles(client, query, 100).await?;
|
||||
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?;
|
||||
name_search_profiles(&events, query)
|
||||
|
||||
@@ -4,6 +4,7 @@ mod commands;
|
||||
mod error;
|
||||
mod help_tree;
|
||||
mod links;
|
||||
mod profile_search;
|
||||
mod validate;
|
||||
|
||||
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
|
||||
|
||||
150
crates/buzz-cli/src/profile_search.rs
Normal file
150
crates/buzz-cli/src/profile_search.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
//! Finding people by profile name.
|
||||
//!
|
||||
//! NIP-50 search over kind:0 matches the whole profile JSON, so text inside
|
||||
//! avatar data URLs, `about` or `nip05` competes with real names: a shared
|
||||
//! default avatar can fill every result page and hide the person a name
|
||||
//! lookup asked for. The relay's `POST /profiles/search` matches only `name`
|
||||
//! and `display_name` and puts exact and prefix name matches first.
|
||||
|
||||
use crate::client::BuzzClient;
|
||||
use crate::error::CliError;
|
||||
|
||||
/// The relay's name search path.
|
||||
const SEARCH_PATH: &str = "/profiles/search";
|
||||
/// Longest query the name search accepts, in characters after trimming.
|
||||
const QUERY_MAX_CHARS: usize = 100;
|
||||
/// Largest page the name search returns.
|
||||
const LIMIT_MAX: u32 = 100;
|
||||
|
||||
/// Returns up to `limit` kind:0 events whose names match `query`, as the raw
|
||||
/// JSON array the relay sent.
|
||||
///
|
||||
/// Uses the relay's name search. A relay without it answers 404 or 405; the
|
||||
/// search then uses the NIP-50 kind:0 search that callers used before.
|
||||
/// Requests the name search cannot take (a query over 100 characters, or a
|
||||
/// `limit` over 100) also use NIP-50, as before. Other errors are returned.
|
||||
/// Callers still filter the events by name.
|
||||
pub(crate) async fn search_profiles(
|
||||
client: &BuzzClient,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<String, CliError> {
|
||||
let text = query.trim();
|
||||
if !text.is_empty()
|
||||
&& text.chars().count() <= QUERY_MAX_CHARS
|
||||
&& (1..=LIMIT_MAX).contains(&limit)
|
||||
{
|
||||
let body = serde_json::json!({ "query": text, "limit": limit });
|
||||
match client.post_json_authed(SEARCH_PATH, &body).await {
|
||||
Ok(raw) => return Ok(raw),
|
||||
Err(CliError::Relay {
|
||||
status: 404 | 405, ..
|
||||
}) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
client
|
||||
.query(&serde_json::json!({
|
||||
"kinds": [0],
|
||||
"search": query,
|
||||
"limit": limit,
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
use nostr::Keys;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
type Seen = Arc<Mutex<Vec<(String, serde_json::Value)>>>;
|
||||
|
||||
/// A relay whose name search answers `status`; `/query` answers `[]`.
|
||||
async fn relay(status: StatusCode) -> (BuzzClient, Seen) {
|
||||
let seen: Seen = Arc::default();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
SEARCH_PATH,
|
||||
post(move |State(seen): State<Seen>, body: Bytes| async move {
|
||||
let body = serde_json::from_slice(&body).unwrap();
|
||||
seen.lock().unwrap().push((SEARCH_PATH.into(), body));
|
||||
(status, r#"[{"kind":0,"from":"names"}]"#)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/query",
|
||||
post(|State(seen): State<Seen>, body: Bytes| async move {
|
||||
let body = serde_json::from_slice(&body).unwrap();
|
||||
seen.lock().unwrap().push(("/query".into(), body));
|
||||
"[]"
|
||||
}),
|
||||
)
|
||||
.with_state(seen.clone());
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async { axum::serve(listener, app).await.unwrap() });
|
||||
let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap();
|
||||
(client, seen)
|
||||
}
|
||||
|
||||
fn nip50(query: &str, limit: u32) -> (String, serde_json::Value) {
|
||||
(
|
||||
"/query".into(),
|
||||
serde_json::json!([{ "kinds": [0], "search": query, "limit": limit }]),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uses_the_name_search() {
|
||||
let (client, seen) = relay(StatusCode::OK).await;
|
||||
let raw = search_profiles(&client, " Wes ", 100).await.unwrap();
|
||||
assert_eq!(raw, r#"[{"kind":0,"from":"names"}]"#);
|
||||
assert_eq!(
|
||||
*seen.lock().unwrap(),
|
||||
[(
|
||||
SEARCH_PATH.to_string(),
|
||||
serde_json::json!({ "query": "Wes", "limit": 100 })
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_relay_without_the_name_search_uses_nip50() {
|
||||
for status in [StatusCode::NOT_FOUND, StatusCode::METHOD_NOT_ALLOWED] {
|
||||
let (client, seen) = relay(status).await;
|
||||
assert_eq!(search_profiles(&client, "Wes", 100).await.unwrap(), "[]");
|
||||
let seen = seen.lock().unwrap();
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert_eq!(seen[1], nip50("Wes", 100));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn other_name_search_errors_are_returned() {
|
||||
let (client, seen) = relay(StatusCode::INTERNAL_SERVER_ERROR).await;
|
||||
let error = search_profiles(&client, "Wes", 100).await.unwrap_err();
|
||||
assert!(matches!(error, CliError::Relay { status: 500, .. }));
|
||||
assert!(seen
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|(path, _)| path != "/query"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requests_the_name_search_cannot_take_use_nip50() {
|
||||
let long = "w".repeat(QUERY_MAX_CHARS + 1);
|
||||
for (query, limit) in [(long.as_str(), 100), ("Wes", 101), (" ", 100)] {
|
||||
let (client, seen) = relay(StatusCode::OK).await;
|
||||
search_profiles(&client, query, limit).await.unwrap();
|
||||
assert_eq!(*seen.lock().unwrap(), [nip50(query, limit)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user