Skip to main content

xmtp_mls/subscriptions/
mod.rs

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