diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..a6f5d5740a 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -148,6 +148,37 @@ fn resolve_mention_pubkeys(text: &str, members: &[(String, String)]) -> Vec Result, ActionSinkError> { + let mut tags = vec![ + Tag::parse(["actor", actor_pubkey]) + .map_err(|e| ActionSinkError::EventBuild(format!("actor tag: {e}")))?, + Tag::parse(["h", channel_id]) + .map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?, + Tag::parse(["buzz:workflow", "true"]) + .map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?, + ]; + + for mentioned in mentioned_pubkeys { + tags.push( + Tag::parse(["p", mentioned]) + .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, + ); + } + + Ok(tags) +} + /// Relay-side action sink — executes workflow side-effects directly. /// /// Holds a **weak** reference to `AppState` to avoid an `Arc` reference cycle: @@ -252,24 +283,14 @@ impl ActionSink for RelayActionSink { // 3. Build kind:9 Nostr event // - Signed by relay keypair (event.pubkey = relay pubkey) - // - `p` tag attributes the message to the workflow owner + // - `actor` tag attributes the message to the workflow owner // - `h` tag scopes to the channel (NIP-29, canonical UUID) // - `buzz:workflow` tag prevents recursive workflow triggering // - one `p` tag per `@Name` that resolves to a channel member, // so mentioned agents are woken (wake is `p`-tag gated) - let mut tags = vec![ - Tag::parse(["p", &author_pubkey_hex]) - .map_err(|e| ActionSinkError::EventBuild(format!("p tag: {e}")))?, - Tag::parse(["h", &channel_id_canonical]) - .map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?, - Tag::parse(["buzz:workflow", "true"]) - .map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?, - ]; - - // Resolve `@Name` mentions to channel-member pubkeys and append a - // `p` tag for each (skipping the author, already tagged above). A - // resolution failure must not drop the message, so log and proceed - // with the base tags. + // Resolve `@Name` mentions to channel-member pubkeys. The owner is + // intentionally eligible here: an explicit owner mention should + // notify them even though ownership alone should not. let members = state .db .get_members(tenant.community(), channel_uuid) @@ -288,15 +309,12 @@ impl ActionSink for RelayActionSink { Some((name, nostr::PublicKey::from_slice(&u.pubkey).ok()?.to_hex())) }) .collect(); - for mentioned in resolve_mention_pubkeys(&text, &named_members) { - if mentioned == author_pubkey_hex { - continue; - } - tags.push( - Tag::parse(["p", &mentioned]) - .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, - ); - } + let mentioned_pubkeys = resolve_mention_pubkeys(&text, &named_members); + let tags = build_message_tags( + &channel_id_canonical, + &author_pubkey_hex, + &mentioned_pubkeys, + )?; let kind = Kind::from(KIND_STREAM_MESSAGE as u16); let event = EventBuilder::new(kind, &text) @@ -377,6 +395,37 @@ mod tests { std::iter::repeat_n(nibble, 64).collect() } + fn tag_values<'a>(tags: &'a [Tag], name: &str) -> Vec<&'a str> { + tags.iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some(name)) + .filter_map(|tag| tag.as_slice().get(1).map(String::as_str)) + .collect() + } + + #[test] + fn workflow_owner_is_actor_without_implicit_mention() { + let actor = pk('a'); + let tags = build_message_tags("36411e44-0e2d-4cfe-bd6e-567eb169db9f", &actor, &[]) + .expect("valid workflow message tags"); + + assert_eq!(tag_values(&tags, "actor"), vec![actor.as_str()]); + assert!(tag_values(&tags, "p").is_empty()); + } + + #[test] + fn explicitly_mentioned_owner_is_actor_and_recipient_once() { + let actor = pk('a'); + let tags = build_message_tags( + "36411e44-0e2d-4cfe-bd6e-567eb169db9f", + &actor, + std::slice::from_ref(&actor), + ) + .expect("valid workflow message tags"); + + assert_eq!(tag_values(&tags, "actor"), vec![actor.as_str()]); + assert_eq!(tag_values(&tags, "p"), vec![actor.as_str()]); + } + #[test] fn resolves_exact_member_name() { let members = vec![m("Robby", &pk('a'))]; @@ -611,7 +660,7 @@ mod integration_tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn workflow_send_message_p_tags_mentioned_member() { + async fn workflow_send_message_separates_actor_from_mentioned_member() { let state = test_state().await; let author = nostr::Keys::generate(); @@ -698,14 +747,23 @@ mod integration_tests { .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("p")) .filter_map(|t| t.as_slice().get(1).map(|s| s.as_str())) .collect(); + let actor_tag_targets: Vec<&str> = stored + .event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("actor")) + .filter_map(|t| t.as_slice().get(1).map(|s| s.as_str())) + .collect(); - assert!( - p_tag_targets.contains(&author_hex.as_str()), - "author should still be attributed via p tag; got {p_tag_targets:?}" + assert_eq!( + actor_tag_targets, + vec![author_hex.as_str()], + "workflow owner should be attributed exactly once via actor tag" ); - assert!( - p_tag_targets.contains(&agent_hex.as_str()), - "mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}" + assert_eq!( + p_tag_targets, + vec![agent_hex.as_str()], + "only the explicitly mentioned member should receive a p tag" ); } } diff --git a/crates/buzz-workflow/src/action_sink.rs b/crates/buzz-workflow/src/action_sink.rs index 0c6002e74e..38faee0c3d 100644 --- a/crates/buzz-workflow/src/action_sink.rs +++ b/crates/buzz-workflow/src/action_sink.rs @@ -56,7 +56,8 @@ pub trait ActionSink: Send + Sync { /// - `channel_id`: UUID string of the target channel /// - `text`: message body (must not be empty/whitespace-only) /// - `author_pubkey`: hex-encoded pubkey of the workflow owner (used for - /// the `p` attribution tag; the relay keypair signs the event) + /// the non-notifying `actor` attribution tag; the relay keypair signs the + /// event) /// /// Returns the event ID hex string on success. fn send_message(