1use futures::{Stream, StreamExt};
2use prost::Message;
3use tokio::sync::oneshot;
4use xmtp_common::snippet::Snippet;
5use xmtp_proto::backend_v1::ServerEnvelope;
6
7use tracing::instrument;
8use xmtp_db::prelude::*;
9use xmtp_proto::api_client::XmtpMlsStreams;
10
11use stream_all::StreamAllMessages;
12use stream_conversations::StreamConversations;
13
14const DEFAULT_STREAM_CONSENT_STATES: [ConsentState; 2] =
15 [ConsentState::Allowed, ConsentState::Unknown];
16
17#[cfg(all(test, not(target_arch = "wasm32")))]
19mod bidi_tests;
20#[cfg(all(test, not(target_arch = "wasm32")))]
22mod bidi_fuzz_tests;
23pub mod barrier;
25pub mod catch_up;
26pub(crate) mod connection_state;
27pub mod incoming;
28#[doc(hidden)]
29pub use crate::core::events as internal;
30pub mod local_delivery;
31pub mod message_reader;
32pub(crate) use crate::core::policy;
33pub(crate) mod recovery;
34mod stream_all;
35pub mod stream_conversations;
36pub mod stream_failure;
37pub mod stream_messages;
38#[cfg(all(test, not(target_arch = "wasm32")))]
41mod delivery_integration_tests;
42#[cfg(all(test, not(target_arch = "wasm32")))]
44mod rate_limit_tests;
45#[cfg(not(target_arch = "wasm32"))]
47pub mod router_callbacks;
48#[cfg(all(test, not(target_arch = "wasm32")))]
49mod router_callbacks_tests;
50pub(crate) mod watchdog;
51
52use crate::messages::enrichment::EnrichMessageError;
53#[cfg(any(test, feature = "test-utils"))]
54use crate::subscriptions::stream_messages::stream_stats::{StreamStatsWrapper, StreamWithStats};
55
56use crate::worker::device_sync::preference_sync::PreferenceUpdate;
57use crate::{
58 context::XmtpSharedContext,
59 groups::{GroupError, MlsGroup, mls_sync::GroupMessageProcessingError},
60 messages::decoded_message::{DecodedMessage, DeletedBy},
61};
62use internal::InternalEvent;
63use thiserror::Error;
64use xmtp_common::{ErrorCode, MaybeSend, RetryableError, StreamHandle, retryable};
65use xmtp_db::{
66 NotFound, StorageError,
67 consent_record::{ConsentState, StoredConsentRecord},
68 group::ConversationType,
69 group_message::StoredGroupMessage,
70};
71use xmtp_events::{EventFilter, Subscription};
72
73pub(crate) type Result<T> = std::result::Result<T, SubscribeError>;
74
75#[derive(Debug, Error)]
76pub enum LocalEventError {
77 #[error("Unable to send event: {0}")]
78 Send(String),
79}
80
81impl RetryableError for LocalEventError {
82 fn is_retryable(&self) -> bool {
83 true
84 }
85}
86
87pub(crate) trait StreamMessages {
88 fn stream_consent_updates(self) -> impl Stream<Item = Result<Vec<StoredConsentRecord>>>;
89 fn stream_preference_updates(self) -> impl Stream<Item = Result<Vec<PreferenceUpdate>>>;
90 fn stream_message_deletions(self) -> impl Stream<Item = Result<DecodedMessage>>;
91}
92
93impl StreamMessages for Subscription<InternalEvent> {
94 #[instrument(level = "trace", skip_all)]
95 fn stream_consent_updates(self) -> impl Stream<Item = Result<Vec<StoredConsentRecord>>> {
96 futures::stream::unfold(self, |subscription| async move {
97 loop {
98 let item = subscription.next().await?;
99 if let Some(xmtp_events::ClientEvent::Lagged(lagged)) = &item.client {
100 tracing::warn!(discarded = lagged.discarded, "legacy consent stream lagged");
101 }
102 if let Some(InternalEvent::PreferencesChanged { updates, .. }) = item.internal {
103 let records = updates
104 .into_iter()
105 .filter_map(|update| match update {
106 PreferenceUpdate::Consent(record) => Some(record),
107 _ => None,
108 })
109 .collect();
110 return Some((Ok(records), subscription));
111 }
112 }
113 })
114 }
115
116 #[instrument(level = "trace", skip_all)]
117 fn stream_preference_updates(self) -> impl Stream<Item = Result<Vec<PreferenceUpdate>>> {
118 futures::stream::unfold(self, |subscription| async move {
119 loop {
120 let item = subscription.next().await?;
121 if let Some(xmtp_events::ClientEvent::Lagged(lagged)) = &item.client {
122 tracing::warn!(
123 discarded = lagged.discarded,
124 "legacy preference stream lagged"
125 );
126 }
127 if let Some(InternalEvent::PreferencesChanged { updates, .. }) = item.internal {
128 return Some((Ok(updates), subscription));
129 }
130 }
131 })
132 }
133
134 #[instrument(level = "trace", skip_all)]
135 fn stream_message_deletions(self) -> impl Stream<Item = Result<DecodedMessage>> {
136 futures::stream::unfold(self, |subscription| async move {
137 loop {
138 let item = subscription.next().await?;
139 if let Some(xmtp_events::ClientEvent::Lagged(lagged)) = &item.client {
140 tracing::warn!(
141 discarded = lagged.discarded,
142 "legacy deletion stream lagged"
143 );
144 }
145 let decoded = match item.internal {
146 Some(InternalEvent::MessagesDeleted(deleted)) => {
150 let now = xmtp_common::time::now_ns();
151 deletion_items(deleted, |m| m.expire_at_ns.is_some_and(|at| at <= now))
152 }
153 Some(InternalEvent::MessagesExpired(deleted)) => {
154 deletion_items(deleted, |_| true)
155 }
156 _ => continue,
157 };
158 return Some((futures::stream::iter(decoded), subscription));
159 }
160 })
161 .flatten()
162 }
163}
164
165fn deletion_items(
171 deleted: internal::DeletedMessages,
172 is_expired: impl Fn(&StoredGroupMessage) -> bool,
173) -> Vec<Result<DecodedMessage>> {
174 let internal::DeletedMessages {
175 messages,
176 deleted_by_inbox_id,
177 } = deleted;
178 messages
179 .into_iter()
180 .map(|message| {
181 if !is_expired(&message) {
182 return Ok(DecodedMessage::from(message));
183 }
184 let deleted_by = deleted_by_inbox_id
186 .as_deref()
187 .map_or(DeletedBy::Sender, |deleter| {
188 DeletedBy::new(deleter, &message.sender_inbox_id)
189 });
190 Ok(DecodedMessage::placeholder(message, deleted_by))
191 })
192 .collect()
193}
194
195#[derive(thiserror::Error, Debug, ErrorCode)]
196pub enum SubscribeError {
197 #[error(transparent)]
199 #[error_code(inherit)]
200 LocalDelivery(#[from] local_delivery::LocalDeliveryError),
201 #[cfg(not(target_arch = "wasm32"))]
203 #[error(transparent)]
204 Transport(#[from] xmtp_api_backend::TransportError),
205 #[error(transparent)]
209 Group(#[from] Box<GroupError>),
210 #[error(transparent)]
214 NotFound(#[from] NotFound),
215 #[error("group message expected in database but is missing")]
220 GroupMessageNotFound,
221 #[error("processing group message in stream: {0}")]
225 ReceiveGroup(#[from] Box<GroupMessageProcessingError>),
226 #[error(transparent)]
230 Storage(#[from] StorageError),
231 #[error(transparent)]
235 Decode(#[from] prost::DecodeError),
236 #[error(transparent)]
240 ApiClient(#[from] xmtp_api::ApiError),
241 #[error("{0}")]
245 BoxError(Box<dyn RetryableError>),
246 #[error(transparent)]
250 Db(#[from] xmtp_db::ConnectionError),
251 #[error(transparent)]
255 Conversion(#[from] xmtp_proto::ConversionError),
256 #[error(transparent)]
260 Envelope(#[from] xmtp_api_backend::envelope::EnvelopeError),
261 #[error("error occured during subscription {0}")]
263 Enriched(#[from] EnrichMessageError),
264 #[error(transparent)]
270 #[error_code(inherit)]
271 Blocked(#[from] crate::server_configuration::BlockedConnection),
272 #[error("stream went stale: no activity within the idle timeout")]
277 StreamStale,
278}
279
280impl SubscribeError {
281 pub fn dyn_err(other: impl RetryableError + 'static) -> Self {
282 SubscribeError::BoxError(Box::new(other) as _)
283 }
284}
285
286impl From<GroupError> for SubscribeError {
287 fn from(value: GroupError) -> Self {
288 SubscribeError::Group(Box::new(value))
289 }
290}
291
292impl From<GroupMessageProcessingError> for SubscribeError {
293 fn from(value: GroupMessageProcessingError) -> Self {
294 SubscribeError::ReceiveGroup(Box::new(value))
295 }
296}
297
298impl RetryableError for SubscribeError {
299 fn is_retryable(&self) -> bool {
300 use SubscribeError::*;
301 match self {
302 #[cfg(not(target_arch = "wasm32"))]
303 Transport(error) => error.is_retryable(),
304 LocalDelivery(e) => retryable!(e),
305 Group(e) => retryable!(e),
306 GroupMessageNotFound => true,
307 ReceiveGroup(e) => retryable!(e),
308 Storage(e) => retryable!(e),
309 Decode(_) => false,
310 NotFound(e) => retryable!(e),
311 ApiClient(e) => retryable!(e),
312 BoxError(e) => retryable!(e),
313 Db(c) => retryable!(c),
314 Conversion(c) => retryable!(c),
315 Envelope(c) => retryable!(c),
316 Enriched(c) => retryable!(c),
317 Blocked(_) => false,
318 StreamStale => true,
319 }
320 }
321
322 fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
323 use SubscribeError::*;
324 match self {
325 #[cfg(not(target_arch = "wasm32"))]
326 Transport(error) => error.rate_limit_delay(),
327 Group(e) => e.rate_limit_delay(),
328 ReceiveGroup(e) => e.rate_limit_delay(),
329 ApiClient(e) => e.rate_limit_delay(),
330 LocalDelivery(e) => e.rate_limit_delay(),
331 BoxError(e) => e.rate_limit_delay(),
332 _ => None,
333 }
334 }
335}
336
337impl crate::worker::NeedsDbReconnect for SubscribeError {
338 #[deny(clippy::wildcard_enum_match_arm)]
341 fn needs_db_reconnect(&self) -> bool {
342 use SubscribeError::*;
343 match self {
344 Group(e) => e.needs_db_reconnect(),
345 Storage(e) => e.db_needs_connection(),
346 LocalDelivery(e) => e.needs_db_reconnect(),
347 Db(c) => c.db_needs_connection(),
348 ReceiveGroup(e) => e.needs_db_reconnect(),
349 Enriched(EnrichMessageError::Storage(e)) => e.db_needs_connection(),
350 Enriched(EnrichMessageError::DbConnection(c)) => c.db_needs_connection(),
351 #[cfg(not(target_arch = "wasm32"))]
352 Transport(..) => false,
353 NotFound(..) | GroupMessageNotFound | Decode(..) | ApiClient(..) | BoxError(..)
354 | Conversion(..) | Envelope(..) | Blocked(..) | StreamStale => false,
355 }
356 }
357}
358
359pub(crate) async fn process_streamed_welcome_message<Context>(
361 context: &Context,
362 envelope_bytes: Vec<u8>,
363) -> Result<Vec<MlsGroup<Context>>>
364where
365 Context: XmtpSharedContext + 'static,
366{
367 let wire = ServerEnvelope::decode(envelope_bytes.as_slice())?;
368 let meta = wire
369 .meta
370 .as_ref()
371 .ok_or(xmtp_api::ApiError::InvalidResponse("welcome metadata"))?;
372 let (topic, cursor, _) = xmtp_api_backend::envelope::metadata(
373 meta,
374 xmtp_proto::types::TopicKind::WelcomeMessagesV1,
375 )?;
376 if topic != xmtp_proto::types::Topic::new_welcome_message(context.installation_id()) {
377 return Err(xmtp_api::ApiError::InvalidResponse("welcome installation").into());
378 }
379 barrier::wait_through(context, [(topic, cursor)].into(), None)
380 .await
381 .map_err(|error| {
382 context
383 .server_configuration()
384 .blocked_or(GroupError::from(error))
385 })?;
386 let Some(group) = context.db().find_group_by_sequence_id(cursor)? else {
387 return Ok(Vec::new());
388 };
389 Ok(vec![MlsGroup::new(
390 context.clone(),
391 group.id,
392 group.dm_id,
393 group.conversation_type,
394 group.created_at_ns,
395 )])
396}
397
398pub async fn process_streamed_group_message<Context>(
400 group: &MlsGroup<Context>,
401 envelope_bytes: Vec<u8>,
402) -> Result<Vec<StoredGroupMessage>>
403where
404 Context: XmtpSharedContext + 'static,
405{
406 let wire = ServerEnvelope::decode(envelope_bytes.as_slice())?;
407 let meta = wire
408 .meta
409 .as_ref()
410 .ok_or(xmtp_api::ApiError::InvalidResponse("group metadata"))?;
411 let (topic, cursor, _) =
412 xmtp_api_backend::envelope::metadata(meta, xmtp_proto::types::TopicKind::GroupMessagesV1)?;
413 if topic != xmtp_proto::types::Topic::new_group_message(group.group_id) {
414 return Err(xmtp_api::ApiError::InvalidResponse("group topic").into());
415 }
416 barrier::wait_through(&group.context, [(topic, cursor)].into(), None)
417 .await
418 .map_err(|error| {
419 group
420 .context
421 .server_configuration()
422 .blocked_or(GroupError::from(error))
423 })?;
424 let message = group
425 .context
426 .db()
427 .get_group_message_by_cursor(group.group_id, cursor)?;
428 let current_time_ns = xmtp_common::time::now_ns();
430 Ok(message
431 .filter(|row| {
432 row.expire_at_ns
433 .is_none_or(|deadline| deadline > current_time_ns)
434 })
435 .into_iter()
436 .collect())
437}
438
439#[xmtp_common::span(prefix = "stream")]
440pub(crate) async fn stream_conversations<'a, Context>(
441 context: &'a Context,
442 conversation_type: Option<ConversationType>,
443 include_duplicate_dms: bool,
444) -> Result<impl Stream<Item = Result<MlsGroup<Context>>> + use<'a, Context>>
445where
446 Context: XmtpSharedContext + 'static,
447 Context::ApiClient: XmtpMlsStreams,
448{
449 StreamConversations::new(context, conversation_type, include_duplicate_dms, None).await
450}
451
452#[xmtp_common::span(prefix = "stream")]
454pub(crate) async fn stream_conversations_owned<Context>(
455 context: Context,
456 conversation_type: Option<ConversationType>,
457 include_duplicate_dms: bool,
458) -> Result<impl Stream<Item = Result<MlsGroup<Context>>> + 'static + use<Context>>
459where
460 Context: XmtpSharedContext + 'static,
461 Context::ApiClient: XmtpMlsStreams,
462{
463 StreamConversations::new_owned(context, conversation_type, include_duplicate_dms, None).await
464}
465
466pub(crate) fn stream_conversations_with_callback<Context>(
468 context: Context,
469 keep_alive: impl MaybeSend + 'static,
470 conversation_type: Option<ConversationType>,
471 convo_callback: impl FnMut(Result<MlsGroup<Context>>) + MaybeSend + 'static,
472 on_close: impl FnOnce() + MaybeSend + 'static,
473 include_duplicate_dms: bool,
474) -> impl StreamHandle<StreamOutput = Result<()>>
475where
476 Context: XmtpSharedContext + 'static,
477 Context::ApiClient: XmtpMlsStreams + 'static,
478 Context::MlsStorage: 'static,
479{
480 let cancel = watchdog::StreamCancel::new(&context);
481 watchdog::spawn_watchdog_stream(
488 cancel,
489 "stream_conversations",
490 move || {
491 let _keep_alive = &keep_alive;
492 stream_conversations_owned(context.clone(), conversation_type, include_duplicate_dms)
493 },
494 convo_callback,
495 on_close,
496 )
497}
498
499#[xmtp_common::span(prefix = "stream")]
500pub(crate) async fn stream_all_messages<'a, Context>(
501 context: &'a Context,
502 conversation_type: Option<ConversationType>,
503 consent_state: Option<Vec<ConsentState>>,
504) -> Result<impl Stream<Item = Result<StoredGroupMessage>> + use<'a, Context>>
505where
506 Context: XmtpSharedContext + 'static,
507 Context::ApiClient: XmtpMlsStreams + 'static,
508 Context::MlsStorage: 'static,
509{
510 tracing::debug!(
511 inbox_id = context.inbox_id(),
512 installation_id = context.installation_id().as_slice().snippet(),
513 conversation_type = ?conversation_type,
514 "stream all messages"
515 );
516
517 StreamAllMessages::new(context, conversation_type, consent_state).await
518}
519
520#[xmtp_common::span(prefix = "stream")]
521pub(crate) async fn stream_all_messages_owned<Context>(
522 context: Context,
523 conversation_type: Option<ConversationType>,
524 consent_state: Option<Vec<ConsentState>>,
525) -> Result<impl Stream<Item = Result<StoredGroupMessage>> + 'static + use<Context>>
526where
527 Context: XmtpSharedContext + 'static,
528 Context::ApiClient: XmtpMlsStreams + 'static,
529 Context::MlsStorage: 'static,
530{
531 tracing::debug!(
532 inbox_id = context.inbox_id(),
533 installation_id = context.installation_id().as_slice().snippet(),
534 conversation_type = ?conversation_type,
535 "stream all messages"
536 );
537
538 StreamAllMessages::new_owned(context, conversation_type, consent_state).await
539}
540
541pub(crate) fn stream_consent_with_callback<Context>(
543 context: Context,
544 keep_alive: impl MaybeSend + 'static,
545 mut callback: impl FnMut(Result<Vec<StoredConsentRecord>>) + MaybeSend + 'static,
546 on_close: impl FnOnce() + MaybeSend + 'static,
547) -> impl StreamHandle<StreamOutput = Result<()>>
548where
549 Context: XmtpSharedContext + 'static,
550{
551 let (tx, rx) = oneshot::channel();
552
553 xmtp_common::spawn(
554 Some(rx),
555 xmtp_common::bind_task_hub(async move {
556 let _keep_alive = keep_alive;
557 let cancel = watchdog::StreamCancel::new(&context);
560 let receiver = context.events().subscribe(
561 EventFilter::default().with_internal(|event| {
562 matches!(event, InternalEvent::PreferencesChanged { .. })
563 }),
564 Some(1024),
565 );
566 let stream = receiver.stream_consent_updates();
567
568 futures::pin_mut!(stream);
569 let _ = tx.send(());
570 let cancelled = loop {
571 tokio::select! {
572 _ = cancel.cancelled() => break true,
573 next = stream.next() => match next {
574 Some(message) => callback(message),
575 None => break false,
576 }
577 }
578 };
579 tracing::debug!("`stream_consent` stream ended, dropping stream");
580 let result = watchdog::close_reason(&cancel, cancelled, &mut callback);
581 on_close();
582 result
583 }),
584 )
585}
586
587pub(crate) fn stream_preferences_with_callback<Context>(
589 context: Context,
590 keep_alive: impl MaybeSend + 'static,
591 mut callback: impl FnMut(Result<Vec<PreferenceUpdate>>) + MaybeSend + 'static,
592 on_close: impl FnOnce() + MaybeSend + 'static,
593) -> impl StreamHandle<StreamOutput = Result<()>>
594where
595 Context: XmtpSharedContext + 'static,
596{
597 let (tx, rx) = oneshot::channel();
598
599 xmtp_common::spawn(
600 Some(rx),
601 xmtp_common::bind_task_hub(async move {
602 let _keep_alive = keep_alive;
603 let cancel = watchdog::StreamCancel::new(&context);
606 let receiver = context.events().subscribe(
607 EventFilter::default().with_internal(|event| {
608 matches!(event, InternalEvent::PreferencesChanged { .. })
609 }),
610 Some(1024),
611 );
612 let stream = receiver.stream_preference_updates();
613
614 futures::pin_mut!(stream);
615 let _ = tx.send(());
616 let cancelled = loop {
617 tokio::select! {
618 _ = cancel.cancelled() => break true,
619 next = stream.next() => match next {
620 Some(message) => callback(message),
621 None => break false,
622 }
623 }
624 };
625 tracing::debug!("`stream_preferences` stream ended, dropping stream");
626 let result = watchdog::close_reason(&cancel, cancelled, &mut callback);
627 on_close();
628 result
629 }),
630 )
631}
632
633pub(crate) fn stream_message_deletions_with_callback<Context>(
635 context: Context,
636 keep_alive: impl MaybeSend + 'static,
637 mut callback: impl FnMut(Result<DecodedMessage>) + MaybeSend + 'static,
638 on_close: impl FnOnce() + MaybeSend + 'static,
639) -> impl StreamHandle<StreamOutput = Result<()>>
640where
641 Context: XmtpSharedContext + 'static,
642{
643 let (tx, rx) = oneshot::channel();
644
645 xmtp_common::spawn(
646 Some(rx),
647 xmtp_common::bind_task_hub(async move {
648 let _keep_alive = keep_alive;
649 let cancel = watchdog::StreamCancel::new(&context);
652 let receiver = context.events().subscribe(
653 EventFilter::default().with_internal(InternalEvent::is_message_deletion),
654 Some(1024),
655 );
656 let stream = receiver.stream_message_deletions();
657
658 futures::pin_mut!(stream);
659 let _ = tx.send(());
660 let cancelled = loop {
661 tokio::select! {
662 _ = cancel.cancelled() => break true,
663 next = stream.next() => match next {
664 Some(message) => callback(message),
665 None => break false,
666 }
667 }
668 };
669 tracing::debug!("`stream_message_deletions` stream ended, dropping stream");
670 let result = watchdog::close_reason(&cancel, cancelled, &mut callback);
671 on_close();
672 result
673 }),
674 )
675}
676
677#[tracing::instrument(level = "trace", skip_all)]
678#[cfg(any(test, feature = "test-utils"))]
679pub(crate) async fn stream_all_messages_owned_with_stats<Context>(
680 context: Context,
681 conversation_type: Option<ConversationType>,
682 consent_state: Option<Vec<ConsentState>>,
683) -> Result<impl StreamWithStats<Item = Result<StoredGroupMessage>> + 'static>
684where
685 Context: XmtpSharedContext + 'static,
686 Context::ApiClient: XmtpMlsStreams + 'static,
687 Context::MlsStorage: 'static,
688 <Context::ApiClient as XmtpMlsStreams>::GroupMessageStream: Unpin,
689 <Context::ApiClient as XmtpMlsStreams>::WelcomeMessageStream: Unpin,
690{
691 tracing::debug!(
692 inbox_id = context.inbox_id(),
693 installation_id = context.installation_id().as_slice().snippet(),
694 conversation_type = ?conversation_type,
695 "stream all messages"
696 );
697
698 let stream = StreamAllMessages::new_owned(context, conversation_type, consent_state).await?;
699
700 Ok(StreamStatsWrapper::new(stream))
701}
702
703#[derive(Debug, Clone, Copy)]
704pub enum StreamKind {
705 All,
706 Conversations,
707 Messages,
708}
709
710#[cfg(test)]
711mod message_deletion_tests;
712
713#[cfg(test)]
714pub(crate) mod tests {
715 #[cfg(not(target_arch = "wasm32"))]
716 mod disconnect_propagation;
717
718 use crate::context::CoreContext;
719 use crate::tester;
720
721 #[macro_export]
728 macro_rules! assert_msg {
729 ($stream:expr, $expected:expr) => {
730 let next = $stream
731 .next()
732 .await
733 .unwrap()
734 .inspect_err(|e| tracing::error!("{}", e.to_string()))
735 .unwrap();
736
737 assert_eq!(
738 String::from_utf8_lossy(next.decrypted_message_bytes.as_slice()),
739 String::from_utf8_lossy($expected.as_bytes())
740 );
741 };
742 }
743
744 #[macro_export]
751 macro_rules! assert_msg_exists {
752 ($stream:expr) => {
753 assert!(
754 !$stream
755 .next()
756 .await
757 .unwrap()
758 .unwrap()
759 .decrypted_message_bytes
760 .is_empty()
761 );
762 };
763 }
764
765 #[xmtp_common::test(flavor = "multi_thread", worker_threads = 5, unwrap_try = true)]
766 async fn test_process_streamed_welcome_message() {
767 use prost::Message;
768 use xmtp_proto::types::{Cursor, Topic};
769 tester!(alix);
770 tester!(bo);
771 let alix_group = alix.create_group(None, None)?;
772 alix_group.add_members(&[bo.inbox_id()]).await?;
773 let envelopes = alix
774 .context
775 .api()
776 .query_all(
777 [(
778 Topic::new_welcome_message(bo.context.installation_id()),
779 Cursor(0),
780 )]
781 .into(),
782 bo.context.api().limits().max_query_limit as u32,
783 )
784 .await?;
785 assert!(!envelopes.is_empty(), "Should have at least one welcome");
786 let groups = bo
787 .process_streamed_welcome_message(envelopes[0].encode_to_vec())
788 .await?;
789 assert_eq!(groups.len(), 1, "Should have exactly one group");
790 }
791}