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 <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-10-29 15:21:56 -07:00
committed by GitHub
parent 1f16f621d8
commit 4b21a10c4b
18 changed files with 665 additions and 94 deletions

View File

@@ -297,6 +297,20 @@ nak req -k 44100 -k 44101 --tag "p=<your-hex-pubkey>" \
--auth --sec <privkey> 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>, "", "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", <owner>]`, 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

View File

@@ -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>, "", "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();

View File

@@ -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

View File

@@ -70,7 +70,9 @@ fn event_targets(event: &Event) -> Vec<Vec<u8>> {
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)]

View File

@@ -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<Vec<u8>> {
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::<Vec<u8>>::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<Arc<Mutex<Vec<Value>>>>,
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<Vec<Vec<u8>>> = 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<_>>(),
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`

View File

@@ -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<String> =
@@ -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),

View File

@@ -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);
});
}

View File

@@ -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)) {

View File

@@ -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>, "", "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", <owner>]`, which
notifies them like any mention. Mobile's Activity feed, unread mention priority
and in-app notification rule apply the same rule.

View File

@@ -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())
}
}
}

View File

@@ -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}}]}]}

View File

@@ -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<HomeFeedResponse> {
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};

View File

@@ -1,14 +1,8 @@
import '../../../shared/mentions/mention_tags.dart';
bool isHighPriorityEvent(List<List<String>> 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',
);
}

View File

@@ -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)) {

View File

@@ -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>, "", "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<List<String>> 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 }

View File

@@ -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<List<String>> 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 <Channel>[]),
),
],
);
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 {

View File

@@ -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');
}
});
}

View File

@@ -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(