diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs index 53643070c..4d8f90ab2 100644 --- a/crates/sprout-cli/src/commands/messages.rs +++ b/crates/sprout-cli/src/commands/messages.rs @@ -5,8 +5,9 @@ use uuid::Uuid; use crate::client::SproutClient; use crate::error::CliError; use crate::validate::{ - infer_language, normalize_mention_pubkeys, percent_encode, read_or_stdin, truncate_diff, - validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, + extract_at_names, infer_language, merge_mentions, normalize_mention_pubkeys, percent_encode, + read_or_stdin, truncate_diff, validate_content_size, validate_hex64, validate_uuid, + MAX_DIFF_BYTES, }; // --------------------------------------------------------------------------- @@ -60,6 +61,39 @@ async fn resolve_channel_id(client: &SproutClient, event_id: &str) -> Result Vec { + let names = extract_at_names(content); + if names.is_empty() { + return vec![]; + } + let body = client + .get_raw(&format!("/api/channels/{channel_id}/members")) + .await + .unwrap_or_default(); + let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + let Some(members) = parsed["members"].as_array() else { + return vec![]; + }; + let mut pubkeys = Vec::new(); + for m in members { + let Some(dn) = m["display_name"].as_str() else { + continue; + }; + if names.iter().any(|n| n.eq_ignore_ascii_case(dn)) { + if let Some(pk) = m["pubkey"].as_str() { + pubkeys.push(pk.to_ascii_lowercase()); + } + } + } + pubkeys +} + // --------------------------------------------------------------------------- // Read commands (unchanged) // --------------------------------------------------------------------------- @@ -157,9 +191,11 @@ pub async fn cmd_send_message( None }; - // Normalize mentions - let normalized: Vec = normalize_mention_pubkeys(mentions, ""); - let mention_refs: Vec<&str> = normalized.iter().map(|s| s.as_str()).collect(); + // Normalize explicit mentions, then merge auto-resolved up to SDK cap of 50. + let mut merged: Vec = normalize_mention_pubkeys(mentions, ""); + let auto_resolved = resolve_content_mentions(client, channel_id, content).await; + merge_mentions(&mut merged, &auto_resolved, 50); + let mention_refs: Vec<&str> = merged.iter().map(|s| s.as_str()).collect(); let builder = sprout_sdk::build_message( channel_uuid, diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs index aa3920683..2c9c4dbcc 100644 --- a/crates/sprout-cli/src/validate.rs +++ b/crates/sprout-cli/src/validate.rs @@ -115,6 +115,65 @@ pub fn infer_language(file_path: &str) -> Option { 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 { + if content.is_empty() || !content.contains('@') { + return vec![]; + } + let mut names: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let chars: Vec = 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, 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 { let sender = sender_pubkey.to_ascii_lowercase(); @@ -335,6 +394,32 @@ 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] @@ -379,4 +464,30 @@ mod tests { 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 = (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 = (0..50).map(|i| format!("{i:064}")).collect(); + merge_mentions(&mut m, &["extra".into()], 50); + assert_eq!(m.len(), 50); + } } diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 1a23fa5f9..c3ff2ead7 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -71,6 +71,82 @@ fn find_root_from_tags(tags: &serde_json::Value) -> Option { /// 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 { + if content.is_empty() || !content.contains('@') { + return vec![]; + } + let mut names: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let chars: Vec = 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. +async fn resolve_content_mentions( + client: &RelayClient, + channel_id: &str, + content: &str, +) -> Vec { + let names = extract_at_names(content); + if names.is_empty() { + return vec![]; + } + let body = client + .get(&format!("/api/channels/{channel_id}/members")) + .await + .unwrap_or_default(); + let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + let Some(members) = parsed["members"].as_array() else { + return vec![]; + }; + let mut pubkeys = Vec::new(); + for m in members { + let Some(dn) = m["display_name"].as_str() else { + continue; + }; + if names.iter().any(|n| n.eq_ignore_ascii_case(dn)) { + if let Some(pk) = m["pubkey"].as_str() { + pubkeys.push(pk.to_ascii_lowercase()); + } + } + } + pubkeys +} + /// Parameters for the `send_message` tool. #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct SendMessageParams { @@ -757,13 +833,32 @@ Default kind is 9 (stream message)." let kind_num = p .kind .unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE as u16); - let mention_refs: Vec<&str> = p + // Collect explicit pubkeys, dedup case-insensitively. + let mut seen = std::collections::HashSet::new(); + let mut mentions: Vec = p .mention_pubkeys .as_deref() .unwrap_or(&[]) .iter() - .map(String::as_str) + .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. + 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; + } + } + + let mention_refs: Vec<&str> = mentions.iter().map(String::as_str).collect(); let broadcast = p.broadcast_to_channel.unwrap_or(false); // Build the event builder via SDK, routing by kind. @@ -2431,6 +2526,34 @@ mod tests { assert_eq!(parsed.event_id, params.event_id); 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()); + } } #[cfg(test)]