From 3a33b29d1240c68aef32cd39d199d34359e85e0f Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Tue, 29 Oct 2024 17:51:08 -0500 Subject: [PATCH] Add 1:1 groups to node bindings (#1199) * Add inner client accessor on client * Move inbox id methods into separate mod * Move consent state methods into mod * Move inbox state method into mod * Add sort direction to NapiListMessagesOptions * Add dm_peer_inbox_id to NapiGroup * Update NapiListConversationsOptions * Add create_dm to NapiConversations * Add list_groups and list_dms * Update conversation streaming methods * Add error logging during tests * Add signatures mod * Add tests * Clippy updates * Remove .only on some tests * Update bindings_node/src/conversations.rs Co-authored-by: Andrew Plaza --------- Co-authored-by: Andrew Plaza --- bindings_node/src/consent_state.rs | 34 +++ bindings_node/src/conversations.rs | 166 ++++++++++++-- bindings_node/src/groups.rs | 25 ++- bindings_node/src/inbox_id.rs | 37 ++++ bindings_node/src/inbox_state.rs | 38 +++- bindings_node/src/lib.rs | 2 + bindings_node/src/messages.rs | 21 +- bindings_node/src/mls_client.rs | 245 ++------------------- bindings_node/src/signatures.rs | 146 +++++++++++++ bindings_node/test/Conversations.test.ts | 262 ++++++++++++++++++++++- bindings_node/test/helpers.ts | 2 +- 11 files changed, 711 insertions(+), 267 deletions(-) create mode 100644 bindings_node/src/inbox_id.rs create mode 100644 bindings_node/src/signatures.rs diff --git a/bindings_node/src/consent_state.rs b/bindings_node/src/consent_state.rs index a674f3090..fe481764d 100644 --- a/bindings_node/src/consent_state.rs +++ b/bindings_node/src/consent_state.rs @@ -1,6 +1,9 @@ +use napi::bindgen_prelude::Result; use napi_derive::napi; use xmtp_mls::storage::consent_record::{ConsentState, ConsentType, StoredConsentRecord}; +use crate::{mls_client::NapiClient, ErrorWrapper}; + #[napi] pub enum NapiConsentState { Unknown, @@ -61,3 +64,34 @@ impl From for StoredConsentRecord { } } } + +#[napi] +impl NapiClient { + #[napi] + pub async fn set_consent_states(&self, records: Vec) -> Result<()> { + let stored_records: Vec = + records.into_iter().map(StoredConsentRecord::from).collect(); + + self + .inner_client() + .set_consent_states(stored_records) + .await + .map_err(ErrorWrapper::from)?; + Ok(()) + } + + #[napi] + pub async fn get_consent_state( + &self, + entity_type: NapiConsentEntityType, + entity: String, + ) -> Result { + let result = self + .inner_client() + .get_consent_state(entity_type.into(), entity) + .await + .map_err(ErrorWrapper::from)?; + + Ok(result.into()) + } +} diff --git a/bindings_node/src/conversations.rs b/bindings_node/src/conversations.rs index 7f0c7ab2b..80ce6c960 100644 --- a/bindings_node/src/conversations.rs +++ b/bindings_node/src/conversations.rs @@ -9,17 +9,91 @@ use napi_derive::napi; use xmtp_mls::client::FindGroupParams; use xmtp_mls::groups::group_metadata::ConversationType; use xmtp_mls::groups::{GroupMetadataOptions, PreconfiguredPolicies}; +use xmtp_mls::storage::group::GroupMembershipState; use crate::messages::NapiMessage; use crate::permissions::NapiGroupPermissionsOptions; use crate::ErrorWrapper; use crate::{groups::NapiGroup, mls_client::RustXmtpClient, streams::NapiStreamCloser}; +#[napi] +#[derive(Debug)] +pub enum NapiConversationType { + Dm = 0, + Group = 1, + Sync = 2, +} + +impl From for NapiConversationType { + fn from(ct: ConversationType) -> Self { + match ct { + ConversationType::Dm => NapiConversationType::Dm, + ConversationType::Group => NapiConversationType::Group, + ConversationType::Sync => NapiConversationType::Sync, + } + } +} + +impl From for ConversationType { + fn from(nct: NapiConversationType) -> Self { + match nct { + NapiConversationType::Dm => ConversationType::Dm, + NapiConversationType::Group => ConversationType::Group, + NapiConversationType::Sync => ConversationType::Sync, + } + } +} + +#[napi] +#[derive(Debug)] +pub enum NapiGroupMembershipState { + Allowed = 0, + Rejected = 1, + Pending = 2, +} + +impl From for NapiGroupMembershipState { + fn from(gms: GroupMembershipState) -> Self { + match gms { + GroupMembershipState::Allowed => NapiGroupMembershipState::Allowed, + GroupMembershipState::Rejected => NapiGroupMembershipState::Rejected, + GroupMembershipState::Pending => NapiGroupMembershipState::Pending, + } + } +} + +impl From for GroupMembershipState { + fn from(ngms: NapiGroupMembershipState) -> Self { + match ngms { + NapiGroupMembershipState::Allowed => GroupMembershipState::Allowed, + NapiGroupMembershipState::Rejected => GroupMembershipState::Rejected, + NapiGroupMembershipState::Pending => GroupMembershipState::Pending, + } + } +} + #[napi(object)] +#[derive(Debug, Default)] pub struct NapiListConversationsOptions { + pub allowed_states: Option>, pub created_after_ns: Option, pub created_before_ns: Option, pub limit: Option, + pub conversation_type: Option, +} + +impl From for FindGroupParams { + fn from(opts: NapiListConversationsOptions) -> Self { + FindGroupParams { + allowed_states: opts + .allowed_states + .map(|states| states.into_iter().map(From::from).collect()), + conversation_type: opts.conversation_type.map(|ct| ct.into()), + created_after_ns: opts.created_after_ns, + created_before_ns: opts.created_before_ns, + limit: opts.limit, + } + } } #[napi(object)] @@ -99,6 +173,17 @@ impl NapiConversations { Ok(convo.into()) } + #[napi] + pub async fn create_dm(&self, account_address: String) -> Result { + let convo = self + .inner_client + .create_dm(account_address) + .await + .map_err(ErrorWrapper::from)?; + + Ok(convo.into()) + } + #[napi] pub fn find_group_by_id(&self, group_id: String) -> Result { let group_id = hex::decode(group_id).map_err(ErrorWrapper::from)?; @@ -159,22 +244,13 @@ impl NapiConversations { #[napi] pub async fn list(&self, opts: Option) -> Result> { - let opts = match opts { - Some(options) => options, - None => NapiListConversationsOptions { - created_after_ns: None, - created_before_ns: None, - limit: None, - }, - }; + // let opts = match opts { + // Some(options) => options, + // None => NapiListConversationsOptions::default(), + // }; let convo_list: Vec = self .inner_client - .find_groups(FindGroupParams { - created_after_ns: opts.created_after_ns, - created_before_ns: opts.created_before_ns, - limit: opts.limit, - ..FindGroupParams::default() - }) + .find_groups(opts.unwrap_or_default().into()) .map_err(ErrorWrapper::from)? .into_iter() .map(NapiGroup::from) @@ -183,13 +259,43 @@ impl NapiConversations { Ok(convo_list) } + #[napi] + pub async fn list_groups( + &self, + opts: Option, + ) -> Result> { + self + .list(Some(NapiListConversationsOptions { + conversation_type: Some(NapiConversationType::Group), + ..opts.unwrap_or_default() + })) + .await + } + + #[napi] + pub async fn list_dms( + &self, + opts: Option, + ) -> Result> { + self + .list(Some(NapiListConversationsOptions { + conversation_type: Some(NapiConversationType::Dm), + ..opts.unwrap_or_default() + })) + .await + } + #[napi(ts_args_type = "callback: (err: null | Error, result: NapiGroup) => void")] - pub fn stream(&self, callback: JsFunction) -> Result { + pub fn stream( + &self, + callback: JsFunction, + conversation_type: Option, + ) -> Result { let tsfn: ThreadsafeFunction = callback.create_threadsafe_function(0, |ctx| Ok(vec![ctx.value]))?; let stream_closer = RustXmtpClient::stream_conversations_with_callback( self.inner_client.clone(), - Some(ConversationType::Group), + conversation_type.map(|ct| ct.into()), move |convo| { tsfn.call( convo @@ -204,13 +310,27 @@ impl NapiConversations { Ok(NapiStreamCloser::new(stream_closer)) } + #[napi(ts_args_type = "callback: (err: null | Error, result: NapiGroup) => void")] + pub fn stream_groups(&self, callback: JsFunction) -> Result { + self.stream(callback, Some(NapiConversationType::Group)) + } + + #[napi(ts_args_type = "callback: (err: null | Error, result: NapiGroup) => void")] + pub fn stream_dms(&self, callback: JsFunction) -> Result { + self.stream(callback, Some(NapiConversationType::Dm)) + } + #[napi(ts_args_type = "callback: (err: null | Error, result: NapiMessage) => void")] - pub fn stream_all_messages(&self, callback: JsFunction) -> Result { + pub fn stream_all_messages( + &self, + callback: JsFunction, + conversation_type: Option, + ) -> Result { let tsfn: ThreadsafeFunction = callback.create_threadsafe_function(0, |ctx| Ok(vec![ctx.value]))?; let stream_closer = RustXmtpClient::stream_all_messages_with_callback( self.inner_client.clone(), - Some(ConversationType::Group), + conversation_type.map(Into::into), move |message| { tsfn.call( message @@ -224,4 +344,14 @@ impl NapiConversations { Ok(NapiStreamCloser::new(stream_closer)) } + + #[napi(ts_args_type = "callback: (err: null | Error, result: NapiMessage) => void")] + pub fn stream_all_group_messages(&self, callback: JsFunction) -> Result { + self.stream_all_messages(callback, Some(NapiConversationType::Group)) + } + + #[napi(ts_args_type = "callback: (err: null | Error, result: NapiMessage) => void")] + pub fn stream_all_dm_messages(&self, callback: JsFunction) -> Result { + self.stream_all_messages(callback, Some(NapiConversationType::Dm)) + } } diff --git a/bindings_node/src/groups.rs b/bindings_node/src/groups.rs index f2c8cd1ff..0d97a6c1a 100644 --- a/bindings_node/src/groups.rs +++ b/bindings_node/src/groups.rs @@ -160,15 +160,7 @@ impl NapiGroup { #[napi] pub fn find_messages(&self, opts: Option) -> Result> { - let opts = match opts { - Some(options) => options, - None => NapiListMessagesOptions { - sent_before_ns: None, - sent_after_ns: None, - limit: None, - delivery_status: None, - }, - }; + let opts = opts.unwrap_or_default(); let group = MlsGroup::new( self.inner_client.clone(), @@ -177,6 +169,7 @@ impl NapiGroup { ); let delivery_status = opts.delivery_status.map(|status| status.into()); + let direction = opts.direction.map(|dir| dir.into()); let messages: Vec = group .find_messages( @@ -184,7 +177,8 @@ impl NapiGroup { .maybe_sent_before_ns(opts.sent_before_ns) .maybe_sent_after_ns(opts.sent_after_ns) .maybe_delivery_status(delivery_status) - .maybe_limit(opts.limit), + .maybe_limit(opts.limit) + .maybe_direction(direction), ) .map_err(ErrorWrapper::from)? .into_iter() @@ -644,4 +638,15 @@ impl NapiGroup { Ok(()) } + + #[napi] + pub fn dm_peer_inbox_id(&self) -> Result { + let group = MlsGroup::new( + self.inner_client.clone(), + self.group_id.clone(), + self.created_at_ns, + ); + + Ok(group.dm_inbox_id().map_err(ErrorWrapper::from)?) + } } diff --git a/bindings_node/src/inbox_id.rs b/bindings_node/src/inbox_id.rs new file mode 100644 index 000000000..094fa68fc --- /dev/null +++ b/bindings_node/src/inbox_id.rs @@ -0,0 +1,37 @@ +use crate::ErrorWrapper; +use napi::bindgen_prelude::Result; +use napi_derive::napi; +use xmtp_api_grpc::grpc_api_helper::Client as TonicApiClient; +use xmtp_id::associations::generate_inbox_id as xmtp_id_generate_inbox_id; +use xmtp_mls::api::ApiClientWrapper; +use xmtp_mls::retry::Retry; + +#[napi] +pub async fn get_inbox_id_for_address( + host: String, + is_secure: bool, + account_address: String, +) -> Result> { + let account_address = account_address.to_lowercase(); + let api_client = ApiClientWrapper::new( + TonicApiClient::create(host.clone(), is_secure) + .await + .map_err(ErrorWrapper::from)?, + Retry::default(), + ); + + let results = api_client + .get_inbox_ids(vec![account_address.clone()]) + .await + .map_err(ErrorWrapper::from)?; + + Ok(results.get(&account_address).cloned()) +} + +#[napi] +pub fn generate_inbox_id(account_address: String) -> String { + let account_address = account_address.to_lowercase(); + // ensure that the nonce is always 1 for now since this will only be used for the + // create_client function above, which also has a hard-coded nonce of 1 + xmtp_id_generate_inbox_id(&account_address, &1) +} diff --git a/bindings_node/src/inbox_state.rs b/bindings_node/src/inbox_state.rs index cd56a804d..5bd09a344 100644 --- a/bindings_node/src/inbox_state.rs +++ b/bindings_node/src/inbox_state.rs @@ -1,8 +1,10 @@ -use napi::bindgen_prelude::BigInt; +use napi::bindgen_prelude::{BigInt, Result}; use napi_derive::napi; use xmtp_cryptography::signature::ed25519_public_key_to_address; use xmtp_id::associations::{AssociationState, MemberIdentifier}; +use crate::{mls_client::NapiClient, ErrorWrapper}; + #[napi(object)] pub struct NapiInstallation { pub id: String, @@ -37,3 +39,37 @@ impl From for NapiInboxState { } } } + +#[napi] +impl NapiClient { + /** + * Get the client's inbox state. + * + * If `refresh_from_network` is true, the client will go to the network first to refresh the state. + * Otherwise, the state will be read from the local database. + */ + #[napi] + pub async fn inbox_state(&self, refresh_from_network: bool) -> Result { + let state = self + .inner_client() + .inbox_state(refresh_from_network) + .await + .map_err(ErrorWrapper::from)?; + Ok(state.into()) + } + + #[napi] + pub async fn get_latest_inbox_state(&self, inbox_id: String) -> Result { + let conn = self + .inner_client() + .store() + .conn() + .map_err(ErrorWrapper::from)?; + let state = self + .inner_client() + .get_latest_association_state(&conn, &inbox_id) + .await + .map_err(ErrorWrapper::from)?; + Ok(state.into()) + } +} diff --git a/bindings_node/src/lib.rs b/bindings_node/src/lib.rs index 6d660a59a..2348b14f2 100755 --- a/bindings_node/src/lib.rs +++ b/bindings_node/src/lib.rs @@ -5,10 +5,12 @@ mod consent_state; mod conversations; mod encoded_content; mod groups; +pub mod inbox_id; mod inbox_state; mod messages; pub mod mls_client; mod permissions; +mod signatures; mod streams; use napi::bindgen_prelude::Error; diff --git a/bindings_node/src/messages.rs b/bindings_node/src/messages.rs index eb207b78c..59f7faca5 100644 --- a/bindings_node/src/messages.rs +++ b/bindings_node/src/messages.rs @@ -1,6 +1,8 @@ use napi::bindgen_prelude::Uint8Array; use prost::Message; -use xmtp_mls::storage::group_message::{DeliveryStatus, GroupMessageKind, StoredGroupMessage}; +use xmtp_mls::storage::group_message::{ + DeliveryStatus, GroupMessageKind, SortDirection, StoredGroupMessage, +}; use napi_derive::napi; use xmtp_proto::xmtp::mls::message_contents::EncodedContent; @@ -49,12 +51,29 @@ impl From for DeliveryStatus { } } +#[napi] +pub enum NapiDirection { + Ascending, + Descending, +} + +impl From for SortDirection { + fn from(direction: NapiDirection) -> Self { + match direction { + NapiDirection::Ascending => SortDirection::Ascending, + NapiDirection::Descending => SortDirection::Descending, + } + } +} + #[napi(object)] +#[derive(Default)] pub struct NapiListMessagesOptions { pub sent_before_ns: Option, pub sent_after_ns: Option, pub limit: Option, pub delivery_status: Option, + pub direction: Option, } #[napi] diff --git a/bindings_node/src/mls_client.rs b/bindings_node/src/mls_client.rs index 8ab417931..b6ed3812a 100644 --- a/bindings_node/src/mls_client.rs +++ b/bindings_node/src/mls_client.rs @@ -1,6 +1,6 @@ -use crate::consent_state::{NapiConsent, NapiConsentEntityType, NapiConsentState}; use crate::conversations::NapiConversations; use crate::inbox_state::NapiInboxState; +use crate::signatures::NapiSignatureRequestType; use crate::ErrorWrapper; use napi::bindgen_prelude::{Error, Result, Uint8Array}; use napi_derive::napi; @@ -12,28 +12,14 @@ use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*}; pub use xmtp_api_grpc::grpc_api_helper::Client as TonicApiClient; use xmtp_cryptography::signature::ed25519_public_key_to_address; use xmtp_id::associations::builder::SignatureRequest; -use xmtp_id::associations::generate_inbox_id as xmtp_id_generate_inbox_id; -use xmtp_id::associations::unverified::UnverifiedSignature; -use xmtp_mls::api::ApiClientWrapper; use xmtp_mls::builder::ClientBuilder; use xmtp_mls::identity::IdentityStrategy; -use xmtp_mls::retry::Retry; -use xmtp_mls::storage::consent_record::StoredConsentRecord; use xmtp_mls::storage::{EncryptedMessageStore, EncryptionKey, StorageOption}; use xmtp_mls::Client as MlsClient; pub type RustXmtpClient = MlsClient; static LOGGER_INIT: Once = Once::new(); -#[napi] -#[derive(Eq, Hash, PartialEq)] -pub enum NapiSignatureRequestType { - AddWallet, - CreateInbox, - RevokeWallet, - RevokeInstallations, -} - #[napi] pub struct NapiClient { inner_client: Arc, @@ -41,6 +27,18 @@ pub struct NapiClient { pub account_address: String, } +impl NapiClient { + pub fn inner_client(&self) -> &Arc { + &self.inner_client + } + + pub fn signature_requests( + &self, + ) -> &Arc>> { + &self.signature_requests + } +} + /// Create an MLS client /// Optionally specify a filter for the log level as a string. /// It can be one of: `debug`, `info`, `warn`, `error` or 'off'. @@ -121,36 +119,6 @@ pub async fn create_client( }) } -#[napi] -pub async fn get_inbox_id_for_address( - host: String, - is_secure: bool, - account_address: String, -) -> Result> { - let account_address = account_address.to_lowercase(); - let api_client = ApiClientWrapper::new( - TonicApiClient::create(host.clone(), is_secure) - .await - .map_err(ErrorWrapper::from)?, - Retry::default(), - ); - - let results = api_client - .get_inbox_ids(vec![account_address.clone()]) - .await - .map_err(ErrorWrapper::from)?; - - Ok(results.get(&account_address).cloned()) -} - -#[napi] -pub fn generate_inbox_id(account_address: String) -> String { - let account_address = account_address.to_lowercase(); - // ensure that the nonce is always 1 for now since this will only be used for the - // create_client function above, which also has a hard-coded nonce of 1 - xmtp_id_generate_inbox_id(&account_address, &1) -} - #[napi] impl NapiClient { #[napi] @@ -204,21 +172,6 @@ impl NapiClient { Ok(()) } - #[napi] - pub async fn create_inbox_signature_text(&self) -> Result> { - let signature_request = match self.inner_client.identity().signature_request() { - Some(signature_req) => signature_req, - // this should never happen since we're checking for it above in is_registered - None => return Err(Error::from_reason("No signature request found")), - }; - let signature_text = signature_request.signature_text(); - let mut signature_requests = self.signature_requests.lock().await; - - signature_requests.insert(NapiSignatureRequestType::CreateInbox, signature_request); - - Ok(Some(signature_text)) - } - #[napi] pub fn conversations(&self) -> NapiConversations { NapiConversations::new(self.inner_client.clone()) @@ -246,22 +199,6 @@ impl NapiClient { Ok(inbox_id) } - /** - * Get the client's inbox state. - * - * If `refresh_from_network` is true, the client will go to the network first to refresh the state. - * Otherwise, the state will be read from the local database. - */ - #[napi] - pub async fn inbox_state(&self, refresh_from_network: bool) -> Result { - let state = self - .inner_client - .inbox_state(refresh_from_network) - .await - .map_err(ErrorWrapper::from)?; - Ok(state.into()) - } - #[napi] pub async fn addresses_from_inbox_id( &self, @@ -275,160 +212,4 @@ impl NapiClient { .map_err(ErrorWrapper::from)?; Ok(state.into_iter().map(Into::into).collect()) } - - #[napi] - pub async fn get_latest_inbox_state(&self, inbox_id: String) -> Result { - let conn = self - .inner_client - .store() - .conn() - .map_err(ErrorWrapper::from)?; - let state = self - .inner_client - .get_latest_association_state(&conn, &inbox_id) - .await - .map_err(ErrorWrapper::from)?; - Ok(state.into()) - } - - #[napi] - pub async fn add_wallet_signature_text( - &self, - existing_wallet_address: String, - new_wallet_address: String, - ) -> Result { - let signature_request = self - .inner_client - .associate_wallet( - existing_wallet_address.to_lowercase(), - new_wallet_address.to_lowercase(), - ) - .map_err(ErrorWrapper::from)?; - let signature_text = signature_request.signature_text(); - let mut signature_requests = self.signature_requests.lock().await; - - signature_requests.insert(NapiSignatureRequestType::AddWallet, signature_request); - - Ok(signature_text) - } - - #[napi] - pub async fn revoke_wallet_signature_text(&self, wallet_address: String) -> Result { - let signature_request = self - .inner_client - .revoke_wallets(vec![wallet_address.to_lowercase()]) - .await - .map_err(ErrorWrapper::from)?; - let signature_text = signature_request.signature_text(); - let mut signature_requests = self.signature_requests.lock().await; - - signature_requests.insert(NapiSignatureRequestType::RevokeWallet, signature_request); - - Ok(signature_text) - } - - #[napi] - pub async fn revoke_installations_signature_text(&self) -> Result { - let installation_id = self.inner_client.installation_public_key(); - let inbox_state = self - .inner_client - .inbox_state(true) - .await - .map_err(ErrorWrapper::from)?; - let other_installation_ids = inbox_state - .installation_ids() - .into_iter() - .filter(|id| id != &installation_id) - .collect(); - let signature_request = self - .inner_client - .revoke_installations(other_installation_ids) - .await - .map_err(ErrorWrapper::from)?; - let signature_text = signature_request.signature_text(); - let mut signature_requests = self.signature_requests.lock().await; - - signature_requests.insert( - NapiSignatureRequestType::RevokeInstallations, - signature_request, - ); - - Ok(signature_text) - } - - #[napi] - pub async fn add_signature( - &self, - signature_type: NapiSignatureRequestType, - signature_bytes: Uint8Array, - ) -> Result<()> { - let mut signature_requests = self.signature_requests.lock().await; - - if let Some(signature_request) = signature_requests.get_mut(&signature_type) { - let signature = UnverifiedSignature::new_recoverable_ecdsa(signature_bytes.deref().to_vec()); - - signature_request - .add_signature(signature, &self.inner_client.scw_verifier()) - .await - .map_err(ErrorWrapper::from)?; - } else { - return Err(Error::from_reason("Signature request not found")); - } - - Ok(()) - } - - #[napi] - pub async fn apply_signature_requests(&self) -> Result<()> { - let mut signature_requests = self.signature_requests.lock().await; - - let request_types: Vec = signature_requests.keys().cloned().collect(); - for signature_request_type in request_types { - // ignore the create inbox request since it's applied with register_identity - if signature_request_type == NapiSignatureRequestType::CreateInbox { - continue; - } - - if let Some(signature_request) = signature_requests.get(&signature_request_type) { - self - .inner_client - .apply_signature_request(signature_request.clone()) - .await - .map_err(ErrorWrapper::from)?; - - // remove the signature request after applying it - signature_requests.remove(&signature_request_type); - } - } - - Ok(()) - } - - #[napi] - pub async fn set_consent_states(&self, records: Vec) -> Result<()> { - let inner = self.inner_client.as_ref(); - let stored_records: Vec = - records.into_iter().map(StoredConsentRecord::from).collect(); - - inner - .set_consent_states(stored_records) - .await - .map_err(ErrorWrapper::from)?; - Ok(()) - } - - #[napi] - pub async fn get_consent_state( - &self, - entity_type: NapiConsentEntityType, - entity: String, - ) -> Result { - let inner = self.inner_client.as_ref(); - let result = inner - .get_consent_state(entity_type.into(), entity) - .await - .map_err(ErrorWrapper::from)?; - - Ok(result.into()) - } } diff --git a/bindings_node/src/signatures.rs b/bindings_node/src/signatures.rs new file mode 100644 index 000000000..43e67a863 --- /dev/null +++ b/bindings_node/src/signatures.rs @@ -0,0 +1,146 @@ +use crate::mls_client::NapiClient; +use crate::ErrorWrapper; +use napi::bindgen_prelude::{Error, Result, Uint8Array}; +use napi_derive::napi; +use std::ops::Deref; +use xmtp_id::associations::unverified::UnverifiedSignature; + +#[napi] +#[derive(Eq, Hash, PartialEq)] +pub enum NapiSignatureRequestType { + AddWallet, + CreateInbox, + RevokeWallet, + RevokeInstallations, +} + +#[napi] +impl NapiClient { + #[napi] + pub async fn create_inbox_signature_text(&self) -> Result> { + let signature_request = match self.inner_client().identity().signature_request() { + Some(signature_req) => signature_req, + // this should never happen since we're checking for it above in is_registered + None => return Err(Error::from_reason("No signature request found")), + }; + let signature_text = signature_request.signature_text(); + let mut signature_requests = self.signature_requests().lock().await; + + signature_requests.insert(NapiSignatureRequestType::CreateInbox, signature_request); + + Ok(Some(signature_text)) + } + + #[napi] + pub async fn add_wallet_signature_text( + &self, + existing_wallet_address: String, + new_wallet_address: String, + ) -> Result { + let signature_request = self + .inner_client() + .associate_wallet( + existing_wallet_address.to_lowercase(), + new_wallet_address.to_lowercase(), + ) + .map_err(ErrorWrapper::from)?; + let signature_text = signature_request.signature_text(); + let mut signature_requests = self.signature_requests().lock().await; + + signature_requests.insert(NapiSignatureRequestType::AddWallet, signature_request); + + Ok(signature_text) + } + + #[napi] + pub async fn revoke_wallet_signature_text(&self, wallet_address: String) -> Result { + let signature_request = self + .inner_client() + .revoke_wallets(vec![wallet_address.to_lowercase()]) + .await + .map_err(ErrorWrapper::from)?; + let signature_text = signature_request.signature_text(); + let mut signature_requests = self.signature_requests().lock().await; + + signature_requests.insert(NapiSignatureRequestType::RevokeWallet, signature_request); + + Ok(signature_text) + } + + #[napi] + pub async fn revoke_installations_signature_text(&self) -> Result { + let installation_id = self.inner_client().installation_public_key(); + let inbox_state = self + .inner_client() + .inbox_state(true) + .await + .map_err(ErrorWrapper::from)?; + let other_installation_ids = inbox_state + .installation_ids() + .into_iter() + .filter(|id| id != &installation_id) + .collect(); + let signature_request = self + .inner_client() + .revoke_installations(other_installation_ids) + .await + .map_err(ErrorWrapper::from)?; + let signature_text = signature_request.signature_text(); + let mut signature_requests = self.signature_requests().lock().await; + + signature_requests.insert( + NapiSignatureRequestType::RevokeInstallations, + signature_request, + ); + + Ok(signature_text) + } + + #[napi] + pub async fn add_signature( + &self, + signature_type: NapiSignatureRequestType, + signature_bytes: Uint8Array, + ) -> Result<()> { + let mut signature_requests = self.signature_requests().lock().await; + + if let Some(signature_request) = signature_requests.get_mut(&signature_type) { + let signature = UnverifiedSignature::new_recoverable_ecdsa(signature_bytes.deref().to_vec()); + + signature_request + .add_signature(signature, &self.inner_client().scw_verifier()) + .await + .map_err(ErrorWrapper::from)?; + } else { + return Err(Error::from_reason("Signature request not found")); + } + + Ok(()) + } + + #[napi] + pub async fn apply_signature_requests(&self) -> Result<()> { + let mut signature_requests = self.signature_requests().lock().await; + + let request_types: Vec = signature_requests.keys().cloned().collect(); + for signature_request_type in request_types { + // ignore the create inbox request since it's applied with register_identity + if signature_request_type == NapiSignatureRequestType::CreateInbox { + continue; + } + + if let Some(signature_request) = signature_requests.get(&signature_request_type) { + self + .inner_client() + .apply_signature_request(signature_request.clone()) + .await + .map_err(ErrorWrapper::from)?; + + // remove the signature request after applying it + signature_requests.remove(&signature_request_type); + } + } + + Ok(()) + } +} diff --git a/bindings_node/test/Conversations.test.ts b/bindings_node/test/Conversations.test.ts index 00edabdca..ba6bf1df4 100644 --- a/bindings_node/test/Conversations.test.ts +++ b/bindings_node/test/Conversations.test.ts @@ -16,11 +16,13 @@ describe('Conversations', () => { it('should not have initial conversations', async () => { const user = createUser() const client = await createRegisteredClient(user) - const conversations = await client.conversations().list() - expect(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 group', async () => { + it('should create a group chat', async () => { const user1 = createUser() const user2 = createUser() const client1 = await createRegisteredClient(user1) @@ -61,6 +63,64 @@ describe('Conversations', () => { const group1 = await client1.conversations().list() expect(group1.length).toBe(1) expect(group1[0].id).toBe(group.id) + expect((await client1.conversations().listDms()).length).toBe(0) + expect((await client1.conversations().listGroups()).length).toBe(1) + + 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((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().createDm(user2.account.address) + expect(group).toBeDefined() + expect(group.id()).toBeDefined() + expect(group.createdAtNs()).toBeTypeOf('number') + expect(group.isActive()).toBe(true) + expect(group.groupName()).toBe('') + expect(group.groupPermissions().policyType()).toBe( + NapiGroupPermissionsOptions.CustomPolicy + ) + expect(group.groupPermissions().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.findMessages().length).toBe(1) + const members = await group.listMembers() + expect(members.length).toBe(2) + const memberInboxIds = members.map((member) => member.inboxId) + expect(memberInboxIds).toContain(client1.inboxId()) + expect(memberInboxIds).toContain(client2.inboxId()) + expect(group.groupMetadata().conversationType()).toBe('dm') + expect(group.groupMetadata().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) @@ -69,6 +129,18 @@ describe('Conversations', () => { 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().findDmByTargetInboxId(client2.inboxId()) + expect(dm1).toBeDefined() + expect(dm1!.id).toBe(group.id) + + const dm2 = client2.conversations().findDmByTargetInboxId(client1.inboxId()) + expect(dm2).toBeDefined() + expect(dm2!.id).toBe(group.id) }) it('should find a group by ID', async () => { @@ -212,13 +284,15 @@ describe('Conversations', () => { expect(group.groupPinnedFrameUrl()).toBe('https://frameurl.xyz') }) - it('should stream new groups', async () => { + it('should stream all groups', 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(undefined) const stream = client3.conversations().stream(asyncStream.callback) const group1 = await client1 @@ -227,6 +301,7 @@ describe('Conversations', () => { const group2 = await client2 .conversations() .createGroup([user3.account.address]) + const group3 = await client4.conversations().createDm(user3.account.address) let count = 0 for await (const convo of asyncStream) { count++ @@ -236,9 +311,78 @@ describe('Conversations', () => { } if (count === 2) { expect(convo!.id).toBe(group2.id) + } + if (count === 3) { + expect(convo!.id).toBe(group3.id) + break + } + } + asyncStream.stop() + stream.end() + }) + + it('should only stream group chats', 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(undefined) + const stream = client3.conversations().streamGroups(asyncStream.callback) + const group3 = await client4.conversations().createDm(user3.account.address) + const group1 = await client1 + .conversations() + .createGroup([user3.account.address]) + const group2 = await client2 + .conversations() + .createGroup([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 + } + } + asyncStream.stop() + stream.end() + }) + + it('should only stream dm groups', 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(undefined) + const stream = client3.conversations().streamDms(asyncStream.callback) + const group1 = await client1 + .conversations() + .createGroup([user3.account.address]) + const group2 = await client2 + .conversations() + .createGroup([user3.account.address]) + const group3 = await client4.conversations().createDm(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) asyncStream.stop() stream.end() }) @@ -247,11 +391,14 @@ describe('Conversations', () => { 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().createGroup([user2.account.address]) await client1.conversations().createGroup([user3.account.address]) + await client1.conversations().createDm(user4.account.address) const asyncStream = new AsyncStream(undefined) const stream = client1 @@ -266,8 +413,13 @@ describe('Conversations', () => { await groups3.sync() const groupsList3 = await groups3.list() + const groups4 = await client4.conversations() + await groups4.sync() + const groupsList4 = await groups4.list() + await groupsList2[0].send(encodeTextMessage('gm!')) await groupsList3[0].send(encodeTextMessage('gm2!')) + await groupsList4[0].send(encodeTextMessage('gm3!')) let count = 0 @@ -279,6 +431,108 @@ describe('Conversations', () => { } if (count === 2) { expect(message!.senderInboxId).toBe(client3.inboxId()) + } + if (count === 3) { + expect(message!.senderInboxId).toBe(client4.inboxId()) + break + } + } + asyncStream.stop() + stream.end() + }) + + it('should only stream group chat 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().createGroup([user2.account.address]) + await client1.conversations().createGroup([user3.account.address]) + await client1.conversations().createDm(user4.account.address) + + const asyncStream = new AsyncStream(undefined) + const stream = client1 + .conversations() + .streamAllGroupMessages(asyncStream.callback) + + 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 = await client4.conversations() + await groups4.sync() + const groupsList4 = await groups4.list() + + await groupsList4[0].send(encodeTextMessage('gm3!')) + await groupsList2[0].send(encodeTextMessage('gm!')) + await groupsList3[0].send(encodeTextMessage('gm2!')) + + let count = 0 + + for await (const message of asyncStream) { + count++ + expect(message).toBeDefined() + if (count === 1) { + expect(message!.senderInboxId).toBe(client2.inboxId()) + } + if (count === 2) { + expect(message!.senderInboxId).toBe(client3.inboxId()) + break + } + } + asyncStream.stop() + stream.end() + }) + + 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().createGroup([user2.account.address]) + await client1.conversations().createGroup([user3.account.address]) + await client1.conversations().createDm(user4.account.address) + + const asyncStream = new AsyncStream(undefined) + const stream = client1 + .conversations() + .streamAllDmMessages(asyncStream.callback) + + 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 = await client4.conversations() + await groups4.sync() + const groupsList4 = await groups4.list() + + await groupsList2[0].send(encodeTextMessage('gm!')) + await groupsList3[0].send(encodeTextMessage('gm2!')) + await groupsList4[0].send(encodeTextMessage('gm3!')) + + let count = 0 + + for await (const message of asyncStream) { + count++ + expect(message).toBeDefined() + if (count === 1) { + expect(message!.senderInboxId).toBe(client4.inboxId()) break } } diff --git a/bindings_node/test/helpers.ts b/bindings_node/test/helpers.ts index 27d7bfb03..3bfbfc64b 100644 --- a/bindings_node/test/helpers.ts +++ b/bindings_node/test/helpers.ts @@ -44,7 +44,7 @@ export const createClient = async (user: User) => { user.account.address, undefined, undefined, - 'off' + 'error' ) }