UNPKG

@nostr-dev-kit/messages

Version:

High-level messaging library for NDK supporting NIP-17 and NIP-EE

863 lines (857 loc) 26.2 kB
// src/cache-module.ts var messagesCacheModule = { namespace: "messages", version: 1, collections: { messages: { primaryKey: "id", indexes: ["conversationId", "timestamp", "sender", "recipient"], compoundIndexes: [ ["conversationId", "timestamp"], // For fetching conversation messages in order ["recipient", "read"] // For fetching unread messages for a user ], schema: { id: "string", content: "string", sender: "string", // pubkey recipient: "string?", // pubkey, optional for group messages timestamp: "number", protocol: "string", read: "boolean", rumor: "object?", conversationId: "string" } }, conversations: { primaryKey: "id", indexes: ["lastMessageAt"], compoundIndexes: [ ["participants"] // For finding conversations by participant ], schema: { id: "string", participants: "string[]", // array of pubkeys name: "string?", avatar: "string?", lastMessageAt: "number?", unreadCount: "number", protocol: "string", metadata: "object?" } }, // For future MLS support mlsGroups: { primaryKey: "id", indexes: ["createdAt"], schema: { id: "string", groupId: "string", epoch: "number", members: "string[]", createdAt: "number", updatedAt: "number", treeHash: "string", confirmedTranscriptHash: "string", interimTranscriptHash: "string", groupContext: "string" // serialized binary } }, // Track which relays are used for DMs dmRelays: { primaryKey: "pubkey", indexes: ["updatedAt"], schema: { pubkey: "string", relays: "string[]", updatedAt: "number" } } }, migrations: { 1: async (context) => { await context.createCollection("messages", messagesCacheModule.collections.messages); await context.createCollection("conversations", messagesCacheModule.collections.conversations); await context.createCollection("mlsGroups", messagesCacheModule.collections.mlsGroups); await context.createCollection("dmRelays", messagesCacheModule.collections.dmRelays); } // Future migrations would go here // 2: async (context) => { // // Add new field or index // await context.addIndex('messages', 'newField'); // } } }; // src/conversation.ts import { NDKUser } from "@nostr-dev-kit/ndk"; import { EventEmitter } from "eventemitter3"; var NDKConversation = class extends EventEmitter { id; participants; protocol; messages = []; storage; nip17; myPubkey; constructor(id, participants, protocol, storage, myPubkey, nip17) { super(); this.id = id; this.participants = participants; this.protocol = protocol; this.storage = storage; this.myPubkey = myPubkey; this.nip17 = nip17; } /** * Send a message in this conversation */ async sendMessage(content) { if (this.protocol === "nip17" && this.nip17) { const recipient = this.participants.find((p) => p.pubkey !== this.myPubkey); if (!recipient) { throw new Error("No recipient found in conversation"); } try { const wrappedEvent = await this.nip17.sendMessage(recipient, content); const message = { id: wrappedEvent.id || "", content, sender: new NDKUser({ pubkey: this.myPubkey }), recipient, timestamp: Math.floor(Date.now() / 1e3), protocol: "nip17", read: true, // Our own messages are always "read" conversationId: this.id }; await this.storage.saveMessage(message); this.messages.push(message); this.emit("message", message); return message; } catch (error) { const errorEvent = { type: "send-failed", message: `Failed to send message: ${error}`, error }; this.emit("error", errorEvent); throw error; } } else { throw new Error(`Protocol ${this.protocol} not supported yet`); } } /** * Get messages in this conversation */ async getMessages(limit) { if (this.messages.length === 0) { this.messages = await this.storage.getMessages(this.id, limit); } if (limit && this.messages.length > limit) { return this.messages.slice(-limit); } return [...this.messages]; } /** * Mark all messages as read */ async markAsRead() { const unreadMessages = this.messages.filter((m) => !m.read); if (unreadMessages.length > 0) { const messageIds = unreadMessages.map((m) => m.id); await this.storage.markAsRead(messageIds); unreadMessages.forEach((m) => m.read = true); } } /** * Get unread count */ getUnreadCount() { return this.messages.filter((m) => !m.read && m.sender.pubkey !== this.myPubkey).length; } /** * Get the other participant in a two-person conversation */ getOtherParticipant() { return this.participants.find((p) => p.pubkey !== this.myPubkey); } /** * Get the last message in the conversation */ getLastMessage() { return this.messages[this.messages.length - 1]; } /** * Handle an incoming message (called by NDKMessenger) */ async _handleIncomingMessage(message) { const exists = this.messages.find((m) => m.id === message.id); if (!exists) { this.messages.push(message); this.messages.sort((a, b) => a.timestamp - b.timestamp); await this.storage.saveMessage(message); this.emit("message", message); } } /** * Handle a state change (for future MLS support) */ _handleStateChange(event) { this.emit("state-change", event); } /** * Handle an error */ _handleError(error) { this.emit("error", error); } /** * Clean up resources */ destroy() { this.removeAllListeners(); this.messages = []; } }; // src/messenger.ts import { NDKKind as NDKKind2, NDKRelaySet as NDKRelaySet2, NDKUser as NDKUser4 } from "@nostr-dev-kit/ndk"; import { EventEmitter as EventEmitter2 } from "eventemitter3"; // src/protocols/nip17.ts import { giftUnwrap, giftWrap, NDKEvent, NDKKind, NDKRelaySet, NDKUser as NDKUser2 } from "@nostr-dev-kit/ndk"; import { getEventHash } from "nostr-tools/pure"; var NIP17Protocol = class { constructor(ndk, signer) { this.ndk = ndk; this.signer = signer; } /** * Send a NIP-17 direct message */ async sendMessage(recipient, content) { const sender = await this.signer.user(); const rumor = new NDKEvent(this.ndk); rumor.kind = NDKKind.PrivateDirectMessage; rumor.content = content; rumor.created_at = Math.floor(Date.now() / 1e3); rumor.pubkey = sender.pubkey; rumor.tags = [["p", recipient.pubkey]]; const wrappedEvent = await giftWrap(rumor, recipient, this.signer); const recipientRelays = await this.getRecipientDMRelays(recipient); const senderRelays = await this.getUserDMRelays(sender); const allRelays = [.../* @__PURE__ */ new Set([...recipientRelays, ...senderRelays])]; if (allRelays.length > 0) { const relaySet = NDKRelaySet.fromRelayUrls(allRelays, this.ndk); await wrappedEvent.publish(relaySet); } else { await wrappedEvent.publish(); } return wrappedEvent; } /** * Unwrap a received gift-wrapped message */ async unwrapMessage(wrappedEvent) { try { const rumor = await giftUnwrap(wrappedEvent, void 0, this.signer); if (rumor.kind !== NDKKind.PrivateDirectMessage) { return null; } return rumor.rawEvent(); } catch (error) { console.error("Failed to unwrap message:", error); return null; } } /** * Convert a rumor event to NDKMessage format */ rumorToMessage(rumor, myPubkey) { if (!rumor.id) { rumor.id = getEventHash(rumor); } const isOutgoing = rumor.pubkey === myPubkey; const otherPubkey = isOutgoing ? rumor.tags.find((t) => t[0] === "p")?.[1] || "" : rumor.pubkey; const conversationId = [myPubkey, otherPubkey].sort().join(":"); return { id: rumor.id, content: rumor.content || "", sender: new NDKUser2({ pubkey: rumor.pubkey }), recipient: isOutgoing ? new NDKUser2({ pubkey: otherPubkey }) : new NDKUser2({ pubkey: myPubkey }), timestamp: rumor.created_at || Math.floor(Date.now() / 1e3), protocol: "nip17", read: isOutgoing, // Outgoing messages are automatically "read" rumor, conversationId }; } /** * Get DM relays for a recipient (kind 10050) */ async getRecipientDMRelays(recipient) { try { const dmRelayList = await this.ndk.fetchEvent({ kinds: [NDKKind.DirectMessageReceiveRelayList], authors: [recipient.pubkey] }); if (dmRelayList) { const relays = dmRelayList.getMatchingTags("relay").map((t) => t[1]); if (relays.length > 0) { return relays; } } const relayList = await this.ndk.fetchEvent({ kinds: [10002], authors: [recipient.pubkey] }); if (relayList) { const relays = relayList.getMatchingTags("r").map((t) => t[1]); if (relays.length > 0) { return relays.slice(0, 3); } } return []; } catch (error) { console.error("Failed to fetch recipient relays:", error); return []; } } /** * Get DM relays for the current user (kind 10050) */ async getUserDMRelays(user) { try { const dmRelayList = await this.ndk.fetchEvent({ kinds: [NDKKind.DirectMessageReceiveRelayList], authors: [user.pubkey] }); if (dmRelayList) { const relays = dmRelayList.getMatchingTags("relay").map((t) => t[1]); if (relays.length > 0) { return relays; } } const relayList = await this.ndk.fetchEvent({ kinds: [10002], authors: [user.pubkey] }); if (relayList) { const relays = relayList.getMatchingTags("r").map((t) => t[1]); if (relays.length > 0) { return relays.slice(0, 3); } } return []; } catch (error) { console.error("Failed to fetch user relays:", error); return []; } } /** * Publish the user's DM relay list (kind 10050) */ async publishDMRelayList(relays) { const event = new NDKEvent(this.ndk); event.kind = NDKKind.DirectMessageReceiveRelayList; event.tags = relays.map((relay) => ["relay", relay]); event.created_at = Math.floor(Date.now() / 1e3); await event.sign(this.signer); await event.publish(); return event; } }; // src/storage/cache-module.ts import { NDKUser as NDKUser3 } from "@nostr-dev-kit/ndk"; var CacheModuleStorage = class { constructor(cache, myPubkey) { this.cache = cache; this.myPubkey = myPubkey; } messagesCollection; conversationsCollection; initialized = false; /** * Initialize the storage by registering the module and getting collections */ async ensureInitialized() { if (this.initialized) return; if (this.cache.registerModule) { await this.cache.registerModule(messagesCacheModule); } if (this.cache.getModuleCollection) { this.messagesCollection = await this.cache.getModuleCollection("messages", "messages"); this.conversationsCollection = await this.cache.getModuleCollection( "messages", "conversations" ); } this.initialized = true; } async saveMessage(message) { await this.ensureInitialized(); if (!this.messagesCollection) return; const cachedMessage = { id: message.id, content: message.content, sender: message.sender.pubkey, recipient: message.recipient?.pubkey, timestamp: message.timestamp, protocol: message.protocol, read: message.read, rumor: message.rumor, conversationId: message.conversationId }; await this.messagesCollection.save(cachedMessage); await this.updateConversationForMessage(message); } async updateConversationForMessage(message) { if (!this.conversationsCollection) return; const conversation = await this.conversationsCollection.get(message.conversationId); if (conversation) { conversation.lastMessageAt = message.timestamp; if (!message.read && message.sender.pubkey !== this.myPubkey) { conversation.unreadCount++; } await this.conversationsCollection.save(conversation); } else { const participants = [message.sender.pubkey]; if (message.recipient) { participants.push(message.recipient.pubkey); } const newConversation = { id: message.conversationId, participants: [...new Set(participants)], // Deduplicate lastMessageAt: message.timestamp, unreadCount: !message.read && message.sender.pubkey !== this.myPubkey ? 1 : 0, protocol: message.protocol }; await this.conversationsCollection.save(newConversation); } } async getMessages(conversationId, limit) { await this.ensureInitialized(); if (!this.messagesCollection) return []; const cachedMessages = await this.messagesCollection.findBy("conversationId", conversationId); cachedMessages.sort((a, b) => a.timestamp - b.timestamp); let messages = cachedMessages; if (limit && messages.length > limit) { messages = messages.slice(-limit); } return messages.map((cached) => ({ id: cached.id, content: cached.content, sender: new NDKUser3({ pubkey: cached.sender }), recipient: cached.recipient ? new NDKUser3({ pubkey: cached.recipient }) : void 0, timestamp: cached.timestamp, protocol: cached.protocol, read: cached.read, rumor: cached.rumor, conversationId: cached.conversationId })); } async markAsRead(messageIds) { await this.ensureInitialized(); if (!this.messagesCollection || !this.conversationsCollection) return; for (const id of messageIds) { const message = await this.messagesCollection.get(id); if (message && !message.read) { message.read = true; await this.messagesCollection.save(message); const conversation = await this.conversationsCollection.get(message.conversationId); if (conversation && conversation.unreadCount > 0) { conversation.unreadCount--; await this.conversationsCollection.save(conversation); } } } } async getConversations(userId) { await this.ensureInitialized(); if (!this.conversationsCollection) return []; const allConversations = await this.conversationsCollection.all(); const userConversations = allConversations.filter((conv) => conv.participants.includes(userId)); userConversations.sort((a, b) => (b.lastMessageAt || 0) - (a.lastMessageAt || 0)); return userConversations.map((conv) => ({ id: conv.id, participants: conv.participants, name: conv.name, avatar: conv.avatar, lastMessageAt: conv.lastMessageAt, unreadCount: conv.unreadCount })); } async saveConversation(conversation) { await this.ensureInitialized(); if (!this.conversationsCollection) return; const cachedConversation = { id: conversation.id, participants: conversation.participants, name: conversation.name, avatar: conversation.avatar, lastMessageAt: conversation.lastMessageAt, unreadCount: conversation.unreadCount, protocol: "nip17" // Default for now }; await this.conversationsCollection.save(cachedConversation); } async deleteMessage(messageId) { await this.ensureInitialized(); if (!this.messagesCollection) return; const message = await this.messagesCollection.get(messageId); if (message) { if (!message.read && this.conversationsCollection) { const conversation = await this.conversationsCollection.get(message.conversationId); if (conversation && conversation.unreadCount > 0) { conversation.unreadCount--; await this.conversationsCollection.save(conversation); } } await this.messagesCollection.delete(messageId); } } async clear() { await this.ensureInitialized(); if (this.messagesCollection) { await this.messagesCollection.clear(); } if (this.conversationsCollection) { await this.conversationsCollection.clear(); } } }; // src/storage/memory.ts var MemoryAdapter = class { messages = /* @__PURE__ */ new Map(); conversations = /* @__PURE__ */ new Map(); messagesByConversation = /* @__PURE__ */ new Map(); async saveMessage(message) { this.messages.set(message.id, message); if (!this.messagesByConversation.has(message.conversationId)) { this.messagesByConversation.set(message.conversationId, /* @__PURE__ */ new Set()); } this.messagesByConversation.get(message.conversationId).add(message.id); const conversation = this.conversations.get(message.conversationId); if (conversation) { conversation.lastMessageAt = message.timestamp; if (!message.read) { conversation.unreadCount++; } } } async getMessages(conversationId, limit) { const messageIds = this.messagesByConversation.get(conversationId); if (!messageIds) { return []; } const messages = []; for (const id of messageIds) { const message = this.messages.get(id); if (message) { messages.push(message); } } messages.sort((a, b) => a.timestamp - b.timestamp); if (limit && messages.length > limit) { return messages.slice(-limit); } return messages; } async markAsRead(messageIds) { for (const id of messageIds) { const message = this.messages.get(id); if (message) { const wasUnread = !message.read; message.read = true; if (wasUnread) { const conversation = this.conversations.get(message.conversationId); if (conversation && conversation.unreadCount > 0) { conversation.unreadCount--; } } } } } async getConversations(userId) { const userConversations = []; for (const conversation of this.conversations.values()) { if (conversation.participants.includes(userId)) { userConversations.push({ ...conversation }); } } userConversations.sort((a, b) => (b.lastMessageAt || 0) - (a.lastMessageAt || 0)); return userConversations; } async saveConversation(conversation) { this.conversations.set(conversation.id, { ...conversation }); } async deleteMessage(messageId) { const message = this.messages.get(messageId); if (message) { const messageIds = this.messagesByConversation.get(message.conversationId); if (messageIds) { messageIds.delete(messageId); } if (!message.read) { const conversation = this.conversations.get(message.conversationId); if (conversation && conversation.unreadCount > 0) { conversation.unreadCount--; } } this.messages.delete(messageId); } } async clear() { this.messages.clear(); this.conversations.clear(); this.messagesByConversation.clear(); } /** * Get a single message by ID (helper method) */ async getMessage(messageId) { return this.messages.get(messageId); } /** * Check if a message exists (helper method for deduplication) */ async hasMessage(messageId) { return this.messages.has(messageId); } }; // src/messenger.ts var NDKMessenger = class extends EventEmitter2 { ndk; storage; nip17; conversations = /* @__PURE__ */ new Map(); subscription; myPubkey; started = false; constructor(ndk, options) { super(); this.ndk = ndk; if (!ndk.signer) { throw new Error("NDK must have a signer configured"); } this.storage = options?.storage || new MemoryAdapter(); this.nip17 = new NIP17Protocol(ndk, ndk.signer); if (options?.autoStart) { this.start().catch(console.error); } } /** * Start the messenger (begin listening for messages) */ async start() { if (this.started) return; if (!this.ndk.signer) { throw new Error("NDK signer not configured"); } const user = await this.ndk.signer.user(); this.myPubkey = user.pubkey; if (this.storage instanceof MemoryAdapter && this.ndk.cacheAdapter?.registerModule) { this.storage = new CacheModuleStorage(this.ndk.cacheAdapter, this.myPubkey); } await this.loadConversations(); await this.subscribeToMessages(); this.started = true; } /** * Stop the messenger */ stop() { if (this.subscription) { this.subscription.stop(); this.subscription = void 0; } this.started = false; } /** * Send a direct message to a user */ async sendMessage(recipient, content) { if (!this.myPubkey) { await this.start(); } const conversation = await this.getConversation(recipient); return conversation.sendMessage(content); } /** * Get or create a conversation with a user */ async getConversation(user) { if (!this.myPubkey) { await this.start(); } const conversationId = [this.myPubkey, user.pubkey].sort().join(":"); let conversation = this.conversations.get(conversationId); if (conversation) { return conversation; } conversation = new NDKConversation( conversationId, [new NDKUser4({ pubkey: this.myPubkey }), user], "nip17", // Default to NIP-17 for now this.storage, this.myPubkey, this.nip17 ); const meta = { id: conversationId, participants: [this.myPubkey, user.pubkey], protocol: "nip17", unreadCount: 0 }; await this.storage.saveConversation(meta); this.conversations.set(conversationId, conversation); conversation.on("message", (message) => { this.emit("message", message); }); conversation.on("error", (error) => { this.emit("error", error); }); return conversation; } /** * Get all conversations */ async getConversations() { if (!this.myPubkey) { await this.start(); } await this.loadConversations(); return Array.from(this.conversations.values()); } /** * Publish DM relay list (kind 10050) */ async publishDMRelays(relays) { return this.nip17.publishDMRelayList(relays); } /** * Load conversations from storage */ async loadConversations() { if (!this.myPubkey) return; const metas = await this.storage.getConversations(this.myPubkey); for (const meta of metas) { if (this.conversations.has(meta.id)) { continue; } const participants = meta.participants.map((pubkey) => new NDKUser4({ pubkey })); const conversation = new NDKConversation( meta.id, participants, meta.protocol, this.storage, this.myPubkey, this.nip17 ); const messages = await this.storage.getMessages(meta.id); for (const message of messages) { message.sender = new NDKUser4({ pubkey: message.sender.pubkey }); if (message.recipient) { message.recipient = new NDKUser4({ pubkey: message.recipient.pubkey }); } await conversation._handleIncomingMessage(message); } this.conversations.set(meta.id, conversation); conversation.on("message", (message) => { this.emit("message", message); }); conversation.on("error", (error) => { this.emit("error", error); }); } } /** * Subscribe to incoming messages */ async subscribeToMessages() { if (!this.myPubkey || !this.ndk.signer) return; const user = await this.ndk.signer.user(); const userRelays = await this.nip17.getUserDMRelays(user); const subOptions = { closeOnEose: false }; if (userRelays.length > 0) { const relaySet = NDKRelaySet2.fromRelayUrls(userRelays, this.ndk); subOptions.relaySet = relaySet; } this.subscription = this.ndk.subscribe( { kinds: [NDKKind2.GiftWrap], "#p": [this.myPubkey] }, subOptions ); this.subscription.on("event", async (wrappedEvent) => { await this.handleIncomingMessage(wrappedEvent); }); this.subscription.on("eose", () => { console.log("Messages subscription active"); }); } /** * Handle an incoming gift-wrapped message */ async handleIncomingMessage(wrappedEvent) { if (!this.myPubkey || !this.ndk.signer) return; try { const rumor = await this.nip17.unwrapMessage(wrappedEvent); if (!rumor) return; const message = this.nip17.rumorToMessage(rumor, this.myPubkey); const otherPubkey = message.sender.pubkey === this.myPubkey ? message.recipient?.pubkey : message.sender.pubkey; if (!otherPubkey) return; const otherUser = new NDKUser4({ pubkey: otherPubkey }); const conversation = await this.getConversation(otherUser); await conversation._handleIncomingMessage(message); this.emit("message", message); if (conversation.getMessages.length === 1) { this.emit("conversation-created", conversation); } } catch (error) { const errorEvent = { type: "decryption-failed", message: `Failed to decrypt message: ${error}`, error }; this.emit("error", errorEvent); } } /** * Clean up resources */ destroy() { this.stop(); this.conversations.forEach((conv) => conv.destroy()); this.conversations.clear(); this.removeAllListeners(); } }; export { CacheModuleStorage, MemoryAdapter, NDKConversation, NDKMessenger, NIP17Protocol, messagesCacheModule }; //# sourceMappingURL=index.mjs.map