Skip to main content

whatsapp_rust/features/
groups.rs

1use crate::client::Client;
2use crate::features::mex::{MexError, mex_request};
3use crate::request::IqError;
4use std::borrow::Cow;
5use std::collections::HashMap;
6use std::sync::Arc;
7use thiserror::Error;
8use wacore::client::context::GroupInfo;
9use wacore::iq::contacts::SetProfilePictureSpec;
10// Returned by set/remove_profile_picture; re-exported so callers don't reach
11// into wacore directly (consistent with GroupProfilePicture below).
12pub use wacore::iq::contacts::SetProfilePictureResponse;
13use wacore::iq::groups::{
14    AcceptGroupInviteIq, AcceptGroupInviteV4Iq, AcknowledgeGroupIq, AddParticipantsIq,
15    BatchGetGroupInfoIq, CancelMembershipRequestsIq, DemoteParticipantsIq, GetGroupInviteInfoIq,
16    GetGroupInviteLinkIq, GetGroupProfilePicturesIq, GetMembershipRequestsIq, GroupCreateIq,
17    GroupInfoOutcome, GroupInfoResponse, GroupParticipantResponse, GroupParticipatingIq,
18    GroupQueryIq, LeaveGroupIq, MembershipRequestActionIq, PromoteParticipantsIq,
19    RemoveParticipantsIncludingLinkedGroupsIq, RemoveParticipantsIq, RevokeRequestCodeIq,
20    SetAllowAdminReportsIq, SetGroupAnnouncementIq, SetGroupDescriptionIq, SetGroupEphemeralIq,
21    SetGroupHistoryIq, SetGroupLockedIq, SetGroupMembershipApprovalIq, SetGroupSubjectIq,
22    SetMemberAddModeIq, SetNoFrequentlyForwardedIq, normalize_participants,
23};
24use wacore::iq::mex_operations::update_group_property;
25use wacore::types::message::AddressingMode;
26use wacore_binary::{Jid, JidExt as _};
27
28use wacore::iq::groups::BatchGroupInfoResult as RawBatchResult;
29pub use wacore::iq::groups::{
30    GroupAppealStatus, GroupCreateOptions, GroupDescription, GroupEphemeralSettings,
31    GroupJoinError, GroupParticipantDetails, GroupParticipantOptions, GroupProfilePicture,
32    GroupSubject, GrowthLockInfo, InviteInfoError, JoinGroupResult, MemberAddMode, MemberLinkMode,
33    MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, ParticipantChangeResponse,
34    ParticipantType, PictureType,
35};
36
37/// Error returned by group operations (metadata queries, participant and
38/// settings mutations, invites, profile pictures).
39#[derive(Debug, Error)]
40#[non_exhaustive]
41pub enum GroupError {
42    /// A `w:g2` IQ to the server failed (transport, timeout, server rejection).
43    #[error("{0}")]
44    Iq(#[from] IqError),
45    /// A MEX (GraphQL) group-property mutation failed.
46    #[error("{0}")]
47    Mex(#[from] MexError),
48    /// The request was malformed (e.g. empty invite code, batch over the limit,
49    /// expired V4 invite, non-group JID where one is required).
50    #[error("invalid group request: {0}")]
51    InvalidRequest(String),
52    /// The server refused a description update because the `prev` token no
53    /// longer matches the group's current description: another device changed
54    /// it first. Distinct from a permission refusal, so a caller can re-read
55    /// the description and retry instead of giving up.
56    #[error("the group description changed since it was read")]
57    DescriptionConflict,
58    /// Catch-all for internal failures (LID/PN resolution, the protocol-message
59    /// send path behind `update_member_label`, cache plumbing).
60    #[error("{0}")]
61    Internal(#[from] anyhow::Error),
62}
63
64/// The description a [`Groups::set_description`] call expects to replace.
65///
66/// The server takes the `prev` attribute as an optimistic-concurrency token: it
67/// applies the update only when the token matches the group's current
68/// description id, and answers `409 conflict` otherwise. A group with no
69/// description expects no token at all.
70///
71/// The default is [`PreviousDescription::Resolve`] because it is the only
72/// variant that is correct without knowing anything about the group. Note that
73/// it is also the only one that costs a round trip.
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75pub enum PreviousDescription<'a> {
76    /// Read the group's current description id from the server before sending.
77    /// The right choice when the caller holds no fresh metadata.
78    #[default]
79    Resolve,
80    /// The group carries no description yet (a freshly created group), so no
81    /// token is sent.
82    Absent,
83    /// A description id the caller already holds, typically
84    /// [`GroupMetadata::description_id`] from a recent [`Groups::get_metadata`].
85    Id(&'a str),
86}
87
88/// Turns a held [`GroupMetadata::description_id`] into a token.
89///
90/// `None` means "that metadata says the group has no description", so it maps
91/// to [`PreviousDescription::Absent`] and not to a fresh read. Metadata stale
92/// enough to have missed a description added since is therefore answered with
93/// [`GroupError::DescriptionConflict`]; pass [`PreviousDescription::Resolve`]
94/// when the age of the metadata is unknown.
95impl<'a> From<Option<&'a str>> for PreviousDescription<'a> {
96    fn from(description_id: Option<&'a str>) -> Self {
97        match description_id {
98            Some(id) => Self::Id(id),
99            None => Self::Absent,
100        }
101    }
102}
103
104/// `<error code="409" text="conflict"/>` on a `w:g2` set: the request lost an
105/// optimistic-concurrency check.
106const CONFLICT_STATUS_CODE: u16 = 409;
107
108/// Typed `update` payload for the `update_group_property` mex mutation. The
109/// generated mirror types this op's `update` as a `String`, but it is a one-of
110/// object; this enum's `#[serde(rename_all = "snake_case")]` emits the exact
111/// wire keys with no `serde_json::Value`. Leaf values use the mex (uppercase)
112/// vocabulary, which differs from the lower-case `WireEnum` IQ values.
113#[derive(serde::Serialize)]
114#[serde(rename_all = "snake_case")]
115enum GroupPropertyUpdate {
116    MemberLinkMode(&'static str),
117    MemberShareGroupHistoryMode(&'static str),
118    LimitSharing(LimitSharingUpdate),
119}
120
121#[derive(serde::Serialize)]
122struct LimitSharingUpdate {
123    limit_sharing_enabled: bool,
124    limit_sharing_trigger: &'static str,
125}
126
127#[derive(serde::Serialize)]
128struct UpdateGroupPropertyVars {
129    group_id: String,
130    update: GroupPropertyUpdate,
131}
132
133/// Result for a single group in a batch query.
134#[derive(Debug, Clone)]
135pub enum BatchGroupResult {
136    Full(Box<GroupMetadata>),
137    /// Server returned truncated info (only id and size).
138    Truncated {
139        id: Jid,
140        size: Option<u32>,
141    },
142    Forbidden(Jid),
143    NotFound(Jid),
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Eq)]
147pub struct GroupMetadata {
148    pub id: Jid,
149    pub subject: String,
150    pub notify: Option<String>,
151    pub participants: Vec<GroupParticipant>,
152    pub addressing_mode: AddressingMode,
153    /// Group creator JID.
154    pub creator: Option<Jid>,
155    pub creator_pn: Option<Jid>,
156    pub creator_username: Option<String>,
157    pub creator_country_code: Option<String>,
158    /// Group creation timestamp (Unix seconds).
159    pub creation_time: Option<u64>,
160    pub participant_version_id: Option<String>,
161    pub admin_version_id: Option<String>,
162    pub open_thread_id: Option<String>,
163    pub has_missing_participant_identification: bool,
164    /// Subject modification timestamp (Unix seconds).
165    pub subject_time: Option<u64>,
166    /// Subject owner JID.
167    pub subject_owner: Option<Jid>,
168    pub subject_owner_pn: Option<Jid>,
169    pub subject_owner_username: Option<String>,
170    /// Group description body text.
171    pub description: Option<String>,
172    /// Description ID (for conflict detection when updating).
173    pub description_id: Option<String>,
174    /// JID of the participant who set the description.
175    pub description_owner: Option<Jid>,
176    pub description_owner_pn: Option<Jid>,
177    pub description_owner_username: Option<String>,
178    /// Timestamp when the description was set.
179    pub description_time: Option<u64>,
180    /// Whether the group is locked (only admins can edit group info).
181    pub is_locked: bool,
182    /// Whether announcement mode is enabled (only admins can send messages).
183    pub is_announcement: bool,
184    /// Disappearing-message settings when the server includes an `<ephemeral>` node.
185    pub ephemeral: Option<GroupEphemeralSettings>,
186    /// Whether membership approval is required to join.
187    pub membership_approval: bool,
188    /// Who can add members to the group.
189    pub member_add_mode: Option<MemberAddMode>,
190    /// Who can use invite links.
191    pub member_link_mode: Option<MemberLinkMode>,
192    /// Total participant count.
193    pub size: Option<u32>,
194    /// Whether this group is a community parent group.
195    pub is_parent_group: bool,
196    pub parent_membership_approval_required: bool,
197    /// JID of the parent community (for subgroups).
198    pub parent_group_jid: Option<Jid>,
199    /// Whether this is the default announcement subgroup of a community.
200    pub is_default_sub_group: bool,
201    /// Whether this is the general chat subgroup of a community.
202    pub is_general_chat: bool,
203    /// Whether non-admin community members can create subgroups.
204    pub allow_non_admin_sub_group_creation: bool,
205    /// Whether frequently-forwarded messages are restricted.
206    pub no_frequently_forwarded: bool,
207    /// Who can share message history with new members.
208    pub member_share_history_mode: Option<MemberShareHistoryMode>,
209    /// Growth lock status (invite links temporarily disabled).
210    pub growth_locked: Option<GrowthLockInfo>,
211    /// Whether the group is suspended.
212    pub is_suspended: bool,
213    pub suspension_can_auto_file: bool,
214    pub appeal_status: Option<GroupAppealStatus>,
215    pub appeal_update_time: Option<u64>,
216    pub is_support_group: bool,
217    /// Whether admin reports are allowed.
218    pub allow_admin_reports: bool,
219    /// Whether the group is hidden.
220    pub is_hidden_group: bool,
221    /// Whether incognito mode is enabled.
222    pub is_incognito: bool,
223    /// Whether group history is enabled.
224    pub has_group_history: bool,
225    pub is_auto_add_disabled: bool,
226    pub has_capi: bool,
227    pub evolution_version: Option<u32>,
228    pub has_group_safety_check: bool,
229    pub participant_label_enabled: bool,
230    /// Whether limit sharing is enabled.
231    pub is_limit_sharing_enabled: bool,
232    pub limit_sharing_trigger: Option<u32>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct GroupParticipant {
237    pub jid: Jid,
238    pub phone_number: Option<Jid>,
239    pub lid: Option<Jid>,
240    pub username: Option<wacore_binary::CompactString>,
241    pub participant_type: ParticipantType,
242    pub details: Option<Box<GroupParticipantDetails>>,
243}
244
245impl GroupParticipant {
246    pub fn is_admin(&self) -> bool {
247        self.participant_type.is_admin()
248    }
249
250    pub fn is_super_admin(&self) -> bool {
251        self.participant_type == ParticipantType::SuperAdmin
252    }
253}
254
255impl From<GroupParticipantResponse> for GroupParticipant {
256    fn from(p: GroupParticipantResponse) -> Self {
257        Self {
258            jid: p.jid,
259            phone_number: p.phone_number,
260            lid: p.lid,
261            username: p.username,
262            participant_type: p.participant_type,
263            details: p.details,
264        }
265    }
266}
267
268impl From<GroupInfoResponse> for GroupMetadata {
269    fn from(group: GroupInfoResponse) -> Self {
270        Self {
271            id: group.id,
272            subject: group.subject.into_string(),
273            notify: group.notify,
274            participants: group.participants.into_iter().map(Into::into).collect(),
275            addressing_mode: group.addressing_mode,
276            creator: group.creator,
277            creator_pn: group.creator_pn,
278            creator_username: group.creator_username,
279            creator_country_code: group.creator_country_code,
280            creation_time: group.creation_time,
281            participant_version_id: group.participant_version_id,
282            admin_version_id: group.admin_version_id,
283            open_thread_id: group.open_thread_id,
284            has_missing_participant_identification: group.has_missing_participant_identification,
285            subject_time: group.subject_time,
286            subject_owner: group.subject_owner,
287            subject_owner_pn: group.subject_owner_pn,
288            subject_owner_username: group.subject_owner_username,
289            description: group.description,
290            description_id: group.description_id,
291            description_owner: group.description_owner,
292            description_owner_pn: group.description_owner_pn,
293            description_owner_username: group.description_owner_username,
294            description_time: group.description_time,
295            is_locked: group.is_locked,
296            is_announcement: group.is_announcement,
297            ephemeral: group.ephemeral,
298            membership_approval: group.membership_approval,
299            member_add_mode: group.member_add_mode,
300            member_link_mode: group.member_link_mode,
301            size: group.size,
302            is_parent_group: group.is_parent_group,
303            parent_membership_approval_required: group.parent_membership_approval_required,
304            parent_group_jid: group.parent_group_jid,
305            is_default_sub_group: group.is_default_sub_group,
306            is_general_chat: group.is_general_chat,
307            allow_non_admin_sub_group_creation: group.allow_non_admin_sub_group_creation,
308            no_frequently_forwarded: group.no_frequently_forwarded,
309            member_share_history_mode: group.member_share_history_mode,
310            growth_locked: group.growth_locked,
311            is_suspended: group.is_suspended,
312            suspension_can_auto_file: group.suspension_can_auto_file,
313            appeal_status: group.appeal_status,
314            appeal_update_time: group.appeal_update_time,
315            is_support_group: group.is_support_group,
316            allow_admin_reports: group.allow_admin_reports,
317            is_hidden_group: group.is_hidden_group,
318            is_incognito: group.is_incognito,
319            has_group_history: group.has_group_history,
320            is_auto_add_disabled: group.is_auto_add_disabled,
321            has_capi: group.has_capi,
322            evolution_version: group.evolution_version,
323            has_group_safety_check: group.has_group_safety_check,
324            participant_label_enabled: group.participant_label_enabled,
325            is_limit_sharing_enabled: group.is_limit_sharing_enabled,
326            limit_sharing_trigger: group.limit_sharing_trigger,
327        }
328    }
329}
330
331#[derive(Debug, Clone)]
332#[non_exhaustive]
333pub struct CreateGroupResult {
334    pub metadata: GroupMetadata,
335}
336
337pub struct Groups<'a> {
338    client: &'a Client,
339}
340
341/// Serializes one group's metadata publication with participant mutations and
342/// sender-key distribution, reusing the client's existing per-group lane.
343/// Keeping the guard in the type prevents persistence and cache publication
344/// from accidentally being split across an unlocked await.
345pub(crate) struct GroupMetadataGuard<'a> {
346    client: &'a Client,
347    jid: &'a Jid,
348    _guard: async_lock::MutexGuardArc<()>,
349}
350
351impl GroupMetadataGuard<'_> {
352    pub(crate) async fn current(&self) -> Option<Arc<GroupInfo>> {
353        self.client.get_group_cache().await.get(self.jid).await
354    }
355
356    async fn cache(&self, info: Arc<GroupInfo>) {
357        self.client
358            .get_group_cache()
359            .await
360            .insert(self.jid.clone(), info)
361            .await;
362    }
363
364    pub(crate) async fn publish(&self, info: Arc<GroupInfo>) {
365        let jid = self.jid.to_string();
366        match serde_json::to_vec(info.as_ref()) {
367            Ok(blob) => {
368                if let Err(error) = self
369                    .client
370                    .persistence_manager
371                    .backend()
372                    .put_group_metadata(&jid, &blob)
373                    .await
374                {
375                    log::warn!("Failed to persist group metadata for {}: {error}", self.jid);
376                }
377            }
378            Err(error) => {
379                log::warn!(
380                    "Failed to serialize group metadata for {}: {error}",
381                    self.jid
382                );
383            }
384        }
385
386        self.cache(info).await;
387    }
388
389    pub(crate) async fn invalidate(&self) {
390        if let Err(error) = self
391            .client
392            .persistence_manager
393            .backend()
394            .delete_group_metadata(&self.jid.to_string())
395            .await
396        {
397            log::warn!(
398                "Failed to invalidate persisted group metadata for {}: {error}",
399                self.jid
400            );
401        }
402        self.client
403            .get_group_cache()
404            .await
405            .invalidate(self.jid)
406            .await;
407    }
408}
409
410#[derive(Clone, Copy)]
411enum ParticipantRemovalScope {
412    Group,
413    LinkedGroups,
414}
415
416impl<'a> Groups<'a> {
417    pub(crate) fn new(client: &'a Client) -> Self {
418        Self { client }
419    }
420
421    /// Query the cached, send-oriented view of a group.
422    ///
423    /// Returns the slim [`GroupInfo`] the encryption path needs: participant
424    /// JIDs, the group's addressing mode, the LID/PN mapping, and whether it is
425    /// a community announcement group. A cached entry is returned as-is, so a
426    /// repeated call is free. Only a cache miss goes to the network, and it
427    /// sends the persisted participant phash, so an unchanged group costs a
428    /// `not-modified` answer instead of a full metadata download.
429    ///
430    /// This is the right call for routing and encrypting a message. For the
431    /// user-facing fields (subject, description, admin roles, group settings)
432    /// use [`Groups::get_metadata`], and to control staleness explicitly use
433    /// [`Groups::query_info_with_freshness`].
434    pub async fn query_info(&self, jid: &Jid) -> Result<Arc<GroupInfo>, GroupError> {
435        self.query_info_with_freshness(jid, crate::cache::Freshness::CachePreferred)
436            .await
437    }
438
439    /// Query group metadata using the requested cache freshness policy.
440    ///
441    /// A refresh leaves the current snapshot readable while the network request
442    /// is in flight, then atomically replaces it after a successful response.
443    pub async fn query_info_with_freshness(
444        &self,
445        jid: &Jid,
446        freshness: crate::cache::Freshness,
447    ) -> Result<Arc<GroupInfo>, GroupError> {
448        let cache = self.client.get_group_cache().await;
449        let mut cached = cache.get(jid).await;
450        if freshness == crate::cache::Freshness::CachePreferred
451            && let Some(cached) = cached.take()
452        {
453            return Ok(cached);
454        }
455
456        self.query_info_from_source(jid, cached).await
457    }
458
459    #[expect(
460        clippy::manual_async_fn,
461        reason = "the explicit async block keeps the network-bound state machine out of line"
462    )]
463    fn query_info_from_source<'b>(
464        &'b self,
465        jid: &'b Jid,
466        mut cached: Option<Arc<GroupInfo>>,
467    ) -> impl Future<Output = Result<Arc<GroupInfo>, GroupError>> + 'b {
468        // Keep the large, network-bound state machine shared between refresh
469        // and cache-miss callers. The cache-hit fast path stays in
470        // `query_info_with_freshness`, while this boundary prevents LTO from
471        // cloning the slow path for each statically known freshness policy.
472        #[inline(never)]
473        async move {
474            let jid_str = jid.to_string();
475            let backend = self.client.persistence_manager.backend();
476            loop {
477                // Send the persisted participant phash (WA Web queryGroup phash) so
478                // the server can omit <group> for an unchanged snapshot. On a cold
479                // L1, keep the lane through the request: without an Arc to compare,
480                // this is the only way to distinguish "still absent" from a
481                // notification that invalidated an already-absent snapshot.
482                let (persisted, mut cold_metadata) = if cached.is_some() {
483                    (None, None)
484                } else {
485                    let metadata = self.client.lock_group_metadata(jid).await;
486                    if let Some(current) = metadata.current().await {
487                        cached = Some(current);
488                        drop(metadata);
489                        continue;
490                    }
491                    let persisted = match backend.get_group_metadata(&jid_str).await {
492                        Ok(Some(blob)) => serde_json::from_slice(&blob).ok(),
493                        _ => None,
494                    };
495                    (persisted, Some(metadata))
496                };
497                let phash = cached.as_deref().or(persisted.as_ref()).and_then(|info| {
498                    wacore::messages::MessageUtils::participant_list_hash(&info.participants).ok()
499                });
500
501                let group = match self
502                    .client
503                    .execute(GroupQueryIq::with_phash(jid, phash))
504                    .await?
505                {
506                    GroupInfoOutcome::NotModified => {
507                        if let Some(metadata) = cold_metadata.take() {
508                            let info = Arc::new(persisted.ok_or_else(|| {
509                                GroupError::InvalidRequest(
510                                    "server returned not-modified group but nothing was cached"
511                                        .into(),
512                                )
513                            })?);
514                            metadata.cache(Arc::clone(&info)).await;
515                            return Ok(info);
516                        }
517
518                        // Participant mutations use the same per-group lane, so the
519                        // snapshot and its persisted blob cannot change between this
520                        // check and the decision below.
521                        let metadata = self.client.lock_group_metadata(jid).await;
522                        if let Some(current) = metadata.current().await {
523                            return Ok(current);
524                        }
525
526                        // The warm snapshot used for the conditional request was
527                        // invalidated while the IQ was in flight. Retry without it
528                        // instead of resurrecting pre-notification membership.
529                        drop(metadata);
530                        cached = None;
531                        continue;
532                    }
533                    GroupInfoOutcome::Full(group) => *group,
534                };
535
536                // Single pass: move participants out and build lid_to_pn_map alongside.
537                let participant_count = group.participants.len();
538                let is_lid = group.addressing_mode == AddressingMode::Lid;
539                let mut participants = Vec::with_capacity(participant_count);
540                let mut lid_to_pn_map: HashMap<wacore_binary::CompactString, Jid> = if is_lid {
541                    HashMap::with_capacity(participant_count)
542                } else {
543                    HashMap::new()
544                };
545                for participant in group.participants {
546                    if is_lid && let Some(pn) = participant.phone_number {
547                        lid_to_pn_map.insert(participant.jid.user.clone(), pn);
548                    }
549                    participants.push(participant.jid);
550                }
551
552                // Populate lid_pn_cache so silent-observer participants (no messages
553                // from them) get their mapping; otherwise `invalidate_device_cache`
554                // can't resolve the PN alias and leaves zombie registry entries.
555                if !lid_to_pn_map.is_empty()
556                    && let Some(client_arc) = self.client.self_weak.get().and_then(|w| w.upgrade())
557                {
558                    let mut batch = Vec::with_capacity(lid_to_pn_map.len());
559                    for (lid_user, pn_jid) in &lid_to_pn_map {
560                        if pn_jid.is_pn() {
561                            batch.push((lid_user.as_str().to_string(), pn_jid.user.to_string()));
562                        }
563                    }
564                    client_arc
565                        .learn_lid_pn_mappings_batch(
566                            batch,
567                            crate::lid_pn_cache::LearningSource::Other,
568                            false,
569                        )
570                        .await;
571                }
572
573                let mut info = GroupInfo::new(participants, group.addressing_mode);
574                info.is_community_announce = Some(group.is_default_sub_group);
575                if !lid_to_pn_map.is_empty() {
576                    info.set_lid_to_pn_map(lid_to_pn_map);
577                }
578                let info = Arc::new(info);
579
580                // Compare and publish while holding the same lane as participant
581                // mutations. Persisting inside the guard prevents the durable blob
582                // and L1 snapshot from being committed in opposite orders.
583                let metadata = match cold_metadata {
584                    Some(metadata) => metadata,
585                    None => {
586                        let metadata = self.client.lock_group_metadata(jid).await;
587                        let current = metadata.current().await;
588                        let unchanged = matches!(
589                            (cached.as_ref(), current.as_ref()),
590                            (Some(expected), Some(current)) if Arc::ptr_eq(expected, current)
591                        );
592                        if !unchanged {
593                            drop(metadata);
594                            if let Some(current) = current {
595                                return Ok(current);
596                            }
597                            cached = None;
598                            continue;
599                        }
600                        metadata
601                    }
602                };
603
604                metadata.publish(Arc::clone(&info)).await;
605                return Ok(info);
606            }
607        }
608    }
609
610    /// Backfills each LID participant's `phone_number` from the client's LID-PN
611    /// cache (`get_lid_pn_entry`, same warm-cache + backend path `create_group`
612    /// uses). The server often omits the attribute on `<participant>` nodes of
613    /// LID-addressed groups, so consumers keying data by PN would treat current
614    /// members as absent. No-op outside LID-addressed groups or when the PN is
615    /// already present; unknown mappings leave the participant untouched.
616    pub(super) async fn fill_participant_pns(&self, meta: &mut GroupMetadata) {
617        if meta.addressing_mode != AddressingMode::Lid {
618            return;
619        }
620        // Participants the server left PN-less, kept with their index.
621        let pending: Vec<(usize, Jid)> = meta
622            .participants
623            .iter()
624            .enumerate()
625            .filter(|(_, p)| p.phone_number.is_none() && p.jid.is_lid())
626            .map(|(i, p)| (i, p.jid.clone()))
627            .collect();
628        if pending.is_empty() {
629            return;
630        }
631
632        // Cache hits are in-memory, but a cold cache falls back to the DB and a
633        // large group would otherwise serialize those lookups — bounded fan-out.
634        use futures::StreamExt;
635        const LID_PN_RESOLVE_CONCURRENCY: usize = 16;
636        let resolved: Vec<(usize, Jid)> = futures::stream::iter(pending)
637            .map(|(i, jid)| async move {
638                let pn = self
639                    .client
640                    .get_lid_pn_entry(&jid)
641                    .await
642                    .ok()
643                    .flatten()
644                    .map(|e| Jid::pn(&*e.phone_number));
645                (i, pn)
646            })
647            .buffer_unordered(LID_PN_RESOLVE_CONCURRENCY)
648            .filter_map(|(i, pn)| async move { pn.map(|pn| (i, pn)) })
649            .collect()
650            .await;
651
652        for (i, pn) in resolved {
653            meta.participants[i].phone_number = Some(pn);
654        }
655    }
656
657    pub async fn get_participating(&self) -> Result<HashMap<Jid, GroupMetadata>, GroupError> {
658        let response = self.client.execute(GroupParticipatingIq::new()).await?;
659
660        let mut result: HashMap<Jid, GroupMetadata> = response
661            .groups
662            .into_iter()
663            .map(|group| {
664                let key = group.id.clone();
665                (key, GroupMetadata::from(group))
666            })
667            .collect();
668
669        for meta in result.values_mut() {
670            self.fill_participant_pns(meta).await;
671        }
672
673        Ok(result)
674    }
675
676    /// Fetch the complete, user-facing metadata of a group.
677    ///
678    /// Returns an owned [`GroupMetadata`]: subject, description, creator,
679    /// per-participant admin roles, and ephemeral and membership settings. In a
680    /// LID-addressed group, participant phone numbers the server left out are
681    /// backfilled from known LID/PN mappings on a best-effort basis; a
682    /// participant with no known mapping keeps `phone_number: None`. The query
683    /// always hits the network (no phash is sent, so the server never answers
684    /// `not-modified`) and the result does not populate the group cache.
685    ///
686    /// This is the right call for displaying or auditing a group. When you only
687    /// need the participant list to send a message, prefer the cached
688    /// [`Groups::query_info`].
689    pub async fn get_metadata(&self, jid: &Jid) -> Result<GroupMetadata, GroupError> {
690        // No phash is sent, so the server always returns the full group.
691        match self.client.execute(GroupQueryIq::new(jid)).await? {
692            GroupInfoOutcome::Full(group) => {
693                let mut meta = GroupMetadata::from(*group);
694                self.fill_participant_pns(&mut meta).await;
695                Ok(meta)
696            }
697            GroupInfoOutcome::NotModified => Err(GroupError::InvalidRequest(
698                "group query returned not-modified without a phash".into(),
699            )),
700        }
701    }
702
703    pub async fn create_group(
704        &self,
705        mut options: GroupCreateOptions,
706    ) -> Result<CreateGroupResult, GroupError> {
707        // Resolve phone numbers for LID participants that don't have one
708        let mut resolved_participants = Vec::with_capacity(options.participants.len());
709
710        for participant in options.participants {
711            let resolved = if participant.jid.is_lid() && participant.phone_number.is_none() {
712                let entry = self
713                    .client
714                    .get_lid_pn_entry(&participant.jid)
715                    .await?
716                    .ok_or_else(|| {
717                        GroupError::InvalidRequest(format!(
718                            "missing phone number mapping for LID {}",
719                            participant.jid
720                        ))
721                    })?;
722                participant.with_phone_number(Jid::pn(&*entry.phone_number))
723            } else {
724                participant
725            };
726            resolved_participants.push(resolved);
727        }
728
729        options.participants = normalize_participants(&resolved_participants);
730
731        if self
732            .client
733            .ab_props()
734            .is_enabled(wacore::iq::abprops::web::PRIVACY_TOKEN_SENDING_ON_GROUP_CREATE)
735            .await
736        {
737            self.attach_tokens_to_participants(&mut options.participants)
738                .await;
739        }
740
741        let group = self.client.execute(GroupCreateIq::new(options)).await?;
742
743        Ok(CreateGroupResult {
744            metadata: GroupMetadata::from(group),
745        })
746    }
747
748    pub async fn set_subject(
749        &self,
750        jid: impl Into<Jid>,
751        subject: GroupSubject,
752    ) -> Result<(), GroupError> {
753        let jid = &jid.into();
754        Ok(self
755            .client
756            .execute(SetGroupSubjectIq::new(jid, subject))
757            .await?)
758    }
759
760    /// Set or delete a group's description.
761    ///
762    /// Pass `None` as the description to delete it. `prev` names the
763    /// description this update replaces; the server accepts the change only
764    /// when that token matches the group's current description, so a group that
765    /// already has one cannot be updated without it. With
766    /// [`PreviousDescription::Resolve`] the token is read from the server first,
767    /// which costs one extra query but is always current; a caller holding
768    /// fresh [`GroupMetadata::description_id`] can pass it directly instead.
769    ///
770    /// Returns [`GroupError::DescriptionConflict`] when the group's description
771    /// changed between the read and this update.
772    pub async fn set_description(
773        &self,
774        jid: impl Into<Jid>,
775        description: Option<GroupDescription>,
776        prev: PreviousDescription<'_>,
777    ) -> Result<(), GroupError> {
778        let jid = &jid.into();
779        let prev: Option<Cow<'_, str>> = match prev {
780            PreviousDescription::Absent => None,
781            PreviousDescription::Id(id) => Some(Cow::Borrowed(id)),
782            // Resolution runs first so a failed read never sends an update
783            // carrying a token the server would reject or, worse, accept
784            // against a description the caller never saw.
785            PreviousDescription::Resolve => self.query_description_id(jid).await?.map(Cow::Owned),
786        };
787
788        self.client
789            .execute(SetGroupDescriptionIq::new(
790                jid,
791                description,
792                prev.as_deref(),
793            ))
794            .await
795            .map_err(|err| match err {
796                IqError::ServerError {
797                    code: CONFLICT_STATUS_CODE,
798                    ..
799                } => GroupError::DescriptionConflict,
800                other => other.into(),
801            })
802    }
803
804    /// Read just the group's current description id from the server.
805    ///
806    /// Deliberately not served from the group cache: that snapshot is the slim
807    /// send-path view, and its refresh is conditional on the participant phash,
808    /// so it can be current for participants while the description behind it
809    /// has already moved. For the same reason the query carries no phash, which
810    /// makes the server answer with the whole group; on a large community that
811    /// is a full participant list downloaded for one attribute. The protocol
812    /// offers no narrower read, so the cost is the price of a correct token.
813    async fn query_description_id(&self, jid: &Jid) -> Result<Option<String>, GroupError> {
814        match self.client.execute(GroupQueryIq::new(jid)).await? {
815            GroupInfoOutcome::Full(group) => Ok(group.description_id),
816            GroupInfoOutcome::NotModified => Err(GroupError::InvalidRequest(
817                "group query returned not-modified without a phash".into(),
818            )),
819        }
820    }
821
822    pub async fn leave(&self, jid: impl Into<Jid>) -> Result<(), GroupError> {
823        let jid = &jid.into();
824        self.client.execute(LeaveGroupIq::new(jid)).await?;
825        self.client
826            .lock_group_metadata(jid)
827            .await
828            .invalidate()
829            .await;
830        Ok(())
831    }
832
833    pub async fn add_participants(
834        &self,
835        jid: impl Into<Jid>,
836        participants: &[Jid],
837    ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
838        let jid = &jid.into();
839        let iq = if self
840            .client
841            .ab_props()
842            .is_enabled(wacore::iq::abprops::web::PRIVACY_TOKEN_SENDING_ON_GROUP_PARTICIPANT_ADD)
843            .await
844        {
845            let options = self.resolve_participant_tokens(participants).await;
846            AddParticipantsIq::with_options(jid, options)
847        } else {
848            AddParticipantsIq::new(jid, participants)
849        };
850
851        let result = self.client.execute(iq).await?;
852        if result.iter().any(|r| r.is_ok()) {
853            let metadata = self.client.lock_group_metadata(jid).await;
854            if let Some(info) = metadata.current().await {
855                let mut info = Arc::unwrap_or_clone(info);
856                info.add_participants(
857                    result
858                        .iter()
859                        .filter(|r| r.is_ok())
860                        .map(|r| (&r.jid, r.phone_number.as_ref())),
861                );
862                metadata.publish(Arc::new(info)).await;
863            } else {
864                // Cache expired: can't patch in place, so drop the now-stale blob.
865                metadata.invalidate().await;
866            }
867        }
868        Ok(result)
869    }
870
871    pub async fn remove_participants(
872        &self,
873        jid: impl Into<Jid>,
874        participants: &[Jid],
875    ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
876        let jid = &jid.into();
877        let result = self
878            .client
879            .execute(RemoveParticipantsIq::new(jid, participants))
880            .await?;
881        self.apply_participant_removals(jid, &result, ParticipantRemovalScope::Group)
882            .await;
883        Ok(result)
884    }
885
886    async fn apply_participant_removals(
887        &self,
888        jid: &Jid,
889        result: &[ParticipantChangeResponse],
890        scope: ParticipantRemovalScope,
891    ) {
892        let accepted: Vec<&str> = result
893            .iter()
894            .filter(|r| r.is_ok())
895            .map(|r| r.jid.user.as_str())
896            .collect();
897        if !accepted.is_empty() {
898            match scope {
899                ParticipantRemovalScope::Group => {
900                    let metadata = self.client.lock_group_metadata(jid).await;
901                    if let Some(info) = metadata.current().await {
902                        let mut info = Arc::unwrap_or_clone(info);
903                        info.remove_participants(&accepted);
904                        metadata.publish(Arc::new(info)).await;
905                    } else {
906                        // Cache expired: can't patch in place, so drop the now-stale blob.
907                        metadata.invalidate().await;
908                    }
909                }
910                ParticipantRemovalScope::LinkedGroups => {
911                    // The response carries no subgroup IDs, and the lean send
912                    // cache intentionally stores no hierarchy. Invalidate the
913                    // known parent here; the per-subgroup remove notifications
914                    // carry the affected JIDs, patch their own cache entries,
915                    // and rotate their sender-key chains without evicting
916                    // unrelated groups.
917                    self.client
918                        .lock_group_metadata(jid)
919                        .await
920                        .invalidate()
921                        .await;
922                }
923            }
924            self.client
925                .rotate_sender_key_on_participant_remove(jid, &accepted)
926                .await;
927        }
928    }
929
930    /// Remove participants from a parent group and all of its linked groups.
931    pub async fn remove_participants_including_linked_groups(
932        &self,
933        jid: impl Into<Jid>,
934        participants: &[Jid],
935    ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
936        let jid = &jid.into();
937        let result = self
938            .client
939            .execute(RemoveParticipantsIncludingLinkedGroupsIq::new(
940                jid,
941                participants,
942            ))
943            .await?;
944        self.apply_participant_removals(jid, &result, ParticipantRemovalScope::LinkedGroups)
945            .await;
946        Ok(result)
947    }
948
949    pub async fn promote_participants(
950        &self,
951        jid: impl Into<Jid>,
952        participants: &[Jid],
953    ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
954        let jid = &jid.into();
955        Ok(self
956            .client
957            .execute(PromoteParticipantsIq::new(jid, participants))
958            .await?)
959    }
960
961    pub async fn demote_participants(
962        &self,
963        jid: impl Into<Jid>,
964        participants: &[Jid],
965    ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
966        let jid = &jid.into();
967        Ok(self
968            .client
969            .execute(DemoteParticipantsIq::new(jid, participants))
970            .await?)
971    }
972
973    pub async fn get_invite_link(
974        &self,
975        jid: impl Into<Jid>,
976        reset: bool,
977    ) -> Result<String, GroupError> {
978        let jid = &jid.into();
979        Ok(self
980            .client
981            .execute(GetGroupInviteLinkIq::new(jid, reset))
982            .await?)
983    }
984
985    /// Lock the group so only admins can change group info.
986    pub async fn set_locked(&self, jid: impl Into<Jid>, locked: bool) -> Result<(), GroupError> {
987        let jid = &jid.into();
988        let spec = if locked {
989            SetGroupLockedIq::lock(jid)
990        } else {
991            SetGroupLockedIq::unlock(jid)
992        };
993        Ok(self.client.execute(spec).await?)
994    }
995
996    /// Set announcement mode. When enabled, only admins can send messages.
997    pub async fn set_announce(
998        &self,
999        jid: impl Into<Jid>,
1000        announce: bool,
1001    ) -> Result<(), GroupError> {
1002        let jid = &jid.into();
1003        let spec = if announce {
1004            SetGroupAnnouncementIq::announce(jid)
1005        } else {
1006            SetGroupAnnouncementIq::unannounce(jid)
1007        };
1008        Ok(self.client.execute(spec).await?)
1009    }
1010
1011    /// Set ephemeral (disappearing) messages timer on the group.
1012    ///
1013    /// Common values: 86400 (24h), 604800 (7d), 7776000 (90d).
1014    /// Pass 0 to disable.
1015    pub async fn set_ephemeral(
1016        &self,
1017        jid: impl Into<Jid>,
1018        expiration: u32,
1019    ) -> Result<(), GroupError> {
1020        let jid = &jid.into();
1021        let spec = match std::num::NonZeroU32::new(expiration) {
1022            Some(exp) => SetGroupEphemeralIq::enable(jid, exp),
1023            None => SetGroupEphemeralIq::disable(jid),
1024        };
1025        Ok(self.client.execute(spec).await?)
1026    }
1027
1028    /// Set membership approval mode. When on, new members must be approved by an admin.
1029    pub async fn set_membership_approval(
1030        &self,
1031        jid: impl Into<Jid>,
1032        mode: MembershipApprovalMode,
1033    ) -> Result<(), GroupError> {
1034        let jid = &jid.into();
1035        Ok(self
1036            .client
1037            .execute(SetGroupMembershipApprovalIq::new(jid, mode))
1038            .await?)
1039    }
1040
1041    /// Join a group using an invite code.
1042    pub async fn join_with_invite_code(&self, code: &str) -> Result<JoinGroupResult, GroupError> {
1043        let code = extract_invite_code(code)
1044            .ok_or_else(|| GroupError::InvalidRequest("invalid or empty invite code".into()))?;
1045        Ok(self.client.execute(AcceptGroupInviteIq::new(code)).await?)
1046    }
1047
1048    /// Accept a V4 invite (received as a GroupInviteMessage, not a link).
1049    pub async fn join_with_invite_v4(
1050        &self,
1051        group_jid: impl Into<Jid>,
1052        code: &str,
1053        expiration: i64,
1054        admin_jid: impl Into<Jid>,
1055    ) -> Result<JoinGroupResult, GroupError> {
1056        let group_jid = &group_jid.into();
1057        let admin_jid = &admin_jid.into();
1058        if expiration > 0 {
1059            let now = wacore::time::now_millis() / 1000;
1060            if expiration < now {
1061                return Err(GroupError::InvalidRequest(format!(
1062                    "V4 invite has expired (expiration={expiration}, now={now})"
1063                )));
1064            }
1065        }
1066        Ok(self
1067            .client
1068            .execute(AcceptGroupInviteV4Iq::new(
1069                group_jid, code, expiration, admin_jid,
1070            ))
1071            .await?)
1072    }
1073
1074    /// Get group metadata from an invite code without joining.
1075    pub async fn get_invite_info(&self, code: &str) -> Result<GroupMetadata, GroupError> {
1076        let code = extract_invite_code(code)
1077            .ok_or_else(|| GroupError::InvalidRequest("invalid or empty invite code".into()))?;
1078        let group = self.client.execute(GetGroupInviteInfoIq::new(code)).await?;
1079        Ok(GroupMetadata::from(group))
1080    }
1081
1082    /// Get pending membership approval requests.
1083    pub async fn get_membership_requests(
1084        &self,
1085        jid: impl Into<Jid>,
1086    ) -> Result<Vec<MembershipRequest>, GroupError> {
1087        let jid = &jid.into();
1088        Ok(self
1089            .client
1090            .execute(GetMembershipRequestsIq::new(jid))
1091            .await?)
1092    }
1093
1094    /// Approve pending membership requests.
1095    pub async fn approve_membership_requests(
1096        &self,
1097        jid: impl Into<Jid>,
1098        participants: &[Jid],
1099    ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
1100        let jid = &jid.into();
1101        Ok(self
1102            .client
1103            .execute(MembershipRequestActionIq::approve(jid, participants))
1104            .await?)
1105    }
1106
1107    /// Reject pending membership requests.
1108    pub async fn reject_membership_requests(
1109        &self,
1110        jid: impl Into<Jid>,
1111        participants: &[Jid],
1112    ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
1113        let jid = &jid.into();
1114        Ok(self
1115            .client
1116            .execute(MembershipRequestActionIq::reject(jid, participants))
1117            .await?)
1118    }
1119
1120    /// Set who can add members to the group.
1121    pub async fn set_member_add_mode(
1122        &self,
1123        jid: impl Into<Jid>,
1124        mode: MemberAddMode,
1125    ) -> Result<(), GroupError> {
1126        let jid = &jid.into();
1127        Ok(self
1128            .client
1129            .execute(SetMemberAddModeIq::new(jid, mode))
1130            .await?)
1131    }
1132
1133    /// Restrict or allow frequently-forwarded messages in the group.
1134    pub async fn set_no_frequently_forwarded(
1135        &self,
1136        jid: impl Into<Jid>,
1137        restrict: bool,
1138    ) -> Result<(), GroupError> {
1139        let jid = &jid.into();
1140        Ok(self
1141            .client
1142            .execute(SetNoFrequentlyForwardedIq::new(jid, restrict))
1143            .await?)
1144    }
1145
1146    /// Enable or disable admin reports in the group.
1147    pub async fn set_allow_admin_reports(
1148        &self,
1149        jid: impl Into<Jid>,
1150        allow: bool,
1151    ) -> Result<(), GroupError> {
1152        let jid = &jid.into();
1153        Ok(self
1154            .client
1155            .execute(SetAllowAdminReportsIq::new(jid, allow))
1156            .await?)
1157    }
1158
1159    /// Enable or disable group history sharing.
1160    pub async fn set_group_history(
1161        &self,
1162        jid: impl Into<Jid>,
1163        enabled: bool,
1164    ) -> Result<(), GroupError> {
1165        let jid = &jid.into();
1166        Ok(self
1167            .client
1168            .execute(SetGroupHistoryIq::new(jid, enabled))
1169            .await?)
1170    }
1171
1172    /// Set who can share invite links (via MEX).
1173    pub async fn set_member_link_mode(
1174        &self,
1175        jid: &Jid,
1176        mode: MemberLinkMode,
1177    ) -> Result<(), GroupError> {
1178        let value = match mode {
1179            MemberLinkMode::AdminLink => "ADMIN_LINK",
1180            MemberLinkMode::AllMemberLink => "ALL_MEMBER_LINK",
1181        };
1182        Ok(self
1183            .mex_update_group_property(jid, GroupPropertyUpdate::MemberLinkMode(value))
1184            .await?)
1185    }
1186
1187    /// Set who can share message history with new members (via MEX).
1188    pub async fn set_member_share_history_mode(
1189        &self,
1190        jid: &Jid,
1191        mode: MemberShareHistoryMode,
1192    ) -> Result<(), GroupError> {
1193        let value = match mode {
1194            MemberShareHistoryMode::AdminShare => "ADMIN_SHARE",
1195            MemberShareHistoryMode::AllMemberShare => "ALL_MEMBER_SHARE",
1196        };
1197        Ok(self
1198            .mex_update_group_property(jid, GroupPropertyUpdate::MemberShareGroupHistoryMode(value))
1199            .await?)
1200    }
1201
1202    /// Enable or disable limit sharing in the group (via MEX).
1203    pub async fn set_limit_sharing(&self, jid: &Jid, enabled: bool) -> Result<(), GroupError> {
1204        Ok(self
1205            .mex_update_group_property(
1206                jid,
1207                GroupPropertyUpdate::LimitSharing(LimitSharingUpdate {
1208                    limit_sharing_enabled: enabled,
1209                    limit_sharing_trigger: "CHAT_SETTING",
1210                }),
1211            )
1212            .await?)
1213    }
1214
1215    /// Cancel pending membership requests (from the requesting user's side).
1216    pub async fn cancel_membership_requests(
1217        &self,
1218        jid: impl Into<Jid>,
1219        participants: &[Jid],
1220    ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
1221        let jid = &jid.into();
1222        Ok(self
1223            .client
1224            .execute(CancelMembershipRequestsIq::new(jid, participants))
1225            .await?)
1226    }
1227
1228    /// Revoke invitation codes from specific participants (admin operation).
1229    pub async fn revoke_request_code(
1230        &self,
1231        jid: impl Into<Jid>,
1232        participants: &[Jid],
1233    ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
1234        let jid = &jid.into();
1235        Ok(self
1236            .client
1237            .execute(RevokeRequestCodeIq::new(jid, participants))
1238            .await?)
1239    }
1240
1241    /// Acknowledge a group notification.
1242    pub async fn acknowledge(&self, jid: impl Into<Jid>) -> Result<(), GroupError> {
1243        let jid = &jid.into();
1244        Ok(self.client.execute(AcknowledgeGroupIq::new(jid)).await?)
1245    }
1246
1247    /// Batch query group info for multiple groups at once (max 10,000).
1248    pub async fn batch_get_info(
1249        &self,
1250        jids: Vec<Jid>,
1251    ) -> Result<Vec<BatchGroupResult>, GroupError> {
1252        if jids.len() > wacore::iq::groups::BATCH_GROUP_INFO_LIMIT {
1253            return Err(GroupError::InvalidRequest(format!(
1254                "batch_get_info: {} groups exceeds limit of {}",
1255                jids.len(),
1256                wacore::iq::groups::BATCH_GROUP_INFO_LIMIT,
1257            )));
1258        }
1259        let raw = self.client.execute(BatchGetGroupInfoIq::new(&jids)).await?;
1260        Ok(raw
1261            .into_iter()
1262            .map(|r| match r {
1263                RawBatchResult::Full(info) => {
1264                    BatchGroupResult::Full(Box::new(GroupMetadata::from(*info)))
1265                }
1266                RawBatchResult::Truncated { id, size } => BatchGroupResult::Truncated { id, size },
1267                RawBatchResult::Forbidden(id) => BatchGroupResult::Forbidden(id),
1268                RawBatchResult::NotFound(id) => BatchGroupResult::NotFound(id),
1269            })
1270            .collect())
1271    }
1272
1273    /// Batch fetch group profile pictures (max 1,000).
1274    pub async fn get_profile_pictures(
1275        &self,
1276        group_jids: Vec<Jid>,
1277        picture_type: PictureType,
1278    ) -> Result<Vec<GroupProfilePicture>, GroupError> {
1279        if group_jids.len() > wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT {
1280            return Err(GroupError::InvalidRequest(format!(
1281                "get_profile_pictures: {} groups exceeds limit of {}",
1282                group_jids.len(),
1283                wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT,
1284            )));
1285        }
1286        let groups: Vec<(Jid, PictureType)> = group_jids
1287            .into_iter()
1288            .map(|jid| (jid, picture_type))
1289            .collect();
1290        Ok(self
1291            .client
1292            .execute(GetGroupProfilePicturesIq::with_type(&groups))
1293            .await?)
1294    }
1295
1296    /// Set a group's profile picture (admin operation).
1297    ///
1298    /// Sends a JPEG; the caller should size/crop it (WhatsApp uses 640x640).
1299    /// Passing empty `image_data` removes the picture, mirroring the own-picture
1300    /// API; prefer [`Groups::remove_profile_picture`] when removal is the intent.
1301    ///
1302    /// ## Wire Format
1303    /// ```xml
1304    /// <iq type="set" xmlns="w:profile:picture" to="{group}@g.us">
1305    ///   <picture type="image">{jpeg bytes}</picture>
1306    /// </iq>
1307    /// ```
1308    pub async fn set_profile_picture(
1309        &self,
1310        group_jid: impl Into<Jid>,
1311        image_data: Vec<u8>,
1312    ) -> Result<SetProfilePictureResponse, GroupError> {
1313        let group_jid = &group_jid.into();
1314        Ok(self
1315            .client
1316            .execute(SetProfilePictureSpec::for_group(group_jid, image_data))
1317            .await?)
1318    }
1319
1320    /// Remove a group's profile picture (admin operation).
1321    pub async fn remove_profile_picture(
1322        &self,
1323        group_jid: impl Into<Jid>,
1324    ) -> Result<SetProfilePictureResponse, GroupError> {
1325        let group_jid = &group_jid.into();
1326        Ok(self
1327            .client
1328            .execute(SetProfilePictureSpec::remove_group(group_jid))
1329            .await?)
1330    }
1331
1332    async fn mex_update_group_property(
1333        &self,
1334        jid: &Jid,
1335        update: GroupPropertyUpdate,
1336    ) -> Result<(), MexError> {
1337        let resp = self
1338            .client
1339            .mex()
1340            .mutate(mex_request!(
1341                update_group_property,
1342                UpdateGroupPropertyVars {
1343                    group_id: jid.to_string(),
1344                    update,
1345                }
1346            ))
1347            .await?;
1348
1349        let state = resp
1350            .data
1351            .as_ref()
1352            .and_then(|d| d.get("xwa2_group_update_property"))
1353            .and_then(|r| r.get("state"))
1354            .and_then(|s| s.as_str());
1355
1356        if state != Some("ACTIVE") {
1357            return Err(MexError::PayloadParsing(format!(
1358                "group property update failed, state: {state:?}"
1359            )));
1360        }
1361
1362        Ok(())
1363    }
1364
1365    /// Set or clear the bot's per-group member label. Empty clears.
1366    ///
1367    /// WA Web sends this as a `ProtocolMessage` over the normal message path,
1368    /// not as an IQ.
1369    pub async fn update_member_label(
1370        &self,
1371        group_jid: impl Into<Jid>,
1372        label: impl Into<String>,
1373    ) -> Result<(), GroupError> {
1374        self.update_member_label_with_id(group_jid, label)
1375            .await
1376            .map(|_| ())
1377    }
1378
1379    /// Set or clear the member label and return the sent message ID.
1380    pub async fn update_member_label_with_id(
1381        &self,
1382        group_jid: impl Into<Jid>,
1383        label: impl Into<String>,
1384    ) -> Result<String, GroupError> {
1385        let group_jid = &group_jid.into();
1386        if !group_jid.is_group() {
1387            return Err(GroupError::InvalidRequest(format!(
1388                "update_member_label requires a group JID, got {group_jid}"
1389            )));
1390        }
1391        let msg = wacore::send::build_member_label_message(label.into(), wacore::time::now_secs());
1392        // This low-level send bypasses send_message_with_options (no reporting
1393        // token for a protocol message), so compute the <meta> here and pass it
1394        // as an extra node — otherwise the member_label appdata/tag_reason attrs
1395        // never reach the wire.
1396        let (_edit, meta) = crate::send::infer_stanza_metadata(&msg);
1397        let message_id = self.client.generate_message_id();
1398        self.client
1399            .send_message_impl(
1400                group_jid.clone(),
1401                &msg,
1402                crate::send::SendPipelineOptions {
1403                    request_id: Some(&message_id),
1404                    extra_stanza_nodes: meta.into_iter().collect(),
1405                    ..Default::default()
1406                },
1407            )
1408            .await?;
1409        Ok(message_id)
1410    }
1411
1412    async fn resolve_participant_tokens(&self, jids: &[Jid]) -> Vec<GroupParticipantOptions> {
1413        if jids.is_empty() {
1414            return Vec::new();
1415        }
1416        let only_lid = self.only_check_lid().await;
1417        let futs = jids.iter().map(|jid| async move {
1418            let mut opt = GroupParticipantOptions::new(jid.clone());
1419            if let Some(token_key) = self.resolve_token_key(jid, only_lid).await
1420                && let Some(token) = self.lookup_valid_token(&token_key).await
1421            {
1422                opt = opt.with_privacy(token);
1423            }
1424            opt
1425        });
1426        futures::future::join_all(futs).await
1427    }
1428
1429    /// Skips participants that already have a token set by the caller.
1430    async fn attach_tokens_to_participants(&self, participants: &mut [GroupParticipantOptions]) {
1431        if participants.is_empty() {
1432            return;
1433        }
1434        let only_lid = self.only_check_lid().await;
1435        let futs = participants.iter().enumerate().map(|(i, p)| async move {
1436            if p.privacy.is_some() {
1437                return (i, None);
1438            }
1439            let Some(token_key) = self.resolve_token_key(&p.jid, only_lid).await else {
1440                log::debug!(
1441                    target: "Client/Groups",
1442                    "No LID mapping for participant {}, skipping privacy attachment",
1443                    p.jid
1444                );
1445                return (i, None);
1446            };
1447            let token = self.lookup_valid_token(&token_key).await;
1448            if token.is_none() {
1449                log::debug!(
1450                    target: "Client/Groups",
1451                    "No valid tc_token for participant {} (key={}), skipping privacy attachment",
1452                    p.jid, token_key
1453                );
1454            }
1455            (i, token)
1456        });
1457        for (i, token) in futures::future::join_all(futs).await {
1458            if token.is_some() {
1459                participants[i].privacy = token;
1460            }
1461        }
1462    }
1463
1464    async fn only_check_lid(&self) -> bool {
1465        self.client
1466            .ab_props()
1467            .is_enabled(wacore::iq::props::stale::PRIVACY_TOKEN_ONLY_CHECK_LID)
1468            .await
1469    }
1470
1471    /// Resolve JID to tc_token store key. When `only_lid`, PN JIDs without a
1472    /// LID mapping return `None` instead of falling back to the PN user.
1473    async fn resolve_token_key(
1474        &self,
1475        jid: &Jid,
1476        only_lid: bool,
1477    ) -> Option<wacore_binary::CompactString> {
1478        if jid.is_lid() {
1479            Some(jid.user.clone())
1480        } else {
1481            let lid = self.client.lid_pn_cache.get_current_lid(&jid.user).await;
1482            if only_lid {
1483                lid
1484            } else {
1485                Some(lid.unwrap_or_else(|| jid.user.clone()))
1486            }
1487        }
1488    }
1489
1490    /// Returns the tc_token if present and not expired.
1491    async fn lookup_valid_token(&self, token_key: &str) -> Option<Vec<u8>> {
1492        use wacore::iq::tctoken::is_tc_token_expired_with;
1493        let tc_config = self.client.tc_token_config().await;
1494        let backend = self.client.persistence_manager.backend();
1495        match backend.get_tc_token(token_key).await {
1496            Ok(Some(entry))
1497                if !entry.token.is_empty()
1498                    && !is_tc_token_expired_with(entry.token_timestamp, &tc_config) =>
1499            {
1500                Some(entry.token)
1501            }
1502            Ok(_) => None,
1503            Err(e) => {
1504                log::warn!(
1505                    target: "Client/Groups",
1506                    "Failed to get tc_token for {}: {e}",
1507                    token_key
1508                );
1509                None
1510            }
1511        }
1512    }
1513}
1514
1515impl Client {
1516    pub fn groups(&self) -> Groups<'_> {
1517        Groups::new(self)
1518    }
1519
1520    pub(crate) async fn lock_group_metadata<'a>(&'a self, jid: &'a Jid) -> GroupMetadataGuard<'a> {
1521        GroupMetadataGuard {
1522            client: self,
1523            jid,
1524            _guard: self.group_distribution_lock(jid).await,
1525        }
1526    }
1527}
1528
1529/// Extract the invite code from any supported invite URL format.
1530///
1531/// Handles all WA Web patterns:
1532/// - `https://chat.whatsapp.com/CODE?query`
1533/// - `https://chat.whatsapp.com/invite/CODE?query`
1534/// - `https://web.whatsapp.com/.../accept/?code=CODE&...`
1535/// - `whatsapp://chat/?code=CODE`
1536/// - bare code string
1537fn extract_invite_code(input: &str) -> Option<&str> {
1538    let input = input.trim();
1539
1540    // whatsapp://chat/?code=CODE or web.whatsapp.com/.../accept/?code=CODE
1541    if let Some(code) = extract_code_param(input) {
1542        return Some(code);
1543    }
1544
1545    // https://chat.whatsapp.com/invite/CODE or https://chat.whatsapp.com/CODE
1546    let stripped = input
1547        .strip_prefix("https://chat.whatsapp.com/")
1548        .or_else(|| input.strip_prefix("http://chat.whatsapp.com/"));
1549
1550    let code = if let Some(path) = stripped {
1551        let path = path.strip_prefix("invite/").unwrap_or(path);
1552        path.split('?').next().unwrap_or(path).trim_end_matches('/')
1553    } else if input.contains("://") || input.contains('?') {
1554        // Looks like a URL we don't recognize or one with an empty code= param
1555        return None;
1556    } else {
1557        input.trim_end_matches('/')
1558    };
1559
1560    if code.is_empty() { None } else { Some(code) }
1561}
1562
1563fn extract_code_param(input: &str) -> Option<&str> {
1564    let query = input.split('?').nth(1)?;
1565    for pair in query.split('&') {
1566        if let Some(val) = pair.strip_prefix("code=") {
1567            let val = val.trim_end_matches('/');
1568            if !val.is_empty() {
1569                return Some(val);
1570            }
1571        }
1572    }
1573    None
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578    use super::*;
1579
1580    #[test]
1581    fn test_group_metadata_struct() {
1582        let jid: Jid = "123456789@g.us"
1583            .parse()
1584            .expect("test group JID should be valid");
1585        let participant_jid: Jid = "1234567890@s.whatsapp.net"
1586            .parse()
1587            .expect("test participant JID should be valid");
1588
1589        let metadata = GroupMetadata {
1590            id: jid.clone(),
1591            subject: "Test Group".to_string(),
1592            participants: vec![GroupParticipant {
1593                jid: participant_jid,
1594                phone_number: None,
1595                lid: None,
1596                username: None,
1597                participant_type: ParticipantType::Admin,
1598                details: None,
1599            }],
1600            ..Default::default()
1601        };
1602
1603        assert_eq!(metadata.subject, "Test Group");
1604        assert_eq!(metadata.participants.len(), 1);
1605        assert!(metadata.participants[0].is_admin());
1606        assert!(!metadata.participants[0].is_super_admin());
1607    }
1608
1609    #[tokio::test]
1610    async fn fill_participant_pns_backfills_from_cache() {
1611        use crate::lid_pn_cache::{LearningSource, LidPnEntry};
1612        use wacore_binary::jid::{Jid, Server};
1613
1614        let client = crate::test_utils::create_test_client().await;
1615        // Warm the LID-PN cache with a mapping the server didn't echo on the
1616        // participant stanza.
1617        let entry = LidPnEntry::new(
1618            "26263000000099".to_string(),
1619            "5521900000099".to_string(),
1620            LearningSource::Usync,
1621        );
1622        client.lid_pn_cache.add(&entry).await;
1623
1624        let mut meta = GroupMetadata {
1625            id: "120399@g.us".parse().unwrap(),
1626            participants: vec![GroupParticipant {
1627                jid: Jid::new("26263000000099", Server::Lid),
1628                phone_number: None,
1629                lid: None,
1630                username: None,
1631                participant_type: ParticipantType::Member,
1632                details: None,
1633            }],
1634            addressing_mode: AddressingMode::Lid,
1635            ..Default::default()
1636        };
1637        client.groups().fill_participant_pns(&mut meta).await;
1638        assert_eq!(
1639            meta.participants[0].phone_number,
1640            Some(Jid::pn("5521900000099")),
1641            "LID participant should receive its PN from the warm cache"
1642        );
1643    }
1644
1645    #[tokio::test]
1646    async fn fill_participant_pns_noop_in_pn_group() {
1647        use wacore_binary::jid::{Jid, Server};
1648
1649        let client = crate::test_utils::create_test_client().await;
1650        // PN-addressed group: untouched (jid already is the PN).
1651        let mut meta = GroupMetadata {
1652            id: "120398@g.us".parse().unwrap(),
1653            participants: vec![GroupParticipant {
1654                jid: Jid::new("5521900000098", Server::Pn),
1655                phone_number: None,
1656                lid: None,
1657                username: None,
1658                participant_type: ParticipantType::Member,
1659                details: None,
1660            }],
1661            addressing_mode: AddressingMode::Pn,
1662            ..Default::default()
1663        };
1664        client.groups().fill_participant_pns(&mut meta).await;
1665        assert_eq!(meta.participants[0].phone_number, None);
1666    }
1667
1668    #[test]
1669    fn test_extract_invite_code() {
1670        // Pattern 3: most common
1671        assert_eq!(
1672            extract_invite_code("https://chat.whatsapp.com/AbCdEfGh").unwrap(),
1673            "AbCdEfGh"
1674        );
1675        assert_eq!(
1676            extract_invite_code("http://chat.whatsapp.com/AbCdEfGh").unwrap(),
1677            "AbCdEfGh"
1678        );
1679
1680        // With query params
1681        assert_eq!(
1682            extract_invite_code("https://chat.whatsapp.com/AbCdEfGh?fbclid=123&utm_source=x")
1683                .unwrap(),
1684            "AbCdEfGh"
1685        );
1686
1687        // Trailing slash
1688        assert_eq!(
1689            extract_invite_code("https://chat.whatsapp.com/AbCdEfGh/").unwrap(),
1690            "AbCdEfGh"
1691        );
1692
1693        // Pattern 2: /invite/ prefix
1694        assert_eq!(
1695            extract_invite_code("https://chat.whatsapp.com/invite/AbCdEfGh").unwrap(),
1696            "AbCdEfGh"
1697        );
1698        assert_eq!(
1699            extract_invite_code("https://chat.whatsapp.com/invite/AbCdEfGh?utm=test").unwrap(),
1700            "AbCdEfGh"
1701        );
1702
1703        // Pattern 1: web.whatsapp.com/accept?code=
1704        assert_eq!(
1705            extract_invite_code("https://web.whatsapp.com/accept?code=AbCdEfGh").unwrap(),
1706            "AbCdEfGh"
1707        );
1708        assert_eq!(
1709            extract_invite_code("https://web.whatsapp.com/accept/?code=AbCdEfGh&other=1").unwrap(),
1710            "AbCdEfGh"
1711        );
1712
1713        // Pattern 4: deep link
1714        assert_eq!(
1715            extract_invite_code("whatsapp://chat/?code=AbCdEfGh").unwrap(),
1716            "AbCdEfGh"
1717        );
1718        assert_eq!(
1719            extract_invite_code("whatsapp://chat?code=AbCdEfGh&extra=y").unwrap(),
1720            "AbCdEfGh"
1721        );
1722
1723        // Bare code
1724        assert_eq!(extract_invite_code("AbCdEfGh").unwrap(), "AbCdEfGh");
1725        assert_eq!(extract_invite_code("AbCdEfGh/").unwrap(), "AbCdEfGh");
1726
1727        // Whitespace
1728        assert_eq!(extract_invite_code("  AbCdEfGh  ").unwrap(), "AbCdEfGh");
1729
1730        // Empty / malformed inputs return None
1731        assert!(extract_invite_code("").is_none());
1732        assert!(extract_invite_code("   ").is_none());
1733        assert!(extract_invite_code("https://chat.whatsapp.com/").is_none());
1734        assert!(extract_invite_code("https://chat.whatsapp.com/invite/").is_none());
1735        assert!(extract_invite_code("whatsapp://chat/?code=").is_none());
1736        assert!(extract_invite_code("whatsapp://chat/?code=&other=1").is_none());
1737    }
1738
1739    #[tokio::test]
1740    async fn warm_group_cache_hit_shares_arc_not_deep_clone() {
1741        use wacore::client::context::GroupInfo;
1742        use wacore::types::message::AddressingMode;
1743
1744        let client = crate::test_utils::create_test_client().await;
1745        let group_jid: Jid = "123456789@g.us".parse().unwrap();
1746
1747        let info = GroupInfo::new(
1748            vec![
1749                "111111111111@s.whatsapp.net".parse().unwrap(),
1750                "222222222222@s.whatsapp.net".parse().unwrap(),
1751            ],
1752            AddressingMode::Pn,
1753        );
1754        let cache = client.get_group_cache().await;
1755        cache.insert(group_jid.clone(), Arc::new(info)).await;
1756
1757        let a = cache.get(&group_jid).await.expect("warm hit");
1758        let b = cache.get(&group_jid).await.expect("warm hit");
1759
1760        // A warm group-cache hit returns a refcount bump of the same allocation,
1761        // not a deep copy of the participant list and LID/PN maps.
1762        assert!(Arc::ptr_eq(&a, &b));
1763        assert_eq!(a.participants.len(), 2);
1764    }
1765
1766    #[tokio::test]
1767    async fn refresh_keeps_the_previous_group_snapshot_on_source_failure() {
1768        let client = crate::test_utils::create_test_client().await;
1769        let group: Jid = "120363000000000099@g.us".parse().unwrap();
1770        let previous = Arc::new(GroupInfo::new(
1771            vec!["12025550101@s.whatsapp.net".parse().unwrap()],
1772            AddressingMode::Pn,
1773        ));
1774        let cache = client.get_group_cache().await;
1775        cache.insert(group.clone(), Arc::clone(&previous)).await;
1776
1777        let result = client
1778            .groups()
1779            .query_info_with_freshness(&group, crate::cache::Freshness::Refresh)
1780            .await;
1781        assert!(
1782            result.is_err(),
1783            "the offline fixture proves refresh consulted the source"
1784        );
1785
1786        let preserved = cache
1787            .get(&group)
1788            .await
1789            .expect("refresh failure must not clear the current snapshot");
1790        assert!(Arc::ptr_eq(&previous, &preserved));
1791    }
1792
1793    #[tokio::test]
1794    async fn linked_removal_preserves_unrelated_group_cache_entries() {
1795        use wacore::protocol::ProtocolNode;
1796        use wacore_binary::builder::NodeBuilder;
1797
1798        let client = crate::test_utils::create_test_client().await;
1799        let parent: Jid = "120363000000000001@g.us".parse().unwrap();
1800        let unrelated: Jid = "120363000000000002@g.us".parse().unwrap();
1801        let removed: Jid = "12025550103@s.whatsapp.net".parse().unwrap();
1802        let cache = client.get_group_cache().await;
1803        for jid in [&parent, &unrelated] {
1804            cache
1805                .insert(
1806                    jid.clone(),
1807                    Arc::new(GroupInfo::new(vec![removed.clone()], AddressingMode::Pn)),
1808                )
1809                .await;
1810        }
1811        let response = ParticipantChangeResponse::try_from_node(
1812            &NodeBuilder::new("participant")
1813                .attr("jid", &removed)
1814                .build(),
1815        )
1816        .expect("participant response should parse");
1817
1818        client
1819            .groups()
1820            .apply_participant_removals(&parent, &[response], ParticipantRemovalScope::LinkedGroups)
1821            .await;
1822
1823        assert!(cache.get(&parent).await.is_none());
1824        assert!(cache.get(&unrelated).await.is_some());
1825    }
1826
1827    #[tokio::test]
1828    async fn invalidate_persisted_group_metadata_drops_blob() {
1829        // The cache-miss branch of add/remove/leave relies on this to drop a now-stale
1830        // persisted blob so the next query re-fetches fresh instead of sending a stale phash.
1831        let client = crate::test_utils::create_test_client().await;
1832        let backend = client.persistence_manager.backend();
1833        let group_jid: Jid = "123456789@g.us".parse().unwrap();
1834
1835        backend
1836            .put_group_metadata(&group_jid.to_string(), b"stale-blob")
1837            .await
1838            .unwrap();
1839        assert!(
1840            backend
1841                .get_group_metadata(&group_jid.to_string())
1842                .await
1843                .unwrap()
1844                .is_some()
1845        );
1846
1847        client
1848            .lock_group_metadata(&group_jid)
1849            .await
1850            .invalidate()
1851            .await;
1852
1853        assert!(
1854            backend
1855                .get_group_metadata(&group_jid.to_string())
1856                .await
1857                .unwrap()
1858                .is_none(),
1859            "invalidation must delete the persisted blob"
1860        );
1861    }
1862
1863    // Protocol-level tests (node building, parsing, validation) are in wacore/src/iq/groups.rs
1864
1865    #[test]
1866    fn group_property_update_serializes_to_wire() {
1867        assert_eq!(
1868            serde_json::to_value(UpdateGroupPropertyVars {
1869                group_id: "123@g.us".to_string(),
1870                update: GroupPropertyUpdate::MemberLinkMode("ADMIN_LINK"),
1871            })
1872            .unwrap(),
1873            serde_json::json!({
1874                "group_id": "123@g.us",
1875                "update": { "member_link_mode": "ADMIN_LINK" }
1876            })
1877        );
1878        assert_eq!(
1879            serde_json::to_value(GroupPropertyUpdate::MemberShareGroupHistoryMode(
1880                "ALL_MEMBER_SHARE"
1881            ))
1882            .unwrap(),
1883            serde_json::json!({ "member_share_group_history_mode": "ALL_MEMBER_SHARE" })
1884        );
1885        assert_eq!(
1886            serde_json::to_value(GroupPropertyUpdate::LimitSharing(LimitSharingUpdate {
1887                limit_sharing_enabled: true,
1888                limit_sharing_trigger: "CHAT_SETTING",
1889            }))
1890            .unwrap(),
1891            serde_json::json!({
1892                "limit_sharing": {
1893                    "limit_sharing_enabled": true,
1894                    "limit_sharing_trigger": "CHAT_SETTING"
1895                }
1896            })
1897        );
1898    }
1899
1900    /// Fictitious group used by the description round-trip tests.
1901    fn description_test_group() -> Jid {
1902        "120363000000000001@g.us"
1903            .parse()
1904            .expect("test group JID should be valid")
1905    }
1906
1907    /// `<iq type="result">` carrying a `<group>` with (optionally) a current
1908    /// description, i.e. what the server answers a metadata query with.
1909    fn group_result_with_description(
1910        request_id: &str,
1911        group: &Jid,
1912        description_id: Option<&str>,
1913    ) -> wacore_binary::Node {
1914        use wacore_binary::builder::NodeBuilder;
1915
1916        let mut group_node = NodeBuilder::new("group")
1917            .attr("id", group.to_string())
1918            .attr("subject", "Test Group");
1919        if let Some(description_id) = description_id {
1920            group_node = group_node.children([NodeBuilder::new("description")
1921                .attr("id", description_id)
1922                .children([NodeBuilder::new("body")
1923                    .string_content("current description")
1924                    .build()])
1925                .build()]);
1926        }
1927
1928        NodeBuilder::new("iq")
1929            .attr("type", "result")
1930            .attr("id", request_id)
1931            .attr("from", group)
1932            .children([group_node.build()])
1933            .build()
1934    }
1935
1936    fn iq_error(request_id: &str, group: &Jid, code: &str, text: &str) -> wacore_binary::Node {
1937        use wacore_binary::builder::NodeBuilder;
1938
1939        NodeBuilder::new("iq")
1940            .attr("type", "error")
1941            .attr("id", request_id)
1942            .attr("from", group)
1943            .children([NodeBuilder::new("error")
1944                .attr("code", code)
1945                .attr("text", text)
1946                .build()])
1947            .build()
1948    }
1949
1950    fn iq_result(request_id: &str, group: &Jid) -> wacore_binary::Node {
1951        use wacore_binary::builder::NodeBuilder;
1952
1953        NodeBuilder::new("iq")
1954            .attr("type", "result")
1955            .attr("id", request_id)
1956            .attr("from", group)
1957            .build()
1958    }
1959
1960    /// A group that already has a description can only be updated by naming the
1961    /// id it replaces, so the update must carry both a fresh `id` and the
1962    /// current one as `prev`.
1963    #[tokio::test]
1964    async fn set_description_sends_the_current_description_id_as_prev() {
1965        let (client, transport) = crate::test_utils::create_iq_test_client().await;
1966        let group = description_test_group();
1967
1968        let update = {
1969            let client = Arc::clone(&client);
1970            let group = group.clone();
1971            tokio::spawn(async move {
1972                client
1973                    .groups()
1974                    .set_description(
1975                        group,
1976                        Some(GroupDescription::new("new description").unwrap()),
1977                        PreviousDescription::Resolve,
1978                    )
1979                    .await
1980            })
1981        };
1982
1983        let query = crate::test_utils::decode_sent_iq(&transport, 0).await;
1984        let query = query.get();
1985        assert_eq!(query.tag.as_ref(), "iq");
1986        assert_eq!(
1987            query.attrs().optional_string("type").as_deref(),
1988            Some("get")
1989        );
1990        assert!(
1991            query.get_optional_child("query").is_some(),
1992            "the resolution step must be a group metadata query"
1993        );
1994        let query_id = query
1995            .attrs()
1996            .optional_string("id")
1997            .expect("query carries an id")
1998            .into_owned();
1999        crate::test_utils::answer_iq(
2000            &client,
2001            &query_id,
2002            &group_result_with_description(&query_id, &group, Some("D1D2D3D4")),
2003        )
2004        .await;
2005
2006        let set = crate::test_utils::decode_sent_iq(&transport, 1).await;
2007        let set = set.get();
2008        assert_eq!(set.attrs().optional_string("type").as_deref(), Some("set"));
2009        let description = set
2010            .get_optional_child("description")
2011            .expect("the update carries a <description>");
2012        let id = description
2013            .attrs()
2014            .optional_string("id")
2015            .expect("a new id is minted");
2016        assert_eq!(id.len(), 8);
2017        assert_ne!(id, "D1D2D3D4", "the new id must not reuse prev");
2018        assert_eq!(
2019            description.attrs().optional_string("prev").as_deref(),
2020            Some("D1D2D3D4"),
2021            "the update must name the description it replaces"
2022        );
2023        let body = description
2024            .get_optional_child("body")
2025            .expect("a set carries a <body>");
2026        assert_eq!(body.content_as_string().as_deref(), Some("new description"));
2027
2028        let set_id = set
2029            .attrs()
2030            .optional_string("id")
2031            .expect("the update carries an id")
2032            .into_owned();
2033        crate::test_utils::answer_iq(&client, &set_id, &iq_result(&set_id, &group)).await;
2034        update
2035            .await
2036            .expect("the update task should not panic")
2037            .expect("the update should succeed");
2038    }
2039
2040    /// Deleting a description is the same optimistic-concurrency check, so the
2041    /// `delete` marker travels with the token it replaces.
2042    #[tokio::test]
2043    async fn delete_description_sends_prev_alongside_the_delete_marker() {
2044        let (client, transport) = crate::test_utils::create_iq_test_client().await;
2045        let group = description_test_group();
2046
2047        let update = {
2048            let client = Arc::clone(&client);
2049            let group = group.clone();
2050            tokio::spawn(async move {
2051                client
2052                    .groups()
2053                    .set_description(group, None, PreviousDescription::Resolve)
2054                    .await
2055            })
2056        };
2057
2058        let query = crate::test_utils::decode_sent_iq(&transport, 0).await;
2059        let query_id = query
2060            .get()
2061            .attrs()
2062            .optional_string("id")
2063            .expect("query carries an id")
2064            .into_owned();
2065        crate::test_utils::answer_iq(
2066            &client,
2067            &query_id,
2068            &group_result_with_description(&query_id, &group, Some("AABBCCDD")),
2069        )
2070        .await;
2071
2072        let set = crate::test_utils::decode_sent_iq(&transport, 1).await;
2073        let set = set.get();
2074        let description = set
2075            .get_optional_child("description")
2076            .expect("the delete carries a <description>");
2077        assert_eq!(
2078            description.attrs().optional_string("delete").as_deref(),
2079            Some("true")
2080        );
2081        assert_eq!(
2082            description.attrs().optional_string("prev").as_deref(),
2083            Some("AABBCCDD")
2084        );
2085        assert!(
2086            description.get_optional_child("body").is_none(),
2087            "a delete carries no body"
2088        );
2089
2090        let set_id = set
2091            .attrs()
2092            .optional_string("id")
2093            .expect("the delete carries an id")
2094            .into_owned();
2095        crate::test_utils::answer_iq(&client, &set_id, &iq_result(&set_id, &group)).await;
2096        update
2097            .await
2098            .expect("the delete task should not panic")
2099            .expect("the delete should succeed");
2100    }
2101
2102    /// The path that already worked: a group with no description expects no
2103    /// token, and sending one would be rejected.
2104    #[tokio::test]
2105    async fn set_description_on_a_group_without_one_omits_prev() {
2106        let (client, transport) = crate::test_utils::create_iq_test_client().await;
2107        let group = description_test_group();
2108
2109        let update = {
2110            let client = Arc::clone(&client);
2111            let group = group.clone();
2112            tokio::spawn(async move {
2113                client
2114                    .groups()
2115                    .set_description(
2116                        group,
2117                        Some(GroupDescription::new("first description").unwrap()),
2118                        PreviousDescription::Resolve,
2119                    )
2120                    .await
2121            })
2122        };
2123
2124        let query = crate::test_utils::decode_sent_iq(&transport, 0).await;
2125        let query_id = query
2126            .get()
2127            .attrs()
2128            .optional_string("id")
2129            .expect("query carries an id")
2130            .into_owned();
2131        crate::test_utils::answer_iq(
2132            &client,
2133            &query_id,
2134            &group_result_with_description(&query_id, &group, None),
2135        )
2136        .await;
2137
2138        let set = crate::test_utils::decode_sent_iq(&transport, 1).await;
2139        let set = set.get();
2140        let description = set
2141            .get_optional_child("description")
2142            .expect("the update carries a <description>");
2143        assert!(
2144            description.attrs().optional_string("prev").is_none(),
2145            "a group with no description must not carry a prev token"
2146        );
2147        assert!(description.attrs().optional_string("id").is_some());
2148
2149        let set_id = set
2150            .attrs()
2151            .optional_string("id")
2152            .expect("the update carries an id")
2153            .into_owned();
2154        crate::test_utils::answer_iq(&client, &set_id, &iq_result(&set_id, &group)).await;
2155        update
2156            .await
2157            .expect("the update task should not panic")
2158            .expect("the update should succeed");
2159    }
2160
2161    /// A caller that already holds the id (the community create path holds
2162    /// "none") skips the resolution query entirely.
2163    #[tokio::test]
2164    async fn set_description_with_a_known_token_skips_the_resolution_query() {
2165        let (client, transport) = crate::test_utils::create_iq_test_client().await;
2166        let group = description_test_group();
2167
2168        let update = {
2169            let client = Arc::clone(&client);
2170            let group = group.clone();
2171            tokio::spawn(async move {
2172                client
2173                    .groups()
2174                    .set_description(
2175                        group,
2176                        Some(GroupDescription::new("known token").unwrap()),
2177                        PreviousDescription::Id("KNOWNID1"),
2178                    )
2179                    .await
2180            })
2181        };
2182
2183        let set = crate::test_utils::decode_sent_iq(&transport, 0).await;
2184        let set = set.get();
2185        assert_eq!(
2186            set.attrs().optional_string("type").as_deref(),
2187            Some("set"),
2188            "the first stanza must be the update itself, not a query"
2189        );
2190        let description = set
2191            .get_optional_child("description")
2192            .expect("the update carries a <description>");
2193        assert_eq!(
2194            description.attrs().optional_string("prev").as_deref(),
2195            Some("KNOWNID1")
2196        );
2197
2198        let set_id = set
2199            .attrs()
2200            .optional_string("id")
2201            .expect("the update carries an id")
2202            .into_owned();
2203        crate::test_utils::answer_iq(&client, &set_id, &iq_result(&set_id, &group)).await;
2204        update
2205            .await
2206            .expect("the update task should not panic")
2207            .expect("the update should succeed");
2208        assert_eq!(transport.sent_count(), 1);
2209    }
2210
2211    /// If the resolution query fails there is no token to send, and guessing
2212    /// one (or omitting it) would either be rejected or silently overwrite a
2213    /// description the caller never read.
2214    #[tokio::test]
2215    async fn a_failed_resolution_sends_no_update() {
2216        let (client, transport) = crate::test_utils::create_iq_test_client().await;
2217        let group = description_test_group();
2218
2219        let update = {
2220            let client = Arc::clone(&client);
2221            let group = group.clone();
2222            tokio::spawn(async move {
2223                client
2224                    .groups()
2225                    .set_description(
2226                        group,
2227                        Some(GroupDescription::new("new description").unwrap()),
2228                        PreviousDescription::Resolve,
2229                    )
2230                    .await
2231            })
2232        };
2233
2234        let query = crate::test_utils::decode_sent_iq(&transport, 0).await;
2235        let query_id = query
2236            .get()
2237            .attrs()
2238            .optional_string("id")
2239            .expect("query carries an id")
2240            .into_owned();
2241        crate::test_utils::answer_iq(
2242            &client,
2243            &query_id,
2244            &iq_error(&query_id, &group, "403", "forbidden"),
2245        )
2246        .await;
2247
2248        let error = update
2249            .await
2250            .expect("the update task should not panic")
2251            .expect_err("a failed resolution must fail the update");
2252        assert!(
2253            matches!(
2254                error,
2255                GroupError::Iq(IqError::ServerError { code: 403, .. })
2256            ),
2257            "the resolution failure must surface as-is, got {error:?}"
2258        );
2259        assert_eq!(
2260            transport.sent_count(),
2261            1,
2262            "no update may be sent once resolution failed"
2263        );
2264    }
2265
2266    /// Another device changing the description between the read and the update
2267    /// is exactly what the token guards against; the server's `409 conflict`
2268    /// must stay distinguishable from a permission refusal.
2269    #[tokio::test]
2270    async fn a_concurrent_change_surfaces_as_a_description_conflict() {
2271        let (client, transport) = crate::test_utils::create_iq_test_client().await;
2272        let group = description_test_group();
2273
2274        let update = {
2275            let client = Arc::clone(&client);
2276            let group = group.clone();
2277            tokio::spawn(async move {
2278                client
2279                    .groups()
2280                    .set_description(
2281                        group,
2282                        Some(GroupDescription::new("new description").unwrap()),
2283                        PreviousDescription::Resolve,
2284                    )
2285                    .await
2286            })
2287        };
2288
2289        let query = crate::test_utils::decode_sent_iq(&transport, 0).await;
2290        let query_id = query
2291            .get()
2292            .attrs()
2293            .optional_string("id")
2294            .expect("query carries an id")
2295            .into_owned();
2296        crate::test_utils::answer_iq(
2297            &client,
2298            &query_id,
2299            &group_result_with_description(&query_id, &group, Some("STALE001")),
2300        )
2301        .await;
2302
2303        let set = crate::test_utils::decode_sent_iq(&transport, 1).await;
2304        let set_id = set
2305            .get()
2306            .attrs()
2307            .optional_string("id")
2308            .expect("the update carries an id")
2309            .into_owned();
2310        crate::test_utils::answer_iq(
2311            &client,
2312            &set_id,
2313            &iq_error(&set_id, &group, "409", "conflict"),
2314        )
2315        .await;
2316
2317        let error = update
2318            .await
2319            .expect("the update task should not panic")
2320            .expect_err("a conflict must fail the update");
2321        assert!(
2322            matches!(error, GroupError::DescriptionConflict),
2323            "a 409 on a description update is a conflict, got {error:?}"
2324        );
2325    }
2326
2327    /// A refusal that is not a conflict keeps its server code, so a caller can
2328    /// still tell "no permission" from "someone else changed it".
2329    #[tokio::test]
2330    async fn a_forbidden_update_is_not_reported_as_a_conflict() {
2331        let (client, transport) = crate::test_utils::create_iq_test_client().await;
2332        let group = description_test_group();
2333
2334        let update = {
2335            let client = Arc::clone(&client);
2336            let group = group.clone();
2337            tokio::spawn(async move {
2338                client
2339                    .groups()
2340                    .set_description(
2341                        group,
2342                        Some(GroupDescription::new("new description").unwrap()),
2343                        PreviousDescription::Id("KNOWNID1"),
2344                    )
2345                    .await
2346            })
2347        };
2348
2349        let set = crate::test_utils::decode_sent_iq(&transport, 0).await;
2350        let set_id = set
2351            .get()
2352            .attrs()
2353            .optional_string("id")
2354            .expect("the update carries an id")
2355            .into_owned();
2356        crate::test_utils::answer_iq(
2357            &client,
2358            &set_id,
2359            &iq_error(&set_id, &group, "403", "forbidden"),
2360        )
2361        .await;
2362
2363        let error = update
2364            .await
2365            .expect("the update task should not panic")
2366            .expect_err("a forbidden update must fail");
2367        assert!(
2368            matches!(
2369                error,
2370                GroupError::Iq(IqError::ServerError { code: 403, .. })
2371            ),
2372            "a non-conflict refusal must keep its code, got {error:?}"
2373        );
2374    }
2375
2376    #[test]
2377    fn previous_description_from_optional_id() {
2378        assert_eq!(
2379            PreviousDescription::from(Some("ABCD1234")),
2380            PreviousDescription::Id("ABCD1234")
2381        );
2382        assert_eq!(
2383            PreviousDescription::from(None),
2384            PreviousDescription::Absent,
2385            "a group with no description resolves to no token, not to a query"
2386        );
2387    }
2388}