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, AddParticipantsIq, DemoteParticipantsIq, GetGroupInviteInfoIq,
6    GetGroupInviteLinkIq, GetMembershipRequestsIq, GroupCreateIq, GroupInfoResponse,
7    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    /// Get group metadata from an invite code without joining.
378    pub async fn get_invite_info(&self, code: &str) -> Result<GroupMetadata, anyhow::Error> {
379        let code = strip_invite_url(code);
380        let group = self.client.execute(GetGroupInviteInfoIq::new(code)).await?;
381        Ok(GroupMetadata::from(group))
382    }
383
384    /// Get pending membership approval requests.
385    pub async fn get_membership_requests(
386        &self,
387        jid: &Jid,
388    ) -> Result<Vec<MembershipRequest>, anyhow::Error> {
389        Ok(self
390            .client
391            .execute(GetMembershipRequestsIq::new(jid))
392            .await?)
393    }
394
395    /// Approve pending membership requests.
396    pub async fn approve_membership_requests(
397        &self,
398        jid: &Jid,
399        participants: &[Jid],
400    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
401        Ok(self
402            .client
403            .execute(MembershipRequestActionIq::approve(jid, participants))
404            .await?)
405    }
406
407    /// Reject pending membership requests.
408    pub async fn reject_membership_requests(
409        &self,
410        jid: &Jid,
411        participants: &[Jid],
412    ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
413        Ok(self
414            .client
415            .execute(MembershipRequestActionIq::reject(jid, participants))
416            .await?)
417    }
418
419    /// Set who can add members to the group.
420    pub async fn set_member_add_mode(
421        &self,
422        jid: &Jid,
423        mode: MemberAddMode,
424    ) -> Result<(), anyhow::Error> {
425        Ok(self
426            .client
427            .execute(SetMemberAddModeIq::new(jid, mode))
428            .await?)
429    }
430}
431
432impl Client {
433    pub fn groups(&self) -> Groups<'_> {
434        Groups::new(self)
435    }
436}
437
438fn strip_invite_url(code: &str) -> &str {
439    let code = code.trim().trim_end_matches('/');
440    code.strip_prefix("https://chat.whatsapp.com/")
441        .or_else(|| code.strip_prefix("http://chat.whatsapp.com/"))
442        .unwrap_or(code)
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn test_group_metadata_struct() {
451        let jid: Jid = "123456789@g.us"
452            .parse()
453            .expect("test group JID should be valid");
454        let participant_jid: Jid = "1234567890@s.whatsapp.net"
455            .parse()
456            .expect("test participant JID should be valid");
457
458        let metadata = GroupMetadata {
459            id: jid.clone(),
460            subject: "Test Group".to_string(),
461            participants: vec![GroupParticipant {
462                jid: participant_jid,
463                phone_number: None,
464                is_admin: true,
465            }],
466            addressing_mode: AddressingMode::Pn,
467            creator: None,
468            creation_time: None,
469            subject_time: None,
470            subject_owner: None,
471            description: None,
472            description_id: None,
473            is_locked: false,
474            is_announcement: false,
475            ephemeral_expiration: 0,
476            membership_approval: false,
477            member_add_mode: None,
478            member_link_mode: None,
479            size: None,
480            is_parent_group: false,
481            parent_group_jid: None,
482            is_default_sub_group: false,
483            is_general_chat: false,
484            allow_non_admin_sub_group_creation: false,
485        };
486
487        assert_eq!(metadata.subject, "Test Group");
488        assert_eq!(metadata.participants.len(), 1);
489        assert!(metadata.participants[0].is_admin);
490    }
491
492    // Protocol-level tests (node building, parsing, validation) are in wacore/src/iq/groups.rs
493}