Skip to content

Commit 91c7f10

Browse files
Bartok9Bartok9
authored andcommitted
fix(mention): emit parallel p + mention tags on intentional @mentions
Contract for #2296 / #1743: intentional authored @mentions emit ["mention", pubkey] alongside ["p", pubkey] from SDK build_message, desktop ordinary composer + reply tags + edit path, mobile send, and workflow_sink. Relay offline-agent notices stay gated on mention tags only (never bare structural p). Tests cover parallel tags + structural-p non-trigger. Signed-off-by: Bartok9 <danielrpike9@gmail.com> (cherry picked from commit 8f70d1f) Signed-off-by: Bartok9 <259807879+Bartok9@users.noreply.github.com> (cherry picked from commit 293122c) Signed-off-by: Bartok9 <259807879+Bartok9@users.noreply.github.com> (cherry picked from commit 56094ac) Signed-off-by: Bartok9 <259807879+Bartok9@users.noreply.github.com> (cherry picked from commit bb64504) Signed-off-by: Bartok9 <259807879+Bartok9@users.noreply.github.com>
1 parent 47bbb2d commit 91c7f10

10 files changed

Lines changed: 149 additions & 18 deletions

File tree

crates/buzz-relay/src/handlers/event.rs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -586,10 +586,11 @@ async fn dispatch_persistent_event_inner(
586586

587587
/// Collect explicit `@mention` targets from event tags.
588588
///
589-
/// Desktop emits `["mention", pubkey]` via `mergeOutgoingTagsWithReferenceMentions`
590-
/// / `MENTION_REFERENCE_TAG` for composer @mentions. Structural reply-author
591-
/// routing only adds `p` tags (`buildReplyTags`) and must **not** count as an
592-
/// explicit mention (Brad review on #2296 / same finding on #1862).
589+
/// Producers emit `["mention", pubkey]` in parallel with `p` for intentional
590+
/// authored @mentions (SDK/CLI `build_message`, Desktop ordinary composer /
591+
/// `buildReplyTags` mention loop, Mobile `SendMessage`). The non-member
592+
/// "send without inviting" path also uses bare `mention` tags.
593+
/// Structural reply-author `p` alone must **not** count (Brad #2296 / #1862).
593594
///
594595
/// - Dedupes first-seen hex (lowercased)
595596
/// - Skips malformed tags (missing/empty pubkey)
@@ -2753,6 +2754,48 @@ mod tests {
27532754
assert_eq!(found, vec![bot]);
27542755
}
27552756

2757+
#[test]
2758+
fn explicit_mention_from_sdk_style_tags_not_structural_p() {
2759+
// Contract: intentional mentions carry parallel p+mention; structural
2760+
// reply author is p-only and must not extract.
2761+
let author = "aa".repeat(32);
2762+
let offline_bot = "bb".repeat(32);
2763+
let structural_only = nostr::Tags::from_list(vec![
2764+
nostr::Tag::parse(["p", &author]).unwrap(),
2765+
nostr::Tag::parse(["h", "00000000-0000-0000-0000-000000000001"]).unwrap(),
2766+
]);
2767+
assert!(
2768+
crate::handlers::event::explicit_mention_pubkeys_from_tags(&structural_only)
2769+
.is_empty()
2770+
);
2771+
2772+
let intentional = nostr::Tags::from_list(vec![
2773+
nostr::Tag::parse(["p", &author]).unwrap(),
2774+
nostr::Tag::parse(["h", "00000000-0000-0000-0000-000000000001"]).unwrap(),
2775+
nostr::Tag::parse(["p", &offline_bot]).unwrap(),
2776+
nostr::Tag::custom(
2777+
nostr::TagKind::Custom("mention".into()),
2778+
[&offline_bot],
2779+
),
2780+
]);
2781+
assert_eq!(
2782+
crate::handlers::event::explicit_mention_pubkeys_from_tags(&intentional),
2783+
vec![offline_bot.clone()]
2784+
);
2785+
2786+
use std::collections::{HashMap, HashSet};
2787+
let bots: HashSet<String> = [offline_bot.clone()].into_iter().collect();
2788+
let present: HashMap<String, String> = HashMap::new();
2789+
let mentioned =
2790+
crate::handlers::event::explicit_mention_pubkeys_from_tags(&intentional);
2791+
assert_eq!(
2792+
crate::handlers::event::select_offline_mentioned_bots(
2793+
&mentioned, &bots, &present
2794+
),
2795+
vec![offline_bot]
2796+
);
2797+
}
2798+
27562799
#[test]
27572800
fn offline_matrix_explicit_offline_bot_only() {
27582801
use std::collections::{HashMap, HashSet};

crates/buzz-relay/src/workflow_sink.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,13 +380,30 @@ impl ActionSink for RelayActionSink {
380380
Some((name, nostr::PublicKey::from_slice(&u.pubkey).ok()?.to_hex()))
381381
})
382382
.collect();
383+
<<<<<<< HEAD
383384
append_workflow_mention_tags(
384385
&mut tags,
385386
&text,
386387
&authored_text,
387388
&named_members,
388389
&author_pubkey_hex,
389390
)?;
391+
=======
392+
for mentioned in resolve_mention_pubkeys(&text, &named_members) {
393+
if mentioned == author_pubkey_hex {
394+
continue;
395+
}
396+
tags.push(
397+
Tag::parse(["p", &mentioned])
398+
.map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?,
399+
);
400+
tags.push(
401+
Tag::parse(["mention", &mentioned]).map_err(|e| {
402+
ActionSinkError::EventBuild(format!("mention intent tag: {e}"))
403+
})?,
404+
);
405+
}
406+
>>>>>>> d8b6d0d (fix(mention): emit parallel p + mention tags on intentional @mentions)
390407

391408
let kind = Kind::from(KIND_STREAM_MESSAGE as u16);
392409
let event = EventBuilder::new(kind, &text)

crates/buzz-sdk/src/builders.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,12 @@ fn thread_tags(thread_ref: &ThreadRef, tags: &mut Vec<Tag>) -> Result<(), SdkErr
189189
Ok(())
190190
}
191191

192-
/// Deduplicate and cap mentions, emitting p-tags.
192+
/// Deduplicate and cap intentional mentions.
193+
///
194+
/// Emits both `["p", hex]` (delivery / subscription fan-out) and
195+
/// `["mention", hex]` (explicit intent marker). Relay offline-agent notices
196+
/// (#1743 / #2296) gate on `mention` only so structural reply-author `p`
197+
/// tags never false-trigger.
193198
fn mention_tags(mentions: &[&str], tags: &mut Vec<Tag>) -> Result<(), SdkError> {
194199
if mentions.len() > crate::mentions::MENTION_CAP {
195200
return Err(SdkError::TooManyMentions);
@@ -199,6 +204,7 @@ fn mention_tags(mentions: &[&str], tags: &mut Vec<Tag>) -> Result<(), SdkError>
199204
let lower = hex.to_ascii_lowercase();
200205
if seen.insert(lower.clone()) {
201206
tags.push(tag(&["p", &lower])?);
207+
tags.push(tag(&["mention", &lower])?);
202208
}
203209
}
204210
Ok(())
@@ -233,7 +239,8 @@ fn nip30_emoji_tags(emoji_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(
233239
/// - `channel_id`: target channel UUID
234240
/// - `content`: message text (max 64 KiB)
235241
/// - `thread_ref`: optional NIP-10 reply context
236-
/// - `mentions`: pubkey hex strings to p-tag (deduped, max 50)
242+
/// - `mentions`: pubkey hex strings for intentional @mentions (deduped, max 50);
243+
/// emits both `p` and `mention` tags
237244
/// - `broadcast`: if true, adds `["broadcast", "1"]` tag
238245
/// - `media_tags`: raw imeta tag vectors
239246
/// - `emoji_tags`: NIP-30 `["emoji", shortcode, url]` tag vectors
@@ -2556,6 +2563,18 @@ mod tests {
25562563
let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[], &[]).unwrap());
25572564
let p_tags = tag_values(&ev, "p");
25582565
assert_eq!(p_tags.len(), 1);
2566+
let mention_tags = tag_values(&ev, "mention");
2567+
assert_eq!(mention_tags, vec![hex.to_string()]);
2568+
}
2569+
2570+
#[test]
2571+
fn message_mentions_emit_parallel_p_and_mention() {
2572+
let cid = uuid();
2573+
let a = "aa".repeat(32);
2574+
let b = "bb".repeat(32);
2575+
let ev = sign(build_message(cid, "hi @a @b", None, &[&a, &b], false, &[]).unwrap());
2576+
assert_eq!(tag_values(&ev, "p"), vec![a.clone(), b.clone()]);
2577+
assert_eq!(tag_values(&ev, "mention"), vec![a, b]);
25592578
}
25602579

25612580
#[test]

crates/buzz-sdk/src/mentions.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
//! │
1616
//! body text ──► strip_code_regions ──► extract_nostr_uris ─┤
1717
//! ▼
18-
//! explicit mentions ──► normalize ──► merge_mentions ──► p-tags
18+
//! explicit mentions ──► normalize ──► merge_mentions ──► p + mention tags
1919
//! ```
2020
//!
2121
//! When the set of known member names is available upfront,
@@ -31,7 +31,7 @@ use std::collections::HashSet;
3131

3232
use nostr::{FromBech32, PublicKey};
3333

34-
/// Maximum number of mention p-tags allowed on a single message.
34+
/// Maximum number of intentional mentions allowed on a single message.
3535
///
3636
/// Matches the cap enforced by Buzz message builders and the legacy MCP
3737
/// inline implementation.

desktop/src-tauri/src/events.rs

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ fn mention_tags(mentions: &[&str]) -> Result<Vec<Tag>, String> {
7474
check_pubkey(hex)?;
7575
let lower = hex.to_ascii_lowercase();
7676
if seen.insert(lower.clone()) {
77+
// Parallel p (fan-out) + mention (explicit intent) for #2296 contract.
7778
tags.push(tag(vec!["p", &lower])?);
79+
tags.push(tag(vec!["mention", &lower])?);
7880
}
7981
}
8082
Ok(tags)
@@ -356,6 +358,19 @@ pub struct MessageEditTags<'a> {
356358

357359
/// Kind 40003 — edit a message with full content, media, emoji, mentions,
358360
/// and optional monotonic link-preview suppression.
361+
///
362+
/// Carries the full new content AND a fresh imeta tag set; the receiver
363+
/// overlays the imeta tags onto the original event so the rendered message
364+
/// reflects exactly the edited state. NIP-30 custom-emoji tags ride along the
365+
/// same way so an edited body's `:shortcode:`s stay resolvable (the send path
366+
/// attaches these too).
367+
///
368+
/// `mentions` carries the pubkeys of mentions that are *newly added* by this
369+
/// edit (the caller diffs the edited body against the original). Only those get
370+
/// parallel `p` + `mention` tags so the newly-mentioned party is notified/woken,
371+
/// while a typo-fix edit that leaves the mention set unchanged emits neither
372+
/// and never re-wakes anyone. Mirrors the send path's `mention_tags` (dedup +
373+
/// lowercase); the receiver overlays these onto the original event's audience.
359374
pub fn build_message_edit(
360375
channel_id: Uuid,
361376
target_event_id: EventId,
@@ -850,6 +865,14 @@ mod tests {
850865
assert_eq!(event.pubkey.to_hex(), TARGET_HEX);
851866
}
852867

868+
// ── build_message_edit p+mention emission (lane 8ace8eed) ──────────────
869+
//
870+
// The composer diffs the edited body's mentions against the original and
871+
// hands `build_message_edit` only the *newly added* pubkeys. These tests
872+
// pins the builder's contract: emit parallel `p` + `mention` per added
873+
// mention (deduped, lowercased), and none when the added set is empty
874+
// (typo-fix edit) — so an unchanged mention set re-wakes nobody.
875+
853876
const CH_ID: &str = "11111111-1111-4111-8111-111111111111";
854877
const ALICE_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
855878
const BOB_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
@@ -892,19 +915,21 @@ mod tests {
892915
let tags = edit_tags(&[ALICE_HEX]);
893916
assert_eq!(tags[0][0], "h");
894917
assert_eq!(tags[1][0], "e");
918+
// `p` + parallel `mention` ride right after the `e` tag (insertion order).
895919
assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]);
920+
assert_eq!(tags[3], vec!["mention".to_string(), ALICE_HEX.to_string()]);
896921
}
897922

898923
#[test]
899924
fn edit_with_no_added_mentions_emits_no_p_tag() {
900925
// Typo-fix edit: mention set unchanged, so the composer passes `&[]`.
901-
// The edit event must carry no `p` tag and re-wake nobody.
926+
// The edit event must carry no `p`/`mention` tags and re-wake nobody.
902927
let tags = edit_tags(&[]);
903928
assert!(
904-
!tags
905-
.iter()
906-
.any(|t| t.first().map(String::as_str) == Some("p")),
907-
"unchanged-mention edit must not emit any `p` tag, got {tags:?}"
929+
!tags.iter().any(|t| {
930+
matches!(t.first().map(String::as_str), Some("p" | "mention"))
931+
}),
932+
"unchanged-mention edit must not emit p/mention tags, got {tags:?}"
908933
);
909934
}
910935

@@ -960,5 +985,18 @@ mod tests {
960985
);
961986
assert_eq!(p_tags[0], &vec!["p".to_string(), ALICE_HEX.to_string()]);
962987
assert_eq!(p_tags[1], &vec!["p".to_string(), BOB_HEX.to_string()]);
988+
let mention_tags: Vec<&Vec<String>> = tags
989+
.iter()
990+
.filter(|t| t.first().map(String::as_str) == Some("mention"))
991+
.collect();
992+
assert_eq!(mention_tags.len(), 2);
993+
assert_eq!(
994+
mention_tags[0],
995+
&vec!["mention".to_string(), ALICE_HEX.to_string()]
996+
);
997+
assert_eq!(
998+
mention_tags[1],
999+
&vec!["mention".to_string(), BOB_HEX.to_string()]
1000+
);
9631001
}
9641002
}

desktop/src/features/messages/hooks.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export function createOptimisticMessage(
135135
identity.pubkey,
136136
)) {
137137
tags.push(["p", pubkey]);
138+
tags.push(["mention", pubkey]);
138139
}
139140
}
140141

@@ -632,8 +633,11 @@ export function useSendMessageMutation(
632633
...baseTags,
633634
// For non-replies, add mention p-tags here (replies get them via buildReplyTags)
634635
...(!parentEventId
635-
? normalizeMentionPubkeys(recipientPubkeys, identity.pubkey).map(
636-
(pk) => ["p", pk],
636+
? normalizeMentionPubkeys(recipientPubkeys, identity.pubkey).flatMap(
637+
(pk) => [
638+
["p", pk],
639+
["mention", pk],
640+
],
637641
)
638642
: []),
639643
...imetaTags,

desktop/src/features/messages/lib/threading.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,13 @@ export function buildReplyTags(
110110
["h", channelId],
111111
];
112112

113-
// Add p-tags for mentioned users so mention-filtered subscriptions
114-
// (e.g. ACP agent harness) receive the reply event.
113+
// Intentional @mentions: `p` for subscription fan-out + explicit `mention`
114+
// intent marker (relay offline-agent notice gates on mention only; structural
115+
// reply-author `p` above must never count as a mention).
115116
// Best-effort normalization — relay performs authoritative validation.
116117
for (const pubkey of normalizeMentionPubkeys(mentionPubkeys, authorPubkey)) {
117118
tags.push(["p", pubkey]);
119+
tags.push(["mention", pubkey]);
118120
}
119121

120122
if (parentEventId === rootEventId) {

desktop/src/shared/api/relayClientSession.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ export class RelayClient {
260260
const tags: string[][] = [["h", channelId]];
261261
for (const pubkey of mentionPubkeys) {
262262
tags.push(["p", pubkey]);
263+
tags.push(["mention", pubkey]);
263264
}
264265
for (const tag of extraTags) {
265266
tags.push(tag);

desktop/src/testing/e2eBridge.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4602,6 +4602,7 @@ function appendMentionTags(
46024602
}
46034603
seen.add(lower);
46044604
tags.push(["p", lower]);
4605+
tags.push(["mention", lower]);
46054606
}
46064607
}
46074608

mobile/lib/features/channels/send_message_provider.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,16 @@ class SendMessage {
8787
if (seenMentions.add(pk.toLowerCase())) pk,
8888
];
8989

90+
// Intentional @mentions emit `p` (fan-out) + `mention` (explicit intent).
91+
// Structural reply routing uses only `e` tags here; relay offline notices
92+
// gate on `mention` so we never treat reply-author `p` as a mention.
9093
final tags = <List<String>>[
9194
['h', channelId],
9295
if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId),
93-
for (final pk in normalizedMentions) ['p', pk],
96+
for (final pk in normalizedMentions) ...[
97+
['p', pk],
98+
['mention', pk],
99+
],
94100
...mediaTags,
95101
];
96102

0 commit comments

Comments
 (0)