libaskexperts
Version:
A TypeScript library to create experts based on NIP-174
507 lines (506 loc) • 23.6 kB
JavaScript
;
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;