mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(cli): mirror Desktop mention delivery (#3330)
🤖 ## Summary Agent-authored mentions currently depend on matching visible `@Name` text to channel profiles. That makes notification delivery ambiguous when names collide or profiles change, and it encourages an extra post-send lookup just to confirm that the intended `p` tags were emitted. This change makes `buzz messages send` mirror Desktop's existing model: the message keeps a readable name in its content while the recipient pubkey is supplied separately. ```bash buzz messages send \ --channel <UUID> \ --content '@Alice could you review this?' \ --mention <alice-hex-or-npub> ``` `--mention` is repeatable. The CLI normalizes and deduplicates explicit pubkeys, merges them with any names it can resolve from the channel, and gives explicit identities priority under the existing 50-mention limit. Before uploading attachments, signing, or publishing, the command checks every resulting pubkey against the channel's current membership: - Members are mentioned normally. - Non-members stop the send and produce an actionable error. - `--allow-non-member-mentions` deliberately sends notifying `p` tags without adding anyone to the channel. Sending a message never changes membership. On success, `mention_pubkeys` is read from the exact signed event and returned with the relay response, so callers can verify the emitted recipients without another query. Managed-agent guidance teaches this single-command mention flow. Desktop mention behavior and the Nostr event schema are unchanged. Forum guidance is intentionally handled separately in #3596. ### Related issue None found. This replaces the earlier guidance-only approach in this PR with the underlying CLI behavior it required. ### Testing - `cargo test -p buzz-sdk` - `cargo test -p buzz-cli` - `cargo test -p buzz-acp` - `cargo test --manifest-path desktop/src-tauri/Cargo.toml` --------- Signed-off-by: npub1fdupjvyregj3z2tx7gx5x6py04zw89jm5usef9lyea4f3vcgh8qq9zgkdz <4b78193083ca25112966f20d4368247d44e3965ba7219497e4cf6a98b308b9c0@buzz.block.builderlab.xyz> Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub1fdupjvyregj3z2tx7gx5x6py04zw89jm5usef9lyea4f3vcgh8qq9zgkdz <4b78193083ca25112966f20d4368247d44e3965ba7219497e4cf6a98b308b9c0@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
npub1fdupjvyregj3z2tx7gx5x6py04zw89jm5usef9lyea4f3vcgh8qq9zgkdz
npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je
parent
66e7054928
commit
7adc46268d
@@ -40,6 +40,8 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat
|
||||
|
||||
- Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently.
|
||||
- Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery.
|
||||
- When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention <hex-or-npub>`. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed.
|
||||
- Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically.
|
||||
- Only `@mention` when you need their attention. Don't mention in narrative (e.g., "coordinating with Duncan" — no `@`). Naming someone while talking *about* them is narrative — "waiting on @morgan", "until @morgan brings work", "I'll loop in @morgan later". Drop the `@`. Every mention sends a notification; a mention nobody needs to act on is a false alarm.
|
||||
|
||||
### Callback Mentions
|
||||
|
||||
@@ -3625,6 +3625,22 @@ mod agent_draft_prompt_tests {
|
||||
assert!(prompt.contains("single-quoted shell strings preserve `\\n` literally"));
|
||||
assert!(prompt.contains("buzz messages send ... --content -"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_base_prompt_teaches_single_command_mentions_and_preflight() {
|
||||
let prompt = include_str!("base_prompt.md");
|
||||
assert!(prompt.contains("--mention <hex-or-npub>"));
|
||||
assert!(prompt.contains("every presentation-only name that should notify"));
|
||||
assert!(
|
||||
prompt.contains("permits unresolved or ambiguous `@Name` text as presentation-only")
|
||||
);
|
||||
assert!(prompt.contains("success JSON's `mention_pubkeys`"));
|
||||
assert!(prompt.contains("no follow-up verification command is needed"));
|
||||
assert!(prompt.contains("stops before sending"));
|
||||
assert!(prompt
|
||||
.contains("add them explicitly with `buzz channels add-member` only when authorized"));
|
||||
assert!(prompt.contains("never changes membership automatically"));
|
||||
}
|
||||
}
|
||||
|
||||
fn default_heartbeat_prompt() -> String {
|
||||
|
||||
@@ -9,8 +9,7 @@ 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, merge_mentions, strip_code_regions,
|
||||
MENTION_CAP,
|
||||
extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP,
|
||||
};
|
||||
|
||||
/// Extract the thread root event ID from a Nostr tag array.
|
||||
@@ -119,47 +118,82 @@ async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result<Uuid,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Resolve `@name` mentions in `content` against this channel's members.
|
||||
fn resolve_names_to_pubkeys(
|
||||
names: &[String],
|
||||
name_to_pubkeys: &std::collections::HashMap<String, Vec<String>>,
|
||||
has_explicit_mentions: bool,
|
||||
) -> Result<Vec<String>, CliError> {
|
||||
let mut resolved = Vec::new();
|
||||
for name in names {
|
||||
match name_to_pubkeys
|
||||
.get(name)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
[pubkey] => resolved.push(pubkey.clone()),
|
||||
[] if has_explicit_mentions => {}
|
||||
[] => {
|
||||
return Err(CliError::Usage(format!(
|
||||
"mention '@{name}' does not match a current channel member; retry with --mention <pubkey>"
|
||||
)))
|
||||
}
|
||||
_ if has_explicit_mentions => {}
|
||||
candidates => {
|
||||
return Err(CliError::Usage(format!(
|
||||
"mention '@{name}' is ambiguous; candidates: {}. Retry with --mention <pubkey>",
|
||||
candidates.join(", ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Resolve mention text against the channel membership snapshot.
|
||||
///
|
||||
/// 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.
|
||||
/// Returns both the current member set and uniquely name-resolved pubkeys.
|
||||
/// Lookup failures are fatal when mention processing is requested: publishing
|
||||
/// visible mention text without its intended `p` tag is worse than not sending.
|
||||
async fn resolve_content_mentions(
|
||||
client: &BuzzClient,
|
||||
channel_id: &str,
|
||||
content: &str,
|
||||
) -> Vec<String> {
|
||||
if !content.contains('@') {
|
||||
return vec![];
|
||||
has_explicit_mentions: bool,
|
||||
) -> Result<(Vec<String>, Vec<String>), CliError> {
|
||||
let stripped = strip_code_regions(content);
|
||||
if !stripped.contains('@') && !has_explicit_mentions {
|
||||
return Ok((vec![], vec![]));
|
||||
}
|
||||
|
||||
// 1. Membership list (kind 39002 is parameterized-replaceable, addressed by `d` tag).
|
||||
let members_filter = serde_json::json!({
|
||||
"kinds": [39002],
|
||||
"#d": [channel_id],
|
||||
"limit": 1,
|
||||
});
|
||||
let member_pubkeys = match fetch_member_pubkeys(client, &members_filter).await {
|
||||
Some(pks) if !pks.is_empty() => pks,
|
||||
_ => return vec![],
|
||||
};
|
||||
let member_pubkeys = fetch_member_pubkeys(client, &members_filter)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
CliError::Other("could not load channel membership for mention preflight".into())
|
||||
})?;
|
||||
|
||||
if !stripped.contains('@') {
|
||||
return Ok((member_pubkeys, 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![],
|
||||
};
|
||||
let profile_events = fetch_events(client, &profiles_filter)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
CliError::Other("could not load member profiles for mention resolution".into())
|
||||
})?;
|
||||
|
||||
// 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();
|
||||
let mut display_names = Vec::new();
|
||||
for e in &profile_events {
|
||||
let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
@@ -178,26 +212,82 @@ async fn resolve_content_mentions(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let lower = name.to_ascii_lowercase();
|
||||
name_to_pubkeys
|
||||
.entry(lower)
|
||||
.entry(name.to_ascii_lowercase())
|
||||
.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);
|
||||
let known_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
|
||||
let names = extract_at_mentions_with_known(&stripped, &known_refs);
|
||||
let resolved = resolve_names_to_pubkeys(&names, &name_to_pubkeys, has_explicit_mentions)?;
|
||||
Ok((member_pubkeys, resolved))
|
||||
}
|
||||
|
||||
// 5. Look up matched names → pubkeys via the map we already built.
|
||||
names
|
||||
fn normalize_explicit_mentions(values: &[String]) -> Result<Vec<String>, CliError> {
|
||||
let mut normalized = Vec::new();
|
||||
for value in values {
|
||||
let pubkey = PublicKey::parse(value.trim())
|
||||
.map_err(|_| CliError::Usage(format!("invalid --mention pubkey: {value}")))?;
|
||||
let hex = pubkey.to_hex();
|
||||
if !normalized.contains(&hex) {
|
||||
normalized.push(hex);
|
||||
}
|
||||
}
|
||||
if normalized.len() > MENTION_CAP {
|
||||
return Err(CliError::Usage(format!(
|
||||
"too many --mention values (max {MENTION_CAP})"
|
||||
)));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn merge_message_mentions(
|
||||
explicit: &[String],
|
||||
uri_pubkeys: &[String],
|
||||
auto_resolved: &[String],
|
||||
) -> Result<Vec<String>, CliError> {
|
||||
let mut mentions = Vec::new();
|
||||
for pubkey in explicit
|
||||
.iter()
|
||||
.flat_map(|n| name_to_pubkeys.get(n).into_iter().flatten())
|
||||
.chain(uri_pubkeys.iter())
|
||||
.chain(auto_resolved.iter())
|
||||
{
|
||||
if !mentions.contains(pubkey) {
|
||||
mentions.push(pubkey.clone());
|
||||
}
|
||||
}
|
||||
if mentions.len() > MENTION_CAP {
|
||||
return Err(CliError::Usage(format!(
|
||||
"too many unique message mentions (max {MENTION_CAP})"
|
||||
)));
|
||||
}
|
||||
Ok(mentions)
|
||||
}
|
||||
|
||||
fn missing_members(mentions: &[String], members: &[String]) -> Vec<String> {
|
||||
let members: std::collections::HashSet<&str> = members.iter().map(String::as_str).collect();
|
||||
mentions
|
||||
.iter()
|
||||
.filter(|pk| !members.contains(pk.as_str()))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn event_mention_pubkeys(event: &nostr::Event) -> Vec<String> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
let parts = tag.as_slice();
|
||||
(parts.first().map(String::as_str) == Some("p"))
|
||||
.then(|| parts.get(1).cloned())
|
||||
.flatten()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fetch raw events for `filter` via the relay's `/query` endpoint.
|
||||
/// Returns `None` on any I/O or parse failure.
|
||||
async fn fetch_events(
|
||||
@@ -478,6 +568,7 @@ pub struct SendMessageParams {
|
||||
pub reply_to: Option<String>,
|
||||
pub broadcast: bool,
|
||||
pub files: Vec<String>,
|
||||
pub mentions: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn cmd_send_message(
|
||||
@@ -495,6 +586,30 @@ pub async fn cmd_send_message(
|
||||
}
|
||||
let channel_uuid = parse_uuid(&p.channel_id)?;
|
||||
|
||||
let explicit_mentions = normalize_explicit_mentions(&p.mentions)?;
|
||||
let stripped = strip_code_regions(&p.content);
|
||||
let uri_pubkeys = extract_nostr_uris(&stripped);
|
||||
// Supplying any identity explicitly authorizes unresolved or ambiguous @Name text
|
||||
// as presentation-only, matching Desktop's separate visible-label and p-tag model.
|
||||
// Uniquely resolvable member names still add their own p-tags; callers must supply
|
||||
// every intended identity whose visible label cannot be resolved uniquely.
|
||||
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 missing = missing_members(&mention_pubkeys, &member_pubkeys);
|
||||
if !missing.is_empty() {
|
||||
return Err(CliError::Usage(
|
||||
serde_json::json!({
|
||||
"message": "mentioned pubkeys are not channel members; add them explicitly before retrying",
|
||||
"missing_member_pubkeys": missing,
|
||||
"add_member_command": format!("buzz channels add-member --channel {} --pubkey <pubkey> --role <member|bot>", p.channel_id),
|
||||
})
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Upload files and build imeta tags
|
||||
let mut media_tags: Vec<Vec<String>> = Vec::new();
|
||||
let mut media_content = String::new();
|
||||
@@ -526,16 +641,7 @@ pub async fn cmd_send_message(
|
||||
None
|
||||
};
|
||||
|
||||
// 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 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 mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect();
|
||||
|
||||
let builder = match p.kind {
|
||||
Some(45001) => {
|
||||
@@ -572,9 +678,17 @@ pub async fn cmd_send_message(
|
||||
};
|
||||
|
||||
let event = client.sign_event(builder)?;
|
||||
|
||||
let emitted_mentions = event_mention_pubkeys(&event);
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{}", normalize_write_response(&resp));
|
||||
let mut output: serde_json::Value = serde_json::from_str(&normalize_write_response(&resp))
|
||||
.unwrap_or_else(|_| serde_json::json!({ "response": resp }));
|
||||
if let Some(object) = output.as_object_mut() {
|
||||
object.insert(
|
||||
"mention_pubkeys".into(),
|
||||
serde_json::json!(emitted_mentions),
|
||||
);
|
||||
}
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -765,6 +879,7 @@ pub async fn dispatch(
|
||||
reply_to,
|
||||
broadcast,
|
||||
files,
|
||||
mentions,
|
||||
} => {
|
||||
cmd_send_message(
|
||||
client,
|
||||
@@ -775,6 +890,7 @@ pub async fn dispatch(
|
||||
reply_to,
|
||||
broadcast,
|
||||
files,
|
||||
mentions,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -876,7 +992,11 @@ pub async fn dispatch(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys};
|
||||
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,
|
||||
};
|
||||
use buzz_sdk::mentions::{
|
||||
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
|
||||
};
|
||||
@@ -1103,6 +1223,94 @@ mod tests {
|
||||
assert_eq!(parse_member_pubkeys(&event), vec![PK_VALID_A, PK_VALID_A]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_mentions_accept_hex_and_npub_and_deduplicate() {
|
||||
use nostr::ToBech32;
|
||||
let npub = nostr::PublicKey::from_hex(PK_VALID_A)
|
||||
.unwrap()
|
||||
.to_bech32()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
normalize_explicit_mentions(&[PK_VALID_A.into(), npub]).unwrap(),
|
||||
vec![PK_VALID_A]
|
||||
);
|
||||
assert!(normalize_explicit_mentions(&["not-a-key".into()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_mentions_authorize_presentation_text_without_name_resolution() {
|
||||
let names = vec!["renamed user".into()];
|
||||
let profiles = std::collections::HashMap::new();
|
||||
assert_eq!(
|
||||
resolve_names_to_pubkeys(&names, &profiles, true).unwrap(),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert!(resolve_names_to_pubkeys(&names, &profiles, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_mentions_authorize_ambiguous_presentation_text() {
|
||||
let names = vec!["alice".into()];
|
||||
let profiles = std::collections::HashMap::from([(
|
||||
"alice".into(),
|
||||
vec![PK_VALID_A.into(), PK_VALID_B.into()],
|
||||
)]);
|
||||
assert_eq!(
|
||||
resolve_names_to_pubkeys(&names, &profiles, true).unwrap(),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
let error = resolve_names_to_pubkeys(&names, &profiles, false).unwrap_err();
|
||||
assert!(error.to_string().contains(PK_VALID_A));
|
||||
assert!(error.to_string().contains(PK_VALID_B));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_mentions_make_all_at_names_presentation_only() {
|
||||
let names = vec!["alice".into(), "bob".into()];
|
||||
let profiles = std::collections::HashMap::from([("alice".into(), vec![PK_VALID_A.into()])]);
|
||||
assert_eq!(
|
||||
resolve_names_to_pubkeys(&names, &profiles, true).unwrap(),
|
||||
vec![PK_VALID_A]
|
||||
);
|
||||
assert!(resolve_names_to_pubkeys(&names, &profiles, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_mention_union_errors_instead_of_truncating() {
|
||||
let explicit: Vec<String> = (0..50).map(|i| format!("explicit-{i}")).collect();
|
||||
assert!(merge_message_mentions(&explicit, &[], &["resolved-bob".into()]).is_err());
|
||||
|
||||
let mut with_duplicate = explicit.clone();
|
||||
with_duplicate.push(explicit[0].clone());
|
||||
assert_eq!(
|
||||
merge_message_mentions(&with_duplicate, &[explicit[1].clone()], &[])
|
||||
.unwrap()
|
||||
.len(),
|
||||
50
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_preflight_lists_only_missing_mentions() {
|
||||
assert_eq!(
|
||||
missing_members(
|
||||
&[PK_VALID_A.into(), PK_VALID_B.into()],
|
||||
&[PK_VALID_A.into()]
|
||||
),
|
||||
vec![PK_VALID_B]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_evidence_comes_from_signed_event_tags() {
|
||||
use nostr::{EventBuilder, Keys, Tag};
|
||||
let event = EventBuilder::text_note("hello")
|
||||
.tags(vec![Tag::parse(["p", PK_VALID_A]).unwrap()])
|
||||
.sign_with_keys(&Keys::generate())
|
||||
.unwrap();
|
||||
assert_eq!(event_mention_pubkeys(&event), vec![PK_VALID_A]);
|
||||
}
|
||||
|
||||
// ---- match_profiles_by_name (author resolution for `messages search --author`) ----
|
||||
|
||||
fn profile_event(
|
||||
|
||||
@@ -369,6 +369,9 @@ pub enum MessagesCmd {
|
||||
/// Attach file(s) — uploads and includes as imeta tags
|
||||
#[arg(long = "file")]
|
||||
files: Vec<String>,
|
||||
/// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity permits unresolved or ambiguous @Name text as presentation-only; uniquely resolved member names still notify.
|
||||
#[arg(long = "mention")]
|
||||
mentions: Vec<String>,
|
||||
},
|
||||
/// Send a code diff / patch to a channel
|
||||
SendDiff {
|
||||
|
||||
@@ -50,7 +50,7 @@ const NEST_AGENTS_VERSION: u32 = 4;
|
||||
|
||||
/// Template content version for SKILL.md.
|
||||
/// Bump this when changing `nest_skill.md` to trigger refresh on existing installs.
|
||||
const NEST_SKILL_VERSION: u32 = 4;
|
||||
const NEST_SKILL_VERSION: u32 = 5;
|
||||
|
||||
const BEGIN_MARKER: &str = "<!-- BEGIN BUZZ MANAGED";
|
||||
const END_MARKER: &str = "<!-- END BUZZ MANAGED -->";
|
||||
|
||||
@@ -29,6 +29,18 @@ fn init_nest_dir_prod_sets_buzz() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nest_skill_contains_safe_mention_workflow() {
|
||||
assert!(BUZZ_CLI_SKILL_MD.contains("--mention <hex-or-npub>"));
|
||||
assert!(BUZZ_CLI_SKILL_MD.contains("every presentation-only name that should notify"));
|
||||
assert!(BUZZ_CLI_SKILL_MD
|
||||
.contains("permits unresolved or ambiguous `@Name` text as presentation-only"));
|
||||
assert!(BUZZ_CLI_SKILL_MD.contains("signed event's `mention_pubkeys`"));
|
||||
assert!(BUZZ_CLI_SKILL_MD.contains("no follow-up verification command is needed"));
|
||||
assert!(BUZZ_CLI_SKILL_MD.contains("Add membership separately only when authorized"));
|
||||
assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nest_creates_all_dirs_and_agents_md() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -87,14 +87,11 @@ Write commands are unaffected. `--format json` (default) returns full fields.
|
||||
|
||||
## Communication Patterns
|
||||
|
||||
**Mentions that notify:** Use `@Name` directly in message content — the CLI auto-resolves channel members by name and adds the required p-tags. No `--mention` flag exists or is needed. `nostr:npub1…` inline references are also auto-resolved to p-tags without needing a flag.
|
||||
**Mentions that notify:** Keep readable `@Name` text in message content and, when intended pubkeys are known, pass the identities in the same send with repeatable `--mention <hex-or-npub>`. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add recipients. Include a pubkey for every presentation-only name that should notify. The CLI reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without explicit identities, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, then retry; sending never changes membership automatically.
|
||||
|
||||
```bash
|
||||
# ✅ Correct — notification delivered automatically
|
||||
buzz messages send --channel <UUID> --content "@Alice check this"
|
||||
|
||||
# Multiple mentions — same pattern
|
||||
buzz messages send --channel <UUID> --content "@Alice @Bob review please"
|
||||
buzz messages send --channel <UUID> \
|
||||
--content "@Alice check this" --mention <alice-pubkey>
|
||||
```
|
||||
|
||||
## DM Management
|
||||
|
||||
Reference in New Issue
Block a user