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:
Will Pfleger
2026-06-08 19:05:37 -04:00
committed by GitHub
parent 15f610dcd5
commit bfafdd46b2
8 changed files with 651 additions and 43 deletions

View File

@@ -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.

View File

@@ -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 ---