Skip to content

Commit 27f919c

Browse files
author
Bartok9
committed
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>
1 parent b8b38b6 commit 27f919c

10 files changed

Lines changed: 131 additions & 21 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)
@@ -2744,6 +2745,48 @@ mod tests {
27442745
assert_eq!(found, vec![bot]);
27452746
}
27462747

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

crates/buzz-relay/src/workflow_sink.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,11 @@ impl ActionSink for RelayActionSink {
296296
Tag::parse(["p", &mentioned])
297297
.map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?,
298298
);
299+
tags.push(
300+
Tag::parse(["mention", &mentioned]).map_err(|e| {
301+
ActionSinkError::EventBuild(format!("mention intent tag: {e}"))
302+
})?,
303+
);
299304
}
300305

301306
let kind = Kind::from(KIND_STREAM_MESSAGE as u16);

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(())
@@ -218,7 +224,8 @@ fn imeta_tags(media_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), Sdk
218224
/// - `channel_id`: target channel UUID
219225
/// - `content`: message text (max 64 KiB)
220226
/// - `thread_ref`: optional NIP-10 reply context
221-
/// - `mentions`: pubkey hex strings to p-tag (deduped, max 50)
227+
/// - `mentions`: pubkey hex strings for intentional @mentions (deduped, max 50);
228+
/// emits both `p` and `mention` tags
222229
/// - `broadcast`: if true, adds `["broadcast", "1"]` tag
223230
/// - `media_tags`: raw imeta tag vectors
224231
pub fn build_message(
@@ -2410,6 +2417,18 @@ mod tests {
24102417
let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap());
24112418
let p_tags = tag_values(&ev, "p");
24122419
assert_eq!(p_tags.len(), 1);
2420+
let mention_tags = tag_values(&ev, "mention");
2421+
assert_eq!(mention_tags, vec![hex.to_string()]);
2422+
}
2423+
2424+
#[test]
2425+
fn message_mentions_emit_parallel_p_and_mention() {
2426+
let cid = uuid();
2427+
let a = "aa".repeat(32);
2428+
let b = "bb".repeat(32);
2429+
let ev = sign(build_message(cid, "hi @a @b", None, &[&a, &b], false, &[]).unwrap());
2430+
assert_eq!(tag_values(&ev, "p"), vec![a.clone(), b.clone()]);
2431+
assert_eq!(tag_values(&ev, "mention"), vec![a, b]);
24132432
}
24142433

24152434
#[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: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ fn mention_tags(mentions: &[&str]) -> Result<Vec<Tag>, String> {
6868
check_pubkey(hex)?;
6969
let lower = hex.to_ascii_lowercase();
7070
if seen.insert(lower.clone()) {
71+
// Parallel p (fan-out) + mention (explicit intent) for #2296 contract.
7172
tags.push(tag(vec!["p", &lower])?);
73+
tags.push(tag(vec!["mention", &lower])?);
7274
}
7375
}
7476
Ok(tags)
@@ -403,6 +405,19 @@ pub fn build_forum_comment(
403405

404406
/// Kind 40003 — edit a message with full content, media, emoji, mentions,
405407
/// and optional monotonic link-preview suppression.
408+
///
409+
/// Carries the full new content AND a fresh imeta tag set; the receiver
410+
/// overlays the imeta tags onto the original event so the rendered message
411+
/// reflects exactly the edited state. NIP-30 custom-emoji tags ride along the
412+
/// same way so an edited body's `:shortcode:`s stay resolvable (the send path
413+
/// attaches these too).
414+
///
415+
/// `mentions` carries the pubkeys of mentions that are *newly added* by this
416+
/// edit (the caller diffs the edited body against the original). Only those get
417+
/// parallel `p` + `mention` tags so the newly-mentioned party is notified/woken,
418+
/// while a typo-fix edit that leaves the mention set unchanged emits neither
419+
/// and never re-wakes anyone. Mirrors the send path's `mention_tags` (dedup +
420+
/// lowercase); the receiver overlays these onto the original event's audience.
406421
pub fn build_message_edit(
407422
channel_id: Uuid,
408423
target_event_id: EventId,
@@ -930,11 +945,11 @@ mod tests {
930945
assert_eq!(event.pubkey.to_hex(), TARGET_HEX);
931946
}
932947

933-
// ── build_message_edit `p`-tag emission (lane 8ace8eed) ──────────────
948+
// ── build_message_edit p+mention emission (lane 8ace8eed) ──────────────
934949
//
935950
// The composer diffs the edited body's mentions against the original and
936951
// hands `build_message_edit` only the *newly added* pubkeys. These tests
937-
// pin the builder's contract given that contract: emit a `p` per added
952+
// pins the builder's contract: emit parallel `p` + `mention` per added
938953
// mention (deduped, lowercased), and none when the added set is empty
939954
// (typo-fix edit) — so an unchanged mention set re-wakes nobody.
940955

@@ -962,20 +977,21 @@ mod tests {
962977
let tags = edit_tags(&[ALICE_HEX]);
963978
assert_eq!(tags[0][0], "h");
964979
assert_eq!(tags[1][0], "e");
965-
// The `p` tag rides right after the `e` tag (insertion order).
980+
// `p` + parallel `mention` ride right after the `e` tag (insertion order).
966981
assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]);
982+
assert_eq!(tags[3], vec!["mention".to_string(), ALICE_HEX.to_string()]);
967983
}
968984

969985
#[test]
970986
fn edit_with_no_added_mentions_emits_no_p_tag() {
971987
// Typo-fix edit: mention set unchanged, so the composer passes `&[]`.
972-
// The edit event must carry no `p` tag and re-wake nobody.
988+
// The edit event must carry no `p`/`mention` tags and re-wake nobody.
973989
let tags = edit_tags(&[]);
974990
assert!(
975-
!tags
976-
.iter()
977-
.any(|t| t.first().map(String::as_str) == Some("p")),
978-
"unchanged-mention edit must not emit any `p` tag, got {tags:?}"
991+
!tags.iter().any(|t| {
992+
matches!(t.first().map(String::as_str), Some("p" | "mention"))
993+
}),
994+
"unchanged-mention edit must not emit p/mention tags, got {tags:?}"
979995
);
980996
}
981997

@@ -995,5 +1011,18 @@ mod tests {
9951011
);
9961012
assert_eq!(p_tags[0], &vec!["p".to_string(), ALICE_HEX.to_string()]);
9971013
assert_eq!(p_tags[1], &vec!["p".to_string(), BOB_HEX.to_string()]);
1014+
let mention_tags: Vec<&Vec<String>> = tags
1015+
.iter()
1016+
.filter(|t| t.first().map(String::as_str) == Some("mention"))
1017+
.collect();
1018+
assert_eq!(mention_tags.len(), 2);
1019+
assert_eq!(
1020+
mention_tags[0],
1021+
&vec!["mention".to_string(), ALICE_HEX.to_string()]
1022+
);
1023+
assert_eq!(
1024+
mention_tags[1],
1025+
&vec!["mention".to_string(), BOB_HEX.to_string()]
1026+
);
9981027
}
9991028
}

desktop/src/features/messages/hooks.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export function createOptimisticMessage(
109109
identity.pubkey,
110110
)) {
111111
tags.push(["p", pubkey]);
112+
tags.push(["mention", pubkey]);
112113
}
113114
}
114115

@@ -519,8 +520,11 @@ export function useSendMessageMutation(
519520
...baseTags,
520521
// For non-replies, add mention p-tags here (replies get them via buildReplyTags)
521522
...(!parentEventId
522-
? normalizeMentionPubkeys(recipientPubkeys, identity.pubkey).map(
523-
(pk) => ["p", pk],
523+
? normalizeMentionPubkeys(recipientPubkeys, identity.pubkey).flatMap(
524+
(pk) => [
525+
["p", pk],
526+
["mention", pk],
527+
],
524528
)
525529
: []),
526530
...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
@@ -3884,6 +3884,7 @@ function appendMentionTags(
38843884
}
38853885
seen.add(lower);
38863886
tags.push(["p", lower]);
3887+
tags.push(["mention", lower]);
38873888
}
38883889
}
38893890

mobile/lib/features/channels/send_message_provider.dart

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

69+
// Intentional @mentions emit `p` (fan-out) + `mention` (explicit intent).
70+
// Structural reply routing uses only `e` tags here; relay offline notices
71+
// gate on `mention` so we never treat reply-author `p` as a mention.
6972
final tags = <List<String>>[
7073
['h', channelId],
7174
if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId),
72-
for (final pk in normalizedMentions) ['p', pk],
75+
for (final pk in normalizedMentions) ...[
76+
['p', pk],
77+
['mention', pk],
78+
],
7379
...mediaTags,
7480
];
7581

0 commit comments

Comments
 (0)