mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: auto-resolve @mentions in MCP server and CLI (#256)
This commit is contained in:
@@ -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<Uui
|
||||
)))
|
||||
}
|
||||
|
||||
/// 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: &SproutClient,
|
||||
channel_id: &str,
|
||||
content: &str,
|
||||
) -> Vec<String> {
|
||||
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<String> = 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<String> = 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,
|
||||
|
||||
@@ -115,6 +115,65 @@ 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();
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,82 @@ 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.
|
||||
async fn resolve_content_mentions(
|
||||
client: &RelayClient,
|
||||
channel_id: &str,
|
||||
content: &str,
|
||||
) -> Vec<String> {
|
||||
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<String> = 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)]
|
||||
|
||||
Reference in New Issue
Block a user