Skip to content

Commit 6829bdc

Browse files
committed
fix(relay): gate offline-agent notice on explicit mention tags
Brad review on #2296: collecting all `p` tags false-positives when buildReplyTags adds a structural reply-author `p` without an @mention. - Extract mentions via tag kind "mention" only (desktop MENTION_REFERENCE_TAG) - Dedup/skip malformed; ignore bare `p` tags - Expand unit matrix for structural-p / online / offline / human / dedup - Document presence as reachability warning, not delivery receipt Refs #1743 Signed-off-by: Bartok9 <danielrpike9@gmail.com>
1 parent 9ff0363 commit 6829bdc

1 file changed

Lines changed: 188 additions & 44 deletions

File tree

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

Lines changed: 188 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
612652
async 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

Comments
 (0)