mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
fix(sdk): resolve multi-word display names and add NIP-27 nostr:npub mention extraction (#905)
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -8,7 +8,10 @@ use crate::validate::{
|
||||
infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff,
|
||||
validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
|
||||
};
|
||||
use sprout_sdk::mentions::{extract_at_names, match_names_to_profiles, MentionProfile};
|
||||
use sprout_sdk::mentions::{
|
||||
extract_at_mentions_with_known, extract_nostr_uris, merge_mentions, strip_code_regions,
|
||||
MENTION_CAP,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -122,20 +125,19 @@ async fn resolve_channel_id(client: &SproutClient, event_id: &str) -> Result<Uui
|
||||
|
||||
/// Resolve `@name` mentions in `content` against this channel's members.
|
||||
///
|
||||
/// Mirrors the MCP implementation: queries kind 39002 (channel members),
|
||||
/// then kind 0 (profiles for those members), and delegates parsing and
|
||||
/// case-insensitive matching to [`sprout_sdk::mentions`]. On any I/O or
|
||||
/// parse failure, returns an empty vec — auto-tagging is best-effort and
|
||||
/// must never block a send.
|
||||
/// Queries kind 39002 (channel members) then kind 0 (profiles), parses
|
||||
/// display names once, and feeds them to [`extract_at_mentions_with_known`]
|
||||
/// for multi-word matching. On any I/O or parse failure, returns an empty
|
||||
/// vec — auto-tagging is best-effort and must never block a send.
|
||||
async fn resolve_content_mentions(
|
||||
client: &SproutClient,
|
||||
channel_id: &str,
|
||||
content: &str,
|
||||
) -> Vec<String> {
|
||||
let names = extract_at_names(content);
|
||||
if names.is_empty() {
|
||||
if !content.contains('@') {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// 1. Membership list (kind 39002 is parameterized-replaceable, addressed by `d` tag).
|
||||
let members_filter = serde_json::json!({
|
||||
"kinds": [39002],
|
||||
@@ -158,19 +160,46 @@ async fn resolve_content_mentions(
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
// 3. Hand the parsed profile content + pubkey to the shared matcher.
|
||||
let entries: Vec<MentionProfile<'_>> = profile_events
|
||||
// 3. Single parse: extract (pubkey, display_name) pairs from profile JSON.
|
||||
let mut name_to_pubkeys: std::collections::HashMap<String, Vec<String>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut display_names: Vec<String> = Vec::new();
|
||||
for e in &profile_events {
|
||||
let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(content_json) = e.get("content").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(content_json) else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = v
|
||||
.get("display_name")
|
||||
.or_else(|| v.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let lower = name.to_ascii_lowercase();
|
||||
name_to_pubkeys
|
||||
.entry(lower)
|
||||
.or_default()
|
||||
.push(pubkey.to_string());
|
||||
display_names.push(name.to_string());
|
||||
}
|
||||
|
||||
// 4. Two-pass extraction: known multi-word names first, single-word fallback.
|
||||
let known_refs: Vec<&str> = display_names.iter().map(|s| s.as_str()).collect();
|
||||
let names = extract_at_mentions_with_known(content, &known_refs);
|
||||
|
||||
// 5. Look up matched names → pubkeys via the map we already built.
|
||||
names
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let pubkey = e.get("pubkey")?.as_str()?;
|
||||
let content_json = e.get("content")?.as_str()?;
|
||||
Some(MentionProfile {
|
||||
pubkey,
|
||||
content_json,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
match_names_to_profiles(&names, &entries)
|
||||
.flat_map(|n| name_to_pubkeys.get(n).into_iter().flatten())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fetch raw events for `filter` via the relay's `/query` endpoint.
|
||||
@@ -396,7 +425,13 @@ pub async fn cmd_send_message(
|
||||
|
||||
// Resolve @name mentions in the author-written body only — not the media markdown we
|
||||
// append above, which is derived from upload metadata and can't carry `@names`.
|
||||
let auto_resolved = resolve_content_mentions(client, &p.channel_id, &p.content).await;
|
||||
let mut auto_resolved = resolve_content_mentions(client, &p.channel_id, &p.content).await;
|
||||
|
||||
// NIP-27: also extract nostr:npub1… inline references (skipping code regions)
|
||||
let stripped = strip_code_regions(&p.content);
|
||||
let uri_pubkeys = extract_nostr_uris(&stripped);
|
||||
merge_mentions(&mut auto_resolved, &uri_pubkeys, MENTION_CAP);
|
||||
|
||||
let mention_refs: Vec<&str> = auto_resolved.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let builder = match p.kind {
|
||||
@@ -704,7 +739,9 @@ pub async fn dispatch(
|
||||
mod tests {
|
||||
use super::{find_root_from_tags, parse_member_pubkeys};
|
||||
use serde_json::json;
|
||||
use sprout_sdk::mentions::{extract_at_names, match_names_to_profiles, MentionProfile};
|
||||
use sprout_sdk::mentions::{
|
||||
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
|
||||
};
|
||||
|
||||
const ID_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const ID_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
@@ -834,6 +871,53 @@ mod tests {
|
||||
assert_eq!(resolved, vec![PK_VALID_A, PK_VALID_C]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_pipeline_resolves_multiword_display_names() {
|
||||
let profile_events: Vec<serde_json::Value> = vec![
|
||||
json!({
|
||||
"pubkey": PK_VALID_A,
|
||||
"content": r#"{"display_name":"Will Pfleger"}"#,
|
||||
}),
|
||||
json!({
|
||||
"pubkey": PK_VALID_B,
|
||||
"content": r#"{"display_name":"Alice"}"#,
|
||||
}),
|
||||
];
|
||||
|
||||
// Simulate the single-parse pipeline from resolve_content_mentions.
|
||||
let mut name_to_pubkeys: std::collections::HashMap<String, Vec<String>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut display_names: Vec<String> = Vec::new();
|
||||
for e in &profile_events {
|
||||
let pubkey = e.get("pubkey").unwrap().as_str().unwrap();
|
||||
let content_json = e.get("content").unwrap().as_str().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(content_json).unwrap();
|
||||
let name = v
|
||||
.get("display_name")
|
||||
.or_else(|| v.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap();
|
||||
let lower = name.to_ascii_lowercase();
|
||||
name_to_pubkeys
|
||||
.entry(lower)
|
||||
.or_default()
|
||||
.push(pubkey.to_string());
|
||||
display_names.push(name.to_string());
|
||||
}
|
||||
|
||||
let known_refs: Vec<&str> = display_names.iter().map(|s| s.as_str()).collect();
|
||||
let names = extract_at_mentions_with_known("hey @Will Pfleger and @alice!", &known_refs);
|
||||
assert_eq!(names, vec!["will pfleger", "alice"]);
|
||||
|
||||
let resolved: Vec<String> = names
|
||||
.iter()
|
||||
.flat_map(|n| name_to_pubkeys.get(n).into_iter().flatten())
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(resolved, vec![PK_VALID_A, PK_VALID_B]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_pipeline_returns_empty_when_no_at_names() {
|
||||
// Sanity: no `@names` in body → no profile match attempt needed.
|
||||
|
||||
@@ -372,8 +372,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Note: `extract_at_names`, `merge_mentions`, and `normalize_mention_pubkeys`
|
||||
// moved to `sprout_sdk::mentions` and are tested there.
|
||||
// Note: `extract_at_names`, `extract_at_mentions_with_known`, `merge_mentions`,
|
||||
// and `normalize_mention_pubkeys` live in `sprout_sdk::mentions` and are tested there.
|
||||
|
||||
// --- parse_event_id ---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user