UNPKG

askexperts

Version:

AskExperts SDK: build and use AI experts - ask them questions and pay with bitcoin on an open protocol

384 lines 17 kB
import { fetchFromRelays } from "../../common/relay.js"; import { debugError, debugExpert } from "../../common/debug.js"; /** * Parse an expert profile event into an Expert object * * @param event - The expert profile event to parse * @returns Expert object or null if invalid */ export function parseExpertProfile(event) { try { // Extract relay URLs from the tags const relayTags = event.tags.filter((tag) => tag[0] === "relay"); const expertRelays = relayTags.map((tag) => tag[1]); if (expertRelays.length === 0) { debugError("Expert profile event missing relay tags:", event); return null; } // Extract formats from the tags const formatTags = event.tags.filter((tag) => tag[0] === "f"); const expertFormats = formatTags.map((tag) => tag[1]); // Check if streaming is supported const streamTag = event.tags.find((tag) => tag[0] === "s" && tag[1] === "true"); const expertStreamSupported = !!streamTag; // Extract payment methods from the tags const methodTags = event.tags.filter((tag) => tag[0] === "m"); const expertMethods = methodTags.map((tag) => tag[1]); // Extract hashtags from the tags const hashtagTags = event.tags.filter((tag) => tag[0] === "t"); const expertHashtags = hashtagTags.map((tag) => tag[1]); // Extract name from the tags const nameTag = event.tags.find((tag) => tag[0] === "name"); const name = nameTag ? nameTag[1] : undefined; // Create an Expert object const expert = { pubkey: event.pubkey, name, description: event.content, relays: expertRelays, formats: expertFormats, stream: expertStreamSupported, methods: expertMethods, hashtags: expertHashtags, event, }; return expert; } catch (error) { debugError("Error processing expert profile event:", error); return null; } } /** * Nostr utility class for fetching and processing Nostr data */ export class Nostr { /** * Creates a new Nostr utility instance * * @param pool - SimplePool instance for Nostr relay connections */ constructor(pool) { this.pool = pool; } /** * Crawls a user's profile and posts * * @param pubkey - Public key of the user * @param limit - Maximum number of posts to fetch (default: 1000) * @returns Promise resolving to ProfileInfo with profile and posts */ async crawlProfile(pubkey, limit = 1000) { try { // 1. Fetch kind:10002 event (relay list) from outbox relays const relayListFilter = { kinds: [10002], authors: [pubkey], limit: 1, }; const relayListEvents = await fetchFromRelays(relayListFilter, Nostr.OUTBOX_RELAYS, this.pool, 10000 // 10 second timeout ); // Parse relay list into read and write relays const { readRelays, writeRelays } = this.parseRelayList(relayListEvents[0]); // If no relay list found, use outbox relays for both read and write const effectiveReadRelays = readRelays.length > 0 ? readRelays : Nostr.OUTBOX_RELAYS; const effectiveWriteRelays = writeRelays.length > 0 ? writeRelays : Nostr.OUTBOX_RELAYS; debugExpert(`Fetched relays for ${pubkey}: ${effectiveWriteRelays}`); // 2. Fetch kind:0 (profile) from outbox relays const profileFilter = { kinds: [0], authors: [pubkey], limit: 1, }; const profileEvents = await fetchFromRelays(profileFilter, Nostr.OUTBOX_RELAYS, this.pool, 10000 // 10 second timeout ); // Parse profile data const profile = profileEvents.length > 0 ? JSON.parse(profileEvents[0].content) : {}; debugExpert(`Fetched profile for ${pubkey}: ${JSON.stringify(profile)}`); // 3. Fetch kind:1 (posts) from write relays with pagination const allPostEvents = []; let oldestTimestamp; // Continue fetching until we reach the limit or no more posts are available while (allPostEvents.length < limit) { // Create filter for the current batch const postsFilter = { kinds: [1], authors: [pubkey], limit: 500, }; // Add until parameter for pagination if we have an oldest timestamp if (oldestTimestamp) { postsFilter.until = oldestTimestamp - 1; // Subtract 1 to avoid duplicates } // Fetch the current batch const postEvents = await fetchFromRelays(postsFilter, effectiveWriteRelays, this.pool, 10000 // 10 second timeout ); debugExpert(`Fetched batch since ${postsFilter.until} events ${postEvents.length}`); // If no more posts were found, break the loop if (postEvents.length === 0) { break; } // Add the new posts to our collection allPostEvents.push(...postEvents); // Find the oldest timestamp for the next iteration oldestTimestamp = Math.min(...postEvents.map((event) => event.created_at)); } // Trim to the requested limit const finalPostEvents = allPostEvents.slice(0, limit); // Sort by created_at in descending order (newest first) finalPostEvents.sort((a, b) => b.created_at - a.created_at); // 4. Process posts to find reply references const referencedEvents = new Map(); // Collect all event references from posts for (const event of finalPostEvents) { for (const tag of event.tags) { if (tag[0] === "e") { const id = tag[1]; const relay = tag[2]; const marker = tag[3]; // Only consider tags with marker absent, 'reply', or 'root' if (!marker || marker === "reply" || marker === "root") { // Prefer 'reply' over 'root' if both exist for the same ID if (referencedEvents.has(id)) { const existingRef = referencedEvents.get(id); if (existingRef.marker === "root" && marker === "reply") { referencedEvents.set(id, { id, relay, marker }); } } else { referencedEvents.set(id, { id, relay, marker }); } } } } } // 5. Group referenced events by relay const relayToIds = new Map(); // Add all referenced events to their respective relays for (const ref of referencedEvents.values()) { if (ref.relay) { const ids = relayToIds.get(ref.relay) || []; ids.push(ref.id); relayToIds.set(ref.relay, ids); } else { // If no relay specified, add to all read relays for (const relay of effectiveReadRelays) { const ids = relayToIds.get(relay) || []; ids.push(ref.id); relayToIds.set(relay, ids); } } } // 6. Fetch referenced events from relays in parallel (one relay at a time) const fetchResults = []; // Process each relay in parallel const relayPromises = []; for (const [relay, ids] of relayToIds.entries()) { // For each relay, create a promise that processes all batches sequentially const relayPromise = (async () => { // Process in batches of IDs const BATCH_SIZE = 200; for (let i = 0; i < ids.length; i += BATCH_SIZE) { const batch = ids.slice(i, i + BATCH_SIZE); const filter = { ids: batch, }; debugExpert("fetching from", relay, batch.length, i, ids.length); // Fetch this batch and add results const batchResults = await fetchFromRelays(filter, [relay], this.pool, 10000 // 15 second timeout ); debugExpert("fetched from", relay, i, batchResults.length); fetchResults.push(batchResults); } })(); relayPromises.push(relayPromise); } // Wait for all relay processing to complete await Promise.all(relayPromises); // 7. Collect and deduplicate referenced events const referencedEventsById = new Map(); for (const events of fetchResults) { for (const event of events) { referencedEventsById.set(event.id, event); } } // 8. Create posts with context const postsWithContext = finalPostEvents.map((event) => { // Find reply reference for this post let inReplyTo = undefined; // Look for e tags with reply or root marker const replyTags = event.tags .filter((tag) => tag[0] === "e" && (!tag[3] || tag[3] === "reply" || tag[3] === "root")) .sort((a, b) => { // Sort to prefer 'reply' over 'root' or undefined const markerA = a[3] || ""; const markerB = b[3] || ""; return markerA === "reply" ? -1 : markerB === "reply" ? 1 : 0; }); // If we have reply tags, try to find the referenced event if (replyTags.length > 0) { const replyId = replyTags[0][1]; const replyEvent = referencedEventsById.get(replyId); if (replyEvent) { inReplyTo = { created_at: replyEvent.created_at, content: replyEvent.content, pubkey: replyEvent.pubkey, }; } } return { id: event.id, created_at: event.created_at, content: event.content, pubkey: event.pubkey, in_reply_to: inReplyTo, }; }); return { profile, posts: postsWithContext, }; } catch (error) { debugError("Error crawling profile:", error); throw error; } } /** * Parses a kind:10002 relay list event into read and write relays * * @param event - The relay list event * @returns Object containing read and write relay arrays */ parseRelayList(event) { const readRelaysSet = new Set(); const writeRelaysSet = new Set(); if (!event) { return { readRelays: [], writeRelays: [] }; } // Process each 'r' tag in the event for (const tag of event.tags) { if (tag[0] === "r") { const relayUrl = tag[1]; const permission = tag[2]; // 'read', 'write', or undefined (which means both) if (!relayUrl) continue; // Validate the relay URL try { const url = new URL(relayUrl); // Check if it's a valid relay URL if (url.protocol !== "wss:") continue; // Check if it's not a localhost or .onion address const hostname = url.hostname; if (hostname === "localhost" || hostname === "127.0.0.1" || hostname.endsWith(".onion")) { continue; } // Add to appropriate sets (deduplicates automatically) if (!permission || permission === "read") { readRelaysSet.add(relayUrl); } if (!permission || permission === "write") { writeRelaysSet.add(relayUrl); } } catch (error) { // Skip invalid URLs continue; } } } // Convert sets to arrays return { readRelays: Array.from(readRelaysSet), writeRelays: Array.from(writeRelaysSet), }; } /** * Crawls Nostr events for a specific pubkey and kinds * * @param pubkey - Public key of the user * @param kinds - Array of event kinds to fetch (default: [1] for notes) * @param relays - Array of relay URLs to fetch from (default: auto-detect from user's kind:10002) * @param limit - Maximum number of events to fetch (default: 1000) * @returns Promise resolving to an array of events */ async crawl({ pubkey, kinds = [], relays = [], limit = 1000 }) { let writeRelays = [...relays]; // If no relays provided, load pubkey's 10002 event to get their relays if (relays.length === 0) { try { // Fetch relay list event (kind 10002) const relayListFilter = { kinds: [10002], authors: [pubkey], limit: 1, }; const relayListEvents = await fetchFromRelays(relayListFilter, Nostr.OUTBOX_RELAYS, this.pool, 10000 // 10 second timeout ); if (relayListEvents.length > 0) { // Parse relay list const { writeRelays: parsedWriteRelays } = this.parseRelayList(relayListEvents[0]); if (parsedWriteRelays.length > 0) { writeRelays = parsedWriteRelays; } } } catch (error) { debugError("Error fetching relay list:", error); } // If still no relays, use default outbox relays if (writeRelays.length === 0) { writeRelays = Nostr.OUTBOX_RELAYS; } } debugExpert(`Using relays: ${writeRelays.join(', ')}`); // Fetch events of specified kinds const events = []; let oldestTimestamp; // Continue fetching until we reach the limit or no more events are available while (events.length < limit) { // Create filter for the current batch const filter = { authors: [pubkey], limit: Math.min(500, limit - events.length), }; if (kinds && kinds.length > 0) filter.kinds = kinds; // Add until parameter for pagination if we have an oldest timestamp if (oldestTimestamp) { filter.until = oldestTimestamp - 1; // Subtract 1 to avoid duplicates } // Fetch the current batch const batchEvents = await fetchFromRelays(filter, writeRelays, this.pool, 10000 // 10 second timeout ); debugExpert(`Fetched ${batchEvents.length} events`); // If no more events were found, break the loop if (batchEvents.length === 0) { break; } // Add the new events to our collection events.push(...batchEvents); // Find the oldest timestamp for the next iteration oldestTimestamp = Math.min(...batchEvents.map((event) => event.created_at)); } // Trim to the requested limit and sort by created_at in descending order const finalEvents = events.slice(0, limit); finalEvents.sort((a, b) => b.created_at - a.created_at); return finalEvents; } } /** Default outbox relays to check for user data */ Nostr.OUTBOX_RELAYS = [ "wss://purplepag.es", "wss://relay.nostr.band", "wss://relay.primal.net", "wss://user.kindpag.es", "wss://relay.nos.social", ]; //# sourceMappingURL=Nostr.js.map