mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-08-03 13:43:18 +00:00
Add group description (#161)
Update config Update constructor calls Enable groupContext for GV2 Remove test group type Add test temporary dep; waiting for upstream Remove owner Doc MLS extension type Add ConvoMetaInfoVersion Update infallable function docs strongly typed metadata Cleanup tests Pin de-mls revision full remove owner
This commit is contained in:
parent
8da9e4da18
commit
c089144dc9
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -1817,7 +1817,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "de-mls"
|
||||
version = "4.0.0"
|
||||
source = "git+https://github.com/vacp2p/de-mls?branch=main#e7d2726fd005e56329f00f91e8c07672a3d3639b"
|
||||
source = "git+https://github.com/vacp2p/de-mls?rev=2c7a8669c1492c749c02efd2c5ac45e93e4926a3#2c7a8669c1492c749c02efd2c5ac45e93e4926a3"
|
||||
dependencies = [
|
||||
"hashgraph-like-consensus",
|
||||
"indexmap 2.14.0",
|
||||
|
||||
@ -18,7 +18,7 @@ storage = { workspace = true }
|
||||
alloy = "2.0"
|
||||
base64 = "0.22"
|
||||
chat-proto = { git = "https://github.com/logos-messaging/chat_proto", rev = "37ec98a151f6d50aab2905802ac0a896477e62ea" }
|
||||
de-mls = { git = "https://github.com/vacp2p/de-mls", branch = "main" }
|
||||
de-mls = { git = "https://github.com/vacp2p/de-mls", rev = "2c7a8669c1492c749c02efd2c5ac45e93e4926a3"} # Expose Mls Extensions (#131)
|
||||
double-ratchets = { path = "../double-ratchets" }
|
||||
hashgraph-like-consensus = "0.6.0"
|
||||
hex = "0.4.3"
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
mod direct_v1;
|
||||
pub mod group_v1;
|
||||
mod group_v2;
|
||||
pub mod mls_extensions;
|
||||
mod privatev1;
|
||||
|
||||
pub use crate::errors::ChatError;
|
||||
use crate::outcomes::ConvoOutcome;
|
||||
use crate::proto::EncryptedPayload;
|
||||
use crate::service_context::{ExternalServices, ServiceContext};
|
||||
use crate::types::ConvoMetadata;
|
||||
pub use direct_v1::DirectV1Convo;
|
||||
pub use group_v1::GroupV1Convo;
|
||||
pub use group_v2::{GroupV2Clock, GroupV2Convo};
|
||||
@ -46,6 +48,10 @@ pub(crate) trait GroupConvo<S: ExternalServices>: Convo<S> + std::fmt::Debug + S
|
||||
/// Each current member's MLS leaf-credential content (hex-encoded), self
|
||||
/// included.
|
||||
fn members(&self) -> Result<Vec<Vec<u8>>, ChatError>;
|
||||
// All GroupConvos MUST return ConvoMetadata
|
||||
// the return type is Option<_> to support legacy ConvoTypes which
|
||||
// are being phased out.
|
||||
fn metadata(&self) -> Option<ConvoMetadata>;
|
||||
}
|
||||
|
||||
pub(crate) trait Identified {
|
||||
|
||||
@ -16,6 +16,7 @@ use crate::conversation::ConversationIdRef;
|
||||
use crate::inbox_v2::MlsProvider;
|
||||
use crate::service_context::{ExternalServices, ServiceContext};
|
||||
|
||||
use crate::types::ConvoMetadata;
|
||||
use crate::utils::{blake2b_hex, hash_size};
|
||||
use crate::{
|
||||
DeliveryService, IdentityProvider,
|
||||
@ -349,4 +350,8 @@ impl<S: ExternalServices> GroupConvo<S> for GroupV1Convo {
|
||||
.map(|m| m.credential.serialized_content().to_vec())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn metadata(&self) -> Option<ConvoMetadata> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
// DeMLS and Libchat have different execution models, trait definitions and ownership/lifetimes of objects.
|
||||
// The easies path is to do a Spike to see what it would take, gather the friction points and then iterate.
|
||||
|
||||
use crate::types::AddressedEncryptedPayload;
|
||||
use crate::conversation::mls_extensions::{
|
||||
ConvoMetaInfo, GROUP_METADATA_EXTENSION_TYPE, capabilities_with_group_metadata,
|
||||
};
|
||||
use crate::types::{AddressedEncryptedPayload, ConvoMetadata};
|
||||
use crate::{Content, WakeupService};
|
||||
use alloy::signers::local::PrivateKeySigner;
|
||||
use blake2::{Blake2b, Digest, digest::consts::U6};
|
||||
@ -16,9 +19,11 @@ use de_mls::{
|
||||
defaults::{DefaultConsensusPlugin, DefaultPeerScoring, InMemoryPeerScoreStorage},
|
||||
};
|
||||
use hashgraph_like_consensus::signing::EthereumConsensusSigner;
|
||||
use openmls::extensions::{Extension, Extensions, UnknownExtension};
|
||||
use openmls::group::MlsGroupCreateConfig;
|
||||
use openmls::prelude::tls_codec::Deserialize as _;
|
||||
use openmls::prelude::{KeyPackageIn, OpenMlsProvider as _, ProtocolVersion};
|
||||
use openmls_traits::crypto::OpenMlsCrypto;
|
||||
use prost::Message;
|
||||
use shared_traits::{IdentId, IdentIdRef};
|
||||
use std::sync::Arc;
|
||||
@ -103,23 +108,41 @@ fn rand_string(n: usize) -> String {
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn group_config() -> MlsGroupCreateConfig {
|
||||
fn group_config<S: ExternalServices>(
|
||||
cx: &mut ServiceContext<S>,
|
||||
name: &str,
|
||||
desc: &str,
|
||||
) -> MlsGroupCreateConfig {
|
||||
let meta = ConvoMetaInfo::new(name, desc);
|
||||
|
||||
let extensions = Extensions::from_vec(vec![Extension::Unknown(
|
||||
GROUP_METADATA_EXTENSION_TYPE,
|
||||
UnknownExtension(meta.to_extension_bytes()),
|
||||
)])
|
||||
.expect("failed to create extensions");
|
||||
|
||||
MlsGroupCreateConfig::builder()
|
||||
.use_ratchet_tree_extension(true)
|
||||
.ciphersuite(cx.mls_provider.crypto().supported_ciphersuites()[0])
|
||||
.capabilities(capabilities_with_group_metadata())
|
||||
.use_ratchet_tree_extension(true) // Embed the ratchet tree in the Welcome so joiners can build the group
|
||||
.with_group_context_extensions(extensions)
|
||||
.build()
|
||||
}
|
||||
|
||||
impl GroupV2Convo {
|
||||
pub fn new<S: ExternalServices>(
|
||||
service_ctx: &mut ServiceContext<S>,
|
||||
name: &str,
|
||||
desc: &str,
|
||||
) -> Result<Self, ChatError> {
|
||||
let convo_id = rand_string(5);
|
||||
let group_config = group_config(service_ctx, name, desc);
|
||||
let conversation = Conversation::create(
|
||||
&convo_id,
|
||||
&member_id(service_ctx),
|
||||
&service_ctx.mls_provider,
|
||||
service_ctx.mls_identity.get_credential(),
|
||||
&group_config(),
|
||||
&group_config,
|
||||
&service_ctx.mls_identity,
|
||||
&make_consensus(),
|
||||
make_scoring(),
|
||||
@ -370,6 +393,19 @@ where
|
||||
Ok(members)
|
||||
}
|
||||
|
||||
fn metadata(&self) -> Option<ConvoMetadata> {
|
||||
let res = self.conversation.extensions().iter().find_map(|ext| {
|
||||
if let Extension::Unknown(ext_type, UnknownExtension(bytes)) = ext
|
||||
&& *ext_type == GROUP_METADATA_EXTENSION_TYPE
|
||||
{
|
||||
return ConvoMetaInfo::from_extension_bytes(bytes).ok();
|
||||
};
|
||||
None
|
||||
});
|
||||
|
||||
res.map(Into::into)
|
||||
}
|
||||
|
||||
// fn conversation_state(&self) -> Result<ConversationState, ChatError> {
|
||||
// Ok(self
|
||||
// .conversation
|
||||
|
||||
120
core/conversations/src/conversation/mls_extensions.rs
Normal file
120
core/conversations/src/conversation/mls_extensions.rs
Normal file
@ -0,0 +1,120 @@
|
||||
use openmls::{
|
||||
extensions::ExtensionType,
|
||||
prelude::{
|
||||
Capabilities,
|
||||
tls_codec::{Deserialize, Error as TlsError, Serialize, Size, VLByteSlice, VLBytes},
|
||||
},
|
||||
};
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use crate::types::ConvoMetadata;
|
||||
|
||||
/// MLS extension type carrying our [`ConvoMetadata`]. In the private-use
|
||||
/// range (0xF000–0xFFFF) reserved by RFC 9420 for non-registered extensions.
|
||||
pub const GROUP_METADATA_EXTENSION_TYPE: u16 = 0xFF01;
|
||||
|
||||
pub fn capabilities_with_group_metadata() -> Capabilities {
|
||||
Capabilities::new(
|
||||
None, // default protocol versions
|
||||
None, // default ciphersuites
|
||||
Some(&[ExtensionType::Unknown(GROUP_METADATA_EXTENSION_TYPE)]),
|
||||
None, // default proposal types
|
||||
None, // default credential types
|
||||
)
|
||||
}
|
||||
|
||||
/// Wire-format version of [`ConvoMetaInfo`], encoded as a `u16`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u16)]
|
||||
enum ConvoMetaInfoVersion {
|
||||
V1 = 1,
|
||||
}
|
||||
|
||||
impl TryFrom<u16> for ConvoMetaInfoVersion {
|
||||
type Error = TlsError;
|
||||
|
||||
fn try_from(value: u16) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
1 => Ok(Self::V1),
|
||||
other => Err(TlsError::DecodingError(format!(
|
||||
"unknown ConvoMetaInfo version {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConvoMetaInfo {
|
||||
version: ConvoMetaInfoVersion,
|
||||
name: String,
|
||||
desc: String,
|
||||
}
|
||||
|
||||
impl ConvoMetaInfo {
|
||||
pub fn new(name: impl Into<String>, desc: impl Into<String>) -> Self {
|
||||
Self {
|
||||
version: ConvoMetaInfoVersion::V1,
|
||||
name: name.into(),
|
||||
desc: desc.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_extension_bytes(&self) -> Vec<u8> {
|
||||
// TLS presentation-language encoding — matches the MLS stack's wire
|
||||
// format. Writing to a Vec is infallible; the only error path is a
|
||||
// field exceeding tls_codec's ~1 GiB length cap, unreachable here.
|
||||
self.tls_serialize_detached()
|
||||
.expect("ConvoMetaInfo serialization to Vec is infallible")
|
||||
}
|
||||
|
||||
pub fn from_extension_bytes(bytes: &[u8]) -> Result<Self, TlsError> {
|
||||
Self::tls_deserialize(&mut &bytes[..])
|
||||
}
|
||||
}
|
||||
|
||||
// Each field is encoded as a variable-length opaque (`opaque <V>`); `IdentId`
|
||||
// and `String` aren't `tls_codec` types, so we encode/decode their UTF-8 bytes.
|
||||
impl Size for ConvoMetaInfo {
|
||||
fn tls_serialized_len(&self) -> usize {
|
||||
(self.version as u16).tls_serialized_len()
|
||||
+ VLByteSlice(self.name.as_bytes()).tls_serialized_len()
|
||||
+ VLByteSlice(self.desc.as_bytes()).tls_serialized_len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ConvoMetaInfo {
|
||||
fn tls_serialize<W: Write>(&self, writer: &mut W) -> Result<usize, TlsError> {
|
||||
let mut written = (self.version as u16).tls_serialize(writer)?;
|
||||
written += VLByteSlice(self.name.as_bytes()).tls_serialize(writer)?;
|
||||
written += VLByteSlice(self.desc.as_bytes()).tls_serialize(writer)?;
|
||||
Ok(written)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deserialize for ConvoMetaInfo {
|
||||
fn tls_deserialize<R: Read>(bytes: &mut R) -> Result<Self, TlsError> {
|
||||
let version = ConvoMetaInfoVersion::try_from(u16::tls_deserialize(bytes)?)?;
|
||||
let name = vl_string(bytes)?;
|
||||
let desc = vl_string(bytes)?;
|
||||
Ok(Self {
|
||||
version,
|
||||
name,
|
||||
desc,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn vl_string<R: Read>(bytes: &mut R) -> Result<String, TlsError> {
|
||||
let raw = VLBytes::tls_deserialize(bytes)?;
|
||||
String::from_utf8(raw.into())
|
||||
.map_err(|_| TlsError::DecodingError("invalid utf-8 in ConvoMetaInfo".into()))
|
||||
}
|
||||
|
||||
impl From<ConvoMetaInfo> for ConvoMetadata {
|
||||
fn from(value: ConvoMetaInfo) -> Self {
|
||||
Self {
|
||||
name: value.name,
|
||||
desc: value.desc,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ use crate::conversation::{
|
||||
ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, PrivateV1Convo,
|
||||
};
|
||||
use crate::service_context::{ExternalServices, ServiceContext};
|
||||
use crate::types::ConvoMetadata;
|
||||
use crate::{
|
||||
DeliveryService, GroupV2Clock, GroupV2Config, IdentityProvider, RegistrationService,
|
||||
WakeupService,
|
||||
@ -240,7 +241,7 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
|
||||
&mut self,
|
||||
participants: &[IdentIdRef],
|
||||
) -> Result<ConversationId, ChatError> {
|
||||
self.create_group_convo_v2(participants)
|
||||
self.create_group_convo_v2(participants, "", "")
|
||||
}
|
||||
|
||||
pub fn create_group_convo_v1(
|
||||
@ -269,11 +270,13 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
|
||||
pub fn create_group_convo_v2(
|
||||
&mut self,
|
||||
participants: &[IdentIdRef],
|
||||
name: &str,
|
||||
desc: &str,
|
||||
) -> Result<ConversationId, ChatError> {
|
||||
// TODO: (P1) Ensure errors are handled properly. This is a high chance for
|
||||
// desynchronized state: MlsGroup persistence, conversation persistence, and
|
||||
// invite delivery all happen separately.
|
||||
let mut convo = GroupV2Convo::new(&mut self.services)?;
|
||||
let mut convo = GroupV2Convo::new(&mut self.services, name, desc)?;
|
||||
convo.add_member(&mut self.services, participants)?;
|
||||
let convo_id = convo.id().to_string();
|
||||
|
||||
@ -524,6 +527,23 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
|
||||
.load_conversation(convo_id)?
|
||||
.ok_or_else(|| ChatError::NoConvo(convo_id.into()))
|
||||
}
|
||||
|
||||
pub fn convo_metadata(&self, convo_id: ConversationIdRef) -> Result<ConvoMetadata, ChatError> {
|
||||
match self.cached_convos.get(convo_id) {
|
||||
Some(ConvoTypeOwned::Group(group_convo)) => {
|
||||
group_convo
|
||||
.metadata()
|
||||
.ok_or(ChatError::UnsupportedConvoType(
|
||||
"metadata is not available for this legacy convo_type".into(),
|
||||
))
|
||||
}
|
||||
Some(ConvoTypeOwned::Direct(_)) => Err(ChatError::UnsupportedFunction(
|
||||
convo_id.into(),
|
||||
"implementation coming".into(),
|
||||
)),
|
||||
None => Err(ChatError::NoConvo(convo_id.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ConvoTypeOwned<S: ExternalServices> {
|
||||
|
||||
@ -20,6 +20,7 @@ use crate::conversation::GroupConvo;
|
||||
use crate::conversation::GroupV1Convo;
|
||||
use crate::conversation::GroupV2Convo;
|
||||
use crate::conversation::Identified as _;
|
||||
use crate::conversation::mls_extensions::GROUP_METADATA_EXTENSION_TYPE;
|
||||
use crate::outcomes::ConversationClass;
|
||||
use crate::service_context::{ExternalServices, ServiceContext};
|
||||
use crate::utils::{blake2b_hex, hash_size};
|
||||
@ -207,6 +208,7 @@ impl InboxV2 {
|
||||
.extensions(vec![
|
||||
ExtensionType::ApplicationId,
|
||||
ExtensionType::LastResort,
|
||||
ExtensionType::Unknown(GROUP_METADATA_EXTENSION_TYPE),
|
||||
])
|
||||
.build();
|
||||
let a = KeyPackage::builder()
|
||||
@ -226,7 +228,7 @@ impl InboxV2 {
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct InboxV2Frame {
|
||||
#[prost(oneof = "InviteType", tags = "1, 2")]
|
||||
#[prost(oneof = "InviteType", tags = "1, 2, 3")]
|
||||
pub payload: Option<InviteType>,
|
||||
}
|
||||
|
||||
|
||||
@ -66,3 +66,9 @@ impl AddressedEncryptedPayload {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ConvoMetadata {
|
||||
pub name: String,
|
||||
pub desc: String,
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ fn groupv2_2way_roundtrip() {
|
||||
let particpants = &[&harness.raya().addr()];
|
||||
let convo_id = harness
|
||||
.saro()
|
||||
.create_group_convo_v2(particpants)
|
||||
.create_group_convo_v2(particpants, "", "")
|
||||
.expect("saro create group");
|
||||
|
||||
// Carry the invite through (commit, WelcomeReady, routing to Raya's inbox,
|
||||
@ -56,7 +56,7 @@ fn core_client() {
|
||||
let particpants = &[&harness.raya().addr()];
|
||||
let convo_id = harness
|
||||
.saro()
|
||||
.create_group_convo_v2(particpants)
|
||||
.create_group_convo_v2(particpants, "", "")
|
||||
.expect("Saro create");
|
||||
|
||||
// Carry the invite through (commit, WelcomeReady, routing to Raya's inbox,
|
||||
@ -114,7 +114,7 @@ fn core_client_batch_add() {
|
||||
let particpants = &[&harness.raya().addr(), &harness.pax().addr()];
|
||||
harness
|
||||
.saro()
|
||||
.create_group_convo_v2(particpants)
|
||||
.create_group_convo_v2(particpants, "", "")
|
||||
.expect("Saro create");
|
||||
|
||||
// Carry the invite through (commit, WelcomeReady, routing to Raya's inbox,
|
||||
@ -144,7 +144,7 @@ fn core_client_four_members_two_epochs() {
|
||||
let particpants = &[&harness.raya().addr(), &harness.pax().addr()];
|
||||
let convo_id = harness
|
||||
.saro()
|
||||
.create_group_convo_v2(particpants)
|
||||
.create_group_convo_v2(particpants, "", "")
|
||||
.expect("Saro create");
|
||||
|
||||
// Carry the invite through (commit, WelcomeReady, routing to Raya's inbox,
|
||||
@ -177,6 +177,68 @@ fn core_client_four_members_two_epochs() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_propagation() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::INFO)
|
||||
.with_test_writer()
|
||||
.try_init();
|
||||
|
||||
let name = "Jankiest Friends";
|
||||
let desc = "A cool group chat, with cool people";
|
||||
|
||||
let mut harness = TestHarness::<4>::new(|_, _| {});
|
||||
|
||||
let members = &[&harness.raya().addr()];
|
||||
let convo_id = harness
|
||||
.saro()
|
||||
.create_group_convo_v2(members, name, desc)
|
||||
.expect("Saro create");
|
||||
|
||||
// Carry the invite through (commit, WelcomeReady, routing to Raya's inbox,
|
||||
// accept_welcome); settle until Raya has joined.
|
||||
harness.process_until_label("Raya join", |h| h.raya().convo_count() == 1);
|
||||
|
||||
// Verfiy that Saro's Metadata is correct
|
||||
assert_eq!(
|
||||
harness.saro().convo_metadata(&convo_id).expect("meta").name,
|
||||
name
|
||||
);
|
||||
assert_eq!(
|
||||
harness.saro().convo_metadata(&convo_id).expect("meta").desc,
|
||||
desc
|
||||
);
|
||||
|
||||
// Verify that Raya has the same MetaData
|
||||
assert_eq!(
|
||||
harness.saro().convo_metadata(&convo_id).expect("meta").name,
|
||||
harness.raya().convo_metadata(&convo_id).expect("meta").name
|
||||
);
|
||||
assert_eq!(
|
||||
harness.saro().convo_metadata(&convo_id).expect("meta").desc,
|
||||
harness.raya().convo_metadata(&convo_id).expect("meta").desc
|
||||
);
|
||||
|
||||
// Epoch 2: Raya adds the 3rd member; settle until Pax has joined
|
||||
let members = &[&harness.pax().addr()];
|
||||
harness
|
||||
.raya()
|
||||
.group_add_member(&convo_id, members)
|
||||
.expect("Add Pax");
|
||||
|
||||
harness.process_until_label("Pax join", |h| h.pax().convo_count() == 1);
|
||||
|
||||
// Verify that Pax has the same MetaData
|
||||
assert_eq!(
|
||||
harness.saro().convo_metadata(&convo_id).expect("meta").name,
|
||||
harness.pax().convo_metadata(&convo_id).expect("meta").name
|
||||
);
|
||||
assert_eq!(
|
||||
harness.saro().convo_metadata(&convo_id).expect("meta").desc,
|
||||
harness.pax().convo_metadata(&convo_id).expect("meta").desc
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_joins_two_groups() {
|
||||
// The same installation is invited to two separate groups. Its single
|
||||
@ -195,14 +257,14 @@ fn member_joins_two_groups() {
|
||||
// Group 1: Saro invites Raya.
|
||||
harness
|
||||
.saro()
|
||||
.create_group_convo_v2(&[&raya_addr])
|
||||
.create_group_convo_v2(&[&raya_addr], "", "")
|
||||
.expect("saro create group 1");
|
||||
harness.process_until_label("raya joins group 1", |h| h.raya().convo_count() == 1);
|
||||
|
||||
// Group 2: Saro invites Raya again, into a fresh group.
|
||||
harness
|
||||
.saro()
|
||||
.create_group_convo_v2(&[&raya_addr])
|
||||
.create_group_convo_v2(&[&raya_addr], "", "")
|
||||
.expect("saro create group 2");
|
||||
harness.process_until_label("raya joins group 2", |h| h.raya().convo_count() == 2);
|
||||
|
||||
@ -238,7 +300,7 @@ fn direct_v1_then_group_v2_reuses_key_package() {
|
||||
// 2. GroupV2 inviting the same Raya.
|
||||
harness
|
||||
.saro()
|
||||
.create_group_convo_v2(&[&raya_addr])
|
||||
.create_group_convo_v2(&[&raya_addr], "", "")
|
||||
.expect("saro create group");
|
||||
harness.process_until_label("raya joins group", |h| h.raya().convo_count() == 2);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user