Skip to content

Commit 359dce5

Browse files
committed
feat(relay): notify channel when an offline agent is @mentioned
When a channel message @mentions an agent (bot member) that has no active WebSocket subscription, the relay fan-out silently delivers nothing to it and the mention is lost with no signal to the sender, channel, or any human observer (issue #1743 — a real 30-minute silent stall between two agents). After a kind:9 (KIND_STREAM_MESSAGE) message is dispatched, this checks any `p`-tagged pubkeys that are bot members against presence. For each mentioned bot with no presence key, it emits a relay-signed system notice (agent_mention_undelivered) into the channel: "<agent> is offline and may not see this mention." Human recipients and online agents are never flagged. - Best-effort, spawned side-effect: never adds latency to dispatch and never fails the triggering event; presence/DB errors are logged and skipped. - Reuses the existing emit_system_message helper (kind:40099). - Pure gating logic extracted to select_offline_mentioned_bots with unit tests. Refs #1743 Signed-off-by: Bartok9 <danielrpike9@gmail.com>
1 parent 7e34bee commit 359dce5

1 file changed

Lines changed: 165 additions & 0 deletions

File tree

crates/buzz-relay/src/handlers/event.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
540671
async 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

Comments
 (0)