diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ecb3e41fcc..dc532d96af 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1,6 +1,9 @@ //! EVENT handler — WS dispatcher → ingest pipeline → fan-out. -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use axum::body::Bytes; use tracing::{debug, error, info, warn}; @@ -73,6 +76,105 @@ where frames } +const EXPLICIT_MENTION_TAG: &str = "mention"; +const MAX_EXPLICIT_MENTION_NOTICES: usize = 50; + +/// Extract Buzz explicit mentions from kind:9 message metadata. +/// +/// Raw Nostr `p` tags are structural reply/subscription metadata in Buzz, so +/// they are deliberately ignored here. Only client-authored +/// `["mention", ""]` tags mean the sender explicitly mentioned a +/// user and should receive an offline-presence warning. +fn explicit_mention_pubkeys_for_kind(event: &Event, kind_u32: u32) -> Vec { + if kind_u32 != buzz_core::kind::KIND_STREAM_MESSAGE { + return Vec::new(); + } + + let mut seen = HashSet::new(); + let mut pubkeys = Vec::new(); + + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some(EXPLICIT_MENTION_TAG) { + continue; + } + + let Some(pubkey_hex) = parts.get(1) else { + continue; + }; + let Ok(pubkey) = PublicKey::from_hex(pubkey_hex) else { + continue; + }; + if pubkey == event.pubkey { + continue; + } + + if seen.insert(pubkey.to_hex()) { + pubkeys.push(pubkey); + if pubkeys.len() >= MAX_EXPLICIT_MENTION_NOTICES { + break; + } + } + } + + pubkeys +} + +fn has_current_buzz_presence(status: Option<&String>) -> bool { + status.is_some_and(|status| !status.eq_ignore_ascii_case("offline")) +} + +fn offline_mention_notice_pubkeys( + accepted: bool, + mentioned_pubkeys: &[PublicKey], + presence_by_pubkey: &HashMap, +) -> Vec { + if !accepted { + return Vec::new(); + } + + mentioned_pubkeys + .iter() + .copied() + .filter(|pubkey| !has_current_buzz_presence(presence_by_pubkey.get(&pubkey.to_hex()))) + .collect() +} + +async fn send_offline_mention_notices( + state: &AppState, + conn: &ConnectionState, + mentioned_pubkeys: &[PublicKey], +) { + if mentioned_pubkeys.is_empty() { + return; + } + + let presence_by_pubkey = match state + .pubsub + .get_presence_bulk(&conn.tenant, mentioned_pubkeys) + .await + { + Ok(presence) => presence, + Err(error) => { + warn!( + error = %error, + mention_count = mentioned_pubkeys.len(), + "offline mention presence lookup failed; skipping sender NOTICE" + ); + metrics::counter!("buzz_mention_presence_lookup_errors_total").increment(1); + return; + } + }; + + for pubkey in offline_mention_notice_pubkeys(true, mentioned_pubkeys, &presence_by_pubkey) { + let pubkey_hex = pubkey.to_hex(); + metrics::counter!("buzz_mention_offline_notices_total").increment(1); + conn.send(RelayMessage::notice(&format!( + "offline: explicitly mentioned user {pubkey_hex} has no current Buzz presence in this workspace" + ))); + } +} + fn send_fanout_frames<'a, I>( state: &AppState, recipients: I, @@ -702,6 +804,11 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { if result.accepted { @@ -721,6 +828,13 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { // Sanitize internal errors — don't leak DB/system details over WS. @@ -1186,6 +1300,81 @@ mod tests { ); } + #[test] + fn explicit_mention_pubkeys_ignore_structural_p_tags() { + let sender = Keys::generate(); + let reply_author = Keys::generate(); + let explicit = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "hi") + .tags([ + Tag::parse(["h", &uuid::Uuid::new_v4().to_string()]).expect("h tag"), + Tag::parse(["p", &reply_author.public_key().to_hex()]).expect("p tag"), + Tag::parse(["mention", &explicit.public_key().to_hex()]).expect("mention tag"), + ]) + .sign_with_keys(&sender) + .expect("sign event"); + + assert_eq!( + super::explicit_mention_pubkeys_for_kind(&event, KIND_STREAM_MESSAGE), + vec![explicit.public_key()] + ); + } + + #[test] + fn explicit_mention_pubkeys_dedupe_filter_and_cap() { + let sender = Keys::generate(); + let duplicate = Keys::generate().public_key(); + let mut expected = vec![duplicate]; + let mut tags = vec![ + Tag::parse(["h", &uuid::Uuid::new_v4().to_string()]).expect("h tag"), + Tag::parse(["mention", &duplicate.to_hex()]).expect("mention tag"), + Tag::parse(["mention", &duplicate.to_hex().to_ascii_uppercase()]) + .expect("duplicate mention tag"), + Tag::parse(["mention", &sender.public_key().to_hex()]).expect("self mention tag"), + Tag::parse(["mention", "not-a-pubkey"]).expect("invalid mention tag"), + ]; + + for _ in 1..(super::MAX_EXPLICIT_MENTION_NOTICES + 5) { + let pubkey = Keys::generate().public_key(); + expected.push(pubkey); + tags.push(Tag::parse(["mention", &pubkey.to_hex()]).expect("mention tag")); + } + expected.truncate(super::MAX_EXPLICIT_MENTION_NOTICES); + + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "hi") + .tags(tags) + .sign_with_keys(&sender) + .expect("sign event"); + + assert_eq!( + super::explicit_mention_pubkeys_for_kind(&event, KIND_STREAM_MESSAGE), + expected + ); + assert!( + super::explicit_mention_pubkeys_for_kind(&event, KIND_FORUM_POST).is_empty(), + "offline mention NOTICEs are only for stream messages" + ); + } + + #[test] + fn offline_mention_notice_pubkeys_require_acceptance_and_missing_presence() { + let offline = Keys::generate().public_key(); + let explicit_offline = Keys::generate().public_key(); + let online = Keys::generate().public_key(); + let away = Keys::generate().public_key(); + let mentioned = vec![offline, explicit_offline, online, away]; + let mut presence = HashMap::new(); + presence.insert(explicit_offline.to_hex(), "offline".to_string()); + presence.insert(online.to_hex(), "online".to_string()); + presence.insert(away.to_hex(), "away".to_string()); + + assert!(super::offline_mention_notice_pubkeys(false, &mentioned, &presence).is_empty()); + assert_eq!( + super::offline_mention_notice_pubkeys(true, &mentioned, &presence), + vec![offline, explicit_offline] + ); + } + #[test] fn channel_scoped_content_kinds_require_h_tags() { for kind in [ diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f9e54de9c5..8bb6c14ecd 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -184,16 +184,19 @@ fn thread_tags(thread_ref: &ThreadRef, tags: &mut Vec) -> Result<(), SdkErr Ok(()) } -/// Deduplicate and cap mentions, emitting p-tags. +/// Deduplicate and cap mentions, emitting delivery `p` tags and explicit +/// Buzz `mention` reference tags. The relay uses the latter to distinguish +/// actual @mentions from structural reply-author `p` tags. fn mention_tags(mentions: &[&str], tags: &mut Vec) -> Result<(), SdkError> { if mentions.len() > crate::mentions::MENTION_CAP { return Err(SdkError::TooManyMentions); } let mut seen = std::collections::HashSet::new(); for &hex in mentions { - let lower = hex.to_ascii_lowercase(); + let lower = check_pubkey_hex(hex, "mention")?; if seen.insert(lower.clone()) { tags.push(tag(&["p", &lower])?); + tags.push(tag(&["mention", &lower])?); } } Ok(()) @@ -213,7 +216,7 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk /// - `channel_id`: target channel UUID /// - `content`: message text (max 64 KiB) /// - `thread_ref`: optional NIP-10 reply context -/// - `mentions`: pubkey hex strings to p-tag (deduped, max 50) +/// - `mentions`: pubkey hex strings to p-tag and `mention`-tag (deduped, max 50) /// - `broadcast`: if true, adds `["broadcast", "1"]` tag /// - `media_tags`: raw imeta tag vectors pub fn build_message( @@ -1977,12 +1980,14 @@ mod tests { } #[test] - fn message_mentions_deduped() { + fn message_mentions_emit_delivery_and_reference_tags_deduped() { let cid = uuid(); let hex = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap()); let p_tags = tag_values(&ev, "p"); - assert_eq!(p_tags.len(), 1); + let mention_tags = tag_values(&ev, "mention"); + assert_eq!(p_tags, vec![hex]); + assert_eq!(mention_tags, vec![hex]); } #[test] diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..4bacabf5db 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -77,6 +77,13 @@ fn mention_tags(mentions: &[&str]) -> Result, String> { } fn mention_reference_tags(mentions: &[Vec], tags: &mut Vec) -> Result<(), String> { + if mentions.len() > MAX_MENTIONS { + return Err(format!( + "too many mention reference tags (max {MAX_MENTIONS})" + )); + } + + let mut seen = std::collections::HashSet::new(); for mention in mentions { if mention.first().map(String::as_str) != Some("mention") { return Err(format!( @@ -84,11 +91,17 @@ fn mention_reference_tags(mentions: &[Vec], tags: &mut Vec) -> Resu mention.first() )); } + if mention.len() != 2 { + return Err("mention reference tags must contain exactly prefix and pubkey".into()); + } let Some(pubkey) = mention.get(1) else { return Err("mention reference tag missing pubkey".into()); }; check_pubkey(pubkey)?; - tags.push(tag(vec!["mention", &pubkey.to_ascii_lowercase()])?); + let lower = pubkey.to_ascii_lowercase(); + if seen.insert(lower.clone()) { + tags.push(tag(vec!["mention", &lower])?); + } } Ok(()) } @@ -847,153 +860,5 @@ pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); - - assert_eq!(event.kind, Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16)); - // Spec layout: ["-"], ["p", target], ["reason", code], ["auth", ...] - assert_eq!(tags[0], vec!["-"]); - assert_eq!(tags[1], vec!["p", TARGET_HEX]); - assert_eq!(tags[2], vec!["reason", "bot-rebuilt"]); - assert_eq!(tags[3], vec!["auth", OWNER_HEX, CONDITIONS, SIG]); - } - - #[test] - fn archive_request_rejects_replaced_by_equal_target() { - const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; - let err = build_archive_identity_request(TARGET_HEX, "", None, Some(TARGET_HEX), None) - .unwrap_err(); - assert!(err.contains("replaced-by")); - } - - #[test] - fn unarchive_request_layout_self_path() { - const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; - let builder = build_unarchive_identity_request( - TARGET_HEX, - "I am active again.", - Some("returned"), - None, - ) - .unwrap(); - let target_secret = nostr::SecretKey::from_hex( - "0000000000000000000000000000000000000000000000000000000000000002", - ) - .unwrap(); - let event = builder.sign_with_keys(&Keys::new(target_secret)).unwrap(); - let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); - assert_eq!(event.kind, Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16)); - // Self-unarchive: the `p` tag MUST point at the signer. Verifies our - // `.allow_self_tagging()` call survives nostr 0.44's default scrub. - assert_eq!(tags[0], vec!["-"]); - assert_eq!(tags[1], vec!["p", TARGET_HEX]); - assert_eq!(tags[2], vec!["reason", "returned"]); - assert_eq!(tags.len(), 3, "self unarchive must not carry auth tag"); - assert_eq!(event.pubkey.to_hex(), TARGET_HEX); - } - - // ── build_message_edit `p`-tag emission (lane 8ace8eed) ────────────── - // - // The composer diffs the edited body's mentions against the original and - // hands `build_message_edit` only the *newly added* pubkeys. These tests - // pin the builder's contract given that contract: emit a `p` per added - // mention (deduped, lowercased), and none when the added set is empty - // (typo-fix edit) — so an unchanged mention set re-wakes nobody. - - const CH_ID: &str = "11111111-1111-4111-8111-111111111111"; - const ALICE_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - const BOB_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; - - fn edit_tags(mentions: &[&str]) -> Vec> { - let channel = Uuid::parse_str(CH_ID).unwrap(); - let target = - EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") - .unwrap(); - let builder = build_message_edit(channel, target, "hi @alice", &[], &[], mentions).unwrap(); - let secret = nostr::SecretKey::from_hex( - "0000000000000000000000000000000000000000000000000000000000000003", - ) - .unwrap(); - let event = builder.sign_with_keys(&Keys::new(secret)).unwrap(); - event.tags.iter().map(|t| t.as_slice().to_vec()).collect() - } - - #[test] - fn edit_with_added_mention_emits_p_tag() { - let tags = edit_tags(&[ALICE_HEX]); - assert_eq!(tags[0][0], "h"); - assert_eq!(tags[1][0], "e"); - // The `p` tag rides right after the `e` tag (insertion order). - assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]); - } - - #[test] - fn edit_with_no_added_mentions_emits_no_p_tag() { - // Typo-fix edit: mention set unchanged, so the composer passes `&[]`. - // The edit event must carry no `p` tag and re-wake nobody. - let tags = edit_tags(&[]); - assert!( - !tags - .iter() - .any(|t| t.first().map(String::as_str) == Some("p")), - "unchanged-mention edit must not emit any `p` tag, got {tags:?}" - ); - } - - #[test] - fn edit_mentions_are_deduped_and_lowercased() { - let alice_upper = ALICE_HEX.to_ascii_uppercase(); - let tags = edit_tags(&[ALICE_HEX, &alice_upper, BOB_HEX]); - let p_tags: Vec<&Vec> = tags - .iter() - .filter(|t| t.first().map(String::as_str) == Some("p")) - .collect(); - // ALICE appears twice (mixed case) but collapses to one lowercase tag. - assert_eq!( - p_tags.len(), - 2, - "duplicate mention must collapse, got {p_tags:?}" - ); - assert_eq!(p_tags[0], &vec!["p".to_string(), ALICE_HEX.to_string()]); - assert_eq!(p_tags[1], &vec!["p".to_string(), BOB_HEX.to_string()]); - } -} +#[path = "events_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/events_tests.rs b/desktop/src-tauri/src/events_tests.rs new file mode 100644 index 0000000000..e7a017ca3d --- /dev/null +++ b/desktop/src-tauri/src/events_tests.rs @@ -0,0 +1,215 @@ +use super::*; +use nostr::Keys; + +#[test] +fn channel_builders_reject_hash_only_names() { + let channel_id = Uuid::new_v4(); + assert!(build_create_channel(channel_id, "###", "open", "stream", None, None).is_err()); + assert!(build_update_channel(channel_id, Some("###"), None, None, None).is_err()); +} + +/// Builder layout regression for the NIP-IA owner-of-agent archive flow. +/// Compares against `docs/nips/NIP-IA.md` §Vector 1. +#[test] +fn archive_identity_request_matches_spec_vector_1_layout() { + const OWNER_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + const CONDITIONS: &str = "kind=1&created_at<1713957000"; + const SIG: &str = "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369"; + + let auth: [String; 4] = [ + "auth".into(), + OWNER_HEX.into(), + CONDITIONS.into(), + SIG.into(), + ]; + let builder = build_archive_identity_request( + TARGET_HEX, + "Archiving zombie agent after rebuild.", + Some("bot-rebuilt"), + None, + Some(&auth), + ) + .expect("build_archive_identity_request"); + + let owner_secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .unwrap(); + let owner_keys = Keys::new(owner_secret); + let event = builder.sign_with_keys(&owner_keys).unwrap(); + + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + + assert_eq!(event.kind, Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16)); + // Spec layout: ["-"], ["p", target], ["reason", code], ["auth", ...] + assert_eq!(tags[0], vec!["-"]); + assert_eq!(tags[1], vec!["p", TARGET_HEX]); + assert_eq!(tags[2], vec!["reason", "bot-rebuilt"]); + assert_eq!(tags[3], vec!["auth", OWNER_HEX, CONDITIONS, SIG]); +} + +#[test] +fn archive_request_rejects_replaced_by_equal_target() { + const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + let err = + build_archive_identity_request(TARGET_HEX, "", None, Some(TARGET_HEX), None).unwrap_err(); + assert!(err.contains("replaced-by")); +} + +#[test] +fn build_message_accepts_deduped_mention_reference_tags() { + const PUBKEY_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const PUBKEY_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let channel_id = uuid::Uuid::new_v4(); + let builder = build_message( + channel_id, + "hi", + None, + &[PUBKEY_A], + &[], + &[], + &[ + vec!["mention".into(), PUBKEY_A.to_ascii_uppercase()], + vec!["mention".to_string(), PUBKEY_A.to_string()], + vec!["mention".to_string(), PUBKEY_B.to_string()], + ], + ) + .expect("build message"); + + let event = builder.sign_with_keys(&Keys::generate()).expect("sign"); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert!(tags.contains(&vec!["p".to_string(), PUBKEY_A.to_string()])); + assert_eq!( + tags.iter() + .filter(|tag| tag.first().map(String::as_str) == Some("mention")) + .cloned() + .collect::>(), + vec![ + vec!["mention".to_string(), PUBKEY_A.to_string()], + vec!["mention".to_string(), PUBKEY_B.to_string()], + ] + ); +} + +#[test] +fn build_message_rejects_malformed_mention_reference_tags() { + const PUBKEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let channel_id = uuid::Uuid::new_v4(); + + let wrong_prefix = build_message( + channel_id, + "hi", + None, + &[], + &[], + &[], + &[vec!["p".into(), PUBKEY.into()]], + ) + .unwrap_err(); + assert!(wrong_prefix.contains("mention reference tags must use 'mention' prefix")); + + let extra_field = build_message( + channel_id, + "hi", + None, + &[], + &[], + &[], + &[vec!["mention".into(), PUBKEY.into(), "extra".into()]], + ) + .unwrap_err(); + assert!(extra_field.contains("exactly prefix and pubkey")); + + let too_many = vec![vec!["mention".into(), PUBKEY.into()]; MAX_MENTIONS + 1]; + let too_many_err = build_message(channel_id, "hi", None, &[], &[], &[], &too_many).unwrap_err(); + assert!(too_many_err.contains("too many mention reference tags")); +} + +#[test] +fn unarchive_request_layout_self_path() { + const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + let builder = + build_unarchive_identity_request(TARGET_HEX, "I am active again.", Some("returned"), None) + .unwrap(); + let target_secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000002", + ) + .unwrap(); + let event = builder.sign_with_keys(&Keys::new(target_secret)).unwrap(); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert_eq!(event.kind, Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16)); + // Self-unarchive: the `p` tag MUST point at the signer. Verifies our + // `.allow_self_tagging()` call survives nostr 0.44's default scrub. + assert_eq!(tags[0], vec!["-"]); + assert_eq!(tags[1], vec!["p", TARGET_HEX]); + assert_eq!(tags[2], vec!["reason", "returned"]); + assert_eq!(tags.len(), 3, "self unarchive must not carry auth tag"); + assert_eq!(event.pubkey.to_hex(), TARGET_HEX); +} + +// ── build_message_edit `p`-tag emission (lane 8ace8eed) ────────────── +// +// The composer diffs the edited body's mentions against the original and +// hands `build_message_edit` only the *newly added* pubkeys. These tests +// pin the builder's contract given that contract: emit a `p` per added +// mention (deduped, lowercased), and none when the added set is empty +// (typo-fix edit) — so an unchanged mention set re-wakes nobody. + +const CH_ID: &str = "11111111-1111-4111-8111-111111111111"; +const ALICE_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const BOB_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + +fn edit_tags(mentions: &[&str]) -> Vec> { + let channel = Uuid::parse_str(CH_ID).unwrap(); + let target = + EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") + .unwrap(); + let builder = build_message_edit(channel, target, "hi @alice", &[], &[], mentions).unwrap(); + let secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000003", + ) + .unwrap(); + let event = builder.sign_with_keys(&Keys::new(secret)).unwrap(); + event.tags.iter().map(|t| t.as_slice().to_vec()).collect() +} + +#[test] +fn edit_with_added_mention_emits_p_tag() { + let tags = edit_tags(&[ALICE_HEX]); + assert_eq!(tags[0][0], "h"); + assert_eq!(tags[1][0], "e"); + // The `p` tag rides right after the `e` tag (insertion order). + assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]); +} + +#[test] +fn edit_with_no_added_mentions_emits_no_p_tag() { + // Typo-fix edit: mention set unchanged, so the composer passes `&[]`. + // The edit event must carry no `p` tag and re-wake nobody. + let tags = edit_tags(&[]); + assert!( + !tags + .iter() + .any(|t| t.first().map(String::as_str) == Some("p")), + "unchanged-mention edit must not emit any `p` tag, got {tags:?}" + ); +} + +#[test] +fn edit_mentions_are_deduped_and_lowercased() { + let alice_upper = ALICE_HEX.to_ascii_uppercase(); + let tags = edit_tags(&[ALICE_HEX, &alice_upper, BOB_HEX]); + let p_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(String::as_str) == Some("p")) + .collect(); + // ALICE appears twice (mixed case) but collapses to one lowercase tag. + assert_eq!( + p_tags.len(), + 2, + "duplicate mention must collapse, got {p_tags:?}" + ); + assert_eq!(p_tags[0], &vec!["p".to_string(), ALICE_HEX.to_string()]); + assert_eq!(p_tags[1], &vec!["p".to_string(), BOB_HEX.to_string()]); +} diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 9561b2138c..b50500edbc 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -55,6 +55,7 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; +import { useRelayNoticeToasts } from "@/shared/api/useRelayNoticeToasts"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; @@ -312,6 +313,7 @@ function CommunityApp({ // as toasts. Mounted before useCommunityInit so the listeners are registered // ahead of the first apply_workspace call. useNestNotifications(); + useRelayNoticeToasts(); // Composite key: changes when community ID changes OR when // the active community's config is updated (relayUrl/token). diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b..62f67b495b 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -465,10 +465,15 @@ export function useSendMessageMutation( mentionPubkeys, ); - // Messages carrying media OR custom-emoji tags MUST go through REST so - // the relay's tag validation runs. The WebSocket path emits no extra - // tags, so emoji-only messages would otherwise lose their emoji tag. - if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) { + // Messages carrying media, custom-emoji, or explicit mention reference + // tags MUST go through REST so the Rust tag guards validate each channel. + // The WebSocket path emits only structural send tags. + if ( + parentEventId || + imetaTags.length > 0 || + emojiTags.length > 0 || + mentionTags.length > 0 + ) { const cachedMessages = queryClient.getQueryData( channelMessagesKey(effectiveChannel.id), diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6d4e007cd4..2d14041d6f 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -27,6 +27,7 @@ import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; +import { withMentionReferenceTags } from "@/shared/lib/mentionReferenceTags"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; type PendingNonMemberMentionSend = { @@ -110,21 +111,6 @@ type UseMentionSendFlowOptions = { resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; }; -function mergeOutgoingTagsWithReferenceMentions( - outgoingTags: string[][] | undefined, - pubkeys: Iterable, -) { - const normalizedPubkeys = uniqueNormalizedPubkeys(pubkeys); - if (normalizedPubkeys.length === 0) { - return outgoingTags; - } - - return [ - ...(outgoingTags ?? []), - ...normalizedPubkeys.map((pubkey) => [MENTION_REFERENCE_TAG, pubkey]), - ]; -} - function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -514,11 +500,16 @@ export function useMentionSendFlow({ ); } + const finalOutgoingTags = withMentionReferenceTags( + outgoingTags ?? [], + mentionPubkeys, + ); + try { await onSendRef.current( draft.finalContent, mentionPubkeys, - outgoingTags, + finalOutgoingTags, sendChannelId, draft.capturedThreadContext, ); @@ -799,8 +790,8 @@ export function useMentionSendFlow({ const mentionPubkeys = pendingNonMemberSend.mentionPubkeys.filter( (pubkey) => !nonMemberPubkeys.has(normalizePubkey(pubkey)), ); - const outgoingTags = mergeOutgoingTagsWithReferenceMentions( - pendingNonMemberSend.outgoingTags, + const outgoingTags = withMentionReferenceTags( + pendingNonMemberSend.outgoingTags ?? [], nonMemberPubkeys, ); void completeSend(pendingNonMemberSend, mentionPubkeys, outgoingTags); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index b8c861af96..fff7b6da1e 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -42,6 +42,8 @@ import { } from "@/shared/api/relayRateLimitGate"; import { requestHistoryGated } from "@/shared/api/relayGateBoundary"; import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter"; +import { handleRelayNoticeFrame } from "@/shared/api/relayNotices"; +import { withMentionReferenceTags } from "@/shared/lib/mentionReferenceTags"; import { isServiceRestartClose, isWebSocketClose, @@ -303,9 +305,7 @@ export class RelayClient { for (const pubkey of mentionPubkeys) { tags.push(["p", pubkey]); } - for (const tag of extraTags) { - tags.push(tag); - } + tags.push(...withMentionReferenceTags(extraTags, mentionPubkeys)); const event = await signRelayEvent({ kind: KIND_STREAM_MESSAGE, @@ -801,6 +801,8 @@ export class RelayClient { return; } + if (handleRelayNoticeFrame(data)) return; + const [type, ...rest] = data; if (type === "AUTH" && typeof rest[0] === "string") { await this.handleAuthChallenge(rest[0], generation); diff --git a/desktop/src/shared/api/relayNotices.test.mjs b/desktop/src/shared/api/relayNotices.test.mjs new file mode 100644 index 0000000000..06cf71ac5b --- /dev/null +++ b/desktop/src/shared/api/relayNotices.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + clearRelayNoticeListenersForTests, + emitRelayNotice, + handleRelayNoticeFrame, + relayNoticeMessageFromFrame, + subscribeToRelayNotices, +} from "./relayNotices.ts"; + +test.afterEach(() => { + clearRelayNoticeListenersForTests(); +}); + +test("relayNoticeMessageFromFrame parses NOTICE messages only", () => { + assert.equal(relayNoticeMessageFromFrame(["NOTICE", "hello"]), "hello"); + assert.equal(relayNoticeMessageFromFrame(["NOTICE", " "]), null); + assert.equal(relayNoticeMessageFromFrame(["OK", "event", true, ""]), null); + assert.equal(relayNoticeMessageFromFrame({ type: "NOTICE" }), null); +}); + +test("handleRelayNoticeFrame consumes NOTICE frames without treating them as errors", () => { + const messages = []; + + assert.equal( + handleRelayNoticeFrame(["NOTICE", "offline warning"], (message) => { + messages.push(message); + }), + true, + ); + assert.deepEqual(messages, ["offline warning"]); + + assert.equal(handleRelayNoticeFrame(["NOTICE", " "], messages.push), true); + assert.deepEqual(messages, ["offline warning"]); + assert.equal( + handleRelayNoticeFrame(["EOSE", "sub-id"], messages.push), + false, + ); +}); + +test("emitRelayNotice notifies subscribers and respects unsubscribe", () => { + const messages = []; + const unsubscribe = subscribeToRelayNotices((message) => { + messages.push(message); + }); + + emitRelayNotice("first"); + unsubscribe(); + emitRelayNotice("second"); + emitRelayNotice(" "); + + assert.deepEqual(messages, ["first"]); +}); diff --git a/desktop/src/shared/api/relayNotices.ts b/desktop/src/shared/api/relayNotices.ts new file mode 100644 index 0000000000..f8809cb9b9 --- /dev/null +++ b/desktop/src/shared/api/relayNotices.ts @@ -0,0 +1,64 @@ +export type RelayNoticeListener = (message: string) => void; + +const relayNoticeListeners = new Set(); + +export function subscribeToRelayNotices( + listener: RelayNoticeListener, +): () => void { + relayNoticeListeners.add(listener); + return () => { + relayNoticeListeners.delete(listener); + }; +} + +export function emitRelayNotice(message: string): void { + if (message.trim().length === 0) { + return; + } + + for (const listener of relayNoticeListeners) { + try { + listener(message); + } catch (error) { + console.error("Failed to deliver relay NOTICE", error); + } + } +} + +export function relayNoticeMessageFromFrame(frame: unknown): string | null { + if (!Array.isArray(frame) || frame[0] !== "NOTICE") { + return null; + } + + const message = frame[1]; + if (typeof message !== "string" || message.trim().length === 0) { + return null; + } + + return message; +} + +/** + * Consume a NIP-01 NOTICE frame and surface it to registered UI listeners. + * Returns true for all NOTICE frames, including malformed/blank ones, so callers + * do not treat relay warnings as socket failures or ordinary protocol frames. + */ +export function handleRelayNoticeFrame( + frame: unknown, + emit: RelayNoticeListener = emitRelayNotice, +): boolean { + if (!Array.isArray(frame) || frame[0] !== "NOTICE") { + return false; + } + + const message = relayNoticeMessageFromFrame(frame); + if (message) { + emit(message); + } + + return true; +} + +export function clearRelayNoticeListenersForTests(): void { + relayNoticeListeners.clear(); +} diff --git a/desktop/src/shared/api/useRelayNoticeToasts.ts b/desktop/src/shared/api/useRelayNoticeToasts.ts new file mode 100644 index 0000000000..c083495d2e --- /dev/null +++ b/desktop/src/shared/api/useRelayNoticeToasts.ts @@ -0,0 +1,12 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { subscribeToRelayNotices } from "@/shared/api/relayNotices"; + +export function useRelayNoticeToasts(): void { + React.useEffect(() => { + return subscribeToRelayNotices((message) => { + toast.warning(message); + }); + }, []); +} diff --git a/desktop/src/shared/lib/mentionReferenceTags.test.mjs b/desktop/src/shared/lib/mentionReferenceTags.test.mjs new file mode 100644 index 0000000000..f9b32da29a --- /dev/null +++ b/desktop/src/shared/lib/mentionReferenceTags.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + appendMentionReferenceTags, + withMentionReferenceTags, +} from "./mentionReferenceTags.ts"; + +const PUBKEY_A = "A".repeat(64); +const PUBKEY_B = "b".repeat(64); + +test("appendMentionReferenceTags appends normalized explicit mention tags", () => { + const tags = [["h", "channel-1"]]; + + appendMentionReferenceTags(tags, [PUBKEY_A, PUBKEY_B]); + + assert.deepEqual(tags, [ + ["h", "channel-1"], + ["mention", PUBKEY_A.toLowerCase()], + ["mention", PUBKEY_B], + ]); +}); + +test("appendMentionReferenceTags dedupes existing mention reference tags", () => { + const tags = [["mention", PUBKEY_A.toLowerCase()]]; + + appendMentionReferenceTags(tags, [ + PUBKEY_A, + PUBKEY_B, + PUBKEY_B.toUpperCase(), + ]); + + assert.deepEqual(tags, [ + ["mention", PUBKEY_A.toLowerCase()], + ["mention", PUBKEY_B], + ]); +}); + +test("withMentionReferenceTags does not mutate the input tag array", () => { + const tags = [["p", PUBKEY_A.toLowerCase()]]; + + const next = withMentionReferenceTags(tags, [PUBKEY_A]); + + assert.deepEqual(tags, [["p", PUBKEY_A.toLowerCase()]]); + assert.deepEqual(next, [ + ["p", PUBKEY_A.toLowerCase()], + ["mention", PUBKEY_A.toLowerCase()], + ]); +}); diff --git a/desktop/src/shared/lib/mentionReferenceTags.ts b/desktop/src/shared/lib/mentionReferenceTags.ts new file mode 100644 index 0000000000..3535d2b52f --- /dev/null +++ b/desktop/src/shared/lib/mentionReferenceTags.ts @@ -0,0 +1,49 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; + +function mentionReferenceKey(tag: string[]): string | null { + if (tag[0] !== MENTION_REFERENCE_TAG || !tag[1]) { + return null; + } + + return normalizePubkey(tag[1]); +} + +/** + * Append explicit-mention reference tags for pubkeys that are already being + * sent as mention targets. Raw `p` tags also carry reply/subscription metadata, + * so the relay uses these tags to distinguish an intentional @mention from a + * structural reply-author `p` tag. + */ +export function appendMentionReferenceTags( + tags: string[][], + pubkeys: Iterable, +): void { + const seen = new Set(); + + for (const tag of tags) { + const pubkey = mentionReferenceKey(tag); + if (pubkey) { + seen.add(pubkey); + } + } + + for (const pubkey of pubkeys) { + const normalized = normalizePubkey(pubkey); + if (!normalized || seen.has(normalized)) { + continue; + } + + seen.add(normalized); + tags.push([MENTION_REFERENCE_TAG, normalized]); + } +} + +export function withMentionReferenceTags( + tags: string[][], + pubkeys: Iterable, +): string[][] { + const next = tags.map((tag) => [...tag]); + appendMentionReferenceTags(next, pubkeys); + return next; +}