Files
buzz/desktop/src-tauri/src/nostr_convert.rs

1116 lines
41 KiB
Rust

//! Nostr event → desktop model converters.
//!
//! These pure functions translate raw Nostr protocol events into the
//! model types expected by the Tauri frontend commands.
//!
//! All converters here are I/O-free and deterministic — they take owned
//! or borrowed events and return models. This makes them trivially
//! testable with hand-crafted events (see the `tests` module below).
use std::collections::{BTreeSet, HashMap};
use nostr::Event;
use serde_json::{json, Value};
use crate::models::*;
// ── Tag helpers ─────────────────────────────────────────────────────────────
/// Find the first tag whose name matches `name` and return its first value.
///
/// e.g. for tag `["name", "general"]` with `name="name"` returns `Some("general")`.
fn first_tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
for tag in event.tags.iter() {
let s = tag.as_slice();
if s.len() >= 2 && s[0] == name {
return Some(s[1].as_str());
}
}
None
}
/// Return true if the event has a tag with the given name (any value).
fn has_tag(event: &Event, name: &str) -> bool {
event
.tags
.iter()
.any(|t| t.as_slice().first().map(|s| s.as_str()) == Some(name))
}
/// Iterate every tag whose name matches `name`, returning the full slice.
fn tags_named<'a>(event: &'a Event, name: &'a str) -> impl Iterator<Item = &'a [String]> + 'a {
event.tags.iter().filter_map(move |t| {
let s = t.as_slice();
if !s.is_empty() && s[0] == name {
Some(s)
} else {
None
}
})
}
// ── kind:39000 / 39002 (NIP-29) ─────────────────────────────────────────────
/// Convert a NIP-29 kind:39000 channel metadata event to [`ChannelInfo`].
///
/// Optionally merges with a kind:40901 channel summary sidecar event for
/// `member_count` and `last_message_at`.
pub fn channel_info_from_event(
event: &Event,
summary: Option<&Event>,
is_member: Option<bool>,
) -> Result<ChannelInfo, String> {
let id = first_tag_value(event, "d")
.ok_or_else(|| "kind:39000 missing required `d` tag".to_string())?
.to_string();
let name = first_tag_value(event, "name").unwrap_or("").to_string();
let description = first_tag_value(event, "about").unwrap_or("").to_string();
let topic = first_tag_value(event, "topic").map(str::to_string);
let purpose = first_tag_value(event, "purpose").map(str::to_string);
// Prefer explicit ["t", type] tag; fall back to inferring from ["hidden"]
// (= dm) for relays that don't yet emit the type tag.
let channel_type = first_tag_value(event, "t")
.map(str::to_string)
.unwrap_or_else(|| {
if has_tag(event, "hidden") {
"dm".to_string()
} else {
"stream".to_string()
}
});
// Prefer explicit ["public"] tag; fall back to NIP-29's absence-of-"private"
// convention for relays that don't yet emit the explicit tag.
let visibility = if has_tag(event, "public") {
"open".to_string()
} else if has_tag(event, "private") {
"private".to_string()
} else {
"open".to_string()
};
// For DM-type channels, p-tags identify the participants.
let participant_pubkeys: Vec<String> = tags_named(event, "p")
.filter_map(|s| s.get(1).cloned())
.collect();
let participants = participant_pubkeys.clone();
// Summary sidecar carries member_count + last_message_at as JSON content.
let (member_count, last_message_at) = if let Some(s) = summary {
let v: Value = serde_json::from_str(&s.content).unwrap_or(Value::Null);
let mc = v.get("member_count").and_then(Value::as_i64).unwrap_or(0);
let lma = v
.get("last_message_at")
.and_then(Value::as_str)
.map(str::to_string);
(mc, lma)
} else {
(0, None)
};
// If the relay emits ["archived", "true"], surface it as a timestamp placeholder
// so the frontend knows the channel is archived. The exact timestamp isn't available
// from the tag alone, so we use the event's created_at as a proxy.
let archived_at = if first_tag_value(event, "archived") == Some("true") {
Some(timestamp_to_iso(event.created_at.as_secs()))
} else {
None
};
// Ephemeral channel TTL — relay emits ["ttl", "<seconds>"] and ["ttl_deadline", "<iso>"].
let ttl_seconds = first_tag_value(event, "ttl").and_then(|v| v.parse::<i32>().ok());
let ttl_deadline = first_tag_value(event, "ttl_deadline").map(str::to_string);
Ok(ChannelInfo {
id,
name,
channel_type,
visibility,
description,
topic,
purpose,
member_count,
member_pubkeys: Vec::new(),
last_message_at,
archived_at,
participants,
participant_pubkeys,
is_member: is_member.unwrap_or(true),
ttl_seconds,
ttl_deadline,
})
}
/// Convert a NIP-29 kind:39000 event to [`ChannelDetailInfo`].
pub fn channel_detail_from_event(event: &Event) -> Result<ChannelDetailInfo, String> {
let id = first_tag_value(event, "d")
.ok_or_else(|| "kind:39000 missing required `d` tag".to_string())?
.to_string();
let name = first_tag_value(event, "name").unwrap_or("").to_string();
let description = first_tag_value(event, "about").unwrap_or("").to_string();
let topic = first_tag_value(event, "topic").map(str::to_string);
let purpose = first_tag_value(event, "purpose").map(str::to_string);
// Prefer explicit ["t", type]; fall back to ["hidden"] = dm, else "stream".
let channel_type = first_tag_value(event, "t")
.map(str::to_string)
.unwrap_or_else(|| {
if has_tag(event, "hidden") {
"dm".to_string()
} else {
"stream".to_string()
}
});
// Prefer explicit ["public"]; fall back to NIP-29 absence-of-"private".
let visibility = if has_tag(event, "public") {
"open".to_string()
} else if has_tag(event, "private") {
"private".to_string()
} else {
"open".to_string()
};
let created_at_iso = timestamp_to_iso(event.created_at.as_secs());
let archived_at = if first_tag_value(event, "archived") == Some("true") {
Some(timestamp_to_iso(event.created_at.as_secs()))
} else {
None
};
Ok(ChannelDetailInfo {
id,
name,
channel_type,
visibility,
description,
topic,
topic_set_by: None,
topic_set_at: None,
purpose,
purpose_set_by: None,
purpose_set_at: None,
created_by: event.pubkey.to_hex(),
created_at: created_at_iso.clone(),
updated_at: created_at_iso,
archived_at,
member_count: 0,
topic_required: false,
max_members: None,
nip29_group_id: None,
ttl_seconds: first_tag_value(event, "ttl").and_then(|v| v.parse::<i32>().ok()),
ttl_deadline: first_tag_value(event, "ttl_deadline").map(str::to_string),
})
}
/// Convert a NIP-29 kind:39002 members event to [`ChannelMembersResponse`].
///
/// Members come from p-tags shaped as `["p", pubkey, relay_url?, role?]`.
/// Role defaults to `"member"` when absent. `joined_at` is `None` because
/// kind:39002 does not carry per-member join timestamps.
pub fn channel_members_from_event(event: &Event) -> Result<ChannelMembersResponse, String> {
// Validate that this is a members event (`d` tag identifies the channel).
if first_tag_value(event, "d").is_none() {
return Err("kind:39002 missing required `d` tag".to_string());
}
let mut seen = BTreeSet::new();
let mut members = Vec::new();
for slice in tags_named(event, "p") {
let Some(pubkey) = slice.get(1) else { continue };
if pubkey.is_empty() || !seen.insert(pubkey.clone()) {
continue;
}
let role = slice
.get(3)
.filter(|s| !s.is_empty())
.cloned()
.unwrap_or_else(|| "member".to_string());
members.push(ChannelMemberInfo {
pubkey: pubkey.clone(),
role,
joined_at: None,
display_name: None,
});
}
Ok(ChannelMembersResponse {
members,
next_cursor: None,
})
}
// ── kind:0 (profile metadata) ───────────────────────────────────────────────
/// Convert a kind:0 metadata event to [`ProfileInfo`].
///
/// The event's `content` is a JSON object per NIP-01:
/// `{"name":"...","display_name":"...","picture":"...","about":"...","nip05":"..."}`.
pub fn profile_info_from_event(event: &Event) -> Result<ProfileInfo, String> {
let v: Value = serde_json::from_str(&event.content)
.map_err(|e| format!("kind:0 content is not valid JSON: {e}"))?;
let display_name = v
.get("display_name")
.and_then(Value::as_str)
.or_else(|| v.get("name").and_then(Value::as_str))
.map(str::to_string);
let avatar_url = v.get("picture").and_then(Value::as_str).map(str::to_string);
let about = v.get("about").and_then(Value::as_str).map(str::to_string);
let nip05_handle = v.get("nip05").and_then(Value::as_str).map(str::to_string);
Ok(ProfileInfo {
pubkey: event.pubkey.to_hex(),
display_name,
avatar_url,
about,
nip05_handle,
})
}
/// Convert multiple kind:0 events to [`UsersBatchResponse`].
///
/// `requested_pubkeys` lets us populate `missing` for any pubkey that had
/// no metadata event in the input set.
pub fn users_batch_from_events(
events: &[Event],
requested_pubkeys: &[String],
) -> UsersBatchResponse {
// Keep only the most recent kind:0 per pubkey.
let mut latest: HashMap<String, &Event> = HashMap::new();
for ev in events {
let pk = ev.pubkey.to_hex();
let take = match latest.get(&pk) {
None => true,
Some(prev) => ev.created_at > prev.created_at,
};
if take {
latest.insert(pk, ev);
}
}
let mut profiles = HashMap::new();
for (pk, ev) in &latest {
let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null);
let summary = UserProfileSummaryInfo {
display_name: v
.get("display_name")
.and_then(Value::as_str)
.or_else(|| v.get("name").and_then(Value::as_str))
.map(str::to_string),
avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string),
nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string),
};
profiles.insert(pk.clone(), summary);
}
let missing: Vec<String> = requested_pubkeys
.iter()
.filter(|pk| !profiles.contains_key(*pk))
.cloned()
.collect();
UsersBatchResponse { profiles, missing }
}
/// Convert kind:0 events (e.g. from a NIP-50 search) to [`SearchUsersResponse`].
/// Convert a single kind:0 event to a [`UserSearchResultInfo`].
pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo {
let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null);
UserSearchResultInfo {
pubkey: ev.pubkey.to_hex(),
display_name: v
.get("display_name")
.and_then(Value::as_str)
.or_else(|| v.get("name").and_then(Value::as_str))
.map(str::to_string),
avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string),
nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string),
}
}
pub fn search_users_from_events(events: &[Event]) -> SearchUsersResponse {
let users = events.iter().map(user_search_result_from_event).collect();
SearchUsersResponse { users }
}
/// Rank and truncate kind:0 events from a NIP-50 search response for the
/// member-picker / DM-recipient autocomplete.
///
/// The relay returns results scored by Typesense BM25 against the whole kind:0
/// JSON content blob. That ranking is fine as a recall mechanism but not as
/// final ordering — a user whose `display_name` *is* the query should always
/// rank above someone whose `about` happens to mention it. We re-rank with a
/// small deterministic scoring function:
///
/// - exact match (case-insensitive) > prefix match > substring match
/// - field priority: display_name (or name) > nip05 > pubkey hex
///
/// `limit` clamps the output. Pubkey de-duplication keeps only the
/// highest-scoring result per pubkey (Typesense should already return one doc
/// per event id, and kind:0 is a NIP-16 replaceable event so stale rows are
/// soft-deleted in the DB and filtered out before reaching us — this is
/// defense in depth in case both somehow slip through).
pub fn rank_user_search_results(
events: &[Event],
query: &str,
limit: usize,
) -> SearchUsersResponse {
let q = query.trim().to_lowercase();
if q.is_empty() || limit == 0 {
return SearchUsersResponse { users: Vec::new() };
}
// (score, input_index) — input index is a stable tiebreaker preserving
// the relay's relevance order for ties.
let mut scored: Vec<(u32, usize, UserSearchResultInfo)> = Vec::with_capacity(events.len());
for (idx, ev) in events.iter().enumerate() {
// Defensive: NIP-50 may return kinds we didn't expect if the relay
// doesn't honor the `kinds` filter under search. Skip non-kind:0.
if ev.kind.as_u16() != 0 {
continue;
}
let info = user_search_result_from_event(ev);
let display = info.display_name.as_deref().unwrap_or("").to_lowercase();
let nip05 = info.nip05_handle.as_deref().unwrap_or("").to_lowercase();
let pubkey = info.pubkey.to_lowercase();
let score = match_score(&q, &display, &nip05, &pubkey);
if score == 0 {
// Typesense returned this as a content-blob match (e.g. `about`).
// No useful name/nip05/pubkey hit — drop it from the autocomplete.
continue;
}
scored.push((score, idx, info));
}
// Sort: higher score first; on tie, earlier input index (= higher Typesense relevance) first.
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
// Dedupe by pubkey, keeping the first occurrence (already best-scored).
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut users: Vec<UserSearchResultInfo> = Vec::with_capacity(limit.min(scored.len()));
for (_, _, info) in scored {
if !seen.insert(info.pubkey.clone()) {
continue;
}
users.push(info);
if users.len() >= limit {
break;
}
}
SearchUsersResponse { users }
}
/// Score a user against a lowercased query.
///
/// Higher is better. Returns 0 if no field matches — caller should drop.
fn match_score(q: &str, display_name: &str, nip05: &str, pubkey_hex: &str) -> u32 {
// Per-field tier scores. We assign large gaps so a substring match on
// display_name still beats an exact match on a lower-priority field —
// "the user named alice" is the autocomplete answer to "alice", not
// "the user with alice@something.com".
const DISPLAY_EXACT: u32 = 1000;
const DISPLAY_PREFIX: u32 = 900;
const DISPLAY_CONTAINS: u32 = 800;
const NIP05_EXACT: u32 = 700;
const NIP05_PREFIX: u32 = 600;
const NIP05_CONTAINS: u32 = 500;
const PUBKEY_PREFIX: u32 = 400;
let score_field = |field: &str, exact: u32, prefix: u32, contains: u32| -> u32 {
if field.is_empty() {
0
} else if field == q {
exact
} else if field.starts_with(q) {
prefix
} else if field.contains(q) {
contains
} else {
0
}
};
let display_score = score_field(
display_name,
DISPLAY_EXACT,
DISPLAY_PREFIX,
DISPLAY_CONTAINS,
);
let nip05_score = score_field(nip05, NIP05_EXACT, NIP05_PREFIX, NIP05_CONTAINS);
let pubkey_score = if !pubkey_hex.is_empty() && pubkey_hex.starts_with(q) {
PUBKEY_PREFIX
} else {
0
};
display_score.max(nip05_score).max(pubkey_score)
}
// ── kind:1 (notes) ──────────────────────────────────────────────────────────
/// Convert kind:1 events to [`UserNotesResponse`].
///
/// Notes are returned in the input order. The cursor is built from the
/// oldest note (last in newest-first ordering) so the caller can page back.
pub fn user_notes_from_events(events: &[Event]) -> UserNotesResponse {
let notes: Vec<UserNoteInfo> = events
.iter()
.map(|ev| UserNoteInfo {
id: ev.id.to_hex(),
pubkey: ev.pubkey.to_hex(),
created_at: ev.created_at.as_secs() as i64,
content: ev.content.clone(),
tags: ev.tags.iter().map(|tag| tag.as_slice().to_vec()).collect(),
})
.collect();
let next_cursor = notes.last().map(|n| UserNotesCursor {
before: n.created_at,
before_id: n.id.clone(),
});
UserNotesResponse { notes, next_cursor }
}
// ── kind:3 (contact list) ───────────────────────────────────────────────────
/// Convert a kind:3 contact list event to [`ContactListResponse`].
pub fn contact_list_from_event(event: &Event) -> Result<ContactListResponse, String> {
let tags: Vec<Vec<String>> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect();
Ok(ContactListResponse {
id: event.id.to_hex(),
pubkey: event.pubkey.to_hex(),
created_at: event.created_at.as_secs() as i64,
tags,
content: event.content.clone(),
})
}
// ── NIP-50 search results ───────────────────────────────────────────────────
/// Convert search-result events (any kind) to [`SearchResponse`].
///
/// NIP-50 does not carry a relevance score on the wire; we use the input
/// position as a proxy: position 0 → score 1.0, dropping linearly to 0.
pub fn search_response_from_events(events: &[Event]) -> SearchResponse {
let total = events.len();
let hits: Vec<SearchHitInfo> = events
.iter()
.enumerate()
.map(|(idx, ev)| {
// Channel id is stored on a NIP-29 `h` tag when present.
let channel_id = first_tag_value(ev, "h").map(str::to_string);
let score = if total <= 1 {
1.0
} else {
1.0 - (idx as f64) / (total as f64)
};
SearchHitInfo {
event_id: ev.id.to_hex(),
content: ev.content.clone(),
kind: ev.kind.as_u16() as u32,
pubkey: ev.pubkey.to_hex(),
channel_id,
channel_name: None,
created_at: ev.created_at.as_secs(),
score,
}
})
.collect();
SearchResponse {
found: hits.len() as u64,
hits,
}
}
// ── kind:10100 (agent profiles) ─────────────────────────────────────────────
/// Convert kind:10100 agent profile events to the agent discovery format.
///
/// Returns a JSON array of `{pubkey, name, ...}` objects parsed from each
/// event's content.
pub fn agents_from_events(events: &[Event]) -> Value {
let arr: Vec<Value> = events
.iter()
.map(|ev| {
let mut v: Value = serde_json::from_str(&ev.content).unwrap_or_else(|_| json!({}));
// Always overwrite the pubkey with the event author — it's the
// authoritative source even if the content claims otherwise.
if let Some(obj) = v.as_object_mut() {
obj.insert("pubkey".to_string(), json!(ev.pubkey.to_hex()));
} else {
v = json!({ "pubkey": ev.pubkey.to_hex() });
}
v
})
.collect();
json!({ "agents": arr })
}
// ── kind:13534 (relay membership list) ──────────────────────────────────────
/// Convert a kind:13534 relay membership list to the relay members format.
///
/// The relay emits `["member", pubkey]` or `["member", pubkey, role]` tags.
/// For backward compatibility, also accepts `["p", pubkey, relay_url?, role?]`.
pub fn relay_members_from_event(event: &Event) -> Value {
let mut seen = BTreeSet::new();
let mut members: Vec<Value> = Vec::new();
// Primary: parse ["member", pubkey, role?] tags (current relay format).
for slice in tags_named(event, "member") {
let Some(pubkey) = slice.get(1).filter(|s| !s.is_empty()) else {
continue;
};
if !seen.insert(pubkey.clone()) {
continue;
}
let role = slice
.get(2)
.filter(|s| !s.is_empty())
.cloned()
.unwrap_or_else(|| "member".to_string());
members.push(json!({ "pubkey": pubkey, "role": role }));
}
// Fallback: parse ["p", pubkey, relay_url?, role?] tags (NIP-29 convention).
for slice in tags_named(event, "p") {
let Some(pubkey) = slice.get(1).filter(|s| !s.is_empty()) else {
continue;
};
if !seen.insert(pubkey.clone()) {
continue;
}
let role = slice
.get(3)
.filter(|s| !s.is_empty())
.cloned()
.unwrap_or_else(|| "member".to_string());
members.push(json!({ "pubkey": pubkey, "role": role }));
}
json!({ "members": members })
}
// ── Time helpers ────────────────────────────────────────────────────────────
/// Convert a unix-seconds timestamp to a UTC RFC-3339 string.
fn timestamp_to_iso(secs: u64) -> String {
use std::time::{Duration, SystemTime, UNIX_EPOCH};
let dt = UNIX_EPOCH + Duration::from_secs(secs);
// Format manually as RFC-3339 — the `time` crate is already a transitive
// dep, but using SystemTime keeps this self-contained.
let dur = dt
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs_total = dur.as_secs() as i64;
// Days since epoch, seconds within day.
let (days, sod) = (secs_total.div_euclid(86_400), secs_total.rem_euclid(86_400));
let h = sod / 3600;
let m = (sod % 3600) / 60;
let s = sod % 60;
let (y, mo, d) = days_to_ymd(days);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
/// Convert days-since-1970-01-01 to (year, month, day) using the civil-from-days
/// algorithm by Howard Hinnant (public domain).
fn days_to_ymd(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};
/// Build a signed event for testing with the given kind, content, and tags.
fn ev(kind: u16, content: &str, tags: Vec<Vec<&str>>) -> Event {
let keys = Keys::generate();
let parsed: Vec<Tag> = tags
.into_iter()
.map(|t| Tag::parse(t).expect("parse tag"))
.collect();
EventBuilder::new(Kind::from_u16(kind), content)
.tags(parsed)
.sign_with_keys(&keys)
.expect("sign")
}
#[test]
fn channel_info_minimal() {
let e = ev(
39000,
"",
vec![
vec!["d", "chan-uuid-1"],
vec!["name", "general"],
vec!["about", "main channel"],
vec!["t", "stream"],
vec!["public"],
],
);
let info = channel_info_from_event(&e, None, None).unwrap();
assert_eq!(info.id, "chan-uuid-1");
assert_eq!(info.name, "general");
assert_eq!(info.description, "main channel");
assert_eq!(info.channel_type, "stream");
assert_eq!(info.visibility, "open");
assert_eq!(info.member_count, 0);
assert!(info.is_member);
}
#[test]
fn channel_info_private_when_private_tag_present() {
// Explicit ["private"] tag → private (NIP-29 convention).
let e = ev(
39000,
"",
vec![
vec!["d", "u"],
vec!["name", "n"],
vec!["t", "forum"],
vec!["private"],
],
);
let info = channel_info_from_event(&e, None, None).unwrap();
assert_eq!(info.visibility, "private");
assert_eq!(info.channel_type, "forum");
}
#[test]
fn channel_info_open_when_neither_public_nor_private() {
// Neither tag present → open (matches NIP-29 default).
let e = ev(
39000,
"",
vec![vec!["d", "u"], vec!["name", "n"], vec!["t", "forum"]],
);
let info = channel_info_from_event(&e, None, None).unwrap();
assert_eq!(info.visibility, "open");
}
#[test]
fn channel_info_dm_inferred_from_hidden_tag() {
// Fallback: relays without ["t", "dm"] still emit ["hidden"] for DMs.
let e = ev(
39000,
"",
vec![vec!["d", "u"], vec!["name", "n"], vec!["hidden"]],
);
let info = channel_info_from_event(&e, None, None).unwrap();
assert_eq!(info.channel_type, "dm");
}
#[test]
fn channel_info_merges_summary() {
let chan = ev(39000, "", vec![vec!["d", "u"], vec!["name", "n"]]);
let summary = ev(
40901,
r#"{"member_count": 7, "last_message_at": "2026-01-01T00:00:00Z"}"#,
vec![vec!["d", "u"]],
);
let info = channel_info_from_event(&chan, Some(&summary), None).unwrap();
assert_eq!(info.member_count, 7);
assert_eq!(
info.last_message_at.as_deref(),
Some("2026-01-01T00:00:00Z")
);
}
#[test]
fn channel_info_missing_d_errors() {
let e = ev(39000, "", vec![vec!["name", "n"]]);
assert!(channel_info_from_event(&e, None, None).is_err());
}
#[test]
fn channel_detail_basic() {
let e = ev(
39000,
"",
vec![
vec!["d", "uuid"],
vec!["name", "n"],
vec!["about", "desc"],
vec!["topic", "tt"],
vec!["purpose", "pp"],
vec!["t", "dm"],
],
);
let d = channel_detail_from_event(&e).unwrap();
assert_eq!(d.id, "uuid");
assert_eq!(d.topic.as_deref(), Some("tt"));
assert_eq!(d.purpose.as_deref(), Some("pp"));
assert_eq!(d.channel_type, "dm");
assert!(d.created_at.ends_with("Z"));
assert_eq!(d.created_by, e.pubkey.to_hex());
}
#[test]
fn channel_members_extracts_p_tags() {
let pk1 = "a".repeat(64);
let pk2 = "b".repeat(64);
let e = ev(
39002,
"",
vec![
vec!["d", "uuid"],
vec!["p", &pk1, "", "admin"],
vec!["p", &pk2],
// Duplicate must be deduped.
vec!["p", &pk1, "wss://x", "owner"],
],
);
let r = channel_members_from_event(&e).unwrap();
assert_eq!(r.members.len(), 2);
assert_eq!(r.members[0].pubkey, pk1);
assert_eq!(r.members[0].role, "admin");
assert!(r.members[0].joined_at.is_none());
assert_eq!(r.members[1].role, "member"); // default
}
#[test]
fn channel_members_missing_d_errors() {
let e = ev(39002, "", vec![]);
assert!(channel_members_from_event(&e).is_err());
}
#[test]
fn profile_info_parses_content() {
let e = ev(
0,
r#"{"name":"alice","display_name":"Alice","picture":"http://x/a.png","about":"hi","nip05":"alice@x"}"#,
vec![],
);
let p = profile_info_from_event(&e).unwrap();
assert_eq!(p.display_name.as_deref(), Some("Alice"));
assert_eq!(p.avatar_url.as_deref(), Some("http://x/a.png"));
assert_eq!(p.about.as_deref(), Some("hi"));
assert_eq!(p.nip05_handle.as_deref(), Some("alice@x"));
assert_eq!(p.pubkey, e.pubkey.to_hex());
}
#[test]
fn profile_info_falls_back_to_name() {
let e = ev(0, r#"{"name":"bob"}"#, vec![]);
let p = profile_info_from_event(&e).unwrap();
assert_eq!(p.display_name.as_deref(), Some("bob"));
}
#[test]
fn profile_info_invalid_json_errors() {
let e = ev(0, "not-json", vec![]);
assert!(profile_info_from_event(&e).is_err());
}
#[test]
fn users_batch_keeps_latest_and_reports_missing() {
let e1 = ev(0, r#"{"name":"old"}"#, vec![]);
// Same author, newer event with display_name.
let keys = Keys::generate();
let e_old = EventBuilder::new(Kind::Metadata, r#"{"name":"old"}"#)
.custom_created_at(nostr::Timestamp::from(1000))
.sign_with_keys(&keys)
.unwrap();
let e_new = EventBuilder::new(Kind::Metadata, r#"{"display_name":"New"}"#)
.custom_created_at(nostr::Timestamp::from(2000))
.sign_with_keys(&keys)
.unwrap();
let pk = keys.public_key().to_hex();
let other_pk = e1.pubkey.to_hex();
let missing_pk = "f".repeat(64);
let resp = users_batch_from_events(
&[e1, e_old, e_new],
&[pk.clone(), other_pk.clone(), missing_pk.clone()],
);
assert_eq!(resp.profiles.len(), 2);
assert_eq!(resp.profiles[&pk].display_name.as_deref(), Some("New"));
assert_eq!(resp.missing, vec![missing_pk]);
}
#[test]
fn search_users_maps_each_event() {
let e1 = ev(0, r#"{"name":"a"}"#, vec![]);
let e2 = ev(0, r#"{"display_name":"B"}"#, vec![]);
let r = search_users_from_events(&[e1, e2]);
assert_eq!(r.users.len(), 2);
assert_eq!(r.users[0].display_name.as_deref(), Some("a"));
assert_eq!(r.users[1].display_name.as_deref(), Some("B"));
}
// ── rank_user_search_results ────────────────────────────────────────────
#[test]
fn rank_empty_query_returns_empty() {
let e = ev(0, r#"{"display_name":"alice"}"#, vec![]);
let r = rank_user_search_results(&[e], " ", 10);
assert!(r.users.is_empty());
}
#[test]
fn rank_zero_limit_returns_empty() {
let e = ev(0, r#"{"display_name":"alice"}"#, vec![]);
let r = rank_user_search_results(&[e], "alice", 0);
assert!(r.users.is_empty());
}
#[test]
fn rank_skips_non_kind_zero_events() {
// Even if a buggy relay returns non-kind:0 hits under a kinds:[0] filter,
// we shouldn't surface them as users.
let e = ev(1, "alice", vec![]);
let r = rank_user_search_results(&[e], "alice", 10);
assert!(r.users.is_empty());
}
#[test]
fn rank_skips_results_with_no_name_or_nip05_match() {
// Typesense matched the kind:0 because "alice" appears in the `about`
// field. We don't want that in autocomplete — there's no useful name
// to display against the query.
let e = ev(
0,
r#"{"display_name":"Bob","nip05":"bob@example.com","about":"I work with alice"}"#,
vec![],
);
let r = rank_user_search_results(&[e], "alice", 10);
assert!(r.users.is_empty());
}
#[test]
fn rank_exact_display_name_beats_substring_match() {
let exact = ev(0, r#"{"display_name":"alice"}"#, vec![]);
let sub = ev(0, r#"{"display_name":"alice-the-second"}"#, vec![]);
// Put the substring match first to prove ordering isn't input-driven.
let r = rank_user_search_results(&[sub, exact], "alice", 10);
assert_eq!(r.users.len(), 2);
assert_eq!(r.users[0].display_name.as_deref(), Some("alice"));
assert_eq!(r.users[1].display_name.as_deref(), Some("alice-the-second"));
}
#[test]
fn rank_display_name_beats_nip05_match_of_same_tier() {
// "alice" exact on display_name vs. "alice" prefix on nip05 — display wins.
let by_name = ev(0, r#"{"display_name":"alice"}"#, vec![]);
let by_nip05 = ev(0, r#"{"display_name":"X","nip05":"alice@x.com"}"#, vec![]);
let r = rank_user_search_results(&[by_nip05, by_name], "alice", 10);
assert_eq!(r.users[0].display_name.as_deref(), Some("alice"));
}
#[test]
fn rank_display_substring_still_beats_nip05_exact() {
// Asserts the field-priority gap is large enough that even a display
// substring outranks a perfect nip05 hit.
let by_name_sub = ev(0, r#"{"display_name":"my-alice-account"}"#, vec![]);
let by_nip05_exact = ev(0, r#"{"display_name":"Bob","nip05":"alice"}"#, vec![]);
let r = rank_user_search_results(&[by_nip05_exact, by_name_sub], "alice", 10);
assert_eq!(r.users[0].display_name.as_deref(), Some("my-alice-account"));
}
#[test]
fn rank_pubkey_prefix_match() {
// The query is a hex prefix; the kind:0 has no name match at all.
// Build the event then read its pubkey hex; use the first 8 chars as
// the query so we know the prefix matches.
let e = ev(0, r#"{"display_name":"unrelated"}"#, vec![]);
let prefix: String = e.pubkey.to_hex().chars().take(8).collect();
let r = rank_user_search_results(std::slice::from_ref(&e), &prefix, 10);
assert_eq!(r.users.len(), 1);
assert_eq!(r.users[0].pubkey, e.pubkey.to_hex());
}
#[test]
fn rank_dedupes_by_pubkey() {
// Same author publishing two kind:0s (e.g. one stale row slipping
// through). Dedupe should keep only one entry — the higher-scored one.
let keys = nostr::Keys::generate();
let mk = |content: &str| {
EventBuilder::new(Kind::from_u16(0), content)
.sign_with_keys(&keys)
.expect("sign")
};
// First event is a substring match on display_name; second is an exact match.
let weak = mk(r#"{"display_name":"alice-old"}"#);
let strong = mk(r#"{"display_name":"alice"}"#);
let r = rank_user_search_results(&[weak, strong], "alice", 10);
assert_eq!(r.users.len(), 1);
assert_eq!(r.users[0].display_name.as_deref(), Some("alice"));
}
#[test]
fn rank_respects_limit() {
let events: Vec<Event> = (0..5)
.map(|i| ev(0, &format!(r#"{{"display_name":"alice{}"}}"#, i), vec![]))
.collect();
let r = rank_user_search_results(&events, "alice", 3);
assert_eq!(r.users.len(), 3);
}
#[test]
fn rank_case_insensitive() {
let e = ev(0, r#"{"display_name":"AlIcE"}"#, vec![]);
let r = rank_user_search_results(&[e], "alice", 10);
assert_eq!(r.users.len(), 1);
let r =
rank_user_search_results(&[ev(0, r#"{"display_name":"alice"}"#, vec![])], "ALICE", 10);
assert_eq!(r.users.len(), 1);
}
#[test]
fn rank_tiebreak_preserves_relay_relevance_order() {
// Two equally-scored matches → first one in input wins.
let first = ev(0, r#"{"display_name":"alice"}"#, vec![]);
let second = ev(0, r#"{"display_name":"alice"}"#, vec![]);
let first_pk = first.pubkey.to_hex();
let r = rank_user_search_results(&[first, second], "alice", 10);
assert_eq!(r.users.len(), 2);
assert_eq!(r.users[0].pubkey, first_pk);
}
#[test]
fn rank_falls_back_to_name_when_display_name_absent() {
// user_search_result_from_event already prefers display_name then name —
// make sure the ranker sees the populated display_name and scores it.
let e = ev(0, r#"{"name":"alice"}"#, vec![]);
let r = rank_user_search_results(&[e], "alice", 10);
assert_eq!(r.users.len(), 1);
assert_eq!(r.users[0].display_name.as_deref(), Some("alice"));
}
#[test]
fn user_notes_builds_cursor_from_last() {
let e1 = ev(1, "first", vec![]);
let e2 = ev(1, "second", vec![]);
let r = user_notes_from_events(&[e1, e2]);
assert_eq!(r.notes.len(), 2);
assert_eq!(r.notes[0].content, "first");
let cursor = r.next_cursor.expect("cursor");
assert_eq!(cursor.before_id, r.notes[1].id);
}
#[test]
fn user_notes_empty_has_no_cursor() {
let r = user_notes_from_events(&[]);
assert!(r.notes.is_empty());
assert!(r.next_cursor.is_none());
}
#[test]
fn contact_list_preserves_tags_and_content() {
let pk = "1".repeat(64);
let e = ev(3, "rel-json", vec![vec!["p", &pk]]);
let r = contact_list_from_event(&e).unwrap();
assert_eq!(r.content, "rel-json");
assert_eq!(r.tags.len(), 1);
assert_eq!(r.tags[0], vec!["p".to_string(), pk]);
}
#[test]
fn search_response_assigns_descending_scores() {
let e1 = ev(1, "one", vec![vec!["h", "chan"]]);
let e2 = ev(1, "two", vec![]);
let r = search_response_from_events(&[e1, e2]);
assert_eq!(r.found, 2);
assert!(r.hits[0].score > r.hits[1].score);
assert_eq!(r.hits[0].channel_id.as_deref(), Some("chan"));
assert!(r.hits[1].channel_id.is_none());
}
#[test]
fn search_response_single_hit_full_score() {
let e = ev(1, "only", vec![]);
let r = search_response_from_events(&[e]);
assert_eq!(r.hits.len(), 1);
assert_eq!(r.hits[0].score, 1.0);
}
#[test]
fn agents_overwrites_pubkey_from_event_author() {
let e = ev(10100, r#"{"pubkey":"forged","name":"agent-1"}"#, vec![]);
let v = agents_from_events(std::slice::from_ref(&e));
let arr = v.get("agents").and_then(Value::as_array).unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(
arr[0].get("pubkey").and_then(Value::as_str).unwrap(),
e.pubkey.to_hex()
);
assert_eq!(arr[0].get("name").and_then(Value::as_str), Some("agent-1"));
}
#[test]
fn agents_handles_invalid_content() {
let e = ev(10100, "not-json", vec![]);
let v = agents_from_events(std::slice::from_ref(&e));
let arr = v.get("agents").and_then(Value::as_array).unwrap();
assert_eq!(
arr[0].get("pubkey").and_then(Value::as_str).unwrap(),
e.pubkey.to_hex()
);
}
#[test]
fn relay_members_dedupes_and_defaults_role() {
let pk1 = "a".repeat(64);
let pk2 = "b".repeat(64);
// Current relay format: ["member", pubkey, role]
let e = ev(
13534,
"",
vec![
vec!["member", &pk1, "owner"],
vec!["member", &pk2],
vec!["member", &pk1, "moderator"], // dupe — ignored
],
);
let v = relay_members_from_event(&e);
let arr = v.get("members").and_then(Value::as_array).unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("owner"));
assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member"));
}
#[test]
fn relay_members_fallback_p_tags() {
let pk1 = "a".repeat(64);
let pk2 = "b".repeat(64);
// Legacy/fallback format: ["p", pubkey, relay_url?, role?]
let e = ev(
13534,
"",
vec![vec!["p", &pk1, "", "admin"], vec!["p", &pk2]],
);
let v = relay_members_from_event(&e);
let arr = v.get("members").and_then(Value::as_array).unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("admin"));
assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member"));
}
#[test]
fn timestamp_to_iso_known_value() {
// 2021-01-01T00:00:00Z = 1609459200
assert_eq!(timestamp_to_iso(1_609_459_200), "2021-01-01T00:00:00Z");
// Epoch
assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z");
}
}