@@ -557,21 +557,20 @@ async fn dispatch_persistent_event_inner(
557557 } ) ;
558558 }
559559
560- // Offline-mention safety net (#1743): if a channel message @mentions an
561- // agent (bot member) that has no presence key, the fan-out above delivered
562- // nothing to it — it has no live subscription. Rather than let the mention
563- // silently vanish, emit a system notice into the channel so the sender and
564- // any human observer can see the target is offline. Spawned so it never
565- // adds latency to dispatch; best-effort (never blocks or fails the event).
560+ // Offline-mention safety net (#1743): if a channel message *explicitly*
561+ // @mentions an agent (bot member) that has no presence key, the fan-out
562+ // above delivered nothing to it — it has no live subscription. Rather
563+ // than let the mention silently vanish, emit a system notice into the
564+ // channel so the sender and any human observer can see the target is
565+ // offline. Only `mention` tags count (not structural reply-author `p`
566+ // tags from buildReplyTags). Spawned so it never adds latency to
567+ // dispatch; best-effort (never blocks or fails the event).
568+ //
569+ // Presence is a reachability warning only — not a delivery receipt that
570+ // a particular harness subscription admitted the event (see also #2386).
566571 if kind_u32 == buzz_core:: kind:: KIND_STREAM_MESSAGE {
567572 if let Some ( channel_id) = stored_event. channel_id {
568- let p = nostr:: SingleLetterTag :: lowercase ( nostr:: Alphabet :: P ) ;
569- let mentioned: Vec < String > = stored_event
570- . event
571- . tags
572- . filter ( nostr:: TagKind :: SingleLetter ( p) )
573- . filter_map ( |t| t. content ( ) . map ( |s| s. to_string ( ) ) )
574- . collect ( ) ;
573+ let mentioned = explicit_mention_pubkeys_from_tags ( & stored_event. event . tags ) ;
575574 if !mentioned. is_empty ( ) {
576575 let tenant = tenant. clone ( ) ;
577576 let state = Arc :: clone ( state) ;
@@ -585,30 +584,71 @@ async fn dispatch_persistent_event_inner(
585584 matches. len ( )
586585}
587586
588- /// Pure selection: given mentioned pubkey hexes, the set of bot-member hexes,
589- /// and the presence map (hex -> status) returned by `get_presence_bulk`,
590- /// return the bot hexes that are mentioned but have no presence key (offline).
591- /// Extracted for unit testing the gating logic without DB/redis (#1743).
592- fn select_offline_mentioned_bots (
587+ /// Collect explicit `@mention` targets from event tags.
588+ ///
589+ /// Desktop emits `["mention", pubkey]` via `mergeOutgoingTagsWithReferenceMentions`
590+ /// / `MENTION_REFERENCE_TAG` for composer @mentions. Structural reply-author
591+ /// routing only adds `p` tags (`buildReplyTags`) and must **not** count as an
592+ /// explicit mention (Brad review on #2296 / same finding on #1862).
593+ ///
594+ /// - Dedupes first-seen hex (lowercased)
595+ /// - Skips malformed tags (missing/empty pubkey)
596+ /// - Ignores bare `p` tags entirely
597+ pub ( crate ) fn explicit_mention_pubkeys_from_tags ( tags : & nostr:: Tags ) -> Vec < String > {
598+ let mut seen = std:: collections:: HashSet :: new ( ) ;
599+ let mut out = Vec :: new ( ) ;
600+ for t in tags. iter ( ) {
601+ if t. kind ( ) . to_string ( ) != "mention" {
602+ continue ;
603+ }
604+ let Some ( raw) = t. content ( ) else {
605+ continue ;
606+ } ;
607+ let hex = raw. trim ( ) . to_ascii_lowercase ( ) ;
608+ if hex. is_empty ( ) {
609+ continue ;
610+ }
611+ if seen. insert ( hex. clone ( ) ) {
612+ out. push ( hex) ;
613+ }
614+ }
615+ out
616+ }
617+
618+ /// Pure selection: mentioned bot hexes with no presence key (offline).
619+ /// Presence (any status value) means "not offline" for this warning — we do
620+ /// **not** treat offline presence statuses specially and we never claim delivery.
621+ pub ( crate ) fn select_offline_mentioned_bots (
593622 mentioned_hex : & [ String ] ,
594623 bot_hexes : & std:: collections:: HashSet < String > ,
595624 present : & std:: collections:: HashMap < String , String > ,
596625) -> Vec < String > {
597- mentioned_hex
598- . iter ( )
599- . filter ( |hex| bot_hexes. contains ( * hex) )
600- . filter ( |hex| !present. contains_key ( * hex) )
601- . cloned ( )
602- . collect ( )
626+ let mut seen = std:: collections:: HashSet :: new ( ) ;
627+ let mut out = Vec :: new ( ) ;
628+ for hex in mentioned_hex {
629+ if !bot_hexes. contains ( hex) {
630+ continue ;
631+ }
632+ if present. contains_key ( hex) {
633+ continue ;
634+ }
635+ if seen. insert ( hex. clone ( ) ) {
636+ out. push ( hex. clone ( ) ) ;
637+ }
638+ }
639+ out
603640}
604641
605- /// Emit a system notice for any @mentioned agent (bot member) that is offline
606- /// when the mention is dispatched, so the mention is not silently lost (#1743).
642+ /// Emit a system notice for any *explicitly* @mentioned agent (bot member)
643+ /// that is offline when the mention is dispatched (#1743).
607644///
608- /// Best-effort and side-effect-only: presence/DB lookups that fail are logged
609- /// and skipped. Only agents that are (a) bot members of this community and
610- /// (b) have no presence key are reported — human users and online agents are
611- /// never flagged.
645+ /// Best-effort and side-effect-only: presence/DB lookups or emit failures are
646+ /// logged and skipped — they never fail the original message ingest (caller
647+ /// already identical-spawned after dispatch).
648+ ///
649+ /// Only agents that are (a) bot members of this community and (b) have no
650+ /// presence key are reported. Human `p` / non-bot primary tags never emit an
651+ /// agent notice. Online agents (any presence key) are never flagged.
612652async fn notify_offline_agent_mentions (
613653 tenant : & TenantContext ,
614654 state : & Arc < AppState > ,
@@ -617,8 +657,6 @@ async fn notify_offline_agent_mentions(
617657) {
618658 use nostr:: PublicKey ;
619659
620- // Restrict to agents: only bot members can be "offline" in a way the
621- // sender can't otherwise see. Human recipients read history on reconnect.
622660 let bots = match state. db . get_bot_members ( tenant. community ( ) ) . await {
623661 Ok ( bots) => bots,
624662 Err ( e) => {
@@ -629,7 +667,6 @@ async fn notify_offline_agent_mentions(
629667 if bots. is_empty ( ) {
630668 return ;
631669 }
632- // Map hex pubkey -> display name for bot members only.
633670 let mut bot_names: std:: collections:: HashMap < String , String > = std:: collections:: HashMap :: new ( ) ;
634671 for b in & bots {
635672 let hex = hex:: encode ( & b. pubkey ) ;
@@ -640,7 +677,6 @@ async fn notify_offline_agent_mentions(
640677 bot_names. insert ( hex, name) ;
641678 }
642679
643- // Only consider mentioned pubkeys that are bot members.
644680 let mentioned_bots: Vec < ( String , PublicKey ) > = mentioned_hex
645681 . iter ( )
646682 . filter ( |hex| bot_names. contains_key ( * hex) )
@@ -654,21 +690,16 @@ async fn notify_offline_agent_mentions(
654690 let present = match state. pubsub . get_presence_bulk ( tenant, & pubkeys) . await {
655691 Ok ( map) => map,
656692 Err ( e) => {
693+ // Presence failure must not affect the original message (already accepted).
657694 warn ! ( channel = %channel_id, error = %e, "offline-mention: presence lookup failed" ) ;
658695 return ;
659696 }
660697 } ;
661698
662699 let bot_hex_set: std:: collections:: HashSet < String > =
663700 mentioned_bots. iter ( ) . map ( |( hex, _) | hex. clone ( ) ) . collect ( ) ;
664- let offline = select_offline_mentioned_bots (
665- & mentioned_bots
666- . iter ( )
667- . map ( |( hex, _) | hex. clone ( ) )
668- . collect :: < Vec < _ > > ( ) ,
669- & bot_hex_set,
670- & present,
671- ) ;
701+ let candidate_hex: Vec < String > = mentioned_bots. iter ( ) . map ( |( hex, _) | hex. clone ( ) ) . collect ( ) ;
702+ let offline = select_offline_mentioned_bots ( & candidate_hex, & bot_hex_set, & present) ;
672703 for hex in & offline {
673704 let name = bot_names
674705 . get ( hex)
@@ -684,6 +715,7 @@ async fn notify_offline_agent_mentions(
684715 crate :: handlers:: side_effects:: emit_system_message ( tenant, state, channel_id, content)
685716 . await
686717 {
718+ // Emit failure must not fail the original message (already accepted).
687719 warn ! ( channel = %channel_id, error = %e, "offline-mention: system notice emit failed" ) ;
688720 } else {
689721 metrics:: counter!( "buzz_agent_mention_undelivered_total" ) . increment ( 1 ) ;
@@ -2628,7 +2660,8 @@ mod tests {
26282660 present. insert ( alice. clone ( ) , "online" . to_string ( ) ) ;
26292661
26302662 let mentioned = vec ! [ alice. clone( ) , bob. clone( ) , human. clone( ) ] ;
2631- let offline = crate :: handlers:: event:: select_offline_mentioned_bots ( & mentioned, & bots, & present) ;
2663+ let offline =
2664+ crate :: handlers:: event:: select_offline_mentioned_bots ( & mentioned, & bots, & present) ;
26322665
26332666 // Only bob: alice is online, human is not a bot member.
26342667 assert_eq ! ( offline, vec![ bob] ) ;
@@ -2643,7 +2676,118 @@ mod tests {
26432676 present. insert ( alice. clone ( ) , "away" . to_string ( ) ) ;
26442677 // alice present (any status) => not flagged; unknown pubkey not a bot.
26452678 let mentioned = vec ! [ alice, "dd" . repeat( 32 ) ] ;
2646- assert ! ( crate :: handlers:: event:: select_offline_mentioned_bots( & mentioned, & bots, & present) . is_empty( ) ) ;
2679+ assert ! ( crate :: handlers:: event:: select_offline_mentioned_bots(
2680+ & mentioned, & bots, & present
2681+ )
2682+ . is_empty( ) ) ;
2683+ }
2684+
2685+ #[ test]
2686+ fn offline_mentioned_bots_dedupes_duplicate_mentions ( ) {
2687+ use std:: collections:: { HashMap , HashSet } ;
2688+ let bob = "bb" . repeat ( 32 ) ;
2689+ let bots: HashSet < String > = [ bob. clone ( ) ] . into_iter ( ) . collect ( ) ;
2690+ let present: HashMap < String , String > = HashMap :: new ( ) ;
2691+ let mentioned = vec ! [ bob. clone( ) , bob. clone( ) , bob. clone( ) ] ;
2692+ let offline =
2693+ crate :: handlers:: event:: select_offline_mentioned_bots ( & mentioned, & bots, & present) ;
2694+ assert_eq ! ( offline, vec![ bob] ) ;
2695+ }
2696+
2697+ #[ test]
2698+ fn explicit_mention_tags_ignore_structural_p_only ( ) {
2699+ // Brad matrix: buildReplyTags-style structural author `p` alone → no notice candidates.
2700+ let tags =
2701+ nostr:: Tags :: from_list ( vec ! [ nostr:: Tag :: parse( [ "p" , & "aa" . repeat( 32 ) ] ) . unwrap( ) ] ) ;
2702+ assert ! ( crate :: handlers:: event:: explicit_mention_pubkeys_from_tags( & tags) . is_empty( ) ) ;
2703+ }
2704+
2705+ #[ test]
2706+ fn explicit_mention_tags_collect_mention_kind_only ( ) {
2707+ let bot = "bb" . repeat ( 32 ) ;
2708+ let human = "cc" . repeat ( 32 ) ;
2709+ let tags = nostr:: Tags :: from_list ( vec ! [
2710+ // structural reply author p — must not count
2711+ nostr:: Tag :: parse( [ "p" , & "aa" . repeat( 32 ) ] ) . unwrap( ) ,
2712+ // human p recipient — not a mention tag
2713+ nostr:: Tag :: parse( [ "p" , & human] ) . unwrap( ) ,
2714+ // explicit composer mentions
2715+ nostr:: Tag :: custom( nostr:: TagKind :: Custom ( "mention" . into( ) ) , [ & bot] ) ,
2716+ nostr:: Tag :: custom( nostr:: TagKind :: Custom ( "mention" . into( ) ) , [ & human] ) ,
2717+ ] ) ;
2718+ let found = crate :: handlers:: event:: explicit_mention_pubkeys_from_tags ( & tags) ;
2719+ assert_eq ! ( found, vec![ bot, human] ) ;
2720+ }
2721+
2722+ #[ test]
2723+ fn explicit_mention_tags_dedupe_and_skip_malformed ( ) {
2724+ let bot = "bb" . repeat ( 32 ) ;
2725+ let tags = nostr:: Tags :: from_list ( vec ! [
2726+ nostr:: Tag :: custom( nostr:: TagKind :: Custom ( "mention" . into( ) ) , [ & bot] ) ,
2727+ // duplicate
2728+ nostr:: Tag :: custom(
2729+ nostr:: TagKind :: Custom ( "mention" . into( ) ) ,
2730+ [ & bot. to_ascii_uppercase( ) ] ,
2731+ ) ,
2732+ // empty content — custom with no values may omit content
2733+ nostr:: Tag :: custom(
2734+ nostr:: TagKind :: Custom ( "mention" . into( ) ) ,
2735+ Vec :: <String >:: new( ) ,
2736+ ) ,
2737+ // wrong kind still ignored
2738+ nostr:: Tag :: parse( [ "p" , & bot] ) . unwrap( ) ,
2739+ ] ) ;
2740+ let found = crate :: handlers:: event:: explicit_mention_pubkeys_from_tags ( & tags) ;
2741+ assert_eq ! ( found, vec![ bot] ) ;
2742+ }
2743+
2744+ #[ test]
2745+ fn offline_matrix_explicit_offline_bot_only ( ) {
2746+ use std:: collections:: { HashMap , HashSet } ;
2747+ let online_bot = "aa" . repeat ( 32 ) ;
2748+ let offline_bot = "bb" . repeat ( 32 ) ;
2749+ let human = "cc" . repeat ( 32 ) ;
2750+ let bots: HashSet < String > = [ online_bot. clone ( ) , offline_bot. clone ( ) ]
2751+ . into_iter ( )
2752+ . collect ( ) ;
2753+ let mut present = HashMap :: new ( ) ;
2754+ present. insert ( online_bot. clone ( ) , "online" . to_string ( ) ) ;
2755+
2756+ // human p only → not selected (not bot)
2757+ assert ! ( crate :: handlers:: event:: select_offline_mentioned_bots(
2758+ & [ human. clone( ) ] ,
2759+ & bots,
2760+ & present
2761+ )
2762+ . is_empty( ) ) ;
2763+
2764+ // online agent → no notice
2765+ assert ! ( crate :: handlers:: event:: select_offline_mentioned_bots(
2766+ & [ online_bot. clone( ) ] ,
2767+ & bots,
2768+ & present
2769+ )
2770+ . is_empty( ) ) ;
2771+
2772+ // offline agent → exactly one
2773+ assert_eq ! (
2774+ crate :: handlers:: event:: select_offline_mentioned_bots(
2775+ & [ offline_bot. clone( ) ] ,
2776+ & bots,
2777+ & present
2778+ ) ,
2779+ vec![ offline_bot. clone( ) ]
2780+ ) ;
2781+
2782+ // mix: only offline bot
2783+ assert_eq ! (
2784+ crate :: handlers:: event:: select_offline_mentioned_bots(
2785+ & [ online_bot, offline_bot. clone( ) , human] ,
2786+ & bots,
2787+ & present
2788+ ) ,
2789+ vec![ offline_bot]
2790+ ) ;
26472791 }
26482792 }
26492793}
0 commit comments