mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
refactor: extract shared @mention resolver into sprout-sdk (#580)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Dawn <dawn@sprout.local>
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
use nostr::EventId;
|
||||
use nostr::{EventId, PublicKey};
|
||||
use sprout_sdk::{DiffMeta, ThreadRef, VoteDirection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::SproutClient;
|
||||
use crate::error::CliError;
|
||||
use crate::validate::{
|
||||
extract_at_names, infer_language, merge_mentions, normalize_mention_pubkeys, read_or_stdin,
|
||||
truncate_diff, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
|
||||
infer_language, 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, merge_mentions, normalize_mention_pubkeys,
|
||||
MentionProfile, MENTION_CAP,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -130,8 +134,13 @@ async fn resolve_channel_id(client: &SproutClient, event_id: &str) -> Result<Uui
|
||||
)))
|
||||
}
|
||||
|
||||
/// Resolve @names in content against channel members (queried from channel metadata).
|
||||
/// Returns matching pubkeys. On any error, returns empty vec — never blocks a send.
|
||||
/// 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.
|
||||
async fn resolve_content_mentions(
|
||||
client: &SproutClient,
|
||||
channel_id: &str,
|
||||
@@ -141,27 +150,82 @@ async fn resolve_content_mentions(
|
||||
if names.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// Query channel metadata to get member list from p-tags
|
||||
let filter = serde_json::json!({
|
||||
// 1. Membership list (kind 39002 is parameterized-replaceable, addressed by `d` tag).
|
||||
let members_filter = serde_json::json!({
|
||||
"kinds": [39002],
|
||||
"#h": [channel_id]
|
||||
"#d": [channel_id],
|
||||
"limit": 1,
|
||||
});
|
||||
let raw = client.query(&filter).await.unwrap_or_default();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or_default();
|
||||
// Channel metadata is returned as an array of events
|
||||
let Some(events) = parsed.as_array() else {
|
||||
return vec![];
|
||||
let member_pubkeys = match fetch_member_pubkeys(client, &members_filter).await {
|
||||
Some(pks) if !pks.is_empty() => pks,
|
||||
_ => return vec![],
|
||||
};
|
||||
let Some(event) = events.first() else {
|
||||
return vec![];
|
||||
|
||||
// 2. Profiles for those members (kind 0).
|
||||
let profiles_filter = serde_json::json!({
|
||||
"kinds": [0],
|
||||
"authors": member_pubkeys,
|
||||
"limit": member_pubkeys.len(),
|
||||
});
|
||||
let profile_events = match fetch_events(client, &profiles_filter).await {
|
||||
Some(v) => v,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
// 3. Hand the parsed profile content + pubkey to the shared matcher.
|
||||
let entries: Vec<MentionProfile<'_>> = profile_events
|
||||
.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)
|
||||
}
|
||||
|
||||
/// Fetch raw events for `filter` via the relay's `/query` endpoint.
|
||||
/// Returns `None` on any I/O or parse failure.
|
||||
async fn fetch_events(
|
||||
client: &SproutClient,
|
||||
filter: &serde_json::Value,
|
||||
) -> Option<Vec<serde_json::Value>> {
|
||||
let raw = client.query(filter).await.ok()?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
parsed.as_array().cloned()
|
||||
}
|
||||
|
||||
/// Extract member pubkeys (the `p` tag values) from a single 39002 event.
|
||||
async fn fetch_member_pubkeys(
|
||||
client: &SproutClient,
|
||||
filter: &serde_json::Value,
|
||||
) -> Option<Vec<String>> {
|
||||
let events = fetch_events(client, filter).await?;
|
||||
Some(parse_member_pubkeys(events.first()?))
|
||||
}
|
||||
|
||||
/// Parse member pubkeys from a kind 39002 event JSON value.
|
||||
///
|
||||
/// Filters and canonicalizes via `nostr::PublicKey::from_hex` — matching
|
||||
/// MCP's typed-Nostr behavior so both surfaces accept exactly the same
|
||||
/// pubkeys. Pure helper, split out for testing.
|
||||
fn parse_member_pubkeys(event: &serde_json::Value) -> Vec<String> {
|
||||
let Some(tags) = event.get("tags").and_then(|t| t.as_array()) else {
|
||||
return vec![];
|
||||
};
|
||||
// p-tags contain member pubkeys; we can't resolve display names without profiles
|
||||
// For now, return empty — @mention resolution requires profile lookup
|
||||
let _ = (tags, names);
|
||||
vec![]
|
||||
tags.iter()
|
||||
.filter_map(|t| {
|
||||
let arr = t.as_array()?;
|
||||
if arr.first()?.as_str()? != "p" {
|
||||
return None;
|
||||
}
|
||||
let pk = arr.get(1)?.as_str()?;
|
||||
PublicKey::from_hex(pk).ok().map(|k| k.to_hex())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -301,10 +365,12 @@ pub async fn cmd_send_message(client: &SproutClient, p: SendMessageParams) -> Re
|
||||
None
|
||||
};
|
||||
|
||||
// Normalize explicit mentions, then merge auto-resolved up to SDK cap of 50.
|
||||
let mut merged: Vec<String> = normalize_mention_pubkeys(&p.mentions, "");
|
||||
let auto_resolved = resolve_content_mentions(client, &p.channel_id, &final_content).await;
|
||||
merge_mentions(&mut merged, &auto_resolved, 50);
|
||||
// Normalize explicit mentions, then merge auto-resolved up to the SDK mention cap.
|
||||
// Auto-resolution scans the author-written body only — not the media markdown we
|
||||
// append above, which is derived from upload metadata and can't carry `@names`.
|
||||
let mut merged: Vec<String> = normalize_mention_pubkeys(&p.mentions, None);
|
||||
let auto_resolved = resolve_content_mentions(client, &p.channel_id, &p.content).await;
|
||||
merge_mentions(&mut merged, &auto_resolved, MENTION_CAP);
|
||||
let mention_refs: Vec<&str> = merged.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let builder = sprout_sdk::build_message(
|
||||
@@ -489,13 +555,20 @@ pub async fn cmd_vote_on_post(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::find_root_from_tags;
|
||||
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};
|
||||
|
||||
const ID_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const ID_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
const PUBKEY: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
|
||||
|
||||
// Three real pubkeys (lowercase 64-char hex) used by parse_member_pubkeys tests.
|
||||
// See the test's own comment on what `PublicKey::from_hex` actually validates.
|
||||
const PK_VALID_A: &str = "35c18ae273fccfaf80d629e20e7f8721b90499379addff533054acc2504c12b4";
|
||||
const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05";
|
||||
const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68";
|
||||
|
||||
#[test]
|
||||
fn root_marker_wins_over_reply_marker() {
|
||||
let tags = json!([
|
||||
@@ -561,4 +634,103 @@ mod tests {
|
||||
assert!(find_root_from_tags(&json!({})).is_none());
|
||||
assert!(find_root_from_tags(&json!(null)).is_none());
|
||||
}
|
||||
|
||||
// ── @mention resolution pipeline ────────────────────────────────────
|
||||
//
|
||||
// These tests don't hit the network — they prove that *given* the
|
||||
// events the relay returns, the CLI's parse + match wiring produces
|
||||
// the right pubkeys. The async I/O wrapper around them is one
|
||||
// straight line; the pure stages it composes are exercised here and
|
||||
// in sprout-sdk.
|
||||
|
||||
/// End-to-end (sans I/O): body text → extracted names → matched
|
||||
/// member pubkeys, using realistic 39002 + kind:0 event JSON.
|
||||
/// This is the regression guard for the previous stub that always
|
||||
/// returned `vec![]`.
|
||||
#[test]
|
||||
fn cli_pipeline_resolves_body_at_names_to_member_pubkeys() {
|
||||
// kind 39002 channel-members event with three members.
|
||||
let members_event = json!({
|
||||
"kind": 39002,
|
||||
"tags": [
|
||||
["d", "00000000-0000-0000-0000-000000000000"],
|
||||
["p", PK_VALID_A, "", "member"],
|
||||
["p", PK_VALID_B, "", "member"],
|
||||
["p", PK_VALID_C, "", "member"],
|
||||
],
|
||||
"content": "",
|
||||
});
|
||||
assert_eq!(
|
||||
parse_member_pubkeys(&members_event),
|
||||
vec![PK_VALID_A, PK_VALID_B, PK_VALID_C]
|
||||
);
|
||||
|
||||
// Three kind:0 profile events.
|
||||
let entries = vec![
|
||||
MentionProfile {
|
||||
pubkey: PK_VALID_A,
|
||||
content_json: r#"{"display_name":"Alice"}"#,
|
||||
},
|
||||
MentionProfile {
|
||||
pubkey: PK_VALID_B,
|
||||
content_json: r#"{"display_name":"Bob"}"#,
|
||||
},
|
||||
MentionProfile {
|
||||
pubkey: PK_VALID_C,
|
||||
content_json: r#"{"name":"Carol"}"#,
|
||||
},
|
||||
];
|
||||
|
||||
// Body mentions Alice and Carol (display_name fallback to `name`).
|
||||
let names = extract_at_names("hello @alice and @CAROL");
|
||||
let resolved = match_names_to_profiles(&names, &entries);
|
||||
assert_eq!(resolved, vec![PK_VALID_A, PK_VALID_C]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_pipeline_returns_empty_when_no_at_names() {
|
||||
// Sanity: no `@names` in body → no profile match attempt needed.
|
||||
let names = extract_at_names("plain message, no mentions");
|
||||
assert!(names.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_member_pubkeys_ignores_non_p_tags() {
|
||||
let event = json!({
|
||||
"tags": [
|
||||
["d", "channel-id"],
|
||||
["p", PK_VALID_A],
|
||||
["h", "channel-id"],
|
||||
["e", "some-event"],
|
||||
["p", PK_VALID_B, "wss://relay", "member"],
|
||||
],
|
||||
});
|
||||
assert_eq!(parse_member_pubkeys(&event), vec![PK_VALID_A, PK_VALID_B]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_member_pubkeys_handles_malformed_event() {
|
||||
assert!(parse_member_pubkeys(&json!({})).is_empty());
|
||||
assert!(parse_member_pubkeys(&json!({"tags": "not an array"})).is_empty());
|
||||
assert!(parse_member_pubkeys(&json!({"tags": [["p"]]})).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_member_pubkeys_filters_invalid_hex() {
|
||||
// `PublicKey::from_hex` rejects non-hex and wrong-length inputs and
|
||||
// canonicalizes hex case. (Note: it accepts any 64-char x-only hex
|
||||
// whose integer value is in field; it does not verify the point is
|
||||
// actually on the curve — same as MCP's behavior.)
|
||||
let pk_uppercase: String = PK_VALID_A.to_ascii_uppercase();
|
||||
let event = json!({
|
||||
"tags": [
|
||||
["p", PK_VALID_A], // valid, lowercase
|
||||
["p", pk_uppercase], // valid hex, canonicalized to lowercase
|
||||
["p", "too-short"], // length fail
|
||||
["p", "z".repeat(64)], // non-hex chars
|
||||
["p", "a".repeat(63)], // off-by-one length
|
||||
],
|
||||
});
|
||||
assert_eq!(parse_member_pubkeys(&event), vec![PK_VALID_A, PK_VALID_A]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,77 +141,6 @@ pub fn infer_language(file_path: &str) -> Option<String> {
|
||||
Some(lang.to_string())
|
||||
}
|
||||
|
||||
/// Extract @mention names from message content.
|
||||
/// Returns lowercased names found after `@` tokens.
|
||||
/// Only matches `@word` preceded by whitespace or start-of-string.
|
||||
/// Characters allowed in names: alphanumeric, `.`, `-`, `_`.
|
||||
pub fn extract_at_names(content: &str) -> Vec<String> {
|
||||
if content.is_empty() || !content.contains('@') {
|
||||
return vec![];
|
||||
}
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let chars: Vec<char> = content.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = 0;
|
||||
while i < len {
|
||||
if chars[i] == '@' {
|
||||
// Must be at start-of-string or preceded by whitespace
|
||||
let preceded_by_ws = i == 0 || chars[i - 1].is_ascii_whitespace();
|
||||
if preceded_by_ws && i + 1 < len {
|
||||
// Capture the name token: [a-zA-Z0-9._-]+
|
||||
let start = i + 1;
|
||||
let mut end = start;
|
||||
while end < len {
|
||||
let c = chars[end];
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
end += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if end > start {
|
||||
let name: String = chars[start..end].iter().collect();
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if seen.insert(lower.clone()) {
|
||||
names.push(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Merge auto-resolved pubkeys into an explicit mention list, up to `cap`.
|
||||
/// Explicit mentions have priority; auto-resolved are added only if not already present.
|
||||
pub fn merge_mentions(explicit: &mut Vec<String>, auto_resolved: &[String], cap: usize) {
|
||||
let budget = cap.saturating_sub(explicit.len());
|
||||
let mut added = 0usize;
|
||||
for pk in auto_resolved {
|
||||
if added >= budget {
|
||||
break;
|
||||
}
|
||||
if !explicit.contains(pk) {
|
||||
explicit.push(pk.clone());
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize mention pubkeys: lowercase, deduplicate, remove sender's own pubkey.
|
||||
pub fn normalize_mention_pubkeys(pubkeys: &[String], sender_pubkey: &str) -> Vec<String> {
|
||||
let sender = sender_pubkey.to_ascii_lowercase();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
pubkeys
|
||||
.iter()
|
||||
.map(|pk| pk.to_ascii_lowercase())
|
||||
.filter(|pk| pk != &sender)
|
||||
.filter(|pk| seen.insert(pk.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read content from a string value or stdin if the value is "-".
|
||||
pub fn read_or_stdin(value: &str) -> Result<String, CliError> {
|
||||
if value == "-" {
|
||||
@@ -420,102 +349,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// --- extract_at_names ---
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_matches() {
|
||||
assert_eq!(extract_at_names("hello @alice"), vec!["alice"]);
|
||||
assert_eq!(extract_at_names("@bob hello"), vec!["bob"]);
|
||||
assert_eq!(
|
||||
extract_at_names("@alice and @alice, meet @Bob"),
|
||||
vec!["alice", "bob"]
|
||||
);
|
||||
assert_eq!(extract_at_names("line1\n@tyler line2"), vec!["tyler"]);
|
||||
assert_eq!(
|
||||
extract_at_names("@john.doe @mary_jane @bob-smith"),
|
||||
vec!["john.doe", "mary_jane", "bob-smith"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_rejects() {
|
||||
assert!(extract_at_names("").is_empty());
|
||||
assert!(extract_at_names("no mentions").is_empty());
|
||||
assert!(extract_at_names("user@example.com").is_empty());
|
||||
assert!(extract_at_names("hello @ world").is_empty());
|
||||
assert!(extract_at_names("hello @").is_empty());
|
||||
}
|
||||
|
||||
// --- normalize_mention_pubkeys ---
|
||||
|
||||
#[test]
|
||||
fn normalize_mention_pubkeys_lowercases() {
|
||||
// 64 chars total
|
||||
let pk = "AABBCC".repeat(10) + "aabb";
|
||||
assert_eq!(pk.len(), 64);
|
||||
let result = normalize_mention_pubkeys(std::slice::from_ref(&pk), "sender");
|
||||
assert_eq!(result, vec![pk.to_ascii_lowercase()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_mention_pubkeys_removes_sender() {
|
||||
let sender = "a".repeat(64);
|
||||
let other = "b".repeat(64);
|
||||
let pubkeys = vec![sender.clone(), other.clone()];
|
||||
let result = normalize_mention_pubkeys(&pubkeys, &sender);
|
||||
assert_eq!(result, vec![other]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_mention_pubkeys_deduplicates() {
|
||||
let pk = "c".repeat(64);
|
||||
let pubkeys = vec![pk.clone(), pk.clone(), pk.clone()];
|
||||
let result = normalize_mention_pubkeys(&pubkeys, "sender");
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0], pk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_mention_pubkeys_removes_sender_case_insensitive() {
|
||||
let sender_lower = "d".repeat(64);
|
||||
let sender_upper = sender_lower.to_ascii_uppercase();
|
||||
let other = "e".repeat(64);
|
||||
let pubkeys = vec![sender_upper, other.clone()];
|
||||
let result = normalize_mention_pubkeys(&pubkeys, &sender_lower);
|
||||
assert_eq!(result, vec![other]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_mention_pubkeys_empty_input() {
|
||||
let result = normalize_mention_pubkeys(&[], "sender");
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
// --- merge_mentions ---
|
||||
|
||||
#[test]
|
||||
fn merge_mentions_dedup_and_cap() {
|
||||
// basic merge
|
||||
let mut m = vec!["a".into()];
|
||||
merge_mentions(&mut m, &["b".into()], 50);
|
||||
assert_eq!(m, ["a", "b"]);
|
||||
|
||||
// dedup: "a" already present
|
||||
let mut m = vec!["a".into()];
|
||||
merge_mentions(&mut m, &["a".into(), "b".into()], 50);
|
||||
assert_eq!(m, ["a", "b"]);
|
||||
|
||||
// cap: 49 explicit + 2 auto → only 1 added
|
||||
let mut m: Vec<String> = (0..49).map(|i| format!("{i:064}")).collect();
|
||||
merge_mentions(&mut m, &["x".into(), "y".into()], 50);
|
||||
assert_eq!(m.len(), 50);
|
||||
assert_eq!(m.last().unwrap(), "x");
|
||||
|
||||
// at cap: 50 explicit → nothing added
|
||||
let mut m: Vec<String> = (0..50).map(|i| format!("{i:064}")).collect();
|
||||
merge_mentions(&mut m, &["extra".into()], 50);
|
||||
assert_eq!(m.len(), 50);
|
||||
}
|
||||
// Note: `extract_at_names`, `merge_mentions`, and `normalize_mention_pubkeys`
|
||||
// moved to `sprout_sdk::mentions` and are tested there.
|
||||
|
||||
// ── validate_repo_id ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -105,73 +105,32 @@ fn find_root_from_tags(tags: &serde_json::Value) -> Option<String> {
|
||||
/// Maximum allowed content size for a single message (64 KiB).
|
||||
const MAX_CONTENT_BYTES: usize = 65_536;
|
||||
|
||||
/// Extract @mention names from message content.
|
||||
/// Returns lowercased names found after `@` tokens.
|
||||
/// Only matches `@word` preceded by whitespace or start-of-string.
|
||||
/// Characters allowed in names: alphanumeric, `.`, `-`, `_`.
|
||||
fn extract_at_names(content: &str) -> Vec<String> {
|
||||
if content.is_empty() || !content.contains('@') {
|
||||
return vec![];
|
||||
}
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let chars: Vec<char> = content.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = 0;
|
||||
while i < len {
|
||||
if chars[i] == '@' {
|
||||
// Must be at start-of-string or preceded by whitespace
|
||||
let preceded_by_ws = i == 0 || chars[i - 1].is_ascii_whitespace();
|
||||
if preceded_by_ws && i + 1 < len {
|
||||
// Capture the name token: [a-zA-Z0-9._-]+
|
||||
let start = i + 1;
|
||||
let mut end = start;
|
||||
while end < len {
|
||||
let c = chars[end];
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
end += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if end > start {
|
||||
let name: String = chars[start..end].iter().collect();
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if seen.insert(lower.clone()) {
|
||||
names.push(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Resolve @names in content against channel members.
|
||||
/// Returns matching pubkeys. On any error, returns empty vec — never blocks a send.
|
||||
/// Resolve `@name` mentions in `content` against this channel's members.
|
||||
///
|
||||
/// Performs the I/O (member + profile queries) and delegates parsing
|
||||
/// and matching to [`sprout_sdk::mentions`]. On any query failure or
|
||||
/// missing data, returns an empty vec — auto-tagging is best-effort
|
||||
/// and must never block a send.
|
||||
async fn resolve_content_mentions(
|
||||
client: &RelayClient,
|
||||
channel_id: &str,
|
||||
content: &str,
|
||||
) -> Vec<String> {
|
||||
let names = extract_at_names(content);
|
||||
let names = sprout_sdk::mentions::extract_at_names(content);
|
||||
if names.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// Query membership list (kind:39002) for this channel.
|
||||
// Query channel membership (kind 39002, addressed by `d` tag).
|
||||
let filter = Filter::new()
|
||||
.kind(k(kind::KIND_NIP29_GROUP_MEMBERS))
|
||||
.custom_tag(tag_d(), [channel_id])
|
||||
.limit(1);
|
||||
let events = match client.query(vec![filter]).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return vec![],
|
||||
let Ok(events) = client.query(vec![filter]).await else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(event) = events.first() else {
|
||||
return vec![];
|
||||
};
|
||||
// Members are in p-tags. We need profiles to match display names.
|
||||
let member_pubkeys: Vec<&str> = event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -181,7 +140,7 @@ async fn resolve_content_mentions(
|
||||
if member_pubkeys.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// Fetch profiles for members.
|
||||
// Fetch profiles for those members.
|
||||
let authors: Vec<nostr::PublicKey> = member_pubkeys
|
||||
.iter()
|
||||
.filter_map(|pk| nostr::PublicKey::from_hex(pk).ok())
|
||||
@@ -190,25 +149,20 @@ async fn resolve_content_mentions(
|
||||
.kind(k(kind::KIND_PROFILE))
|
||||
.authors(authors)
|
||||
.limit(member_pubkeys.len());
|
||||
let profiles = match client.query(vec![profile_filter]).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return vec![],
|
||||
let Ok(profiles) = client.query(vec![profile_filter]).await else {
|
||||
return vec![];
|
||||
};
|
||||
let mut pubkeys = Vec::new();
|
||||
for profile in &profiles {
|
||||
let Ok(content) = serde_json::from_str::<serde_json::Value>(&profile.content) else {
|
||||
continue;
|
||||
};
|
||||
let display_name = content
|
||||
.get("display_name")
|
||||
.or_else(|| content.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if names.iter().any(|n| n.eq_ignore_ascii_case(display_name)) {
|
||||
pubkeys.push(profile.pubkey.to_hex());
|
||||
}
|
||||
}
|
||||
pubkeys
|
||||
// Stable lowercase-hex strings for borrowing into MentionProfile.
|
||||
let hex_pubkeys: Vec<String> = profiles.iter().map(|p| p.pubkey.to_hex()).collect();
|
||||
let entries: Vec<sprout_sdk::mentions::MentionProfile<'_>> = profiles
|
||||
.iter()
|
||||
.zip(hex_pubkeys.iter())
|
||||
.map(|(p, pk)| sprout_sdk::mentions::MentionProfile {
|
||||
pubkey: pk.as_str(),
|
||||
content_json: p.content.as_str(),
|
||||
})
|
||||
.collect();
|
||||
sprout_sdk::mentions::match_names_to_profiles(&names, &entries)
|
||||
}
|
||||
|
||||
/// Parameters for the `send_message` tool.
|
||||
@@ -978,30 +932,16 @@ Default kind is 9 (stream message)."
|
||||
let kind_num = p
|
||||
.kind
|
||||
.unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE as u16);
|
||||
// Collect explicit pubkeys, dedup case-insensitively.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut mentions: Vec<String> = p
|
||||
.mention_pubkeys
|
||||
.as_deref()
|
||||
.unwrap_or(&[])
|
||||
.iter()
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.filter(|s| seen.insert(s.clone()))
|
||||
.collect();
|
||||
|
||||
// Auto-resolve @names in content and merge, up to SDK cap of 50.
|
||||
// Normalize explicit mentions, then merge auto-resolved `@names` from
|
||||
// the body up to the SDK mention cap.
|
||||
let explicit = p.mention_pubkeys.as_deref().unwrap_or(&[]);
|
||||
let mut mentions = sprout_sdk::mentions::normalize_mention_pubkeys(explicit, None);
|
||||
let auto = resolve_content_mentions(&self.client, &p.channel_id, &p.content).await;
|
||||
let budget = 50usize.saturating_sub(mentions.len());
|
||||
let mut added = 0usize;
|
||||
for pk in &auto {
|
||||
if added >= budget {
|
||||
break;
|
||||
}
|
||||
if !mentions.contains(pk) {
|
||||
mentions.push(pk.clone());
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
sprout_sdk::mentions::merge_mentions(
|
||||
&mut mentions,
|
||||
&auto,
|
||||
sprout_sdk::mentions::MENTION_CAP,
|
||||
);
|
||||
|
||||
let mention_refs: Vec<&str> = mentions.iter().map(String::as_str).collect();
|
||||
let broadcast = p.broadcast_to_channel.unwrap_or(false);
|
||||
@@ -3269,33 +3209,9 @@ mod tests {
|
||||
assert!(matches!(parsed.direction, VoteDirection::Up));
|
||||
}
|
||||
|
||||
// ── extract_at_names ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_matches() {
|
||||
// basic, start-of-string, dedup, newline, dots/hyphens/underscores
|
||||
assert_eq!(extract_at_names("Hello @Tyler"), vec!["tyler"]);
|
||||
assert_eq!(extract_at_names("@Tyler are you there?"), vec!["tyler"]);
|
||||
assert_eq!(
|
||||
extract_at_names("Hey @Alice and @alice, meet @Bob"),
|
||||
vec!["alice", "bob"]
|
||||
);
|
||||
assert_eq!(extract_at_names("first line\n@Tyler second"), vec!["tyler"]);
|
||||
assert_eq!(
|
||||
extract_at_names("@john.doe @mary_jane @bob-smith"),
|
||||
vec!["john.doe", "mary_jane", "bob-smith"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_rejects() {
|
||||
// empty, no @, email, bare @, @ at EOF
|
||||
assert!(extract_at_names("").is_empty());
|
||||
assert!(extract_at_names("no mentions").is_empty());
|
||||
assert!(extract_at_names("user@example.com").is_empty());
|
||||
assert!(extract_at_names("hello @ world").is_empty());
|
||||
assert!(extract_at_names("hello @").is_empty());
|
||||
}
|
||||
// Note: `extract_at_names` is now exercised by `sprout-sdk::mentions`
|
||||
// tests. The MCP-side I/O wrapper around it (`resolve_content_mentions`)
|
||||
// is covered by integration tests against a running relay.
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -68,7 +68,7 @@ fn thread_tags(thread_ref: &ThreadRef, tags: &mut Vec<Tag>) -> Result<(), SdkErr
|
||||
|
||||
/// Deduplicate and cap mentions, emitting p-tags.
|
||||
fn mention_tags(mentions: &[&str], tags: &mut Vec<Tag>) -> Result<(), SdkError> {
|
||||
if mentions.len() > 50 {
|
||||
if mentions.len() > crate::mentions::MENTION_CAP {
|
||||
return Err(SdkError::TooManyMentions);
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! No keys are held here. No network calls are made.
|
||||
|
||||
pub mod builders;
|
||||
pub mod mentions;
|
||||
pub mod nip_oa;
|
||||
|
||||
pub use builders::*;
|
||||
|
||||
330
crates/sprout-sdk/src/mentions.rs
Normal file
330
crates/sprout-sdk/src/mentions.rs
Normal file
@@ -0,0 +1,330 @@
|
||||
//! `@name` mention resolution helpers for Sprout chat messages.
|
||||
//!
|
||||
//! These helpers are **pure** — no network calls, no async. Callers query
|
||||
//! channel membership (kind 39002) and profile (kind 0) events themselves,
|
||||
//! then hand the profile JSON to [`match_names_to_profiles`].
|
||||
//!
|
||||
//! ## Pipeline
|
||||
//!
|
||||
//! ```text
|
||||
//! body text ──► extract_at_names ──► names: Vec<String>
|
||||
//! │
|
||||
//! members + profiles (queried by caller) │
|
||||
//! ▼
|
||||
//! match_names_to_profiles
|
||||
//! │
|
||||
//! explicit mentions ──► normalize ──► merge_mentions ──► p-tags
|
||||
//! ```
|
||||
//!
|
||||
//! See [`crate::mentions::MENTION_CAP`] for the hard upper bound on tags.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Maximum number of mention p-tags allowed on a single message.
|
||||
///
|
||||
/// Matches the cap enforced by Sprout message builders and the legacy MCP
|
||||
/// inline implementation.
|
||||
pub const MENTION_CAP: usize = 50;
|
||||
|
||||
/// A channel-member profile, as needed for name matching.
|
||||
///
|
||||
/// `pubkey` is the lowercase hex public key. `content_json` is the raw
|
||||
/// kind 0 event content (a JSON object). Borrowing the content avoids
|
||||
/// cloning what can be a sizable string.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MentionProfile<'a> {
|
||||
/// Lowercase hex public key.
|
||||
pub pubkey: &'a str,
|
||||
/// Raw kind 0 event `content` field (a JSON object).
|
||||
pub content_json: &'a str,
|
||||
}
|
||||
|
||||
/// Extract `@mention` names from message content.
|
||||
///
|
||||
/// Returns lowercased names found after `@` tokens. An `@name` only matches
|
||||
/// when the `@` is at start-of-string or preceded by an ASCII whitespace
|
||||
/// character — this excludes things like email addresses (`user@host`).
|
||||
///
|
||||
/// Allowed name characters: ASCII alphanumerics, `.`, `-`, `_`.
|
||||
/// Duplicates are removed; first-seen order is preserved.
|
||||
pub fn extract_at_names(content: &str) -> Vec<String> {
|
||||
if content.is_empty() || !content.contains('@') {
|
||||
return vec![];
|
||||
}
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let chars: Vec<char> = content.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = 0;
|
||||
while i < len {
|
||||
if chars[i] == '@' {
|
||||
let preceded_by_ws = i == 0 || chars[i - 1].is_ascii_whitespace();
|
||||
if preceded_by_ws && i + 1 < len {
|
||||
let start = i + 1;
|
||||
let mut end = start;
|
||||
while end < len {
|
||||
let c = chars[end];
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
end += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if end > start {
|
||||
let name: String = chars[start..end].iter().collect();
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if seen.insert(lower.clone()) {
|
||||
names.push(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Match extracted `@names` against channel-member profiles.
|
||||
///
|
||||
/// For each profile, parses its `content_json` and reads the
|
||||
/// `display_name` field (falling back to `name` **only if `display_name`
|
||||
/// is absent**, preserving the legacy MCP behavior). If the resulting
|
||||
/// name matches any extracted `@name` case-insensitively, the profile's
|
||||
/// pubkey is included.
|
||||
///
|
||||
/// Output order is **profile-input order**, not name-input order. When
|
||||
/// the [`MENTION_CAP`] is later applied during merging, this means the
|
||||
/// matched-pubkey set is stable with respect to query result ordering
|
||||
/// rather than text-position ordering.
|
||||
///
|
||||
/// Profiles whose `content_json` does not parse, or whose `display_name`
|
||||
/// (and `name`) are absent or non-string, are silently skipped.
|
||||
///
|
||||
/// Duplicate display names within a channel will produce multiple matches
|
||||
/// for a single `@name` — this is by design; resolution is bounded to
|
||||
/// channel members, so ambiguity is local to that channel.
|
||||
pub fn match_names_to_profiles(names: &[String], profiles: &[MentionProfile<'_>]) -> Vec<String> {
|
||||
if names.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for p in profiles {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(p.content_json) else {
|
||||
continue;
|
||||
};
|
||||
let name = value
|
||||
.get("display_name")
|
||||
.or_else(|| value.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if names.iter().any(|n| n.eq_ignore_ascii_case(name)) {
|
||||
out.push(p.pubkey.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Merge auto-resolved pubkeys into an explicit mention list, up to `cap`.
|
||||
///
|
||||
/// Explicit mentions have priority; auto-resolved entries are appended
|
||||
/// only if not already present (case-sensitive contains check — callers
|
||||
/// should normalize beforehand). Stops adding once `cap` is reached.
|
||||
pub fn merge_mentions(explicit: &mut Vec<String>, auto_resolved: &[String], cap: usize) {
|
||||
let budget = cap.saturating_sub(explicit.len());
|
||||
let mut added = 0usize;
|
||||
for pk in auto_resolved {
|
||||
if added >= budget {
|
||||
break;
|
||||
}
|
||||
if !explicit.contains(pk) {
|
||||
explicit.push(pk.clone());
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a list of mention pubkeys.
|
||||
///
|
||||
/// - Lowercases every entry.
|
||||
/// - Removes duplicates, preserving first-seen order.
|
||||
/// - When `sender_pubkey` is `Some(pk)`, removes any case-insensitive match
|
||||
/// against the sender's own pubkey (you don't @mention yourself).
|
||||
pub fn normalize_mention_pubkeys(pubkeys: &[String], sender_pubkey: Option<&str>) -> Vec<String> {
|
||||
let sender = sender_pubkey.map(|s| s.to_ascii_lowercase());
|
||||
let mut seen = HashSet::new();
|
||||
pubkeys
|
||||
.iter()
|
||||
.map(|pk| pk.to_ascii_lowercase())
|
||||
.filter(|pk| sender.as_deref() != Some(pk.as_str()))
|
||||
.filter(|pk| seen.insert(pk.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── extract_at_names ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_matches_basic() {
|
||||
assert_eq!(extract_at_names("hello @alice"), vec!["alice"]);
|
||||
assert_eq!(extract_at_names("@bob hello"), vec!["bob"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_lowercases_and_dedups() {
|
||||
assert_eq!(
|
||||
extract_at_names("@Alice and @alice, meet @Bob"),
|
||||
vec!["alice", "bob"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_allows_newline_prefix() {
|
||||
assert_eq!(extract_at_names("line1\n@tyler line2"), vec!["tyler"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_allows_punctuation_in_names() {
|
||||
assert_eq!(
|
||||
extract_at_names("@john.doe @mary_jane @bob-smith"),
|
||||
vec!["john.doe", "mary_jane", "bob-smith"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_rejects_email_and_empty() {
|
||||
assert!(extract_at_names("").is_empty());
|
||||
assert!(extract_at_names("no mentions").is_empty());
|
||||
assert!(extract_at_names("user@example.com").is_empty());
|
||||
assert!(extract_at_names("hello @ world").is_empty());
|
||||
assert!(extract_at_names("hello @").is_empty());
|
||||
}
|
||||
|
||||
// ── match_names_to_profiles ─────────────────────────────────────────
|
||||
|
||||
fn profile<'a>(pk: &'a str, json: &'a str) -> MentionProfile<'a> {
|
||||
MentionProfile {
|
||||
pubkey: pk,
|
||||
content_json: json,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_uses_display_name_case_insensitive() {
|
||||
let names = vec!["alice".to_string()];
|
||||
let profiles = vec![profile("pk1", r#"{"display_name":"Alice"}"#)];
|
||||
assert_eq!(match_names_to_profiles(&names, &profiles), vec!["pk1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_falls_back_to_name_only_if_display_name_absent() {
|
||||
let names = vec!["bob".to_string()];
|
||||
// display_name present but empty → skipped (no fallback to `name`).
|
||||
let p1 = profile("pk1", r#"{"display_name":"","name":"Bob"}"#);
|
||||
// display_name absent → falls back to `name`.
|
||||
let p2 = profile("pk2", r#"{"name":"Bob"}"#);
|
||||
let out = match_names_to_profiles(&names, &[p1, p2]);
|
||||
assert_eq!(out, vec!["pk2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_preserves_profile_input_order() {
|
||||
let names = vec!["alice".to_string(), "bob".to_string()];
|
||||
let profiles = vec![
|
||||
profile("pkB", r#"{"display_name":"Bob"}"#),
|
||||
profile("pkA", r#"{"display_name":"Alice"}"#),
|
||||
];
|
||||
// Output order tracks the profile slice, not the name slice.
|
||||
assert_eq!(
|
||||
match_names_to_profiles(&names, &profiles),
|
||||
vec!["pkB", "pkA"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_all_pubkeys_for_duplicate_display_names() {
|
||||
// Ambiguity is intentional and bounded to channel members.
|
||||
let names = vec!["alice".to_string()];
|
||||
let profiles = vec![
|
||||
profile("pk1", r#"{"display_name":"Alice"}"#),
|
||||
profile("pk2", r#"{"display_name":"alice"}"#),
|
||||
];
|
||||
assert_eq!(
|
||||
match_names_to_profiles(&names, &profiles),
|
||||
vec!["pk1", "pk2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_skips_unparseable_and_missing_fields() {
|
||||
let names = vec!["alice".to_string()];
|
||||
let profiles = vec![
|
||||
profile("pk1", "not json"),
|
||||
profile("pk2", "{}"),
|
||||
profile("pk3", r#"{"display_name":42}"#),
|
||||
profile("pk4", r#"{"display_name":"Alice"}"#),
|
||||
];
|
||||
assert_eq!(match_names_to_profiles(&names, &profiles), vec!["pk4"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_empty_names_returns_empty() {
|
||||
let profiles = vec![profile("pk1", r#"{"display_name":"Alice"}"#)];
|
||||
assert!(match_names_to_profiles(&[], &profiles).is_empty());
|
||||
}
|
||||
|
||||
// ── merge_mentions ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn merge_appends_new_and_skips_dupes() {
|
||||
let mut m = vec!["a".to_string()];
|
||||
merge_mentions(&mut m, &["a".into(), "b".into()], MENTION_CAP);
|
||||
assert_eq!(m, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_respects_cap() {
|
||||
let mut m: Vec<String> = (0..49).map(|i| format!("pk{i}")).collect();
|
||||
merge_mentions(&mut m, &["x".into(), "y".into()], MENTION_CAP);
|
||||
assert_eq!(m.len(), MENTION_CAP);
|
||||
assert_eq!(m.last().unwrap(), "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_noop_when_explicit_at_cap() {
|
||||
let mut m: Vec<String> = (0..MENTION_CAP).map(|i| format!("pk{i}")).collect();
|
||||
merge_mentions(&mut m, &["extra".into()], MENTION_CAP);
|
||||
assert_eq!(m.len(), MENTION_CAP);
|
||||
assert!(!m.contains(&"extra".to_string()));
|
||||
}
|
||||
|
||||
// ── normalize_mention_pubkeys ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normalize_lowercases_and_dedups() {
|
||||
let pks = vec!["ABC".to_string(), "abc".to_string(), "DEF".to_string()];
|
||||
assert_eq!(normalize_mention_pubkeys(&pks, None), vec!["abc", "def"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_removes_sender_case_insensitive() {
|
||||
let pks = vec!["ABC".to_string(), "DEF".to_string()];
|
||||
assert_eq!(normalize_mention_pubkeys(&pks, Some("abc")), vec!["def"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_with_none_sender_keeps_everything() {
|
||||
let pks = vec!["abc".to_string()];
|
||||
assert_eq!(normalize_mention_pubkeys(&pks, None), vec!["abc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_empty_input() {
|
||||
assert!(normalize_mention_pubkeys(&[], Some("anything")).is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user