Skip to main content

whatsapp_rust/features/
groups.rs

1use crate::client::Client;
2use crate::features::mex::{MexError, MexRequest};
3use std::collections::HashMap;
4use wacore::client::context::GroupInfo;
5use wacore::iq::groups::{
6    AcceptGroupInviteIq, AcceptGroupInviteV4Iq, AcknowledgeGroupIq, AddParticipantsIq,
7    BatchGetGroupInfoIq, CancelMembershipRequestsIq, DemoteParticipantsIq, GetGroupInviteInfoIq,
8    GetGroupInviteLinkIq, GetGroupProfilePicturesIq, GetMembershipRequestsIq, GroupCreateIq,
9    GroupInfoResponse, GroupParticipantResponse, GroupParticipatingIq, GroupQueryIq, LeaveGroupIq,
10    MembershipRequestActionIq, PromoteParticipantsIq, RemoveParticipantsIq, RevokeRequestCodeIq,
11    SetAllowAdminReportsIq, SetGroupAnnouncementIq, SetGroupDescriptionIq, SetGroupEphemeralIq,
12    SetGroupHistoryIq, SetGroupLockedIq, SetGroupMembershipApprovalIq, SetGroupSubjectIq,
13    SetMemberAddModeIq, SetNoFrequentlyForwardedIq, normalize_participants,
14};
15use wacore::types::message::AddressingMode;
16use wacore_binary::{Jid, JidExt as _};
17
18use wacore::iq::groups::BatchGroupInfoResult as RawBatchResult;
19pub use wacore::iq::groups::{
20    GroupCreateOptions, GroupDescription, GroupJoinError, GroupParticipantOptions,
21    GroupProfilePicture, GroupSubject, GrowthLockInfo, InviteInfoError, JoinGroupResult,
22    MemberAddMode, MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode,
23    MembershipRequest, ParticipantChangeResponse, ParticipantType, PictureType,
24};
25
26/// Result for a single group in a batch query.
27#[derive(Debug, Clone)]
28pub enum BatchGroupResult {
29    Full(Box<GroupMetadata>),
30    /// Server returned truncated info (only id and size).
31    Truncated {
32        id: Jid,
33        size: Option<u32>,
34    },
35    Forbidden(Jid),
36    NotFound(Jid),
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct GroupMetadata {
41    pub id: Jid,
42    pub subject: String,
43    pub participants: Vec<GroupParticipant>,
44    pub addressing_mode: AddressingMode,
45    /// Group creator JID.
46    pub creator: Option<Jid>,
47    /// Group creation timestamp (Unix seconds).
48    pub creation_time: Option<u64>,
49    /// Subject modification timestamp (Unix seconds).
50    pub subject_time: Option<u64>,
51    /// Subject owner JID.
52    pub subject_owner: Option<Jid>,
53    /// Group description body text.
54    pub description: Option<String>,
55    /// Description ID (for conflict detection when updating).
56    pub description_id: Option<String>,
57    /// JID of the participant who set the description.
58    pub description_owner: Option<Jid>,
59    /// Timestamp when the description was set.
60    pub description_time: Option<u64>,
61    /// Whether the group is locked (only admins can edit group info).
62    pub is_locked: bool,
63    /// Whether announcement mode is enabled (only admins can send messages).
64    pub is_announcement: bool,
65    /// Ephemeral message expiration in seconds (0 = disabled).
66    pub ephemeral_expiration: u32,
67    /// Disappearing mode trigger (from `trigger` attribute on `<ephemeral>`).
68    pub ephemeral_trigger: Option<u32>,
69    /// Whether membership approval is required to join.
70    pub membership_approval: bool,
71    /// Who can add members to the group.
72    pub member_add_mode: Option<MemberAddMode>,
73    /// Who can use invite links.
74    pub member_link_mode: Option<MemberLinkMode>,
75    /// Total participant count.
76    pub size: Option<u32>,
77    /// Whether this group is a community parent group.
78    pub is_parent_group: bool,
79    /// JID of the parent community (for subgroups).
80    pub parent_group_jid: Option<Jid>,
81    /// Whether this is the default announcement subgroup of a community.
82    pub is_default_sub_group: bool,
83    /// Whether this is the general chat subgroup of a community.
84    pub is_general_chat: bool,
85    /// Whether non-admin community members can create subgroups.
86    pub allow_non_admin_sub_group_creation: bool,
87    /// Whether frequently-forwarded messages are restricted.
88    pub no_frequently_forwarded: bool,
89    /// Who can share message history with new members.
90    pub member_share_history_mode: Option<MemberShareHistoryMode>,
91    /// Growth lock status (invite links temporarily disabled).
92    pub growth_locked: Option<GrowthLockInfo>,
93    /// Whether the group is suspended.
94    pub is_suspended: bool,
95    /// Whether admin reports are allowed.
96    pub allow_admin_reports: bool,
97    /// Whether the group is hidden.
98    pub is_hidden_group: bool,
99    /// Whether incognito mode is enabled.
100    pub is_incognito: bool,
101    /// Whether group history is enabled.
102    pub has_group_history: bool,
103    /// Whether limit sharing is enabled.
104    pub is_limit_sharing_enabled: bool,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct GroupParticipant {
109    pub jid: Jid,
110    pub phone_number: Option<Jid>,
111    pub participant_type: ParticipantType,
112}
113
114impl GroupParticipant {
115    pub fn is_admin(&self) -> bool {
116        self.participant_type.is_admin()
117    }
118
119    pub fn is_super_admin(&self) -> bool {
120        self.participant_type == ParticipantType::SuperAdmin
121    }
122}
123
124impl From<GroupParticipantResponse> for GroupParticipant {
125    fn from(p: GroupParticipantResponse) -> Self {
126        Self {
127            jid: p.jid,
128            phone_number: p.phone_number,
129            participant_type: p.participant_type,
130        }
131    }
132}
133
134impl From<GroupInfoResponse> for GroupMetadata {
135    fn from(group: GroupInfoResponse) -> Self {
136        Self {
137            id: group.id,
138            subject: group.subject.into_string(),
139            participants: group.participants.into_iter().map(Into::into).collect(),
140            addressing_mode: group.addressing_mode,
141            creator: group.creator,
142            creation_time: group.creation_time,
143            subject_time: group.subject_time,
144            subject_owner: group.subject_owner,
145            description: group.description,
146            description_id: group.description_id,
147            description_owner: group.description_owner,
148            description_time: group.description_time,
149            is_locked: group.is_locked,
150            is_announcement: group.is_announcement,
151            ephemeral_expiration: group.ephemeral_expiration,
152            ephemeral_trigger: group.ephemeral_trigger,
153            membership_approval: group.membership_approval,
154            member_add_mode: group.member_add_mode,
155            member_link_mode: group.member_link_mode,
156            size: group.size,
157            is_parent_group: group.is_parent_group,
158            parent_group_jid: group.parent_group_jid,
159            is_default_sub_group: group.is_default_sub_group,
160            is_general_chat: group.is_general_chat,
161            allow_non_admin_sub_group_creation: group.allow_non_admin_sub_group_creation,
162            no_frequently_forwarded: group.no_frequently_forwarded,
163            member_share_history_mode: group.member_share_history_mode,
164            growth_locked: group.growth_locked,
165            is_suspended: group.is_suspended,
166            allow_admin_reports: group.allow_admin_reports,
167            is_hidden_group: group.is_hidden_group,
168            is_incognito: group.is_incognito,
169            has_group_history: group.has_group_history,
170            is_limit_sharing_enabled: group.is_limit_sharing_enabled,
171        }
172    }
173}
174
175#[derive(Debug, Clone)]
176pub struct CreateGroupResult {
177    pub metadata: GroupMetadata,
178}
179
180pub struct Groups<'a> {
181    client: &'a Client,
182}
183
184impl<'a> Groups<'a> {
185    pub(crate) fn new(client: &'a Client) -> Self {
186        Self { client }
187    }
188
189    pub async fn query_info(&self, jid: &Jid) -> Result<GroupInfo, anyhow::Error> {
190        if let Some(cached) = self.client.get_group_cache().await.get(jid).await {
191            return Ok(cached);
192        }
193
194        let group = self.client.execute(GroupQueryIq::new(jid)).await?;
195
196        // Single pass: move participants out and build lid_to_pn_map alongside.
197        let n = group.participants.len();
198        let is_lid = group.addressing_mode == AddressingMode::Lid;
199        let mut participants: Vec<Jid> = Vec::with_capacity(n);
200        let mut lid_to_pn_map: HashMap<wacore_binary::CompactString, Jid> = if is_lid {
201            HashMap::with_capacity(n)
202        } else {
203            HashMap::new()
204        };
205        for p in group.participants {
206            if is_lid && let Some(pn) = p.phone_number {
207                lid_to_pn_map.insert(p.jid.user.clone(), pn);
208            }
209            participants.push(p.jid);
210        }
211
212        // Populate lid_pn_cache so silent-observer participants (no messages
213        // from them) get their mapping; otherwise `invalidate_device_cache`
214        // can't resolve the PN alias and leaves zombie registry entries.
215        // One batched call mirrors WA Web's single `createLidPnMappings`
216        // invocation from `QueryGroupJob`, so N participants = 1 persist
217        // task + 1 DB transaction instead of N detached tasks.
218        if !lid_to_pn_map.is_empty()
219            && let Some(client_arc) = self.client.self_weak.get().and_then(|w| w.upgrade())
220        {
221            let mut batch: Vec<(String, String)> = Vec::with_capacity(lid_to_pn_map.len());
222            for (lid_user, pn_jid) in &lid_to_pn_map {
223                if pn_jid.is_pn() {
224                    batch.push((lid_user.as_str().to_string(), pn_jid.user.to_string()));
225                }
226            }
227            client_arc
228                .learn_lid_pn_mappings_batch(
229                    batch,
230                    crate::lid_pn_cache::LearningSource::Other,
231                    false,
232                )
233                .await;
234        }
235
236        let mut info = GroupInfo::new(participants, group.addressing_mode);
237        if !lid_to_pn_map.is_empty() {
238            info.set_lid_to_pn_map(lid_to_pn_map);
239        }
240
241        self.client
242            .get_group_cache()
243            .await
244            .insert(jid.clone(), info.clone())
245            .await;
246
247        Ok(info)
248    }
249
250    pub async fn get_participating(&self) -> Result<HashMap<String, GroupMetadata>, anyhow::Error> {
251        let response = self.client.execute(GroupParticipatingIq::new()).await?;
252
253        let result = response
254            .groups
255            .into_iter()
256            .map(|group| {
257                let key = group.id.to_string();
258                let metadata = GroupMetadata::from(group);
259                (key, metadata)
260            })
261            .collect();
262
263        Ok(result)
264    }
265
266    pub async fn get_metadata(&self, jid: &Jid) -> Result<GroupMetadata, anyhow::Error> {
267        let group = self.client.execute(GroupQueryIq::new(jid)).await?;
268        Ok(GroupMetadata::from(group))
269    }
270
271    pub async fn create_group(
272        &self,
273        mut options: GroupCreateOptions,
274    ) -> Result<CreateGroupResult, anyhow::Error> {
275        // Resolve phone numbers for LID participants that don't have one
276        let mut resolved_participants = Vec::with_capacity(options.participants.len());
277
278        for participant in options.participants {
279            let resolved = if participant.jid.is_lid() && participant.phone_number.is_none() {
280                let entry = self
281                    .client
282                    .get_lid_pn_entry(&participant.jid)
283                    .await?
284                    .ok_or_else(|| {
285                        anyhow::anyhow!("Missing phone number mapping for LID {}", participant.jid)
286                    })?;
287                participant.with_phone_number(Jid::pn(entry.phone_number))
288            } else {
289                participant
290            };
291            resolved_participants.push(resolved);
292        }
293
294        options.participants = normalize_participants(&resolved_participants);
295
296        if self
297            .client
298            .ab_props()
299            .is_enabled(wacore::iq::props::config_codes::PRIVACY_TOKEN_ON_GROUP_CREATE)
300            .await
301        {
302            self.attach_tokens_to_participants(&mut options.participants)
303                .await;
304        }
305
306        let group = self.client.execute(GroupCreateIq::new(options)).await?;
307
308        Ok(CreateGroupResult {
309            metadata: GroupMetadata::from(group),
310        })
311    }
312
313    pub async fn set_subject(&self, jid: &Jid, subject: GroupSubject) -> Result<(), anyhow::Error> {
314        Ok(self
315            .client
316            .execute(SetGroupSubjectIq::new(jid, subject))
317            .await?)
318    }
319
320    /// Sets or deletes a group's description.
321    ///
322    /// `prev` is the current description ID (from group metadata) used for
323    /// conflict detection. Pass `None` if unknown.
324    pub async fn set_description(
325        &self,
326        jid: &Jid,
327        description: Option<GroupDescription>,
328        prev: Option<String>,
329    ) -> Result<(), anyhow::Error> {
330        Ok(self
331            .client
332            .execute(SetGroupDescriptionIq::new(jid, description, prev))
333            .await?)
334    }
335
336    pub async fn leave(&self, jid: &Jid) -> Result<(), anyhow::Error> {
337        self.client.execute(LeaveGroupIq::new(jid)).await?;
338        self.client.get_group_cache().await.invalidate(jid).await;
339        Ok(())
340    }
341
342    pub async fn add_participants(
343        &self,
344        jid: &Jid,
345        participants: &[Jid],
346    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
347        let iq = if self
348            .client
349            .ab_props()
350            .is_enabled(wacore::iq::props::config_codes::PRIVACY_TOKEN_ON_GROUP_PARTICIPANT_ADD)
351            .await
352        {
353            let options = self.resolve_participant_tokens(participants).await;
354            AddParticipantsIq::with_options(jid, options)
355        } else {
356            AddParticipantsIq::new(jid, participants)
357        };
358
359        let result = self.client.execute(iq).await?;
360        if result.iter().any(|r| r.is_ok()) {
361            let group_cache = self.client.get_group_cache().await;
362            if let Some(mut info) = group_cache.get(jid).await {
363                info.add_participants(
364                    result
365                        .iter()
366                        .filter(|r| r.is_ok())
367                        .map(|r| (&r.jid, r.phone_number.as_ref())),
368                );
369                group_cache.insert(jid.clone(), info).await;
370            }
371        }
372        Ok(result)
373    }
374
375    pub async fn remove_participants(
376        &self,
377        jid: &Jid,
378        participants: &[Jid],
379    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
380        let result = self
381            .client
382            .execute(RemoveParticipantsIq::new(jid, participants))
383            .await?;
384        let accepted: Vec<&str> = result
385            .iter()
386            .filter(|r| r.is_ok())
387            .map(|r| r.jid.user.as_str())
388            .collect();
389        if !accepted.is_empty() {
390            let group_cache = self.client.get_group_cache().await;
391            if let Some(mut info) = group_cache.get(jid).await {
392                info.remove_participants(&accepted);
393                group_cache.insert(jid.clone(), info).await;
394            }
395            self.client
396                .rotate_sender_key_on_participant_remove(&jid.to_string(), &accepted)
397                .await;
398        }
399        Ok(result)
400    }
401
402    pub async fn promote_participants(
403        &self,
404        jid: &Jid,
405        participants: &[Jid],
406    ) -> Result<(), anyhow::Error> {
407        Ok(self
408            .client
409            .execute(PromoteParticipantsIq::new(jid, participants))
410            .await?)
411    }
412
413    pub async fn demote_participants(
414        &self,
415        jid: &Jid,
416        participants: &[Jid],
417    ) -> Result<(), anyhow::Error> {
418        Ok(self
419            .client
420            .execute(DemoteParticipantsIq::new(jid, participants))
421            .await?)
422    }
423
424    pub async fn get_invite_link(&self, jid: &Jid, reset: bool) -> Result<String, anyhow::Error> {
425        Ok(self
426            .client
427            .execute(GetGroupInviteLinkIq::new(jid, reset))
428            .await?)
429    }
430
431    /// Lock the group so only admins can change group info.
432    pub async fn set_locked(&self, jid: &Jid, locked: bool) -> Result<(), anyhow::Error> {
433        let spec = if locked {
434            SetGroupLockedIq::lock(jid)
435        } else {
436            SetGroupLockedIq::unlock(jid)
437        };
438        Ok(self.client.execute(spec).await?)
439    }
440
441    /// Set announcement mode. When enabled, only admins can send messages.
442    pub async fn set_announce(&self, jid: &Jid, announce: bool) -> Result<(), anyhow::Error> {
443        let spec = if announce {
444            SetGroupAnnouncementIq::announce(jid)
445        } else {
446            SetGroupAnnouncementIq::unannounce(jid)
447        };
448        Ok(self.client.execute(spec).await?)
449    }
450
451    /// Set ephemeral (disappearing) messages timer on the group.
452    ///
453    /// Common values: 86400 (24h), 604800 (7d), 7776000 (90d).
454    /// Pass 0 to disable.
455    pub async fn set_ephemeral(&self, jid: &Jid, expiration: u32) -> Result<(), anyhow::Error> {
456        let spec = match std::num::NonZeroU32::new(expiration) {
457            Some(exp) => SetGroupEphemeralIq::enable(jid, exp),
458            None => SetGroupEphemeralIq::disable(jid),
459        };
460        Ok(self.client.execute(spec).await?)
461    }
462
463    /// Set membership approval mode. When on, new members must be approved by an admin.
464    pub async fn set_membership_approval(
465        &self,
466        jid: &Jid,
467        mode: MembershipApprovalMode,
468    ) -> Result<(), anyhow::Error> {
469        Ok(self
470            .client
471            .execute(SetGroupMembershipApprovalIq::new(jid, mode))
472            .await?)
473    }
474
475    /// Join a group using an invite code.
476    pub async fn join_with_invite_code(
477        &self,
478        code: &str,
479    ) -> Result<JoinGroupResult, anyhow::Error> {
480        let code = extract_invite_code(code)
481            .ok_or_else(|| anyhow::anyhow!("invalid or empty invite code"))?;
482        Ok(self.client.execute(AcceptGroupInviteIq::new(code)).await?)
483    }
484
485    /// Accept a V4 invite (received as a GroupInviteMessage, not a link).
486    pub async fn join_with_invite_v4(
487        &self,
488        group_jid: &Jid,
489        code: &str,
490        expiration: i64,
491        admin_jid: &Jid,
492    ) -> Result<JoinGroupResult, anyhow::Error> {
493        if expiration > 0 {
494            let now = wacore::time::now_millis() / 1000;
495            if expiration < now {
496                anyhow::bail!("V4 invite has expired (expiration={expiration}, now={now})");
497            }
498        }
499        Ok(self
500            .client
501            .execute(AcceptGroupInviteV4Iq::new(
502                group_jid.clone(),
503                code.to_string(),
504                expiration,
505                admin_jid.clone(),
506            ))
507            .await?)
508    }
509
510    /// Get group metadata from an invite code without joining.
511    pub async fn get_invite_info(&self, code: &str) -> Result<GroupMetadata, anyhow::Error> {
512        let code = extract_invite_code(code)
513            .ok_or_else(|| anyhow::anyhow!("invalid or empty invite code"))?;
514        let group = self.client.execute(GetGroupInviteInfoIq::new(code)).await?;
515        Ok(GroupMetadata::from(group))
516    }
517
518    /// Get pending membership approval requests.
519    pub async fn get_membership_requests(
520        &self,
521        jid: &Jid,
522    ) -> Result<Vec<MembershipRequest>, anyhow::Error> {
523        Ok(self
524            .client
525            .execute(GetMembershipRequestsIq::new(jid))
526            .await?)
527    }
528
529    /// Approve pending membership requests.
530    pub async fn approve_membership_requests(
531        &self,
532        jid: &Jid,
533        participants: &[Jid],
534    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
535        Ok(self
536            .client
537            .execute(MembershipRequestActionIq::approve(jid, participants))
538            .await?)
539    }
540
541    /// Reject pending membership requests.
542    pub async fn reject_membership_requests(
543        &self,
544        jid: &Jid,
545        participants: &[Jid],
546    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
547        Ok(self
548            .client
549            .execute(MembershipRequestActionIq::reject(jid, participants))
550            .await?)
551    }
552
553    /// Set who can add members to the group.
554    pub async fn set_member_add_mode(
555        &self,
556        jid: &Jid,
557        mode: MemberAddMode,
558    ) -> Result<(), anyhow::Error> {
559        Ok(self
560            .client
561            .execute(SetMemberAddModeIq::new(jid, mode))
562            .await?)
563    }
564
565    /// Restrict or allow frequently-forwarded messages in the group.
566    pub async fn set_no_frequently_forwarded(
567        &self,
568        jid: &Jid,
569        restrict: bool,
570    ) -> Result<(), anyhow::Error> {
571        Ok(self
572            .client
573            .execute(SetNoFrequentlyForwardedIq::new(jid, restrict))
574            .await?)
575    }
576
577    /// Enable or disable admin reports in the group.
578    pub async fn set_allow_admin_reports(
579        &self,
580        jid: &Jid,
581        allow: bool,
582    ) -> Result<(), anyhow::Error> {
583        Ok(self
584            .client
585            .execute(SetAllowAdminReportsIq::new(jid, allow))
586            .await?)
587    }
588
589    /// Enable or disable group history sharing.
590    pub async fn set_group_history(&self, jid: &Jid, enabled: bool) -> Result<(), anyhow::Error> {
591        Ok(self
592            .client
593            .execute(SetGroupHistoryIq::new(jid, enabled))
594            .await?)
595    }
596
597    /// Set who can share invite links (via MEX).
598    pub async fn set_member_link_mode(
599        &self,
600        jid: &Jid,
601        mode: MemberLinkMode,
602    ) -> Result<(), MexError> {
603        let value = match mode {
604            MemberLinkMode::AdminLink => "ADMIN_LINK",
605            MemberLinkMode::AllMemberLink => "ALL_MEMBER_LINK",
606        };
607        self.mex_update_group_property(jid, serde_json::json!({ "member_link_mode": value }))
608            .await
609    }
610
611    /// Set who can share message history with new members (via MEX).
612    pub async fn set_member_share_history_mode(
613        &self,
614        jid: &Jid,
615        mode: MemberShareHistoryMode,
616    ) -> Result<(), MexError> {
617        let value = match mode {
618            MemberShareHistoryMode::AdminShare => "ADMIN_SHARE",
619            MemberShareHistoryMode::AllMemberShare => "ALL_MEMBER_SHARE",
620        };
621        self.mex_update_group_property(
622            jid,
623            serde_json::json!({ "member_share_group_history_mode": value }),
624        )
625        .await
626    }
627
628    /// Enable or disable limit sharing in the group (via MEX).
629    pub async fn set_limit_sharing(&self, jid: &Jid, enabled: bool) -> Result<(), MexError> {
630        self.mex_update_group_property(
631            jid,
632            serde_json::json!({
633                "limit_sharing": {
634                    "limit_sharing_enabled": enabled,
635                    "limit_sharing_trigger": "CHAT_SETTING"
636                }
637            }),
638        )
639        .await
640    }
641
642    /// Cancel pending membership requests (from the requesting user's side).
643    pub async fn cancel_membership_requests(
644        &self,
645        jid: &Jid,
646        participants: &[Jid],
647    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
648        Ok(self
649            .client
650            .execute(CancelMembershipRequestsIq::new(jid, participants))
651            .await?)
652    }
653
654    /// Revoke invitation codes from specific participants (admin operation).
655    pub async fn revoke_request_code(
656        &self,
657        jid: &Jid,
658        participants: &[Jid],
659    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
660        Ok(self
661            .client
662            .execute(RevokeRequestCodeIq::new(jid, participants))
663            .await?)
664    }
665
666    /// Acknowledge a group notification.
667    pub async fn acknowledge(&self, jid: &Jid) -> Result<(), anyhow::Error> {
668        Ok(self.client.execute(AcknowledgeGroupIq::new(jid)).await?)
669    }
670
671    /// Batch query group info for multiple groups at once (max 10,000).
672    pub async fn batch_get_info(
673        &self,
674        jids: Vec<Jid>,
675    ) -> Result<Vec<BatchGroupResult>, anyhow::Error> {
676        anyhow::ensure!(
677            jids.len() <= wacore::iq::groups::BATCH_GROUP_INFO_LIMIT,
678            "batch_get_info: {} groups exceeds limit of {}",
679            jids.len(),
680            wacore::iq::groups::BATCH_GROUP_INFO_LIMIT,
681        );
682        let raw = self.client.execute(BatchGetGroupInfoIq::new(jids)).await?;
683        Ok(raw
684            .into_iter()
685            .map(|r| match r {
686                RawBatchResult::Full(info) => {
687                    BatchGroupResult::Full(Box::new(GroupMetadata::from(*info)))
688                }
689                RawBatchResult::Truncated { id, size } => BatchGroupResult::Truncated { id, size },
690                RawBatchResult::Forbidden(id) => BatchGroupResult::Forbidden(id),
691                RawBatchResult::NotFound(id) => BatchGroupResult::NotFound(id),
692            })
693            .collect())
694    }
695
696    /// Batch fetch group profile pictures (max 1,000).
697    pub async fn get_profile_pictures(
698        &self,
699        group_jids: Vec<Jid>,
700        picture_type: PictureType,
701    ) -> Result<Vec<GroupProfilePicture>, anyhow::Error> {
702        anyhow::ensure!(
703            group_jids.len() <= wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT,
704            "get_profile_pictures: {} groups exceeds limit of {}",
705            group_jids.len(),
706            wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT,
707        );
708        let groups = group_jids
709            .into_iter()
710            .map(|jid| (jid, picture_type))
711            .collect();
712        Ok(self
713            .client
714            .execute(GetGroupProfilePicturesIq::with_type(groups))
715            .await?)
716    }
717
718    async fn mex_update_group_property(
719        &self,
720        jid: &Jid,
721        update: serde_json::Value,
722    ) -> Result<(), MexError> {
723        let resp = self
724            .client
725            .mex()
726            .mutate(MexRequest {
727                doc: wacore::iq::mex_ids::groups::UPDATE_GROUP_PROPERTY,
728                variables: serde_json::json!({
729                    "group_id": jid.to_string(),
730                    "update": update,
731                }),
732            })
733            .await?;
734
735        let state = resp
736            .data
737            .as_ref()
738            .and_then(|d| d.get("xwa2_group_update_property"))
739            .and_then(|r| r.get("state"))
740            .and_then(|s| s.as_str());
741
742        if state != Some("ACTIVE") {
743            return Err(MexError::PayloadParsing(format!(
744                "group property update failed, state: {state:?}"
745            )));
746        }
747
748        Ok(())
749    }
750
751    /// Set or clear the bot's per-group member label. Empty clears.
752    ///
753    /// WA Web sends this as a `ProtocolMessage` over the normal message path,
754    /// not as an IQ.
755    pub async fn update_member_label(
756        &self,
757        group_jid: &Jid,
758        label: impl Into<String>,
759    ) -> Result<(), anyhow::Error> {
760        if !group_jid.is_group() {
761            return Err(anyhow::anyhow!(
762                "update_member_label requires a group JID, got {group_jid}"
763            ));
764        }
765        let msg = wacore::send::build_member_label_message(label.into(), wacore::time::now_secs());
766        self.client
767            .send_message_impl(group_jid.clone(), &msg, None, false, false, None, vec![])
768            .await
769    }
770
771    async fn resolve_participant_tokens(&self, jids: &[Jid]) -> Vec<GroupParticipantOptions> {
772        if jids.is_empty() {
773            return Vec::new();
774        }
775        let only_lid = self.only_check_lid().await;
776        let futs = jids.iter().map(|jid| async move {
777            let mut opt = GroupParticipantOptions::new(jid.clone());
778            if let Some(token_key) = self.resolve_token_key(jid, only_lid).await
779                && let Some(token) = self.lookup_valid_token(&token_key).await
780            {
781                opt = opt.with_privacy(token);
782            }
783            opt
784        });
785        futures::future::join_all(futs).await
786    }
787
788    /// Skips participants that already have a token set by the caller.
789    async fn attach_tokens_to_participants(&self, participants: &mut [GroupParticipantOptions]) {
790        if participants.is_empty() {
791            return;
792        }
793        let only_lid = self.only_check_lid().await;
794        let futs = participants.iter().enumerate().map(|(i, p)| async move {
795            if p.privacy.is_some() {
796                return (i, None);
797            }
798            let Some(token_key) = self.resolve_token_key(&p.jid, only_lid).await else {
799                log::debug!(
800                    target: "Client/Groups",
801                    "No LID mapping for participant {}, skipping privacy attachment",
802                    p.jid
803                );
804                return (i, None);
805            };
806            let token = self.lookup_valid_token(&token_key).await;
807            if token.is_none() {
808                log::debug!(
809                    target: "Client/Groups",
810                    "No valid tc_token for participant {} (key={}), skipping privacy attachment",
811                    p.jid, token_key
812                );
813            }
814            (i, token)
815        });
816        for (i, token) in futures::future::join_all(futs).await {
817            if token.is_some() {
818                participants[i].privacy = token;
819            }
820        }
821    }
822
823    async fn only_check_lid(&self) -> bool {
824        self.client
825            .ab_props()
826            .is_enabled(wacore::iq::props::config_codes::PRIVACY_TOKEN_ONLY_CHECK_LID)
827            .await
828    }
829
830    /// Resolve JID to tc_token store key. When `only_lid`, PN JIDs without a
831    /// LID mapping return `None` instead of falling back to the PN user.
832    async fn resolve_token_key(&self, jid: &Jid, only_lid: bool) -> Option<String> {
833        if jid.is_lid() {
834            Some(jid.user.to_string())
835        } else {
836            let lid = self.client.lid_pn_cache.get_current_lid(&jid.user).await;
837            if only_lid {
838                lid
839            } else {
840                Some(lid.unwrap_or_else(|| jid.user.to_string()))
841            }
842        }
843    }
844
845    /// Returns the tc_token if present and not expired.
846    async fn lookup_valid_token(&self, token_key: &str) -> Option<Vec<u8>> {
847        use wacore::iq::tctoken::is_tc_token_expired_with;
848        let tc_config = self.client.tc_token_config().await;
849        let backend = self.client.persistence_manager.backend();
850        match backend.get_tc_token(token_key).await {
851            Ok(Some(entry))
852                if !entry.token.is_empty()
853                    && !is_tc_token_expired_with(entry.token_timestamp, &tc_config) =>
854            {
855                Some(entry.token)
856            }
857            Ok(_) => None,
858            Err(e) => {
859                log::warn!(
860                    target: "Client/Groups",
861                    "Failed to get tc_token for {}: {e}",
862                    token_key
863                );
864                None
865            }
866        }
867    }
868}
869
870impl Client {
871    pub fn groups(&self) -> Groups<'_> {
872        Groups::new(self)
873    }
874}
875
876/// Extract the invite code from any supported invite URL format.
877///
878/// Handles all WA Web patterns:
879/// - `https://chat.whatsapp.com/CODE?query`
880/// - `https://chat.whatsapp.com/invite/CODE?query`
881/// - `https://web.whatsapp.com/.../accept/?code=CODE&...`
882/// - `whatsapp://chat/?code=CODE`
883/// - bare code string
884fn extract_invite_code(input: &str) -> Option<&str> {
885    let input = input.trim();
886
887    // whatsapp://chat/?code=CODE or web.whatsapp.com/.../accept/?code=CODE
888    if let Some(code) = extract_code_param(input) {
889        return Some(code);
890    }
891
892    // https://chat.whatsapp.com/invite/CODE or https://chat.whatsapp.com/CODE
893    let stripped = input
894        .strip_prefix("https://chat.whatsapp.com/")
895        .or_else(|| input.strip_prefix("http://chat.whatsapp.com/"));
896
897    let code = if let Some(path) = stripped {
898        let path = path.strip_prefix("invite/").unwrap_or(path);
899        path.split('?').next().unwrap_or(path).trim_end_matches('/')
900    } else if input.contains("://") || input.contains('?') {
901        // Looks like a URL we don't recognize or one with an empty code= param
902        return None;
903    } else {
904        input.trim_end_matches('/')
905    };
906
907    if code.is_empty() { None } else { Some(code) }
908}
909
910fn extract_code_param(input: &str) -> Option<&str> {
911    let query = input.split('?').nth(1)?;
912    for pair in query.split('&') {
913        if let Some(val) = pair.strip_prefix("code=") {
914            let val = val.trim_end_matches('/');
915            if !val.is_empty() {
916                return Some(val);
917            }
918        }
919    }
920    None
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    #[test]
928    fn test_group_metadata_struct() {
929        let jid: Jid = "123456789@g.us"
930            .parse()
931            .expect("test group JID should be valid");
932        let participant_jid: Jid = "1234567890@s.whatsapp.net"
933            .parse()
934            .expect("test participant JID should be valid");
935
936        let metadata = GroupMetadata {
937            id: jid.clone(),
938            subject: "Test Group".to_string(),
939            participants: vec![GroupParticipant {
940                jid: participant_jid,
941                phone_number: None,
942                participant_type: ParticipantType::Admin,
943            }],
944            ..Default::default()
945        };
946
947        assert_eq!(metadata.subject, "Test Group");
948        assert_eq!(metadata.participants.len(), 1);
949        assert!(metadata.participants[0].is_admin());
950        assert!(!metadata.participants[0].is_super_admin());
951    }
952
953    #[test]
954    fn test_extract_invite_code() {
955        // Pattern 3: most common
956        assert_eq!(
957            extract_invite_code("https://chat.whatsapp.com/AbCdEfGh").unwrap(),
958            "AbCdEfGh"
959        );
960        assert_eq!(
961            extract_invite_code("http://chat.whatsapp.com/AbCdEfGh").unwrap(),
962            "AbCdEfGh"
963        );
964
965        // With query params
966        assert_eq!(
967            extract_invite_code("https://chat.whatsapp.com/AbCdEfGh?fbclid=123&utm_source=x")
968                .unwrap(),
969            "AbCdEfGh"
970        );
971
972        // Trailing slash
973        assert_eq!(
974            extract_invite_code("https://chat.whatsapp.com/AbCdEfGh/").unwrap(),
975            "AbCdEfGh"
976        );
977
978        // Pattern 2: /invite/ prefix
979        assert_eq!(
980            extract_invite_code("https://chat.whatsapp.com/invite/AbCdEfGh").unwrap(),
981            "AbCdEfGh"
982        );
983        assert_eq!(
984            extract_invite_code("https://chat.whatsapp.com/invite/AbCdEfGh?utm=test").unwrap(),
985            "AbCdEfGh"
986        );
987
988        // Pattern 1: web.whatsapp.com/accept?code=
989        assert_eq!(
990            extract_invite_code("https://web.whatsapp.com/accept?code=AbCdEfGh").unwrap(),
991            "AbCdEfGh"
992        );
993        assert_eq!(
994            extract_invite_code("https://web.whatsapp.com/accept/?code=AbCdEfGh&other=1").unwrap(),
995            "AbCdEfGh"
996        );
997
998        // Pattern 4: deep link
999        assert_eq!(
1000            extract_invite_code("whatsapp://chat/?code=AbCdEfGh").unwrap(),
1001            "AbCdEfGh"
1002        );
1003        assert_eq!(
1004            extract_invite_code("whatsapp://chat?code=AbCdEfGh&extra=y").unwrap(),
1005            "AbCdEfGh"
1006        );
1007
1008        // Bare code
1009        assert_eq!(extract_invite_code("AbCdEfGh").unwrap(), "AbCdEfGh");
1010        assert_eq!(extract_invite_code("AbCdEfGh/").unwrap(), "AbCdEfGh");
1011
1012        // Whitespace
1013        assert_eq!(extract_invite_code("  AbCdEfGh  ").unwrap(), "AbCdEfGh");
1014
1015        // Empty / malformed inputs return None
1016        assert!(extract_invite_code("").is_none());
1017        assert!(extract_invite_code("   ").is_none());
1018        assert!(extract_invite_code("https://chat.whatsapp.com/").is_none());
1019        assert!(extract_invite_code("https://chat.whatsapp.com/invite/").is_none());
1020        assert!(extract_invite_code("whatsapp://chat/?code=").is_none());
1021        assert!(extract_invite_code("whatsapp://chat/?code=&other=1").is_none());
1022    }
1023
1024    // Protocol-level tests (node building, parsing, validation) are in wacore/src/iq/groups.rs
1025}