UNPKG

libaskexperts

Version:

A TypeScript library to create experts based on NIP-174

507 lines (506 loc) 23.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Expert = void 0; const nostr_tools_1 = require("nostr-tools"); const constants_1 = require("./constants"); const sdk_1 = require("@getalby/sdk"); const utils_1 = require("@noble/hashes/utils"); const sha2_1 = require("@noble/hashes/sha2"); /** * Expert class that implements the NIP-174 protocol * * This class encapsulates all the logic of working with nostr and lightning payments * for experts who want to participate in the Ask Experts protocol. */ class Expert { // Private properties pool; nwcClient; expertPrivkey; expertPubkey; askRelays; questionRelays; hashtags; onAskCallback; onQuestionCallback; bidTimeout; min_bid_sats; activeBids; subscriptions; /** * Create a new Expert * * @param params Configuration parameters for the expert */ constructor({ nwcString, expertPrivkey, askRelays, questionRelays, hashtags, onAsk, onQuestion, bidTimeout = 600, // Default 10 minutes min_bid_sats, }) { this.nwcClient = new sdk_1.nwc.NWCClient({ nostrWalletConnectUrl: nwcString, }); this.expertPrivkey = expertPrivkey; this.expertPubkey = (0, nostr_tools_1.getPublicKey)(expertPrivkey); this.askRelays = askRelays; this.questionRelays = questionRelays; this.hashtags = hashtags; this.onAskCallback = onAsk; this.onQuestionCallback = onQuestion; this.bidTimeout = bidTimeout; this.min_bid_sats = min_bid_sats; this.pool = new nostr_tools_1.SimplePool(); this.activeBids = new Map(); this.subscriptions = []; } /** * Start listening for asks */ start() { const currentTime = Math.floor(Date.now() / 1000) - 10; // Only get new events from now // Create a filter for ask events with hashtags const hashtagFilter = { kinds: [constants_1.NOSTR_EVENT_KIND_ASK], since: currentTime, }; // Add hashtag filter if hashtags are provided if (this.hashtags.length > 0) { hashtagFilter["#t"] = this.hashtags; } // Create a filter for ask events that tag the expert's pubkey const pubkeyFilter = { kinds: [constants_1.NOSTR_EVENT_KIND_ASK], "#p": [this.expertPubkey], since: currentTime, }; // Common event handler for both subscriptions const handleEvent = (event) => { this.handleAskEvent(event).catch((error) => { console.error("Error handling ask event:", error); }); }; // Subscribe to ask events with hashtags const hashtagSub = this.pool.subscribeMany(this.askRelays, [hashtagFilter], { onevent: handleEvent, oneose: () => { console.log("End of stored hashtag events, now listening for new events in real-time"); }, }); // Subscribe to ask events that tag the expert's pubkey const pubkeySub = this.pool.subscribeMany(this.askRelays, [pubkeyFilter], { onevent: handleEvent, oneose: () => { console.log("End of stored pubkey events, now listening for new events in real-time"); }, }); this.subscriptions.push(hashtagSub); this.subscriptions.push(pubkeySub); } /** * Clean up resources when the expert is disposed */ [Symbol.dispose]() { // Close all subscriptions for (const sub of this.subscriptions) { sub.close(); } this.subscriptions = []; // Close all active bid subscriptions and clear timeouts for (const [_, bidInfo] of this.activeBids.entries()) { if (bidInfo.subscription) { bidInfo.subscription.close(); } if (bidInfo.timeoutId) { clearTimeout(bidInfo.timeoutId); } } this.activeBids.clear(); // Close all relay connections this.pool.close(this.askRelays); this.pool.close(this.questionRelays); } /** * Handle an ask event * * @param askEvent The ask event to handle */ async handleAskEvent(askEvent) { try { console.log(`Received ask event: ${JSON.stringify(askEvent)}`); // Check if the event kind is correct if (askEvent.kind !== constants_1.NOSTR_EVENT_KIND_ASK) { console.error(`Unexpected event kind: ${askEvent.kind}, expected: ${constants_1.NOSTR_EVENT_KIND_ASK}`); return; } // Extract hashtags from 't' tags const hashtags = askEvent.tags .filter(tag => tag[0] === 't') .map(tag => tag[1]); // Extract max_bid_sats from tag if present const maxBidSatsTag = askEvent.tags.find(tag => tag[0] === 'max_bid_sats'); const max_bid_sats = maxBidSatsTag ? parseInt(maxBidSatsTag[1], 10) : undefined; // Convert the ask event to our Ask type const ask = { id: askEvent.id, pubkey: askEvent.pubkey, content: askEvent.content, created_at: askEvent.created_at, tags: askEvent.tags, hashtags, max_bid_sats, }; // Check if the ask's max_bid_sats is less than our min_bid_sats if (this.min_bid_sats !== undefined && max_bid_sats !== undefined && max_bid_sats < this.min_bid_sats) { console.log(`Ask ${askEvent.id} max_bid_sats (${max_bid_sats}) is less than min_bid_sats (${this.min_bid_sats}), ignoring`); return; } // Call the onAsk callback to get a bid const bid = await this.onAskCallback(ask); // If no bid is returned, ignore this ask if (!bid) { console.log(`No bid returned for ask ${askEvent.id}, ignoring`); return; } // Generate a random keypair for the bid const bidPrivateKey = (0, nostr_tools_1.generateSecretKey)(); const bidPublicKey = (0, nostr_tools_1.getPublicKey)(bidPrivateKey); // Generate an invoice for the bid amount const { invoice, payment_hash } = await this.nwcClient.makeInvoice({ amount: bid.bid_sats * 1000, // Convert sats to millisats description: `Bid for ask ${askEvent.id}`, }); console.log(`Generated invoice: ${invoice}`); console.log(`Payment hash: ${payment_hash}`); // Create the bid payload event const bidPayload = { kind: constants_1.NOSTR_EVENT_KIND_BID_PAYLOAD, created_at: Math.floor(Date.now() / 1000), pubkey: this.expertPubkey, content: bid.content, tags: [ ["invoice", invoice], ...this.questionRelays.map((relay) => ["relay", relay]), ...(bid.tags || []), ], }; // Sign the bid payload const signedBidPayload = (0, nostr_tools_1.finalizeEvent)(bidPayload, this.expertPrivkey); console.log(`Created bid payload: ${JSON.stringify(signedBidPayload)}`); // Encrypt the bid payload for the ask pubkey let encryptedContent; try { // Generate the conversation key for encryption const conversationKey = nostr_tools_1.nip44.getConversationKey(bidPrivateKey, askEvent.pubkey); // Convert payload to string const payloadString = JSON.stringify(signedBidPayload); // Encrypt using the conversation key encryptedContent = nostr_tools_1.nip44.encrypt(payloadString, conversationKey); } catch (error) { console.error("Error encrypting bid payload:", error); throw error; } // Create the bid event const bidEvent = { kind: constants_1.NOSTR_EVENT_KIND_BID, created_at: Math.floor(Date.now() / 1000), pubkey: bidPublicKey, content: encryptedContent, tags: [["e", askEvent.id]], }; // Sign the bid event const signedBidEvent = (0, nostr_tools_1.finalizeEvent)(bidEvent, bidPrivateKey); console.log(`Created bid event: ${JSON.stringify(signedBidEvent)}`); // Publish the bid event to the relays try { // pool.publish returns an array of promises - one for each relay const publishPromises = this.pool.publish(this.askRelays, signedBidEvent); // Wait for all promises to resolve const results = await Promise.allSettled(publishPromises); // Check results const successful = results.filter((result) => result.status === "fulfilled").length; const failed = results.filter((result) => result.status === "rejected").length; console.log(`Bid published to ${successful} relays, failed on ${failed} relays`); if (successful > 0) { console.log(`Bid published successfully`); // Set up a subscription for questions related to this bid this.setupQuestionSubscription(askEvent, signedBidEvent, signedBidPayload, payment_hash, bid); } else { console.error(`Failed to publish bid to any relay`); } } catch (error) { console.error(`Failed to publish bid: ${error}`); } } catch (error) { console.error("Error handling ask event:", error); } } /** * Set up a subscription for questions related to a bid * * @param askEvent The original ask event * @param bidEvent The bid event * @param bidPayloadEvent The bid payload event * @param paymentHash The payment hash for the invoice * @param bid The bid object returned by onAsk */ setupQuestionSubscription(askEvent, bidEvent, bidPayloadEvent, paymentHash, bid) { // Create a filter for question events that tag our bid payload ID const filter = { kinds: [constants_1.NOSTR_EVENT_KIND_QUESTION], "#e": [bidPayloadEvent.id], }; // Subscribe to question events const subscription = this.pool.subscribeMany(this.questionRelays, [filter], { onevent: async (questionEvent) => { try { console.log(`Received question event for bid payload ${bidPayloadEvent.id}: ${JSON.stringify(questionEvent)}`); // Get the active bid from the map const activeBid = this.activeBids.get(bidPayloadEvent.id); if (!activeBid) { console.error(`No active bid found for bid payload ${bidPayloadEvent.id}`); return; } // We got the question subscription.close(); clearTimeout(timeoutId); this.activeBids.delete(bidPayloadEvent.id); // Handle the question await this.handleQuestionEvent(questionEvent, activeBid); } catch (error) { console.error(`Error handling question event: ${error}`); } }, oneose: () => { console.log(`End of stored events for bid payload ${bidPayloadEvent.id}, now listening for new events`); }, }); // Set a timeout for the bid const timeoutId = setTimeout(() => { console.log(`Timeout reached for bid payload ${bidPayloadEvent.id}, closing subscription`); subscription.close(); this.activeBids.delete(bidPayloadEvent.id); }, this.bidTimeout * 1000); // Store the active bid this.activeBids.set(bidPayloadEvent.id, { askEvent, bidEvent, bidPayloadEvent, sessionPubkey: askEvent.pubkey, paymentHash, timestamp: Math.floor(Date.now() / 1000), subscription, timeoutId, bid, history: [], messageId: bidPayloadEvent.id, }); } /** * Handle a question event * * @param questionEvent The question event to handle * @param activeBid The active bid associated with this question */ async handleQuestionEvent(questionEvent, activeBid) { try { // Check if the event kind is correct if (questionEvent.kind !== constants_1.NOSTR_EVENT_KIND_QUESTION) { console.error(`Unexpected event kind: ${questionEvent.kind}, expected: ${constants_1.NOSTR_EVENT_KIND_QUESTION}`); return; } // Check if the question event tags the current context id const eTag = questionEvent.tags.find((tag) => tag[0] === "e"); if (!eTag || eTag[1] !== activeBid.messageId) { console.error(`Question event does not tag the correct context id: ${JSON.stringify(questionEvent)}`); return; } // Decrypt the question content let questionPayload; try { // Generate the conversation key for decryption const conversationKey = nostr_tools_1.nip44.getConversationKey(this.expertPrivkey, activeBid.sessionPubkey); // Decrypt the question content const decryptedContent = nostr_tools_1.nip44.decrypt(questionEvent.content, conversationKey); questionPayload = JSON.parse(decryptedContent); console.log(`Decrypted question: ${JSON.stringify(questionPayload)}`); } catch (error) { console.error(`Failed to decrypt question: ${error}`); throw error; } // Message ID required const questionMessageId = questionPayload.tags.find((tag) => tag[0] === "message_id")?.[1]; if (!questionMessageId) { console.error(`No message_id found in question payload`); throw new Error(`No message_id found in question payload`); } // Extract the preimage from the question payload const preimage = questionPayload.tags.find((tag) => tag[0] === "preimage")?.[1]; if (!preimage) { console.error(`No preimage found in question payload`); throw new Error(`No preimage found in question payload`); } // Check preimage against payment_hash console.log(`Checking preimage ${preimage} against payment_hash ${activeBid.paymentHash}`); if (!preimage || (0, utils_1.bytesToHex)((0, sha2_1.sha256)((0, utils_1.hexToBytes)(preimage))) !== activeBid.paymentHash) { console.error(`Failed to match preimage with payment_hash`); throw new Error(`Failed to match preimage with payment_hash`); } // Look up the invoice to check if it's been paid console.log(`Looking up invoice with payment_hash: ${activeBid.paymentHash}`); const invoiceStatus = await this.nwcClient.lookupInvoice({ payment_hash: activeBid.paymentHash, }); console.log(`Invoice status: ${JSON.stringify(invoiceStatus)}`); // Check if the invoice has been settled (paid) if (!invoiceStatus.settled_at || invoiceStatus.settled_at <= 0) { console.log(`Invoice for bid payload ${activeBid.bidPayloadEvent.id} has not been paid, ignoring question`); throw new Error(`Invoice for bid payload ${activeBid.bidPayloadEvent.id} has not been paid, ignoring question`); } console.log(`Invoice for bid payload ${activeBid.bidPayloadEvent.id} has been paid, proceeding with answer`); // Create a Question object const question = { id: questionEvent.id, content: questionPayload.content, preimage, tags: questionPayload.tags, }; // Extract hashtags from 't' tags for the ask event const askHashtags = activeBid.askEvent.tags .filter(tag => tag[0] === 't') .map(tag => tag[1]); // Extract max_bid_sats from tag if present const askMaxBidSatsTag = activeBid.askEvent.tags.find(tag => tag[0] === 'max_bid_sats'); const askMaxBidSats = askMaxBidSatsTag ? parseInt(askMaxBidSatsTag[1], 10) : undefined; // Call the onQuestion callback to get an answer const answer = await this.onQuestionCallback({ id: activeBid.askEvent.id, pubkey: activeBid.askEvent.pubkey, content: activeBid.askEvent.content, created_at: activeBid.askEvent.created_at, tags: activeBid.askEvent.tags, hashtags: askHashtags, max_bid_sats: askMaxBidSats, }, activeBid.bid, question, activeBid.history); // Check if the expert wants to allow followup questions let invoice; let paymentHash; if (answer.followup_sats && answer.followup_sats > 0) { // Generate an invoice for the followup question const invoiceResponse = await this.nwcClient.makeInvoice({ amount: answer.followup_sats * 1000, // Convert sats to millisats description: `Followup question for ${activeBid.messageId}`, expiry: constants_1.FOLLOWUP_INVOICE_EXPIRY, }); invoice = invoiceResponse.invoice; paymentHash = invoiceResponse.payment_hash; console.log(`Generated followup invoice: ${invoice}`); } // Answer id const answerMessageId = (0, utils_1.bytesToHex)((0, utils_1.randomBytes)(32)); // Create the answer payload const answerPayload = { content: answer.content, tags: [ ...(answer.tags || []), ...(invoice ? [ ["invoice", invoice], ["message_id", answerMessageId], ] : []), ], }; // Encrypt the answer payload for the question pubkey const answerConversationKey = nostr_tools_1.nip44.getConversationKey(this.expertPrivkey, activeBid.sessionPubkey); const encryptedAnswerContent = nostr_tools_1.nip44.encrypt(JSON.stringify(answerPayload), answerConversationKey); // Generate a random keypair for the answer const answerPrivateKey = (0, nostr_tools_1.generateSecretKey)(); const answerPublicKey = (0, nostr_tools_1.getPublicKey)(answerPrivateKey); // Create the answer event const answerEvent = { kind: constants_1.NOSTR_EVENT_KIND_ANSWER, created_at: Math.floor(Date.now() / 1000), pubkey: answerPublicKey, content: encryptedAnswerContent, tags: [["e", questionMessageId]], }; // Sign the answer event const signedAnswerEvent = (0, nostr_tools_1.finalizeEvent)(answerEvent, answerPrivateKey); console.log(`Created answer event: ${JSON.stringify(signedAnswerEvent)}`); // Publish the answer event const publishPromises = this.pool.publish(this.questionRelays, signedAnswerEvent); const results = await Promise.allSettled(publishPromises); const successful = results.filter((result) => result.status === "fulfilled").length; const failed = results.filter((result) => result.status === "rejected").length; console.log(`Answer published to ${successful} relays, failed on ${failed} relays`); // Store the question-answer pair in history if (!activeBid.history) { activeBid.history = []; } activeBid.history.push({ question, answer, }); // If there's a followup invoice, set up a subscription for followup questions if (invoice && answer.followup_sats && answer.followup_sats > 0) { // Update payment hash with the followup data activeBid.paymentHash = paymentHash; // Subscribe for the followup this.setupFollowupSubscription(answerMessageId, activeBid); } } catch (error) { console.error(`Error handling question event: ${error}`); } } /** * Set up a subscription for followup questions * * @param answerMessageId The answer message_id tag * @param activeBid The active bid associated with this conversation */ setupFollowupSubscription(answerMessageId, activeBid) { // Create a filter for question events that tag our answer ID const filter = { kinds: [constants_1.NOSTR_EVENT_KIND_QUESTION], "#e": [answerMessageId], }; // Subscribe to question events const subscription = this.pool.subscribeMany(this.questionRelays, [filter], { onevent: async (questionEvent) => { try { console.log(`Received followup question event for answer ${answerMessageId}: ${JSON.stringify(questionEvent)}`); // We got the question subscription.close(); clearTimeout(timeoutId); this.activeBids.delete(answerMessageId); // Handle the question await this.handleQuestionEvent(questionEvent, activeBid); } catch (error) { console.error(`Error handling followup question event: ${error}`); } }, oneose: () => { console.log(`End of stored events for answer ${answerMessageId}, now listening for new events`); }, }); // Set a timeout for the followup const timeoutId = setTimeout(() => { console.log(`Timeout reached for followup to answer ${answerMessageId}, closing subscription`); subscription.close(); this.activeBids.delete(answerMessageId); }, constants_1.FOLLOWUP_INVOICE_EXPIRY * 2 * 1000); // Update the active bid with the new subscription and timeout activeBid.subscription = subscription; activeBid.timeoutId = timeoutId; activeBid.messageId = answerMessageId; // Store the updated active bid this.activeBids.set(answerMessageId, activeBid); } } exports.Expert = Expert;