diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 9b4b80aa06..3d831b566c 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -306,6 +306,10 @@ pub struct SendReplyParams { /// If true, the reply is also broadcast to the main channel timeline. #[serde(default)] pub broadcast_to_channel: Option, + /// Hex-encoded pubkeys of users/agents mentioned in this reply. + /// Required for @mention notifications to reach mention-filtered subscribers. + #[serde(default)] + pub mention_pubkeys: Option>, } /// Parameters for the `get_thread` tool. @@ -1416,11 +1420,14 @@ impl SproutMcpServer { ); } - let body = serde_json::json!({ + let mut body = serde_json::json!({ "content": p.content, "parent_event_id": p.parent_event_id, "broadcast_to_channel": p.broadcast_to_channel.unwrap_or(false), }); + if let Some(ref mentions) = p.mention_pubkeys { + body["mention_pubkeys"] = serde_json::json!(mentions); + } match self .client .post(&format!("/api/channels/{}/messages", p.channel_id), &body) diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs index 0c62fc9574..f5ede79957 100644 --- a/crates/sprout-relay/src/api/messages.rs +++ b/crates/sprout-relay/src/api/messages.rs @@ -392,6 +392,48 @@ fn reactions_to_json(reactions: &[sprout_db::reaction::ReactionSummary]) -> serd // ── POST /api/channels/:channel_id/messages ─────────────────────────────────── +/// Maximum number of mention pubkeys allowed per message. +const MAX_MENTION_PUBKEYS: usize = 50; + +/// Validate, canonicalize, and deduplicate mention pubkeys. +/// +/// - Lowercases each entry (Nostr pubkeys are lowercase hex by convention). +/// - Validates each is a 64-char hex string (32-byte pubkey). +/// - Deduplicates (including against `self_pubkey_hex`). +/// - Rejects results exceeding [`MAX_MENTION_PUBKEYS`] **after** normalization. +/// +/// Returns the deduplicated, canonical pubkeys (excluding self). +fn normalize_mention_pubkeys( + raw: &[String], + self_pubkey_hex: &str, +) -> Result, (StatusCode, axum::Json)> { + let mut seen = std::collections::HashSet::new(); + seen.insert(self_pubkey_hex.to_ascii_lowercase()); + let mut result = Vec::new(); + for entry in raw { + let canonical = entry.to_ascii_lowercase(); + if canonical.len() != 64 || !canonical.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!("invalid mention_pubkeys entry: {entry}"), + )); + } + if seen.insert(canonical.clone()) { + result.push(canonical); + } + } + if result.len() > MAX_MENTION_PUBKEYS { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!( + "mention_pubkeys exceeds maximum of {MAX_MENTION_PUBKEYS} after deduplication (got {})", + result.len() + ), + )); + } + Ok(result) +} + /// Build Nostr tags for a kind:40008 diff message. /// /// Required fields: `diff_repo_url` (must be http/https) and `diff_commit_sha`. @@ -530,8 +572,9 @@ pub struct SendMessageBody { pub content: String, /// Hex-encoded event ID of the parent message (for replies). pub parent_event_id: Option, - /// Hex-encoded pubkeys to mention in the message (added as `p` tags). - /// Distinct from the author-attribution `p` tag which is always added. + /// Hex-encoded pubkeys of users mentioned in this message. + /// Each pubkey gets a `p` tag so mention-filtered subscriptions deliver the event. + /// Normalized server-side: lowercased, deduplicated, self-mention skipped, capped at 50. #[serde(default)] pub mention_pubkeys: Vec, /// When `true`, a reply is also surfaced in the channel feed (broadcast). @@ -707,19 +750,13 @@ pub async fn send_message( .map_err(|e| internal_error(&format!("tag build error: {e}")))?, ]; - // Mention p tags — distinct from the author-attribution p tag above. - for mention_hex in &body.mention_pubkeys { - // Validate hex format (32-byte pubkey = 64 hex chars) - if mention_hex.len() == 64 - && mention_hex - .chars() - .all(|c| matches!(c, '0'..='9' | 'a'..='f')) - { - tags.push( - Tag::parse(&["p", mention_hex]) - .map_err(|e| internal_error(&format!("tag build error: {e}")))?, - ); - } + // Mention p-tags — each mentioned pubkey gets a `p` tag so that + // mention-filtered subscriptions (e.g. ACP agent harness) receive the event. + for mention in normalize_mention_pubkeys(&body.mention_pubkeys, &user_pubkey_hex)? { + tags.push( + Tag::parse(&["p", &mention]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); } // Thread reply tags (NIP-10 style). @@ -1217,6 +1254,120 @@ pub async fn get_thread( mod tests { use super::*; + // ── normalize_mention_pubkeys tests ───────────────────────────────────── + + const SELF_PK: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OTHER_PK: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const THIRD_PK: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + #[test] + fn normalize_empty_array() { + let result = normalize_mention_pubkeys(&[], SELF_PK).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn normalize_single_valid_mention() { + let input = vec![OTHER_PK.to_string()]; + let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap(); + assert_eq!(result, vec![OTHER_PK]); + } + + #[test] + fn normalize_skips_self_mention() { + let input = vec![SELF_PK.to_string()]; + let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn normalize_skips_self_mention_case_insensitive() { + let upper_self = SELF_PK.to_uppercase(); + let input = vec![upper_self]; + let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn normalize_deduplicates() { + let input = vec![OTHER_PK.to_string(), OTHER_PK.to_string()]; + let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0], OTHER_PK); + } + + #[test] + fn normalize_deduplicates_mixed_case() { + let input = vec![OTHER_PK.to_string(), OTHER_PK.to_uppercase()]; + let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0], OTHER_PK); + } + + #[test] + fn normalize_canonicalizes_to_lowercase() { + let input = vec![OTHER_PK.to_uppercase()]; + let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap(); + assert_eq!(result, vec![OTHER_PK]); + } + + #[test] + fn normalize_preserves_order() { + let input = vec![THIRD_PK.to_string(), OTHER_PK.to_string()]; + let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap(); + assert_eq!(result, vec![THIRD_PK, OTHER_PK]); + } + + #[test] + fn normalize_rejects_invalid_hex() { + let input = + vec!["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".to_string()]; + let err = normalize_mention_pubkeys(&input, SELF_PK); + assert!(err.is_err()); + } + + #[test] + fn normalize_rejects_wrong_length() { + let input = vec!["aabb".to_string()]; + let err = normalize_mention_pubkeys(&input, SELF_PK); + assert!(err.is_err()); + } + + #[test] + fn normalize_rejects_exceeding_cap() { + let input: Vec = (0..MAX_MENTION_PUBKEYS + 1) + .map(|i| format!("{:064x}", i)) + .collect(); + let err = normalize_mention_pubkeys(&input, SELF_PK); + assert!(err.is_err()); + } + + #[test] + fn normalize_accepts_exactly_at_cap() { + // Generate MAX_MENTION_PUBKEYS unique pubkeys (none matching SELF_PK). + let input: Vec = (1..=MAX_MENTION_PUBKEYS) + .map(|i| format!("{:064x}", i)) + .collect(); + let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap(); + assert_eq!(result.len(), MAX_MENTION_PUBKEYS); + } + + #[test] + fn normalize_over_cap_raw_but_under_after_dedupe() { + // 51 raw entries: 50 unique pubkeys + 1 duplicate + self-mention. + // After dedupe and self-skip the result should be exactly 50. + let mut input: Vec = (1..=MAX_MENTION_PUBKEYS) + .map(|i| format!("{:064x}", i)) + .collect(); + input.push(SELF_PK.to_string()); // self-mention → skipped + input.push(format!("{:064x}", 1)); // duplicate → collapsed + assert!(input.len() > MAX_MENTION_PUBKEYS); + let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap(); + assert_eq!(result.len(), MAX_MENTION_PUBKEYS); + } + + // ── local media URL tests ─────────────────────────────────────────────── + const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; const BASE: &str = "https://relay.example.com/media"; diff --git a/crates/sprout-test-client/tests/e2e_rest_api.rs b/crates/sprout-test-client/tests/e2e_rest_api.rs index 6f91e17ee6..1d42afb8a5 100644 --- a/crates/sprout-test-client/tests/e2e_rest_api.rs +++ b/crates/sprout-test-client/tests/e2e_rest_api.rs @@ -2039,3 +2039,325 @@ async fn test_batch_add_mixed_policies() { "error should mention policy:nobody; got: {error_msg}" ); } + +// ── Thread reply mention p-tag tests ────────────────────────────────────────── + +/// Thread replies created via REST with `mention_pubkeys` must include the +/// extra `p` tags so that `#p`-filtered subscribers (e.g. ACP agent harness) +/// receive the event. This is the regression test for the bug where +/// @mentioning an agent in a thread reply did not trigger the agent. +#[tokio::test] +#[ignore] +async fn test_rest_reply_with_mention_pubkeys_includes_p_tags() { + let client = http_client(); + let poster_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let poster_hex = poster_keys.public_key().to_hex(); + let agent_hex = agent_keys.public_key().to_hex(); + let ws_url = relay_ws_url(); + + // Create an open channel. + let channels_url = format!("{}/api/channels", relay_http_url()); + let channel_name = format!("e2e-reply-mention-{}", uuid::Uuid::new_v4().simple()); + let create_resp = authed_post_json( + &client, + &channels_url, + &poster_hex, + serde_json::json!({ + "name": channel_name, + "channel_type": "stream", + "visibility": "open" + }), + ) + .await; + assert_eq!(create_resp.status(), 201, "failed to create test channel"); + let created: serde_json::Value = create_resp.json().await.expect("JSON"); + let channel_id = created["id"].as_str().expect("channel id").to_string(); + + // Subscribe as the "agent" with a #p filter matching agent_hex — this is + // exactly how the ACP harness subscribes. + let mut agent_sub = SproutTestClient::connect(&ws_url, &agent_keys) + .await + .expect("agent WS connect"); + let sid = format!("agent-mention-{}", uuid::Uuid::new_v4().simple()); + let filter = Filter::new() + .kind(Kind::Custom(9)) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::H), + [channel_id.as_str()], + ) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::P), + [agent_hex.as_str()], + ); + agent_sub + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + agent_sub + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE"); + + // Post a top-level message (the future thread root). + let msg_url = format!("{}/api/channels/{}/messages", relay_http_url(), channel_id); + let root_resp = authed_post_json( + &client, + &msg_url, + &poster_hex, + serde_json::json!({ "content": "thread root" }), + ) + .await; + assert_eq!(root_resp.status(), 200); + let root: serde_json::Value = root_resp.json().await.expect("JSON"); + let root_event_id = root["event_id"].as_str().expect("event_id").to_string(); + + // Post a reply that @mentions the agent via mention_pubkeys. + let reply_resp = authed_post_json( + &client, + &msg_url, + &poster_hex, + serde_json::json!({ + "content": "hey @agent check this out", + "parent_event_id": root_event_id, + "mention_pubkeys": [agent_hex], + }), + ) + .await; + assert_eq!( + reply_resp.status(), + 200, + "reply with mention_pubkeys failed" + ); + + // The agent subscriber should receive the reply because it has a p-tag + // matching agent_hex. + let msg = agent_sub + .recv_event(Duration::from_secs(5)) + .await + .expect("agent subscriber did not receive the reply — p-tag likely missing"); + + match msg { + RelayMessage::Event { event, .. } => { + assert_eq!(event.content, "hey @agent check this out"); + + let tags: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().iter().map(|s| s.to_string()).collect()) + .collect(); + + // Must have the agent's p-tag (the mention). + assert!( + tags.iter() + .any(|t| t.len() >= 2 && t[0] == "p" && t[1] == agent_hex), + "reply is missing the agent mention p-tag: {tags:?}" + ); + // Must still have the sender's p-tag (attribution). + assert!( + tags.iter() + .any(|t| t.len() >= 2 && t[0] == "p" && t[1] == poster_hex), + "reply is missing the sender attribution p-tag: {tags:?}" + ); + // Must have the reply e-tag. + assert!( + tags.iter().any(|t| t.len() >= 4 + && t[0] == "e" + && t[1] == root_event_id + && t[3] == "reply"), + "reply is missing the NIP-10 reply e-tag: {tags:?}" + ); + } + other => panic!("expected EVENT, got {other:?}"), + } + + agent_sub.disconnect().await.expect("disconnect"); +} + +/// mention_pubkeys validation: duplicates are deduplicated, self is skipped, +/// mixed-case is canonicalized, and invalid entries are rejected. +#[tokio::test] +#[ignore] +async fn test_rest_mention_pubkeys_validation() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Create a channel. + let channels_url = format!("{}/api/channels", relay_http_url()); + let channel_name = format!("e2e-mention-val-{}", uuid::Uuid::new_v4().simple()); + let create_resp = authed_post_json( + &client, + &channels_url, + &pubkey_hex, + serde_json::json!({ + "name": channel_name, + "channel_type": "stream", + "visibility": "open" + }), + ) + .await; + assert_eq!(create_resp.status(), 201); + let created: serde_json::Value = create_resp.json().await.expect("JSON"); + let channel_id = created["id"].as_str().expect("channel id").to_string(); + let msg_url = format!("{}/api/channels/{}/messages", relay_http_url(), channel_id); + + // Invalid hex should be rejected. + let resp = authed_post_json( + &client, + &msg_url, + &pubkey_hex, + serde_json::json!({ + "content": "bad mention", + "mention_pubkeys": ["not-valid-hex"], + }), + ) + .await; + assert_eq!(resp.status(), 400, "invalid hex should be rejected"); + + // Too many mentions should be rejected. + let too_many: Vec = (0..51).map(|i| format!("{:064x}", i)).collect(); + let resp = authed_post_json( + &client, + &msg_url, + &pubkey_hex, + serde_json::json!({ + "content": "too many mentions", + "mention_pubkeys": too_many, + }), + ) + .await; + assert_eq!( + resp.status(), + 400, + "exceeding 50 mentions should be rejected" + ); + + // Empty array should succeed. + let resp = authed_post_json( + &client, + &msg_url, + &pubkey_hex, + serde_json::json!({ + "content": "no mentions", + "mention_pubkeys": [], + }), + ) + .await; + assert_eq!(resp.status(), 200, "empty mention_pubkeys should succeed"); +} + +/// Thread reply with duplicate, uppercase, and self mention_pubkeys must +/// produce exactly the expected normalized p-tags: sender first, then each +/// unique mentioned pubkey lowercased, with self and duplicates removed. +#[tokio::test] +#[ignore] +async fn test_rest_mention_pubkeys_normalization_in_emitted_event() { + let client = http_client(); + let poster_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let poster_hex = poster_keys.public_key().to_hex(); + let agent_hex = agent_keys.public_key().to_hex(); + let ws_url = relay_ws_url(); + + // Create an open channel. + let channels_url = format!("{}/api/channels", relay_http_url()); + let channel_name = format!("e2e-norm-{}", uuid::Uuid::new_v4().simple()); + let create_resp = authed_post_json( + &client, + &channels_url, + &poster_hex, + serde_json::json!({ + "name": channel_name, + "channel_type": "stream", + "visibility": "open" + }), + ) + .await; + assert_eq!(create_resp.status(), 201); + let created: serde_json::Value = create_resp.json().await.expect("JSON"); + let channel_id = created["id"].as_str().expect("channel id").to_string(); + + // Subscribe to the channel (no #p filter — we want to see all events). + let mut sub = SproutTestClient::connect(&ws_url, &poster_keys) + .await + .expect("WS connect"); + let sid = format!("norm-{}", uuid::Uuid::new_v4().simple()); + let filter = Filter::new().kind(Kind::Custom(9)).custom_tag( + SingleLetterTag::lowercase(Alphabet::H), + [channel_id.as_str()], + ); + sub.subscribe(&sid, vec![filter]).await.expect("subscribe"); + sub.collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE"); + + // Post a root message. + let msg_url = format!("{}/api/channels/{}/messages", relay_http_url(), channel_id); + let root_resp = authed_post_json( + &client, + &msg_url, + &poster_hex, + serde_json::json!({ "content": "root" }), + ) + .await; + assert_eq!(root_resp.status(), 200); + let root: serde_json::Value = root_resp.json().await.expect("JSON"); + let root_id = root["event_id"].as_str().expect("event_id").to_string(); + + // Consume the root event so it doesn't interfere. + sub.recv_event(Duration::from_secs(5)) + .await + .expect("root event"); + + // Post a reply with: duplicate agent, uppercase agent, and self-mention. + let reply_resp = authed_post_json( + &client, + &msg_url, + &poster_hex, + serde_json::json!({ + "content": "normalization test", + "parent_event_id": root_id, + "mention_pubkeys": [ + agent_hex, + agent_hex.to_uppercase(), + agent_hex, + poster_hex, + ], + }), + ) + .await; + assert_eq!(reply_resp.status(), 200); + + let msg = sub + .recv_event(Duration::from_secs(5)) + .await + .expect("reply event"); + + match msg { + RelayMessage::Event { event, .. } => { + let p_tags: Vec = event + .tags + .iter() + .filter(|t| t.kind().to_string() == "p") + .filter_map(|t| t.content().map(|s| s.to_string())) + .collect(); + + // Expect exactly 2 p-tags: sender (first) + agent (deduplicated, lowercased). + // Self-mention (poster_hex) must be suppressed. + assert_eq!( + p_tags.len(), + 2, + "expected exactly 2 p-tags (sender + agent), got {p_tags:?}" + ); + assert_eq!(p_tags[0], poster_hex, "first p-tag must be sender"); + assert_eq!( + p_tags[1], agent_hex, + "second p-tag must be the agent (lowercased, deduplicated)" + ); + } + other => panic!("expected EVENT, got {other:?}"), + } + + sub.disconnect().await.expect("disconnect"); +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index d76c55d4e5..354a6d8ac3 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -50,13 +50,15 @@ pub async fn send_channel_message( state: State<'_, AppState>, ) -> Result { let path = format!("/api/channels/{channel_id}/messages"); + let mentions = mention_pubkeys.unwrap_or_default(); + let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect(); let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?.json( &SendChannelMessageBody { content: content.trim(), parent_event_id: parent_event_id.as_deref(), broadcast_to_channel: false, media_tags, - mention_pubkeys: mention_pubkeys.unwrap_or_default(), + mention_pubkeys: mention_refs, }, ); diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 77104538b1..9fc8da5ead 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -177,7 +177,7 @@ pub struct SendChannelMessageBody<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub media_tags: Option>>, #[serde(skip_serializing_if = "Vec::is_empty")] - pub mention_pubkeys: Vec, + pub mention_pubkeys: Vec<&'a str>, } #[derive(Serialize)] diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index a05cc5bd99..037308a446 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { updateChannelLastMessageAt } from "@/features/channels/hooks"; import { buildReplyTags, + normalizeMentionPubkeys, resolveReplyRootId, } from "@/features/messages/lib/threading"; import { relayClient } from "@/shared/api/relayClient"; @@ -73,11 +74,16 @@ function createOptimisticMessage( identity.pubkey, parentEventId, resolveReplyRootId(parentEventId, currentMessages), + mentionPubkeys, ), ); } else { tags.push(["h", channelId]); - for (const pubkey of mentionPubkeys) { + tags.push(["p", identity.pubkey]); + for (const pubkey of normalizeMentionPubkeys( + mentionPubkeys, + identity.pubkey, + )) { tags.push(["p", pubkey]); } } @@ -266,10 +272,11 @@ export function useSendMessageMutation( identity.pubkey, parentEventId, resolveReplyRootId(parentEventId, cachedMessages), + mentionPubkeys, ) : []; const baseTags = parentEventId - ? replyTags // buildReplyTags includes h + author p + ? replyTags // buildReplyTags includes h + author p + mention ps : [ ["h", channel.id], ["p", identity.pubkey], @@ -282,7 +289,13 @@ export function useSendMessageMutation( kind: KIND_STREAM_MESSAGE, tags: [ ...baseTags, - ...(mentionPubkeys ?? []).map((pk) => ["p", pk]), + // For non-replies, add mention p-tags here (replies get them via buildReplyTags) + ...(!parentEventId + ? normalizeMentionPubkeys( + mentionPubkeys ?? [], + identity.pubkey, + ).map((pk) => ["p", pk]) + : []), ...(mediaTags ?? []), ], content: content.trim(), diff --git a/desktop/src/features/messages/lib/threading.ts b/desktop/src/features/messages/lib/threading.ts index ced2ed2057..bfbd3a3a53 100644 --- a/desktop/src/features/messages/lib/threading.ts +++ b/desktop/src/features/messages/lib/threading.ts @@ -42,17 +42,48 @@ export function getThreadReference(tags: string[][]): ThreadReference { }; } +/** + * Best-effort client-side normalization of mention pubkeys: lowercase, deduplicate, skip self. + * The relay performs authoritative validation (hex format, 64-char length, cap of 50) + * on top of the same normalization — this helper keeps optimistic UI tags consistent. + */ +export function normalizeMentionPubkeys( + mentionPubkeys: string[], + selfPubkey: string, +): string[] { + const selfLower = selfPubkey.toLowerCase(); + const seen = new Set([selfLower]); + const result: string[] = []; + for (const pk of mentionPubkeys) { + const lower = pk.toLowerCase(); + if (seen.has(lower)) { + continue; + } + seen.add(lower); + result.push(lower); + } + return result; +} + export function buildReplyTags( channelId: string, authorPubkey: string, parentEventId: string, rootEventId: string, + mentionPubkeys: string[] = [], ) { const tags: string[][] = [ ["p", authorPubkey], ["h", channelId], ]; + // Add p-tags for mentioned users so mention-filtered subscriptions + // (e.g. ACP agent harness) receive the reply event. + // Best-effort normalization — relay performs authoritative validation. + for (const pubkey of normalizeMentionPubkeys(mentionPubkeys, authorPubkey)) { + tags.push(["p", pubkey]); + } + if (parentEventId === rootEventId) { tags.push(["e", rootEventId, "", "reply"]); return tags; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 76bebf1405..299eef0e09 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2271,6 +2271,7 @@ async function handleSendChannelMessage( channelId: string; content: string; parentEventId?: string | null; + mentionPubkeys?: string[]; }, config: E2eConfig | undefined, ): Promise { @@ -2329,19 +2330,32 @@ async function handleSendChannelMessage( pubkey: getMockMemberPubkey(config), created_at: createdAt, kind: 9, - tags: - rootEventId === args.parentEventId - ? [ - ["p", getMockMemberPubkey(config)], - ["h", args.channelId], - ["e", rootEventId, "", "reply"], - ] - : [ - ["p", getMockMemberPubkey(config)], - ["h", args.channelId], - ["e", rootEventId, "", "root"], - ["e", args.parentEventId, "", "reply"], - ], + tags: (() => { + const authorPubkey = getMockMemberPubkey(config); + // Match production tag ordering: author p, h, mention ps, then e-tags. + const tags: string[][] = [ + ["p", authorPubkey], + ["h", args.channelId], + ]; + // Best-effort client-side normalization (relay is authoritative). + const selfLower = authorPubkey.toLowerCase(); + const seen = new Set([selfLower]); + for (const pk of args.mentionPubkeys ?? []) { + const lower = pk.toLowerCase(); + if (!seen.has(lower)) { + seen.add(lower); + tags.push(["p", lower]); + } + } + // Thread e-tags come after mention p-tags. + if (rootEventId === args.parentEventId) { + tags.push(["e", rootEventId, "", "reply"]); + } else { + tags.push(["e", rootEventId, "", "root"]); + tags.push(["e", args.parentEventId, "", "reply"]); + } + return tags; + })(), content: args.content.trim(), sig: "mocksig".repeat(20).slice(0, 128), }; @@ -2367,6 +2381,7 @@ async function handleSendChannelMessage( content: args.content, parent_event_id: args.parentEventId, broadcast_to_channel: false, + mention_pubkeys: args.mentionPubkeys ?? [], }), }, );