Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 94 additions & 12 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use crate::validate::{
validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP,
contains_all_mention, extract_at_mentions_with_known, extract_nostr_uris,
resolve_all_mention_pubkeys, strip_code_regions, MENTION_CAP,
};

/// Extract the thread root event ID from a Nostr tag array.
Expand Down Expand Up @@ -220,11 +221,18 @@ async fn resolve_content_mentions(
}

let known_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
let names = extract_at_mentions_with_known(&stripped, &known_refs);
let names = filter_reserved_all_name(extract_at_mentions_with_known(&stripped, &known_refs));
if names.is_empty() {
return Ok((member_pubkeys, vec![]));
}
let resolved = resolve_names_to_pubkeys(&names, &name_to_pubkeys, has_explicit_mentions)?;
Ok((member_pubkeys, resolved))
}

fn filter_reserved_all_name(names: Vec<String>) -> Vec<String> {
names.into_iter().filter(|name| name != "all").collect()
}

fn normalize_explicit_mentions(values: &[String]) -> Result<Vec<String>, CliError> {
let mut normalized = Vec::new();
for value in values {
Expand All @@ -248,6 +256,20 @@ fn merge_message_mentions(
uri_pubkeys: &[String],
auto_resolved: &[String],
) -> Result<Vec<String>, CliError> {
let mentions = merge_message_mentions_unchecked(explicit, uri_pubkeys, auto_resolved);
if mentions.len() > MENTION_CAP {
return Err(CliError::Usage(format!(
"too many unique message mentions (max {MENTION_CAP})"
)));
}
Ok(mentions)
}

fn merge_message_mentions_unchecked(
explicit: &[String],
uri_pubkeys: &[String],
auto_resolved: &[String],
) -> Vec<String> {
let mut mentions = Vec::new();
for pubkey in explicit
.iter()
Expand All @@ -258,12 +280,18 @@ fn merge_message_mentions(
mentions.push(pubkey.clone());
}
}
if mentions.len() > MENTION_CAP {
return Err(CliError::Usage(format!(
"too many unique message mentions (max {MENTION_CAP})"
)));
}
Ok(mentions)
mentions
}

fn finalize_message_mentions(
content: &str,
existing_mentions: &[String],
member_pubkeys: &[String],
sender_pubkey: &str,
) -> Result<Vec<String>, CliError> {
resolve_all_mention_pubkeys(content, existing_mentions, member_pubkeys, sender_pubkey)
.map(|resolved| resolved.unwrap_or_else(|| existing_mentions.to_vec()))
.map_err(|error| CliError::Usage(error.to_string()))
}

fn missing_members(mentions: &[String], members: &[String]) -> Vec<String> {
Expand Down Expand Up @@ -596,7 +624,14 @@ pub async fn cmd_send_message(
let has_explicit_mentions = !explicit_mentions.is_empty() || !uri_pubkeys.is_empty();
let (member_pubkeys, auto_resolved) =
resolve_content_mentions(client, &p.channel_id, &p.content, has_explicit_mentions).await?;
let mention_pubkeys = merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?;
let base_mentions = if contains_all_mention(&p.content) {
merge_message_mentions_unchecked(&explicit_mentions, &uri_pubkeys, &auto_resolved)
} else {
merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?
};
let sender_pubkey = client.keys().public_key().to_hex();
let mention_pubkeys =
finalize_message_mentions(&p.content, &base_mentions, &member_pubkeys, &sender_pubkey)?;

let missing = missing_members(&mention_pubkeys, &member_pubkeys);
if !missing.is_empty() {
Expand Down Expand Up @@ -993,9 +1028,9 @@ pub async fn dispatch(
#[cfg(test)]
mod tests {
use super::{
event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions,
missing_members, normalize_explicit_mentions, parse_member_pubkeys,
resolve_names_to_pubkeys,
event_mention_pubkeys, filter_reserved_all_name, finalize_message_mentions,
find_root_from_tags, match_profiles_by_name, merge_message_mentions, missing_members,
normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
Expand Down Expand Up @@ -1183,6 +1218,53 @@ mod tests {
assert!(names.is_empty());
}

#[test]
fn cli_reserved_all_precedence_removes_profile_name_resolution() {
let names = vec!["all".to_string(), "alice".to_string()];
assert_eq!(filter_reserved_all_name(names), vec!["alice"]);
}

#[test]
fn cli_finalize_all_mentions_uses_sdk_resolution() {
let existing = vec![PK_VALID_A.to_ascii_uppercase()];
let members = vec![
PK_VALID_A.to_string(),
PK_VALID_B.to_string(),
PK_VALID_C.to_string(),
];

let mentions = finalize_message_mentions("ping @ALL", &existing, &members, PK_VALID_B)
.expect("group expansion should fit");

assert_eq!(mentions, vec![PK_VALID_A, PK_VALID_C]);
}

#[test]
fn cli_finalize_without_all_preserves_existing_mentions() {
let existing = vec![PK_VALID_A.to_string()];
let mentions = finalize_message_mentions(
"ping @alice",
&existing,
&[PK_VALID_B.to_string()],
PK_VALID_C,
)
.expect("ordinary mentions should be unchanged");
assert_eq!(mentions, existing);
}

#[test]
fn cli_finalize_all_mentions_reports_final_unique_count() {
let members: Vec<String> = (0..=buzz_sdk::mentions::MENTION_CAP)
.map(|index| format!("{index:064x}"))
.collect();

let error = finalize_message_mentions("@all", &[], &members, PK_VALID_A)
.expect_err("one over the cap should fail");

assert!(error.to_string().contains("51 unique recipients"));
assert!(error.to_string().contains("max 50"));
}

#[test]
fn parse_member_pubkeys_ignores_non_p_tags() {
let event = json!({
Expand Down
124 changes: 124 additions & 0 deletions crates/buzz-sdk/src/mentions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ use nostr::{FromBech32, PublicKey};
/// inline implementation.
pub const MENTION_CAP: usize = 50;

/// The final unique recipient set for an `@all` expansion exceeded
/// [`MENTION_CAP`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AllMentionOverflow {
/// Number of unique recipients after deduplication and sender exclusion.
pub count: usize,
/// Maximum number of mention p-tags allowed on one message.
pub max: usize,
}

impl std::fmt::Display for AllMentionOverflow {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"@all resolves to {} unique recipients (max {})",
self.count, self.max
)
}
}

impl std::error::Error for AllMentionOverflow {}

/// A channel-member profile, as needed for name matching.
///
/// `pubkey` is the lowercase hex public key. `content_json` is the raw
Expand Down Expand Up @@ -236,6 +258,46 @@ pub fn normalize_mention_pubkeys(pubkeys: &[String], sender_pubkey: Option<&str>
.collect()
}

/// Return whether `content` contains an active, exact `@all` mention.
///
/// Matching is case-insensitive and uses the same token boundaries as
/// [`extract_at_names`]. Inline and fenced code are removed before scanning.
pub fn contains_all_mention(content: &str) -> bool {
let stripped = strip_code_regions(content);
extract_at_names(&stripped).iter().any(|name| name == "all")
}

/// Expand an active `@all` mention into the final unique recipient set.
///
/// Existing explicit/name/URI mentions retain priority, followed by the fresh
/// channel membership order. Pubkeys are lowercased, duplicates and the sender
/// are removed, and the cap is checked only after that normalization. `None`
/// means `content` did not contain an active reserved token and callers should
/// keep their existing send path unchanged.
pub fn resolve_all_mention_pubkeys(
content: &str,
existing_mentions: &[String],
fresh_member_pubkeys: &[String],
sender_pubkey: &str,
) -> Result<Option<Vec<String>>, AllMentionOverflow> {
if !contains_all_mention(content) {
return Ok(None);
}

let mut combined = Vec::with_capacity(existing_mentions.len() + fresh_member_pubkeys.len());
combined.extend_from_slice(existing_mentions);
combined.extend_from_slice(fresh_member_pubkeys);
let recipients = normalize_mention_pubkeys(&combined, Some(sender_pubkey));
if recipients.len() > MENTION_CAP {
return Err(AllMentionOverflow {
count: recipients.len(),
max: MENTION_CAP,
});
}

Ok(Some(recipients))
}

/// Remove fenced code blocks and inline code spans from content.
///
/// Returns a copy of `content` with ` ```…``` ` blocks and `` `…` `` spans
Expand Down Expand Up @@ -817,4 +879,66 @@ mod tests {
let result = extract_nostr_uris(&content);
assert_eq!(result, vec![TEST_HEX1]);
}

#[test]
fn all_mention_detects_exact_token_case_insensitively() {
let cases: serde_json::Value =
serde_json::from_str(include_str!("../tests/fixtures/at_all_detection.json"))
.expect("shared detection corpus must be valid JSON");

for case in cases.as_array().expect("corpus root must be an array") {
let name = case["name"].as_str().expect("case must have a name");
let content = case["content"]
.as_str()
.expect("case must have string content");
let active = case["active"]
.as_bool()
.expect("case must have boolean active state");
assert_eq!(contains_all_mention(content), active, "{name}");
}
}

#[test]
fn all_mention_resolution_merges_normalizes_dedupes_and_excludes_sender() {
let existing = vec!["EXPLICIT".to_string(), "member-a".to_string()];
let members = vec![
"SENDER".to_string(),
"MEMBER-A".to_string(),
"member-b".to_string(),
"MEMBER-B".to_string(),
];

let resolved = resolve_all_mention_pubkeys("hello @ALL", &existing, &members, "sender")
.expect("resolution should fit")
.expect("reserved token should be present");

assert_eq!(resolved, vec!["explicit", "member-a", "member-b"]);
}

#[test]
fn all_mention_resolution_returns_none_when_token_is_absent() {
let resolved = resolve_all_mention_pubkeys(
"ordinary message",
&["explicit".to_string()],
&["member".to_string()],
"sender",
)
.expect("absence is not an error");
assert_eq!(resolved, None);
}

#[test]
fn all_mention_cap_counts_final_unique_recipients() {
let at_cap: Vec<String> = (0..MENTION_CAP).map(|i| format!("member-{i}")).collect();
let resolved = resolve_all_mention_pubkeys("@all", &[], &at_cap, "sender")
.expect("exactly the cap should pass")
.expect("reserved token should be present");
assert_eq!(resolved.len(), MENTION_CAP);

let over_cap: Vec<String> = (0..=MENTION_CAP).map(|i| format!("member-{i}")).collect();
let error = resolve_all_mention_pubkeys("@all", &[], &over_cap, "sender")
.expect_err("one over the cap should fail");
assert_eq!(error.count, MENTION_CAP + 1);
assert_eq!(error.max, MENTION_CAP);
}
}
47 changes: 47 additions & 0 deletions crates/buzz-sdk/tests/fixtures/at_all_detection.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
[
{
"name": "lowercase top-level token",
"content": "@all please review",
"active": true
},
{
"name": "uppercase token",
"content": "ping @ALL now",
"active": true
},
{
"name": "mixed-case token with punctuation",
"content": "ping @AlL, now",
"active": true
},
{
"name": "longer mention token",
"content": "@alligator",
"active": false
},
{
"name": "email-like text",
"content": "user@all",
"active": false
},
{
"name": "inline code only",
"content": "show `@all` literally",
"active": false
},
{
"name": "fenced code only",
"content": "before\n```text\n@all\n```\nafter",
"active": false
},
{
"name": "code example followed by active token",
"content": "`@all` then @all",
"active": true
},
{
"name": "ordinary plain text",
"content": "plain text",
"active": false
}
]
Loading