Skip to main content

whatsapp_rust/features/
groups.rs

1use crate::client::Client;
2use std::collections::HashMap;
3use wacore::client::context::GroupInfo;
4use wacore::iq::groups::{
5    AcceptGroupInviteIq, AcceptGroupInviteV4Iq, AddParticipantsIq, DemoteParticipantsIq,
6    GetGroupInviteInfoIq, GetGroupInviteLinkIq, GetMembershipRequestsIq, GroupCreateIq,
7    GroupInfoResponse, GroupParticipantResponse, GroupParticipatingIq, GroupQueryIq, LeaveGroupIq,
8    MembershipRequestActionIq, PromoteParticipantsIq, RemoveParticipantsIq, SetGroupAnnouncementIq,
9    SetGroupDescriptionIq, SetGroupEphemeralIq, SetGroupLockedIq, SetGroupMembershipApprovalIq,
10    SetGroupSubjectIq, SetMemberAddModeIq, normalize_participants,
11};
12use wacore::types::message::AddressingMode;
13use wacore_binary::jid::Jid;
14
15pub use wacore::iq::groups::{
16    GroupCreateOptions, GroupDescription, GroupParticipantOptions, GroupSubject, JoinGroupResult,
17    MemberAddMode, MemberLinkMode, MembershipApprovalMode, MembershipRequest,
18    ParticipantChangeResponse,
19};
20
21#[derive(Debug, Clone)]
22pub struct GroupMetadata {
23    pub id: Jid,
24    pub subject: String,
25    pub participants: Vec<GroupParticipant>,
26    pub addressing_mode: AddressingMode,
27    /// Group creator JID.
28    pub creator: Option<Jid>,
29    /// Group creation timestamp (Unix seconds).
30    pub creation_time: Option<u64>,
31    /// Subject modification timestamp (Unix seconds).
32    pub subject_time: Option<u64>,
33    /// Subject owner JID.
34    pub subject_owner: Option<Jid>,
35    /// Group description body text.
36    pub description: Option<String>,
37    /// Description ID (for conflict detection when updating).
38    pub description_id: Option<String>,
39    /// Whether the group is locked (only admins can edit group info).
40    pub is_locked: bool,
41    /// Whether announcement mode is enabled (only admins can send messages).
42    pub is_announcement: bool,
43    /// Ephemeral message expiration in seconds (0 = disabled).
44    pub ephemeral_expiration: u32,
45    /// Whether membership approval is required to join.
46    pub membership_approval: bool,
47    /// Who can add members to the group.
48    pub member_add_mode: Option<MemberAddMode>,
49    /// Who can use invite links.
50    pub member_link_mode: Option<MemberLinkMode>,
51    /// Total participant count.
52    pub size: Option<u32>,
53    /// Whether this group is a community parent group.
54    pub is_parent_group: bool,
55    /// JID of the parent community (for subgroups).
56    pub parent_group_jid: Option<Jid>,
57    /// Whether this is the default announcement subgroup of a community.
58    pub is_default_sub_group: bool,
59    /// Whether this is the general chat subgroup of a community.
60    pub is_general_chat: bool,
61    /// Whether non-admin community members can create subgroups.
62    pub allow_non_admin_sub_group_creation: bool,
63}
64
65#[derive(Debug, Clone)]
66pub struct GroupParticipant {
67    pub jid: Jid,
68    pub phone_number: Option<Jid>,
69    pub is_admin: bool,
70}
71
72impl From<GroupParticipantResponse> for GroupParticipant {
73    fn from(p: GroupParticipantResponse) -> Self {
74        Self {
75            jid: p.jid,
76            phone_number: p.phone_number,
77            is_admin: p.participant_type.is_admin(),
78        }
79    }
80}
81
82impl From<GroupInfoResponse> for GroupMetadata {
83    fn from(group: GroupInfoResponse) -> Self {
84        Self {
85            id: group.id,
86            subject: group.subject.into_string(),
87            participants: group.participants.into_iter().map(Into::into).collect(),
88            addressing_mode: group.addressing_mode,
89            creator: group.creator,
90            creation_time: group.creation_time,
91            subject_time: group.subject_time,
92            subject_owner: group.subject_owner,
93            description: group.description,
94            description_id: group.description_id,
95            is_locked: group.is_locked,
96            is_announcement: group.is_announcement,
97            ephemeral_expiration: group.ephemeral_expiration,
98            membership_approval: group.membership_approval,
99            member_add_mode: group.member_add_mode,
100            member_link_mode: group.member_link_mode,
101            size: group.size,
102            is_parent_group: group.is_parent_group,
103            parent_group_jid: group.parent_group_jid,
104            is_default_sub_group: group.is_default_sub_group,
105            is_general_chat: group.is_general_chat,
106            allow_non_admin_sub_group_creation: group.allow_non_admin_sub_group_creation,
107        }
108    }
109}
110
111#[derive(Debug, Clone)]
112pub struct CreateGroupResult {
113    pub gid: Jid,
114}
115
116pub struct Groups<'a> {
117    client: &'a Client,
118}
119
120impl<'a> Groups<'a> {
121    pub(crate) fn new(client: &'a Client) -> Self {
122        Self { client }
123    }
124
125    pub async fn query_info(&self, jid: &Jid) -> Result<GroupInfo, anyhow::Error> {
126        if let Some(cached) = self.client.get_group_cache().await.get(jid).await {
127            return Ok(cached);
128        }
129
130        let group = self.client.execute(GroupQueryIq::new(jid)).await?;
131
132        let participants: Vec<Jid> = group.participants.iter().map(|p| p.jid.clone()).collect();
133
134        let lid_to_pn_map: HashMap<String, Jid> = if group.addressing_mode == AddressingMode::Lid {
135            group
136                .participants
137                .iter()
138                .filter_map(|p| {
139                    p.phone_number
140                        .as_ref()
141                        .map(|pn| (p.jid.user.clone(), pn.clone()))
142                })
143                .collect()
144        } else {
145            HashMap::new()
146        };
147
148        let mut info = GroupInfo::new(participants, group.addressing_mode);
149        if !lid_to_pn_map.is_empty() {
150            info.set_lid_to_pn_map(lid_to_pn_map);
151        }
152
153        self.client
154            .get_group_cache()
155            .await
156            .insert(jid.clone(), info.clone())
157            .await;
158
159        Ok(info)
160    }
161
162    pub async fn get_participating(&self) -> Result<HashMap<String, GroupMetadata>, anyhow::Error> {
163        let response = self.client.execute(GroupParticipatingIq::new()).await?;
164
165        let result = response
166            .groups
167            .into_iter()
168            .map(|group| {
169                let key = group.id.to_string();
170                let metadata = GroupMetadata::from(group);
171                (key, metadata)
172            })
173            .collect();
174
175        Ok(result)
176    }
177
178    pub async fn get_metadata(&self, jid: &Jid) -> Result<GroupMetadata, anyhow::Error> {
179        let group = self.client.execute(GroupQueryIq::new(jid)).await?;
180        Ok(GroupMetadata::from(group))
181    }
182
183    pub async fn create_group(
184        &self,
185        mut options: GroupCreateOptions,
186    ) -> Result<CreateGroupResult, anyhow::Error> {
187        // Resolve phone numbers for LID participants that don't have one
188        let mut resolved_participants = Vec::with_capacity(options.participants.len());
189
190        for participant in options.participants {
191            let resolved = if participant.jid.is_lid() && participant.phone_number.is_none() {
192                let phone_number = self
193                    .client
194                    .get_phone_number_from_lid(&participant.jid.user)
195                    .await
196                    .ok_or_else(|| {
197                        anyhow::anyhow!("Missing phone number mapping for LID {}", participant.jid)
198                    })?;
199                participant.with_phone_number(Jid::pn(phone_number))
200            } else {
201                participant
202            };
203            resolved_participants.push(resolved);
204        }
205
206        options.participants = normalize_participants(&resolved_participants);
207
208        let gid = self.client.execute(GroupCreateIq::new(options)).await?;
209
210        Ok(CreateGroupResult { gid })
211    }
212
213    pub async fn set_subject(&self, jid: &Jid, subject: GroupSubject) -> Result<(), anyhow::Error> {
214        Ok(self
215            .client
216            .execute(SetGroupSubjectIq::new(jid, subject))
217            .await?)
218    }
219
220    /// Sets or deletes a group's description.
221    ///
222    /// `prev` is the current description ID (from group metadata) used for
223    /// conflict detection. Pass `None` if unknown.
224    pub async fn set_description(
225        &self,
226        jid: &Jid,
227        description: Option<GroupDescription>,
228        prev: Option<String>,
229    ) -> Result<(), anyhow::Error> {
230        Ok(self
231            .client
232            .execute(SetGroupDescriptionIq::new(jid, description, prev))
233            .await?)
234    }
235
236    pub async fn leave(&self, jid: &Jid) -> Result<(), anyhow::Error> {
237        self.client.execute(LeaveGroupIq::new(jid)).await?;
238        self.client.get_group_cache().await.invalidate(jid).await;
239        Ok(())
240    }
241
242    pub async fn add_participants(
243        &self,
244        jid: &Jid,
245        participants: &[Jid],
246    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
247        let result = self
248            .client
249            .execute(AddParticipantsIq::new(jid, participants))
250            .await?;
251        // Patch cache with only the participants the server accepted (status 200).
252        // Note: the get→mutate→insert is not atomic; a concurrent notification
253        // for the same group could race.  This is acceptable — the cache is
254        // best-effort and a full refetch on next query_info() corrects it.
255        let accepted: Vec<_> = result
256            .iter()
257            .filter(|r| r.status.as_deref() == Some("200"))
258            .map(|r| (r.jid.clone(), None))
259            .collect();
260        if !accepted.is_empty() {
261            let group_cache = self.client.get_group_cache().await;
262            if let Some(mut info) = group_cache.get(jid).await {
263                info.add_participants(&accepted);
264                group_cache.insert(jid.clone(), info).await;
265            }
266        }
267        Ok(result)
268    }
269
270    pub async fn remove_participants(
271        &self,
272        jid: &Jid,
273        participants: &[Jid],
274    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
275        let result = self
276            .client
277            .execute(RemoveParticipantsIq::new(jid, participants))
278            .await?;
279        // Patch cache with only the participants the server accepted.
280        let accepted: Vec<&str> = result
281            .iter()
282            .filter(|r| r.status.as_deref() == Some("200"))
283            .map(|r| r.jid.user.as_str())
284            .collect();
285        if !accepted.is_empty() {
286            let group_cache = self.client.get_group_cache().await;
287            if let Some(mut info) = group_cache.get(jid).await {
288                info.remove_participants(&accepted);
289                group_cache.insert(jid.clone(), info).await;
290            }
291        }
292        Ok(result)
293    }
294
295    pub async fn promote_participants(
296        &self,
297        jid: &Jid,
298        participants: &[Jid],
299    ) -> Result<(), anyhow::Error> {
300        Ok(self
301            .client
302            .execute(PromoteParticipantsIq::new(jid, participants))
303            .await?)
304    }
305
306    pub async fn demote_participants(
307        &self,
308        jid: &Jid,
309        participants: &[Jid],
310    ) -> Result<(), anyhow::Error> {
311        Ok(self
312            .client
313            .execute(DemoteParticipantsIq::new(jid, participants))
314            .await?)
315    }
316
317    pub async fn get_invite_link(&self, jid: &Jid, reset: bool) -> Result<String, anyhow::Error> {
318        Ok(self
319            .client
320            .execute(GetGroupInviteLinkIq::new(jid, reset))
321            .await?)
322    }
323
324    /// Lock the group so only admins can change group info.
325    pub async fn set_locked(&self, jid: &Jid, locked: bool) -> Result<(), anyhow::Error> {
326        let spec = if locked {
327            SetGroupLockedIq::lock(jid)
328        } else {
329            SetGroupLockedIq::unlock(jid)
330        };
331        Ok(self.client.execute(spec).await?)
332    }
333
334    /// Set announcement mode. When enabled, only admins can send messages.
335    pub async fn set_announce(&self, jid: &Jid, announce: bool) -> Result<(), anyhow::Error> {
336        let spec = if announce {
337            SetGroupAnnouncementIq::announce(jid)
338        } else {
339            SetGroupAnnouncementIq::unannounce(jid)
340        };
341        Ok(self.client.execute(spec).await?)
342    }
343
344    /// Set ephemeral (disappearing) messages timer on the group.
345    ///
346    /// Common values: 86400 (24h), 604800 (7d), 7776000 (90d).
347    /// Pass 0 to disable.
348    pub async fn set_ephemeral(&self, jid: &Jid, expiration: u32) -> Result<(), anyhow::Error> {
349        let spec = match std::num::NonZeroU32::new(expiration) {
350            Some(exp) => SetGroupEphemeralIq::enable(jid, exp),
351            None => SetGroupEphemeralIq::disable(jid),
352        };
353        Ok(self.client.execute(spec).await?)
354    }
355
356    /// Set membership approval mode. When on, new members must be approved by an admin.
357    pub async fn set_membership_approval(
358        &self,
359        jid: &Jid,
360        mode: MembershipApprovalMode,
361    ) -> Result<(), anyhow::Error> {
362        Ok(self
363            .client
364            .execute(SetGroupMembershipApprovalIq::new(jid, mode))
365            .await?)
366    }
367
368    /// Join a group using an invite code.
369    pub async fn join_with_invite_code(
370        &self,
371        code: &str,
372    ) -> Result<JoinGroupResult, anyhow::Error> {
373        let code = strip_invite_url(code);
374        Ok(self.client.execute(AcceptGroupInviteIq::new(code)).await?)
375    }
376
377    /// Accept a V4 invite (received as a GroupInviteMessage, not a link).
378    pub async fn join_with_invite_v4(
379        &self,
380        group_jid: &Jid,
381        code: &str,
382        expiration: i64,
383        admin_jid: &Jid,
384    ) -> Result<JoinGroupResult, anyhow::Error> {
385        if expiration > 0 {
386            let now = wacore::time::now_millis() / 1000;
387            if expiration < now {
388                anyhow::bail!("V4 invite has expired (expiration={expiration}, now={now})");
389            }
390        }
391        Ok(self
392            .client
393            .execute(AcceptGroupInviteV4Iq::new(
394                group_jid.clone(),
395                code.to_string(),
396                expiration,
397                admin_jid.clone(),
398            ))
399            .await?)
400    }
401
402    /// Get group metadata from an invite code without joining.
403    pub async fn get_invite_info(&self, code: &str) -> Result<GroupMetadata, anyhow::Error> {
404        let code = strip_invite_url(code);
405        let group = self.client.execute(GetGroupInviteInfoIq::new(code)).await?;
406        Ok(GroupMetadata::from(group))
407    }
408
409    /// Get pending membership approval requests.
410    pub async fn get_membership_requests(
411        &self,
412        jid: &Jid,
413    ) -> Result<Vec<MembershipRequest>, anyhow::Error> {
414        Ok(self
415            .client
416            .execute(GetMembershipRequestsIq::new(jid))
417            .await?)
418    }
419
420    /// Approve pending membership requests.
421    pub async fn approve_membership_requests(
422        &self,
423        jid: &Jid,
424        participants: &[Jid],
425    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
426        Ok(self
427            .client
428            .execute(MembershipRequestActionIq::approve(jid, participants))
429            .await?)
430    }
431
432    /// Reject pending membership requests.
433    pub async fn reject_membership_requests(
434        &self,
435        jid: &Jid,
436        participants: &[Jid],
437    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
438        Ok(self
439            .client
440            .execute(MembershipRequestActionIq::reject(jid, participants))
441            .await?)
442    }
443
444    /// Set who can add members to the group.
445    pub async fn set_member_add_mode(
446        &self,
447        jid: &Jid,
448        mode: MemberAddMode,
449    ) -> Result<(), anyhow::Error> {
450        Ok(self
451            .client
452            .execute(SetMemberAddModeIq::new(jid, mode))
453            .await?)
454    }
455}
456
457impl Client {
458    pub fn groups(&self) -> Groups<'_> {
459        Groups::new(self)
460    }
461}
462
463fn strip_invite_url(code: &str) -> &str {
464    let code = code.trim().trim_end_matches('/');
465    code.strip_prefix("https://chat.whatsapp.com/")
466        .or_else(|| code.strip_prefix("http://chat.whatsapp.com/"))
467        .unwrap_or(code)
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn test_group_metadata_struct() {
476        let jid: Jid = "123456789@g.us"
477            .parse()
478            .expect("test group JID should be valid");
479        let participant_jid: Jid = "1234567890@s.whatsapp.net"
480            .parse()
481            .expect("test participant JID should be valid");
482
483        let metadata = GroupMetadata {
484            id: jid.clone(),
485            subject: "Test Group".to_string(),
486            participants: vec![GroupParticipant {
487                jid: participant_jid,
488                phone_number: None,
489                is_admin: true,
490            }],
491            addressing_mode: AddressingMode::Pn,
492            creator: None,
493            creation_time: None,
494            subject_time: None,
495            subject_owner: None,
496            description: None,
497            description_id: None,
498            is_locked: false,
499            is_announcement: false,
500            ephemeral_expiration: 0,
501            membership_approval: false,
502            member_add_mode: None,
503            member_link_mode: None,
504            size: None,
505            is_parent_group: false,
506            parent_group_jid: None,
507            is_default_sub_group: false,
508            is_general_chat: false,
509            allow_non_admin_sub_group_creation: false,
510        };
511
512        assert_eq!(metadata.subject, "Test Group");
513        assert_eq!(metadata.participants.len(), 1);
514        assert!(metadata.participants[0].is_admin);
515    }
516
517    // Protocol-level tests (node building, parsing, validation) are in wacore/src/iq/groups.rs
518}