@@ -534,9 +534,140 @@ async fn dispatch_persistent_event_inner(
534534 } ) ;
535535 }
536536
537+ // Offline-mention safety net (#1743): if a channel message @mentions an
538+ // agent (bot member) that has no presence key, the fan-out above delivered
539+ // nothing to it — it has no live subscription. Rather than let the mention
540+ // silently vanish, emit a system notice into the channel so the sender and
541+ // any human observer can see the target is offline. Spawned so it never
542+ // adds latency to dispatch; best-effort (never blocks or fails the event).
543+ if kind_u32 == buzz_core:: kind:: KIND_STREAM_MESSAGE {
544+ if let Some ( channel_id) = stored_event. channel_id {
545+ let p = nostr:: SingleLetterTag :: lowercase ( nostr:: Alphabet :: P ) ;
546+ let mentioned: Vec < String > = stored_event
547+ . event
548+ . tags
549+ . filter ( nostr:: TagKind :: SingleLetter ( p) )
550+ . filter_map ( |t| t. content ( ) . map ( |s| s. to_string ( ) ) )
551+ . collect ( ) ;
552+ if !mentioned. is_empty ( ) {
553+ let tenant = tenant. clone ( ) ;
554+ let state = Arc :: clone ( state) ;
555+ tokio:: spawn ( async move {
556+ notify_offline_agent_mentions ( & tenant, & state, channel_id, mentioned) . await ;
557+ } ) ;
558+ }
559+ }
560+ }
561+
537562 matches. len ( )
538563}
539564
565+ /// Pure selection: given mentioned pubkey hexes, the set of bot-member hexes,
566+ /// and the presence map (hex -> status) returned by `get_presence_bulk`,
567+ /// return the bot hexes that are mentioned but have no presence key (offline).
568+ /// Extracted for unit testing the gating logic without DB/redis (#1743).
569+ fn select_offline_mentioned_bots (
570+ mentioned_hex : & [ String ] ,
571+ bot_hexes : & std:: collections:: HashSet < String > ,
572+ present : & std:: collections:: HashMap < String , String > ,
573+ ) -> Vec < String > {
574+ mentioned_hex
575+ . iter ( )
576+ . filter ( |hex| bot_hexes. contains ( * hex) )
577+ . filter ( |hex| !present. contains_key ( * hex) )
578+ . cloned ( )
579+ . collect ( )
580+ }
581+
582+ /// Emit a system notice for any @mentioned agent (bot member) that is offline
583+ /// when the mention is dispatched, so the mention is not silently lost (#1743).
584+ ///
585+ /// Best-effort and side-effect-only: presence/DB lookups that fail are logged
586+ /// and skipped. Only agents that are (a) bot members of this community and
587+ /// (b) have no presence key are reported — human users and online agents are
588+ /// never flagged.
589+ async fn notify_offline_agent_mentions (
590+ tenant : & TenantContext ,
591+ state : & Arc < AppState > ,
592+ channel_id : uuid:: Uuid ,
593+ mentioned_hex : Vec < String > ,
594+ ) {
595+ use nostr:: PublicKey ;
596+
597+ // Restrict to agents: only bot members can be "offline" in a way the
598+ // sender can't otherwise see. Human recipients read history on reconnect.
599+ let bots = match state. db . get_bot_members ( tenant. community ( ) ) . await {
600+ Ok ( bots) => bots,
601+ Err ( e) => {
602+ warn ! ( channel = %channel_id, error = %e, "offline-mention: bot lookup failed" ) ;
603+ return ;
604+ }
605+ } ;
606+ if bots. is_empty ( ) {
607+ return ;
608+ }
609+ // Map hex pubkey -> display name for bot members only.
610+ let mut bot_names: std:: collections:: HashMap < String , String > = std:: collections:: HashMap :: new ( ) ;
611+ for b in & bots {
612+ let hex = hex:: encode ( & b. pubkey ) ;
613+ let name = b
614+ . display_name
615+ . clone ( )
616+ . unwrap_or_else ( || format ! ( "agent {}" , & hex[ ..hex. len( ) . min( 8 ) ] ) ) ;
617+ bot_names. insert ( hex, name) ;
618+ }
619+
620+ // Only consider mentioned pubkeys that are bot members.
621+ let mentioned_bots: Vec < ( String , PublicKey ) > = mentioned_hex
622+ . iter ( )
623+ . filter ( |hex| bot_names. contains_key ( * hex) )
624+ . filter_map ( |hex| PublicKey :: from_hex ( hex) . ok ( ) . map ( |pk| ( hex. clone ( ) , pk) ) )
625+ . collect ( ) ;
626+ if mentioned_bots. is_empty ( ) {
627+ return ;
628+ }
629+
630+ let pubkeys: Vec < PublicKey > = mentioned_bots. iter ( ) . map ( |( _, pk) | * pk) . collect ( ) ;
631+ let present = match state. pubsub . get_presence_bulk ( tenant, & pubkeys) . await {
632+ Ok ( map) => map,
633+ Err ( e) => {
634+ warn ! ( channel = %channel_id, error = %e, "offline-mention: presence lookup failed" ) ;
635+ return ;
636+ }
637+ } ;
638+
639+ let bot_hex_set: std:: collections:: HashSet < String > =
640+ mentioned_bots. iter ( ) . map ( |( hex, _) | hex. clone ( ) ) . collect ( ) ;
641+ let offline = select_offline_mentioned_bots (
642+ & mentioned_bots
643+ . iter ( )
644+ . map ( |( hex, _) | hex. clone ( ) )
645+ . collect :: < Vec < _ > > ( ) ,
646+ & bot_hex_set,
647+ & present,
648+ ) ;
649+ for hex in & offline {
650+ let name = bot_names
651+ . get ( hex)
652+ . cloned ( )
653+ . unwrap_or_else ( || "agent" . to_string ( ) ) ;
654+ let content = serde_json:: json!( {
655+ "type" : "agent_mention_undelivered" ,
656+ "agent_pubkey" : hex,
657+ "agent_name" : name,
658+ "message" : format!( "{name} is offline and may not see this mention." ) ,
659+ } ) ;
660+ if let Err ( e) =
661+ crate :: handlers:: side_effects:: emit_system_message ( tenant, state, channel_id, content)
662+ . await
663+ {
664+ warn ! ( channel = %channel_id, error = %e, "offline-mention: system notice emit failed" ) ;
665+ } else {
666+ metrics:: counter!( "buzz_agent_mention_undelivered_total" ) . increment ( 1 ) ;
667+ }
668+ }
669+ }
670+
540671async fn enqueue_event_created_audit (
541672 tenant : & TenantContext ,
542673 state : & Arc < AppState > ,
@@ -2457,5 +2588,39 @@ mod tests {
24572588 must not receive a community-B event. Got: {out:?}"
24582589 ) ;
24592590 }
2591+
2592+ #[ test]
2593+ fn offline_mentioned_bots_selects_only_offline_bot_members ( ) {
2594+ use std:: collections:: { HashMap , HashSet } ;
2595+
2596+ let alice = "aa" . repeat ( 32 ) ;
2597+ let bob = "bb" . repeat ( 32 ) ;
2598+ let human = "cc" . repeat ( 32 ) ;
2599+
2600+ // alice + bob are bot members; human is not a bot.
2601+ let bots: HashSet < String > = [ alice. clone ( ) , bob. clone ( ) ] . into_iter ( ) . collect ( ) ;
2602+
2603+ // alice is online (present), bob is offline (absent).
2604+ let mut present: HashMap < String , String > = HashMap :: new ( ) ;
2605+ present. insert ( alice. clone ( ) , "online" . to_string ( ) ) ;
2606+
2607+ let mentioned = vec ! [ alice. clone( ) , bob. clone( ) , human. clone( ) ] ;
2608+ let offline = crate :: handlers:: event:: select_offline_mentioned_bots ( & mentioned, & bots, & present) ;
2609+
2610+ // Only bob: alice is online, human is not a bot member.
2611+ assert_eq ! ( offline, vec![ bob] ) ;
2612+ }
2613+
2614+ #[ test]
2615+ fn offline_mentioned_bots_empty_when_all_online_or_non_bots ( ) {
2616+ use std:: collections:: { HashMap , HashSet } ;
2617+ let alice = "aa" . repeat ( 32 ) ;
2618+ let bots: HashSet < String > = [ alice. clone ( ) ] . into_iter ( ) . collect ( ) ;
2619+ let mut present: HashMap < String , String > = HashMap :: new ( ) ;
2620+ present. insert ( alice. clone ( ) , "away" . to_string ( ) ) ;
2621+ // alice present (any status) => not flagged; unknown pubkey not a bot.
2622+ let mentioned = vec ! [ alice, "dd" . repeat( 32 ) ] ;
2623+ assert ! ( crate :: handlers:: event:: select_offline_mentioned_bots( & mentioned, & bots, & present) . is_empty( ) ) ;
2624+ }
24602625 }
24612626}
0 commit comments