Skip to main content

xmtp_mls/groups/
oneshot.rs

1use super::{GroupError, MlsGroup, PreconfiguredPolicies};
2use crate::context::XmtpSharedContext;
3use xmtp_common::snippet::Snippet;
4use xmtp_db::{
5    MlsProviderExt, XmtpMlsStorageProvider,
6    consent_record::{ConsentState, ConsentType},
7    group::ConversationType,
8    prelude::{QueryConsentRecord, QueryReaddStatus},
9};
10use xmtp_id::AsIdRef;
11use xmtp_mls_common::{group::GroupMetadataOptions, group_metadata::GroupMetadata};
12use xmtp_proto::{
13    types::{Cursor, GroupId},
14    xmtp::mls::message_contents::{OneshotMessage, oneshot_message},
15};
16
17pub struct Oneshot {}
18
19impl Oneshot {
20    /// Creates a oneshot group with the given message and adds the specified inbox IDs to it.
21    ///
22    /// A oneshot group is a special type of group that contains a single message and is used
23    /// for specific messaging scenarios like readd requests where no long-lived group is needed.
24    ///
25    /// # Arguments
26    /// * `context` - The shared context for group operations
27    /// * `inbox_ids` - List of inbox IDs to add to the group. Note that the sender's other
28    ///   installations are implicitly included and will also receive the oneshot message.
29    /// * `oneshot_message` - The oneshot message to include in the group metadata
30    ///
31    /// # Returns
32    /// An error if sending failed, otherwise nothing
33    pub async fn send_message<C: XmtpSharedContext, S: AsIdRef>(
34        context: C,
35        inbox_ids: impl AsRef<[S]>,
36        oneshot_message: OneshotMessage,
37    ) -> Result<(), GroupError> {
38        // Create a oneshot group with the oneshot message
39        let group = MlsGroup::<C>::create_and_insert(
40            context.clone(),
41            ConversationType::Oneshot,
42            PreconfiguredPolicies::default().to_policy_set(),
43            GroupMetadataOptions::default(),
44            Some(oneshot_message),
45        )?;
46
47        // Add the specified inbox IDs to the group
48        if !inbox_ids.as_ref().is_empty() {
49            group.add_members(&inbox_ids).await?;
50        } else {
51            group.add_missing_installations().await?;
52        }
53
54        // Optional: delete group from DB here
55        Ok(())
56    }
57
58    pub fn process_message(
59        provider: &impl MlsProviderExt,
60        _sender_inbox_id: String,
61        sender_installation_id: Vec<u8>,
62        message: OneshotMessage,
63    ) -> Result<(), GroupError> {
64        match message.message_type {
65            Some(oneshot_message::MessageType::ReaddRequest(readd_request)) => {
66                if Self::validate_readd_request(
67                    provider.key_store(),
68                    &readd_request.group_id,
69                    &sender_installation_id,
70                )? {
71                    let group_id = GroupId::try_from(readd_request.group_id.as_slice())?;
72                    provider.key_store().db().update_requested_at_sequence_id(
73                        &group_id,
74                        &sender_installation_id,
75                        readd_request.latest_commit_sequence_id as i64,
76                    )?;
77                    tracing::info!(
78                        group_id = readd_request.group_id.snippet(),
79                        sender_installation_id = sender_installation_id.snippet(),
80                        latest_commit_sequence_id = readd_request.latest_commit_sequence_id,
81                        "Stored incoming readd request"
82                    );
83                }
84            }
85            _ => {
86                tracing::warn!(
87                    "Oneshot message {:?} is not a recognized message type",
88                    message.message_type
89                );
90            }
91        }
92        Ok(())
93    }
94
95    pub fn process_welcome(
96        provider: &impl MlsProviderExt,
97        cursor: Cursor,
98        sender_inbox_id: String,
99        sender_installation_id: Vec<u8>,
100        metadata: GroupMetadata,
101    ) -> Result<(), GroupError> {
102        tracing::debug!("Processing oneshot welcome");
103        if let Some(message) = metadata.oneshot_message {
104            Self::process_message(provider, sender_inbox_id, sender_installation_id, message)?;
105        } else {
106            tracing::warn!(
107                "Oneshot group welcome {} does not have oneshot message",
108                cursor
109            );
110        }
111        Ok(())
112    }
113
114    fn validate_readd_request(
115        storage: &impl XmtpMlsStorageProvider,
116        group_id: &Vec<u8>,
117        sender_installation_id: &Vec<u8>,
118    ) -> Result<bool, GroupError> {
119        let Some(record) = storage
120            .db()
121            .get_consent_record(hex::encode(group_id), ConsentType::ConversationId)?
122        else {
123            tracing::warn!(
124                group_id = group_id.snippet(),
125                "No consent record found for readd request"
126            );
127            return Ok(false);
128        };
129        if record.state != ConsentState::Allowed {
130            tracing::warn!(
131                group_id = group_id.snippet(),
132                "Group not consented for readd request"
133            );
134            return Ok(false);
135        }
136        // Fetch the OpenMLS group without locking; consistency is not important here, and we are not writing to it
137        let Ok(Some(openmls_group)) = openmls::group::MlsGroup::load(
138            storage,
139            &openmls::group::GroupId::from_slice(group_id.as_slice()),
140        ) else {
141            tracing::warn!(
142                group_id = group_id.snippet(),
143                "Unable to load OpenMLS group for readd request"
144            );
145            return Ok(false);
146        };
147        if !openmls_group
148            .members()
149            .any(|member| member.signature_key == *sender_installation_id)
150        {
151            tracing::warn!(
152                group_id = group_id.snippet(),
153                "Sender not a member of group for readd request"
154            );
155            return Ok(false);
156        }
157
158        Ok(true)
159    }
160}
161
162#[cfg(all(test, not(target_arch = "wasm32")))]
163mod tests {
164    use super::*;
165    use crate::tester;
166    use futures::stream::StreamExt;
167    use xmtp_proto::xmtp::mls::message_contents::{ReaddRequest, oneshot_message::MessageType};
168
169    #[tokio::test]
170    async fn test_receive_oneshot_message_via_syncing() {
171        use xmtp_db::prelude::QueryReaddStatus;
172
173        tester!(alix);
174        tester!(bo);
175        tester!(caro);
176
177        let a_group = alix
178            .create_group_with_members(&[bo.inbox_id(), caro.inbox_id()], None, None)
179            .await
180            .unwrap();
181        let group_id = a_group.group_id;
182        let group_id_typed: GroupId = group_id;
183        bo.sync_all_welcomes_and_groups(None).await.unwrap();
184        caro.sync_all_welcomes_and_groups(None).await.unwrap();
185        let b_group = bo.group(&group_id).unwrap();
186        b_group.update_consent_state(ConsentState::Allowed).unwrap();
187        let c_group = caro.group(&group_id).unwrap();
188        c_group.update_consent_state(ConsentState::Allowed).unwrap();
189        let latest_commit_sequence_id = 42;
190
191        // Verify that Bo and Caro have no readd status for Alix initially
192        let bo_initial_status = bo
193            .context
194            .db()
195            .get_readd_status(&group_id_typed, alix.context.installation_id().as_slice())
196            .expect("Failed to query readd status");
197        assert!(
198            bo_initial_status.is_none(),
199            "Bo should not have readd status for Alix initially"
200        );
201
202        let caro_initial_status = caro
203            .context
204            .db()
205            .get_readd_status(&group_id_typed, alix.context.installation_id().as_slice())
206            .expect("Failed to query readd status");
207        assert!(
208            caro_initial_status.is_none(),
209            "Caro should not have readd status for Alix initially"
210        );
211
212        // Create a test oneshot message (using ReaddRequest as example)
213        let readd_request = ReaddRequest {
214            group_id: group_id.to_vec(),
215            latest_commit_sequence_id,
216        };
217        let oneshot_message = OneshotMessage {
218            message_type: Some(MessageType::ReaddRequest(readd_request.clone())),
219        };
220
221        // Send the oneshot message
222        Oneshot::send_message(
223            alix.context.clone(),
224            vec![bo.inbox_id(), caro.inbox_id()],
225            oneshot_message.clone(),
226        )
227        .await
228        .expect("Failed to send oneshot message");
229
230        // Bo syncs welcomes
231        bo.sync_welcomes().await.expect("Failed to sync welcomes");
232
233        // Verify that Bo now has readd status for Alix with the correct sequence ID
234        let bo_status = bo
235            .context
236            .db()
237            .get_readd_status(&group_id_typed, alix.context.installation_id().as_slice())
238            .expect("Failed to query readd status")
239            .expect("Bo should have readd status for Alix after syncing");
240
241        assert_eq!(
242            bo_status.requested_at_sequence_id,
243            Some(latest_commit_sequence_id as i64),
244            "Bo should have requested_at_sequence_id set to {}",
245            latest_commit_sequence_id
246        );
247        assert_eq!(
248            bo_status.responded_at_sequence_id, None,
249            "Bo should not have responded_at_sequence_id set"
250        );
251
252        // Caro syncs welcomes
253        caro.sync_welcomes().await.expect("Failed to sync welcomes");
254
255        // Verify that Caro now has readd status for Alix with the correct sequence ID
256        let caro_status = caro
257            .context
258            .db()
259            .get_readd_status(&group_id_typed, alix.context.installation_id().as_slice())
260            .expect("Failed to query readd status")
261            .expect("Caro should have readd status for Alix after syncing");
262
263        assert_eq!(
264            caro_status.requested_at_sequence_id,
265            Some(latest_commit_sequence_id as i64),
266            "Caro should have requested_at_sequence_id set to {}",
267            latest_commit_sequence_id
268        );
269        assert_eq!(
270            caro_status.responded_at_sequence_id, None,
271            "Caro should not have responded_at_sequence_id set"
272        );
273    }
274
275    #[tokio::test]
276    async fn test_oneshot_groups_not_in_find_groups() {
277        tester!(alix);
278        tester!(bo);
279
280        // Create a test oneshot message
281        let readd_request = ReaddRequest {
282            group_id: vec![1, 2, 3, 4],
283            latest_commit_sequence_id: 0,
284        };
285        let oneshot_message = OneshotMessage {
286            message_type: Some(MessageType::ReaddRequest(readd_request)),
287        };
288
289        // Alix sends the oneshot message to Bo
290        Oneshot::send_message(alix.context.clone(), vec![bo.inbox_id()], oneshot_message)
291            .await
292            .expect("Failed to send oneshot message");
293
294        // Bo syncs welcomes to receive any oneshot groups
295        bo.sync_welcomes().await.expect("Failed to sync welcomes");
296
297        // Check that neither Alix nor Bo has any oneshot groups in find_groups
298        let alix_groups = alix.find_groups(Default::default()).unwrap();
299        let bo_groups = bo.find_groups(Default::default()).unwrap();
300
301        // Oneshot groups should not appear in the regular groups list
302        assert_eq!(alix_groups.len(), 0, "Alix should have no groups");
303        assert_eq!(bo_groups.len(), 0, "Bo should have no groups");
304    }
305
306    #[tokio::test]
307    async fn test_oneshot_groups_not_in_stream_groups() {
308        tester!(alix);
309        tester!(bo);
310
311        // Subscribe to conversation events
312        let mut alix_conversations = alix
313            .stream_conversations(None, false)
314            .await
315            .expect("Failed to stream conversations");
316        let mut bo_conversations = bo
317            .stream_conversations(None, false)
318            .await
319            .expect("Failed to stream conversations");
320
321        // Create a test oneshot message
322        let readd_request = ReaddRequest {
323            group_id: vec![5, 6, 7, 8],
324            latest_commit_sequence_id: 0,
325        };
326        let oneshot_message = OneshotMessage {
327            message_type: Some(MessageType::ReaddRequest(readd_request)),
328        };
329
330        // Alix sends the oneshot message to Bo
331        Oneshot::send_message(alix.context.clone(), vec![bo.inbox_id()], oneshot_message)
332            .await
333            .expect("Failed to send oneshot message");
334
335        // Small delay to ensure any events would have been processed
336        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
337
338        // Try to get any conversation events with a timeout
339        let alix_event = tokio::time::timeout(
340            std::time::Duration::from_millis(100),
341            alix_conversations.next(),
342        )
343        .await;
344        let bo_event = tokio::time::timeout(
345            std::time::Duration::from_millis(100),
346            bo_conversations.next(),
347        )
348        .await;
349
350        // Both should timeout (no new conversations)
351        assert!(
352            alix_event.is_err(),
353            "Alix should not receive new conversation event"
354        );
355        assert!(
356            bo_event.is_err(),
357            "Bo should not receive new conversation event"
358        );
359    }
360
361    #[tokio::test]
362    async fn test_syncing_and_streaming_oneshot_group_simultaneously() {
363        // Test syncing and streaming simultaneously, which causes Welcome to be processed twice
364        // Note that on the second time, there is no group in the DB to refetch by ID - this should
365        // not surface an error in the stream
366        tester!(alix);
367        tester!(bo);
368
369        let mut bo_conversations = bo
370            .stream_conversations(None, false)
371            .await
372            .expect("Failed to stream conversations");
373
374        // Create a test oneshot message
375        let readd_request = ReaddRequest {
376            group_id: vec![5, 6, 7, 8],
377            latest_commit_sequence_id: 0,
378        };
379        let oneshot_message = OneshotMessage {
380            message_type: Some(MessageType::ReaddRequest(readd_request)),
381        };
382
383        // Alix sends the oneshot message to Bo
384        Oneshot::send_message(alix.context.clone(), vec![bo.inbox_id()], oneshot_message)
385            .await
386            .expect("Failed to send oneshot message");
387
388        // Bo syncs welcomes to receive any oneshot groups
389        bo.sync_welcomes().await.expect("Failed to sync welcomes");
390
391        // Small delay to ensure any events would have been processed
392        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
393
394        let bo_event = tokio::time::timeout(
395            std::time::Duration::from_millis(100),
396            bo_conversations.next(),
397        )
398        .await;
399
400        assert!(
401            bo_event.is_err(),
402            "Bo should not receive new conversation event"
403        );
404    }
405}