Skip to main content

xmtp_mls/subscriptions/
mod.rs

1use futures::{Stream, StreamExt};
2use prost::Message;
3use std::sync::Arc;
4use tokio::sync::{broadcast, oneshot};
5use tokio_stream::wrappers::BroadcastStream;
6use xmtp_proto::backend_v1::ServerEnvelope;
7use xmtp_proto::types::GroupId;
8
9use tracing::instrument;
10use xmtp_db::prelude::*;
11use xmtp_proto::api_client::XmtpMlsStreams;
12
13use stream_all::StreamAllMessages;
14use stream_conversations::StreamConversations;
15
16// Live backend tests require native full-duplex HTTP/2.
17#[cfg(all(test, not(target_arch = "wasm32")))]
18mod bidi_tests;
19// Randomized delivery fuzz over the live node (same gating as `bidi_tests`).
20#[cfg(all(test, not(target_arch = "wasm32")))]
21mod bidi_fuzz_tests;
22// One-shot bounded catch-up over the bidi wire (native-only, like the
23// connection it rides).
24pub mod barrier;
25#[cfg(not(target_arch = "wasm32"))]
26pub mod catch_up;
27pub mod incoming;
28pub mod local_delivery;
29pub mod message_reader;
30pub(crate) mod policy;
31mod stream_all;
32mod stream_conversations;
33pub mod stream_failure;
34pub mod stream_messages;
35// Live integration tests for the router (v3 wire; same gating rationale as
36// `bidi_tests` above).
37#[cfg(all(test, not(target_arch = "wasm32")))]
38mod delivery_integration_tests;
39// Native callback adapters over the shared backend transport.
40#[cfg(not(target_arch = "wasm32"))]
41pub mod router_callbacks;
42#[cfg(all(test, not(target_arch = "wasm32")))]
43mod router_callbacks_tests;
44pub(crate) mod watchdog;
45
46use crate::messages::enrichment::EnrichMessageError;
47#[cfg(any(test, feature = "test-utils"))]
48use crate::subscriptions::stream_messages::stream_stats::{StreamStatsWrapper, StreamWithStats};
49
50use crate::worker::device_sync::preference_sync::PreferenceUpdate;
51use crate::{
52    Client,
53    context::XmtpSharedContext,
54    groups::{GroupError, MlsGroup, mls_sync::GroupMessageProcessingError},
55    messages::decoded_message::DecodedMessage,
56};
57use thiserror::Error;
58use xmtp_common::{ErrorCode, MaybeSend, RetryableError, StreamHandle, retryable};
59use xmtp_db::{
60    NotFound, StorageError,
61    consent_record::{ConsentState, StoredConsentRecord},
62    group::ConversationType,
63    group_message::StoredGroupMessage,
64};
65
66pub(crate) type Result<T> = std::result::Result<T, SubscribeError>;
67
68#[derive(Debug, Error)]
69pub enum LocalEventError {
70    #[error("Unable to send event: {0}")]
71    Send(String),
72}
73
74impl RetryableError for LocalEventError {
75    fn is_retryable(&self) -> bool {
76        true
77    }
78}
79
80/// Events local to this client
81/// are broadcast across all senders/receivers of streams
82#[derive(Debug, Clone)]
83pub enum LocalEvents {
84    // a new group was created
85    NewGroup(GroupId),
86    /// A committed local message can be read. This is a hint, not a delivery event.
87    MessagesStored,
88    PreferencesChanged(Vec<PreferenceUpdate>),
89    // a message was deleted (contains the decoded message that was deleted)
90    MsgsDeleted(Vec<StoredGroupMessage>),
91}
92
93#[derive(Clone)]
94pub enum SyncWorkerEvent {
95    NewSyncGroupFromWelcome(Vec<u8>),
96    NewSyncGroupMsg,
97    // The sync worker will auto-sync these with other devices.
98    SyncPreferences(Vec<PreferenceUpdate>),
99    CycleHMAC,
100    Tick,
101}
102
103impl std::fmt::Debug for SyncWorkerEvent {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::NewSyncGroupFromWelcome(arg0) => f
107                .debug_tuple("NewSyncGroupFromWelcome")
108                .field(&hex::encode(arg0))
109                .finish(),
110            Self::NewSyncGroupMsg => write!(f, "NewSyncGroupMsg"),
111            Self::SyncPreferences(arg0) => f.debug_tuple("SyncPreferences").field(arg0).finish(),
112            Self::CycleHMAC => write!(f, "CycleHMAC"),
113            Self::Tick => write!(f, "Tick"),
114        }
115    }
116}
117
118impl LocalEvents {
119    fn consent_filter(self) -> Option<Vec<StoredConsentRecord>> {
120        match self {
121            Self::PreferencesChanged(updates) => {
122                let updates = updates
123                    .into_iter()
124                    .filter_map(|pu| match pu {
125                        PreferenceUpdate::Consent(cr) => Some(cr),
126                        _ => None,
127                    })
128                    .collect();
129                Some(updates)
130            }
131
132            _ => None,
133        }
134    }
135
136    fn preference_filter(self) -> Option<Vec<PreferenceUpdate>> {
137        match self {
138            Self::PreferencesChanged(updates) => Some(updates),
139            _ => None,
140        }
141    }
142
143    fn message_deletion_filter(self) -> Option<Vec<StoredGroupMessage>> {
144        match self {
145            Self::MsgsDeleted(msgs) => Some(msgs),
146            _ => None,
147        }
148    }
149}
150
151pub(crate) trait StreamMessages {
152    fn stream_consent_updates(self) -> impl Stream<Item = Result<Vec<StoredConsentRecord>>>;
153    fn stream_preference_updates(self) -> impl Stream<Item = Result<Vec<PreferenceUpdate>>>;
154    fn stream_message_deletions(self) -> impl Stream<Item = Result<DecodedMessage>>;
155}
156
157impl StreamMessages for broadcast::Receiver<LocalEvents> {
158    #[instrument(level = "trace", skip_all)]
159    fn stream_consent_updates(self) -> impl Stream<Item = Result<Vec<StoredConsentRecord>>> {
160        BroadcastStream::new(self).filter_map(|event| async {
161            xmtp_common::optify!(event, "Missed message due to event queue lag")
162                .and_then(LocalEvents::consent_filter)
163                .map(Result::Ok)
164        })
165    }
166
167    #[instrument(level = "trace", skip_all)]
168    fn stream_preference_updates(self) -> impl Stream<Item = Result<Vec<PreferenceUpdate>>> {
169        BroadcastStream::new(self).filter_map(|event| async {
170            xmtp_common::optify!(event, "Missed message due to event queue lag")
171                .and_then(LocalEvents::preference_filter)
172                .map(Result::Ok)
173        })
174    }
175
176    #[instrument(level = "trace", skip_all)]
177    fn stream_message_deletions(self) -> impl Stream<Item = Result<DecodedMessage>> {
178        BroadcastStream::new(self)
179            .filter_map(|event| async {
180                xmtp_common::optify!(event, "Missed message due to event queue lag")
181                    .and_then(LocalEvents::message_deletion_filter)
182                    .map(futures::stream::iter)
183            })
184            .flatten()
185            // let caller handle any potential decode failures
186            // this should be rare since the message already in db
187            .map(|m| DecodedMessage::try_from(m).map_err(Into::into))
188    }
189}
190
191#[derive(thiserror::Error, Debug, ErrorCode)]
192pub enum SubscribeError {
193    /// Local message delivery failed. May be retryable for a storage failure.
194    #[error(transparent)]
195    #[error_code(inherit)]
196    LocalDelivery(#[from] local_delivery::LocalDeliveryError),
197    /// The shared native transport failed. May be retryable.
198    #[cfg(not(target_arch = "wasm32"))]
199    #[error(transparent)]
200    Transport(#[from] xmtp_api_backend::TransportError),
201    /// Group error.
202    ///
203    /// Group operation failed during subscription. May be retryable.
204    #[error(transparent)]
205    Group(#[from] Box<GroupError>),
206    /// Not found.
207    ///
208    /// Subscribed resource not found. Retryable.
209    #[error(transparent)]
210    NotFound(#[from] NotFound),
211    /// Group message not found.
212    ///
213    /// Expected message missing from database. Retryable.
214    // TODO: Add this to `NotFound`
215    #[error("group message expected in database but is missing")]
216    GroupMessageNotFound,
217    /// Receive group error.
218    ///
219    /// Processing streamed group message failed. May be retryable.
220    #[error("processing group message in stream: {0}")]
221    ReceiveGroup(#[from] Box<GroupMessageProcessingError>),
222    /// Storage error.
223    ///
224    /// Database operation failed. May be retryable.
225    #[error(transparent)]
226    Storage(#[from] StorageError),
227    /// Decode error.
228    ///
229    /// Protobuf decoding failed. Not retryable.
230    #[error(transparent)]
231    Decode(#[from] prost::DecodeError),
232    /// API client error.
233    ///
234    /// Network request failed. Retryable.
235    #[error(transparent)]
236    ApiClient(#[from] xmtp_api::ApiError),
237    /// Boxed error.
238    ///
239    /// Wrapped dynamic error. May be retryable.
240    #[error("{0}")]
241    BoxError(Box<dyn RetryableError>),
242    /// Database connection error.
243    ///
244    /// Database connection failed. Retryable.
245    #[error(transparent)]
246    Db(#[from] xmtp_db::ConnectionError),
247    /// Conversion error.
248    ///
249    /// Proto conversion failed. Not retryable.
250    #[error(transparent)]
251    Conversion(#[from] xmtp_proto::ConversionError),
252    /// Envelope error.
253    ///
254    /// Invalid backend envelope. Not retryable.
255    #[error(transparent)]
256    Envelope(#[from] xmtp_api_backend::envelope::EnvelopeError),
257    /// Enriched Message Error.
258    #[error("error occured during subscription {0}")]
259    Enriched(#[from] EnrichMessageError),
260    /// The client latched a fatal configuration failure.
261    ///
262    /// Either this database is bound to a different deployment (CFG-051) or the
263    /// deployment now requires a newer client (CFG-061). Every open stream is
264    /// closed with it and every later call fails with it. Not retryable.
265    #[error(transparent)]
266    #[error_code(inherit)]
267    Configuration(Box<crate::client::ClientError>),
268    /// Stream liveness watchdog tripped.
269    ///
270    /// No activity arrived within the idle timeout, so the stream was terminated to force
271    /// a reconnect (resuming from the persisted cursor). Retryable.
272    #[error("stream went stale: no activity within the idle timeout")]
273    StreamStale,
274}
275
276impl SubscribeError {
277    pub fn dyn_err(other: impl RetryableError + 'static) -> Self {
278        SubscribeError::BoxError(Box::new(other) as _)
279    }
280}
281
282impl From<GroupError> for SubscribeError {
283    fn from(value: GroupError) -> Self {
284        SubscribeError::Group(Box::new(value))
285    }
286}
287
288impl From<GroupMessageProcessingError> for SubscribeError {
289    fn from(value: GroupMessageProcessingError) -> Self {
290        SubscribeError::ReceiveGroup(Box::new(value))
291    }
292}
293
294impl RetryableError for SubscribeError {
295    fn is_retryable(&self) -> bool {
296        use SubscribeError::*;
297        match self {
298            #[cfg(not(target_arch = "wasm32"))]
299            Transport(error) => error.is_retryable(),
300            LocalDelivery(e) => retryable!(e),
301            Group(e) => retryable!(e),
302            GroupMessageNotFound => true,
303            ReceiveGroup(e) => retryable!(e),
304            Storage(e) => retryable!(e),
305            Decode(_) => false,
306            NotFound(e) => retryable!(e),
307            ApiClient(e) => retryable!(e),
308            BoxError(e) => retryable!(e),
309            Db(c) => retryable!(c),
310            Conversion(c) => retryable!(c),
311            Envelope(c) => retryable!(c),
312            Enriched(c) => retryable!(c),
313            Configuration(_) => false,
314            StreamStale => true,
315        }
316    }
317}
318
319impl crate::worker::NeedsDbReconnect for SubscribeError {
320    /// Forwards a dropped-pool signal so the device-sync worker stops on
321    /// disconnect. `BoxError` wraps an opaque error we can't introspect → `false`.
322    fn needs_db_reconnect(&self) -> bool {
323        use SubscribeError::*;
324        match self {
325            Group(e) => e.needs_db_reconnect(),
326            Storage(e) => e.db_needs_connection(),
327            LocalDelivery(local_delivery::LocalDeliveryError::Storage(e)) => {
328                e.db_needs_connection()
329            }
330            LocalDelivery(_) => false,
331            Db(c) => c.db_needs_connection(),
332            #[cfg(not(target_arch = "wasm32"))]
333            Transport(_) => false,
334            GroupMessageNotFound | ReceiveGroup(_) | Decode(_) | NotFound(_) | ApiClient(_)
335            | BoxError(_) | Conversion(_) | Envelope(_) | Enriched(_) | Configuration(_)
336            | StreamStale => false,
337        }
338    }
339}
340
341impl<Context> Client<Context>
342where
343    Context: XmtpSharedContext + 'static,
344{
345    /// Use push metadata as a target. Fetch its complete prefix before processing.
346    pub async fn process_streamed_welcome_message(
347        &self,
348        envelope_bytes: Vec<u8>,
349    ) -> Result<Vec<MlsGroup<Context>>> {
350        let wire = ServerEnvelope::decode(envelope_bytes.as_slice())?;
351        let meta = wire
352            .meta
353            .as_ref()
354            .ok_or(xmtp_api::ApiError::InvalidResponse("welcome metadata"))?;
355        let (topic, cursor, _) = xmtp_api_backend::envelope::metadata(
356            meta,
357            xmtp_proto::types::TopicKind::WelcomeMessagesV1,
358        )?;
359        if topic != xmtp_proto::types::Topic::new_welcome_message(self.context.installation_id()) {
360            return Err(xmtp_api::ApiError::InvalidResponse("welcome installation").into());
361        }
362        barrier::wait_through(&self.context, [(topic, cursor)].into(), None)
363            .await
364            .map_err(GroupError::from)?;
365        let Some(group) = self.context.db().find_group_by_sequence_id(cursor)? else {
366            return Ok(Vec::new());
367        };
368        Ok(vec![MlsGroup::new(
369            self.context.clone(),
370            group.id,
371            group.dm_id,
372            group.conversation_type,
373            group.created_at_ns,
374        )])
375    }
376
377    #[xmtp_common::span(prefix = "stream")]
378    pub async fn stream_conversations(
379        &self,
380        conversation_type: Option<ConversationType>,
381        include_duplicate_dms: bool,
382    ) -> Result<impl Stream<Item = Result<MlsGroup<Context>>> + use<'_, Context>>
383    where
384        Context::ApiClient: XmtpMlsStreams,
385    {
386        StreamConversations::new(
387            &self.context,
388            conversation_type,
389            include_duplicate_dms,
390            None,
391        )
392        .await
393    }
394
395    /// Stream conversations but decouple the lifetime of 'self' from the stream.
396    #[xmtp_common::span(prefix = "stream")]
397    pub async fn stream_conversations_owned(
398        &self,
399        conversation_type: Option<ConversationType>,
400        include_duplicate_dms: bool,
401    ) -> Result<impl Stream<Item = Result<MlsGroup<Context>>> + 'static + use<Context>>
402    where
403        Context::ApiClient: XmtpMlsStreams,
404    {
405        StreamConversations::new_owned(
406            self.context.clone(),
407            conversation_type,
408            include_duplicate_dms,
409            None,
410        )
411        .await
412    }
413}
414
415impl<Context> Client<Context>
416where
417    Context: XmtpSharedContext + 'static,
418    Context::ApiClient: XmtpMlsStreams + 'static,
419    Context::MlsStorage: 'static,
420{
421    pub fn stream_conversations_with_callback(
422        client: Arc<Client<Context>>,
423        conversation_type: Option<ConversationType>,
424        convo_callback: impl FnMut(Result<MlsGroup<Context>>) + MaybeSend + 'static,
425        on_close: impl FnOnce() + MaybeSend + 'static,
426        include_duplicate_dms: bool,
427    ) -> impl StreamHandle<StreamOutput = Result<()>> {
428        let cancel = watchdog::StreamCancel::new(&client.context);
429        // Re-subscribing recreates the underlying `LocalEvents` broadcast receiver, which
430        // has no replay; the watchdog runner establishes the new subscription *before* its
431        // reconnect wait, so the new receiver is attached while we pause. Network welcomes
432        // are caught up from the persisted cursor, so the only residual gap is a *locally*
433        // created group (`LocalEvents::NewGroup`) broadcast in the brief window while the new
434        // subscription is being built — bounded, since the caller already holds that group.
435        watchdog::spawn_watchdog_stream(
436            cancel,
437            "stream_conversations",
438            move || {
439                let client = client.clone();
440                async move {
441                    client
442                        .stream_conversations_owned(conversation_type, include_duplicate_dms)
443                        .await
444                }
445            },
446            convo_callback,
447            on_close,
448        )
449    }
450
451    #[xmtp_common::span(prefix = "stream")]
452    pub async fn stream_all_messages(
453        &self,
454        conversation_type: Option<ConversationType>,
455        consent_state: Option<Vec<ConsentState>>,
456    ) -> Result<impl Stream<Item = Result<StoredGroupMessage>> + '_> {
457        tracing::debug!(
458            inbox_id = self.inbox_id(),
459            installation_id = %self.context.installation_id(),
460            conversation_type = ?conversation_type,
461            "stream all messages"
462        );
463
464        StreamAllMessages::new(&self.context, conversation_type, consent_state).await
465    }
466
467    #[xmtp_common::span(prefix = "stream")]
468    pub async fn stream_all_messages_owned(
469        &self,
470        conversation_type: Option<ConversationType>,
471        consent_state: Option<Vec<ConsentState>>,
472    ) -> Result<impl Stream<Item = Result<StoredGroupMessage>> + 'static + use<Context>> {
473        tracing::debug!(
474            inbox_id = self.inbox_id(),
475            installation_id = %self.context.installation_id(),
476            conversation_type = ?conversation_type,
477            "stream all messages"
478        );
479
480        StreamAllMessages::new_owned(self.context.clone(), conversation_type, consent_state).await
481    }
482
483    pub fn stream_all_messages_with_callback(
484        context: Context,
485        conversation_type: Option<ConversationType>,
486        consent_state: Option<Vec<ConsentState>>,
487        callback: impl FnMut(Result<StoredGroupMessage>) + MaybeSend + 'static,
488        on_close: impl FnOnce() + MaybeSend + 'static,
489    ) -> impl StreamHandle<StreamOutput = Result<()>> {
490        let cancel = watchdog::StreamCancel::new(&context);
491        watchdog::spawn_watchdog_stream(
492            cancel,
493            "stream_all_messages",
494            move || {
495                let context = context.clone();
496                let consent_state = consent_state.clone();
497                async move {
498                    StreamAllMessages::new_owned(context, conversation_type, consent_state).await
499                }
500            },
501            callback,
502            on_close,
503        )
504    }
505
506    pub fn stream_consent_with_callback(
507        client: Arc<Client<Context>>,
508        mut callback: impl FnMut(Result<Vec<StoredConsentRecord>>) + MaybeSend + 'static,
509        on_close: impl FnOnce() + MaybeSend + 'static,
510    ) -> impl StreamHandle<StreamOutput = Result<()>> {
511        let (tx, rx) = oneshot::channel();
512
513        xmtp_common::spawn(
514            Some(rx),
515            xmtp_common::bind_task_hub(async move {
516                // CFG-051 and CFG-061: cancellation can carry a latched reason,
517                // and this stream closes with it rather than silently.
518                let cancel = watchdog::StreamCancel::new(&client.context);
519                let receiver = client.local_events.subscribe();
520                let stream = receiver.stream_consent_updates();
521
522                futures::pin_mut!(stream);
523                let _ = tx.send(());
524                let cancelled = loop {
525                    tokio::select! {
526                        _ = cancel.cancelled() => break true,
527                        next = stream.next() => match next {
528                            Some(message) => callback(message),
529                            None => break false,
530                        }
531                    }
532                };
533                tracing::debug!("`stream_consent` stream ended, dropping stream");
534                let result = watchdog::close_reason(&cancel, cancelled, &mut callback);
535                on_close();
536                result
537            }),
538        )
539    }
540
541    pub fn stream_preferences_with_callback(
542        client: Arc<Client<Context>>,
543        mut callback: impl FnMut(Result<Vec<PreferenceUpdate>>) + MaybeSend + 'static,
544        on_close: impl FnOnce() + MaybeSend + 'static,
545    ) -> impl StreamHandle<StreamOutput = Result<()>> {
546        let (tx, rx) = oneshot::channel();
547
548        xmtp_common::spawn(
549            Some(rx),
550            xmtp_common::bind_task_hub(async move {
551                // CFG-051 and CFG-061: cancellation can carry a latched reason,
552                // and this stream closes with it rather than silently.
553                let cancel = watchdog::StreamCancel::new(&client.context);
554                let receiver = client.local_events.subscribe();
555                let stream = receiver.stream_preference_updates();
556
557                futures::pin_mut!(stream);
558                let _ = tx.send(());
559                let cancelled = loop {
560                    tokio::select! {
561                        _ = cancel.cancelled() => break true,
562                        next = stream.next() => match next {
563                            Some(message) => callback(message),
564                            None => break false,
565                        }
566                    }
567                };
568                tracing::debug!("`stream_preferences` stream ended, dropping stream");
569                let result = watchdog::close_reason(&cancel, cancelled, &mut callback);
570                on_close();
571                result
572            }),
573        )
574    }
575
576    pub fn stream_message_deletions_with_callback(
577        client: Arc<Client<Context>>,
578        mut callback: impl FnMut(Result<DecodedMessage>) + MaybeSend + 'static,
579        on_close: impl FnOnce() + MaybeSend + 'static,
580    ) -> impl StreamHandle<StreamOutput = Result<()>> {
581        let (tx, rx) = oneshot::channel();
582
583        xmtp_common::spawn(
584            Some(rx),
585            xmtp_common::bind_task_hub(async move {
586                // CFG-051 and CFG-061: cancellation can carry a latched reason,
587                // and this stream closes with it rather than silently.
588                let cancel = watchdog::StreamCancel::new(&client.context);
589                let receiver = client.local_events.subscribe();
590                let stream = receiver.stream_message_deletions();
591
592                futures::pin_mut!(stream);
593                let _ = tx.send(());
594                let cancelled = loop {
595                    tokio::select! {
596                        _ = cancel.cancelled() => break true,
597                        next = stream.next() => match next {
598                            Some(message) => callback(message),
599                            None => break false,
600                        }
601                    }
602                };
603                tracing::debug!("`stream_message_deletions` stream ended, dropping stream");
604                let result = watchdog::close_reason(&cancel, cancelled, &mut callback);
605                on_close();
606                result
607            }),
608        )
609    }
610}
611
612impl<Context> Client<Context>
613where
614    Context: XmtpSharedContext + 'static,
615    Context::ApiClient: XmtpMlsStreams + 'static,
616    Context::MlsStorage: 'static,
617    <Context::ApiClient as XmtpMlsStreams>::GroupMessageStream: Unpin,
618    <Context::ApiClient as XmtpMlsStreams>::WelcomeMessageStream: Unpin,
619{
620    #[tracing::instrument(level = "trace", skip_all)]
621    #[cfg(any(test, feature = "test-utils"))]
622    pub async fn stream_all_messages_owned_with_stats(
623        &self,
624        conversation_type: Option<ConversationType>,
625        consent_state: Option<Vec<ConsentState>>,
626    ) -> Result<impl StreamWithStats<Item = Result<StoredGroupMessage>> + 'static> {
627        tracing::debug!(
628            inbox_id = self.inbox_id(),
629            installation_id = %self.context.installation_id(),
630            conversation_type = ?conversation_type,
631            "stream all messages"
632        );
633
634        let stream =
635            StreamAllMessages::new_owned(self.context.clone(), conversation_type, consent_state)
636                .await?;
637
638        Ok(StreamStatsWrapper::new(stream))
639    }
640}
641
642#[derive(Debug, Clone, Copy)]
643pub enum StreamKind {
644    All,
645    Conversations,
646    Messages,
647}
648
649#[cfg(test)]
650pub(crate) mod tests {
651    use crate::context::XmtpSharedContext;
652    use crate::tester;
653
654    /// A macro for asserting that a stream yields a specific decrypted message.
655    ///
656    /// # Example
657    /// ```rust
658    /// assert_msg!(stream, b"first");
659    /// ```
660    #[macro_export]
661    macro_rules! assert_msg {
662        ($stream:expr, $expected:expr) => {
663            let next = $stream
664                .next()
665                .await
666                .unwrap()
667                .inspect_err(|e| tracing::error!("{}", e.to_string()))
668                .unwrap();
669
670            assert_eq!(
671                String::from_utf8_lossy(next.decrypted_message_bytes.as_slice()),
672                String::from_utf8_lossy($expected.as_bytes())
673            );
674        };
675    }
676
677    /// A macro for asserting that a stream yields a specific decrypted message.
678    ///
679    /// # Example
680    /// ```rust
681    /// assert_msg!(stream, b"first");
682    /// ```
683    #[macro_export]
684    macro_rules! assert_msg_exists {
685        ($stream:expr) => {
686            assert!(
687                !$stream
688                    .next()
689                    .await
690                    .unwrap()
691                    .unwrap()
692                    .decrypted_message_bytes
693                    .is_empty()
694            );
695        };
696    }
697
698    #[xmtp_common::test(flavor = "multi_thread", worker_threads = 5, unwrap_try = true)]
699    async fn test_process_streamed_welcome_message() {
700        use prost::Message;
701        use xmtp_proto::types::{Cursor, Topic};
702        tester!(alix);
703        tester!(bo);
704        let alix_group = alix.create_group(None, None)?;
705        alix_group.add_members(&[bo.inbox_id()]).await?;
706        let envelopes = alix
707            .context
708            .api()
709            .query_all(
710                [(
711                    Topic::new_welcome_message(bo.context.installation_id()),
712                    Cursor(0),
713                )]
714                .into(),
715                bo.context.api().limits().max_query_limit as u32,
716            )
717            .await?;
718        assert!(!envelopes.is_empty(), "Should have at least one welcome");
719        let groups = bo
720            .process_streamed_welcome_message(envelopes[0].encode_to_vec())
721            .await?;
722        assert_eq!(groups.len(), 1, "Should have exactly one group");
723    }
724}