From c9c176bc2be7c1336ffadc3ba47a4a796d86aeb4 Mon Sep 17 00:00:00 2001 From: LordMelkor Date: Mon, 27 Jul 2026 13:25:24 -0400 Subject: [PATCH 01/26] feat(relay): accept @channel/@here notify tag and feed @channel mentions (#3146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of NIP-CM channel-wide mentions: the relay, core, and DB seams. - buzz-core gains `channel_mentions`, the pure validation module for the single `["notify", "channel"|"here"]` marker tag — mode enum, allowed kinds (9/40003/45001/45003), at-most-one-tag, and the reserved-token guard so `channel`/`here` never resolve to a member identity. There is no p-tag roster expansion, so agents are never woken. - Migration 0025 adds `channel_notifications` — one row per notifying event, not one per member; the audience is resolved at read time. - Relay ingest validates the tag for every kind and rejects it in DM channels; accepted kind 9/45001/45003 `mode=channel` events persist a row alongside `insert_mentions`. Edits (40003) carry the tag for render continuity only and never persist or re-notify. - The mentions feed unions `channel_notifications` for channels the caller currently belongs to, deduped by event id, keeping the existing visibility scoping and FEED_MAX_LIMIT. `@here` is live-only and never reaches the feed. Refs #3146 Signed-off-by: LordMelkor --- crates/buzz-core/src/channel_mentions.rs | 354 ++++++++++++++++++ crates/buzz-core/src/lib.rs | 2 + crates/buzz-db/src/feed.rs | 349 ++++++++++++++++- crates/buzz-db/src/lib.rs | 57 +++ crates/buzz-db/src/migration.rs | 12 +- crates/buzz-relay/src/handlers/ingest.rs | 111 ++++++ .../tests/e2e_channel_mentions.rs | 347 +++++++++++++++++ migrations/0026_channel_notifications.sql | 23 ++ 8 files changed, 1247 insertions(+), 8 deletions(-) create mode 100644 crates/buzz-core/src/channel_mentions.rs create mode 100644 crates/buzz-test-client/tests/e2e_channel_mentions.rs create mode 100644 migrations/0026_channel_notifications.sql diff --git a/crates/buzz-core/src/channel_mentions.rs b/crates/buzz-core/src/channel_mentions.rs new file mode 100644 index 0000000000..8132e48218 --- /dev/null +++ b/crates/buzz-core/src/channel_mentions.rs @@ -0,0 +1,354 @@ +//! NIP-CM channel-wide mentions — the `["notify", …]` marker tag. +//! +//! A channel-wide mention is carried by a single marker tag on the message +//! event itself; there is no per-member `p` tag expansion, so the roster is +//! never written into the event and agents are never woken by `@channel` or +//! `@here`. +//! +//! ```text +//! ["notify", "channel"] // every member of the channel +//! ["notify", "here"] // members who are online right now (live-only) +//! ``` +//! +//! Validation here is pure (no I/O): it covers tag shape, mode spelling, +//! at-most-one-tag, and the allowed kinds. The DM-channel rejection needs the +//! channel row and therefore lives at the relay ingest seam, which calls +//! [`validate_notify_tag`] first and then applies +//! [`NotifyTagError::DirectMessage`] itself. + +use std::fmt; +use std::str::FromStr; + +use crate::kind::{ + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_EDIT, +}; + +/// Tag name carrying a channel-wide mention. +pub const NOTIFY_TAG: &str = "notify"; + +/// Reserved mention tokens that must never resolve to a member identity. +/// +/// Parsers compare case-insensitively: a member whose display name is +/// literally `here` still loses to the reserved token. +pub const RESERVED_MENTION_TOKENS: [&str; 2] = ["channel", "here"]; + +/// Event kinds that may carry a [`NOTIFY_TAG`]. +/// +/// `40003` (message edit) is accepted for render continuity only — it never +/// escalates a notification and never persists a feed row (see +/// [`persists_channel_notification`]). +pub const NOTIFY_ALLOWED_KINDS: [u32; 4] = [ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_EDIT, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, +]; + +/// Returns whether `token` is a reserved channel-wide mention token. +/// +/// Comparison is ASCII case-insensitive and the token must be given without +/// its leading `@`. +pub fn is_reserved_mention_token(token: &str) -> bool { + RESERVED_MENTION_TOKENS + .iter() + .any(|reserved| token.eq_ignore_ascii_case(reserved)) +} + +/// The audience selected by a `["notify", …]` tag. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotifyMode { + /// Every member of the channel; persistent (feed row, badge, offline catch-up). + Channel, + /// Members who are online at delivery time; live-only, never persisted. + Here, +} + +impl NotifyMode { + /// Canonical string representation (the tag's second element). + pub fn as_str(&self) -> &'static str { + match self { + Self::Channel => "channel", + Self::Here => "here", + } + } +} + +impl fmt::Display for NotifyMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for NotifyMode { + type Err = NotifyTagError; + + fn from_str(s: &str) -> Result { + match s { + "channel" => Ok(Self::Channel), + "here" => Ok(Self::Here), + other => Err(NotifyTagError::InvalidMode(other.to_string())), + } + } +} + +/// Why a `["notify", …]` tag was rejected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NotifyTagError { + /// The tag has no mode element (`["notify"]`). + MissingMode, + /// The mode is not `channel` or `here`. Carries the offending value. + InvalidMode(String), + /// More than one notify tag on a single event. + Duplicate, + /// The event kind may not carry a notify tag. Carries the kind. + KindNotAllowed(u32), + /// Channel-wide mentions are meaningless in a DM channel. + DirectMessage, +} + +impl fmt::Display for NotifyTagError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingMode => write!(f, "notify tag requires a mode value"), + Self::InvalidMode(value) => { + write!( + f, + "invalid notify mode {value:?} (expected channel or here)" + ) + } + Self::Duplicate => write!(f, "at most one notify tag is allowed per event"), + Self::KindNotAllowed(kind) => { + write!(f, "kind {kind} may not carry a notify tag") + } + Self::DirectMessage => { + write!(f, "channel-wide mentions are not allowed in DM channels") + } + } + } +} + +impl std::error::Error for NotifyTagError {} + +/// Validate the notify tag (if any) carried by an event's tags. +/// +/// Returns `Ok(None)` when the event carries no notify tag, `Ok(Some(mode))` +/// when it carries exactly one well-formed tag on an allowed kind, and an +/// error otherwise. Extra elements past the mode are ignored, matching Nostr's +/// forward-compatible tag convention. +/// +/// This function performs no I/O; the DM-channel rule is applied by the caller +/// that can see the channel row. +pub fn validate_notify_tag<'a, I, T>( + kind: u32, + tags: I, +) -> Result, NotifyTagError> +where + I: IntoIterator, + T: AsRef<[String]> + 'a, +{ + let mut found: Option = None; + for tag in tags { + let parts = tag.as_ref(); + let Some(name) = parts.first() else { + continue; + }; + if name != NOTIFY_TAG { + continue; + } + if found.is_some() { + return Err(NotifyTagError::Duplicate); + } + let value = parts.get(1).ok_or(NotifyTagError::MissingMode)?; + found = Some(value.parse::()?); + } + + if found.is_some() && !NOTIFY_ALLOWED_KINDS.contains(&kind) { + return Err(NotifyTagError::KindNotAllowed(kind)); + } + + Ok(found) +} + +/// Validate the notify tag carried by a signed Nostr event. +/// +/// Thin wrapper over [`validate_notify_tag`] for callers holding an event. +pub fn event_notify_mode(event: &nostr::Event) -> Result, NotifyTagError> { + let tags: Vec<&[String]> = event.tags.iter().map(|tag| tag.as_slice()).collect(); + validate_notify_tag(event.kind.as_u16() as u32, &tags) +} + +/// Whether an accepted notify tag persists a `channel_notifications` feed row. +/// +/// Only `mode = channel` persists, and only on the kinds that create new +/// content: edits (`40003`) re-carry the tag for rendering but must not +/// re-notify, and `here` is live-only by design. +pub fn persists_channel_notification(kind: u32, mode: NotifyMode) -> bool { + mode == NotifyMode::Channel + && matches!( + kind, + KIND_STREAM_MESSAGE | KIND_FORUM_POST | KIND_FORUM_COMMENT + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tags(raw: &[&[&str]]) -> Vec> { + raw.iter() + .map(|tag| tag.iter().map(|s| s.to_string()).collect()) + .collect() + } + + #[test] + fn no_notify_tag_is_ok() { + let t = tags(&[&["h", "abc"], &["p", "deadbeef"]]); + assert_eq!(validate_notify_tag(KIND_STREAM_MESSAGE, &t), Ok(None)); + } + + #[test] + fn parses_both_modes() { + for (value, expected) in [("channel", NotifyMode::Channel), ("here", NotifyMode::Here)] { + let t = tags(&[&["notify", value]]); + assert_eq!( + validate_notify_tag(KIND_STREAM_MESSAGE, &t), + Ok(Some(expected)) + ); + } + } + + #[test] + fn rejects_unknown_mode() { + let t = tags(&[&["notify", "everyone"]]); + assert_eq!( + validate_notify_tag(KIND_STREAM_MESSAGE, &t), + Err(NotifyTagError::InvalidMode("everyone".into())) + ); + } + + #[test] + fn mode_is_case_sensitive() { + let t = tags(&[&["notify", "Channel"]]); + assert!(matches!( + validate_notify_tag(KIND_STREAM_MESSAGE, &t), + Err(NotifyTagError::InvalidMode(_)) + )); + } + + #[test] + fn rejects_missing_mode() { + let t = tags(&[&["notify"]]); + assert_eq!( + validate_notify_tag(KIND_STREAM_MESSAGE, &t), + Err(NotifyTagError::MissingMode) + ); + } + + #[test] + fn rejects_duplicate_tags() { + let t = tags(&[&["notify", "channel"], &["notify", "here"]]); + assert_eq!( + validate_notify_tag(KIND_STREAM_MESSAGE, &t), + Err(NotifyTagError::Duplicate) + ); + let same = tags(&[&["notify", "channel"], &["notify", "channel"]]); + assert_eq!( + validate_notify_tag(KIND_STREAM_MESSAGE, &same), + Err(NotifyTagError::Duplicate) + ); + } + + #[test] + fn duplicate_check_precedes_kind_check() { + let t = tags(&[&["notify", "channel"], &["notify", "channel"]]); + assert_eq!( + validate_notify_tag(1, &t), + Err(NotifyTagError::Duplicate), + "shape errors are reported before the kind gate" + ); + } + + #[test] + fn allows_only_the_four_kinds() { + let t = tags(&[&["notify", "channel"]]); + for kind in NOTIFY_ALLOWED_KINDS { + assert!(validate_notify_tag(kind, &t).is_ok(), "kind {kind}"); + } + for kind in [1u32, 7, 40002, 45002, 9735] { + assert_eq!( + validate_notify_tag(kind, &t), + Err(NotifyTagError::KindNotAllowed(kind)), + "kind {kind}" + ); + } + } + + #[test] + fn disallowed_kind_without_tag_is_fine() { + let t = tags(&[&["e", "abc"]]); + assert_eq!(validate_notify_tag(1, &t), Ok(None)); + } + + #[test] + fn extra_tag_elements_are_ignored() { + let t = tags(&[&["notify", "here", "future-field"]]); + assert_eq!( + validate_notify_tag(KIND_FORUM_POST, &t), + Ok(Some(NotifyMode::Here)) + ); + } + + #[test] + fn only_channel_mode_persists_and_never_on_edits() { + assert!(persists_channel_notification( + KIND_STREAM_MESSAGE, + NotifyMode::Channel + )); + assert!(persists_channel_notification( + KIND_FORUM_POST, + NotifyMode::Channel + )); + assert!(persists_channel_notification( + KIND_FORUM_COMMENT, + NotifyMode::Channel + )); + assert!(!persists_channel_notification( + KIND_STREAM_MESSAGE_EDIT, + NotifyMode::Channel + )); + for kind in NOTIFY_ALLOWED_KINDS { + assert!( + !persists_channel_notification(kind, NotifyMode::Here), + "here is live-only (kind {kind})" + ); + } + } + + #[test] + fn mode_round_trips_through_str() { + for mode in [NotifyMode::Channel, NotifyMode::Here] { + assert_eq!(mode.as_str().parse::(), Ok(mode)); + } + } + + #[test] + fn reserved_tokens_are_case_insensitive() { + for token in ["channel", "Channel", "HERE", "here"] { + assert!(is_reserved_mention_token(token), "{token}"); + } + for token in ["chan", "everyone", "here2", ""] { + assert!(!is_reserved_mention_token(token), "{token}"); + } + } + + #[test] + fn event_helper_reads_tags_from_signed_event() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "hi") + .tags([Tag::parse(["notify", "channel"]).expect("tag")]) + .sign_with_keys(&keys) + .expect("sign"); + assert_eq!(event_notify_mode(&event), Ok(Some(NotifyMode::Channel))); + } +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 66b7708f1d..5fbc2c636f 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// NIP-CM channel-wide mentions — the `["notify", …]` marker tag. +pub mod channel_mentions; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/feed.rs index 511a2a6083..96af0ca336 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/feed.rs @@ -1,7 +1,8 @@ //! Feed-specific DB queries for the Home Feed feature. //! //! Aggregates three categories of data: -//! - **Mentions**: Events where the user's pubkey appears in a `p` tag. +//! - **Mentions**: Events where the user's pubkey appears in a `p` tag, plus +//! NIP-CM `["notify", "channel"]` events in channels the user belongs to. //! - **Needs Action**: Approval requests (kind 46010) and reminders (kind 40007) tagged to the user. //! - **Activity**: Recent events from channels the user can access. //! @@ -92,8 +93,9 @@ fn build_mentions_query( let limit = limit.min(FEED_MAX_LIMIT); let pubkey_hex = hex::encode(pubkey_bytes); + // Branch 1 — direct `p`-tag mentions. let mut qb: QueryBuilder = QueryBuilder::new(format!( - "SELECT {EVENT_COLS} FROM events e \ + "SELECT * FROM ((SELECT {EVENT_COLS}, m.event_created_at AS feed_created_at FROM events e \ INNER JOIN event_mentions m ON e.community_id = m.community_id AND e.id = m.event_id \ WHERE e.community_id = " )); @@ -114,14 +116,55 @@ fn build_mentions_query( } qb.push(" ORDER BY m.event_created_at DESC LIMIT ") .push_bind(limit); + + // Branch 2 — NIP-CM `["notify", "channel"]` events in channels the caller + // is still a member of. `UNION` (not `UNION ALL`) collapses an event that + // both p-tags the caller and notifies the channel into one feed row. + // `@here` is never stored, so it can never surface here. + qb.push(format!( + ") UNION (SELECT {EVENT_COLS}, n.event_created_at AS feed_created_at FROM events e \ + INNER JOIN channel_notifications n ON e.community_id = n.community_id \ + AND e.id = n.event_id \ + INNER JOIN channel_members cm ON cm.community_id = n.community_id \ + AND cm.channel_id = n.channel_id \ + WHERE e.community_id = " + )); + qb.push_bind(*community.as_uuid()); + qb.push(" AND n.community_id = ") + .push_bind(*community.as_uuid()); + qb.push(" AND cm.pubkey = ") + .push_bind(pubkey_bytes.to_vec()); + qb.push(" AND cm.removed_at IS NULL"); + qb.push(" AND e.deleted_at IS NULL"); + // The caller's own announcement is not a mention of the caller. + qb.push(" AND e.pubkey <> ") + .push_bind(pubkey_bytes.to_vec()); + qb.push(format!( + " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" + )); + push_visible_channel_filter(&mut qb, "e.channel_id", accessible_channel_ids); + if let Some(s) = since { + qb.push(" AND n.event_created_at >= ").push_bind(s); + } + qb.push(" ORDER BY n.event_created_at DESC LIMIT ") + .push_bind(limit); + + qb.push(")) u ORDER BY feed_created_at DESC LIMIT ") + .push_bind(limit); qb } -/// Find events that @mention the given pubkey (have `["p", pubkey_hex]` in tags). +/// Find events that mention the given pubkey. /// -/// Joins against the `event_mentions` table -- Phase 2 implementation. -/// **Performance**: community-leading indexed lookup on -/// `(community_id, pubkey_hex, event_created_at DESC)`. +/// Two sources, unioned and deduplicated by event id: +/// - direct `["p", pubkey_hex]` mentions, via the `event_mentions` index; +/// - NIP-CM `["notify", "channel"]` events (`channel_notifications`) in +/// channels where the caller is a current member. `["notify", "here"]` is +/// live-only and never persisted, so it never appears in this feed. +/// +/// **Performance**: community-leading indexed lookups on +/// `(community_id, pubkey_hex, event_created_at DESC)` and +/// `(community_id, channel_id, event_created_at DESC)`. /// /// Only returns community-global events and events from `accessible_channel_ids`. /// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller. @@ -318,6 +361,263 @@ mod tests { event } + /// Store an event authored by `keys` and run both denormalized indexes + /// (`event_mentions`, `channel_notifications`) exactly as the relay does. + async fn store_feed_event_as( + pool: &PgPool, + community: CommunityId, + keys: &Keys, + kind: u32, + content: &str, + channel_id: Option, + tags: Vec, + ) -> nostr::Event { + let event = EventBuilder::new(Kind::Custom(kind as u16), content) + .tags(tags) + .sign_with_keys(keys) + .expect("sign event"); + crate::event::insert_event(pool, community, &event, channel_id) + .await + .expect("insert feed event"); + crate::insert_mentions(pool, community, &event, channel_id) + .await + .expect("insert mentions"); + crate::insert_channel_notification(pool, community, &event, channel_id) + .await + .expect("insert channel notification"); + event + } + + async fn add_channel_member( + pool: &PgPool, + community: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) { + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(pubkey) + .execute(pool) + .await + .expect("insert channel member"); + } + + fn notify_tag(mode: &str) -> Tag { + Tag::parse(["notify", mode]).expect("notify tag") + } + + // -- NIP-CM channel-wide mentions ----------------------------------------- + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_mention_reaches_members_and_only_members() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = insert_test_channel(&pool, community).await; + let author = Keys::generate(); + let member = Keys::generate(); + let outsider = Keys::generate(); + let member_bytes = member.public_key().to_bytes().to_vec(); + let outsider_bytes = outsider.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community, channel, &member_bytes).await; + + let event = store_feed_event_as( + &pool, + community, + &author, + KIND_STREAM_MESSAGE, + "ship it @channel", + Some(channel), + vec![notify_tag("channel")], + ) + .await; + + let member_feed = query_mentions(&pool, community, &member_bytes, &[channel], None, 10) + .await + .expect("member mentions feed"); + assert!( + member_feed.iter().any(|row| row.event.id == event.id), + "channel members must see the @channel event in their mentions feed" + ); + + let outsider_feed = query_mentions(&pool, community, &outsider_bytes, &[channel], None, 10) + .await + .expect("outsider mentions feed"); + assert!( + outsider_feed.iter().all(|row| row.event.id != event.id), + "non-members must not see the @channel event" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn here_and_edits_never_persist_a_channel_notification() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = insert_test_channel(&pool, community).await; + let author = Keys::generate(); + let member = Keys::generate(); + let member_bytes = member.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community, channel, &member_bytes).await; + + let here = store_feed_event_as( + &pool, + community, + &author, + KIND_STREAM_MESSAGE, + "standup now @here", + Some(channel), + vec![notify_tag("here")], + ) + .await; + let edit = store_feed_event_as( + &pool, + community, + &author, + buzz_core::kind::KIND_STREAM_MESSAGE_EDIT, + "edited @channel", + Some(channel), + vec![notify_tag("channel")], + ) + .await; + + let feed = query_mentions(&pool, community, &member_bytes, &[channel], None, 10) + .await + .expect("member mentions feed"); + assert!( + feed.iter().all(|row| row.event.id != here.id), + "@here is live-only and must never reach the feed" + ); + assert!( + feed.iter().all(|row| row.event.id != edit.id), + "edits carry the tag for rendering only and must not re-notify" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_mention_is_deduped_with_a_direct_mention_and_excludes_the_author() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = insert_test_channel(&pool, community).await; + let author = Keys::generate(); + let member = Keys::generate(); + let author_bytes = author.public_key().to_bytes().to_vec(); + let member_bytes = member.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community, channel, &member_bytes).await; + add_channel_member(&pool, community, channel, &author_bytes).await; + + let event = store_feed_event_as( + &pool, + community, + &author, + KIND_STREAM_MESSAGE, + "heads up @channel", + Some(channel), + vec![ + notify_tag("channel"), + Tag::parse(["p", &member.public_key().to_hex()]).expect("p tag"), + ], + ) + .await; + + let member_feed = query_mentions(&pool, community, &member_bytes, &[channel], None, 10) + .await + .expect("member mentions feed"); + assert_eq!( + member_feed + .iter() + .filter(|row| row.event.id == event.id) + .count(), + 1, + "an event that is both p-tagged and @channel must appear once" + ); + + let author_feed = query_mentions(&pool, community, &author_bytes, &[channel], None, 10) + .await + .expect("author mentions feed"); + assert!( + author_feed.iter().all(|row| row.event.id != event.id), + "the author's own @channel event is not a mention of the author" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_mention_respects_visible_channel_scoping() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = insert_test_channel(&pool, community).await; + let author = Keys::generate(); + let member = Keys::generate(); + let member_bytes = member.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community, channel, &member_bytes).await; + + let event = store_feed_event_as( + &pool, + community, + &author, + KIND_FORUM_POST, + "forum @channel", + Some(channel), + vec![notify_tag("channel")], + ) + .await; + + let scoped_out = query_mentions(&pool, community, &member_bytes, &[], None, 10) + .await + .expect("mentions feed with no accessible channels"); + assert!( + scoped_out.iter().all(|row| row.event.id != event.id), + "an empty accessible-channel list means global-only, never all channels" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_mention_is_scoped_across_communities() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + let channel_a = insert_test_channel(&pool, community_a).await; + let channel_b = insert_test_channel(&pool, community_b).await; + let author = Keys::generate(); + let member = Keys::generate(); + let member_bytes = member.public_key().to_bytes().to_vec(); + add_channel_member(&pool, community_a, channel_a, &member_bytes).await; + add_channel_member(&pool, community_b, channel_b, &member_bytes).await; + + let event_b = store_feed_event_as( + &pool, + community_b, + &author, + KIND_STREAM_MESSAGE, + "community-b @channel", + Some(channel_b), + vec![notify_tag("channel")], + ) + .await; + + let feed_a = query_mentions( + &pool, + community_a, + &member_bytes, + &[channel_a, channel_b], + None, + 10, + ) + .await + .expect("community A mentions feed"); + assert!( + feed_a.iter().all(|row| row.event.id != event_b.id), + "community B channel mention must not appear in community A feed" + ); + } + // -- Postgres tenant-scope regressions ------------------------------------ #[tokio::test] @@ -788,6 +1088,43 @@ mod tests { ); } + #[test] + fn mentions_query_unions_channel_notifications_for_member_channels() { + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let pubkey = vec![0x42; 32]; + let channel_id = Uuid::new_v4(); + let mut qb = build_mentions_query(community, &pubkey, &[channel_id], None, 10); + let query = qb.build(); + let sql_str = sqlx::Execute::sql(query); + let sql = sql_str.as_str(); + + assert!( + sql.contains("INNER JOIN channel_notifications n ON e.community_id = n.community_id"), + "mentions must union NIP-CM channel notifications on the composite tenant/event key: {sql}" + ); + assert!( + sql.contains("INNER JOIN channel_members cm ON cm.community_id = n.community_id"), + "channel notifications must resolve the audience through channel_members: {sql}" + ); + assert!( + sql.contains("AND cm.removed_at IS NULL"), + "removed members must not receive channel mentions: {sql}" + ); + assert!( + sql.contains(") UNION (") && sql.contains(")) u ORDER BY feed_created_at DESC LIMIT "), + "both branches must be deduplicated and ordered together: {sql}" + ); + assert!( + !sql.contains("UNION ALL"), + "UNION (not UNION ALL) is what dedupes an event that is both p-tagged and @channel: {sql}" + ); + assert_eq!( + sql.matches("LIMIT ").count(), + 3, + "each branch and the outer query must carry the feed limit: {sql}" + ); + } + #[test] fn needs_action_query_is_tenant_scoped_and_joins_mentions_by_composite_key() { let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 2a3ba9a63e..fd166f2684 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -168,6 +168,53 @@ pub async fn insert_mentions( Ok(()) } +/// Record a NIP-CM `["notify", "channel"]` event in `channel_notifications`. +/// +/// One row per event — the member roster is resolved at read time by the +/// mentions feed, never denormalized here. No-ops unless the event carries a +/// valid notify tag whose mode persists (see +/// [`buzz_core::channel_mentions::persists_channel_notification`]) and the +/// event is channel-scoped. Like [`insert_mentions`], this is a denormalized +/// index: callers log failures rather than failing the event insert. +pub async fn insert_channel_notification( + pool: &PgPool, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, +) -> Result<()> { + use buzz_core::channel_mentions::{event_notify_mode, persists_channel_notification}; + + let Some(channel_id) = channel_id else { + return Ok(()); + }; + let kind = event.kind.as_u16() as u32; + let Ok(Some(mode)) = event_notify_mode(event) else { + return Ok(()); + }; + if !persists_channel_notification(kind, mode) { + return Ok(()); + } + + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = DateTime::from_timestamp(created_at_secs, 0) + .ok_or(crate::error::DbError::InvalidTimestamp(created_at_secs))?; + + sqlx::query( + "INSERT INTO channel_notifications \ + (community_id, channel_id, event_id, mode, event_created_at) \ + VALUES ($1, $2, $3, $4, $5) \ + ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(event.id.as_bytes().as_slice()) + .bind(mode.as_str()) + .bind(created_at) + .execute(pool) + .await?; + Ok(()) +} + /// Database handle. Clone is cheap (Arc-backed pool). #[derive(Clone, Debug)] pub struct Db { @@ -1089,6 +1136,11 @@ impl Db { if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } + if let Err(e) = + insert_channel_notification(&self.pool, community_id, event, channel_id).await + { + tracing::warn!(event_id = %event.id, "Failed to insert channel notification: {e}"); + } } Ok(result) } @@ -1397,6 +1449,11 @@ impl Db { if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } + if let Err(e) = + insert_channel_notification(&self.pool, community_id, event, channel_id).await + { + tracing::warn!(event_id = %event.id, "Failed to insert channel notification: {e}"); + } } Ok(result) } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1d1b7e05d4..c3894e26a4 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -560,7 +560,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 25); + assert_eq!(migrations.len(), 26); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -879,7 +879,6 @@ mod tests { .to_lowercase() .contains("for update")); assert!(ttl_shared.contains("NEW.kind <> 9007")); - // Use-limited invite links: durable relay_invites table stores only // the SHA-256 of an opaque v2 code, scoped by community_id. Never // listed in _operator_global_tables — it is community-scoped. @@ -904,6 +903,15 @@ mod tests { desired_schema.contains("CREATE TABLE join_policy_acceptances"), "desired-state schema must include join-policy evidence used by invite claims", ); + + // NIP-CM: @channel mentions persist one row per event (never one per + // member) and @here never persists at all. + assert_eq!(migrations[25].version, 26); + let channel_notifications = migrations[25].sql.as_str(); + assert!(channel_notifications.contains("CREATE TABLE channel_notifications")); + assert!(channel_notifications.contains("PRIMARY KEY (community_id, event_id)")); + assert!(channel_notifications.contains("mode IN ('channel')")); + assert!(channel_notifications.contains("idx_channel_notifications_channel_created")); } #[test] diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index a30b0e714d..927f31e928 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -906,6 +906,30 @@ async fn validate_forum_vote_target( } /// Validate kind:40008 diff event metadata tags. +/// NIP-CM: validate a `["notify", "channel"|"here"]` channel-wide mention tag. +/// +/// Tag shape, mode spelling, at-most-one, and the allowed-kind gate are pure +/// and live in [`buzz_core::channel_mentions`]. Only the DM rejection needs +/// the channel row, which is why it is applied here rather than in core. +fn validate_channel_mention( + event: &Event, + channel_row: Option<&buzz_db::channel::ChannelRecord>, +) -> Result<(), String> { + use buzz_core::channel_mentions::{event_notify_mode, NotifyTagError}; + + if event_notify_mode(event) + .map_err(|e| e.to_string())? + .is_none() + { + return Ok(()); + } + if channel_row.is_some_and(|row| row.channel_type == buzz_db::channel::ChannelType::Dm.as_str()) + { + return Err(NotifyTagError::DirectMessage.to_string()); + } + Ok(()) +} + fn validate_diff_event(event: &Event) -> Result<(), String> { // Content max 60KB if event.content.len() > 61_440 { @@ -1781,6 +1805,12 @@ async fn ingest_event_inner( Some(ch_id) => state.db.get_channel(tenant.community(), ch_id).await.ok(), None => None, }; + // NIP-CM: gate the channel-wide mention tag before anything stores or + // fans out the event. Runs for every kind so an unlisted kind carrying a + // notify tag is rejected rather than silently ignored. + validate_channel_mention(&event, channel_row.as_ref()) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + // E1 phase-2 (§4.8 phase-2 addendum): resolve the fan-out visibility once, // here, through the same `channel_visibility_cached` gate fan-out uses // (fence 2: cached `private` wins over the prefetched row; a `private` @@ -3096,6 +3126,87 @@ mod tests { ); } + fn make_channel_row(channel_type: &str) -> buzz_db::channel::ChannelRecord { + let now = chrono::Utc::now(); + buzz_db::channel::ChannelRecord { + id: uuid::Uuid::new_v4(), + name: "test".into(), + channel_type: channel_type.into(), + visibility: "open".into(), + description: None, + canvas: None, + created_by: vec![0u8; 32], + created_at: now, + updated_at: now, + archived_at: None, + deleted_at: None, + nip29_group_id: None, + topic_required: false, + max_members: None, + topic: None, + topic_set_by: None, + topic_set_at: None, + purpose: None, + purpose_set_by: None, + purpose_set_at: None, + ttl_seconds: None, + ttl_deadline: None, + } + } + + #[test] + fn channel_mention_accepted_on_allowed_kinds() { + for kind in buzz_core::channel_mentions::NOTIFY_ALLOWED_KINDS { + for mode in ["channel", "here"] { + let event = make_event_with_tags(kind, "hi", &[&["notify", mode]]); + assert!( + validate_channel_mention(&event, Some(&make_channel_row("stream"))).is_ok(), + "kind {kind} mode {mode}" + ); + } + } + } + + #[test] + fn channel_mention_rejected_on_other_kinds() { + let event = make_event_with_tags(KIND_STREAM_MESSAGE_V2, "hi", &[&["notify", "channel"]]); + assert!(validate_channel_mention(&event, Some(&make_channel_row("stream"))).is_err()); + } + + #[test] + fn channel_mention_rejected_for_bad_mode_and_duplicates() { + let bad_mode = make_event_with_tags(KIND_STREAM_MESSAGE, "hi", &[&["notify", "everyone"]]); + assert!(validate_channel_mention(&bad_mode, Some(&make_channel_row("stream"))).is_err()); + + let duplicate = make_event_with_tags( + KIND_STREAM_MESSAGE, + "hi", + &[&["notify", "channel"], &["notify", "here"]], + ); + assert!(validate_channel_mention(&duplicate, Some(&make_channel_row("stream"))).is_err()); + + let missing_mode = make_event_with_tags(KIND_STREAM_MESSAGE, "hi", &[&["notify"]]); + assert!( + validate_channel_mention(&missing_mode, Some(&make_channel_row("stream"))).is_err() + ); + } + + #[test] + fn channel_mention_rejected_in_dm_channels() { + let event = make_event_with_tags(KIND_STREAM_MESSAGE, "hi", &[&["notify", "channel"]]); + let err = validate_channel_mention(&event, Some(&make_channel_row("dm"))) + .expect_err("DM channels must reject channel-wide mentions"); + assert!(err.contains("DM"), "{err}"); + } + + #[test] + fn untagged_events_are_unaffected() { + let event = make_event_with_tags(KIND_STREAM_MESSAGE, "hi", &[&["h", "abc"]]); + assert!(validate_channel_mention(&event, Some(&make_channel_row("dm"))).is_ok()); + let other_kind = make_event_with_tags(1, "hi", &[]); + assert!(validate_channel_mention(&other_kind, None).is_ok()); + } + #[test] fn diff_validation_rejects_missing_repo() { let event = make_event_with_tags( diff --git a/crates/buzz-test-client/tests/e2e_channel_mentions.rs b/crates/buzz-test-client/tests/e2e_channel_mentions.rs new file mode 100644 index 0000000000..1fbac8db24 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_channel_mentions.rs @@ -0,0 +1,347 @@ +//! End-to-end integration tests for NIP-CM channel-wide mentions (`@channel` / `@here`). +//! +//! These tests cover the relay write path — the accept/reject matrix for the +//! `["notify", "channel"|"here"]` marker tag — and the read path, where an +//! accepted `@channel` event surfaces in the mentions feed of every channel +//! member (and of nobody else). +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_channel_mentions -- --ignored +//! ``` + +use nostr::{EventBuilder, Keys, Kind, Tag}; +use serde_json::Value; +use uuid::Uuid; + +const KIND_STREAM_MESSAGE: u16 = 9; +const KIND_STREAM_MESSAGE_V2: u16 = 40002; +const KIND_CREATE_GROUP: u16 = 9007; +const KIND_JOIN_REQUEST: u16 = 9021; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +/// Submit a signed event over `POST /events` and return the parsed body. +/// +/// The bridge answers 4xx for rejections, so the status is folded into the +/// returned tuple instead of asserted here. +async fn post_event(keys: &Keys, event: &nostr::Event) -> (reqwest::StatusCode, Value) { + let response = reqwest::Client::new() + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(event).expect("serialize event")) + .send() + .await + .expect("submit event"); + let status = response.status(); + let text = response.text().await.expect("read event response"); + let body = serde_json::from_str(&text).unwrap_or(Value::String(text)); + (status, body) +} + +fn accepted(body: &Value) -> bool { + body.get("accepted") + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn rejection_message(status: reqwest::StatusCode, body: &Value) -> String { + match body { + Value::String(text) => format!("{status}: {text}"), + other => format!("{status}: {other}"), + } +} + +async fn create_channel(keys: &Keys, channel_type: &str) -> Uuid { + let channel_id = Uuid::new_v4(); + let event = EventBuilder::new(Kind::Custom(KIND_CREATE_GROUP), "") + .tags(vec![ + Tag::parse(["h", &channel_id.to_string()]).expect("h tag"), + Tag::parse(["name", &format!("cm-e2e-{channel_id}")]).expect("name tag"), + Tag::parse(["channel_type", channel_type]).expect("channel_type tag"), + Tag::parse(["visibility", "open"]).expect("visibility tag"), + ]) + .sign_with_keys(keys) + .expect("sign create-group event"); + let (status, body) = post_event(keys, &event).await; + assert!( + status.is_success() && accepted(&body), + "channel creation failed: {}", + rejection_message(status, &body) + ); + channel_id +} + +async fn join_channel(keys: &Keys, channel_id: Uuid) { + let event = EventBuilder::new(Kind::Custom(KIND_JOIN_REQUEST), "") + .tags(vec![ + Tag::parse(["h", &channel_id.to_string()]).expect("h tag") + ]) + .sign_with_keys(keys) + .expect("sign join event"); + let (status, body) = post_event(keys, &event).await; + assert!( + status.is_success() && accepted(&body), + "join failed: {}", + rejection_message(status, &body) + ); +} + +fn message( + keys: &Keys, + kind: u16, + channel_id: Uuid, + content: &str, + tags: &[&[&str]], +) -> nostr::Event { + let mut all = vec![Tag::parse(["h", &channel_id.to_string()]).expect("h tag")]; + all.extend( + tags.iter() + .map(|t| Tag::parse(t.iter().copied()).expect("tag")), + ); + EventBuilder::new(Kind::Custom(kind), content) + .tags(all) + .sign_with_keys(keys) + .expect("sign message") +} + +/// Query the caller's mentions feed over `POST /query`. +async fn mentions_feed(keys: &Keys) -> Vec { + // `POST /query` takes an array of Nostr filters, as the CLI sends them. + let filters = serde_json::json!([{ + "#p": [keys.public_key().to_hex()], + "feed_types": ["mentions"], + "limit": 50 + }]); + let response = reqwest::Client::new() + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&filters).expect("serialize filters")) + .send() + .await + .expect("query mentions feed"); + assert!( + response.status().is_success(), + "mentions feed query failed: {}", + response.status() + ); + response.json().await.expect("parse mentions feed") +} + +fn feed_contains(feed: &[Value], event_id: &str) -> bool { + feed.iter() + .any(|e| e.get("id").and_then(Value::as_str) == Some(event_id)) +} + +// -- Write path: accept/reject matrix ----------------------------------------- + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn notify_tag_accepted_on_stream_messages_in_both_modes() { + let author = Keys::generate(); + let channel = create_channel(&author, "stream").await; + + for mode in ["channel", "here"] { + let event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + &format!("heads up @{mode}"), + &[&["notify", mode]], + ); + let (status, body) = post_event(&author, &event).await; + assert!( + status.is_success() && accepted(&body), + "mode {mode} must be accepted: {}", + rejection_message(status, &body) + ); + } +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn notify_tag_rejected_for_invalid_mode() { + let author = Keys::generate(); + let channel = create_channel(&author, "stream").await; + + let event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + "hi", + &[&["notify", "everyone"]], + ); + let (status, body) = post_event(&author, &event).await; + assert!( + !status.is_success() || !accepted(&body), + "an unknown notify mode must be rejected: {}", + rejection_message(status, &body) + ); +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn notify_tag_rejected_when_missing_a_mode() { + let author = Keys::generate(); + let channel = create_channel(&author, "stream").await; + + let event = message(&author, KIND_STREAM_MESSAGE, channel, "hi", &[&["notify"]]); + let (status, body) = post_event(&author, &event).await; + assert!( + !status.is_success() || !accepted(&body), + "a bare notify tag must be rejected: {}", + rejection_message(status, &body) + ); +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn duplicate_notify_tags_are_rejected() { + let author = Keys::generate(); + let channel = create_channel(&author, "stream").await; + + let event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + "hi", + &[&["notify", "channel"], &["notify", "here"]], + ); + let (status, body) = post_event(&author, &event).await; + assert!( + !status.is_success() || !accepted(&body), + "at most one notify tag is allowed: {}", + rejection_message(status, &body) + ); +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn notify_tag_rejected_on_disallowed_kind() { + let author = Keys::generate(); + let channel = create_channel(&author, "stream").await; + + let event = message( + &author, + KIND_STREAM_MESSAGE_V2, + channel, + "hi", + &[&["notify", "channel"]], + ); + let (status, body) = post_event(&author, &event).await; + assert!( + !status.is_success() || !accepted(&body), + "kind 40002 may not carry a notify tag: {}", + rejection_message(status, &body) + ); +} + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn notify_tag_rejected_in_dm_channels() { + let author = Keys::generate(); + let channel = create_channel(&author, "dm").await; + + let event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + "hi", + &[&["notify", "channel"]], + ); + let (status, body) = post_event(&author, &event).await; + assert!( + !status.is_success() || !accepted(&body), + "DM channels must reject channel-wide mentions: {}", + rejection_message(status, &body) + ); + + // Control: the same message without the tag is fine in the same channel. + let plain = message(&author, KIND_STREAM_MESSAGE, channel, "hi", &[]); + let (status, body) = post_event(&author, &plain).await; + assert!( + status.is_success() && accepted(&body), + "untagged DM messages must still be accepted: {}", + rejection_message(status, &body) + ); +} + +// -- Read path: mentions feed -------------------------------------------------- + +#[tokio::test] +#[ignore = "requires a running relay"] +async fn channel_mention_surfaces_in_member_feeds_and_here_never_does() { + let author = Keys::generate(); + let member = Keys::generate(); + let outsider = Keys::generate(); + let channel = create_channel(&author, "stream").await; + join_channel(&member, channel).await; + + let channel_event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + "deploy window closes in 10 @channel", + &[&["notify", "channel"]], + ); + let (status, body) = post_event(&author, &channel_event).await; + assert!( + status.is_success() && accepted(&body), + "@channel message must be accepted: {}", + rejection_message(status, &body) + ); + + let here_event = message( + &author, + KIND_STREAM_MESSAGE, + channel, + "standup now @here", + &[&["notify", "here"]], + ); + let (status, body) = post_event(&author, &here_event).await; + assert!( + status.is_success() && accepted(&body), + "@here message must be accepted: {}", + rejection_message(status, &body) + ); + + let channel_event_id = channel_event.id.to_hex(); + let here_event_id = here_event.id.to_hex(); + + let member_feed = mentions_feed(&member).await; + assert!( + feed_contains(&member_feed, &channel_event_id), + "channel members must see the @channel event in their mentions feed" + ); + assert!( + !feed_contains(&member_feed, &here_event_id), + "@here is live-only and must never reach the mentions feed" + ); + + let outsider_feed = mentions_feed(&outsider).await; + assert!( + !feed_contains(&outsider_feed, &channel_event_id), + "non-members must not see the @channel event" + ); + + let author_feed = mentions_feed(&author).await; + assert!( + !feed_contains(&author_feed, &channel_event_id), + "the author's own @channel event is not a mention of the author" + ); +} diff --git a/migrations/0026_channel_notifications.sql b/migrations/0026_channel_notifications.sql new file mode 100644 index 0000000000..cba52f1364 --- /dev/null +++ b/migrations/0026_channel_notifications.sql @@ -0,0 +1,23 @@ +-- NIP-CM channel-wide mentions (@channel). +-- +-- One row per notifying event, NOT one row per member: the mentions feed +-- resolves the audience at read time by joining channel_members, so a +-- 5000-member channel costs a single row here and the roster is never +-- denormalized into the event or this table. +-- +-- @here is deliberately absent: it is live-only (no persistence, no +-- retroactive badge). `mode` is still stored so the column can carry future +-- persistent modes without a second table. +CREATE TABLE channel_notifications ( + community_id UUID NOT NULL REFERENCES communities(id), + channel_id UUID NOT NULL, + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + mode TEXT NOT NULL CHECK (mode IN ('channel')), + event_created_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (community_id, event_id), + FOREIGN KEY (community_id, channel_id) + REFERENCES channels (community_id, id) ON DELETE CASCADE +); + +CREATE INDEX idx_channel_notifications_channel_created + ON channel_notifications (community_id, channel_id, event_created_at DESC); From e3ad57453086ae9233da6dc5e7a2b01d560746d0 Mon Sep 17 00:00:00 2001 From: LordMelkor Date: Mon, 27 Jul 2026 13:36:37 -0400 Subject: [PATCH 02/26] fix(relay): make the mentions UNION dedupe on event columns alone (#3146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @channel feed branch aliased `n.event_created_at` into the select list while the p-tag branch aliased `m.event_created_at`, so `UNION` compared a column that two independent denormalized indexes have to keep byte-identical for the dedupe to hold. Both branches now project exactly `EVENT_COLS` and the outer query orders on the event's own `created_at`, so an event that is both p-tagged and `@channel` collapses to one row by construction. Also drops the word "audience" from the NIP-CM comments and doc text — it is reserved for the persistent agent audience work (#1949). Refs #3146 Signed-off-by: LordMelkor Co-authored-by: Claude Code Ai-assisted: true Signed-off-by: LordMelkor --- crates/buzz-core/src/channel_mentions.rs | 2 +- crates/buzz-db/src/feed.rs | 17 +++++++++++------ migrations/0026_channel_notifications.sql | 5 ++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/crates/buzz-core/src/channel_mentions.rs b/crates/buzz-core/src/channel_mentions.rs index 8132e48218..3c0a3fe1a1 100644 --- a/crates/buzz-core/src/channel_mentions.rs +++ b/crates/buzz-core/src/channel_mentions.rs @@ -54,7 +54,7 @@ pub fn is_reserved_mention_token(token: &str) -> bool { .any(|reserved| token.eq_ignore_ascii_case(reserved)) } -/// The audience selected by a `["notify", …]` tag. +/// Who a `["notify", …]` tag notifies. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NotifyMode { /// Every member of the channel; persistent (feed row, badge, offline catch-up). diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/feed.rs index 96af0ca336..c17a7824c3 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/feed.rs @@ -95,7 +95,7 @@ fn build_mentions_query( // Branch 1 — direct `p`-tag mentions. let mut qb: QueryBuilder = QueryBuilder::new(format!( - "SELECT * FROM ((SELECT {EVENT_COLS}, m.event_created_at AS feed_created_at FROM events e \ + "SELECT * FROM ((SELECT {EVENT_COLS} FROM events e \ INNER JOIN event_mentions m ON e.community_id = m.community_id AND e.id = m.event_id \ WHERE e.community_id = " )); @@ -119,10 +119,11 @@ fn build_mentions_query( // Branch 2 — NIP-CM `["notify", "channel"]` events in channels the caller // is still a member of. `UNION` (not `UNION ALL`) collapses an event that - // both p-tags the caller and notifies the channel into one feed row. + // both p-tags the caller and notifies the channel into one feed row: both + // branches project exactly `EVENT_COLS`, so identical event rows collapse. // `@here` is never stored, so it can never surface here. qb.push(format!( - ") UNION (SELECT {EVENT_COLS}, n.event_created_at AS feed_created_at FROM events e \ + ") UNION (SELECT {EVENT_COLS} FROM events e \ INNER JOIN channel_notifications n ON e.community_id = n.community_id \ AND e.id = n.event_id \ INNER JOIN channel_members cm ON cm.community_id = n.community_id \ @@ -149,7 +150,7 @@ fn build_mentions_query( qb.push(" ORDER BY n.event_created_at DESC LIMIT ") .push_bind(limit); - qb.push(")) u ORDER BY feed_created_at DESC LIMIT ") + qb.push(")) u ORDER BY created_at DESC LIMIT ") .push_bind(limit); qb } @@ -1104,16 +1105,20 @@ mod tests { ); assert!( sql.contains("INNER JOIN channel_members cm ON cm.community_id = n.community_id"), - "channel notifications must resolve the audience through channel_members: {sql}" + "channel notifications must resolve recipients through channel_members: {sql}" ); assert!( sql.contains("AND cm.removed_at IS NULL"), "removed members must not receive channel mentions: {sql}" ); assert!( - sql.contains(") UNION (") && sql.contains(")) u ORDER BY feed_created_at DESC LIMIT "), + sql.contains(") UNION (") && sql.contains(")) u ORDER BY created_at DESC LIMIT "), "both branches must be deduplicated and ordered together: {sql}" ); + assert!( + !sql.contains(" AS feed_created_at"), + "both branches must project exactly EVENT_COLS or UNION cannot dedupe: {sql}" + ); assert!( !sql.contains("UNION ALL"), "UNION (not UNION ALL) is what dedupes an event that is both p-tagged and @channel: {sql}" diff --git a/migrations/0026_channel_notifications.sql b/migrations/0026_channel_notifications.sql index cba52f1364..dd0d04cbb9 100644 --- a/migrations/0026_channel_notifications.sql +++ b/migrations/0026_channel_notifications.sql @@ -1,13 +1,12 @@ -- NIP-CM channel-wide mentions (@channel). -- -- One row per notifying event, NOT one row per member: the mentions feed --- resolves the audience at read time by joining channel_members, so a +-- resolves the recipients at read time by joining channel_members, so a -- 5000-member channel costs a single row here and the roster is never -- denormalized into the event or this table. -- -- @here is deliberately absent: it is live-only (no persistence, no --- retroactive badge). `mode` is still stored so the column can carry future --- persistent modes without a second table. +-- retroactive badge), which is why `mode` is constrained to 'channel'. CREATE TABLE channel_notifications ( community_id UUID NOT NULL REFERENCES communities(id), channel_id UUID NOT NULL, From 7702a2e42de3c5b1f390b0945fd78cb9a8e2daef Mon Sep 17 00:00:00 2001 From: LordMelkor Date: Mon, 27 Jul 2026 13:36:59 -0400 Subject: [PATCH 03/26] docs(test): note deferred execution of the NIP-CM e2e suite (#3146) The local dev stack had no Redis container, so the relay could not be started; the suite was compile-verified only. Refs #3146 Signed-off-by: LordMelkor Co-authored-by: Claude Code Ai-assisted: true Signed-off-by: LordMelkor --- crates/buzz-test-client/tests/e2e_channel_mentions.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/buzz-test-client/tests/e2e_channel_mentions.rs b/crates/buzz-test-client/tests/e2e_channel_mentions.rs index 1fbac8db24..3a5a942429 100644 --- a/crates/buzz-test-client/tests/e2e_channel_mentions.rs +++ b/crates/buzz-test-client/tests/e2e_channel_mentions.rs @@ -12,6 +12,10 @@ //! ```text //! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_channel_mentions -- --ignored //! ``` +//! +//! These were compile-verified (`--no-run`) when written; the local dev stack +//! had no Redis container, so the relay could not be started and execution is +//! deferred to the integration-gate run. use nostr::{EventBuilder, Keys, Kind, Tag}; use serde_json::Value; From 33e77b66389c53e32f62d2acd30b1f234991354d Mon Sep 17 00:00:00 2001 From: LordMelkor Date: Mon, 27 Jul 2026 13:39:39 -0400 Subject: [PATCH 04/26] feat(cli): add --notify channel/here to messages send (#3146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the NIP-CM channel-wide mention marker tag through the SDK and CLI: - buzz-sdk: build_message/build_forum_post/build_forum_comment take an optional NotifyMode and emit ["notify", "channel"|"here"]. No p-tag expansion — the marker tag alone carries the mention. - buzz-sdk mentions: reserved tokens channel/here (case-insensitive) never resolve to a member identity, even when a member is literally named one of them; new extract_reserved_mention_tokens() reports them instead. - buzz-cli: messages send --notify (invalid value is a usage error, exit 1) plus a stderr warning when literal @channel/@here appears outside code regions without the flag — the send still goes out untagged. - workflow_sink: resolve_mention_pubkeys skips reserved tokens, so a workflow message saying @here wakes nobody. Signed-off-by: LordMelkor --- crates/buzz-acp/src/pool.rs | 23 +++-- crates/buzz-acp/src/setup_mode.rs | 1 + crates/buzz-cli/README.md | 2 + crates/buzz-cli/src/commands/messages.rs | 109 +++++++++++++++++++++-- crates/buzz-cli/src/lib.rs | 9 +- crates/buzz-relay/src/workflow_sink.rs | 29 +++++- crates/buzz-sdk/src/builders.rs | 86 +++++++++++++++--- crates/buzz-sdk/src/lib.rs | 2 + crates/buzz-sdk/src/mentions.rs | 85 +++++++++++++++++- examples/countdown-bot/src/main.rs | 1 + 10 files changed, 315 insertions(+), 32 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0c51fe954f..11c5cc7194 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -3569,14 +3569,21 @@ pub(crate) async fn post_failure_notice( parent_event_id: parent_id, }) }); - let builder = - match buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) { - Ok(b) => b, - Err(e) => { - tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); - return; - } - }; + let builder = match buzz_sdk::build_message( + channel_id, + content, + thread_ref.as_ref(), + &[], + false, + None, + &[], + ) { + Ok(b) => b, + Err(e) => { + tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); + return; + } + }; let event = match builder.sign_with_keys(&rest.keys) { Ok(e) => e, Err(e) => { diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea4..c744877c92 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -629,6 +629,7 @@ async fn publish_setup_nudge( thread_ref.as_ref(), &[&author_hex], // p-tag the asker false, + None, &[], ) .map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?; diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a8c668cf06..cc17da560b 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -32,6 +32,8 @@ export BUZZ_RELAY_URL="https://relay.example.com" buzz messages send --channel --content "Hello" buzz messages send --channel --content "Reply" --reply-to --broadcast buzz messages send --channel --content - < message.md # read body from stdin +buzz messages send --channel --content "deploy is done" --notify channel # @channel +buzz messages send --channel --content "standup now" --notify here # @here buzz messages get --channel --limit 20 buzz messages thread --channel --event buzz messages search --query "architecture" diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 290cc59fa8..7eb95db894 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1,4 +1,4 @@ -use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection}; +use buzz_sdk::{DeleteMessageOptions, DiffMeta, NotifyMode, ThreadRef, VoteDirection}; use nostr::PublicKey; use uuid::Uuid; @@ -9,8 +9,8 @@ use crate::validate::{ validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ - extract_at_mentions_with_known, extract_nostr_uris, merge_mentions, strip_code_regions, - MENTION_CAP, + extract_at_mentions_with_known, extract_nostr_uris, extract_reserved_mention_tokens, + merge_mentions, strip_code_regions, MENTION_CAP, }; /// Extract the thread root event ID from a Nostr tag array. @@ -477,9 +477,42 @@ pub struct SendMessageParams { pub kind: Option, pub reply_to: Option, pub broadcast: bool, + /// Raw `--notify` value, parsed by [`parse_notify_mode`]. + pub notify: Option, pub files: Vec, } +/// Parse the `--notify` flag value into a [`NotifyMode`]. +/// +/// Unknown values are a usage error (exit code 1) rather than a silent +/// downgrade — sending a message that quietly failed to notify anyone is the +/// worse outcome. +fn parse_notify_mode(value: Option<&str>) -> Result, CliError> { + match value { + None => Ok(None), + Some(raw) => raw.parse::().map(Some).map_err(|_| { + CliError::Usage(format!( + "invalid --notify value '{raw}' (expected 'channel' or 'here')" + )) + }), + } +} + +/// Warning shown when content mentions `@channel`/`@here` without the flag. +/// +/// Returns `None` when no reserved token is present outside code regions, so +/// an `@here` inside a code fence stays quiet. +fn unflagged_notify_warning(content: &str, notify: Option) -> Option { + if notify.is_some() { + return None; + } + let tokens = extract_reserved_mention_tokens(&strip_code_regions(content)); + let first = tokens.first()?; + Some(format!( + "warning: @{first} does not notify anyone unless --notify {first} is passed; sending without it" + )) +} + pub async fn cmd_send_message( client: &BuzzClient, mut p: SendMessageParams, @@ -490,6 +523,10 @@ pub async fn cmd_send_message( // bugs for agent and human users alike. p.content = read_or_stdin(&p.content)?; validate_content_size(&p.content)?; + let notify = parse_notify_mode(p.notify.as_deref())?; + if let Some(warning) = unflagged_notify_warning(&p.content, notify) { + eprintln!("{warning}"); + } if let Some(ref r) = p.reply_to { validate_hex64(r)?; } @@ -538,10 +575,14 @@ pub async fn cmd_send_message( let mention_refs: Vec<&str> = auto_resolved.iter().map(|s| s.as_str()).collect(); let builder = match p.kind { - Some(45001) => { - buzz_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags) - .map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))? - } + Some(45001) => buzz_sdk::build_forum_post( + channel_uuid, + &final_content, + &mention_refs, + notify, + &media_tags, + ) + .map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))?, Some(45003) => { let tr = thread_ref.as_ref().ok_or_else(|| { CliError::Usage("--reply-to is required for forum comments (kind 45003)".into()) @@ -551,6 +592,7 @@ pub async fn cmd_send_message( &final_content, tr, &mention_refs, + notify, &media_tags, ) .map_err(|e| CliError::Other(format!("build_forum_comment failed: {e}")))? @@ -561,6 +603,7 @@ pub async fn cmd_send_message( thread_ref.as_ref(), &mention_refs, p.broadcast, + notify, &media_tags, ) .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?, @@ -764,6 +807,7 @@ pub async fn dispatch( kind, reply_to, broadcast, + notify, files, } => { cmd_send_message( @@ -774,6 +818,7 @@ pub async fn dispatch( kind, reply_to, broadcast, + notify, files, }, ) @@ -876,7 +921,10 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys}; + use super::{ + find_root_from_tags, match_profiles_by_name, parse_member_pubkeys, parse_notify_mode, + unflagged_notify_warning, CliError, NotifyMode, + }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, }; @@ -1164,4 +1212,49 @@ mod tests { ]; assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } + + #[test] + fn notify_flag_parses_both_modes() { + assert_eq!(parse_notify_mode(None).expect("no flag"), None); + assert_eq!( + parse_notify_mode(Some("channel")).expect("channel"), + Some(NotifyMode::Channel) + ); + assert_eq!( + parse_notify_mode(Some("here")).expect("here"), + Some(NotifyMode::Here) + ); + } + + #[test] + fn notify_flag_rejects_unknown_value_as_usage_error() { + // Usage errors exit 1; anything else would mask a typo as a send failure. + let err = parse_notify_mode(Some("everyone")).unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "got {err:?}"); + assert!(err.to_string().contains("everyone")); + // Wire format is lowercase-only. + assert!(parse_notify_mode(Some("Channel")).is_err()); + } + + #[test] + fn literal_reserved_token_without_flag_warns() { + let warning = unflagged_notify_warning("ship it @channel", None).expect("warning"); + assert!(warning.contains("@channel")); + assert!(warning.contains("--notify channel")); + assert!(unflagged_notify_warning("heads up @here", None) + .expect("warning") + .contains("--notify here")); + } + + #[test] + fn no_warning_when_flag_passed_or_token_absent() { + assert!(unflagged_notify_warning("ship it @channel", Some(NotifyMode::Channel)).is_none()); + assert!(unflagged_notify_warning("hello @alice", None).is_none()); + } + + #[test] + fn reserved_token_inside_code_region_does_not_warn() { + assert!(unflagged_notify_warning("run `git push @here`", None).is_none()); + assert!(unflagged_notify_warning("```\n@channel\n```", None).is_none()); + } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082d..214a48326e 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -348,7 +348,11 @@ buzz agents archived" pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" + after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n buzz messages send --channel --content \"deploy is done\" --notify channel\n echo \"hello from stdin\" | buzz messages send --channel --content -\n\n\ +Channel-wide mentions:\n \ +--notify channel notifies every member of the channel (muted members excluded)\n \ +--notify here notifies members who are online right now\n \ +Literal @channel/@here text in --content does NOT notify without the flag." )] Send { /// Channel UUID (from 'buzz channels list') @@ -366,6 +370,9 @@ pub enum MessagesCmd { /// Also publish to the Nostr network #[arg(long, default_value_t = false)] broadcast: bool, + /// Channel-wide mention: 'channel' (all members) or 'here' (online members) + #[arg(long, value_name = "MODE")] + notify: Option, /// Attach file(s) — uploads and includes as imeta tags #[arg(long = "file")] files: Vec, diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..da49669f86 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -8,6 +8,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Weak}; +use buzz_core::channel_mentions::is_reserved_mention_token; use buzz_core::kind::KIND_STREAM_MESSAGE; use buzz_core::tenant::CommunityId; use buzz_workflow::action_sink::{ActionSink, ActionSinkError}; @@ -40,6 +41,10 @@ use crate::state::AppState; /// - **Ambiguous names wake no one.** If two or more members share the matched /// display name, no `p` tag is emitted for it — arbitrary selection would /// silently misroute and tagging all of them is a false-wake firehose. +/// - **Reserved tokens name nobody.** `@channel` and `@here` are NIP-CM +/// channel-wide mentions; they never resolve to an identity, even when a +/// member is literally named one of them. Workflows emit no notify tag in v1, +/// so such text is inert. /// /// Returns deduplicated pubkey hexes, in first-appearance order in `text`. fn resolve_mention_pubkeys(text: &str, members: &[(String, String)]) -> Vec { @@ -48,7 +53,7 @@ fn resolve_mention_pubkeys(text: &str, members: &[(String, String)]) -> Vec> = std::collections::HashMap::new(); for (name, pubkey) in members { - if name.trim().is_empty() { + if name.trim().is_empty() || is_reserved_mention_token(name.trim()) { continue; } by_name @@ -63,7 +68,10 @@ fn resolve_mention_pubkeys(text: &str, members: &[(String, String)]) -> Vec = members.iter().collect(); + let mut names: Vec<&(String, String)> = members + .iter() + .filter(|(name, _)| !is_reserved_mention_token(name.trim())) + .collect(); names.sort_by_key(|(name, _)| std::cmp::Reverse(name.chars().count())); let chars: Vec = text.chars().collect(); @@ -401,6 +409,23 @@ mod tests { assert!(resolve_mention_pubkeys("hey @Stranger and @", &members).is_empty()); } + #[test] + fn reserved_channel_wide_tokens_resolve_to_nobody() { + // Even with a member literally named "here", @here is a NIP-CM + // channel-wide mention and must never emit a p tag. + let members = vec![m("here", &pk('a')), m("channel", &pk('b'))]; + assert!(resolve_mention_pubkeys("@here @channel @Here", &members).is_empty()); + } + + #[test] + fn reserved_token_does_not_shadow_a_real_mention() { + let members = vec![m("here", &pk('a')), m("Robby", &pk('b'))]; + assert_eq!( + resolve_mention_pubkeys("@here @Robby take a look", &members), + vec![pk('b')] + ); + } + #[test] fn greedy_longest_binds_full_name_not_prefix() { // Both "Will" and "Will Pfleger" are members. `@Will Pfleger` must bind diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f9e54de9c5..0675565bd1 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -4,6 +4,7 @@ //! The caller signs: `builder.sign_with_keys(&keys)?`. use buzz_core::{ + channel_mentions::{NotifyMode, NOTIFY_TAG}, kind::{ KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH, @@ -208,6 +209,17 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk Ok(()) } +/// Emit the NIP-CM channel-wide mention tag, if any. +/// +/// Deliberately no `p` tag expansion: the marker tag alone carries the +/// channel-wide mention, so the roster never lands in the event. +fn notify_tag(notify: Option, tags: &mut Vec) -> Result<(), SdkError> { + if let Some(mode) = notify { + tags.push(tag(&[NOTIFY_TAG, mode.as_str()])?); + } + Ok(()) +} + /// Build a stream message (kind 9). /// /// - `channel_id`: target channel UUID @@ -215,6 +227,7 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk /// - `thread_ref`: optional NIP-10 reply context /// - `mentions`: pubkey hex strings to p-tag (deduped, max 50) /// - `broadcast`: if true, adds `["broadcast", "1"]` tag +/// - `notify`: if set, adds the NIP-CM `["notify", "channel"|"here"]` tag /// - `media_tags`: raw imeta tag vectors pub fn build_message( channel_id: Uuid, @@ -222,6 +235,7 @@ pub fn build_message( thread_ref: Option<&ThreadRef>, mentions: &[&str], broadcast: bool, + notify: Option, media_tags: &[Vec], ) -> Result { check_content(content, 64 * 1024)?; @@ -233,6 +247,7 @@ pub fn build_message( if broadcast { tags.push(tag(&["broadcast", "1"])?); } + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } @@ -275,31 +290,39 @@ pub fn build_agent_observer_frame( } /// Build a forum post thread root (kind 45001). +/// +/// `notify` optionally adds the NIP-CM `["notify", …]` channel-wide mention tag. pub fn build_forum_post( channel_id: Uuid, content: &str, mentions: &[&str], + notify: Option, media_tags: &[Vec], ) -> Result { check_content(content, 64 * 1024)?; let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; mention_tags(mentions, &mut tags)?; + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(45001), content).tags(tags)) } /// Build a forum comment reply (kind 45003). +/// +/// `notify` optionally adds the NIP-CM `["notify", …]` channel-wide mention tag. pub fn build_forum_comment( channel_id: Uuid, content: &str, thread_ref: &ThreadRef, mentions: &[&str], + notify: Option, media_tags: &[Vec], ) -> Result { check_content(content, 64 * 1024)?; let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; thread_tags(thread_ref, &mut tags)?; mention_tags(mentions, &mut tags)?; + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } @@ -1873,7 +1896,7 @@ mod tests { #[test] fn message_happy_path() { let cid = uuid(); - let ev = sign(build_message(cid, "hello", None, &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "hello", None, &[], false, None, &[]).unwrap()); assert_eq!(ev.kind.as_u16(), 9); assert_eq!(ev.content, "hello"); assert!(has_tag(&ev, "h", &cid.to_string())); @@ -1931,7 +1954,7 @@ mod tests { root_event_id: eid, parent_event_id: eid, }; - let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, None, &[]).unwrap()); // Direct reply: only one e-tag with "reply" marker let e_tags: Vec<_> = ev .tags @@ -1954,7 +1977,7 @@ mod tests { root_event_id: root, parent_event_id: parent, }; - let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, None, &[]).unwrap()); let e_tags: Vec<_> = ev .tags .iter() @@ -1972,15 +1995,56 @@ mod tests { #[test] fn message_broadcast_flag() { let cid = uuid(); - let ev = sign(build_message(cid, "hi", None, &[], true, &[]).unwrap()); + let ev = sign(build_message(cid, "hi", None, &[], true, None, &[]).unwrap()); assert!(has_tag(&ev, "broadcast", "1")); } + #[test] + fn message_notify_tag() { + let cid = uuid(); + for (mode, expected) in [(NotifyMode::Channel, "channel"), (NotifyMode::Here, "here")] { + let ev = sign(build_message(cid, "hi", None, &[], false, Some(mode), &[]).unwrap()); + assert!(has_tag(&ev, "notify", expected)); + assert!( + !ev.tags + .iter() + .any(|t| t.as_slice().first() == Some(&"p".to_string())), + "channel-wide mentions never expand to p tags" + ); + } + } + + #[test] + fn message_without_notify_has_no_notify_tag() { + let cid = uuid(); + let ev = sign(build_message(cid, "hi", None, &[], false, None, &[]).unwrap()); + assert!(!ev + .tags + .iter() + .any(|t| t.as_slice().first() == Some(&"notify".to_string()))); + } + + #[test] + fn forum_builders_carry_notify_tag() { + let cid = uuid(); + let ev = sign(build_forum_post(cid, "post", &[], Some(NotifyMode::Channel), &[]).unwrap()); + assert!(has_tag(&ev, "notify", "channel")); + + let eid = event_id(); + let tr = ThreadRef { + root_event_id: eid, + parent_event_id: eid, + }; + let ev = + sign(build_forum_comment(cid, "c", &tr, &[], Some(NotifyMode::Here), &[]).unwrap()); + assert!(has_tag(&ev, "notify", "here")); + } + #[test] fn message_mentions_deduped() { let cid = uuid(); let hex = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; - let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap()); + let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, None, &[]).unwrap()); let p_tags = tag_values(&ev, "p"); assert_eq!(p_tags.len(), 1); } @@ -2001,7 +2065,7 @@ mod tests { }) .collect(); let refs: Vec<&str> = hexes.iter().map(|s| s.as_str()).collect(); - let result = build_message(cid, "hi", None, &refs, false, &[]); + let result = build_message(cid, "hi", None, &refs, false, None, &[]); assert!(matches!(result, Err(SdkError::TooManyMentions))); } @@ -2009,7 +2073,7 @@ mod tests { fn message_content_too_large() { let cid = uuid(); let big = "x".repeat(64 * 1024 + 1); - let result = build_message(cid, &big, None, &[], false, &[]); + let result = build_message(cid, &big, None, &[], false, None, &[]); assert!(matches!(result, Err(SdkError::ContentTooLarge { .. }))); } @@ -2017,13 +2081,13 @@ mod tests { fn message_max_content_ok() { let cid = uuid(); let max = "x".repeat(64 * 1024); - assert!(build_message(cid, &max, None, &[], false, &[]).is_ok()); + assert!(build_message(cid, &max, None, &[], false, None, &[]).is_ok()); } #[test] fn forum_post_happy_path() { let cid = uuid(); - let ev = sign(build_forum_post(cid, "post body", &[], &[]).unwrap()); + let ev = sign(build_forum_post(cid, "post body", &[], None, &[]).unwrap()); assert_eq!(ev.kind.as_u16(), 45001); assert!(has_tag(&ev, "h", &cid.to_string())); } @@ -2033,7 +2097,7 @@ mod tests { let cid = uuid(); let big = "x".repeat(64 * 1024 + 1); assert!(matches!( - build_forum_post(cid, &big, &[], &[]), + build_forum_post(cid, &big, &[], None, &[]), Err(SdkError::ContentTooLarge { .. }) )); } @@ -2046,7 +2110,7 @@ mod tests { root_event_id: eid, parent_event_id: eid, }; - let ev = sign(build_forum_comment(cid, "comment", &tr, &[], &[]).unwrap()); + let ev = sign(build_forum_comment(cid, "comment", &tr, &[], None, &[]).unwrap()); assert_eq!(ev.kind.as_u16(), 45003); assert!(has_tag(&ev, "h", &cid.to_string())); } diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 4ee0cd4c88..1095042c97 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -82,6 +82,8 @@ pub use buzz_core::channel::ChannelType as ChannelKind; pub use buzz_core::channel::ChannelVisibility as Visibility; /// Member role. pub use buzz_core::channel::MemberRole; +/// NIP-CM channel-wide mention mode (`@channel` / `@here`). +pub use buzz_core::channel_mentions::NotifyMode; /// Errors returned by SDK builder functions. #[derive(Debug, thiserror::Error)] diff --git a/crates/buzz-sdk/src/mentions.rs b/crates/buzz-sdk/src/mentions.rs index e59580c7ae..c8cc99bd7f 100644 --- a/crates/buzz-sdk/src/mentions.rs +++ b/crates/buzz-sdk/src/mentions.rs @@ -29,6 +29,7 @@ use std::collections::HashSet; +use buzz_core::channel_mentions::is_reserved_mention_token; use nostr::{FromBech32, PublicKey}; /// Maximum number of mention p-tags allowed on a single message. @@ -61,7 +62,31 @@ pub struct MentionProfile<'a> { /// /// Allowed name characters: ASCII alphanumerics, `.`, `-`, `_`. /// Duplicates are removed; first-seen order is preserved. +/// +/// The reserved channel-wide mention tokens (`@channel`, `@here`) are never +/// returned — see [`extract_reserved_mention_tokens`]. pub fn extract_at_names(content: &str) -> Vec { + scan_at_tokens(content) + .into_iter() + .filter(|name| !is_reserved_mention_token(name)) + .collect() +} + +/// Extract the reserved channel-wide mention tokens (`channel`, `here`) that +/// appear as `@tokens` in `content`. +/// +/// Returned lowercased, deduplicated, in first-seen order. Matching is +/// case-insensitive, so `@Here` is reported as `here`. Callers that care about +/// code blocks should pass content through [`strip_code_regions`] first. +pub fn extract_reserved_mention_tokens(content: &str) -> Vec { + scan_at_tokens(content) + .into_iter() + .filter(|name| is_reserved_mention_token(name)) + .collect() +} + +/// Scan single-word `@tokens`, lowercased, deduplicated, first-seen order. +fn scan_at_tokens(content: &str) -> Vec { if content.is_empty() || !content.contains('@') { return vec![]; } @@ -104,6 +129,9 @@ pub fn extract_at_names(content: &str) -> Vec { /// longest-first (case-insensitive, word-boundary-checked), then falls back /// to single-word tokenization. Returns lowercased names in first-seen order, /// deduplicated. Empty/whitespace-only entries in `known_names` are ignored. +/// +/// The reserved channel-wide mention tokens (`channel`, `here`) are never +/// returned, even when a member is literally named one of them. pub fn extract_at_mentions_with_known(content: &str, known_names: &[&str]) -> Vec { if content.is_empty() || !content.contains('@') { return vec![]; @@ -144,6 +172,9 @@ pub fn extract_at_mentions_with_known(content: &str, known_names: &[&str]) -> Ve rest[..end].to_ascii_lowercase() }; + if is_reserved_mention_token(&lower) { + continue; + } if seen.insert(lower.clone()) { names.push(lower); } @@ -171,7 +202,9 @@ fn is_word_boundary(s: &str) -> bool { /// rather than text-position ordering. /// /// Profiles whose `content_json` does not parse, or whose `display_name` -/// (and `name`) are absent or non-string, are silently skipped. +/// (and `name`) are absent or non-string, are silently skipped. So are +/// profiles named after a reserved channel-wide mention token (`channel`, +/// `here`) — those tokens never resolve to an identity. /// /// Duplicate display names within a channel will produce multiple matches /// for a single `@name` — this is by design; resolution is bounded to @@ -190,7 +223,7 @@ pub fn match_names_to_profiles(names: &[String], profiles: &[MentionProfile<'_>] .or_else(|| value.get("name")) .and_then(|v| v.as_str()) .unwrap_or(""); - if name.is_empty() { + if name.is_empty() || is_reserved_mention_token(name) { continue; } if names.iter().any(|n| n.eq_ignore_ascii_case(name)) { @@ -426,6 +459,54 @@ mod tests { assert!(extract_at_names("hello @").is_empty()); } + #[test] + fn reserved_tokens_are_never_extracted_as_names() { + assert!(extract_at_names("@channel ship it").is_empty()); + assert!(extract_at_names("heads up @Here").is_empty()); + assert_eq!( + extract_at_names("@channel and @alice"), + vec!["alice"], + "regular names alongside a reserved token still resolve" + ); + } + + #[test] + fn reserved_tokens_lose_to_no_one_even_a_member_named_here() { + // A member whose display name is literally "here" must not be pulled in + // by @here — the reserved token wins in every parser. + let names = extract_at_mentions_with_known("ping @here now", &["here", "Alice"]); + assert!(names.is_empty(), "got {names:?}"); + + let profiles = [MentionProfile { + pubkey: "aa", + content_json: r#"{"display_name":"here"}"#, + }]; + assert!(match_names_to_profiles(&["here".to_string()], &profiles).is_empty()); + } + + #[test] + fn reserved_token_prefixes_are_still_ordinary_names() { + assert_eq!( + extract_at_names("@channels @herer"), + vec!["channels", "herer"] + ); + } + + #[test] + fn extract_reserved_mention_tokens_reports_lowercased_tokens() { + assert_eq!( + extract_reserved_mention_tokens("hey @Channel and @here"), + vec!["channel", "here"] + ); + assert!(extract_reserved_mention_tokens("hey @alice").is_empty()); + assert!(extract_reserved_mention_tokens("user@channel.com").is_empty()); + assert_eq!( + extract_reserved_mention_tokens("@here @HERE"), + vec!["here"], + "deduplicated case-insensitively" + ); + } + #[test] fn known_multiword_name_matches_fully() { // "Will Pfleger" should match @Will Pfleger, not just @Will. diff --git a/examples/countdown-bot/src/main.rs b/examples/countdown-bot/src/main.rs index ed06212156..f0267efe95 100644 --- a/examples/countdown-bot/src/main.rs +++ b/examples/countdown-bot/src/main.rs @@ -239,6 +239,7 @@ async fn maybe_reply( None, &[&event.pubkey.to_hex()], false, + None, &[], )?; let reply_event = builder.sign_with_keys(&config.bot_keys)?; From 5319e5207a69ef2197f4def326d84380b01ca186 Mon Sep 17 00:00:00 2001 From: LordMelkor Date: Mon, 27 Jul 2026 13:42:55 -0400 Subject: [PATCH 05/26] feat(sdk,cli): channel-wide mention support for @channel/@here (#3146) Wire the NIP-CM ["notify", "channel"|"here"] marker tag through the SDK builders and expose it on the CLI as "buzz messages send --notify ". No p-tag expansion: the marker tag alone carries the channel-wide mention. - buzz-sdk: optional NotifyMode on build_message / build_forum_post / build_forum_comment; re-export NotifyMode. - buzz-sdk mentions: reserved tokens (channel, here, case-insensitive) never resolve to a member identity, even for a member literally named "here"; new extract_reserved_mention_tokens for the CLI warning path. - buzz-cli: --notify (invalid value is a usage error, exit 1, checked before stdin is consumed); stderr warning when literal @channel or @here appears outside code regions without the flag, send proceeds untagged. - workflow_sink: resolve_mention_pubkeys skips reserved tokens. Signed-off-by: LordMelkor Co-authored-by: Claude Code Ai-assisted: true Signed-off-by: LordMelkor --- crates/buzz-cli/src/commands/messages.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 7eb95db894..8e0a2d403e 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -517,13 +517,15 @@ pub async fn cmd_send_message( client: &BuzzClient, mut p: SendMessageParams, ) -> Result<(), CliError> { + // Reject a bad --notify value before consuming stdin, so a typo does not + // eat piped content the caller cannot replay. + let notify = parse_notify_mode(p.notify.as_deref())?; // Allow '-' to read content from stdin. This keeps callers from having to // jam shell-metacharacter-heavy text (backticks, $vars, etc.) through argv // quoting — the source of countless self-inflicted command-substitution // bugs for agent and human users alike. p.content = read_or_stdin(&p.content)?; validate_content_size(&p.content)?; - let notify = parse_notify_mode(p.notify.as_deref())?; if let Some(warning) = unflagged_notify_warning(&p.content, notify) { eprintln!("{warning}"); } From 52783dc6cbf04c12b7e04dc9d30b52a24e280aed Mon Sep 17 00:00:00 2001 From: LordMelkor Date: Mon, 27 Jul 2026 14:04:27 -0400 Subject: [PATCH 06/26] feat(desktop): compose and send @channel/@here channel mentions (#3146) Adds the compose half of NIP-CM to the desktop app: the autocomplete offers @channel / @here, selecting one inserts the literal token instead of a pubkey mention, and the send flow confirms the blast before attaching a single ["notify", mode] tag to the outgoing event. - shared/constants/notify.ts pins the tag name and the two lowercase wire modes, mirroring buzz_core::channel_mentions. - mentionCandidates gains a `special` candidate kind for the two rows (member count shown only when the member list is already loaded; no online count for @here), suppressed in DMs where the relay rejects the tag. - extractMentionPubkeys moves to resolveMentionPubkeys.ts, which gives the reserved tokens precedence: neither @channel nor @here ever resolves to a member pubkey, including a member literally named "here". - Detection reuses the hasMention matcher, so a token inside a code fence or backtick span notifies nobody. - The Tauri builders take an optional notify mode and emit the marker, with the mode parsed by buzz-core so desktop and relay accept the same spellings. Render, notification, and unread handling are not part of this change. Signed-off-by: LordMelkor --- desktop/src-tauri/src/commands/messages.rs | 7 + desktop/src-tauri/src/events.rs | 179 +++------------ desktop/src-tauri/src/events_tests.rs | 214 ++++++++++++++++++ desktop/src-tauri/src/huddle/pipeline.rs | 2 +- desktop/src/features/home/ui/HomeView.tsx | 15 +- desktop/src/features/messages/hooks.ts | 14 +- .../messages/lib/channelNotify.test.mjs | 46 ++++ .../features/messages/lib/channelNotify.ts | 47 ++++ .../messages/lib/imetaMediaMarkdown.test.mjs | 21 +- .../messages/lib/imetaMediaMarkdown.ts | 32 ++- .../messages/lib/mentionCandidates.test.mjs | 31 +++ .../messages/lib/mentionCandidates.ts | 38 +++- .../features/messages/lib/mentionRanking.ts | 4 +- .../messages/lib/mentionSuggestionMapping.ts | 5 +- .../lib/resolveMentionPubkeys.test.mjs | 89 ++++++++ .../messages/lib/resolveMentionPubkeys.ts | 55 +++++ .../src/features/messages/lib/useMentions.ts | 83 ++++--- .../messages/ui/ChannelNotifyDialog.tsx | 81 +++++++ .../messages/ui/ComposerMentionDialogs.tsx | 41 ++++ .../messages/ui/MentionAutocomplete.tsx | 32 ++- .../features/messages/ui/MessageComposer.tsx | 13 +- .../messages/ui/useMentionSendFlow.ts | 60 ++++- desktop/src/shared/api/tauri.ts | 7 + desktop/src/shared/constants/notify.test.mjs | 28 +++ desktop/src/shared/constants/notify.ts | 35 +++ desktop/src/testing/e2eBridge.ts | 7 +- 26 files changed, 948 insertions(+), 238 deletions(-) create mode 100644 desktop/src-tauri/src/events_tests.rs create mode 100644 desktop/src/features/messages/lib/channelNotify.test.mjs create mode 100644 desktop/src/features/messages/lib/channelNotify.ts create mode 100644 desktop/src/features/messages/lib/resolveMentionPubkeys.test.mjs create mode 100644 desktop/src/features/messages/lib/resolveMentionPubkeys.ts create mode 100644 desktop/src/features/messages/ui/ChannelNotifyDialog.tsx create mode 100644 desktop/src/features/messages/ui/ComposerMentionDialogs.tsx create mode 100644 desktop/src/shared/constants/notify.test.mjs create mode 100644 desktop/src/shared/constants/notify.ts diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b7c37bec3d..781c2a0661 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -537,6 +537,7 @@ pub async fn send_channel_message( mention_tags: Option>>, mention_pubkeys: Option>, kind: Option, + notify: Option, state: State<'_, AppState>, ) -> Result { let channel_uuid = uuid::Uuid::parse_str(&channel_id) @@ -547,6 +548,8 @@ pub async fn send_channel_message( let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); + // NIP-CM channel-wide mention marker; validated by the builder. + let notify_mode = notify.as_deref().map(str::trim).filter(|m| !m.is_empty()); let mut resolved_root: Option = None; @@ -555,6 +558,7 @@ pub async fn send_channel_message( channel_uuid, content.trim(), &mention_refs, + notify_mode, &media, &mention_refs_only, )?, @@ -569,6 +573,7 @@ pub async fn send_channel_message( content.trim(), &thread_ref, &mention_refs, + notify_mode, &media, &mention_refs_only, )? @@ -587,6 +592,7 @@ pub async fn send_channel_message( content.trim(), thread_ref.as_ref(), &mention_refs, + notify_mode, &media, &emoji, &mention_refs_only, @@ -753,6 +759,7 @@ fn build_managed_agent_channel_message( content, thread_ref, &mention_refs, + None, &[], &[], &[], diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..18e30294a0 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -9,6 +9,9 @@ //! Each function validates inputs and returns a nostr::EventBuilder. //! Signing and submission happen in relay::submit_event. +use std::str::FromStr; + +use buzz_core_pkg::channel_mentions::{NotifyMode, NOTIFY_TAG}; use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; @@ -93,6 +96,20 @@ fn mention_reference_tags(mentions: &[Vec], tags: &mut Vec) -> Resu Ok(()) } +/// Validate and append the NIP-CM channel-wide mention marker. +/// +/// At most one `["notify", mode]` tag per event; the mode is parsed by +/// `buzz_core::channel_mentions` so the desktop and the relay accept exactly +/// the same spellings (lowercase `channel` / `here`). +fn notify_tag(notify: Option<&str>, tags: &mut Vec) -> Result<(), String> { + let Some(raw) = notify else { + return Ok(()); + }; + let mode = NotifyMode::from_str(raw).map_err(|e| e.to_string())?; + tags.push(tag(vec![NOTIFY_TAG, mode.as_str()])?); + Ok(()) +} + /// Validate and append imeta tags. Rejects any tag whose first element is not "imeta" /// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags). fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { @@ -295,11 +312,13 @@ pub fn build_remove_member(channel_id: Uuid, target_pubkey: &str) -> Result, mentions: &[&str], + notify: Option<&str>, media_tags: &[Vec], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], @@ -309,6 +328,7 @@ pub fn build_message( content, thread_ref, mentions, + notify, media_tags, custom_emoji_tags, mention_ref_tags, @@ -327,6 +347,7 @@ pub fn build_message_with_client_tags( content: &str, thread_ref: Option<&ThreadRef>, mentions: &[&str], + notify: Option<&str>, media_tags: &[Vec], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], @@ -338,6 +359,7 @@ pub fn build_message_with_client_tags( tags.extend(thread_tags(tr)?); } tags.extend(mention_tags(mentions)?); + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; emoji_tags(custom_emoji_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; @@ -367,23 +389,27 @@ pub fn build_forum_post( channel_id: Uuid, content: &str, mentions: &[&str], + notify: Option<&str>, media_tags: &[Vec], mention_ref_tags: &[Vec], ) -> Result { check_content(content)?; let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; tags.extend(mention_tags(mentions)?); + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(45001), content).tags(tags)) } /// Kind 45003 — forum comment. +#[allow(clippy::too_many_arguments)] pub fn build_forum_comment( channel_id: Uuid, content: &str, thread_ref: &ThreadRef, mentions: &[&str], + notify: Option<&str>, media_tags: &[Vec], mention_ref_tags: &[Vec], ) -> Result { @@ -391,6 +417,7 @@ pub fn build_forum_comment( let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; tags.extend(thread_tags(thread_ref)?); tags.extend(mention_tags(mentions)?); + notify_tag(notify, &mut tags)?; imeta_tags(media_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) @@ -847,153 +874,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..4ddd02ae26 --- /dev/null +++ b/desktop/src-tauri/src/events_tests.rs @@ -0,0 +1,214 @@ +use super::*; +use nostr::Keys; +fn builder_tags(builder: EventBuilder) -> Vec> { + let keys = Keys::generate(); + let event = builder.sign_with_keys(&keys).expect("sign event"); + event.tags.iter().map(|t| t.as_slice().to_vec()).collect() +} + +#[test] +fn build_message_emits_one_notify_tag_per_mode() { + for mode in ["channel", "here"] { + let builder = build_message( + Uuid::new_v4(), + "heads up", + None, + &[], + Some(mode), + &[], + &[], + &[], + ) + .expect("build_message"); + let notify: Vec> = builder_tags(builder) + .into_iter() + .filter(|t| t.first().map(String::as_str) == Some("notify")) + .collect(); + assert_eq!(notify, vec![vec!["notify".to_string(), mode.to_string()]]); + } +} + +#[test] +fn build_message_omits_notify_tag_when_absent() { + let builder = + build_message(Uuid::new_v4(), "hi", None, &[], None, &[], &[], &[]).expect("build_message"); + assert!(builder_tags(builder) + .iter() + .all(|t| t.first().map(String::as_str) != Some("notify"))); +} + +#[test] +fn build_message_rejects_unknown_notify_mode() { + for mode in ["Channel", "everyone", ""] { + let err = build_message(Uuid::new_v4(), "hi", None, &[], Some(mode), &[], &[], &[]) + .expect_err("mode must be rejected"); + assert!(err.contains("notify mode"), "unexpected error: {err}"); + } +} + +#[test] +fn forum_builders_carry_notify_tag() { + let post = build_forum_post(Uuid::new_v4(), "ship it", &[], Some("channel"), &[], &[]) + .expect("build_forum_post"); + assert!(builder_tags(post).contains(&vec!["notify".into(), "channel".into()])); + + let event_id = EventId::all_zeros(); + let thread_ref = ThreadRef { + root_event_id: event_id, + parent_event_id: event_id, + }; + let comment = build_forum_comment( + Uuid::new_v4(), + "agreed", + &thread_ref, + &[], + Some("here"), + &[], + &[], + ) + .expect("build_forum_comment"); + assert!(builder_tags(comment).contains(&vec!["notify".into(), "here".into()])); +} + +#[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 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-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index ceccedd8b6..b6ebb42498 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -298,7 +298,7 @@ pub(crate) fn spawn_transcription_task( let p_tags: Vec<&str> = agent_pubkeys.iter().map(|s| s.as_str()).collect(); let builder = - match events::build_message(channel_uuid, &t, None, &p_tags, &[], &[], &[]) { + match events::build_message(channel_uuid, &t, None, &p_tags, None, &[], &[], &[]) { Ok(b) => b, Err(e) => { eprintln!("buzz-desktop: STT build_message: {e}"); diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 0f7c851643..90254b60c8 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -799,20 +799,17 @@ export function HomeView({ const itemToReply = selectedItem; setIsSendingReply(true); try { - const { - mediaTags: imetaTags, - emojiTags, - mentionTags, - } = splitOutgoingTags(mediaTags); + const split = splitOutgoingTags(mediaTags); const result = await sendChannelMessage( channelId, content, parentEventId, - imetaTags, + split.mediaTags, mentionPubkeys, undefined, - emojiTags, - mentionTags, + split.emojiTags, + split.mentionTags, + split.notifyMode, ); const authorPubkey = currentPubkey ?? itemToReply.item.pubkey; const reply: InboxReply = { @@ -838,7 +835,7 @@ export function HomeView({ id: result.eventId, parentId: result.parentEventId, rootId: result.rootEventId, - tags: emojiTags, + tags: split.emojiTags, timeLabel: formatTime(result.createdAt), }; setLocalRepliesByItemId((current) => ({ diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b..d06c2b4eea 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -458,6 +458,9 @@ export function useSendMessageMutation( mediaTags: imetaTags, emojiTags, mentionTags, + notifyTags, + // NIP-CM: at most one marker; the Rust command re-validates the mode. + notifyMode: notify, } = splitOutgoingTags(mediaTags); const recipientPubkeys = messageMentionPubkeys( effectiveChannel, @@ -468,7 +471,14 @@ export function useSendMessageMutation( // 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) { + // A notify tag also forces the REST path: the WebSocket send emits no + // extra tags, so the marker would be silently dropped. + if ( + parentEventId || + imetaTags.length > 0 || + emojiTags.length > 0 || + notify !== null + ) { const cachedMessages = queryClient.getQueryData( channelMessagesKey(effectiveChannel.id), @@ -482,6 +492,7 @@ export function useSendMessageMutation( undefined, emojiTags, mentionTags, + notify, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -519,6 +530,7 @@ export function useSendMessageMutation( ...imetaTags, ...emojiTags, ...mentionTags, + ...notifyTags, ], content: content.trim(), sig: "", diff --git a/desktop/src/features/messages/lib/channelNotify.test.mjs b/desktop/src/features/messages/lib/channelNotify.test.mjs new file mode 100644 index 0000000000..b6472c97ba --- /dev/null +++ b/desktop/src/features/messages/lib/channelNotify.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildNotifyTags, + detectNotifyMode, + isReservedMentionName, + reservedMentionToken, +} from "./channelNotify.ts"; + +test("reserved tokens are matched exactly and case-insensitively", () => { + assert.equal(reservedMentionToken("channel"), "channel"); + assert.equal(reservedMentionToken("Here"), "here"); + assert.equal(reservedMentionToken(" CHANNEL "), "channel"); + assert.equal(reservedMentionToken("channels"), null); + assert.equal(reservedMentionToken("hereford"), null); + assert.equal(isReservedMentionName("HERE"), true); + assert.equal(isReservedMentionName("Herelia"), false); +}); + +test("detectNotifyMode finds either mode in ordinary prose", () => { + assert.equal(detectNotifyMode("heads up @channel"), "channel"); + assert.equal(detectNotifyMode("@here can someone look?"), "here"); + assert.equal(detectNotifyMode("**@here** please"), "here"); + assert.equal(detectNotifyMode("no mention at all"), null); + assert.equal(detectNotifyMode("mail me at foo@here.example"), null); +}); + +test("detectNotifyMode prefers @channel when both appear", () => { + assert.equal(detectNotifyMode("@here and @channel"), "channel"); + assert.equal(detectNotifyMode("@channel plus @here"), "channel"); +}); + +test("detectNotifyMode ignores tokens inside code", () => { + assert.equal(detectNotifyMode("use `@here` in a message"), null); + assert.equal(detectNotifyMode("```\n@channel\n```"), null); + assert.equal(detectNotifyMode(" @channel"), null); + // A real mention alongside a code sample still notifies. + assert.equal(detectNotifyMode("@channel see `@here`"), "channel"); +}); + +test("buildNotifyTags emits at most one marker tag", () => { + assert.deepEqual(buildNotifyTags("channel"), [["notify", "channel"]]); + assert.deepEqual(buildNotifyTags("here"), [["notify", "here"]]); + assert.deepEqual(buildNotifyTags(null), []); +}); diff --git a/desktop/src/features/messages/lib/channelNotify.ts b/desktop/src/features/messages/lib/channelNotify.ts new file mode 100644 index 0000000000..7ef452c434 --- /dev/null +++ b/desktop/src/features/messages/lib/channelNotify.ts @@ -0,0 +1,47 @@ +/** + * Compose-side helpers for channel-wide mentions (`@channel` / `@here`). + * + * `channel` and `here` are reserved mention tokens: they never resolve to a + * member pubkey, even when somebody's display name is literally "here". What + * they do produce is a single `["notify", mode]` tag on the outgoing event. + */ + +import { + NOTIFY_MODES, + NOTIFY_TAG, + type NotifyMode, +} from "@/shared/constants/notify"; +import { hasMention } from "./hasMention"; + +export type { NotifyMode }; + +/** + * Resolve a display name to the notify mode it reserves, if any. Matching is + * exact and case-insensitive: `@Channel` is reserved, `@channels` is not. + */ +export function reservedMentionToken(name: string): NotifyMode | null { + const normalized = name.trim().toLowerCase(); + return NOTIFY_MODES.find((mode) => mode === normalized) ?? null; +} + +/** Whether `name` is a reserved mention token and so never maps to a pubkey. */ +export function isReservedMentionName(name: string): boolean { + return reservedMentionToken(name) !== null; +} + +/** + * Detect the notify mode an outgoing message body asks for, or null. + * + * Uses the shared `@mention` matcher, so tokens inside code fences, indented + * blocks, or backtick spans are masked and never notify. `@channel` wins over + * `@here` when both appear: it is the broader audience, so confirming it also + * covers everyone `@here` would have reached. + */ +export function detectNotifyMode(text: string): NotifyMode | null { + return NOTIFY_MODES.find((mode) => hasMention(text, mode)) ?? null; +} + +/** Outgoing tag set for a notify mode — empty when there is nothing to notify. */ +export function buildNotifyTags(mode: NotifyMode | null): string[][] { + return mode ? [[NOTIFY_TAG, mode]] : []; +} diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index a2edaa6f8c..4ae79b31b6 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -667,14 +667,33 @@ const MENTION_REF = [ "1111111111111111111111111111111111111111111111111111111111111111", ]; -test("splitOutgoingTags: undefined input yields three empty arrays", () => { +test("splitOutgoingTags: undefined input yields four empty arrays", () => { assert.deepEqual(splitOutgoingTags(undefined), { mediaTags: [], emojiTags: [], mentionTags: [], + notifyTags: [], + notifyMode: null, }); }); +test("splitOutgoingTags: separates the channel-wide notify marker", () => { + const notify = ["notify", "channel"]; + const { mediaTags, emojiTags, mentionTags, notifyTags, notifyMode } = + splitOutgoingTags([IMETA, notify]); + assert.deepEqual(mediaTags, [IMETA]); + assert.deepEqual(emojiTags, []); + assert.deepEqual(mentionTags, []); + assert.deepEqual(notifyTags, [notify]); + assert.equal(notifyMode, "channel"); +}); + +test("splitOutgoingTags: a malformed notify marker reads as no mention", () => { + const { notifyTags, notifyMode } = splitOutgoingTags([["notify", "all"]]); + assert.deepEqual(notifyTags, [["notify", "all"]]); + assert.equal(notifyMode, null); +}); + test("splitOutgoingTags: separates emoji tags from imeta tags", () => { const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ IMETA, diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 3e16cb332d..6e67c3a89f 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -26,6 +26,11 @@ */ import type { BlobDescriptor } from "@/shared/api/tauri"; +import { + NOTIFY_TAG, + type NotifyMode, + notifyModeFromTags, +} from "@/shared/constants/notify"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; export type ImetaMedia = BlobDescriptor & { @@ -349,28 +354,43 @@ export function mergeOutgoingTags( /** * Inverse of `mergeOutgoingTags`: split a merged outgoing tag set back into - * imeta media tags, NIP-30 `["emoji", ...]` tags, and reference-only mention - * tags, so the send path can route each to its own validated Tauri arg. Emoji - * and mention tags must never ride the imeta-only `media` channel (its guard - * rejects any non-imeta prefix). Any other prefix stays with `mediaTags` — the - * imeta guard will reject it, which is the intended injection defense. + * imeta media tags, NIP-30 `["emoji", ...]` tags, reference-only mention tags, + * and the channel-wide `["notify", mode]` marker, so the send path can route + * each to its own validated Tauri arg. Emoji, mention, and notify tags must + * never ride the imeta-only `media` channel (its guard rejects any non-imeta + * prefix). Any other prefix stays with `mediaTags` — the imeta guard will + * reject it, which is the intended injection defense. + * + * `notifyMode` is the validated mode senders pass to the Tauri command; + * `notifyTags` is the raw marker, which optimistic cache echoes replay as-is. */ export function splitOutgoingTags(tags: string[][] | undefined): { mediaTags: string[][]; emojiTags: string[][]; mentionTags: string[][]; + notifyTags: string[][]; + notifyMode: NotifyMode | null; } { const mediaTags: string[][] = []; const emojiTags: string[][] = []; const mentionTags: string[][] = []; + const notifyTags: string[][] = []; for (const tag of tags ?? []) { if (tag[0] === "emoji") { emojiTags.push(tag); } else if (tag[0] === "mention") { mentionTags.push(tag); + } else if (tag[0] === NOTIFY_TAG) { + notifyTags.push(tag); } else { mediaTags.push(tag); } } - return { mediaTags, emojiTags, mentionTags }; + return { + mediaTags, + emojiTags, + mentionTags, + notifyTags, + notifyMode: notifyModeFromTags(notifyTags), + }; } diff --git a/desktop/src/features/messages/lib/mentionCandidates.test.mjs b/desktop/src/features/messages/lib/mentionCandidates.test.mjs index 355b56dfea..8c307e4e06 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/mentionCandidates.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildChannelMentionCandidates, buildTeamMentionCandidates, formatTeamMention, } from "./mentionCandidates.ts"; @@ -56,6 +57,36 @@ function identity(personaId, displayName, overrides = {}) { }; } +test("channel-wide rows carry no pubkey and describe who they notify", () => { + const [channel, here] = buildChannelMentionCandidates(3); + + assert.deepEqual( + [channel.kind, channel.displayName, channel.pubkey, channel.isMember], + ["special", "channel", undefined, false], + ); + assert.equal( + channel.description, + "Notify everyone in this channel · 3 members", + ); + assert.deepEqual( + [here.kind, here.displayName, here.pubkey], + ["special", "here", undefined], + ); + assert.equal(here.description, "Notify members who are online"); +}); + +test("the member count is omitted when it is not available", () => { + for (const count of [undefined, null, 0]) { + const [channel] = buildChannelMentionCandidates(count); + assert.equal(channel.description, "Notify everyone in this channel"); + } + const [single] = buildChannelMentionCandidates(1); + assert.equal( + single.description, + "Notify everyone in this channel · 1 member", + ); +}); + test("team mentions preserve team order and prefer concrete managed agents", () => { const personas = [ persona("planner", "Planner"), diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 0498bef9ae..9e957b0f66 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -10,12 +10,14 @@ export type TeamMentionMember = { }; export type MentionCandidate = { - kind: "identity" | "persona" | "team"; + kind: "identity" | "persona" | "team" | "special"; pubkey?: string; personaId?: string; teamId?: string; teamMembers?: TeamMentionMember[]; displayName: string | null; + /** Static subtitle for `special` rows; ordinary rows derive theirs. */ + description?: string; avatarUrl?: string | null; isMember: boolean; role?: ChannelRole | null; @@ -27,6 +29,40 @@ export type MentionCandidate = { isGlobalSearchResult?: boolean; }; +/** + * Autocomplete rows for the channel-wide mentions. They resolve to a notify + * tag rather than to identities, so they carry no pubkey. + * + * `memberCount` is only rendered when the caller already has the member list + * loaded; the online count for `@here` is not cheaply available, so that row + * stays count-free. Omitted entirely in DMs, where the relay rejects the tag. + */ +export function buildChannelMentionCandidates( + memberCount?: number | null, +): MentionCandidate[] { + const members = + typeof memberCount === "number" && memberCount > 0 + ? ` · ${memberCount} ${memberCount === 1 ? "member" : "members"}` + : ""; + + return [ + { + kind: "special", + displayName: "channel", + description: `Notify everyone in this channel${members}`, + isMember: false, + isAgent: false, + }, + { + kind: "special", + displayName: "here", + description: "Notify members who are online", + isMember: false, + isAgent: false, + }, + ]; +} + export function mentionCandidateLabel(candidate: MentionCandidate) { return ( candidate.displayName ?? diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index 09b9e03de7..39045d631e 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -4,7 +4,7 @@ export type MentionCandidateForRanking = { displayName: string | null; isAgent: boolean; isMember: boolean; - kind: "identity" | "persona" | "team"; + kind: "identity" | "persona" | "team" | "special"; personaId?: string | null; personaName?: string | null; pubkey?: string; @@ -23,6 +23,8 @@ function getMentionCandidateGroupRank( candidate: MentionCandidateForRanking, activePersonaIds: ReadonlySet, ) { + // Channel-wide rows sort with members: they address the whole channel. + if (candidate.kind === "special") return 0; if (candidate.isMember) return 0; const isRunnablePersona = diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index c710cf613b..8a9d5af293 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -6,7 +6,8 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; import type { TeamMentionMember } from "./mentionCandidates"; export type MentionSuggestionCandidate = { - kind: "identity" | "persona" | "team"; + kind: "identity" | "persona" | "team" | "special"; + description?: string; pubkey?: string; personaId?: string | null; teamId?: string; @@ -44,6 +45,7 @@ export function mapMentionCandidateToSuggestion(opts: { teamId: candidate.teamId, teamMembers: candidate.teamMembers, kind: candidate.kind, + description: candidate.description, displayName: label, avatarUrl: candidate.avatarUrl ?? @@ -54,6 +56,7 @@ export function mapMentionCandidateToSuggestion(opts: { isAgent: candidate.isAgent, notInChannel: candidate.kind !== "team" && + candidate.kind !== "special" && channelType !== "dm" && candidate.isMember === false, ownerLabel, diff --git a/desktop/src/features/messages/lib/resolveMentionPubkeys.test.mjs b/desktop/src/features/messages/lib/resolveMentionPubkeys.test.mjs new file mode 100644 index 0000000000..13a4dfd661 --- /dev/null +++ b/desktop/src/features/messages/lib/resolveMentionPubkeys.test.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveMentionPubkeys } from "./resolveMentionPubkeys.ts"; + +const ALICE = "a".repeat(64); +const HERE_MEMBER = "b".repeat(64); + +function member(displayName, pubkey, overrides = {}) { + return { displayName, pubkey, isMember: true, ...overrides }; +} + +test("selected names resolve to their pubkey", () => { + const pubkeys = resolveMentionPubkeys( + "hi @Alice", + new Map([["Alice", ALICE]]), + [], + [], + ); + assert.deepEqual(pubkeys, [ALICE]); +}); + +test("members are matched by literal display name without a selection", () => { + const pubkeys = resolveMentionPubkeys( + "hi @Alice", + new Map(), + [], + [member("Alice", ALICE)], + ); + assert.deepEqual(pubkeys, [ALICE]); +}); + +test("@channel and @here never resolve to a pubkey", () => { + assert.deepEqual( + resolveMentionPubkeys( + "@channel ship it", + new Map(), + [], + [member("channel", ALICE)], + ), + [], + ); + assert.deepEqual( + resolveMentionPubkeys( + "@here ship it", + new Map(), + [], + [member("here", HERE_MEMBER)], + ), + [], + ); +}); + +test("a member literally named here loses to the reserved token", () => { + const pubkeys = resolveMentionPubkeys( + "@here and @Alice", + new Map([ + ["here", HERE_MEMBER], + ["Alice", ALICE], + ]), + [], + [member("here", HERE_MEMBER)], + ); + assert.deepEqual(pubkeys, [ALICE]); +}); + +test("non-members and duplicate pubkeys are dropped", () => { + const pubkeys = resolveMentionPubkeys( + "@Alice @Bob", + new Map(), + [], + [ + member("Alice", ALICE), + member("Alice", ALICE), + member("Bob", "c".repeat(64), { isMember: false }), + ], + ); + assert.deepEqual(pubkeys, [ALICE]); +}); + +test("persona names already selected are not re-matched as members", () => { + const pubkeys = resolveMentionPubkeys( + "@Planner", + new Map(), + ["Planner"], + [member("Planner", ALICE)], + ); + assert.deepEqual(pubkeys, []); +}); diff --git a/desktop/src/features/messages/lib/resolveMentionPubkeys.ts b/desktop/src/features/messages/lib/resolveMentionPubkeys.ts new file mode 100644 index 0000000000..4440357017 --- /dev/null +++ b/desktop/src/features/messages/lib/resolveMentionPubkeys.ts @@ -0,0 +1,55 @@ +/** + * Resolve the `p`-tag recipients an outgoing message body mentions. + * + * Two sources, in order: names the author picked from autocomplete (which + * carry an exact pubkey), then channel members whose display name still + * matches literally. Reserved tokens (`@channel`, `@here`) are excluded from + * both — a channel-wide mention never expands into per-member pubkeys, and a + * member who happens to be named "here" is not the target of `@here`. + * + * Extracted from `useMentions` so the precedence rules stay unit-testable. + */ + +import { isReservedMentionName } from "./channelNotify"; +import { hasMention } from "./hasMention"; + +export type MentionPubkeyCandidate = { + displayName: string | null; + isMember: boolean; + pubkey?: string; +}; + +export function resolveMentionPubkeys( + text: string, + mentionMap: ReadonlyMap, + personaMentionNames: Iterable, + candidates: readonly MentionPubkeyCandidate[], +): string[] { + const pubkeys: string[] = []; + const selectedDisplayNames = new Set( + [...mentionMap.keys(), ...personaMentionNames].map((name) => + name.trim().toLowerCase(), + ), + ); + + for (const [displayName, pubkey] of mentionMap) { + if (isReservedMentionName(displayName)) continue; + if (hasMention(text, displayName)) { + pubkeys.push(pubkey); + } + } + + for (const candidate of candidates) { + if (!candidate.pubkey) continue; + if (!candidate.isMember) continue; + if (pubkeys.includes(candidate.pubkey)) continue; + const name = candidate.displayName; + if (!name || isReservedMentionName(name)) continue; + if (selectedDisplayNames.has(name.trim().toLowerCase())) continue; + if (hasMention(text, name)) { + pubkeys.push(candidate.pubkey); + } + } + + return [...new Set(pubkeys)]; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..9a6dd70c55 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -41,12 +41,15 @@ import { useDraftMentionRouting } from "./useDraftMentionRouting"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; import { + buildChannelMentionCandidates, buildTeamMentionCandidates, formatTeamMention, globalSearchIdentityKey, type MentionCandidate, mentionCandidateLabel, } from "./mentionCandidates"; +import { reservedMentionToken } from "./channelNotify"; +import { resolveMentionPubkeys } from "./resolveMentionPubkeys"; const MENTION_DEBOUNCE_MS = 120; const MENTION_SUGGESTION_LIMIT = 50; export type PersonaMentionTarget = { @@ -439,8 +442,18 @@ export function useMentions( personasQuery.data ?? [], mentionCandidates, ), + // Channel-wide mentions are rejected by the relay in DMs. + ...(options?.channelType === "dm" + ? [] + : buildChannelMentionCandidates(members?.length)), + ], + [ + members?.length, + mentionCandidates, + options?.channelType, + personasQuery.data, + teamsQuery.data, ], - [mentionCandidates, personasQuery.data, teamsQuery.data], ); const ownerPubkeys = React.useMemo( @@ -614,6 +627,26 @@ export function useMentions( debounceTimerRef.current = null; } + const startIndex = + flushedMentionStartIndexRef.current ?? mentionStartIndex; + flushedMentionStartIndexRef.current = null; + setMentionQuery(null); + setMentionSelectedIndex(0); + + // Reserved tokens insert literally and notify via the event's notify + // tag; they never enter the pubkey mention map (D16 precedence). + const reserved = reservedMentionToken(suggestion.displayName); + if (reserved) { + setSelectedMentionNames((current) => + appendUniqueName(current, reserved), + ); + return { + replaceFromOffset: startIndex, + replaceToOffset: selectionEnd, + insertText: `@${reserved} `, + }; + } + const displayName = suggestion.displayName; const teamMembers = suggestion.kind === "team" ? suggestion.teamMembers : null; @@ -664,12 +697,7 @@ export function useMentions( } trimMapToSize(mentions, 200); trimMapToSize(personaMentions, 200); - setMentionQuery(null); - setMentionSelectedIndex(0); - const startIndex = - flushedMentionStartIndexRef.current ?? mentionStartIndex; - flushedMentionStartIndexRef.current = null; return { replaceFromOffset: startIndex, replaceToOffset: selectionEnd, @@ -792,42 +820,13 @@ export function useMentions( ); const extractMentionPubkeys = React.useCallback( - (text: string): string[] => { - const pubkeys: string[] = []; - const selectedDisplayNames = new Set( - [ - ...mentionMapRef.current.keys(), - ...personaMentionMapRef.current.keys(), - ].map((name) => name.trim().toLowerCase()), - ); - - for (const [displayName, pubkey] of mentionMapRef.current) { - if (hasMention(text, displayName)) { - pubkeys.push(pubkey); - } - } - - for (const candidate of mentionCandidates) { - if (!candidate.pubkey) { - continue; - } - if (!candidate.isMember) { - continue; - } - if (pubkeys.includes(candidate.pubkey)) { - continue; - } - const name = candidate.displayName; - if (name && selectedDisplayNames.has(name.trim().toLowerCase())) { - continue; - } - if (name && hasMention(text, name)) { - pubkeys.push(candidate.pubkey); - } - } - - return [...new Set(pubkeys)]; - }, + (text: string): string[] => + resolveMentionPubkeys( + text, + mentionMapRef.current, + personaMentionMapRef.current.keys(), + mentionCandidates, + ), [mentionCandidates], ); diff --git a/desktop/src/features/messages/ui/ChannelNotifyDialog.tsx b/desktop/src/features/messages/ui/ChannelNotifyDialog.tsx new file mode 100644 index 0000000000..96953f4be8 --- /dev/null +++ b/desktop/src/features/messages/ui/ChannelNotifyDialog.tsx @@ -0,0 +1,81 @@ +import type { NotifyMode } from "@/features/messages/lib/channelNotify"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; + +type ChannelNotifyDialogProps = { + isSendPending: boolean; + /** Channel member count, used to size the `@channel` prompt. */ + memberCount: number; + mode: NotifyMode | null; + onCancel: () => void; + onConfirm: () => void; +}; + +/** + * Confirmation shown before a message that carries `@channel` or `@here` is + * sent. Mirrors the non-member mention prompt's seam in the composer. + */ +export function ChannelNotifyDialog({ + isSendPending, + memberCount, + mode, + onCancel, + onConfirm, +}: ChannelNotifyDialogProps) { + const isChannel = mode === "channel"; + + return ( + { + if (!nextOpen) { + onCancel(); + } + }} + open={mode !== null} + > + + + + {isChannel + ? memberCount > 0 + ? `Notify all ${memberCount} members?` + : "Notify everyone in this channel?" + : "Notify members who are online?"} + + + {isChannel + ? "@channel notifies every member of this channel, even when they are away. Members who muted the channel are not notified." + : "@here notifies only the members who are online right now. Members who muted the channel are not notified."} + + + + + + + + + ); +} diff --git a/desktop/src/features/messages/ui/ComposerMentionDialogs.tsx b/desktop/src/features/messages/ui/ComposerMentionDialogs.tsx new file mode 100644 index 0000000000..3f539cc6a7 --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerMentionDialogs.tsx @@ -0,0 +1,41 @@ +import { ChannelNotifyDialog } from "./ChannelNotifyDialog"; +import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; +import type { UseMentionSendFlowResult } from "./useMentionSendFlow"; + +type ComposerMentionDialogsProps = { + /** Channel member count, used to size the `@channel` prompt. */ + memberCount: number; + sendFlow: UseMentionSendFlowResult; +}; + +/** + * The prompts the send flow can interpose before a message goes out, in the + * order the flow raises them: confirm a channel-wide mention, then decide what + * to do about mentioned non-members. At most one is open at a time. + */ +export function ComposerMentionDialogs({ + memberCount, + sendFlow, +}: ComposerMentionDialogsProps) { + return ( + <> + + + + + ); +} diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 508e35f402..783c1f3ba8 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Bot, Users } from "lucide-react"; +import { Bot, Megaphone, Users } from "lucide-react"; import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates"; import { Badge } from "@/shared/ui/badge"; @@ -18,8 +18,10 @@ export type MentionSuggestion = { personaId?: string; teamId?: string; teamMembers?: TeamMentionMember[]; - kind?: "identity" | "persona" | "team"; + kind?: "identity" | "persona" | "team" | "special"; displayName: string; + /** Static subtitle, used by the channel-wide (`special`) rows. */ + description?: string; avatarUrl?: string | null; isAgent?: boolean; notInChannel?: boolean; @@ -101,6 +103,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ (suggestion.teamId ? `team-${suggestion.teamId}` : null) ?? suggestion.displayName; const agentLabel = "agent"; + // Channel-wide rows read as the token the author is inserting. + const label = + suggestion.kind === "special" + ? `@${suggestion.displayName}` + : suggestion.displayName; const hasNameCollision = (nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1; const collisionNpub = @@ -125,9 +132,13 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ tabIndex={-1} type="button" > - {suggestion.kind === "team" ? ( + {suggestion.kind === "team" || suggestion.kind === "special" ? ( - ) : ( - {suggestion.displayName} + {label} - {suggestion.kind === "team" || + {suggestion.description || + suggestion.kind === "team" || suggestion.isAgent || suggestion.role || suggestion.ownerLabel || @@ -157,7 +169,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ : "text-muted-foreground", )} > - {suggestion.kind === "team" ? ( + {suggestion.description ? ( + + {suggestion.description} + + ) : suggestion.kind === "team" ? (