mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
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:
14
NOSTR.md
14
NOSTR.md
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}]}]}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user