Skip to content

Commit a427e1e

Browse files
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 0e19eb0 commit a427e1e

10 files changed

Lines changed: 121 additions & 24 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)
@@ -2741,6 +2742,48 @@ mod tests {
27412742
assert_eq!(found, vec![bot]);
27422743
}
27432744

2745+
#[test]
2746+
fn explicit_mention_from_sdk_style_tags_not_structural_p() {
2747+
// Contract: intentional mentions carry parallel p+mention; structural
2748+
// reply author is p-only and must not extract.
2749+
let author = "aa".repeat(32);
2750+
let offline_bot = "bb".repeat(32);
2751+
let structural_only = nostr::Tags::from_list(vec![
2752+
nostr::Tag::parse(["p", &author]).unwrap(),
2753+
nostr::Tag::parse(["h", "00000000-0000-0000-0000-000000000001"]).unwrap(),
2754+
]);
2755+
assert!(
2756+
crate::handlers::event::explicit_mention_pubkeys_from_tags(&structural_only)
2757+
.is_empty()
2758+
);
2759+
2760+
let intentional = nostr::Tags::from_list(vec![
2761+
nostr::Tag::parse(["p", &author]).unwrap(),
2762+
nostr::Tag::parse(["h", "00000000-0000-0000-0000-000000000001"]).unwrap(),
2763+
nostr::Tag::parse(["p", &offline_bot]).unwrap(),
2764+
nostr::Tag::custom(
2765+
nostr::TagKind::Custom("mention".into()),
2766+
[&offline_bot],
2767+
),
2768+
]);
2769+
assert_eq!(
2770+
crate::handlers::event::explicit_mention_pubkeys_from_tags(&intentional),
2771+
vec![offline_bot.clone()]
2772+
);
2773+
2774+
use std::collections::{HashMap, HashSet};
2775+
let bots: HashSet<String> = [offline_bot.clone()].into_iter().collect();
2776+
let present: HashMap<String, String> = HashMap::new();
2777+
let mentioned =
2778+
crate::handlers::event::explicit_mention_pubkeys_from_tags(&intentional);
2779+
assert_eq!(
2780+
crate::handlers::event::select_offline_mentioned_bots(
2781+
&mentioned, &bots, &present
2782+
),
2783+
vec![offline_bot]
2784+
);
2785+
}
2786+
27442787
#[test]
27452788
fn offline_matrix_explicit_offline_bot_only() {
27462789
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
@@ -184,7 +184,12 @@ fn thread_tags(thread_ref: &ThreadRef, tags: &mut Vec<Tag>) -> Result<(), SdkErr
184184
Ok(())
185185
}
186186

187-
/// Deduplicate and cap mentions, emitting p-tags.
187+
/// Deduplicate and cap intentional mentions.
188+
///
189+
/// Emits both `["p", hex]` (delivery / subscription fan-out) and
190+
/// `["mention", hex]` (explicit intent marker). Relay offline-agent notices
191+
/// (#1743 / #2296) gate on `mention` only so structural reply-author `p`
192+
/// tags never false-trigger.
188193
fn mention_tags(mentions: &[&str], tags: &mut Vec<Tag>) -> Result<(), SdkError> {
189194
if mentions.len() > crate::mentions::MENTION_CAP {
190195
return Err(SdkError::TooManyMentions);
@@ -194,6 +199,7 @@ fn mention_tags(mentions: &[&str], tags: &mut Vec<Tag>) -> Result<(), SdkError>
194199
let lower = hex.to_ascii_lowercase();
195200
if seen.insert(lower.clone()) {
196201
tags.push(tag(&["p", &lower])?);
202+
tags.push(tag(&["mention", &lower])?);
197203
}
198204
}
199205
Ok(())
@@ -213,7 +219,8 @@ fn imeta_tags(media_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), Sdk
213219
/// - `channel_id`: target channel UUID
214220
/// - `content`: message text (max 64 KiB)
215221
/// - `thread_ref`: optional NIP-10 reply context
216-
/// - `mentions`: pubkey hex strings to p-tag (deduped, max 50)
222+
/// - `mentions`: pubkey hex strings for intentional @mentions (deduped, max 50);
223+
/// emits both `p` and `mention` tags
217224
/// - `broadcast`: if true, adds `["broadcast", "1"]` tag
218225
/// - `media_tags`: raw imeta tag vectors
219226
pub fn build_message(
@@ -1983,6 +1990,18 @@ mod tests {
19831990
let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap());
19841991
let p_tags = tag_values(&ev, "p");
19851992
assert_eq!(p_tags.len(), 1);
1993+
let mention_tags = tag_values(&ev, "mention");
1994+
assert_eq!(mention_tags, vec![hex.to_string()]);
1995+
}
1996+
1997+
#[test]
1998+
fn message_mentions_emit_parallel_p_and_mention() {
1999+
let cid = uuid();
2000+
let a = "aa".repeat(32);
2001+
let b = "bb".repeat(32);
2002+
let ev = sign(build_message(cid, "hi @a @b", None, &[&a, &b], false, &[]).unwrap());
2003+
assert_eq!(tag_values(&ev, "p"), vec![a.clone(), b.clone()]);
2004+
assert_eq!(tag_values(&ev, "mention"), vec![a, b]);
19862005
}
19872006

19882007
#[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: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ fn mention_tags(mentions: &[&str]) -> Result<Vec<Tag>, String> {
7070
check_pubkey(hex)?;
7171
let lower = hex.to_ascii_lowercase();
7272
if seen.insert(lower.clone()) {
73+
// Parallel p (fan-out) + mention (explicit intent) for #2296 contract.
7374
tags.push(tag(vec!["p", &lower])?);
75+
tags.push(tag(vec!["mention", &lower])?);
7476
}
7577
}
7678
Ok(tags)
@@ -404,9 +406,9 @@ pub fn build_forum_comment(
404406
///
405407
/// `mentions` carries the pubkeys of mentions that are *newly added* by this
406408
/// edit (the caller diffs the edited body against the original). Only those get
407-
/// a `p` tag so the newly-mentioned party is notified/woken, while a typo-fix
408-
/// edit that leaves the mention set unchanged emits no `p` tags and never
409-
/// re-wakes anyone. This mirrors the send path's `mention_tags` (dedup +
409+
/// parallel `p` + `mention` tags so the newly-mentioned party is notified/woken,
410+
/// while a typo-fix edit that leaves the mention set unchanged emits neither
411+
/// and never re-wakes anyone. Mirrors the send path's `mention_tags` (dedup +
410412
/// lowercase); the receiver overlays these onto the original event's audience.
411413
pub fn build_message_edit(
412414
channel_id: Uuid,
@@ -931,11 +933,11 @@ mod tests {
931933
assert_eq!(event.pubkey.to_hex(), TARGET_HEX);
932934
}
933935

934-
// ── build_message_edit `p`-tag emission (lane 8ace8eed) ──────────────
936+
// ── build_message_edit p+mention emission (lane 8ace8eed) ──────────────
935937
//
936938
// The composer diffs the edited body's mentions against the original and
937939
// hands `build_message_edit` only the *newly added* pubkeys. These tests
938-
// pin the builder's contract given that contract: emit a `p` per added
940+
// pins the builder's contract: emit parallel `p` + `mention` per added
939941
// mention (deduped, lowercased), and none when the added set is empty
940942
// (typo-fix edit) — so an unchanged mention set re-wakes nobody.
941943

@@ -962,20 +964,21 @@ mod tests {
962964
let tags = edit_tags(&[ALICE_HEX]);
963965
assert_eq!(tags[0][0], "h");
964966
assert_eq!(tags[1][0], "e");
965-
// The `p` tag rides right after the `e` tag (insertion order).
967+
// `p` + parallel `mention` ride right after the `e` tag (insertion order).
966968
assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]);
969+
assert_eq!(tags[3], vec!["mention".to_string(), ALICE_HEX.to_string()]);
967970
}
968971

969972
#[test]
970973
fn edit_with_no_added_mentions_emits_no_p_tag() {
971974
// Typo-fix edit: mention set unchanged, so the composer passes `&[]`.
972-
// The edit event must carry no `p` tag and re-wake nobody.
975+
// The edit event must carry no `p`/`mention` tags and re-wake nobody.
973976
let tags = edit_tags(&[]);
974977
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:?}"
978+
!tags.iter().any(|t| {
979+
matches!(t.first().map(String::as_str), Some("p" | "mention"))
980+
}),
981+
"unchanged-mention edit must not emit p/mention tags, got {tags:?}"
979982
);
980983
}
981984

@@ -995,5 +998,18 @@ mod tests {
995998
);
996999
assert_eq!(p_tags[0], &vec!["p".to_string(), ALICE_HEX.to_string()]);
9971000
assert_eq!(p_tags[1], &vec!["p".to_string(), BOB_HEX.to_string()]);
1001+
let mention_tags: Vec<&Vec<String>> = tags
1002+
.iter()
1003+
.filter(|t| t.first().map(String::as_str) == Some("mention"))
1004+
.collect();
1005+
assert_eq!(mention_tags.len(), 2);
1006+
assert_eq!(
1007+
mention_tags[0],
1008+
&vec!["mention".to_string(), ALICE_HEX.to_string()]
1009+
);
1010+
assert_eq!(
1011+
mention_tags[1],
1012+
&vec!["mention".to_string(), BOB_HEX.to_string()]
1013+
);
9981014
}
9991015
}

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

@@ -512,8 +513,11 @@ export function useSendMessageMutation(
512513
...baseTags,
513514
// For non-replies, add mention p-tags here (replies get them via buildReplyTags)
514515
...(!parentEventId
515-
? normalizeMentionPubkeys(recipientPubkeys, identity.pubkey).map(
516-
(pk) => ["p", pk],
516+
? normalizeMentionPubkeys(recipientPubkeys, identity.pubkey).flatMap(
517+
(pk) => [
518+
["p", pk],
519+
["mention", pk],
520+
],
517521
)
518522
: []),
519523
...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
@@ -302,6 +302,7 @@ export class RelayClient {
302302
const tags: string[][] = [["h", channelId]];
303303
for (const pubkey of mentionPubkeys) {
304304
tags.push(["p", pubkey]);
305+
tags.push(["mention", pubkey]);
305306
}
306307
for (const tag of extraTags) {
307308
tags.push(tag);

desktop/src/testing/e2eBridge.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3472,6 +3472,7 @@ function appendMentionTags(
34723472
}
34733473
seen.add(lower);
34743474
tags.push(["p", lower]);
3475+
tags.push(["mention", lower]);
34753476
}
34763477
}
34773478

mobile/lib/features/channels/send_message_provider.dart

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

54+
// Intentional @mentions emit `p` (fan-out) + `mention` (explicit intent).
55+
// Structural reply routing uses only `e` tags here; relay offline notices
56+
// gate on `mention` so we never treat reply-author `p` as a mention.
5457
final tags = <List<String>>[
5558
['h', channelId],
5659
if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId),
57-
for (final pk in normalizedMentions) ['p', pk],
60+
for (final pk in normalizedMentions) ...[
61+
['p', pk],
62+
['mention', pk],
63+
],
5864
...mediaTags,
5965
];
6066

0 commit comments

Comments
 (0)