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 <kaichaosuna@gmail.com>

---------

Co-authored-by: kaichao <kaichaosuna@gmail.com>
This commit is contained in:
Jazz Turner-Baggs 2026-06-19 12:01:17 -07:00 committed by GitHub
parent c5b264c827
commit 7612b233c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 156 additions and 8 deletions

View File

@ -1,3 +1,4 @@
mod direct_v1;
pub mod group_v1; pub mod group_v1;
mod group_v2; mod group_v2;
mod privatev1; mod privatev1;
@ -6,6 +7,7 @@ pub use crate::errors::ChatError;
use crate::outcomes::ConvoOutcome; use crate::outcomes::ConvoOutcome;
use crate::proto::EncryptedPayload; use crate::proto::EncryptedPayload;
use crate::service_context::{ExternalServices, ServiceContext}; use crate::service_context::{ExternalServices, ServiceContext};
pub use direct_v1::DirectV1Convo;
pub use group_v1::GroupV1Convo; pub use group_v1::GroupV1Convo;
pub use group_v2::GroupV2Convo; pub use group_v2::GroupV2Convo;
pub use privatev1::PrivateV1Convo; pub use privatev1::PrivateV1Convo;
@ -15,7 +17,7 @@ pub type ConversationId = String;
pub type ConversationIdRef<'a> = &'a str; pub type ConversationIdRef<'a> = &'a str;
/// Behaviour shared by every conversation kind. /// Behaviour shared by every conversation kind.
pub(crate) trait Convo<S: ExternalServices>: Identified { pub(crate) trait Convo<S: ExternalServices>: Identified + Send {
fn send_content(&mut self, cx: &mut ServiceContext<S>, content: &[u8]) fn send_content(&mut self, cx: &mut ServiceContext<S>, content: &[u8])
-> Result<(), ChatError>; -> Result<(), ChatError>;

View File

@ -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<S: ExternalServices>(
cx: &mut ServiceContext<S>,
members: &[IdentIdRef],
) -> Result<Self, ChatError> {
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<S> Convo<S> for DirectV1Convo
where
S: ExternalServices,
{
fn send_content(
&mut self,
cx: &mut ServiceContext<S>,
content: &[u8],
) -> Result<(), super::ChatError> {
self.inner_group.send_content(cx, content)
}
fn handle_frame(
&mut self,
cx: &mut ServiceContext<S>,
enc: EncryptedPayload,
) -> Result<crate::ConvoOutcome, ChatError> {
self.inner_group.handle_frame(cx, enc)
}
fn wakeup(&mut self, service_ctx: &mut ServiceContext<S>) -> Result<(), ChatError> {
self.inner_group.wakeup(service_ctx)
}
}

View File

@ -1,6 +1,6 @@
use crate::causal_history::{CausalHistoryStore, MissingMessage}; use crate::causal_history::{CausalHistoryStore, MissingMessage};
use crate::conversation::{ use crate::conversation::{
ConversationIdRef, GroupV1Convo, GroupV2Convo, Identified, PrivateV1Convo, ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, PrivateV1Convo,
}; };
use crate::service_context::{ExternalServices, ServiceContext}; use crate::service_context::{ExternalServices, ServiceContext};
use crate::{DeliveryService, IdentityProvider, RegistrationService, WakeupService}; use crate::{DeliveryService, IdentityProvider, RegistrationService, WakeupService};
@ -16,6 +16,7 @@ use crypto::{Identity, PublicKey};
use openmls::group::GroupId; use openmls::group::GroupId;
use shared_traits::IdentIdRef; use shared_traits::IdentIdRef;
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::Debug;
use storage::{ChatStore, ConversationKind, ConversationStore}; use storage::{ChatStore, ConversationKind, ConversationStore};
use tracing::{info, instrument}; use tracing::{info, instrument};
@ -188,6 +189,14 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
&mut self, &mut self,
remote_bundle: &Introduction, remote_bundle: &Introduction,
content: &[u8], content: &[u8],
) -> Result<ConversationId, ChatError> {
self.create_private_convo_v1(remote_bundle, content)
}
pub fn create_private_convo_v1(
&mut self,
remote_bundle: &Introduction,
content: &[u8],
) -> Result<ConversationId, ChatError> { ) -> Result<ConversationId, ChatError> {
let (mut convo, payloads) = let (mut convo, payloads) =
self.inbox self.inbox
@ -204,6 +213,17 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
Ok(convo_id) Ok(convo_id)
} }
pub fn create_direct_convo_v1(
&mut self,
members: &[IdentIdRef],
) -> Result<ConversationId, ChatError> {
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( pub fn create_group_convo(
&mut self, &mut self,
participants: &[IdentIdRef], participants: &[IdentIdRef],
@ -266,6 +286,10 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
ConvoTypeOwned::Group(group_convo) => { ConvoTypeOwned::Group(group_convo) => {
group_convo.add_member(&mut self.services, members) group_convo.add_member(&mut self.services, members)
} }
ConvoTypeOwned::Direct(convo) => Err(ChatError::UnsupportedFunction(
convo.id().into(),
"Add Member".into(),
)),
} }
} else { } else {
let mut convo = self.load_group_convo(convo_id)?; let mut convo = self.load_group_convo(convo_id)?;
@ -397,6 +421,7 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
}; };
let convo = match convo { let convo = match convo {
ConvoTypeOwned::Group(c) => c.as_mut(), ConvoTypeOwned::Group(c) => c.as_mut(),
ConvoTypeOwned::Direct(c) => c.as_mut(),
}; };
convo.wakeup(&mut self.services) convo.wakeup(&mut self.services)
@ -466,15 +491,24 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
} }
} }
#[derive(Debug)]
enum ConvoTypeOwned<S: ExternalServices> { enum ConvoTypeOwned<S: ExternalServices> {
// Pairwise(Box<dyn BaseConvo<S>>), Direct(Box<dyn Convo<S>>),
Group(Box<dyn GroupConvo<S>>), Group(Box<dyn GroupConvo<S>>),
} }
impl<S: ExternalServices> Debug for ConvoTypeOwned<S> {
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<S: ExternalServices> Identified for ConvoTypeOwned<S> { impl<S: ExternalServices> Identified for ConvoTypeOwned<S> {
fn id(&self) -> ConversationIdRef<'_> { fn id(&self) -> ConversationIdRef<'_> {
match self { match self {
ConvoTypeOwned::Direct(convo) => convo.id(),
ConvoTypeOwned::Group(group_convo) => group_convo.id(), ConvoTypeOwned::Group(group_convo) => group_convo.id(),
} }
} }
@ -488,6 +522,7 @@ impl<S: ExternalServices> Convo<S> for ConvoTypeOwned<S> {
) -> Result<(), ChatError> { ) -> Result<(), ChatError> {
match self { match self {
ConvoTypeOwned::Group(group_convo) => group_convo.send_content(cx, content), ConvoTypeOwned::Group(group_convo) => group_convo.send_content(cx, content),
ConvoTypeOwned::Direct(convo) => convo.send_content(cx, content),
} }
} }
@ -498,12 +533,14 @@ impl<S: ExternalServices> Convo<S> for ConvoTypeOwned<S> {
) -> Result<ConvoOutcome, ChatError> { ) -> Result<ConvoOutcome, ChatError> {
match self { match self {
ConvoTypeOwned::Group(group_convo) => group_convo.handle_frame(cx, enc), 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<S>) -> Result<(), ChatError> { fn wakeup(&mut self, service_ctx: &mut ServiceContext<S>) -> Result<(), ChatError> {
match self { match self {
ConvoTypeOwned::Group(group_convo) => group_convo.wakeup(service_ctx), ConvoTypeOwned::Group(group_convo) => group_convo.wakeup(service_ctx),
ConvoTypeOwned::Direct(convo) => convo.wakeup(service_ctx),
} }
} }
} }

View File

@ -4,6 +4,8 @@ pub use thiserror::Error;
use storage::StorageError; use storage::StorageError;
use crate::ConversationId;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum ChatError { pub enum ChatError {
#[error("protocol error: {0:?}")] #[error("protocol error: {0:?}")]
@ -42,6 +44,9 @@ pub enum ChatError {
MlsError(#[from] MlsError), MlsError(#[from] MlsError),
#[error("demls error: {0}")] #[error("demls error: {0}")]
DeMlsError(#[from] ConversationError), 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 { impl ChatError {

View File

@ -87,7 +87,7 @@ fn ctx_integration() {
// Saro initiates conversation with Raya // Saro initiates conversation with Raya
let mut content = vec![10]; 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 // Raya receives the invite + initial message
let initial = recv_one(&mut raya); let initial = recv_one(&mut raya);
@ -178,7 +178,7 @@ fn conversation_metadata_persistence() {
let bundle = alice.create_intro_bundle().unwrap(); let bundle = alice.create_intro_bundle().unwrap();
let intro = Introduction::try_from(bundle.as_slice()).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 result = recv_one(&mut alice);
let PayloadOutcome::Inbox(io) = result else { let PayloadOutcome::Inbox(io) = result else {
@ -219,7 +219,7 @@ fn conversation_full_flow() {
let bundle = alice.create_intro_bundle().unwrap(); let bundle = alice.create_intro_bundle().unwrap();
let intro = Introduction::try_from(bundle.as_slice()).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 result = recv_one(&mut alice);
let PayloadOutcome::Inbox(io) = result else { let PayloadOutcome::Inbox(io) = result else {

View File

@ -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));
}

View File

@ -168,7 +168,7 @@ where
let intro = Introduction::try_from(intro_bundle)?; let intro = Introduction::try_from(intro_bundle)?;
self.core self.core
.lock() .lock()
.create_private_convo(&intro, initial_content) .create_private_convo_v1(&intro, initial_content)
.map_err(Into::into) .map_err(Into::into)
} }