From 4b21a10c4b100bfccfa03ab4a0e85329c9880c9f Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Oct 2026 15:21:56 -0700 Subject: [PATCH] fix: don't notify workflow owners through their attribution tag (#8694) A workflow message names its owner in a marked p tag, ["p", owner, "", "owner"], for attribution. #p live, history and push matching, operator listeners, desktop, mobile and the iOS NSE treat that tag as attribution rather than a mention. A workflow that mentions its owner adds a plain p, so the owner is notified only when actually mentioned. NOSTR.md records the #p exception. Co-authored-by: studio Signed-off-by: Tom Brow --- NOSTR.md | 14 + crates/buzz-core/src/filter.rs | 54 +++- crates/buzz-db/src/runtime/mod.rs | 5 +- crates/buzz-db/src/store/operator_listener.rs | 28 +- crates/buzz-relay/src/push_runtime.rs | 268 +++++++++++++++++- crates/buzz-relay/src/workflow_sink.rs | 93 +++--- .../notifications/lib/shouldNotify.test.mjs | 19 ++ .../notifications/lib/shouldNotify.ts | 28 +- docs/buzz-push.md | 12 + .../Sources/BuzzPushKit/PushLease.swift | 20 +- .../BuzzPushKitTests/PushLeaseTests.swift | 27 ++ .../features/activity/activity_provider.dart | 8 +- .../unread_badge/is_high_priority_event.dart | 18 +- .../unread_badge/should_notify_for_event.dart | 10 +- mobile/lib/shared/mentions/mention_tags.dart | 20 ++ .../activity/activity_provider_test.dart | 46 +++ .../unread_badge/owner_attribution_test.dart | 40 +++ .../shared/mentions/mention_rules_test.dart | 49 ++++ 18 files changed, 665 insertions(+), 94 deletions(-) create mode 100644 mobile/test/features/channels/unread_badge/owner_attribution_test.dart diff --git a/NOSTR.md b/NOSTR.md index 61f3e49d3b..d47e5c479b 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -297,6 +297,20 @@ nak req -k 44100 -k 44101 --tag "p=" \ --auth --sec ws://localhost:3000 ``` +### Workflow Owner Attribution + +A workflow message is a kind:9 event that the relay signs and marks with +`["buzz:workflow", "true"]`. Its first `p` tag is `["p", , "", "owner"]`, +which names the workflow's owner for display. This tag is attribution, not a +mention, so `#p` filters do not match it. This is a deliberate exception to +NIP-01, applied to live subscriptions, history and push leases alike, so a +bounded `#p:[me]` read returns the messages that really mention you. A +workflow that mentions its owner also carries a plain `["p", ]`, which +`#p` matches as usual. To find a workflow's messages by owner, read the tags +of the channel's messages: `buzz:workflow-owner` also names the owner. A +marked `p` tag on any other event, for example a kind:39002 member list role, +matches `#p` normally. See `docs/buzz-push.md` for the push rules. + ### Sending Messages ```bash diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index 2a0ef391f2..73b7d6e56b 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -7,6 +7,22 @@ use nostr::Filter; use crate::channel::LEGACY_CHANNEL_TYPES; use crate::event::StoredEvent; +/// Whether `tag` attributes a workflow message to its owner instead of +/// mentioning them: `["p", , "", "owner"]` on an event marked +/// `["buzz:workflow", "true"]`. The relay names the owner that way so clients +/// can show who owns the message, and adds a plain `p` tag when the workflow +/// does mention its owner. `#p` matching, the mention index and other mention +/// consumers skip the attribution. The workflow marker keeps the rule off +/// other `p` tags with a fourth field, such as NIP-29 roster roles. An author +/// who marks a tag only withholds a mention they could have left out. +pub fn is_workflow_owner_attribution(event: &nostr::Event, tag: &nostr::Tag) -> bool { + matches!(tag.as_slice(), [name, _, _, marker, ..] if name == "p" && marker == "owner") + && event + .tags + .iter() + .any(|tag| tag.as_slice() == ["buzz:workflow", "true"]) +} + /// Returns `true` if the event matches any of the provided NIP-01 filters. pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { filters.iter().any(|f| filter_match_one(f, event)) @@ -138,7 +154,10 @@ fn filter_match_one(f: &Filter, ev: &StoredEvent) -> bool { ev.event .tags .iter() - .filter(|t| t.kind().to_string() == tag_key_str) + .filter(|t| { + t.kind().to_string() == tag_key_str + && !is_workflow_owner_attribution(&ev.event, t) + }) .filter_map(|t| t.content()) .any(|event_val| event_val == filter_val.as_str()) }); @@ -186,6 +205,39 @@ mod tests { StoredEvent::with_received_at(event, Utc::now(), None, true) } + #[test] + fn a_workflow_owner_attribution_does_not_match_p_filters() { + let owner = Keys::generate().public_key().to_hex(); + let by_p: Filter = serde_json::from_value(serde_json::json!({"#p": [owner]})).unwrap(); + let attribution: &[&str] = &["p", &owner, "", "owner"]; + let workflow: &[&str] = &["buzz:workflow", "true"]; + let mention: &[&str] = &["p", &owner]; + // (tags, matches `#p: [owner]`) + let cases: [(&[&[&str]], bool); 4] = [ + (&[attribution, workflow], false), + (&[attribution, workflow, mention], true), + // Without the workflow marker the fourth field is something + // else, such as a NIP-29 roster role, and the tag still matches. + (&[attribution], true), + (&[mention], true), + ]; + for (tags, matches) in cases { + let event = EventBuilder::new(Kind::Custom(9), "run") + .tags( + tags.iter() + .map(|tag| Tag::parse(tag.iter().copied()).unwrap()), + ) + .sign_with_keys(&Keys::generate()) + .expect("sign"); + let stored = StoredEvent::with_received_at(event, Utc::now(), None, true); + assert_eq!( + filters_match(std::slice::from_ref(&by_p), &stored), + matches, + "{tags:?}" + ); + } + } + #[test] fn a_notice_matches_only_filters_that_know_its_channel_type() { let relay = Keys::generate(); diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index e299e41f85..061076cae1 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -64,7 +64,10 @@ pub(crate) async fn insert_mentions_in_transaction( .iter() .filter_map(|tag| { let tag_vec = tag.as_slice(); - if tag_vec.len() >= 2 && tag_vec[0] == "p" { + if tag_vec.len() >= 2 + && tag_vec[0] == "p" + && !buzz_core::filter::is_workflow_owner_attribution(event, tag) + { Some(tag_vec[1].as_str()) } else { None diff --git a/crates/buzz-db/src/store/operator_listener.rs b/crates/buzz-db/src/store/operator_listener.rs index 18d9c1cca0..08983b36d2 100644 --- a/crates/buzz-db/src/store/operator_listener.rs +++ b/crates/buzz-db/src/store/operator_listener.rs @@ -70,7 +70,9 @@ fn event_targets(event: &Event) -> Vec> { let mut targets = Vec::new(); for tag in event.tags.iter() { let parts = tag.as_slice(); - if parts.first().map(String::as_str) != Some("p") { + if parts.first().map(String::as_str) != Some("p") + || buzz_core::filter::is_workflow_owner_attribution(event, tag) + { continue; } let Some(value) = parts.get(1) else { @@ -523,6 +525,30 @@ mod tests { assert!(!is_listener_mention_kind(1)); assert!(!is_listener_mention_kind(40003)); } + + #[test] + fn a_workflow_owner_attribution_is_not_a_listener_target() { + let owner = nostr::Keys::generate().public_key(); + let agent = nostr::Keys::generate().public_key(); + let targets = |mention_owner: bool| { + let mut tags = vec![ + nostr::Tag::parse(["p", &owner.to_hex(), "", "owner"]).unwrap(), + nostr::Tag::parse(["buzz:workflow", "true"]).unwrap(), + nostr::Tag::public_key(agent), + ]; + if mention_owner { + tags.push(nostr::Tag::public_key(owner)); + } + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "run") + .tags(tags) + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + event_targets(&event) + }; + let bytes = |pubkey: nostr::PublicKey| pubkey.to_bytes().to_vec(); + assert_eq!(targets(false), vec![bytes(agent)]); + assert_eq!(targets(true), vec![bytes(agent), bytes(owner)]); + } } #[cfg(test)] diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 7de9cdb9ca..d44245cd41 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -5,7 +5,9 @@ use std::{ time::{Duration, Instant}, }; -use buzz_core::filter::{filters_match, reader_authorized_for_event}; +use buzz_core::filter::{ + filters_match, is_workflow_owner_attribution, reader_authorized_for_event, +}; use chrono::{TimeDelta, Utc}; use nostr::Filter; use serde::{Deserialize, Serialize}; @@ -324,7 +326,10 @@ fn match_job( .event .tags .iter() - .filter(|t| t.kind().to_string() == "p") + .filter(|t| { + t.kind().to_string() == "p" + && !is_workflow_owner_attribution(&job.event.event, t) + }) .count() as u64; if ignored || sub @@ -888,6 +893,104 @@ mod tests { ); } + #[test] + fn owner_attribution_is_not_a_mention_but_an_owner_mention_is() { + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let channel = uuid::Uuid::new_v4(); + let author = |keys: &nostr::Keys| keys.public_key().to_bytes().to_vec(); + // The mobile lease shape: wake on my `p`, ignore what I authored. + let lease = |keys: &nostr::Keys| { + let me = keys.public_key().to_hex(); + buzz_db::push::MatchLease { + author: author(keys), + installation_id: "phone".into(), + generation: 1, + subscriptions: serde_json::json!([{ + "filter": {"kinds": [9], "#p": [me]}, + "class": "default", + "ignore": [{"kinds": [9], "authors": [me]}], + }]), + expires_at: Utc::now().timestamp() + 3600, + } + }; + let context = MatchContext { + leases: vec![lease(&owner), lease(&agent)], + memberships: [&owner, &agent] + .into_iter() + .map(|keys| (channel, author(keys))) + .collect(), + named_only: NamedOnlyChannels::from_typed([]), + }; + let relay = nostr::Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + let woken = |mentioned: &[&nostr::Keys]| -> Vec> { + let event = EventBuilder::new(Kind::Custom(9), "workflow output") + .tag(Tag::parse(["p", &owner_hex, "", "owner"]).unwrap()) + .tag(Tag::parse(["buzz:workflow", "true"]).unwrap()) + .tag(Tag::parse(["h", &channel.to_string()]).unwrap()) + .tags( + mentioned + .iter() + .map(|keys| Tag::public_key(keys.public_key())), + ) + .sign_with_keys(&relay) + .unwrap(); + let job = buzz_db::push::BatchedMatch { + event: buzz_core::StoredEvent::new(event, Some(channel)), + attempt: 1, + }; + match_job(&job, &context) + .unwrap() + .into_iter() + .map(|wake| wake.author) + .collect() + }; + + assert_eq!(woken(&[&agent]), vec![author(&agent)], "agent mentioned"); + assert_eq!(woken(&[]), Vec::>::new(), "no mention"); + assert_eq!( + woken(&[&owner, &agent]), + vec![author(&owner), author(&agent)], + "owner mentioned too" + ); + // The hellthread gate counts mentions, not the attribution: one + // mention passes `p_tags_max: 1`, two do not. + let gated = |mentioned: &[&nostr::Keys]| { + let me = agent.public_key().to_hex(); + let context = MatchContext { + leases: vec![buzz_db::push::MatchLease { + subscriptions: serde_json::json!([{ + "filter": {"kinds": [9], "#p": [me]}, + "class": "default", + "suppress": {"p_tags_max": 1}, + }]), + ..lease(&agent) + }], + memberships: [(channel, author(&agent))].into_iter().collect(), + named_only: NamedOnlyChannels::from_typed([]), + }; + let event = EventBuilder::new(Kind::Custom(9), "workflow output") + .tag(Tag::parse(["p", &owner_hex, "", "owner"]).unwrap()) + .tag(Tag::parse(["buzz:workflow", "true"]).unwrap()) + .tag(Tag::parse(["h", &channel.to_string()]).unwrap()) + .tags( + mentioned + .iter() + .map(|keys| Tag::public_key(keys.public_key())), + ) + .sign_with_keys(&relay) + .unwrap(); + let job = buzz_db::push::BatchedMatch { + event: buzz_core::StoredEvent::new(event, Some(channel)), + attempt: 1, + }; + match_job(&job, &context).unwrap().len() + }; + assert_eq!(gated(&[&agent]), 1, "one mention under the gate"); + assert_eq!(gated(&[&agent, &owner]), 0, "two mentions over the gate"); + } + async fn capture( State(seen): State>>>, OriginalUri(uri): OriginalUri, @@ -1023,6 +1126,167 @@ mod postgres_tests { use super::{load_match_context, match_job}; use nostr::{EventBuilder, Kind, Tag}; + /// Real workflow messages from `RelayActionSink` against mobile-shaped + /// leases. The owner's attribution `p` tag wakes no one; a mention of the + /// agent wakes only the agent; a mention of the owner wakes the owner. + /// Mutation: match push filters against the event's raw tags → RED (the + /// owner wakes for every message). + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn workflow_messages_wake_whom_they_mention_not_their_owner() { + use crate::handlers::group_state_postgres_tests::create_channel; + use buzz_workflow::action_sink::ActionSink; + + let state = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .expect("local Postgres and Redis"); + let host = format!("push-workflow-{}.local", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let tenant = buzz_core::TenantContext::resolved(community, &host); + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let channel = create_channel(&state, &tenant, &owner, &[]).await; + let owner_bytes = owner.public_key().to_bytes().to_vec(); + let agent_bytes = agent.public_key().to_bytes().to_vec(); + for (bytes, name) in [(&owner_bytes, "Kal"), (&agent_bytes, "Robby")] { + state + .db + .ensure_user(community, bytes) + .await + .expect("ensure user"); + state + .db + .update_user_profile(community, bytes, Some(name), None, None, None) + .await + .expect("name user"); + } + state + .db + .add_member( + community, + channel, + &agent_bytes, + buzz_core::channel::MemberRole::Bot, + Some(&owner_bytes), + ) + .await + .expect("add agent"); + + // Mobile's lease for each member: wake on my `p` or anything in the + // channel, and ignore what I authored. + for (n, keys) in [&owner, &agent].into_iter().enumerate() { + let me = keys.public_key().to_hex(); + let ignore = serde_json::json!([{"kinds": [9], "authors": [me]}]); + let subscriptions = serde_json::json!([ + {"filter": {"kinds": [9], "#p": [me]}, "class": "default", "ignore": ignore}, + ]); + assert_eq!( + buzz_db::push::replace_active_lease( + state.db.pool(), + community, + &keys.public_key().to_bytes(), + "phone", + buzz_db::push::LeaseVersion { + source_event_id: &[n as u8 + 1; 32], + source_created_at: 10, + generation: 1, + expires_at: i64::MAX / 2, + }, + buzz_db::push::ActiveLease { + endpoint_hash: &[n as u8 + 42; 32], + endpoint_grant: "opaque-grant", + max_class: "default", + subscriptions: &subscriptions, + }, + ) + .await + .expect("activate lease"), + buzz_db::push::ReplaceLeaseOutcome::Accepted + ); + } + + let mut jobs = Vec::new(); + for text in [ + "@Robby read the canvas and do what it says.", + "nothing to see", + "@Kal the canvas is done.", + ] { + let event_id = crate::workflow_sink::RelayActionSink::new(&state) + .send_message( + community, + &channel.to_string(), + text, + text, + &owner.public_key().to_hex(), + None, + ) + .await + .expect("send workflow message"); + let stored = state + .db + .get_event_by_id( + community, + nostr::EventId::from_hex(&event_id).unwrap().as_bytes(), + ) + .await + .expect("load message") + .expect("stored message"); + jobs.push(buzz_db::push::BatchedMatch { + event: stored, + attempt: 1, + }); + } + let batch = buzz_db::push::ClaimedMatchBatch { + community, + claim_id: uuid::Uuid::new_v4(), + jobs, + }; + let context = load_match_context(&state, &batch) + .await + .expect("match context"); + let woken: Vec>> = batch + .jobs + .iter() + .map(|job| { + match_job(job, &context) + .expect("match") + .into_iter() + .map(|wake| wake.author) + .collect() + }) + .collect(); + assert_eq!( + woken, + vec![vec![agent_bytes], vec![], vec![owner_bytes]], + "agent mention wakes the agent; no mention wakes no one; owner mention wakes the owner" + ); + + // A `#p` history read joins the mention index, which must agree with + // live matching: only the message that mentions the owner is theirs. + let owner_mentions = state + .db + .query_events(&buzz_db::EventQuery { + kinds: Some(vec![9]), + p_tag_hex: Some(owner.public_key().to_hex()), + ..buzz_db::EventQuery::for_community(community) + }) + .await + .expect("query owner mentions"); + assert_eq!( + owner_mentions + .iter() + .map(|stored| stored.event.id) + .collect::>(), + vec![batch.jobs[2].event.event.id], + "the mention index skips the owner attribution" + ); + } + /// The matcher on real channel rows: a member's lease that names no /// channel wakes for an ordinary channel's message but not for a system /// channel's. A member's lease that names the system channel by `#h` diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index e1719df9eb..9c80916cf6 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -155,7 +155,6 @@ fn append_workflow_mention_tags( rendered_text: &str, authored_text: &str, members: &[(String, String)], - author_pubkey_hex: &str, ) -> Result<(), ActionSinkError> { let rendered_mentions = resolve_mention_pubkeys(rendered_text, members); let authored_mentions: std::collections::HashSet = @@ -164,12 +163,10 @@ fn append_workflow_mention_tags( .collect(); for mentioned in rendered_mentions { - if mentioned != author_pubkey_hex { - tags.push( - Tag::parse(["p", &mentioned]) - .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, - ); - } + tags.push( + Tag::parse(["p", &mentioned]) + .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, + ); if authored_mentions.contains(&mentioned) { tags.push( Tag::parse(["buzz:workflow-mention", &mentioned]).map_err(|e| { @@ -304,19 +301,20 @@ 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 + // - first `p` tag attributes the message to the workflow owner; + // its `owner` marker keeps it from counting as a mention // - `h` tag scopes to the channel (NIP-29, canonical UUID) // - `buzz:workflow` tag prevents recursive workflow triggering // - `buzz:workflow-owner` lets harnesses apply the owner's // inbound-author policy after verifying the relay signature - // - one `p` tag for every resolved mention in the rendered output, - // preserving legacy wake/feed behavior + // - one plain `p` tag for every resolved mention in the rendered + // output, the owner included, preserving legacy wake/feed behavior // - one `buzz:workflow-mention` tag only when the same target was // named in the workflow owner's stored step template. This is the // authority-bearing provenance used by ACP; trigger-controlled // template substitutions cannot create it. let mut tags = vec![ - Tag::parse(["p", &author_pubkey_hex]) + Tag::parse(["p", &author_pubkey_hex, "", "owner"]) .map_err(|e| ActionSinkError::EventBuild(format!("p tag: {e}")))?, Tag::parse(["h", &channel_id_canonical]) .map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?, @@ -396,13 +394,7 @@ impl ActionSink for RelayActionSink { Some((name, nostr::PublicKey::from_slice(&u.pubkey).ok()?.to_hex())) }) .collect(); - append_workflow_mention_tags( - &mut tags, - &text, - &authored_text, - &named_members, - &author_pubkey_hex, - )?; + append_workflow_mention_tags(&mut tags, &text, &authored_text, &named_members)?; let kind = Kind::from(KIND_STREAM_MESSAGE as u16); let event = EventBuilder::new(kind, &text) @@ -690,14 +682,13 @@ mod tests { let first = pk('2'); let second = pk('3'); let members = vec![m("First", &first), m("Second", &second)]; - let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + let mut tags = vec![Tag::parse(["p", owner.as_str(), "", "owner"]).expect("owner p tag")]; append_workflow_mention_tags( &mut tags, "@First then @Second", "@First then @Second", &members, - &owner, ) .expect("append mention tags"); @@ -713,10 +704,8 @@ mod tests { values("buzz:workflow-mention"), vec![first.as_str(), second.as_str()] ); - assert_eq!( - values("p"), - vec![owner.as_str(), first.as_str(), second.as_str()] - ); + assert_eq!(tags[0].as_slice(), ["p", owner.as_str(), "", "owner"]); + assert_eq!(values("p"), vec![first.as_str(), second.as_str()]); } #[test] @@ -724,14 +713,13 @@ mod tests { let owner = pk('1'); let agent = pk('2'); let members = vec![m("Agent", &agent)]; - let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + let mut tags = vec![Tag::parse(["p", owner.as_str(), "", "owner"]).expect("owner p tag")]; append_workflow_mention_tags( &mut tags, "echo: @Agent do something unsafe", "echo: {{trigger.text}}", &members, - &owner, ) .expect("append mention tags"); @@ -748,42 +736,36 @@ mod tests { } #[test] - fn explicit_owner_mention_keeps_single_legacy_owner_tag() { + fn explicit_owner_mention_adds_a_plain_p_beside_the_owner_attribution() { let owner = pk('1'); let members = vec![m("Owner Agent", &owner)]; - let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + let attribution = Tag::parse(["p", owner.as_str(), "", "owner"]).expect("owner p tag"); + let mut tags = vec![attribution.clone()]; - append_workflow_mention_tags( - &mut tags, - "@Owner Agent run", - "@Owner Agent run", - &members, - &owner, - ) - .expect("append owner mention tag"); + append_workflow_mention_tags(&mut tags, "@Owner Agent run", "@Owner Agent run", &members) + .expect("append owner mention tag"); - let owner_p_tags = tags - .iter() - .filter(|tag| tag.as_slice() == ["p", owner.as_str()]) - .count(); - let owner_workflow_mentions = tags - .iter() - .filter(|tag| tag.as_slice() == ["buzz:workflow-mention", owner.as_str()]) - .count(); - assert_eq!(owner_p_tags, 1); - assert_eq!(owner_workflow_mentions, 1); + assert_eq!( + tags, + vec![ + attribution, + Tag::parse(["p", owner.as_str()]).expect("mention p tag"), + Tag::parse(["buzz:workflow-mention", owner.as_str()]).expect("mention tag"), + ], + "a mention of the owner must stay distinguishable from the attribution" + ); } #[test] fn no_mentions_adds_no_tags() { let owner = pk('1'); - let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + let mut tags = vec![Tag::parse(["p", owner.as_str(), "", "owner"]).expect("owner p tag")]; - append_workflow_mention_tags(&mut tags, "plain", "plain", &[], &owner) + append_workflow_mention_tags(&mut tags, "plain", "plain", &[]) .expect("append no mention tags"); assert_eq!(tags.len(), 1); - assert_eq!(tags[0].as_slice(), ["p", owner.as_str()]); + assert_eq!(tags[0].as_slice(), ["p", owner.as_str(), "", "owner"]); } } @@ -1030,6 +1012,19 @@ mod postgres_tests { .collect() }; + assert_eq!( + explicit.event.tags.first().map(|tag| tag.as_slice()), + Some( + [ + "p".to_owned(), + author_hex.clone(), + String::new(), + "owner".to_owned() + ] + .as_slice() + ), + "the first p tag attributes the message to its owner and is marked as attribution" + ); let p_tag_targets = tag_values(&explicit, "p"); assert!( p_tag_targets.contains(&author_hex), diff --git a/desktop/src/features/notifications/lib/shouldNotify.test.mjs b/desktop/src/features/notifications/lib/shouldNotify.test.mjs index 2642b9b204..e1ff612c8a 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.test.mjs +++ b/desktop/src/features/notifications/lib/shouldNotify.test.mjs @@ -326,3 +326,22 @@ test("isHighPriorityEventForUser returns false for event with no tags at all", ( const event = makeEvent([]); assert.equal(isHighPriorityEventForUser(event, PUBKEY), false); }); + +const workflowTag = () => ["buzz:workflow", "true"]; +const ownerTag = (pubkey) => ["p", pubkey, "", "owner"]; + +for (const [name, tags, addressed] of [ + ["workflow owner attribution", [ownerTag(PUBKEY), workflowTag()], false], + [ + "workflow owner attribution plus a mention", + [ownerTag(PUBKEY), workflowTag(), pTag(PUBKEY)], + true, + ], + ["owner-marked p tag without the workflow marker", [ownerTag(PUBKEY)], true], +]) { + test(`${name}: thread reply mention path and high priority`, () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID), ...tags]); + assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), addressed); + assert.equal(isHighPriorityEventForUser(event, PUBKEY), addressed); + }); +} diff --git a/desktop/src/features/notifications/lib/shouldNotify.ts b/desktop/src/features/notifications/lib/shouldNotify.ts index 9ceb9c2df8..d6ef14ff22 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.ts +++ b/desktop/src/features/notifications/lib/shouldNotify.ts @@ -4,15 +4,26 @@ import { isBroadcastReply, } from "@/features/messages/lib/threading"; +/** + * Whether the event's `p` tags address `currentPubkey`. A workflow message + * names its owner in `["p", owner, "", "owner"]` for attribution only; that + * tag is not a mention. A workflow that mentions its owner adds a plain `p`. + */ export function hasMentionForEvent( event: RelayEvent, currentPubkey: string, ): boolean { - return ( - currentPubkey.length > 0 && - event.tags.some( - (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey, - ) + if (currentPubkey.length === 0) { + return false; + } + const workflow = event.tags.some( + (tag) => tag[0] === "buzz:workflow" && tag[1] === "true", + ); + return event.tags.some( + (tag) => + tag[0] === "p" && + tag[1]?.toLowerCase() === currentPubkey && + !(workflow && tag[3] === "owner"), ); } @@ -79,12 +90,7 @@ export function isHighPriorityEventForUser( event: RelayEvent, currentPubkey: string, ): boolean { - if ( - currentPubkey.length > 0 && - event.tags.some( - (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey, - ) - ) { + if (hasMentionForEvent(event, currentPubkey)) { return true; } if (isBroadcastReply(event.tags)) { diff --git a/docs/buzz-push.md b/docs/buzz-push.md index 18e7d4cf96..1b7af8ef47 100644 --- a/docs/buzz-push.md +++ b/docs/buzz-push.md @@ -33,3 +33,15 @@ Do not change the relay advertisement until mobile builds that only parse `uuid-v4-lowercase` have drained. Those builds parse the descriptor before renewal, opt-out and revocation, so a new grammar would leave their existing leases active until expiry. + +## Workflow owner attribution + +The relay signs workflow messages. Their first `p` tag, +`["p", , "", "owner"]`, names the workflow owner so clients can show +who owns the message. On an event marked `["buzz:workflow", "true"]` that tag +is attribution, not a mention: `#p` filters (live, history through the mention +index, and push leases), operator listeners and the push hellthread gate all +skip it, so the owner is not notified by every run of their workflow. When a +workflow mentions its owner, the relay adds a plain `["p", ]`, which +notifies them like any mention. Mobile's Activity feed, unread mention priority +and in-app notification rule apply the same rule. diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift index 969520c2cd..5358ba3e3b 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift @@ -132,16 +132,30 @@ public enum PushLeaseMatcher { } extension VerifiedNostrEvent { + /// A relay-signed workflow message names its owner in + /// `["p", owner, "", "owner"]` for attribution, not as a mention. Like the + /// relay, `p` matching and counting skip that tag on workflow messages. + private var isWorkflow: Bool { + tags.contains { $0.count >= 2 && $0[0] == "buzz:workflow" && $0[1] == "true" } + } + + private func countsAsTag(_ tag: [String], named name: String, workflow: Bool) -> Bool { + guard tag.count >= 2 && tag[0] == name else { return false } + return !(workflow && name == "p" && tag.count >= 4 && tag[3] == "owner") + } + public func tagCount(named name: String) -> Int { - tags.reduce(into: 0) { count, tag in - if tag.count >= 2 && tag[0] == name { count += 1 } + let workflow = isWorkflow + return tags.reduce(into: 0) { count, tag in + if countsAsTag(tag, named: name, workflow: workflow) { count += 1 } } } public func hasAnyTag(named name: String, values: [String]) -> Bool { let expected = Set(values.map { $0.lowercased() }) + let workflow = isWorkflow return tags.contains { tag in - tag.count >= 2 && tag[0] == name && expected.contains(tag[1].lowercased()) + countsAsTag(tag, named: name, workflow: workflow) && expected.contains(tag[1].lowercased()) } } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift index 096737d535..07c2d318a5 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift @@ -53,6 +53,33 @@ final class PushLeaseTests: XCTestCase { XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, policy: suppressed)) } + func testWorkflowOwnerAttributionIsNotAMentionOrCounted() { + let workflow = ["buzz:workflow", "true"] + let attribution = ["p", mine, "", "owner"] + let mentions = PushResolutionPolicy( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]) + ) + XCTAssertFalse( + PushLeaseMatcher.matches( + event: makeEvent(kind: 9, tags: [attribution, workflow]), policy: mentions)) + XCTAssertTrue( + PushLeaseMatcher.matches( + event: makeEvent(kind: 9, tags: [attribution, workflow, ["p", mine]]), + policy: mentions)) + XCTAssertTrue( + PushLeaseMatcher.matches(event: makeEvent(kind: 9, tags: [attribution]), policy: mentions)) + + let atLimit = makeEvent( + kind: 9, + tags: [attribution, workflow] + (0..<20).map { ["p", String(format: "%064x", $0)] } + ) + let suppressed = PushResolutionPolicy( + filter: PushLeaseFilter(kinds: [9], authors: [other]), + suppress: PushLeaseSuppression(pTagsMax: 20) + ) + XCTAssertTrue(PushLeaseMatcher.matches(event: atLimit, policy: suppressed)) + } + func testDecodesSnapshotContractFromDartShape() throws { let json = """ {"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","policies":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}]} diff --git a/mobile/lib/features/activity/activity_provider.dart b/mobile/lib/features/activity/activity_provider.dart index 329d9763c7..e89db3f54d 100644 --- a/mobile/lib/features/activity/activity_provider.dart +++ b/mobile/lib/features/activity/activity_provider.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/mentions/mention_tags.dart'; import '../../shared/relay/relay.dart'; import '../channels/channel.dart'; import '../channels/channel_management_provider.dart'; @@ -500,12 +501,7 @@ class ActivityNotifier extends AsyncNotifier { bool isFromOther(NostrEvent e) => e.pubkey.toLowerCase() != myPk.toLowerCase(); - bool isAddressedToMe(NostrEvent event) => event.tags.any( - (tag) => - tag.length > 1 && - tag[0] == 'p' && - tag[1].toLowerCase() == myPk.toLowerCase(), - ); + bool isAddressedToMe(NostrEvent event) => tagsAddress(event.tags, myPk); const mentionKinds = {9, 40002, 1, 45001, 45003}; const needsActionKinds = {46010, 46011, 46012}; diff --git a/mobile/lib/features/channels/unread_badge/is_high_priority_event.dart b/mobile/lib/features/channels/unread_badge/is_high_priority_event.dart index 4195e3a541..6cc42c4cf3 100644 --- a/mobile/lib/features/channels/unread_badge/is_high_priority_event.dart +++ b/mobile/lib/features/channels/unread_badge/is_high_priority_event.dart @@ -1,14 +1,8 @@ +import '../../../shared/mentions/mention_tags.dart'; + bool isHighPriorityEvent(List> tags, String currentPubkey) { - final normalizedPubkey = currentPubkey.toLowerCase(); - for (final tag in tags) { - if (tag.length >= 2 && - tag[0] == 'p' && - tag[1].toLowerCase() == normalizedPubkey) { - return true; - } - if (tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1') { - return true; - } - } - return false; + return tagsAddress(tags, currentPubkey) || + tags.any( + (tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1', + ); } diff --git a/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart b/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart index f2f3c1e3b6..e878fc5040 100644 --- a/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart +++ b/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart @@ -1,3 +1,4 @@ +import '../../../shared/mentions/mention_tags.dart'; import '../../../shared/relay/nostr_models.dart'; bool shouldNotifyForEvent( @@ -22,14 +23,7 @@ bool shouldNotifyForEvent( } } - final normalizedPk = myPubkey.toLowerCase(); - for (final tag in event.tags) { - if (tag.length >= 2 && - tag[0] == 'p' && - tag[1].toLowerCase() == normalizedPk) { - return true; - } - } + if (tagsAddress(event.tags, myPubkey)) return true; final eventChannelId = channelId ?? event.channelId; if (eventChannelId != null && mutedChannelIds.contains(eventChannelId)) { diff --git a/mobile/lib/shared/mentions/mention_tags.dart b/mobile/lib/shared/mentions/mention_tags.dart index af7cfc42ad..16a7594813 100644 --- a/mobile/lib/shared/mentions/mention_tags.dart +++ b/mobile/lib/shared/mentions/mention_tags.dart @@ -13,6 +13,26 @@ final _key = RegExp(r'^[0-9a-f]{64}$'); /// Whether [key] is a lowercase 64-hex public key. bool isMentionKey(String key) => _key.hasMatch(key); +/// Whether [tags] address [pubkey] through a `p` tag. A workflow message's +/// owner attribution, `["p", , "", "owner"]` beside +/// `["buzz:workflow", "true"]`, names who owns the message rather than whom it +/// addresses, so it does not count. The relay adds a plain `p` tag when a +/// workflow does mention its owner. This matches the relay's +/// `is_workflow_owner_attribution`. +bool tagsAddress(Iterable> tags, String pubkey) { + final normalized = pubkey.toLowerCase(); + final workflow = tags.any( + (tag) => tag.length == 2 && tag[0] == 'buzz:workflow' && tag[1] == 'true', + ); + return tags.any( + (tag) => + tag.length >= 2 && + tag[0] == 'p' && + tag[1].toLowerCase() == normalized && + !(workflow && tag.length >= 4 && tag[3] == 'owner'), + ); +} + /// Why a writer refused a message's mention tags. enum MentionTagError { notMember, invalid, tooMany } diff --git a/mobile/test/features/activity/activity_provider_test.dart b/mobile/test/features/activity/activity_provider_test.dart index eda77f46eb..234c643c0c 100644 --- a/mobile/test/features/activity/activity_provider_test.dart +++ b/mobile/test/features/activity/activity_provider_test.dart @@ -304,6 +304,52 @@ void main() { expect(feed.mentions.map((item) => item.id), ['fallback-mention']); }); + test('a workflow owner attribution is not a mention of the owner', () async { + NostrEvent workflowMessage(String id, List> mentions) => + NostrEvent( + id: id, + pubkey: 'relay_pk', + createdAt: 1_700_000_001, + kind: 9, + tags: [ + ['p', 'me_pk', '', 'owner'], + ['h', 'channel-1'], + ['buzz:workflow', 'true'], + ['buzz:workflow-owner', 'me_pk'], + ...mentions, + ], + content: 'workflow output', + sig: '', + ); + final session = _RecordingSessionNotifier() + ..seed( + workflowMessage('mentions-agent', [ + ['p', 'agent_pk'], + ]), + ) + ..seed( + workflowMessage('mentions-owner', [ + ['p', 'me_pk'], + ]), + ); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new), + myPubkeyProvider.overrideWithValue('me_pk'), + relaySessionProvider.overrideWith(() => session), + channelsProvider.overrideWith( + () => _FixedChannelsNotifier(const []), + ), + ], + ); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + final feed = await container.read(activityProvider.future); + + expect(feed.mentions.map((item) => item.id), ['mentions-owner']); + }); + test( 'refreshes the inbox projection when addressed activity arrives', () async { diff --git a/mobile/test/features/channels/unread_badge/owner_attribution_test.dart b/mobile/test/features/channels/unread_badge/owner_attribution_test.dart new file mode 100644 index 0000000000..a56b729088 --- /dev/null +++ b/mobile/test/features/channels/unread_badge/owner_attribution_test.dart @@ -0,0 +1,40 @@ +import 'package:buzz/features/channels/unread_badge/is_high_priority_event.dart'; +import 'package:buzz/features/channels/unread_badge/should_notify_for_event.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('a workflow owner attribution neither notifies nor marks priority', () { + final me = 'a' * 64; + final attribution = ['p', me, '', 'owner']; + for (final (tags, addressed) in [ + ([attribution], false), + ( + [ + attribution, + ['p', me], + ], + true, + ), + ]) { + // A reply in a thread the reader hasn't joined notifies only when it + // addresses them, so `addressed` is the whole outcome. + final event = NostrEvent( + id: 'b' * 64, + pubkey: 'c' * 64, + createdAt: 1_700_000_000, + kind: 9, + tags: [ + ...tags, + ['buzz:workflow', 'true'], + ['h', 'channel-1'], + ['e', 'd' * 64, '', 'reply'], + ], + content: 'workflow output', + sig: '', + ); + expect(isHighPriorityEvent(event.tags, me), addressed, reason: '$tags'); + expect(shouldNotifyForEvent(event, me), addressed, reason: '$tags'); + } + }); +} diff --git a/mobile/test/shared/mentions/mention_rules_test.dart b/mobile/test/shared/mentions/mention_rules_test.dart index 4322cb361e..6fe8276591 100644 --- a/mobile/test/shared/mentions/mention_rules_test.dart +++ b/mobile/test/shared/mentions/mention_rules_test.dart @@ -165,6 +165,55 @@ void main() { } }); + test('a workflow owner attribution does not address its owner', () { + final me = 'a' * 64; + for (final (tags, addressed) in [ + ( + [ + ['p', me], + ], + true, + ), + ( + [ + ['p', me.toUpperCase()], + ], + true, + ), + ( + [ + ['p', me, '', 'owner'], + ['buzz:workflow', 'true'], + ], + false, + ), + ( + [ + ['p', me, '', 'owner'], + ['buzz:workflow', 'true'], + ['p', me], + ], + true, + ), + // Without the workflow marker the fourth field means something else, + // such as a NIP-29 roster role. + ( + [ + ['p', me, '', 'owner'], + ], + true, + ), + ( + [ + ['p', 'b' * 64], + ], + false, + ), + ]) { + expect(tagsAddress(tags, me), addressed, reason: '$tags'); + } + }); + test('order does not depend on input order', () { // Review case: owned Zed, unowned Alpha, human Mary. const zed = MentionChoice(