Skip to main content

xmtp_mls/groups/
membership.rs

1//! Adding, removing, and re-adding members.
2
3use super::*;
4
5impl<Context> MlsGroup<Context>
6where
7    Context: XmtpSharedContext,
8{
9    ///
10    /// Add members to the group by account address
11    ///
12    /// If any existing members have new installations that have not been added or removed, the
13    /// group membership will be updated to include those changes as well.
14    /// # Returns
15    /// - `Ok(UpdateGroupMembershipResult)`: Contains details about the membership changes, including:
16    ///   - `added_members`: list of added installations
17    ///   - `removed_members`: A list of installations that were removed.
18    ///   - `members_with_errors`: A list of members that encountered errors during the update.
19    /// - `Err(GroupError)`: If the operation fails due to an error.
20    #[tracing::instrument(level = "trace", skip_all)]
21    pub async fn add_members_by_identity(
22        &self,
23        account_identifiers: &[Identifier],
24    ) -> Result<UpdateGroupMembershipResult, GroupError> {
25        // Fetch the associated inbox_ids
26        let requests = account_identifiers.iter().map(Into::into).collect();
27        let inbox_id_map: HashMap<Identifier, String> = self
28            .context
29            .api()
30            .get_inbox_ids(requests)
31            .await?
32            .into_iter()
33            .zip(account_identifiers.iter().cloned())
34            .filter_map(|(inbox, identifier)| inbox.map(|inbox| (identifier, inbox)))
35            .collect();
36
37        // The member limit is enforced in `add_members`, the one method both entry
38        // points reach.
39        if inbox_id_map.len() != account_identifiers.len() {
40            // The requested identifiers with no inbox, in request order.
41            let missing_addresses = account_identifiers
42                .iter()
43                .filter(|identifier| !inbox_id_map.contains_key(*identifier))
44                .map(|identifier| identifier.to_string())
45                .collect();
46            return Err(GroupError::AddressNotFound(missing_addresses));
47        }
48
49        self.add_members(&inbox_id_map.into_values().collect::<Vec<_>>())
50            .await
51    }
52
53    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", skip_all, fields(inbox_id = %self.context.inbox_id(), inbox_ids = ?inbox_ids.as_ref().iter().map(|i| i.as_ref()).collect::<Vec<_>>())))]
54    #[cfg_attr(
55        not(any(test, feature = "test-utils")),
56        tracing::instrument(level = "trace", skip_all)
57    )]
58    pub async fn add_members<S: AsIdRef>(
59        &self,
60        inbox_ids: impl AsRef<[S]>,
61    ) -> Result<UpdateGroupMembershipResult, GroupError> {
62        self.ensure_not_paused().await?;
63
64        let ids = inbox_ids
65            .as_ref()
66            .iter()
67            .map(AsIdRef::as_ref)
68            .collect::<Vec<&str>>();
69        let intent_data = self
70            .get_membership_update_intent(ids.as_slice(), &[])
71            .await?;
72
73        // TODO:nm this isn't the best test for whether the request is valid
74        // If some existing group member has an update, this will return an intent with changes
75        // when we really should return an error
76        let ok_result = Ok(UpdateGroupMembershipResult::from(intent_data.clone()));
77
78        if intent_data.is_empty() {
79            tracing::warn!("Member already added");
80            return ok_result;
81        }
82
83        // The deployment sets the ceiling, checked here — the one
84        // method both entry points reach — before the commit is built and
85        // before anything is published. The count comes from the group's own
86        // membership extension unioned with the inboxes this commit would add,
87        // not from `members()`: that helper skips an inbox still at sequence id
88        // zero, which is exactly where a just-created group's creator sits.
89        let max_members = self
90            .context
91            .server_configuration()
92            .configuration()
93            .mls
94            .max_group_members;
95        let existing = self.with_group_snapshot(|group| {
96            Ok(xmtp_mls_validation::commit::extract_group_membership(
97                group.extensions(),
98            )?)
99        })?;
100        let resulting: HashSet<&str> = existing
101            .inbox_ids()
102            .into_iter()
103            .chain(intent_data.membership_updates.keys().map(String::as_str))
104            .collect();
105        if resulting.len() > max_members {
106            return Err(GroupError::UserLimitExceeded);
107        }
108
109        let intent = QueueIntent::update_group_membership()
110            .data(intent_data)
111            .queue(self)?;
112
113        self.sync_until_intent_resolved(intent.id).await?;
114        let epoch = self.epoch().await?;
115
116        log_event!(
117            Event::AddedMembers,
118            self.context.installation_id(),
119            group_id = self.group_id,
120            members = ?ids,
121            epoch
122        );
123
124        ok_result
125    }
126
127    /// Removes members from the group by their account addresses.
128    ///
129    /// # Arguments
130    /// * `client` - The XMTP client.
131    /// * `account_addresses_to_remove` - A vector of account addresses to remove from the group.
132    ///
133    /// # Returns
134    /// A `Result` indicating success or failure of the operation.
135    pub async fn remove_members_by_identity(
136        &self,
137        account_addresses_to_remove: &[Identifier],
138    ) -> Result<(), GroupError> {
139        let account_addresses_to_remove =
140            account_addresses_to_remove.iter().map(Into::into).collect();
141
142        let inbox_id_map = self
143            .context
144            .api()
145            .get_inbox_ids(account_addresses_to_remove)
146            .await?;
147
148        let ids = inbox_id_map
149            .iter()
150            .flatten()
151            .map(AsRef::as_ref)
152            .collect::<Vec<&str>>();
153        self.remove_members(ids.as_slice()).await
154    }
155
156    /// Removes members from the group by their inbox IDs.
157    ///
158    /// # Arguments
159    /// * `client` - The XMTP client.
160    /// * `inbox_ids` - A vector of inbox IDs to remove from the group.
161    ///
162    /// # Returns
163    /// A `Result` indicating success or failure of the operation.
164    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", skip_all, fields(inbox_id = %self.context.inbox_id(), inbox_ids = ?inbox_ids)))]
165    #[cfg_attr(
166        not(any(test, feature = "test-utils")),
167        tracing::instrument(level = "trace", skip_all)
168    )]
169    pub async fn remove_members(&self, inbox_ids: &[InboxIdRef<'_>]) -> Result<(), GroupError> {
170        self.ensure_not_paused().await?;
171        let intent_data = self.get_membership_update_intent(&[], inbox_ids).await?;
172        let intent = QueueIntent::update_group_membership()
173            .data(intent_data)
174            .queue(self)?;
175
176        let _ = self.sync_until_intent_resolved(intent.id).await?;
177
178        Ok(())
179    }
180
181    /// Removes and readds installations from the MLS tree.
182    ///
183    /// The installation list should be validated beforehand - invalid installations
184    /// will simply be omitted at the time that the intent's publish data is computed.
185    ///
186    /// # Arguments
187    /// * `installations` - A vector of installations to readd.
188    ///
189    /// # Returns
190    /// A `Result` indicating success or failure of the operation.
191    #[allow(dead_code)]
192    pub(crate) async fn readd_installations(
193        &self,
194        installations: Vec<Vec<u8>>,
195    ) -> Result<(), GroupError> {
196        self.ensure_not_paused().await?;
197
198        let readd_min_version =
199            LibXMTPVersion::parse(xmtp_configuration::MIN_RECOVERY_REQUEST_VERSION)
200                .map_err(super::validated_commit::CommitValidationError::from)?;
201        let metadata = self.live_mutable_metadata()?;
202        let group_version = metadata
203            .attributes
204            .get(MetadataField::MinimumSupportedProtocolVersion.as_str());
205        let group_min_version =
206            LibXMTPVersion::parse(group_version.unwrap_or(&"0.0.0".to_string()))
207                .map_err(super::validated_commit::CommitValidationError::from)?;
208
209        if readd_min_version > group_min_version {
210            self.update_group_min_version(xmtp_configuration::MIN_RECOVERY_REQUEST_VERSION)
211                .await?;
212        }
213
214        let intent_data: Vec<u8> = ReaddInstallationsIntentData::new(installations.clone()).into();
215        let intent = QueueIntent::readd_installations()
216            .data(intent_data)
217            .queue(self)?;
218
219        let _ = self.sync_until_intent_resolved(intent.id).await?;
220
221        Ok(())
222    }
223
224    /// Process this group's pending self-remove requests end-to-end: remove the
225    /// members still in the group that requested removal, then clean up stale
226    /// pending-remove rows. Idempotent and a no-op when this client is not a
227    /// super-admin, so it is safe to call from both the inline message-processing
228    /// fast-path and the durable `TaskRunner` retry path.
229    pub(crate) async fn process_pending_self_removals(&self) -> Result<(), GroupError> {
230        // Both helpers early-return on an empty pending list; cleanup owns the
231        // flag-clear. Keeping the empty-check inside cleanup (rather than a
232        // separate clear here) avoids racing a concurrent LeaveRequest insert and
233        // wrongly clearing the flag.
234        self.remove_members_pending_removal().await?;
235        self.cleanup_pending_removal_list().await?;
236        Ok(())
237    }
238
239    /// Removes all members from the group who are currently in the pending removal list.
240    ///
241    /// Only admins and super admins can call this function. Validates permissions, filters
242    /// out invalid removal requests and performs batch removal of valid pending members.
243    ///
244    /// # Returns
245    /// * `Ok(())` - All valid pending members were successfully removed
246    /// * `Err(GroupError)` - Failed to retrieve metadata, validate permissions or execute removals
247    pub async fn remove_members_pending_removal(&self) -> Result<(), GroupError> {
248        let pending_removal_list = self.pending_remove_list()?;
249
250        if pending_removal_list.is_empty() {
251            tracing::debug!(
252                group_id = %self.group_id,
253                inbox_id = %self.context.inbox_id(),
254                "Group has no pending removal members"
255            );
256            return Ok(());
257        }
258
259        let is_super_admin = self.live_is_super_admin(self.context.inbox_id())?;
260        if !is_super_admin {
261            tracing::debug!(
262                group_id = %self.group_id,
263                inbox_id = %self.context.inbox_id(),
264                "Current inbox ID is not in admin or super admin list, skipping pending removal processing"
265            );
266            return Ok(());
267        }
268
269        // Get current group members to validate which ones actually exist
270        let members = self.members().await?;
271        let member_inbox_ids: HashSet<String> =
272            members.iter().map(|m| m.inbox_id.clone()).collect();
273
274        // Filter pending removals to only include actual group members
275        let valid_removals: Vec<&str> = pending_removal_list
276            .iter()
277            .filter(|inbox_id| member_inbox_ids.contains(*inbox_id))
278            .map(|s| s.as_str())
279            .collect();
280
281        if valid_removals.is_empty() {
282            tracing::warn!(
283                group_id = %self.group_id,
284                pending_count = pending_removal_list.len(),
285                "No valid members found in pending removal list"
286            );
287            return Ok(());
288        }
289        // Log members that are in pending list but not in group
290        let invalid_removals: Vec<&String> = pending_removal_list
291            .iter()
292            .filter(|inbox_id| !member_inbox_ids.contains(*inbox_id))
293            .collect();
294
295        if !invalid_removals.is_empty() {
296            tracing::warn!(
297                group_id = %self.group_id,
298                invalid_members = ?invalid_removals,
299                "Some members in pending removal list are not in the group"
300            );
301        }
302
303        // Remove all valid members at once
304        tracing::info!(
305            group_id = %self.group_id,
306            removing_count = valid_removals.len(),
307            members_to_remove = ?valid_removals,
308            "Removing pending members from group"
309        );
310
311        match self.remove_members(&valid_removals).await {
312            Ok(_) => {
313                tracing::info!(
314                    group_id = %self.group_id,
315                    removed_count = valid_removals.len(),
316                    removed_members = ?valid_removals,
317                    "Successfully removed all pending members from group"
318                );
319            }
320            Err(e) => {
321                tracing::error!(
322                    group_id = %self.group_id,
323                    removed_members = ?valid_removals,
324                    error = %e,
325                    "Failed to remove pending members from group"
326                );
327                return Err(e);
328            }
329        }
330
331        Ok(())
332    }
333
334    /// Removes members from the pending removal list who are no longer in the group.
335    ///
336    /// Iterates through all members in the pending removal list, checking each one to see
337    /// if they're still in the group. If a member is no longer in the group, they are
338    /// removed from the pending list. The pending list is refreshed after each removal
339    /// to ensure we're working with the most current data.
340    ///
341    /// # Returns
342    /// * `Ok(())` - Successfully processed all pending removal members
343    /// * `Err(GroupError)` - Failed to retrieve data or update the pending list
344    pub async fn cleanup_pending_removal_list(&self) -> Result<(), GroupError> {
345        tracing::debug!(
346            group_id = %self.group_id,
347            "Starting pending removal list cleanup"
348        );
349
350        // Get both lists upfront
351        let pending_removal_list = self.pending_remove_list()?;
352
353        if pending_removal_list.is_empty() {
354            tracing::debug!(
355                group_id = %self.group_id,
356                "No pending removals to clean up"
357            );
358            // Clear the pending leave request status
359            self.context
360                .db()
361                .set_group_has_pending_leave_request_status(&self.group_id, Some(false))?;
362            return Ok(());
363        }
364
365        // Get current group members
366        let current_members = self.members().await?;
367        let current_member_ids: Vec<String> = current_members
368            .iter()
369            .map(|member| member.inbox_id.clone())
370            .collect();
371
372        // Calculate removed members: users in pending list but not in current group
373        let removed_members: Vec<String> = pending_removal_list
374            .iter()
375            .filter(|pending_user| !current_member_ids.contains(pending_user))
376            .cloned()
377            .collect();
378
379        if !removed_members.is_empty() {
380            tracing::info!(
381                group_id = %self.group_id,
382                removed_count = removed_members.len(),
383                removed_members = ?removed_members,
384                "Removing members from pending removal list - they are no longer in the group"
385            );
386
387            // Remove all users who are no longer in the group from pending list
388            self.context
389                .db()
390                .delete_pending_remove_users(&self.group_id, removed_members)?;
391        }
392
393        // After cleanup, check if there are any pending removals left
394        let remaining_pending_list = self.pending_remove_list()?;
395        if remaining_pending_list.is_empty() {
396            // Clear the pending leave request status if no pending removals remain
397            self.context
398                .db()
399                .set_group_has_pending_leave_request_status(&self.group_id, Some(false))?;
400        }
401
402        tracing::info!(
403            group_id = %self.group_id,
404            remaining_pending = remaining_pending_list.len(),
405            "Finished cleaning up pending removal list"
406        );
407
408        Ok(())
409    }
410
411    // implements: GMOD-031
412    pub async fn leave_group(&self) -> Result<(), GroupError> {
413        self.ensure_not_paused().await?;
414
415        // Check if user is a member
416        let is_member = self.is_member().await?;
417        if !is_member {
418            return Err(GroupLeaveValidationError::NotAGroupMember.into());
419        }
420
421        //check member size
422        let members = self.members().await?;
423
424        // check if the group has other members
425        if members.len() == 1 {
426            return Err(GroupLeaveValidationError::SingleMemberLeaveRejected.into());
427        }
428
429        // check if the conversation is not a DM
430        if self.metadata().await?.conversation_type == ConversationType::Dm {
431            return Err(GroupLeaveValidationError::DmLeaveForbidden.into());
432        }
433
434        let is_super_admin = self.live_is_super_admin(self.context.inbox_id())?;
435
436        // super-admin cannot leave a group; must be demoted first
437        // since SuperAdmins can't remove other SuperAdmins they need to be demoted first
438        if is_super_admin {
439            return Err(GroupLeaveValidationError::SuperAdminLeaveForbidden.into());
440        }
441
442        if !self.is_in_pending_remove(self.context.inbox_id())? {
443            let content = LeaveRequestCodec::encode(LeaveRequest {
444                authenticated_note: None,
445            })?;
446            self.send_message(
447                &encoded_content_to_bytes(content),
448                SendMessageOpts::default(),
449            )
450            .await?;
451        };
452        Ok(())
453    }
454
455    /// Checks if the current user is a member of the group.
456    /// Returns true if the user is a member, false otherwise.
457    #[tracing::instrument(level = "debug", skip(self))]
458    async fn is_member(&self) -> Result<bool, GroupError> {
459        let members = self.members().await?;
460        Ok(members
461            .iter()
462            .any(|m| m.inbox_id == self.context.inbox_id()))
463    }
464}