From cae06fc4fe9f61a38fa302c10f1897ca02423226 Mon Sep 17 00:00:00 2001 From: Anderson Juhasc Date: Thu, 24 Oct 2024 21:10:09 -0300 Subject: [PATCH] Implemented NIP-17 support (#449) --- nip17.test.ts | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++ nip17.ts | 112 ++++++++++++++++++++++++++++++++++++++++++++ nip28.ts | 8 +++- 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 nip17.test.ts create mode 100644 nip17.ts diff --git a/nip17.test.ts b/nip17.test.ts new file mode 100644 index 0000000..d9bda58 --- /dev/null +++ b/nip17.test.ts @@ -0,0 +1,126 @@ +import { test, expect } from 'bun:test' +import { getPublicKey } from './pure.ts' +import { decode } from './nip19.ts' +import { wrapEvent, wrapManyEvents, unwrapEvent, unwrapManyEvents, getWrappedEvents } from './nip17.ts' + +const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data + +const sk1 = 'f09ac9b695d0a4c6daa418fe95b977eea20f54d9545592bc36a4f9e14f3eb840' +const sk2 = '5393a825e5892d8e18d4a5ea61ced105e8bb2a106f42876be3a40522e0b13747' + +const recipients = [ + { publicKey: getPublicKey(sk1), relayUrl: 'wss://relay1.com' }, + { publicKey: getPublicKey(sk2) }, // No relay URL for this recipient +] +const message = 'Hello, this is a direct message!' +const conversationTitle = 'Private Group Conversation' // Optional +const replyTo = { eventId: 'previousEventId123' } // Optional, for replies + +const wrappedEvent = wrapEvent(senderPrivateKey, recipients[0], message, conversationTitle, replyTo) + +test('wrapEvent', () => { + const expected = { + content: '', + id: '', + created_at: 1728537932, + kind: 1059, + pubkey: '', + sig: '', + tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']], + [Symbol('verified')]: true, + } + + expect(wrappedEvent.kind).toEqual(expected.kind) + expect(wrappedEvent.tags).toEqual(expected.tags) +}) + +test('wrapManyEvents', () => { + const expected = [ + { + kind: 1059, + content: '', + created_at: 1729581521, + tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']], + pubkey: '', + id: '', + sig: '', + [Symbol('verified')]: true, + }, + { + kind: 1059, + content: '', + created_at: 1729594619, + tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']], + pubkey: '', + id: '', + sig: '', + [Symbol('verified')]: true, + }, + { + kind: 1059, + content: '', + created_at: 1729560014, + tags: [['p', '36f7288c84d85ca6aa189dc3581d63ce140b7eeef5ae759421c5b5a3627312db']], + pubkey: '', + id: '', + sig: '', + [Symbol('verified')]: true, + }, + ] + + const wrappedEvents = wrapManyEvents(senderPrivateKey, recipients, message, conversationTitle, replyTo) + + wrappedEvents.forEach((event, index) => { + expect(event.kind).toEqual(expected[index].kind) + expect(event.tags).toEqual(expected[index].tags) + }) +}) + +test('unwrapEvent', () => { + const expected = { + kind: 14, + content: 'Hello, this is a direct message!', + pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9', + tags: [ + ['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0', 'wss://relay1.com'], + ['e', 'previousEventId123', '', 'reply'], + ['subject', 'Private Group Conversation'], + ], + } + const result = unwrapEvent(wrappedEvent, sk1) + + expect(result.kind).toEqual(expected.kind) + expect(result.content).toEqual(expected.content) + expect(result.pubkey).toEqual(expected.pubkey) + expect(result.tags).toEqual(expected.tags) +}) + +test('getWrappedEvents and unwrapManyEvents', async () => { + const expected = [ + { + created_at: 1729721879, + content: 'Hello!', + tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']], + kind: 14, + pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22', + id: 'aee0a3e6487b2ac8c1851cc84f3ae0fca9af8a9bdad85c4ba5fdf45d3ee817c3', + }, + { + created_at: 1729722025, + content: 'How are you?', + tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']], + kind: 14, + pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22', + id: '212387ec5efee7d6eb20b747121e9fc1adb798de6c3185e932335bb1bcc61a77', + }, + ] + const relays = ['wss://relay.damus.io', 'wss://nos.lol'] + const privateKey = '582c3e7902c10c84d1cfe899a102e56bde628972d58d63011163ce0cdf4279b6' + const publicKey = '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa' + const wrappedEvents = await getWrappedEvents(publicKey, relays) + const unwrappedEvents = unwrapManyEvents(wrappedEvents, privateKey) + + unwrappedEvents.forEach((event, index) => { + expect(event).toEqual(expected[index]) + }) +}) diff --git a/nip17.ts b/nip17.ts new file mode 100644 index 0000000..117b3ff --- /dev/null +++ b/nip17.ts @@ -0,0 +1,112 @@ +import { Event } from './core.ts' +import { PrivateDirectMessage, GiftWrap } from './kinds.ts' +import { getPublicKey } from './pure' +import { SimplePool } from './pool' +import * as nip59 from './nip59' + +type Recipient = { + publicKey: string + relayUrl?: string +} + +type ReplyTo = { + eventId: string + relayUrl?: string +} + +function createEvent( + recipients: Recipient | Recipient[], + message: string, + conversationTitle?: string, + replyTo?: ReplyTo, +) { + const baseEvent = { + kind: PrivateDirectMessage, + tags: [] as (string | string[])[], + content: message, + } + + const recipientsArray = Array.isArray(recipients) ? recipients : [recipients] + + recipientsArray.forEach(({ publicKey, relayUrl }) => { + baseEvent.tags.push(relayUrl ? ['p', publicKey, relayUrl] : ['p', publicKey]) + }) + + if (replyTo) { + baseEvent.tags.push(['e', replyTo.eventId, replyTo.relayUrl || '', 'reply']) + } + + if (conversationTitle) { + baseEvent.tags.push(['subject', conversationTitle]) + } + + return baseEvent +} + +export function wrapEvent( + senderPrivateKey: Uint8Array, + recipient: Recipient, + message: string, + conversationTitle?: string, + replyTo?: ReplyTo, +) { + // Create the event using createEvent + const event = createEvent(recipient, message, conversationTitle, replyTo) + + // Wrap the created event using nip59 + return nip59.wrapEvent(event, senderPrivateKey, recipient.publicKey) +} + +export function wrapManyEvents( + senderPrivateKey: Uint8Array, + recipients: Recipient[], + message: string, + conversationTitle?: string, + replyTo?: ReplyTo, +) { + if (!recipients || recipients.length === 0) { + throw new Error('At least one recipient is required.') + } + + const senderPublicKey = getPublicKey(senderPrivateKey) + + // Initialize the wraps array with the sender's own wrapped event + const wrappeds = [wrapEvent(senderPrivateKey, { publicKey: senderPublicKey }, message, conversationTitle, replyTo)] + + // Wrap the event for each recipient + recipients.forEach(recipient => { + wrappeds.push(wrapEvent(senderPrivateKey, recipient, message, conversationTitle, replyTo)) + }) + + return wrappeds +} + +export function unwrapEvent(wrappedEvent: Event, recipientPrivateKey: Uint8Array) { + return nip59.unwrapEvent(wrappedEvent, recipientPrivateKey) +} + +export function unwrapManyEvents(wrappedEvents: Event[], recipientPrivateKey: Uint8Array) { + let unwrappedEvents = [] + + wrappedEvents.forEach(e => { + unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey)) + }) + + unwrappedEvents.sort((a, b) => a.created_at - b.created_at) + + return unwrappedEvents +} + +export async function getWrappedEvents(pubKey: string, relays: string[] = []): Promise { + const pool = new SimplePool() + + try { + const events: Event[] = await pool.querySync(relays, { kinds: [GiftWrap], '#p': [pubKey] }) + pool.close(relays) + + return events + } catch (error) { + console.error('Failed to:', error) + return undefined + } +} diff --git a/nip28.ts b/nip28.ts index 321a58e..5677bef 100644 --- a/nip28.ts +++ b/nip28.ts @@ -1,5 +1,11 @@ import { Event, finalizeEvent } from './pure.ts' -import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata as KindChannelMetadata, ChannelMuteUser } from './kinds.ts' +import { + ChannelCreation, + ChannelHideMessage, + ChannelMessage, + ChannelMetadata as KindChannelMetadata, + ChannelMuteUser, +} from './kinds.ts' export interface ChannelMetadata { name: string