Skip to content

Commit e9cd1c3

Browse files
authored
Improve mentions for agents + people (#942)
1 parent 384eb6c commit e9cd1c3

54 files changed

Lines changed: 4043 additions & 781 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/sprout-acp/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1415,7 +1415,7 @@ async fn tokio_main() -> Result<()> {
14151415

14161416
if let Some(filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) {
14171417
tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel");
1418-
if let Err(e) = relay.subscribe_channel(ch, filter).await {
1418+
if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await {
14191419
tracing::warn!("failed to subscribe to new channel {ch}: {e}");
14201420
}
14211421
} else {

crates/sprout-acp/src/relay.rs

Lines changed: 91 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ const REST_RETRY_BASE_DELAYS: [Duration; 3] = [
114114
Duration::from_millis(2000),
115115
];
116116

117+
fn unix_now_secs() -> u64 {
118+
std::time::SystemTime::now()
119+
.duration_since(std::time::UNIX_EPOCH)
120+
.unwrap_or_default()
121+
.as_secs()
122+
}
123+
117124
impl RestClient {
118125
// ── NIP-98 signing ────────────────────────────────────────────────────
119126

@@ -370,6 +377,7 @@ enum RelayCommand {
370377
Subscribe {
371378
channel_id: Uuid,
372379
filter: ChannelFilter,
380+
replay_since: Option<u64>,
373381
},
374382
/// Unsubscribe from a channel (sends a NIP-01 CLOSE).
375383
Unsubscribe { channel_id: Uuid },
@@ -634,9 +642,28 @@ impl HarnessRelay {
634642
&mut self,
635643
channel_id: Uuid,
636644
filter: ChannelFilter,
645+
) -> Result<(), RelayError> {
646+
self.subscribe_channel_from(channel_id, filter, None).await
647+
}
648+
649+
/// Subscribe to events in a channel, replaying from a known timestamp.
650+
///
651+
/// Used for channels discovered from membership notifications: the mention
652+
/// that invited an agent can be published immediately after the membership
653+
/// event, before this subscription is active. Replaying from the membership
654+
/// event timestamp closes that race.
655+
pub async fn subscribe_channel_from(
656+
&mut self,
657+
channel_id: Uuid,
658+
filter: ChannelFilter,
659+
replay_since: Option<u64>,
637660
) -> Result<(), RelayError> {
638661
self.cmd_tx
639-
.send(RelayCommand::Subscribe { channel_id, filter })
662+
.send(RelayCommand::Subscribe {
663+
channel_id,
664+
filter,
665+
replay_since,
666+
})
640667
.await
641668
.map_err(|_| RelayError::ConnectionClosed)?;
642669
debug!("queued subscribe for channel {channel_id}");
@@ -888,12 +915,12 @@ struct BgState {
888915
/// (Finding #22). Used as the floor `since` for membership notification
889916
/// replay so events predating this session are never re-delivered.
890917
startup_watermark: Option<u64>,
891-
/// Wall-clock timestamp when each channel was first subscribed.
918+
/// Replay floor captured when each channel was first subscribed.
892919
/// Used as the `since` fallback on reconnect for channels that have no
893920
/// `last_seen` or `channel_dropped_since`. This prevents channels joined
894-
/// after startup from replaying from `startup_watermark` (which could be
895-
/// hours old), while still allowing startup-era channels to use the
896-
/// startup watermark via their `subscribe_since ≈ startup_watermark`.
921+
/// after startup from replaying from an hours-old `startup_watermark`.
922+
/// Startup-era channels use the startup watermark; dynamic channels use
923+
/// the membership notification timestamp that caused the subscription.
897924
subscribe_since: HashMap<Uuid, u64>,
898925
}
899926

@@ -977,20 +1004,22 @@ impl BgState {
9771004
/// arm here is a logic error.
9781005
fn apply_command_to_state(state: &mut BgState, cmd: RelayCommand) {
9791006
match cmd {
980-
RelayCommand::Subscribe { channel_id, filter } => {
1007+
RelayCommand::Subscribe {
1008+
channel_id,
1009+
filter,
1010+
replay_since,
1011+
} => {
9811012
state
9821013
.active_subscriptions
9831014
.insert(channel_id, channel_sub_id(channel_id));
9841015
state.active_filters.insert(channel_id, filter);
9851016
state.subscribe_since.entry(channel_id).or_insert_with(|| {
986-
// Use startup_watermark as floor when available — closes the
1017+
// Use an explicit replay floor when available (dynamic
1018+
// membership), otherwise startup_watermark closes the startup
9871019
// blind spot between watermark capture and first REQ.
988-
state.startup_watermark.unwrap_or_else(|| {
989-
std::time::SystemTime::now()
990-
.duration_since(std::time::UNIX_EPOCH)
991-
.unwrap_or_default()
992-
.as_secs()
993-
})
1020+
replay_since
1021+
.or(state.startup_watermark)
1022+
.unwrap_or_else(unix_now_secs)
9941023
});
9951024
}
9961025
RelayCommand::Unsubscribe { channel_id } => {
@@ -1040,19 +1069,18 @@ async fn execute_connected_command(
10401069
cmd: RelayCommand,
10411070
) -> bool {
10421071
match cmd {
1043-
RelayCommand::Subscribe { channel_id, filter } => {
1072+
RelayCommand::Subscribe {
1073+
channel_id,
1074+
filter,
1075+
replay_since,
1076+
} => {
10441077
// Seed subscribe_since BEFORE computing since — on first
10451078
// subscribe, this provides the fallback timestamp that
1046-
// closes the startup blind spot. Use startup_watermark as
1047-
// floor when available so events between watermark capture
1048-
// and this REQ are not missed.
1079+
// closes the startup/dynamic-membership blind spot.
10491080
state.subscribe_since.entry(channel_id).or_insert_with(|| {
1050-
state.startup_watermark.unwrap_or_else(|| {
1051-
std::time::SystemTime::now()
1052-
.duration_since(std::time::UNIX_EPOCH)
1053-
.unwrap_or_default()
1054-
.as_secs()
1055-
})
1081+
replay_since
1082+
.or(state.startup_watermark)
1083+
.unwrap_or_else(unix_now_secs)
10561084
});
10571085
let since = state
10581086
.last_seen
@@ -1070,7 +1098,14 @@ async fn execute_connected_command(
10701098
} else {
10711099
// Send failed — record intent so reconnect restores it.
10721100
warn!("subscribe REQ failed for channel {channel_id} — recording intent for reconnect");
1073-
apply_command_to_state(state, RelayCommand::Subscribe { channel_id, filter });
1101+
apply_command_to_state(
1102+
state,
1103+
RelayCommand::Subscribe {
1104+
channel_id,
1105+
filter,
1106+
replay_since,
1107+
},
1108+
);
10741109
false
10751110
}
10761111
}
@@ -3352,6 +3387,38 @@ mod tests {
33523387
);
33533388
}
33543389

3390+
#[test]
3391+
fn dynamic_subscribe_records_membership_replay_floor() {
3392+
let mut state = BgState::new();
3393+
state.startup_watermark = Some(2_000);
3394+
let channel_id = Uuid::new_v4();
3395+
let membership_ts = 10_000;
3396+
let filter = ChannelFilter {
3397+
kinds: Some(vec![9]),
3398+
require_mention: true,
3399+
};
3400+
3401+
apply_command_to_state(
3402+
&mut state,
3403+
RelayCommand::Subscribe {
3404+
channel_id,
3405+
filter,
3406+
replay_since: Some(membership_ts),
3407+
},
3408+
);
3409+
3410+
assert_eq!(
3411+
state.subscribe_since.get(&channel_id).copied(),
3412+
Some(membership_ts),
3413+
"dynamic channel subscriptions should replay from the membership notification, not startup"
3414+
);
3415+
assert_eq!(
3416+
state.channel_since(&channel_id),
3417+
Some(membership_ts),
3418+
"channel_since should use the dynamic replay floor until an event is seen"
3419+
);
3420+
}
3421+
33553422
// ── Membership dedup regression tests (M4) ───────────────────────────
33563423

33573424
/// Membership dedup must NOT contaminate per-channel `last_seen`.

desktop/src-tauri/src/commands/channels.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -287,21 +287,31 @@ pub async fn get_channel_members(
287287
.await
288288
.unwrap_or_default();
289289

290-
// Build pubkey → display_name map from kind:0 events
291-
let mut name_map = std::collections::HashMap::new();
290+
// Build pubkey → profile display metadata from kind:0 events.
291+
let mut profile_map = std::collections::HashMap::new();
292292
for ev in &profile_events {
293293
let pk = ev.pubkey.to_hex();
294294
if let Ok(profile) = nostr_convert::profile_info_from_event(ev) {
295-
if let Some(name) = profile.display_name {
296-
name_map.insert(pk, name);
297-
}
295+
profile_map.insert(
296+
pk,
297+
(
298+
profile.display_name,
299+
nostr_convert::profile_has_valid_oa_owner(ev),
300+
),
301+
);
298302
}
299303
}
300304

301-
// Populate display_name on each member
305+
// Populate profile-derived fields on each member.
302306
for member in &mut response.members {
303-
if member.display_name.is_none() {
304-
member.display_name = name_map.get(&member.pubkey).cloned();
307+
if member.role == "bot" {
308+
member.is_agent = true;
309+
}
310+
if let Some((display_name, is_agent)) = profile_map.get(&member.pubkey) {
311+
if member.display_name.is_none() {
312+
member.display_name = display_name.clone();
313+
}
314+
member.is_agent = member.is_agent || *is_agent;
305315
}
306316
}
307317
}

desktop/src-tauri/src/commands/messages.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ pub async fn send_channel_message(
272272
parent_event_id: Option<String>,
273273
media_tags: Option<Vec<Vec<String>>>,
274274
emoji_tags: Option<Vec<Vec<String>>>,
275+
mention_tags: Option<Vec<Vec<String>>>,
275276
mention_pubkeys: Option<Vec<String>>,
276277
kind: Option<u32>,
277278
state: State<'_, AppState>,
@@ -282,14 +283,19 @@ pub async fn send_channel_message(
282283
let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect();
283284
let media = media_tags.unwrap_or_default();
284285
let emoji = emoji_tags.unwrap_or_default();
286+
let mention_refs_only = mention_tags.unwrap_or_default();
285287
let kind_num = kind.unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE);
286288

287289
let mut resolved_root: Option<String> = None;
288290

289291
let builder = match kind_num {
290-
sprout_core::kind::KIND_FORUM_POST => {
291-
events::build_forum_post(channel_uuid, content.trim(), &mention_refs, &media)?
292-
}
292+
sprout_core::kind::KIND_FORUM_POST => events::build_forum_post(
293+
channel_uuid,
294+
content.trim(),
295+
&mention_refs,
296+
&media,
297+
&mention_refs_only,
298+
)?,
293299
sprout_core::kind::KIND_FORUM_COMMENT => {
294300
let parent_id = parent_event_id
295301
.as_deref()
@@ -302,6 +308,7 @@ pub async fn send_channel_message(
302308
&thread_ref,
303309
&mention_refs,
304310
&media,
311+
&mention_refs_only,
305312
)?
306313
}
307314
_ => {
@@ -320,6 +327,7 @@ pub async fn send_channel_message(
320327
&mention_refs,
321328
&media,
322329
&emoji,
330+
&mention_refs_only,
323331
)?
324332
}
325333
};

desktop/src-tauri/src/commands/profile.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,23 @@ pub async fn search_users(
177177
let trimmed = query.trim();
178178
let max = limit.unwrap_or(8).min(50) as usize;
179179

180-
if trimmed.is_empty() || max == 0 {
180+
if max == 0 {
181181
return Ok(SearchUsersResponse { users: Vec::new() });
182182
}
183183

184+
if trimmed.is_empty() {
185+
let events = query_relay(
186+
&state,
187+
&[serde_json::json!({
188+
"kinds": [0],
189+
"limit": max,
190+
})],
191+
)
192+
.await?;
193+
194+
return Ok(nostr_convert::list_user_search_results(&events, max));
195+
}
196+
184197
// NIP-50 full-text search on kind:0 profiles. The relay's HTTP bridge
185198
// intercepts the `search` field on POST /query and routes to Typesense
186199
// (see `crates/sprout-relay/src/api/bridge.rs::handle_bridge_search`),

desktop/src-tauri/src/events.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,23 @@ fn mention_tags(mentions: &[&str]) -> Result<Vec<Tag>, String> {
7676
Ok(tags)
7777
}
7878

79+
fn mention_reference_tags(mentions: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), String> {
80+
for mention in mentions {
81+
if mention.first().map(String::as_str) != Some("mention") {
82+
return Err(format!(
83+
"mention reference tags must use 'mention' prefix (got {:?})",
84+
mention.first()
85+
));
86+
}
87+
let Some(pubkey) = mention.get(1) else {
88+
return Err("mention reference tag missing pubkey".into());
89+
};
90+
check_pubkey(pubkey)?;
91+
tags.push(tag(vec!["mention", &pubkey.to_ascii_lowercase()])?);
92+
}
93+
Ok(())
94+
}
95+
7996
/// Validate and append imeta tags. Rejects any tag whose first element is not "imeta"
8097
/// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags).
8198
fn imeta_tags(media_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), String> {
@@ -277,6 +294,7 @@ pub fn build_message(
277294
mentions: &[&str],
278295
media_tags: &[Vec<String>],
279296
custom_emoji_tags: &[Vec<String>],
297+
mention_ref_tags: &[Vec<String>],
280298
) -> Result<EventBuilder, String> {
281299
check_content(content)?;
282300
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
@@ -286,6 +304,7 @@ pub fn build_message(
286304
tags.extend(mention_tags(mentions)?);
287305
imeta_tags(media_tags, &mut tags)?;
288306
emoji_tags(custom_emoji_tags, &mut tags)?;
307+
mention_reference_tags(mention_ref_tags, &mut tags)?;
289308
Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags))
290309
}
291310

@@ -295,11 +314,13 @@ pub fn build_forum_post(
295314
content: &str,
296315
mentions: &[&str],
297316
media_tags: &[Vec<String>],
317+
mention_ref_tags: &[Vec<String>],
298318
) -> Result<EventBuilder, String> {
299319
check_content(content)?;
300320
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
301321
tags.extend(mention_tags(mentions)?);
302322
imeta_tags(media_tags, &mut tags)?;
323+
mention_reference_tags(mention_ref_tags, &mut tags)?;
303324
Ok(EventBuilder::new(Kind::Custom(45001), content).tags(tags))
304325
}
305326

@@ -310,12 +331,14 @@ pub fn build_forum_comment(
310331
thread_ref: &ThreadRef,
311332
mentions: &[&str],
312333
media_tags: &[Vec<String>],
334+
mention_ref_tags: &[Vec<String>],
313335
) -> Result<EventBuilder, String> {
314336
check_content(content)?;
315337
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
316338
tags.extend(thread_tags(thread_ref)?);
317339
tags.extend(mention_tags(mentions)?);
318340
imeta_tags(media_tags, &mut tags)?;
341+
mention_reference_tags(mention_ref_tags, &mut tags)?;
319342
Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags))
320343
}
321344

desktop/src-tauri/src/huddle/pipeline.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -269,13 +269,14 @@ pub(crate) fn spawn_transcription_task(
269269
.clone();
270270

271271
let p_tags: Vec<&str> = agent_pubkeys.iter().map(|s| s.as_str()).collect();
272-
let builder = match events::build_message(channel_uuid, &t, None, &p_tags, &[], &[]) {
273-
Ok(b) => b,
274-
Err(e) => {
275-
eprintln!("sprout-desktop: STT build_message: {e}");
276-
continue;
277-
}
278-
};
272+
let builder =
273+
match events::build_message(channel_uuid, &t, None, &p_tags, &[], &[], &[]) {
274+
Ok(b) => b,
275+
Err(e) => {
276+
eprintln!("sprout-desktop: STT build_message: {e}");
277+
continue;
278+
}
279+
};
279280
let event = match builder.sign_with_keys(&keys) {
280281
Ok(e) => e,
281282
Err(e) => {

0 commit comments

Comments
 (0)