You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
266 lines
6.8 KiB
266 lines
6.8 KiB
/** |
|
* Unified user data fetcher |
|
* Consolidates profile, status, and relay list fetching |
|
*/ |
|
|
|
import { nostrClient } from './nostr/nostr-client.js'; |
|
import { relayManager } from './nostr/relay-manager.js'; |
|
import { cacheProfile, getProfile, getProfiles } from './cache/profile-cache.js'; |
|
import { config } from './nostr/config.js'; |
|
import type { NostrEvent } from '../types/nostr.js'; |
|
|
|
// Re-export profile types and functions |
|
export interface ProfileData { |
|
name?: string; |
|
about?: string; |
|
picture?: string; |
|
website?: string[]; |
|
nip05?: string[]; |
|
lud16?: string[]; |
|
} |
|
|
|
/** |
|
* Parse profile from kind 0 event |
|
*/ |
|
export function parseProfile(event: NostrEvent): ProfileData { |
|
const profile: ProfileData = {}; |
|
|
|
// Try to parse from tags first (preferred) |
|
const nameTag = event.tags.find((t) => t[0] === 'name'); |
|
if (nameTag && nameTag[1]) profile.name = nameTag[1]; |
|
|
|
const aboutTag = event.tags.find((t) => t[0] === 'about'); |
|
if (aboutTag && aboutTag[1]) profile.about = aboutTag[1]; |
|
|
|
const pictureTag = event.tags.find((t) => t[0] === 'picture'); |
|
if (pictureTag && pictureTag[1]) profile.picture = pictureTag[1]; |
|
|
|
// Multiple tags for website, nip05, lud16 |
|
profile.website = event.tags.filter((t) => t[0] === 'website').map((t) => t[1]).filter(Boolean); |
|
profile.nip05 = event.tags.filter((t) => t[0] === 'nip05').map((t) => t[1]).filter(Boolean); |
|
profile.lud16 = event.tags.filter((t) => t[0] === 'lud16').map((t) => t[1]).filter(Boolean); |
|
|
|
// Fallback to JSON content if tags not found |
|
if (!profile.name || !profile.about) { |
|
try { |
|
const json = JSON.parse(event.content); |
|
if (json.name && !profile.name) profile.name = json.name; |
|
if (json.about && !profile.about) profile.about = json.about; |
|
if (json.picture && !profile.picture) profile.picture = json.picture; |
|
if (json.website && profile.website.length === 0) { |
|
profile.website = Array.isArray(json.website) ? json.website : [json.website]; |
|
} |
|
if (json.nip05 && profile.nip05.length === 0) { |
|
profile.nip05 = Array.isArray(json.nip05) ? json.nip05 : [json.nip05]; |
|
} |
|
if (json.lud16 && profile.lud16.length === 0) { |
|
profile.lud16 = Array.isArray(json.lud16) ? json.lud16 : [json.lud16]; |
|
} |
|
} catch { |
|
// Invalid JSON, ignore |
|
} |
|
} |
|
|
|
return profile; |
|
} |
|
|
|
/** |
|
* Fetch profile for a pubkey |
|
*/ |
|
export async function fetchProfile( |
|
pubkey: string, |
|
relays?: string[] |
|
): Promise<ProfileData | null> { |
|
// Try cache first |
|
const cached = await getProfile(pubkey); |
|
if (cached) { |
|
return parseProfile(cached.event); |
|
} |
|
|
|
// Fetch from relays |
|
const relayList = relays || [ |
|
...config.defaultRelays, |
|
...config.profileRelays |
|
]; |
|
|
|
const events = await nostrClient.fetchEvents( |
|
[{ kinds: [0], authors: [pubkey], limit: 1 }], |
|
relayList, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
|
|
if (events.length === 0) return null; |
|
|
|
const event = events[0]; |
|
await cacheProfile(event); |
|
|
|
return parseProfile(event); |
|
} |
|
|
|
/** |
|
* Fetch multiple profiles |
|
*/ |
|
export async function fetchProfiles( |
|
pubkeys: string[], |
|
relays?: string[] |
|
): Promise<Map<string, ProfileData>> { |
|
const profiles = new Map<string, ProfileData>(); |
|
|
|
// Check cache first |
|
const cached = await getProfiles(pubkeys); |
|
for (const [pubkey, cachedProfile] of cached.entries()) { |
|
profiles.set(pubkey, parseProfile(cachedProfile.event)); |
|
} |
|
|
|
// Fetch missing profiles |
|
const missing = pubkeys.filter((p) => !profiles.has(p)); |
|
if (missing.length === 0) return profiles; |
|
|
|
const relayList = relays || [ |
|
...config.defaultRelays, |
|
...config.profileRelays |
|
]; |
|
|
|
const events = await nostrClient.fetchEvents( |
|
[{ kinds: [0], authors: missing, limit: 1 }], |
|
relayList, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
|
|
for (const event of events) { |
|
await cacheProfile(event); |
|
profiles.set(event.pubkey, parseProfile(event)); |
|
} |
|
|
|
return profiles; |
|
} |
|
|
|
/** |
|
* Parse user status from kind 30315 event |
|
*/ |
|
export function parseUserStatus(event: NostrEvent): string | null { |
|
if (event.kind !== 30315) return null; |
|
|
|
// Check for d tag with value "general" |
|
const dTag = event.tags.find((t) => t[0] === 'd' && t[1] === 'general'); |
|
if (!dTag) return null; |
|
|
|
return event.content || null; |
|
} |
|
|
|
/** |
|
* Fetch user status for a pubkey |
|
*/ |
|
export async function fetchUserStatus( |
|
pubkey: string, |
|
relays?: string[] |
|
): Promise<string | null> { |
|
const relayList = relays || [ |
|
...config.defaultRelays, |
|
...config.profileRelays |
|
]; |
|
|
|
const events = await nostrClient.fetchEvents( |
|
[ |
|
{ |
|
kinds: [30315], |
|
authors: [pubkey], |
|
'#d': ['general'], |
|
limit: 1 |
|
} as any // NIP-38 uses #d tag for parameterized replaceable events |
|
], |
|
relayList, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
|
|
if (events.length === 0) return null; |
|
|
|
return parseUserStatus(events[0]); |
|
} |
|
|
|
export interface RelayInfo { |
|
url: string; |
|
read: boolean; |
|
write: boolean; |
|
} |
|
|
|
/** |
|
* Parse relay list from event |
|
*/ |
|
export function parseRelayList(event: NostrEvent): RelayInfo[] { |
|
const relays: RelayInfo[] = []; |
|
|
|
for (const tag of event.tags) { |
|
if (tag[0] === 'r' && tag[1]) { |
|
const url = tag[1]; |
|
const markers = tag.slice(2); |
|
|
|
// If no markers, relay is both read and write |
|
if (markers.length === 0) { |
|
relays.push({ url, read: true, write: true }); |
|
continue; |
|
} |
|
|
|
// Check for explicit markers |
|
const hasRead = markers.includes('read'); |
|
const hasWrite = markers.includes('write'); |
|
|
|
// If only 'read' marker: read=true, write=false |
|
// If only 'write' marker: read=false, write=true |
|
// If both or neither explicitly: both true (default behavior) |
|
const read = hasRead || (!hasRead && !hasWrite); |
|
const write = hasWrite || (!hasRead && !hasWrite); |
|
|
|
relays.push({ url, read, write }); |
|
} |
|
} |
|
|
|
return relays; |
|
} |
|
|
|
/** |
|
* Fetch relay lists for a pubkey (kind 10002 and 10432) |
|
*/ |
|
export async function fetchRelayLists( |
|
pubkey: string, |
|
relays?: string[] |
|
): Promise<{ |
|
inbox: string[]; |
|
outbox: string[]; |
|
}> { |
|
const relayList = relays || [ |
|
...config.defaultRelays, |
|
...config.profileRelays |
|
]; |
|
|
|
// Fetch both kind 10002 and 10432 |
|
const events = await nostrClient.fetchEvents( |
|
[ |
|
{ kinds: [10002], authors: [pubkey], limit: 1 }, |
|
{ kinds: [10432], authors: [pubkey], limit: 1 } |
|
], |
|
relayList, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
|
|
const inbox: string[] = []; |
|
const outbox: string[] = []; |
|
|
|
for (const event of events) { |
|
const relayInfos = parseRelayList(event); |
|
for (const info of relayInfos) { |
|
if (info.read && !inbox.includes(info.url)) { |
|
inbox.push(info.url); |
|
} |
|
if (info.write && !outbox.includes(info.url)) { |
|
outbox.push(info.url); |
|
} |
|
} |
|
} |
|
|
|
// Deduplicate |
|
return { |
|
inbox: [...new Set(inbox)], |
|
outbox: [...new Set(outbox)] |
|
}; |
|
}
|
|
|