libaskexperts
Version:
A TypeScript library to create experts based on NIP-174
731 lines (647 loc) • 22.4 kB
text/typescript
import {
SimplePool,
Event,
Filter,
UnsignedEvent,
finalizeEvent,
generateSecretKey,
getPublicKey,
nip44,
} from "nostr-tools";
import {
NOSTR_EVENT_KIND_ASK,
NOSTR_EVENT_KIND_BID,
NOSTR_EVENT_KIND_BID_PAYLOAD,
NOSTR_EVENT_KIND_QUESTION,
NOSTR_EVENT_KIND_ANSWER,
FOLLOWUP_INVOICE_EXPIRY,
} from "./constants";
import { nwc } from "@getalby/sdk";
import {
ExpertParams,
Ask,
Bid,
Question,
Answer,
ActiveBid,
QuestionAnswerPair,
} from "./types";
import { bytesToHex, hexToBytes, randomBytes } from "@noble/hashes/utils";
import { sha256 } from "@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.
*/
export class Expert {
// Private properties
private pool: SimplePool;
private nwcClient: nwc.NWCClient;
private expertPrivkey: Uint8Array;
private expertPubkey: string;
private askRelays: string[];
private questionRelays: string[];
private hashtags: string[];
private onAskCallback: (ask: Ask) => Promise<Bid | undefined>;
private onQuestionCallback: (
ask: Ask,
bid: Bid,
question: Question,
history?: QuestionAnswerPair[]
) => Promise<Answer>;
private bidTimeout: number;
private min_bid_sats?: number;
private activeBids: Map<string, ActiveBid>;
private subscriptions: any[];
/**
* 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,
}: ExpertParams) {
this.nwcClient = new nwc.NWCClient({
nostrWalletConnectUrl: nwcString,
});
this.expertPrivkey = expertPrivkey;
this.expertPubkey = 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 SimplePool();
this.activeBids = new Map();
this.subscriptions = [];
}
/**
* Start listening for asks
*/
public start(): void {
const currentTime = Math.floor(Date.now() / 1000) - 10; // Only get new events from now
// Create a filter for ask events with hashtags
const hashtagFilter: Filter = {
kinds: [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: Filter = {
kinds: [NOSTR_EVENT_KIND_ASK],
"#p": [this.expertPubkey],
since: currentTime,
};
// Common event handler for both subscriptions
const handleEvent = (event: 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
*/
public [Symbol.dispose](): void {
// 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
*/
private async handleAskEvent(askEvent: Event): Promise<void> {
try {
console.log(`Received ask event: ${JSON.stringify(askEvent)}`);
// Check if the event kind is correct
if (askEvent.kind !== NOSTR_EVENT_KIND_ASK) {
console.error(
`Unexpected event kind: ${askEvent.kind}, expected: ${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: 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 = generateSecretKey();
const bidPublicKey = 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: UnsignedEvent = {
kind: 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 = 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 = nip44.getConversationKey(
bidPrivateKey,
askEvent.pubkey
);
// Convert payload to string
const payloadString = JSON.stringify(signedBidPayload);
// Encrypt using the conversation key
encryptedContent = nip44.encrypt(payloadString, conversationKey);
} catch (error) {
console.error("Error encrypting bid payload:", error);
throw error;
}
// Create the bid event
const bidEvent: UnsignedEvent = {
kind: 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 = 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
*/
private setupQuestionSubscription(
askEvent: Event,
bidEvent: Event,
bidPayloadEvent: Event,
paymentHash: string,
bid: Bid
): void {
// Create a filter for question events that tag our bid payload ID
const filter: Filter = {
kinds: [NOSTR_EVENT_KIND_QUESTION],
"#e": [bidPayloadEvent.id],
};
// Subscribe to question events
const subscription = this.pool.subscribeMany(
this.questionRelays,
[filter],
{
onevent: async (questionEvent: Event) => {
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
*/
private async handleQuestionEvent(
questionEvent: Event,
activeBid: ActiveBid
): Promise<void> {
try {
// Check if the event kind is correct
if (questionEvent.kind !== NOSTR_EVENT_KIND_QUESTION) {
console.error(
`Unexpected event kind: ${questionEvent.kind}, expected: ${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 = nip44.getConversationKey(
this.expertPrivkey,
activeBid.sessionPubkey
);
// Decrypt the question content
const decryptedContent = 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: string[]) => 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: string[]) => 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 ||
bytesToHex(sha256(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: 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: string | undefined;
let paymentHash: string | undefined;
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: FOLLOWUP_INVOICE_EXPIRY,
});
invoice = invoiceResponse.invoice;
paymentHash = invoiceResponse.payment_hash;
console.log(`Generated followup invoice: ${invoice}`);
}
// Answer id
const answerMessageId = bytesToHex(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 = nip44.getConversationKey(
this.expertPrivkey,
activeBid.sessionPubkey
);
const encryptedAnswerContent = nip44.encrypt(
JSON.stringify(answerPayload),
answerConversationKey
);
// Generate a random keypair for the answer
const answerPrivateKey = generateSecretKey();
const answerPublicKey = getPublicKey(answerPrivateKey);
// Create the answer event
const answerEvent: UnsignedEvent = {
kind: NOSTR_EVENT_KIND_ANSWER,
created_at: Math.floor(Date.now() / 1000),
pubkey: answerPublicKey,
content: encryptedAnswerContent,
tags: [["e", questionMessageId]],
};
// Sign the answer event
const signedAnswerEvent = 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
*/
private setupFollowupSubscription(
answerMessageId: string,
activeBid: ActiveBid
): void {
// Create a filter for question events that tag our answer ID
const filter: Filter = {
kinds: [NOSTR_EVENT_KIND_QUESTION],
"#e": [answerMessageId],
};
// Subscribe to question events
const subscription = this.pool.subscribeMany(
this.questionRelays,
[filter],
{
onevent: async (questionEvent: Event) => {
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);
}, 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);
}
}