From 6ec7ef32bde84206caed648fe84035d32668a7f9 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 10:39:17 -0500 Subject: [PATCH 01/14] Upgrade node bindings --- sdks/node-sdk/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sdks/node-sdk/package.json b/sdks/node-sdk/package.json index 49e2b3b9..c31e69f7 100644 --- a/sdks/node-sdk/package.json +++ b/sdks/node-sdk/package.json @@ -51,7 +51,7 @@ "@xmtp/content-type-group-updated": "^1.0.0", "@xmtp/content-type-primitives": "^1.0.2", "@xmtp/content-type-text": "^1.0.0", - "@xmtp/node-bindings": "^0.0.14", + "@xmtp/node-bindings": "^0.0.16", "@xmtp/proto": "^3.62.1" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 8eb78cdd..abb8974d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4832,10 +4832,10 @@ __metadata: languageName: unknown linkType: soft -"@xmtp/node-bindings@npm:^0.0.14": - version: 0.0.14 - resolution: "@xmtp/node-bindings@npm:0.0.14" - checksum: 10/1c2186418fc3c3066f3a63f91b547b23a0165b2b7ca162e9032a19e575fe9f1fc4922ca4ee4af332d898d98c03ecdec7d954aec3dbed065f8893cf06689a7475 +"@xmtp/node-bindings@npm:^0.0.16": + version: 0.0.16 + resolution: "@xmtp/node-bindings@npm:0.0.16" + checksum: 10/d6bf406c0e2802061a1d5808dac17fc7c0bcf01b1d3917805832e38fa5a69ccfdb0b0283fe47da2e9c302214cf693271207c466d061f8a7096b86edf7fa12c8e languageName: node linkType: hard @@ -4850,7 +4850,7 @@ __metadata: "@xmtp/content-type-group-updated": "npm:^1.0.0" "@xmtp/content-type-primitives": "npm:^1.0.2" "@xmtp/content-type-text": "npm:^1.0.0" - "@xmtp/node-bindings": "npm:^0.0.14" + "@xmtp/node-bindings": "npm:^0.0.16" "@xmtp/proto": "npm:^3.62.1" "@xmtp/xmtp-js": "workspace:^" fast-glob: "npm:^3.3.2" From d906e2aef2444f688580215d3da047a59ac6a8fd Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 10:59:18 -0500 Subject: [PATCH 02/14] Expose stream error in async iterator --- sdks/node-sdk/src/AsyncStream.ts | 35 ++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/sdks/node-sdk/src/AsyncStream.ts b/sdks/node-sdk/src/AsyncStream.ts index 1e8121d3..4065d8b4 100644 --- a/sdks/node-sdk/src/AsyncStream.ts +++ b/sdks/node-sdk/src/AsyncStream.ts @@ -1,6 +1,7 @@ type ResolveValue = { value: T | undefined; done: boolean; + error: Error | null; }; type ResolveNext = (resolveValue: ResolveValue) => void; @@ -24,10 +25,10 @@ export class AsyncStream { return this.#done; } - callback: StreamCallback = (err, value) => { - if (err) { - console.error("stream error", err); - this.stop(); + callback: StreamCallback = (error, value) => { + if (error) { + console.error("stream error", error); + this.stop(error); return; } @@ -36,26 +37,42 @@ export class AsyncStream { } if (this.#resolveNext) { - this.#resolveNext({ value, done: false }); + this.#resolveNext({ + done: false, + error: null, + value, + }); this.#resolveNext = null; } else { this.#queue.push(value); } }; - stop = () => { + stop = (error?: Error) => { this.#done = true; if (this.#resolveNext) { - this.#resolveNext({ value: undefined, done: true }); + this.#resolveNext({ + done: true, + error: error ?? null, + value: undefined, + }); } this.stopCallback?.(); }; next = (): Promise> => { if (this.#queue.length > 0) { - return Promise.resolve({ value: this.#queue.shift(), done: false }); + return Promise.resolve({ + done: false, + error: null, + value: this.#queue.shift(), + }); } else if (this.#done) { - return Promise.resolve({ value: undefined, done: true }); + return Promise.resolve({ + done: true, + error: null, + value: undefined, + }); } else { return new Promise((resolve) => { this.#resolveNext = resolve; From 7e143c009ec922b278ef11bb553b146903c0e160 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 11:12:05 -0500 Subject: [PATCH 03/14] Add new methods --- sdks/node-sdk/src/Conversation.ts | 4 ++ sdks/node-sdk/src/Conversations.ts | 104 ++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/sdks/node-sdk/src/Conversation.ts b/sdks/node-sdk/src/Conversation.ts index a7466d1d..96adffa8 100644 --- a/sdks/node-sdk/src/Conversation.ts +++ b/sdks/node-sdk/src/Conversation.ts @@ -197,4 +197,8 @@ export class Conversation { .filter((message) => message.content !== undefined) ); } + + get dmPeerInboxId() { + return this.#group.dmPeerInboxId(); + } } diff --git a/sdks/node-sdk/src/Conversations.ts b/sdks/node-sdk/src/Conversations.ts index 16d33561..71b59ddf 100644 --- a/sdks/node-sdk/src/Conversations.ts +++ b/sdks/node-sdk/src/Conversations.ts @@ -1,7 +1,7 @@ import type { NapiConversations, NapiCreateGroupOptions, - NapiListMessagesOptions, + NapiListConversationsOptions, } from "@xmtp/node-bindings"; import { AsyncStream, type StreamCallback } from "@/AsyncStream"; import type { Client } from "@/Client"; @@ -27,6 +27,16 @@ export class Conversations { } } + getDmByInboxId(inboxId: string) { + try { + // findDmByTargetInboxId will throw if group is not found + const group = this.#conversations.findDmByTargetInboxId(inboxId); + return new Conversation(this.#client, group); + } catch { + return null; + } + } + getMessageById(id: string) { try { // findMessageById will throw if message is not found @@ -49,7 +59,13 @@ export class Conversations { return conversation; } - async list(options?: NapiListMessagesOptions) { + async newDm(accountAddress: string) { + const group = await this.#conversations.createDm(accountAddress); + const conversation = new Conversation(this.#client, group); + return conversation; + } + + async list(options?: NapiListConversationsOptions) { const groups = await this.#conversations.list(options); return groups.map((group) => { const conversation = new Conversation(this.#client, group); @@ -57,6 +73,26 @@ export class Conversations { }); } + async listGroups( + options?: Omit, + ) { + const groups = await this.#conversations.listGroups(options); + return groups.map((group) => { + const conversation = new Conversation(this.#client, group); + return conversation; + }); + } + + async listDms( + options?: Omit, + ) { + const groups = await this.#conversations.listDms(options); + return groups.map((group) => { + const conversation = new Conversation(this.#client, group); + return conversation; + }); + } + async sync() { return this.#conversations.sync(); } @@ -75,6 +111,34 @@ export class Conversations { return asyncStream; } + streamGroups(callback?: StreamCallback) { + const asyncStream = new AsyncStream(); + + const stream = this.#conversations.streamGroups((err, group) => { + const conversation = new Conversation(this.#client, group); + asyncStream.callback(err, conversation); + callback?.(err, conversation); + }); + + asyncStream.stopCallback = stream.end.bind(stream); + + return asyncStream; + } + + streamDms(callback?: StreamCallback) { + const asyncStream = new AsyncStream(); + + const stream = this.#conversations.streamDms((err, group) => { + const conversation = new Conversation(this.#client, group); + asyncStream.callback(err, conversation); + callback?.(err, conversation); + }); + + asyncStream.stopCallback = stream.end.bind(stream); + + return asyncStream; + } + async streamAllMessages(callback?: StreamCallback) { // sync conversations first await this.sync(); @@ -91,4 +155,40 @@ export class Conversations { return asyncStream; } + + async streamAllGroupMessages(callback?: StreamCallback) { + // sync conversations first + await this.sync(); + + const asyncStream = new AsyncStream(); + + const stream = this.#conversations.streamAllGroupMessages( + (err, message) => { + const decodedMessage = new DecodedMessage(this.#client, message); + asyncStream.callback(err, decodedMessage); + callback?.(err, decodedMessage); + }, + ); + + asyncStream.stopCallback = stream.end.bind(stream); + + return asyncStream; + } + + async streamAllDmMessages(callback?: StreamCallback) { + // sync conversations first + await this.sync(); + + const asyncStream = new AsyncStream(); + + const stream = this.#conversations.streamAllDmMessages((err, message) => { + const decodedMessage = new DecodedMessage(this.#client, message); + asyncStream.callback(err, decodedMessage); + callback?.(err, decodedMessage); + }); + + asyncStream.stopCallback = stream.end.bind(stream); + + return asyncStream; + } } From eb2032b69ea2231d098ad2d58f1279e4992488b7 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 11:23:21 -0500 Subject: [PATCH 04/14] Add signature methods --- sdks/node-sdk/src/Client.ts | 53 ++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/sdks/node-sdk/src/Client.ts b/sdks/node-sdk/src/Client.ts index 5a645c28..35ed17bd 100644 --- a/sdks/node-sdk/src/Client.ts +++ b/sdks/node-sdk/src/Client.ts @@ -15,9 +15,9 @@ import { generateInboxId, getInboxIdForAddress, NapiGroupMessageKind, - NapiSignatureRequestType, type NapiClient, type NapiMessage, + type NapiSignatureRequestType, } from "@xmtp/node-bindings"; import { Conversations } from "@/Conversations"; @@ -135,7 +135,7 @@ export class Client { return this.#innerClient.isRegistered(); } - async signatureText() { + async createInboxSignatureText() { try { const signatureText = await this.#innerClient.createInboxSignatureText(); return signatureText; @@ -144,15 +144,54 @@ export class Client { } } + async addWalletSignatureText( + existingAccountAddress: string, + newAccountAddress: string, + ) { + try { + const signatureText = await this.#innerClient.addWalletSignatureText( + existingAccountAddress, + newAccountAddress, + ); + return signatureText; + } catch { + return null; + } + } + + async revokeWalletSignatureText(accountAddress: string) { + try { + const signatureText = + await this.#innerClient.revokeWalletSignatureText(accountAddress); + return signatureText; + } catch { + return null; + } + } + + async revokeInstallationsSignatureText() { + try { + const signatureText = + await this.#innerClient.revokeInstallationsSignatureText(); + return signatureText; + } catch { + return null; + } + } + async canMessage(accountAddresses: string[]) { return this.#innerClient.canMessage(accountAddresses); } - addSignature(signatureBytes: Uint8Array) { - void this.#innerClient.addSignature( - NapiSignatureRequestType.CreateInbox, - signatureBytes, - ); + addSignature( + signatureType: NapiSignatureRequestType, + signatureBytes: Uint8Array, + ) { + void this.#innerClient.addSignature(signatureType, signatureBytes); + } + + async applySignatures() { + return this.#innerClient.applySignatureRequests(); } async registerIdentity() { From 7090a36183e8d9970b62a36479245e435d919d16 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 11:42:35 -0500 Subject: [PATCH 05/14] Add consent state methods --- sdks/node-sdk/src/Client.ts | 10 ++++++++++ sdks/node-sdk/src/Conversation.ts | 14 +++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/sdks/node-sdk/src/Client.ts b/sdks/node-sdk/src/Client.ts index 35ed17bd..a7898063 100644 --- a/sdks/node-sdk/src/Client.ts +++ b/sdks/node-sdk/src/Client.ts @@ -16,6 +16,8 @@ import { getInboxIdForAddress, NapiGroupMessageKind, type NapiClient, + type NapiConsent, + type NapiConsentEntityType, type NapiMessage, type NapiSignatureRequestType, } from "@xmtp/node-bindings"; @@ -258,4 +260,12 @@ export class Client { inboxIds, ); } + + async setConsentStates(consentStates: NapiConsent[]) { + return this.#innerClient.setConsentStates(consentStates); + } + + async getConsentState(entityType: NapiConsentEntityType, entity: string) { + return this.#innerClient.getConsentState(entityType, entity); + } } diff --git a/sdks/node-sdk/src/Conversation.ts b/sdks/node-sdk/src/Conversation.ts index 96adffa8..f33da7d8 100644 --- a/sdks/node-sdk/src/Conversation.ts +++ b/sdks/node-sdk/src/Conversation.ts @@ -1,6 +1,10 @@ import type { ContentTypeId } from "@xmtp/content-type-primitives"; import { ContentTypeText } from "@xmtp/content-type-text"; -import type { NapiGroup, NapiListMessagesOptions } from "@xmtp/node-bindings"; +import type { + NapiConsentState, + NapiGroup, + NapiListMessagesOptions, +} from "@xmtp/node-bindings"; import { AsyncStream, type StreamCallback } from "@/AsyncStream"; import type { Client } from "@/Client"; import { DecodedMessage } from "@/DecodedMessage"; @@ -198,6 +202,14 @@ export class Conversation { ); } + get consentState() { + return this.#group.consentState(); + } + + updateConsentState(consentState: NapiConsentState) { + this.#group.updateConsentState(consentState); + } + get dmPeerInboxId() { return this.#group.dmPeerInboxId(); } From 9f06e686e1f6c7b06ea011ec34e64d9a669d17cf Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 11:43:17 -0500 Subject: [PATCH 06/14] Add getLatestInboxState to client --- sdks/node-sdk/src/Client.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdks/node-sdk/src/Client.ts b/sdks/node-sdk/src/Client.ts index a7898063..dde05fda 100644 --- a/sdks/node-sdk/src/Client.ts +++ b/sdks/node-sdk/src/Client.ts @@ -251,6 +251,10 @@ export class Client { return this.#innerClient.inboxState(refreshFromNetwork); } + async getLatestInboxState(inboxId: string) { + return this.#innerClient.getLatestInboxState(inboxId); + } + async inboxStateFromInboxIds( inboxIds: string[], refreshFromNetwork?: boolean, From 048cd1f2e305e4c06c741b1136974871eb1e4b86 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 12:06:08 -0500 Subject: [PATCH 07/14] Add client tests --- sdks/node-sdk/package.json | 1 + sdks/node-sdk/test/Client.test.ts | 200 +++++++++++++++++++++++++++++- sdks/node-sdk/test/helpers.ts | 9 +- yarn.lock | 10 ++ 4 files changed, 211 insertions(+), 9 deletions(-) diff --git a/sdks/node-sdk/package.json b/sdks/node-sdk/package.json index c31e69f7..7c18ab30 100644 --- a/sdks/node-sdk/package.json +++ b/sdks/node-sdk/package.json @@ -67,6 +67,7 @@ "rollup-plugin-filesize": "^10.0.0", "rollup-plugin-tsconfig-paths": "^1.5.2", "typescript": "^5.6.3", + "uuid": "^11.0.2", "viem": "^2.13.6", "vite": "^5.4.9", "vite-tsconfig-paths": "^5.0.1", diff --git a/sdks/node-sdk/test/Client.test.ts b/sdks/node-sdk/test/Client.test.ts index 9736dded..aadb98b0 100644 --- a/sdks/node-sdk/test/Client.test.ts +++ b/sdks/node-sdk/test/Client.test.ts @@ -1,3 +1,10 @@ +import { + NapiConsentEntityType, + NapiConsentState, + NapiSignatureRequestType, +} from "@xmtp/node-bindings"; +import { v4 } from "uuid"; +import { toBytes } from "viem"; import { describe, expect, it } from "vitest"; import { createClient, @@ -11,7 +18,7 @@ describe("Client", () => { const client = await createClient(user); expect(client.accountAddress).toBe(user.account.address); expect(client.isRegistered).toBe(false); - expect(await client.signatureText()).not.toBe(null); + expect(await client.createInboxSignatureText()).not.toBe(null); expect(client.inboxId).toBeDefined(); expect(client.installationId).toBeDefined(); }); @@ -21,12 +28,19 @@ describe("Client", () => { await createRegisteredClient(user); const client2 = await createRegisteredClient(user); expect(client2.isRegistered).toBe(true); - expect(await client2.signatureText()).toBe(null); + expect(await client2.createInboxSignatureText()).toBe(null); expect(await client2.canMessage([user.account.address])).toEqual({ [user.account.address.toLowerCase()]: true, }); }); + it("should be able to message registered identity", async () => { + const user = createUser(); + const client = await createRegisteredClient(user); + const canMessage = await client.canMessage([user.account.address]); + expect(canMessage).toEqual({ [user.account.address.toLowerCase()]: true }); + }); + it("should get an inbox ID from an address", async () => { const user = createUser(); const client = await createRegisteredClient(user); @@ -46,22 +60,196 @@ describe("Client", () => { user.account.address.toLowerCase(), ]); expect(inboxState.recoveryAddress).toBe(user.account.address.toLowerCase()); + + const user2 = createUser(); + const client2 = await createClient(user2); + const inboxState2 = await client2.getLatestInboxState(client.inboxId); + expect(inboxState2.inboxId).toBe(client.inboxId); + expect(inboxState.installations.length).toBe(1); + expect(inboxState.installations[0].id).toBe(client.installationId); + expect(inboxState2.accountAddresses).toEqual([ + user.account.address.toLowerCase(), + ]); + expect(inboxState2.recoveryAddress).toBe( + user.account.address.toLowerCase(), + ); }); it("should get inbox states from inbox IDs", async () => { const user = createUser(); + const user2 = createUser(); const client = await createRegisteredClient(user); + const client2 = await createRegisteredClient(user2); const inboxStates = await client.inboxStateFromInboxIds([client.inboxId]); expect(inboxStates.length).toBe(1); expect(inboxStates[0].inboxId).toBe(client.inboxId); - expect(inboxStates[0].installations.map((install) => install.id)).toEqual([ - client.installationId, - ]); expect(inboxStates[0].accountAddresses).toEqual([ user.account.address.toLowerCase(), ]); - expect(inboxStates[0].recoveryAddress).toBe( + + const inboxStates2 = await client2.inboxStateFromInboxIds( + [client2.inboxId], + true, + ); + expect(inboxStates2.length).toBe(1); + expect(inboxStates2[0].inboxId).toBe(client2.inboxId); + expect(inboxStates2[0].accountAddresses).toEqual([ + user2.account.address.toLowerCase(), + ]); + }); + + it("should add a wallet association to the client", async () => { + const user = createUser(); + const user2 = createUser(); + const client = await createRegisteredClient(user); + const signatureText = await client.addWalletSignatureText( + user.account.address, + user2.account.address, + ); + expect(signatureText).toBeDefined(); + + // sign message + const signature = await user.wallet.signMessage({ + message: signatureText!, + }); + const signature2 = await user2.wallet.signMessage({ + message: signatureText!, + }); + + client.addSignature(NapiSignatureRequestType.AddWallet, toBytes(signature)); + client.addSignature( + NapiSignatureRequestType.AddWallet, + toBytes(signature2), + ); + await client.applySignatures(); + const inboxState = await client.inboxState(); + expect(inboxState.accountAddresses.length).toEqual(2); + expect(inboxState.accountAddresses).toContain( user.account.address.toLowerCase(), ); + expect(inboxState.accountAddresses).toContain( + user2.account.address.toLowerCase(), + ); + }); + + it("should revoke a wallet association from the client", async () => { + const user = createUser(); + const user2 = createUser(); + const client = await createRegisteredClient(user); + const signatureText = await client.addWalletSignatureText( + user.account.address, + user2.account.address, + ); + expect(signatureText).toBeDefined(); + + // sign message + const signature = await user.wallet.signMessage({ + message: signatureText!, + }); + const signature2 = await user2.wallet.signMessage({ + message: signatureText!, + }); + + client.addSignature(NapiSignatureRequestType.AddWallet, toBytes(signature)); + client.addSignature( + NapiSignatureRequestType.AddWallet, + toBytes(signature2), + ); + await client.applySignatures(); + + const signatureText2 = await client.revokeWalletSignatureText( + user2.account.address, + ); + expect(signatureText2).toBeDefined(); + + // sign message + const signature3 = await user.wallet.signMessage({ + message: signatureText2!, + }); + + client.addSignature( + NapiSignatureRequestType.RevokeWallet, + toBytes(signature3), + ); + await client.applySignatures(); + const inboxState = await client.inboxState(); + expect(inboxState.accountAddresses).toEqual([ + user.account.address.toLowerCase(), + ]); + }); + + it("should revoke all installations", async () => { + const user = createUser(); + + const client = await createRegisteredClient(user); + user.uuid = v4(); + const client2 = await createRegisteredClient(user); + user.uuid = v4(); + const client3 = await createRegisteredClient(user); + + const inboxState = await client3.inboxState(true); + expect(inboxState.installations.length).toBe(3); + + const installationIds = inboxState.installations.map((i) => i.id); + expect(installationIds).toContain(client.installationId); + expect(installationIds).toContain(client2.installationId); + expect(installationIds).toContain(client3.installationId); + + const signatureText = await client3.revokeInstallationsSignatureText(); + expect(signatureText).toBeDefined(); + + // sign message + const signature = await user.wallet.signMessage({ + message: signatureText!, + }); + + client3.addSignature( + NapiSignatureRequestType.RevokeInstallations, + toBytes(signature), + ); + await client3.applySignatures(); + const inboxState2 = await client3.inboxState(true); + + expect(inboxState2.installations.length).toBe(1); + expect(inboxState2.installations[0].id).toBe(client3.installationId); + }); + + it("should manage consent states", async () => { + const user1 = createUser(); + const user2 = createUser(); + const client1 = await createRegisteredClient(user1); + const client2 = await createRegisteredClient(user2); + const group = await client1.conversations.newConversation([ + user2.account.address, + ]); + + await client2.conversations.sync(); + const group2 = client2.conversations.getConversationById(group.id); + + expect(group2).not.toBeNull(); + + expect( + await client2.getConsentState(NapiConsentEntityType.GroupId, group2!.id), + ).toBe(NapiConsentState.Unknown); + + await client2.setConsentStates([ + { + entityType: NapiConsentEntityType.GroupId, + entity: group2!.id, + state: NapiConsentState.Allowed, + }, + ]); + + expect( + await client2.getConsentState(NapiConsentEntityType.GroupId, group2!.id), + ).toBe(NapiConsentState.Allowed); + + expect(group2!.consentState).toBe(NapiConsentState.Allowed); + + group2!.updateConsentState(NapiConsentState.Denied); + + expect( + await client2.getConsentState(NapiConsentEntityType.GroupId, group2!.id), + ).toBe(NapiConsentState.Denied); }); }); diff --git a/sdks/node-sdk/test/helpers.ts b/sdks/node-sdk/test/helpers.ts index 592500d4..fb55a198 100644 --- a/sdks/node-sdk/test/helpers.ts +++ b/sdks/node-sdk/test/helpers.ts @@ -5,6 +5,8 @@ import { type ContentCodec, type EncodedContent, } from "@xmtp/content-type-primitives"; +import { NapiSignatureRequestType } from "@xmtp/node-bindings"; +import { v4 } from "uuid"; import { createWalletClient, http, toBytes } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { sepolia } from "viem/chains"; @@ -23,13 +25,14 @@ export const createUser = () => { chain: sepolia, transport: http(), }), + uuid: v4(), }; }; export type User = ReturnType; export const getSignature = async (client: Client, user: User) => { - const signatureText = await client.signatureText(); + const signatureText = await client.createInboxSignatureText(); if (signatureText) { const signature = await user.wallet.signMessage({ message: signatureText, @@ -46,7 +49,7 @@ export const createClient = async (user: User, options?: ClientOptions) => { }; return Client.create(user.account.address, { ...opts, - dbPath: join(__dirname, `./test-${user.account.address}.db3`), + dbPath: join(__dirname, `./test-${user.uuid}.db3`), }); }; @@ -58,7 +61,7 @@ export const createRegisteredClient = async ( if (!client.isRegistered) { const signature = await getSignature(client, user); if (signature) { - client.addSignature(signature); + client.addSignature(NapiSignatureRequestType.CreateInbox, signature); } await client.registerIdentity(); } diff --git a/yarn.lock b/yarn.lock index abb8974d..cbd2d21b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4860,6 +4860,7 @@ __metadata: rollup-plugin-filesize: "npm:^10.0.0" rollup-plugin-tsconfig-paths: "npm:^1.5.2" typescript: "npm:^5.6.3" + uuid: "npm:^11.0.2" viem: "npm:^2.13.6" vite: "npm:^5.4.9" vite-tsconfig-paths: "npm:^5.0.1" @@ -12565,6 +12566,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^11.0.2": + version: 11.0.2 + resolution: "uuid@npm:11.0.2" + bin: + uuid: dist/esm/bin/uuid + checksum: 10/b98082f398fa2ece8cacc2264402f739256ca70def4bb82e3a14ec70777d189c01ce1054764c3b59b8fc098b62b135a15d1b24914712904c988822e2ac9b4f44 + languageName: node + linkType: hard + "uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" From cef3609e2dada6eba203064406260ed6b972448d Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 12:36:26 -0500 Subject: [PATCH 08/14] Add conversation test --- sdks/node-sdk/test/Conversation.test.ts | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/sdks/node-sdk/test/Conversation.test.ts b/sdks/node-sdk/test/Conversation.test.ts index 82486e10..93843eeb 100644 --- a/sdks/node-sdk/test/Conversation.test.ts +++ b/sdks/node-sdk/test/Conversation.test.ts @@ -1,3 +1,4 @@ +import { NapiConsentState } from "@xmtp/node-bindings"; import { describe, expect, it } from "vitest"; import { ContentTypeTest, @@ -390,4 +391,33 @@ describe("Conversation", () => { expect(conversation.superAdmins.length).toBe(1); expect(conversation.superAdmins).toContain(client1.inboxId); }); + + it("should manage group consent state", async () => { + const user1 = createUser(); + const user2 = createUser(); + const user3 = createUser(); + const client1 = await createRegisteredClient(user1); + const client2 = await createRegisteredClient(user2); + const client3 = await createRegisteredClient(user3); + const group = await client1.conversations.newConversation([ + user2.account.address, + ]); + expect(group).toBeDefined(); + const dmGroup = await client1.conversations.newDm(user3.account.address); + expect(dmGroup).toBeDefined(); + + await client2.conversations.sync(); + const group2 = client2.conversations.getConversationById(group.id); + expect(group2).toBeDefined(); + expect(group2!.consentState).toBe(NapiConsentState.Unknown); + await group2!.send("gm!"); + expect(group2!.consentState).toBe(NapiConsentState.Allowed); + + await client3.conversations.sync(); + const dmGroup2 = client3.conversations.getConversationById(dmGroup.id); + expect(dmGroup2).toBeDefined(); + expect(dmGroup2!.consentState).toBe(NapiConsentState.Unknown); + await dmGroup2!.send("gm!"); + expect(dmGroup2!.consentState).toBe(NapiConsentState.Allowed); + }); }); From af882152137f412a31efdb34ca88b9fe6123e512 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 13:48:39 -0500 Subject: [PATCH 09/14] Add conversations tests --- sdks/node-sdk/test/Conversations.test.ts | 232 ++++++++++++++++++++++- 1 file changed, 229 insertions(+), 3 deletions(-) diff --git a/sdks/node-sdk/test/Conversations.test.ts b/sdks/node-sdk/test/Conversations.test.ts index 29bd5803..6f027add 100644 --- a/sdks/node-sdk/test/Conversations.test.ts +++ b/sdks/node-sdk/test/Conversations.test.ts @@ -1,13 +1,20 @@ -import { NapiGroupPermissionsOptions } from "@xmtp/node-bindings"; +import { + NapiConsentState, + NapiGroupPermissionsOptions, +} from "@xmtp/node-bindings"; import { describe, expect, it } from "vitest"; +import { AsyncStream } from "@/AsyncStream"; +import type { Conversation } from "@/Conversation"; import { createRegisteredClient, createUser } from "@test/helpers"; describe("Conversations", () => { it("should not have initial conversations", async () => { const user = createUser(); const client = await createRegisteredClient(user); - const conversations = client.conversations.list(); - expect((await conversations).length).toBe(0); + + expect((await client.conversations.list()).length).toBe(0); + expect((await client.conversations.listDms()).length).toBe(0); + expect((await client.conversations.listGroups()).length).toBe(0); }); it("should create a new conversation", async () => { @@ -64,6 +71,74 @@ describe("Conversations", () => { const conversations2 = await client2.conversations.list(); expect(conversations2.length).toBe(1); expect(conversations2[0].id).toBe(conversation.id); + + expect((await client2.conversations.listDms()).length).toBe(0); + expect((await client2.conversations.listGroups()).length).toBe(1); + }); + + it("should create a dm group", async () => { + const user1 = createUser(); + const user2 = createUser(); + const client1 = await createRegisteredClient(user1); + const client2 = await createRegisteredClient(user2); + const group = await client1.conversations.newDm(user2.account.address); + expect(group).toBeDefined(); + expect(group.id).toBeDefined(); + expect(group.createdAtNs).toBeTypeOf("number"); + expect(group.isActive).toBe(true); + expect(group.name).toBe(""); + expect(group.permissions.policyType).toBe( + NapiGroupPermissionsOptions.CustomPolicy, + ); + expect(group.permissions.policySet).toEqual({ + addAdminPolicy: 1, + addMemberPolicy: 1, + removeAdminPolicy: 1, + removeMemberPolicy: 1, + updateGroupDescriptionPolicy: 0, + updateGroupImageUrlSquarePolicy: 0, + updateGroupNamePolicy: 0, + updateGroupPinnedFrameUrlPolicy: 0, + }); + expect(group.addedByInboxId).toBe(client1.inboxId); + expect(group.messages().length).toBe(1); + const members = await group.members(); + expect(members.length).toBe(2); + const memberInboxIds = members.map((member) => member.inboxId); + expect(memberInboxIds).toContain(client1.inboxId); + expect(memberInboxIds).toContain(client2.inboxId); + expect(group.metadata.conversationType).toBe("dm"); + expect(group.metadata.creatorInboxId).toBe(client1.inboxId); + + expect(group.consentState).toBe(NapiConsentState.Allowed); + + const group1 = await client1.conversations.list(); + expect(group1.length).toBe(1); + expect(group1[0].id).toBe(group.id); + expect(group1[0].dmPeerInboxId).toBe(client2.inboxId); + + expect((await client1.conversations.listDms()).length).toBe(1); + expect((await client1.conversations.listGroups()).length).toBe(0); + + expect((await client2.conversations.list()).length).toBe(0); + + await client2.conversations.sync(); + + const group2 = await client2.conversations.list(); + expect(group2.length).toBe(1); + expect(group2[0].id).toBe(group.id); + expect(group2[0].dmPeerInboxId).toBe(client1.inboxId); + + expect((await client2.conversations.listDms()).length).toBe(1); + expect((await client2.conversations.listGroups()).length).toBe(0); + + const dm1 = client1.conversations.getDmByInboxId(client2.inboxId); + expect(dm1).toBeDefined(); + expect(dm1!.id).toBe(group.id); + + const dm2 = client2.conversations.getDmByInboxId(client1.inboxId); + expect(dm2).toBeDefined(); + expect(dm2!.id).toBe(group.id); }); it("should get a group by ID", async () => { @@ -220,6 +295,66 @@ describe("Conversations", () => { ).toBe(conversation2.id); }); + it("should only stream group conversations", async () => { + const user1 = createUser(); + const user2 = createUser(); + const user3 = createUser(); + const user4 = createUser(); + const client1 = await createRegisteredClient(user1); + const client2 = await createRegisteredClient(user2); + const client3 = await createRegisteredClient(user3); + const client4 = await createRegisteredClient(user4); + const asyncStream = new AsyncStream(); + const stream = client3.conversations.streamGroups(asyncStream.callback); + await client4.conversations.newDm(user3.account.address); + const group1 = await client1.conversations.newConversation([ + user3.account.address, + ]); + const group2 = await client2.conversations.newConversation([ + user3.account.address, + ]); + let count = 0; + for await (const convo of asyncStream) { + count++; + expect(convo).toBeDefined(); + if (count === 1) { + expect(convo!.id).toBe(group1.id); + } + if (count === 2) { + expect(convo!.id).toBe(group2.id); + break; + } + } + stream.stop(); + }); + + it("should only stream dm conversations", async () => { + const user1 = createUser(); + const user2 = createUser(); + const user3 = createUser(); + const user4 = createUser(); + const client1 = await createRegisteredClient(user1); + const client2 = await createRegisteredClient(user2); + const client3 = await createRegisteredClient(user3); + const client4 = await createRegisteredClient(user4); + const asyncStream = new AsyncStream(); + const stream = client3.conversations.streamDms(asyncStream.callback); + await client1.conversations.newConversation([user3.account.address]); + await client2.conversations.newConversation([user3.account.address]); + const group3 = await client4.conversations.newDm(user3.account.address); + let count = 0; + for await (const convo of asyncStream) { + count++; + expect(convo).toBeDefined(); + if (count === 1) { + expect(convo!.id).toBe(group3.id); + break; + } + } + expect(count).toBe(1); + stream.stop(); + }); + it("should stream all messages", async () => { const user1 = createUser(); const user2 = createUser(); @@ -256,4 +391,95 @@ describe("Conversations", () => { } stream.stop(); }); + + it("should only stream group conversation messages", async () => { + const user1 = createUser(); + const user2 = createUser(); + const user3 = createUser(); + const user4 = createUser(); + const client1 = await createRegisteredClient(user1); + const client2 = await createRegisteredClient(user2); + const client3 = await createRegisteredClient(user3); + const client4 = await createRegisteredClient(user4); + await client1.conversations.newConversation([user2.account.address]); + await client1.conversations.newConversation([user3.account.address]); + await client1.conversations.newDm(user4.account.address); + + const stream = await client1.conversations.streamAllGroupMessages(); + + const groups2 = client2.conversations; + await groups2.sync(); + const groupsList2 = await groups2.list(); + + const groups3 = client3.conversations; + await groups3.sync(); + const groupsList3 = await groups3.list(); + + const groups4 = client4.conversations; + await groups4.sync(); + const groupsList4 = await groups4.list(); + + await groupsList4[0].send("gm3!"); + await groupsList2[0].send("gm!"); + await groupsList3[0].send("gm2!"); + + let count = 0; + + for await (const message of stream) { + count++; + expect(message).toBeDefined(); + if (count === 1) { + expect(message!.senderInboxId).toBe(client2.inboxId); + } + if (count === 2) { + expect(message!.senderInboxId).toBe(client3.inboxId); + break; + } + } + stream.stop(); + }); + + it("should only stream dm messages", async () => { + const user1 = createUser(); + const user2 = createUser(); + const user3 = createUser(); + const user4 = createUser(); + const client1 = await createRegisteredClient(user1); + const client2 = await createRegisteredClient(user2); + const client3 = await createRegisteredClient(user3); + const client4 = await createRegisteredClient(user4); + await client1.conversations.newConversation([user2.account.address]); + await client1.conversations.newConversation([user3.account.address]); + await client1.conversations.newDm(user4.account.address); + + const stream = await client1.conversations.streamAllDmMessages(); + + const groups2 = client2.conversations; + await groups2.sync(); + const groupsList2 = await groups2.list(); + + const groups3 = client3.conversations; + await groups3.sync(); + const groupsList3 = await groups3.list(); + + const groups4 = client4.conversations; + await groups4.sync(); + const groupsList4 = await groups4.list(); + + await groupsList2[0].send("gm!"); + await groupsList3[0].send("gm2!"); + await groupsList4[0].send("gm3!"); + + let count = 0; + + for await (const message of stream) { + count++; + expect(message).toBeDefined(); + if (count === 1) { + expect(message!.senderInboxId).toBe(client4.inboxId); + break; + } + } + stream.stop(); + }); }); From 215d63908856072192d95ff369dd8c15b85cc419 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 13:48:56 -0500 Subject: [PATCH 10/14] Add inbox ID helpers --- sdks/node-sdk/src/helpers/inboxId.ts | 18 ++++++++++++++++++ sdks/node-sdk/src/index.ts | 1 + sdks/node-sdk/test/inboxId.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 sdks/node-sdk/src/helpers/inboxId.ts create mode 100644 sdks/node-sdk/test/inboxId.test.ts diff --git a/sdks/node-sdk/src/helpers/inboxId.ts b/sdks/node-sdk/src/helpers/inboxId.ts new file mode 100644 index 00000000..81d2abdf --- /dev/null +++ b/sdks/node-sdk/src/helpers/inboxId.ts @@ -0,0 +1,18 @@ +import { + generateInboxId as generateInboxIdBinding, + getInboxIdForAddress as getInboxIdForAddressBinding, +} from "@xmtp/node-bindings"; +import { ApiUrls, type XmtpEnv } from "@/Client"; + +export const generateInboxId = (accountAddress: string): string => { + return generateInboxIdBinding(accountAddress); +}; + +export const getInboxIdForAddress = async ( + accountAddress: string, + env: XmtpEnv = "dev", +) => { + const host = ApiUrls[env]; + const isSecure = host.startsWith("https"); + return getInboxIdForAddressBinding(host, isSecure, accountAddress); +}; diff --git a/sdks/node-sdk/src/index.ts b/sdks/node-sdk/src/index.ts index ae43e691..bfbdfed7 100644 --- a/sdks/node-sdk/src/index.ts +++ b/sdks/node-sdk/src/index.ts @@ -11,3 +11,4 @@ export { Conversations } from "./Conversations"; export { DecodedMessage } from "./DecodedMessage"; export type { StreamCallback } from "./AsyncStream"; export type * from "@xmtp/node-bindings"; +export { generateInboxId, getInboxIdForAddress } from "./helpers/inboxId"; diff --git a/sdks/node-sdk/test/inboxId.test.ts b/sdks/node-sdk/test/inboxId.test.ts new file mode 100644 index 00000000..456d43df --- /dev/null +++ b/sdks/node-sdk/test/inboxId.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { generateInboxId, getInboxIdForAddress } from "@/helpers/inboxId"; +import { createRegisteredClient, createUser } from "@test/helpers"; + +describe("generateInboxId", () => { + it("should generate an inbox id", () => { + const user = createUser(); + const inboxId = generateInboxId(user.account.address); + expect(inboxId).toBeDefined(); + }); +}); + +describe("getInboxIdForAddress", () => { + it("should return `null` inbox ID for unregistered address", async () => { + const user = createUser(); + const inboxId = await getInboxIdForAddress(user.account.address, "local"); + expect(inboxId).toBe(null); + }); + + it("should return inbox ID for registered address", async () => { + const user = createUser(); + const client = await createRegisteredClient(user); + const inboxId = await getInboxIdForAddress(user.account.address, "local"); + expect(inboxId).toBe(client.inboxId); + }); +}); From 97aae13dcb6955b81aaa06ec27100b82f1a6e83c Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 13:57:38 -0500 Subject: [PATCH 11/14] Rename GitHub workflow --- .github/workflows/{mls-client.yml => node-sdk.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{mls-client.yml => node-sdk.yml} (99%) diff --git a/.github/workflows/mls-client.yml b/.github/workflows/node-sdk.yml similarity index 99% rename from .github/workflows/mls-client.yml rename to .github/workflows/node-sdk.yml index a68890fa..e12e938f 100644 --- a/.github/workflows/mls-client.yml +++ b/.github/workflows/node-sdk.yml @@ -1,4 +1,4 @@ -name: MLS Client +name: Node SDK on: push: From 17fb1327f87502cb049ac210fb9d691d8476d4ed Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 14:02:45 -0500 Subject: [PATCH 12/14] Add links to more repository packages --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1a17ee26..469a85cd 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ To learn more about the contents of this repository, see this README and the REA ### SDKs - [`js-sdk`](https://github.com/xmtp/xmtp-js/blob/main/sdks/js-sdk): XMTP JS client SDK for Node and the browser +- [`node-sdk`](https://github.com/xmtp/xmtp-js/blob/main/sdks/node-sdk): XMTP client SDK for Node (V3 only) +- [`browser-sdk`](https://github.com/xmtp/xmtp-js/blob/main/sdks/browser-sdk): XMTP client SDK for browsers (V3 only) ### Content types @@ -22,8 +24,9 @@ To learn more about the contents of this repository, see this README and the REA ### Packages -- [`frames-client`](https://github.com/xmtp/xmtp-js/blob/main/packages/frames-client): XMTP Open Frames client - [`consent-proof-signature`](https://github.com/xmtp/xmtp-js/blob/main/packages/consent-proof-signature): Lightweight package for creating consent proofs +- [`frames-client`](https://github.com/xmtp/xmtp-js/blob/main/packages/frames-client): XMTP Open Frames client +- [`frames-validator`](https://github.com/xmtp/xmtp-js/blob/main/packages/frames-validator): Tools for validating POST payloads from XMTP Open Frames ## Contributing From 6b6f272e3a2d1a24c7625869a84097907766add4 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 14:02:59 -0500 Subject: [PATCH 13/14] Update Node SDK README --- sdks/node-sdk/README.md | 51 +++++++++++++++++++++++++++++++++++--- sdks/node-sdk/package.json | 1 + 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/sdks/node-sdk/README.md b/sdks/node-sdk/README.md index 28f64d29..e9cb54c1 100644 --- a/sdks/node-sdk/README.md +++ b/sdks/node-sdk/README.md @@ -1,4 +1,49 @@ -# XMTP MLS Client +# XMTP client SDK for Node -> **Important** -> This package is currently in **Alpha** status. Do not use in production as the API is not final and certain functionality may not work as intended. This package only works in Node. +This package provides the XMTP client SDK for Node. + +To keep up with the latest SDK developments, see the [Issues tab](https://github.com/xmtp/xmtp-js/issues) in this repo. + +To learn more about XMTP and get answers to frequently asked questions, see the [XMTP documentation](https://xmtp.org/docs). + +> [!CAUTION] +> This SDK is currently in alpha. The API is subject to change and it is not yet recommended for production use. + +## Requirements + +- Node.js 20+ + +## Install + +**NPM** + +```bash +npm install @xmtp/node-sdk +``` + +**PNPM** + +```bash +pnpm install @xmtp/node-sdk +``` + +**Yarn** + +```bash +yarn add @xmtp/node-sdk +``` + +## XMTP network environments + +XMTP provides `production`, `dev`, and `local` network environments to support the development phases of your project. To learn more about these environments, see our [official documentation](https://xmtp.org/docs/build/authentication#environments). + +## Developing + +Run `yarn dev` to build the SDK and watch for changes, which will trigger a rebuild. + +### Useful commands + +- `yarn build`: Builds the SDK +- `yarn clean`: Removes `node_modules`, `dist`, and `.turbo` folders +- `yarn test`: Runs all tests +- `yarn typecheck`: Runs `tsc` diff --git a/sdks/node-sdk/package.json b/sdks/node-sdk/package.json index 7c18ab30..f415a30a 100644 --- a/sdks/node-sdk/package.json +++ b/sdks/node-sdk/package.json @@ -43,6 +43,7 @@ "clean:deps": "rimraf node_modules", "clean:dist": "rimraf dist", "clean:tests": "rimraf test/*.db3* ||:", + "dev": "yarn build --watch", "test": "vitest run", "test:cov": "vitest run --coverage", "typecheck": "tsc" From 981bcf40432107ca3c304df66b198e025e7829f1 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Wed, 30 Oct 2024 14:20:55 -0500 Subject: [PATCH 14/14] Create small-singers-type.md --- .changeset/small-singers-type.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/small-singers-type.md diff --git a/.changeset/small-singers-type.md b/.changeset/small-singers-type.md new file mode 100644 index 00000000..0d37e410 --- /dev/null +++ b/.changeset/small-singers-type.md @@ -0,0 +1,5 @@ +--- +"@xmtp/node-sdk": patch +--- + +Add 1:1 messages, consent state, and more identity updates