From e16398071597d947056c8254368411bf7b811d33 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:27:39 -0700 Subject: [PATCH 1/4] Move Ephemeral registry to submodule (#136) --- extensions/components/src/contact_registry.rs | 114 +----------------- .../src/contact_registry/ephemeral.rs | 112 +++++++++++++++++ extensions/components/src/lib.rs | 2 +- 3 files changed, 114 insertions(+), 114 deletions(-) create mode 100644 extensions/components/src/contact_registry/ephemeral.rs diff --git a/extensions/components/src/contact_registry.rs b/extensions/components/src/contact_registry.rs index acb42c7..6d63e33 100644 --- a/extensions/components/src/contact_registry.rs +++ b/extensions/components/src/contact_registry.rs @@ -1,114 +1,2 @@ -use std::{ - collections::HashMap, - fmt::Debug, - sync::{Arc, Mutex}, -}; - -use crypto::Ed25519VerifyingKey; -use libchat::{ - AccountDirectory, DeviceSet, IdentityProvider, RegistrationService, SignedDeviceBundle, - verify_bundle, -}; - +pub mod ephemeral; pub mod http; - -/// A Contact Registry used for Tests. -/// This implementation stores bundle bytes and then returns them when -/// retrieved. -/// -/// Like the real `keypackage-registry`, one object serves both roles: a -/// keypackage store ([`RegistrationService`]) keyed by `device_id`, and an -/// account → device directory ([`AccountDirectory`]) keyed by the hex account key. -#[derive(Clone, Default)] -pub struct EphemeralRegistry { - key_packages: Arc>>>, - installations: Arc>>, -} - -impl EphemeralRegistry { - pub fn new() -> Self { - Self::default() - } -} - -impl Debug for EphemeralRegistry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let registry = self.key_packages.lock().unwrap(); - let truncated: Vec<(&String, String)> = registry - .iter() - .map(|(k, v)| { - let hex = if v.len() <= 8 { - hex::encode(v) - } else { - format!( - "{}..{}", - hex::encode(&v[..4]), - hex::encode(&v[v.len() - 4..]) - ) - }; - (k, hex) - }) - .collect(); - f.debug_struct("EphemeralRegistry") - .field("registry", &truncated) - .finish() - } -} - -impl RegistrationService for EphemeralRegistry { - type Error = String; - - fn register( - &mut self, - identity: &dyn IdentityProvider, - key_bundle: Vec, - ) -> Result<(), ::Error> { - self.key_packages - .lock() - .unwrap() - .insert(identity.id().to_string(), key_bundle); - Ok(()) - } - - fn retrieve( - &self, - device_id: &str, - ) -> Result>, ::Error> { - Ok(self.key_packages.lock().unwrap().get(device_id).cloned()) - } -} - -/// Account → device directory, verifying each bundle on `fetch` exactly as the -/// HTTP client does so callers exercise the same trust path without a server. -impl AccountDirectory for EphemeralRegistry { - type Error = String; - - fn publish( - &mut self, - bundle: &SignedDeviceBundle, - ) -> Result<(), ::Error> { - self.installations - .lock() - .unwrap() - .insert(hex::encode(bundle.account_pub.as_ref()), bundle.clone()); - Ok(()) - } - - fn fetch( - &self, - account: &Ed25519VerifyingKey, - ) -> Result, ::Error> { - let Some(bundle) = self - .installations - .lock() - .unwrap() - .get(&hex::encode(account.as_ref())) - .cloned() - else { - return Ok(None); - }; - verify_bundle(account, &bundle) - .map(Some) - .map_err(|e| e.to_string()) - } -} diff --git a/extensions/components/src/contact_registry/ephemeral.rs b/extensions/components/src/contact_registry/ephemeral.rs new file mode 100644 index 0000000..8b28c70 --- /dev/null +++ b/extensions/components/src/contact_registry/ephemeral.rs @@ -0,0 +1,112 @@ +use std::{ + collections::HashMap, + fmt::Debug, + sync::{Arc, Mutex}, +}; + +use crypto::Ed25519VerifyingKey; +use libchat::{ + AccountDirectory, DeviceSet, IdentityProvider, RegistrationService, SignedDeviceBundle, + verify_bundle, +}; + +/// A Contact Registry used for Tests. +/// This implementation stores bundle bytes and then returns them when +/// retrieved. +/// +/// Like the real `keypackage-registry`, one object serves both roles: a +/// keypackage store ([`RegistrationService`]) keyed by `device_id`, and an +/// account → device directory ([`AccountDirectory`]) keyed by the hex account key. +#[derive(Clone, Default)] +pub struct EphemeralRegistry { + key_packages: Arc>>>, + installations: Arc>>, +} + +impl EphemeralRegistry { + pub fn new() -> Self { + Self::default() + } +} + +impl Debug for EphemeralRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let registry = self.key_packages.lock().unwrap(); + let truncated: Vec<(&String, String)> = registry + .iter() + .map(|(k, v)| { + let hex = if v.len() <= 8 { + hex::encode(v) + } else { + format!( + "{}..{}", + hex::encode(&v[..4]), + hex::encode(&v[v.len() - 4..]) + ) + }; + (k, hex) + }) + .collect(); + f.debug_struct("EphemeralRegistry") + .field("registry", &truncated) + .finish() + } +} + +impl RegistrationService for EphemeralRegistry { + type Error = String; + + fn register( + &mut self, + identity: &dyn IdentityProvider, + key_bundle: Vec, + ) -> Result<(), ::Error> { + self.key_packages + .lock() + .unwrap() + .insert(identity.id().to_string(), key_bundle); + Ok(()) + } + + fn retrieve( + &self, + device_id: &str, + ) -> Result>, ::Error> { + Ok(self.key_packages.lock().unwrap().get(device_id).cloned()) + } +} + +/// Account → device directory, verifying each bundle on `fetch` exactly as the +/// HTTP client does so callers exercise the same trust path without a server. +impl AccountDirectory for EphemeralRegistry { + type Error = String; + + fn publish( + &mut self, + bundle: &SignedDeviceBundle, + ) -> Result<(), ::Error> { + self.installations + .lock() + .unwrap() + .insert(hex::encode(bundle.account_pub.as_ref()), bundle.clone()); + Ok(()) + } + + fn fetch( + &self, + account: &Ed25519VerifyingKey, + ) -> Result, ::Error> { + let Some(bundle) = self + .installations + .lock() + .unwrap() + .get(&hex::encode(account.as_ref())) + .cloned() + else { + return Ok(None); + }; + verify_bundle(account, &bundle) + .map(Some) + .map_err(|e| e.to_string()) + } +} diff --git a/extensions/components/src/lib.rs b/extensions/components/src/lib.rs index 25ae825..d7cb449 100644 --- a/extensions/components/src/lib.rs +++ b/extensions/components/src/lib.rs @@ -3,7 +3,7 @@ mod delivery; mod storage; mod wakeup; -pub use contact_registry::EphemeralRegistry; +pub use contact_registry::ephemeral::EphemeralRegistry; pub use contact_registry::http::{HttpRegistry, HttpRegistryError}; pub use delivery::*; pub use storage::*; From c5b264c82735a3205bcab269b65e5bc0d3ea3b1e Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:43:55 -0700 Subject: [PATCH 2/4] Add Identified Trait for convo (#138) * Add Identified Trait for convo --- core/conversations/src/conversation.rs | 4 +++- core/conversations/src/conversation/group_v1.rs | 17 ++++++++--------- core/conversations/src/conversation/group_v2.rs | 11 +++++++---- .../conversations/src/conversation/privatev1.rs | 12 +++++++----- core/conversations/src/core.rs | 8 +++++--- core/conversations/src/inbox_v2.rs | 1 + 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/core/conversations/src/conversation.rs b/core/conversations/src/conversation.rs index c8816fa..c9377c4 100644 --- a/core/conversations/src/conversation.rs +++ b/core/conversations/src/conversation.rs @@ -15,7 +15,7 @@ pub type ConversationId = String; pub type ConversationIdRef<'a> = &'a str; /// Behaviour shared by every conversation kind. -pub(crate) trait Convo { +pub(crate) trait Convo: Identified { fn send_content(&mut self, cx: &mut ServiceContext, content: &[u8]) -> Result<(), ChatError>; @@ -40,6 +40,8 @@ pub(crate) trait GroupConvo: Convo + std::fmt::Debug + S cx: &mut ServiceContext, members: &[IdentIdRef], ) -> Result<(), ChatError>; +} +pub(crate) trait Identified { fn id(&self) -> ConversationIdRef<'_>; } diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index 0d0d433..ba59a9e 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -11,12 +11,13 @@ use prost::Message as _; use shared_traits::IdentIdRef; use crate::account_directory::{AccountDirectory, resolve_device_ids}; +use crate::conversation::ConversationIdRef; use crate::inbox_v2::MlsProvider; use crate::service_context::{ExternalServices, ServiceContext}; use crate::{ DeliveryService, IdentityProvider, - conversation::{ChatError, Convo, GroupConvo}, + conversation::{ChatError, Convo, GroupConvo, Identified}, outcomes::{Content, ConvoOutcome}, service_traits::KeyPackageProvider, types::AddressedEncryptedPayload, @@ -174,10 +175,6 @@ impl GroupV1Convo { Ok(keypackages) } - pub fn id(&self) -> &str { - &self.convo_id - } - fn send_message( &mut self, content: &[u8], @@ -205,6 +202,12 @@ impl GroupV1Convo { } } +impl Identified for GroupV1Convo { + fn id(&self) -> ConversationIdRef<'_> { + &self.convo_id + } +} + impl Convo for GroupV1Convo { fn send_content( &mut self, @@ -344,8 +347,4 @@ impl GroupConvo for GroupV1Convo { .publish(env) .map_err(|e| ChatError::Generic(format!("Publish: {e}"))) } - - fn id(&self) -> super::ConversationIdRef<'_> { - &self.convo_id - } } diff --git a/core/conversations/src/conversation/group_v2.rs b/core/conversations/src/conversation/group_v2.rs index c8a42cb..815cf10 100644 --- a/core/conversations/src/conversation/group_v2.rs +++ b/core/conversations/src/conversation/group_v2.rs @@ -31,7 +31,7 @@ use crate::IdentityProvider; use crate::conversation::{ConversationIdRef, ExternalServices, ServiceContext}; use crate::{ ConvoOutcome, DeliveryService, RegistrationService, - conversation::{ChatError, Convo, GroupConvo}, + conversation::{ChatError, Convo, GroupConvo, Identified}, }; /// Namespace used for de-mls (GroupV2) keypackages, so they don't collide @@ -272,6 +272,12 @@ impl GroupV2Convo { } } +impl Identified for GroupV2Convo { + fn id(&self) -> ConversationIdRef<'_> { + &self.convo_id + } +} + impl Convo for GroupV2Convo where S: ExternalServices, @@ -347,9 +353,6 @@ impl GroupConvo for GroupV2Convo where S: ExternalServices, { - fn id(&self) -> ConversationIdRef<'_> { - &self.convo_id - } #[instrument(name = "groupv2.add_member", skip_all, fields(user_id = %service_ctx.mls_identity.display_name()))] fn add_member( &mut self, diff --git a/core/conversations/src/conversation/privatev1.rs b/core/conversations/src/conversation/privatev1.rs index 8bb1a1e..a6e278c 100644 --- a/core/conversations/src/conversation/privatev1.rs +++ b/core/conversations/src/conversation/privatev1.rs @@ -14,7 +14,7 @@ use storage::{ConversationKind, ConversationMeta, ConversationStore}; use crate::{ DeliveryService, - conversation::{ChatError, ConversationId, Convo}, + conversation::{ChatError, ConversationId, ConversationIdRef, Convo, Identified}, errors::EncryptionError, inbox::PRIVATE_V1_INBOX_ADDRESS, outcomes::{Content, ConvoOutcome}, @@ -200,10 +200,6 @@ impl PrivateV1Convo { } } - pub fn id(&self) -> &str { - &self.local_convo_id - } - pub fn encrypt_content( &mut self, content: &[u8], @@ -235,6 +231,12 @@ impl PrivateV1Convo { } } +impl Identified for PrivateV1Convo { + fn id(&self) -> ConversationIdRef<'_> { + &self.local_convo_id + } +} + impl Convo for PrivateV1Convo { fn send_content( &mut self, diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index 0997afa..1bc5a88 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -1,5 +1,7 @@ use crate::causal_history::{CausalHistoryStore, MissingMessage}; -use crate::conversation::{ConversationIdRef, GroupV1Convo, GroupV2Convo, PrivateV1Convo}; +use crate::conversation::{ + ConversationIdRef, GroupV1Convo, GroupV2Convo, Identified, PrivateV1Convo, +}; use crate::service_context::{ExternalServices, ServiceContext}; use crate::{DeliveryService, IdentityProvider, RegistrationService, WakeupService}; use crate::{ @@ -470,8 +472,8 @@ enum ConvoTypeOwned { Group(Box>), } -impl<'a, S: ExternalServices> ConvoTypeOwned { - pub fn id(&'a self) -> ConversationIdRef<'a> { +impl Identified for ConvoTypeOwned { + fn id(&self) -> ConversationIdRef<'_> { match self { ConvoTypeOwned::Group(group_convo) => group_convo.id(), } diff --git a/core/conversations/src/inbox_v2.rs b/core/conversations/src/inbox_v2.rs index 23589ff..57f1463 100644 --- a/core/conversations/src/inbox_v2.rs +++ b/core/conversations/src/inbox_v2.rs @@ -21,6 +21,7 @@ use crate::RegistrationService; use crate::conversation::GroupConvo; use crate::conversation::GroupV1Convo; use crate::conversation::GroupV2Convo; +use crate::conversation::Identified as _; use crate::service_context::{ExternalServices, ServiceContext}; use crate::utils::{blake2b_hex, hash_size}; use crate::{ From 7612b233c9e87452dc2f7d0f6b341e6ba2a770b7 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:01:17 -0700 Subject: [PATCH 3/4] Add 1:1 Chats using Groups (#139) * Add Identified Trait for convo * Add PrivateV2Convo * Rename to DirectV1 * Rename ConvoTypeOwned variant * Update DirectV1 to support multiple members * Apply suggestion from @kaichaosun Co-authored-by: kaichao --------- Co-authored-by: kaichao --- core/conversations/src/conversation.rs | 4 +- .../src/conversation/direct_v1.rs | 61 +++++++++++++++++++ core/conversations/src/core.rs | 43 ++++++++++++- core/conversations/src/errors.rs | 5 ++ .../tests/private_integration.rs | 6 +- .../tests/test_direct_v1.rs | 43 +++++++++++++ crates/client/src/client.rs | 2 +- 7 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 core/conversations/src/conversation/direct_v1.rs create mode 100644 core/integration_tests_core/tests/test_direct_v1.rs diff --git a/core/conversations/src/conversation.rs b/core/conversations/src/conversation.rs index c9377c4..6f17892 100644 --- a/core/conversations/src/conversation.rs +++ b/core/conversations/src/conversation.rs @@ -1,3 +1,4 @@ +mod direct_v1; pub mod group_v1; mod group_v2; mod privatev1; @@ -6,6 +7,7 @@ pub use crate::errors::ChatError; use crate::outcomes::ConvoOutcome; use crate::proto::EncryptedPayload; use crate::service_context::{ExternalServices, ServiceContext}; +pub use direct_v1::DirectV1Convo; pub use group_v1::GroupV1Convo; pub use group_v2::GroupV2Convo; pub use privatev1::PrivateV1Convo; @@ -15,7 +17,7 @@ pub type ConversationId = String; pub type ConversationIdRef<'a> = &'a str; /// Behaviour shared by every conversation kind. -pub(crate) trait Convo: Identified { +pub(crate) trait Convo: Identified + Send { fn send_content(&mut self, cx: &mut ServiceContext, content: &[u8]) -> Result<(), ChatError>; diff --git a/core/conversations/src/conversation/direct_v1.rs b/core/conversations/src/conversation/direct_v1.rs new file mode 100644 index 0000000..f75bdb0 --- /dev/null +++ b/core/conversations/src/conversation/direct_v1.rs @@ -0,0 +1,61 @@ +use chat_proto::logoschat::encryption::EncryptedPayload; +use shared_traits::IdentIdRef; + +use crate::{ + ChatError, ExternalServices, + conversation::{ConversationIdRef, Convo, GroupConvo, GroupV1Convo, Identified}, + service_context::ServiceContext, +}; + +type DelegateGroup = GroupV1Convo; + +/// A Conversation between two participants. +#[derive(Debug)] +pub struct DirectV1Convo { + inner_group: DelegateGroup, +} + +impl DirectV1Convo { + // Constructor must accept multiple IdentId's + // While the conversation is limited to 2 participants, each participants may + // have multiple Installations. + pub fn new( + cx: &mut ServiceContext, + members: &[IdentIdRef], + ) -> Result { + let mut inner_group = DelegateGroup::new(cx)?; + inner_group.add_member(cx, members)?; + Ok(Self { inner_group }) + } +} + +impl Identified for DirectV1Convo { + fn id(&self) -> ConversationIdRef<'_> { + self.inner_group.id() + } +} + +impl Convo for DirectV1Convo +where + S: ExternalServices, +{ + fn send_content( + &mut self, + cx: &mut ServiceContext, + content: &[u8], + ) -> Result<(), super::ChatError> { + self.inner_group.send_content(cx, content) + } + + fn handle_frame( + &mut self, + cx: &mut ServiceContext, + enc: EncryptedPayload, + ) -> Result { + self.inner_group.handle_frame(cx, enc) + } + + fn wakeup(&mut self, service_ctx: &mut ServiceContext) -> Result<(), ChatError> { + self.inner_group.wakeup(service_ctx) + } +} diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index 1bc5a88..d88a4a8 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -1,6 +1,6 @@ use crate::causal_history::{CausalHistoryStore, MissingMessage}; use crate::conversation::{ - ConversationIdRef, GroupV1Convo, GroupV2Convo, Identified, PrivateV1Convo, + ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, PrivateV1Convo, }; use crate::service_context::{ExternalServices, ServiceContext}; use crate::{DeliveryService, IdentityProvider, RegistrationService, WakeupService}; @@ -16,6 +16,7 @@ use crypto::{Identity, PublicKey}; use openmls::group::GroupId; use shared_traits::IdentIdRef; use std::collections::HashMap; +use std::fmt::Debug; use storage::{ChatStore, ConversationKind, ConversationStore}; use tracing::{info, instrument}; @@ -188,6 +189,14 @@ impl<'a, S: ExternalServices + 'static> Core { &mut self, remote_bundle: &Introduction, content: &[u8], + ) -> Result { + self.create_private_convo_v1(remote_bundle, content) + } + + pub fn create_private_convo_v1( + &mut self, + remote_bundle: &Introduction, + content: &[u8], ) -> Result { let (mut convo, payloads) = self.inbox @@ -204,6 +213,17 @@ impl<'a, S: ExternalServices + 'static> Core { Ok(convo_id) } + pub fn create_direct_convo_v1( + &mut self, + members: &[IdentIdRef], + ) -> Result { + let convo = DirectV1Convo::new(&mut self.services, members)?; + let convo_id = convo.id().to_string(); + self.register_convo(ConvoTypeOwned::Direct(Box::new(convo)))?; + + Ok(convo_id) + } + pub fn create_group_convo( &mut self, participants: &[IdentIdRef], @@ -266,6 +286,10 @@ impl<'a, S: ExternalServices + 'static> Core { ConvoTypeOwned::Group(group_convo) => { group_convo.add_member(&mut self.services, members) } + ConvoTypeOwned::Direct(convo) => Err(ChatError::UnsupportedFunction( + convo.id().into(), + "Add Member".into(), + )), } } else { let mut convo = self.load_group_convo(convo_id)?; @@ -397,6 +421,7 @@ impl<'a, S: ExternalServices + 'static> Core { }; let convo = match convo { ConvoTypeOwned::Group(c) => c.as_mut(), + ConvoTypeOwned::Direct(c) => c.as_mut(), }; convo.wakeup(&mut self.services) @@ -466,15 +491,24 @@ impl<'a, S: ExternalServices + 'static> Core { } } -#[derive(Debug)] enum ConvoTypeOwned { - // Pairwise(Box>), + Direct(Box>), Group(Box>), } +impl Debug for ConvoTypeOwned { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Direct(arg0) => f.debug_tuple("Pairwise").field(&arg0.id()).finish(), + Self::Group(arg0) => f.debug_tuple("Group").field(&arg0.id()).finish(), + } + } +} + impl Identified for ConvoTypeOwned { fn id(&self) -> ConversationIdRef<'_> { match self { + ConvoTypeOwned::Direct(convo) => convo.id(), ConvoTypeOwned::Group(group_convo) => group_convo.id(), } } @@ -488,6 +522,7 @@ impl Convo for ConvoTypeOwned { ) -> Result<(), ChatError> { match self { ConvoTypeOwned::Group(group_convo) => group_convo.send_content(cx, content), + ConvoTypeOwned::Direct(convo) => convo.send_content(cx, content), } } @@ -498,12 +533,14 @@ impl Convo for ConvoTypeOwned { ) -> Result { match self { ConvoTypeOwned::Group(group_convo) => group_convo.handle_frame(cx, enc), + ConvoTypeOwned::Direct(convo) => convo.handle_frame(cx, enc), } } fn wakeup(&mut self, service_ctx: &mut ServiceContext) -> Result<(), ChatError> { match self { ConvoTypeOwned::Group(group_convo) => group_convo.wakeup(service_ctx), + ConvoTypeOwned::Direct(convo) => convo.wakeup(service_ctx), } } } diff --git a/core/conversations/src/errors.rs b/core/conversations/src/errors.rs index 879e923..4126582 100644 --- a/core/conversations/src/errors.rs +++ b/core/conversations/src/errors.rs @@ -4,6 +4,8 @@ pub use thiserror::Error; use storage::StorageError; +use crate::ConversationId; + #[derive(Error, Debug)] pub enum ChatError { #[error("protocol error: {0:?}")] @@ -42,6 +44,9 @@ pub enum ChatError { MlsError(#[from] MlsError), #[error("demls error: {0}")] DeMlsError(#[from] ConversationError), + // Used when a core function is called with a convo_id which is unsupported + #[error("convo:{0} does not support {1}")] + UnsupportedFunction(ConversationId, String), } impl ChatError { diff --git a/core/integration_tests_core/tests/private_integration.rs b/core/integration_tests_core/tests/private_integration.rs index 61e9584..55819e0 100644 --- a/core/integration_tests_core/tests/private_integration.rs +++ b/core/integration_tests_core/tests/private_integration.rs @@ -87,7 +87,7 @@ fn ctx_integration() { // Saro initiates conversation with Raya let mut content = vec![10]; - let saro_convo_id = saro.create_private_convo(&intro, &content).unwrap(); + let saro_convo_id = saro.create_private_convo_v1(&intro, &content).unwrap(); // Raya receives the invite + initial message let initial = recv_one(&mut raya); @@ -178,7 +178,7 @@ fn conversation_metadata_persistence() { let bundle = alice.create_intro_bundle().unwrap(); let intro = Introduction::try_from(bundle.as_slice()).unwrap(); - bob.create_private_convo(&intro, b"hi").unwrap(); + bob.create_private_convo_v1(&intro, b"hi").unwrap(); let result = recv_one(&mut alice); let PayloadOutcome::Inbox(io) = result else { @@ -219,7 +219,7 @@ fn conversation_full_flow() { let bundle = alice.create_intro_bundle().unwrap(); let intro = Introduction::try_from(bundle.as_slice()).unwrap(); - let bob_convo_id = bob.create_private_convo(&intro, b"hello").unwrap(); + let bob_convo_id = bob.create_private_convo_v1(&intro, b"hello").unwrap(); let result = recv_one(&mut alice); let PayloadOutcome::Inbox(io) = result else { diff --git a/core/integration_tests_core/tests/test_direct_v1.rs b/core/integration_tests_core/tests/test_direct_v1.rs new file mode 100644 index 0000000..da050d1 --- /dev/null +++ b/core/integration_tests_core/tests/test_direct_v1.rs @@ -0,0 +1,43 @@ +use integration_tests_core::TestHarness; +use tracing::info; + +#[test] +fn happypath_roundtrip() { + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_test_writer() + .try_init(); + + const S_M1: &[u8] = b"Marco"; + const R_M1: &[u8] = b"Polo"; + + // Initialize TestHarness with 2 clients + let mut harness = TestHarness::<2>::new(|_, _| {}); + + //Saro Create Convo + let particpant = harness.raya().addr(); + let convo_id = harness + .saro() + .create_direct_convo_v1(&[&particpant]) + .expect("saro create group"); + + // Carry the invite through (commit, WelcomeReady, routing to Raya's inbox, + // accept_welcome); settle until Raya has joined. + harness.process_until_label("Saro Send", |h| h.raya().convo_count() == 1); + + // Saro sends a message; settle until Raya receives it. + info!(target: "chat", "Saro -> sending: {S_M1:?}"); + harness + .saro() + .send_content(&convo_id, S_M1) + .expect("saro send"); + + harness.process_until(|h| h.raya().check(&convo_id, S_M1)); + + // Raya replies; settle until Saro receives it. + info!(target: "chat", "Raya -> sending:{R_M1:?}"); + harness.raya().send_content(&convo_id, R_M1).unwrap(); + harness.process_until(|h| h.saro().check(&convo_id, R_M1)); + + assert!(harness.saro().check(&convo_id, R_M1)); +} diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 17b5439..170030a 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -168,7 +168,7 @@ where let intro = Introduction::try_from(intro_bundle)?; self.core .lock() - .create_private_convo(&intro, initial_content) + .create_private_convo_v1(&intro, initial_content) .map_err(Into::into) } From 1c984f442cb52dcec50d48a3013b9a83d03441dd Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:44:27 -0700 Subject: [PATCH 4/4] Add client path for DirectConvo (#140) * Add PrivateV2Convo * Rename to DirectV1 * Update DirectV1 to support multiple members * Add client path for DirectConvo --- core/conversations/src/core.rs | 7 +++---- crates/client/src/client.rs | 31 +++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index d88a4a8..37fbe71 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -185,12 +185,11 @@ impl<'a, S: ExternalServices + 'static> Core { self.services.identity.public_key() } - pub fn create_private_convo( + pub fn create_direct_convo( &mut self, - remote_bundle: &Introduction, - content: &[u8], + members: &[IdentIdRef], ) -> Result { - self.create_private_convo_v1(remote_bundle, content) + self.create_direct_convo_v1(members) } pub fn create_private_convo_v1( diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 170030a..4337bf9 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -4,8 +4,8 @@ use std::thread::{self, JoinHandle}; use components::{EphemeralRegistry, ThreadedWakeupService, WakeupEvent}; use crossbeam_channel::{Receiver, Sender, select}; use libchat::{ - ChatError, ChatStorage, ConversationId, ConvoOutcome, Core, DeliveryService, InboxOutcome, - Introduction, PayloadOutcome, RegistrationService, StorageConfig, + ChatError, ChatStorage, ConversationId, ConvoOutcome, Core, DeliveryService, IdentId, + IdentIdRef, InboxOutcome, Introduction, PayloadOutcome, RegistrationService, StorageConfig, }; use logos_account::TestLogosAccount; use parking_lot::Mutex; @@ -14,6 +14,8 @@ use crate::errors::ClientError; use crate::event::Event; type ClientCore = Core<(TestLogosAccount, T, R, ThreadedWakeupService, ChatStorage)>; +type AccountAddressRef<'a> = &'a str; +type LocalSignerId = IdentId; /// The transport as the client sees it: a [`DeliveryService`] for outbound /// publishing plus the inbound payload stream the worker drains. One object owns @@ -158,8 +160,24 @@ where self.core.lock().create_intro_bundle().map_err(Into::into) } + // Creates a conversation between two Accounts. + pub fn create_direct_conversation( + &mut self, + account: AccountAddressRef, + ) -> Result { + let signers = self.signers_from_account(account)?; + let signer_refs: Vec = signers.iter().collect(); + + self.core + .lock() + .create_direct_convo(&signer_refs) + .map_err(Into::into) + } + /// Parse intro bundle bytes and initiate a private conversation. Outbound /// envelopes are published by the core. Returns this side's conversation ID. + /// + /// This function will be deprecated in the future. Use `create_direct_conversation` pub fn create_conversation( &mut self, intro_bundle: &[u8], @@ -185,6 +203,15 @@ where .send_content(convo_id, content) .map_err(Into::into) } + + // Get signers for a given AccountAddress. + fn signers_from_account( + &self, + account: AccountAddressRef, + ) -> Result, ClientError> { + // Assume Account = LocalSigner until Account is ready + Ok(vec![IdentId::new(account.to_string())]) + } } impl Drop for ChatClient {