Skip to main content

wacore_ng/iq/
groups.rs

1use crate::StringEnum;
2use crate::iq::node::{collect_children, optional_attr, required_attr, required_child};
3use crate::iq::spec::IqSpec;
4use crate::protocol::ProtocolNode;
5use crate::request::InfoQuery;
6use anyhow::{Result, anyhow};
7use std::num::NonZeroU32;
8use typed_builder::TypedBuilder;
9use wacore_binary_ng::builder::NodeBuilder;
10use wacore_binary_ng::jid::{GROUP_SERVER, Jid};
11use wacore_binary_ng::node::{Node, NodeContent};
12
13// Re-export AddressingMode from types::message for convenience
14pub use crate::types::message::AddressingMode;
15/// IQ namespace for group operations.
16pub const GROUP_IQ_NAMESPACE: &str = "w:g2";
17
18/// Maximum length for a WhatsApp group subject (from `group_max_subject` A/B prop).
19pub const GROUP_SUBJECT_MAX_LENGTH: usize = 100;
20
21/// Maximum length for a WhatsApp group description (from `group_description_length` A/B prop).
22pub const GROUP_DESCRIPTION_MAX_LENGTH: usize = 2048;
23
24/// Maximum number of participants in a group (from `group_size_limit` A/B prop).
25pub const GROUP_SIZE_LIMIT: usize = 257;
26/// Member link mode for group invite links.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
28pub enum MemberLinkMode {
29    #[str = "admin_link"]
30    AdminLink,
31    #[str = "all_member_link"]
32    AllMemberLink,
33}
34
35/// Member add mode for who can add participants.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
37pub enum MemberAddMode {
38    #[str = "admin_add"]
39    AdminAdd,
40    #[str = "all_member_add"]
41    AllMemberAdd,
42}
43
44/// Membership approval mode for join requests.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
46pub enum MembershipApprovalMode {
47    #[string_default]
48    #[str = "off"]
49    Off,
50    #[str = "on"]
51    On,
52}
53
54/// Query request type.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
56pub enum GroupQueryRequestType {
57    #[string_default]
58    #[str = "interactive"]
59    Interactive,
60}
61
62/// Participant type (admin level).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
64pub enum ParticipantType {
65    #[string_default]
66    #[str = "member"]
67    Member,
68    #[str = "admin"]
69    Admin,
70    #[str = "superadmin"]
71    SuperAdmin,
72}
73
74impl ParticipantType {
75    pub fn is_admin(&self) -> bool {
76        matches!(self, ParticipantType::Admin | ParticipantType::SuperAdmin)
77    }
78}
79
80impl TryFrom<Option<&str>> for ParticipantType {
81    type Error = anyhow::Error;
82
83    fn try_from(value: Option<&str>) -> Result<Self> {
84        match value {
85            Some("admin") => Ok(ParticipantType::Admin),
86            Some("superadmin") => Ok(ParticipantType::SuperAdmin),
87            Some("member") | None => Ok(ParticipantType::Member),
88            Some(other) => Err(anyhow!("unknown participant type: {other}")),
89        }
90    }
91}
92crate::define_validated_string! {
93    /// A validated group subject string.
94    ///
95    /// WhatsApp limits group subjects to [`GROUP_SUBJECT_MAX_LENGTH`] characters.
96    pub struct GroupSubject(max_len = GROUP_SUBJECT_MAX_LENGTH, name = "Group subject")
97}
98
99crate::define_validated_string! {
100    /// A validated group description string.
101    ///
102    /// WhatsApp limits group descriptions to [`GROUP_DESCRIPTION_MAX_LENGTH`] characters.
103    pub struct GroupDescription(max_len = GROUP_DESCRIPTION_MAX_LENGTH, name = "Group description")
104}
105/// Options for a participant when creating a group.
106#[derive(Debug, Clone, TypedBuilder)]
107#[builder(build_method(into))]
108pub struct GroupParticipantOptions {
109    pub jid: Jid,
110    #[builder(default, setter(strip_option))]
111    pub phone_number: Option<Jid>,
112    #[builder(default, setter(strip_option))]
113    pub privacy: Option<Vec<u8>>,
114}
115
116impl GroupParticipantOptions {
117    pub fn new(jid: Jid) -> Self {
118        Self {
119            jid,
120            phone_number: None,
121            privacy: None,
122        }
123    }
124
125    pub fn from_phone(phone_number: Jid) -> Self {
126        Self::new(phone_number)
127    }
128
129    pub fn from_lid_and_phone(lid: Jid, phone_number: Jid) -> Self {
130        Self::new(lid).with_phone_number(phone_number)
131    }
132
133    pub fn with_phone_number(mut self, phone_number: Jid) -> Self {
134        self.phone_number = Some(phone_number);
135        self
136    }
137
138    pub fn with_privacy(mut self, privacy: Vec<u8>) -> Self {
139        self.privacy = Some(privacy);
140        self
141    }
142}
143
144/// Options for creating a new group.
145#[derive(Debug, Clone, TypedBuilder)]
146#[builder(build_method(into))]
147pub struct GroupCreateOptions {
148    #[builder(setter(into))]
149    pub subject: String,
150    #[builder(default)]
151    pub participants: Vec<GroupParticipantOptions>,
152    #[builder(default = Some(MemberLinkMode::AdminLink), setter(strip_option))]
153    pub member_link_mode: Option<MemberLinkMode>,
154    #[builder(default = Some(MemberAddMode::AllMemberAdd), setter(strip_option))]
155    pub member_add_mode: Option<MemberAddMode>,
156    #[builder(default = Some(MembershipApprovalMode::Off), setter(strip_option))]
157    pub membership_approval_mode: Option<MembershipApprovalMode>,
158    #[builder(default = Some(0), setter(strip_option))]
159    pub ephemeral_expiration: Option<u32>,
160}
161
162impl GroupCreateOptions {
163    /// Create new options with just a subject (for backwards compatibility).
164    pub fn new(subject: impl Into<String>) -> Self {
165        Self {
166            subject: subject.into(),
167            ..Default::default()
168        }
169    }
170
171    pub fn with_participant(mut self, participant: GroupParticipantOptions) -> Self {
172        self.participants.push(participant);
173        self
174    }
175
176    pub fn with_participants(mut self, participants: Vec<GroupParticipantOptions>) -> Self {
177        self.participants = participants;
178        self
179    }
180
181    pub fn with_member_link_mode(mut self, mode: MemberLinkMode) -> Self {
182        self.member_link_mode = Some(mode);
183        self
184    }
185
186    pub fn with_member_add_mode(mut self, mode: MemberAddMode) -> Self {
187        self.member_add_mode = Some(mode);
188        self
189    }
190
191    pub fn with_membership_approval_mode(mut self, mode: MembershipApprovalMode) -> Self {
192        self.membership_approval_mode = Some(mode);
193        self
194    }
195
196    pub fn with_ephemeral_expiration(mut self, expiration: u32) -> Self {
197        self.ephemeral_expiration = Some(expiration);
198        self
199    }
200}
201
202impl Default for GroupCreateOptions {
203    fn default() -> Self {
204        Self {
205            subject: String::new(),
206            participants: Vec::new(),
207            member_link_mode: Some(MemberLinkMode::AdminLink),
208            member_add_mode: Some(MemberAddMode::AllMemberAdd),
209            membership_approval_mode: Some(MembershipApprovalMode::Off),
210            ephemeral_expiration: Some(0),
211        }
212    }
213}
214
215/// Normalize participants: drop phone_number for non-LID JIDs.
216pub fn normalize_participants(
217    participants: &[GroupParticipantOptions],
218) -> Vec<GroupParticipantOptions> {
219    participants
220        .iter()
221        .cloned()
222        .map(|p| {
223            if !p.jid.is_lid() && p.phone_number.is_some() {
224                GroupParticipantOptions {
225                    phone_number: None,
226                    ..p
227                }
228            } else {
229                p
230            }
231        })
232        .collect()
233}
234
235/// Build the `<create>` node for group creation.
236pub fn build_create_group_node(options: &GroupCreateOptions) -> Node {
237    let mut children = Vec::new();
238
239    if let Some(link_mode) = &options.member_link_mode {
240        children.push(
241            NodeBuilder::new("member_link_mode")
242                .string_content(link_mode.as_str())
243                .build(),
244        );
245    }
246
247    if let Some(add_mode) = &options.member_add_mode {
248        children.push(
249            NodeBuilder::new("member_add_mode")
250                .string_content(add_mode.as_str())
251                .build(),
252        );
253    }
254
255    // Normalize participants to avoid sending phone_number for non-LID JIDs
256    let participants = normalize_participants(&options.participants);
257
258    for participant in &participants {
259        let mut attrs = vec![("jid", participant.jid.to_string())];
260        if let Some(pn) = &participant.phone_number {
261            attrs.push(("phone_number", pn.to_string()));
262        }
263
264        let participant_node = if let Some(privacy_bytes) = &participant.privacy {
265            NodeBuilder::new("participant")
266                .attrs(attrs)
267                .children([NodeBuilder::new("privacy")
268                    .string_content(hex::encode(privacy_bytes))
269                    .build()])
270                .build()
271        } else {
272            NodeBuilder::new("participant").attrs(attrs).build()
273        };
274        children.push(participant_node);
275    }
276
277    if let Some(expiration) = &options.ephemeral_expiration {
278        children.push(
279            NodeBuilder::new("ephemeral")
280                .attr("expiration", expiration.to_string())
281                .build(),
282        );
283    }
284
285    if let Some(approval_mode) = &options.membership_approval_mode {
286        children.push(
287            NodeBuilder::new("membership_approval_mode")
288                .children([NodeBuilder::new("group_join")
289                    .attr("state", approval_mode.as_str())
290                    .build()])
291                .build(),
292        );
293    }
294
295    NodeBuilder::new("create")
296        .attr("subject", &options.subject)
297        .children(children)
298        .build()
299}
300/// Request to query group information.
301///
302/// Wire format: `<query request="interactive"/>`
303#[derive(Debug, Clone, crate::ProtocolNode)]
304#[protocol(tag = "query")]
305pub struct GroupQueryRequest {
306    #[attr(name = "request", string_enum)]
307    pub request: GroupQueryRequestType,
308}
309
310/// A participant in a group response.
311#[derive(Debug, Clone)]
312pub struct GroupParticipantResponse {
313    pub jid: Jid,
314    pub phone_number: Option<Jid>,
315    pub participant_type: ParticipantType,
316}
317
318impl ProtocolNode for GroupParticipantResponse {
319    fn tag(&self) -> &'static str {
320        "participant"
321    }
322
323    fn into_node(self) -> Node {
324        let mut builder = NodeBuilder::new("participant").attr("jid", self.jid.to_string());
325        if let Some(pn) = &self.phone_number {
326            builder = builder.attr("phone_number", pn.to_string());
327        }
328        if self.participant_type != ParticipantType::Member {
329            builder = builder.attr("type", self.participant_type.as_str());
330        }
331        builder.build()
332    }
333
334    fn try_from_node(node: &Node) -> Result<Self> {
335        if node.tag != "participant" {
336            return Err(anyhow!("expected <participant>, got <{}>", node.tag));
337        }
338        let jid = node
339            .attrs()
340            .optional_jid("jid")
341            .ok_or_else(|| anyhow!("participant missing required 'jid' attribute"))?;
342        let phone_number = node.attrs().optional_jid("phone_number");
343        // Default to Member for unknown participant types to avoid failing the whole group parse
344        let participant_type = ParticipantType::try_from(node.attrs().optional_string("type"))
345            .unwrap_or(ParticipantType::Member);
346
347        Ok(Self {
348            jid,
349            phone_number,
350            participant_type,
351        })
352    }
353}
354
355/// Response from a group info query.
356#[derive(Debug, Clone)]
357pub struct GroupInfoResponse {
358    pub id: Jid,
359    pub subject: GroupSubject,
360    pub addressing_mode: AddressingMode,
361    pub participants: Vec<GroupParticipantResponse>,
362    /// Group creator JID (from `creator` attribute).
363    pub creator: Option<Jid>,
364    /// Group creation timestamp (from `creation` attribute).
365    pub creation_time: Option<u64>,
366    /// Subject modification timestamp (from `s_t` attribute).
367    pub subject_time: Option<u64>,
368    /// Subject owner JID (from `s_o` attribute).
369    pub subject_owner: Option<Jid>,
370    /// Group description body text.
371    pub description: Option<String>,
372    /// Description ID (for conflict detection when updating).
373    pub description_id: Option<String>,
374    /// Whether the group is locked (only admins can edit group info).
375    pub is_locked: bool,
376    /// Whether announcement mode is enabled (only admins can send messages).
377    pub is_announcement: bool,
378    /// Ephemeral message expiration in seconds (0 = disabled).
379    pub ephemeral_expiration: u32,
380    /// Whether membership approval is required to join.
381    pub membership_approval: bool,
382    /// Who can add members to the group.
383    pub member_add_mode: Option<MemberAddMode>,
384    /// Who can use invite links.
385    pub member_link_mode: Option<MemberLinkMode>,
386    /// Total participant count (from `size` attribute, useful for large groups).
387    pub size: Option<u32>,
388}
389
390impl ProtocolNode for GroupInfoResponse {
391    fn tag(&self) -> &'static str {
392        "group"
393    }
394
395    fn into_node(self) -> Node {
396        let mut children: Vec<Node> = self
397            .participants
398            .into_iter()
399            .map(|p| p.into_node())
400            .collect();
401
402        if self.is_locked {
403            children.push(NodeBuilder::new("locked").build());
404        }
405        if self.is_announcement {
406            children.push(NodeBuilder::new("announcement").build());
407        }
408        if self.ephemeral_expiration > 0 {
409            children.push(
410                NodeBuilder::new("ephemeral")
411                    .attr("expiration", self.ephemeral_expiration.to_string())
412                    .build(),
413            );
414        }
415        if self.membership_approval {
416            children.push(
417                NodeBuilder::new("membership_approval_mode")
418                    .children(vec![
419                        NodeBuilder::new("group_join").attr("state", "on").build(),
420                    ])
421                    .build(),
422            );
423        }
424        if let Some(ref add_mode) = self.member_add_mode {
425            children.push(
426                NodeBuilder::new("member_add_mode")
427                    .string_content(add_mode.as_str())
428                    .build(),
429            );
430        }
431        if let Some(ref link_mode) = self.member_link_mode {
432            children.push(
433                NodeBuilder::new("member_link_mode")
434                    .string_content(link_mode.as_str())
435                    .build(),
436            );
437        }
438        if let Some(ref desc) = self.description {
439            let mut desc_builder = NodeBuilder::new("description");
440            if let Some(ref desc_id) = self.description_id {
441                desc_builder = desc_builder.attr("id", desc_id.as_str());
442            }
443            children.push(desc_builder.string_content(desc.as_str()).build());
444        }
445
446        let mut builder = NodeBuilder::new("group")
447            .attr("id", self.id.to_string())
448            .attr("subject", self.subject.as_str())
449            .attr("addressing_mode", self.addressing_mode.as_str());
450
451        if let Some(ref creator) = self.creator {
452            builder = builder.attr("creator", creator.to_string());
453        }
454        if let Some(creation_time) = self.creation_time {
455            builder = builder.attr("creation", creation_time.to_string());
456        }
457        if let Some(subject_time) = self.subject_time {
458            builder = builder.attr("s_t", subject_time.to_string());
459        }
460        if let Some(ref subject_owner) = self.subject_owner {
461            builder = builder.attr("s_o", subject_owner.to_string());
462        }
463        if let Some(size) = self.size {
464            builder = builder.attr("size", size.to_string());
465        }
466
467        builder.children(children).build()
468    }
469
470    fn try_from_node(node: &Node) -> Result<Self> {
471        if node.tag != "group" {
472            return Err(anyhow!("expected <group>, got <{}>", node.tag));
473        }
474
475        let id_str = required_attr(node, "id")?;
476        let id = if id_str.contains('@') {
477            id_str.parse()?
478        } else {
479            Jid::group(id_str)
480        };
481
482        let subject =
483            GroupSubject::new_unchecked(optional_attr(node, "subject").unwrap_or_default());
484
485        let addressing_mode =
486            AddressingMode::try_from(optional_attr(node, "addressing_mode").unwrap_or("pn"))?;
487
488        let participants = collect_children::<GroupParticipantResponse>(node, "participant")?;
489
490        // Parse attributes
491        let creator = node
492            .attrs()
493            .optional_string("creator")
494            .and_then(|s| s.parse::<Jid>().ok());
495        let creation_time = node
496            .attrs()
497            .optional_string("creation")
498            .and_then(|s| s.parse::<u64>().ok());
499        let subject_time = node
500            .attrs()
501            .optional_string("s_t")
502            .and_then(|s| s.parse::<u64>().ok());
503        let subject_owner = node
504            .attrs()
505            .optional_string("s_o")
506            .and_then(|s| s.parse::<Jid>().ok());
507        let size = node
508            .attrs()
509            .optional_string("size")
510            .and_then(|s| s.parse::<u32>().ok());
511
512        // Parse settings from child nodes
513        let is_locked = node.get_optional_child_by_tag(&["locked"]).is_some();
514        let is_announcement = node.get_optional_child_by_tag(&["announcement"]).is_some();
515
516        let ephemeral_expiration = node
517            .get_optional_child_by_tag(&["ephemeral"])
518            .and_then(|n| n.attrs().optional_string("expiration"))
519            .and_then(|s| s.parse::<u32>().ok())
520            .unwrap_or(0);
521
522        let membership_approval = node
523            .get_optional_child_by_tag(&["membership_approval_mode", "group_join"])
524            .and_then(|n| n.attrs().optional_string("state"))
525            .is_some_and(|s| s == "on");
526
527        let member_add_mode = node
528            .get_optional_child_by_tag(&["member_add_mode"])
529            .and_then(|n| match &n.content {
530                Some(NodeContent::String(s)) => MemberAddMode::try_from(s.as_str()).ok(),
531                _ => None,
532            });
533
534        let member_link_mode = node
535            .get_optional_child_by_tag(&["member_link_mode"])
536            .and_then(|n| match &n.content {
537                Some(NodeContent::String(s)) => MemberLinkMode::try_from(s.as_str()).ok(),
538                _ => None,
539            });
540
541        // Parse description
542        let description_node = node.get_optional_child_by_tag(&["description"]);
543        let description = description_node.and_then(|n| match &n.content {
544            Some(NodeContent::String(s)) => Some(s.clone()),
545            _ => None,
546        });
547        let description_id = description_node
548            .and_then(|n| n.attrs().optional_string("id"))
549            .map(|s| s.to_string());
550
551        Ok(Self {
552            id,
553            subject,
554            addressing_mode,
555            participants,
556            creator,
557            creation_time,
558            subject_time,
559            subject_owner,
560            description,
561            description_id,
562            is_locked,
563            is_announcement,
564            ephemeral_expiration,
565            membership_approval,
566            member_add_mode,
567            member_link_mode,
568            size,
569        })
570    }
571}
572/// Request to get all groups the user is participating in.
573#[derive(Debug, Clone)]
574pub struct GroupParticipatingRequest {
575    pub include_participants: bool,
576    pub include_description: bool,
577}
578
579impl GroupParticipatingRequest {
580    pub fn new() -> Self {
581        Self {
582            include_participants: true,
583            include_description: true,
584        }
585    }
586}
587
588impl Default for GroupParticipatingRequest {
589    fn default() -> Self {
590        Self::new()
591    }
592}
593
594impl ProtocolNode for GroupParticipatingRequest {
595    fn tag(&self) -> &'static str {
596        "participating"
597    }
598
599    fn into_node(self) -> Node {
600        let mut children = Vec::new();
601        if self.include_participants {
602            children.push(NodeBuilder::new("participants").build());
603        }
604        if self.include_description {
605            children.push(NodeBuilder::new("description").build());
606        }
607        NodeBuilder::new("participating").children(children).build()
608    }
609
610    fn try_from_node(node: &Node) -> Result<Self> {
611        if node.tag != "participating" {
612            return Err(anyhow!("expected <participating>, got <{}>", node.tag));
613        }
614        Ok(Self::default())
615    }
616}
617
618/// Response containing all groups the user is participating in.
619#[derive(Debug, Clone, Default)]
620pub struct GroupParticipatingResponse {
621    pub groups: Vec<GroupInfoResponse>,
622}
623
624impl ProtocolNode for GroupParticipatingResponse {
625    fn tag(&self) -> &'static str {
626        "groups"
627    }
628
629    fn into_node(self) -> Node {
630        let children: Vec<Node> = self.groups.into_iter().map(|g| g.into_node()).collect();
631        NodeBuilder::new("groups").children(children).build()
632    }
633
634    fn try_from_node(node: &Node) -> Result<Self> {
635        if node.tag != "groups" {
636            return Err(anyhow!("expected <groups>, got <{}>", node.tag));
637        }
638
639        let groups = collect_children::<GroupInfoResponse>(node, "group")?;
640
641        Ok(Self { groups })
642    }
643}
644/// IQ specification for querying a specific group's info.
645#[derive(Debug, Clone)]
646pub struct GroupQueryIq {
647    pub group_jid: Jid,
648}
649
650impl GroupQueryIq {
651    pub fn new(group_jid: &Jid) -> Self {
652        Self {
653            group_jid: group_jid.clone(),
654        }
655    }
656}
657
658impl IqSpec for GroupQueryIq {
659    type Response = GroupInfoResponse;
660
661    fn build_iq(&self) -> InfoQuery<'static> {
662        InfoQuery::get_ref(
663            GROUP_IQ_NAMESPACE,
664            &self.group_jid,
665            Some(NodeContent::Nodes(vec![
666                GroupQueryRequest::default().into_node(),
667            ])),
668        )
669    }
670
671    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
672        let group_node = required_child(response, "group")?;
673        GroupInfoResponse::try_from_node(group_node)
674    }
675}
676
677/// IQ specification for getting all groups the user is participating in.
678#[derive(Debug, Clone, Default)]
679pub struct GroupParticipatingIq;
680
681impl GroupParticipatingIq {
682    pub fn new() -> Self {
683        Self
684    }
685}
686
687impl IqSpec for GroupParticipatingIq {
688    type Response = GroupParticipatingResponse;
689
690    fn build_iq(&self) -> InfoQuery<'static> {
691        InfoQuery::get(
692            GROUP_IQ_NAMESPACE,
693            Jid::new("", GROUP_SERVER),
694            Some(NodeContent::Nodes(vec![
695                GroupParticipatingRequest::new().into_node(),
696            ])),
697        )
698    }
699
700    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
701        let groups_node = required_child(response, "groups")?;
702        GroupParticipatingResponse::try_from_node(groups_node)
703    }
704}
705
706/// IQ specification for creating a new group.
707#[derive(Debug, Clone)]
708pub struct GroupCreateIq {
709    pub options: GroupCreateOptions,
710}
711
712impl GroupCreateIq {
713    pub fn new(options: GroupCreateOptions) -> Self {
714        Self { options }
715    }
716}
717
718impl IqSpec for GroupCreateIq {
719    type Response = Jid;
720
721    fn build_iq(&self) -> InfoQuery<'static> {
722        InfoQuery::set(
723            GROUP_IQ_NAMESPACE,
724            Jid::new("", GROUP_SERVER),
725            Some(NodeContent::Nodes(vec![build_create_group_node(
726                &self.options,
727            )])),
728        )
729    }
730
731    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
732        let group_node = required_child(response, "group")?;
733        let group_id_str = required_attr(group_node, "id")?;
734
735        if group_id_str.contains('@') {
736            group_id_str.parse().map_err(Into::into)
737        } else {
738            Ok(Jid::group(group_id_str))
739        }
740    }
741}
742
743// ---------------------------------------------------------------------------
744// Group Management IQ Specs
745// ---------------------------------------------------------------------------
746
747/// Response for participant change operations (add/remove/promote/demote).
748///
749/// Wire format: `<participant jid="..." type="200" error="..."/>`
750#[derive(Debug, Clone, crate::ProtocolNode)]
751#[protocol(tag = "participant")]
752pub struct ParticipantChangeResponse {
753    #[attr(name = "jid", jid)]
754    pub jid: Jid,
755    /// HTTP-like status code (e.g. 200, 403, 409).
756    #[attr(name = "type")]
757    pub status: Option<String>,
758    #[attr(name = "error")]
759    pub error: Option<String>,
760}
761
762/// IQ specification for setting a group's subject.
763///
764/// Wire format:
765/// ```xml
766/// <iq type="set" xmlns="w:g2" to="{group_jid}">
767///   <subject>{text}</subject>
768/// </iq>
769/// ```
770#[derive(Debug, Clone)]
771pub struct SetGroupSubjectIq {
772    pub group_jid: Jid,
773    pub subject: GroupSubject,
774}
775
776impl SetGroupSubjectIq {
777    pub fn new(group_jid: &Jid, subject: GroupSubject) -> Self {
778        Self {
779            group_jid: group_jid.clone(),
780            subject,
781        }
782    }
783}
784
785impl IqSpec for SetGroupSubjectIq {
786    type Response = ();
787
788    fn build_iq(&self) -> InfoQuery<'static> {
789        InfoQuery::set_ref(
790            GROUP_IQ_NAMESPACE,
791            &self.group_jid,
792            Some(NodeContent::Nodes(vec![
793                NodeBuilder::new("subject")
794                    .string_content(self.subject.as_str())
795                    .build(),
796            ])),
797        )
798    }
799
800    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
801        Ok(())
802    }
803}
804
805/// IQ specification for setting a group's description.
806///
807/// Wire format:
808/// ```xml
809/// <iq type="set" xmlns="w:g2" to="{group_jid}">
810///   <description id="{new_id}" prev="{prev_id}"><body>{text}</body></description>
811/// </iq>
812/// ```
813///
814/// - `id`: random 8-char hex, generated automatically.
815/// - `prev`: the current description ID (from group metadata), used for conflict detection.
816/// - To delete the description, pass `None` as the description.
817#[derive(Debug, Clone)]
818pub struct SetGroupDescriptionIq {
819    pub group_jid: Jid,
820    pub description: Option<GroupDescription>,
821    /// New description ID (random 8-char hex).
822    pub id: String,
823    /// Previous description ID from group metadata, for conflict detection.
824    pub prev: Option<String>,
825}
826
827impl SetGroupDescriptionIq {
828    pub fn new(
829        group_jid: &Jid,
830        description: Option<GroupDescription>,
831        prev: Option<String>,
832    ) -> Self {
833        use rand::Rng;
834        let id = format!("{:08X}", rand::rng().random::<u32>());
835        Self {
836            group_jid: group_jid.clone(),
837            description,
838            id,
839            prev,
840        }
841    }
842}
843
844impl IqSpec for SetGroupDescriptionIq {
845    type Response = ();
846
847    fn build_iq(&self) -> InfoQuery<'static> {
848        let desc_node = if let Some(ref desc) = self.description {
849            let mut builder = NodeBuilder::new("description").attr("id", &self.id);
850            if let Some(ref prev) = self.prev {
851                builder = builder.attr("prev", prev);
852            }
853            builder
854                .children([NodeBuilder::new("body")
855                    .string_content(desc.as_str())
856                    .build()])
857                .build()
858        } else {
859            let mut builder = NodeBuilder::new("description")
860                .attr("id", &self.id)
861                .attr("delete", "true");
862            if let Some(ref prev) = self.prev {
863                builder = builder.attr("prev", prev);
864            }
865            builder.build()
866        };
867
868        InfoQuery::set_ref(
869            GROUP_IQ_NAMESPACE,
870            &self.group_jid,
871            Some(NodeContent::Nodes(vec![desc_node])),
872        )
873    }
874
875    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
876        Ok(())
877    }
878}
879
880/// IQ specification for leaving a group.
881///
882/// Wire format:
883/// ```xml
884/// <iq type="set" xmlns="w:g2" to="g.us">
885///   <leave><group id="{group_jid}"/></leave>
886/// </iq>
887/// ```
888#[derive(Debug, Clone)]
889pub struct LeaveGroupIq {
890    pub group_jid: Jid,
891}
892
893impl LeaveGroupIq {
894    pub fn new(group_jid: &Jid) -> Self {
895        Self {
896            group_jid: group_jid.clone(),
897        }
898    }
899}
900
901impl IqSpec for LeaveGroupIq {
902    type Response = ();
903
904    fn build_iq(&self) -> InfoQuery<'static> {
905        let group_node = NodeBuilder::new("group")
906            .attr("id", self.group_jid.to_string())
907            .build();
908        let leave_node = NodeBuilder::new("leave").children([group_node]).build();
909
910        InfoQuery::set(
911            GROUP_IQ_NAMESPACE,
912            Jid::new("", GROUP_SERVER),
913            Some(NodeContent::Nodes(vec![leave_node])),
914        )
915    }
916
917    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
918        Ok(())
919    }
920}
921
922/// Macro to generate group participant IQ specs that share the same structure:
923/// a `set` IQ to `{group_jid}` with `<{action}><participant jid="..."/>...</{action}>`.
924macro_rules! define_group_participant_iq {
925    (
926        $(#[$meta:meta])*
927        $name:ident, action = $action:literal, response = Vec<ParticipantChangeResponse>
928    ) => {
929        $(#[$meta])*
930        #[derive(Debug, Clone)]
931        pub struct $name {
932            pub group_jid: Jid,
933            pub participants: Vec<Jid>,
934        }
935
936        impl $name {
937            pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
938                Self {
939                    group_jid: group_jid.clone(),
940                    participants: participants.to_vec(),
941                }
942            }
943        }
944
945        impl IqSpec for $name {
946            type Response = Vec<ParticipantChangeResponse>;
947
948            fn build_iq(&self) -> InfoQuery<'static> {
949                let children: Vec<Node> = self
950                    .participants
951                    .iter()
952                    .map(|jid| {
953                        NodeBuilder::new("participant")
954                            .attr("jid", jid.to_string())
955                            .build()
956                    })
957                    .collect();
958
959                let action_node = NodeBuilder::new($action).children(children).build();
960
961                InfoQuery::set_ref(
962                    GROUP_IQ_NAMESPACE,
963                    &self.group_jid,
964                    Some(NodeContent::Nodes(vec![action_node])),
965                )
966            }
967
968            fn parse_response(&self, response: &Node) -> Result<Self::Response> {
969                let action_node = required_child(response, $action)?;
970                collect_children::<ParticipantChangeResponse>(action_node, "participant")
971            }
972        }
973    };
974    (
975        $(#[$meta:meta])*
976        $name:ident, action = $action:literal, response = ()
977    ) => {
978        $(#[$meta])*
979        #[derive(Debug, Clone)]
980        pub struct $name {
981            pub group_jid: Jid,
982            pub participants: Vec<Jid>,
983        }
984
985        impl $name {
986            pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
987                Self {
988                    group_jid: group_jid.clone(),
989                    participants: participants.to_vec(),
990                }
991            }
992        }
993
994        impl IqSpec for $name {
995            type Response = ();
996
997            fn build_iq(&self) -> InfoQuery<'static> {
998                let children: Vec<Node> = self
999                    .participants
1000                    .iter()
1001                    .map(|jid| {
1002                        NodeBuilder::new("participant")
1003                            .attr("jid", jid.to_string())
1004                            .build()
1005                    })
1006                    .collect();
1007
1008                let action_node = NodeBuilder::new($action).children(children).build();
1009
1010                InfoQuery::set_ref(
1011                    GROUP_IQ_NAMESPACE,
1012                    &self.group_jid,
1013                    Some(NodeContent::Nodes(vec![action_node])),
1014                )
1015            }
1016
1017            fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1018                Ok(())
1019            }
1020        }
1021    };
1022}
1023
1024define_group_participant_iq!(
1025    /// IQ specification for adding participants to a group.
1026    ///
1027    /// Wire format:
1028    /// ```xml
1029    /// <iq type="set" xmlns="w:g2" to="{group_jid}">
1030    ///   <add><participant jid="{user_jid}"/></add>
1031    /// </iq>
1032    /// ```
1033    AddParticipantsIq, action = "add", response = Vec<ParticipantChangeResponse>
1034);
1035
1036define_group_participant_iq!(
1037    /// IQ specification for removing participants from a group.
1038    ///
1039    /// Wire format:
1040    /// ```xml
1041    /// <iq type="set" xmlns="w:g2" to="{group_jid}">
1042    ///   <remove><participant jid="{user_jid}"/></remove>
1043    /// </iq>
1044    /// ```
1045    RemoveParticipantsIq, action = "remove", response = Vec<ParticipantChangeResponse>
1046);
1047
1048define_group_participant_iq!(
1049    /// IQ specification for promoting participants to admin.
1050    ///
1051    /// Wire format:
1052    /// ```xml
1053    /// <iq type="set" xmlns="w:g2" to="{group_jid}">
1054    ///   <promote><participant jid="{user_jid}"/></promote>
1055    /// </iq>
1056    /// ```
1057    PromoteParticipantsIq, action = "promote", response = ()
1058);
1059
1060define_group_participant_iq!(
1061    /// IQ specification for demoting participants from admin.
1062    ///
1063    /// Wire format:
1064    /// ```xml
1065    /// <iq type="set" xmlns="w:g2" to="{group_jid}">
1066    ///   <demote><participant jid="{user_jid}"/></demote>
1067    /// </iq>
1068    /// ```
1069    DemoteParticipantsIq, action = "demote", response = ()
1070);
1071
1072/// IQ specification for getting (or resetting) a group's invite link.
1073///
1074/// - `reset: false` (GET) fetches the existing link.
1075/// - `reset: true` (SET) revokes the old link and generates a new one.
1076///
1077/// Response: `<invite code="XXXX"/>`
1078#[derive(Debug, Clone)]
1079pub struct GetGroupInviteLinkIq {
1080    pub group_jid: Jid,
1081    pub reset: bool,
1082}
1083
1084impl GetGroupInviteLinkIq {
1085    pub fn new(group_jid: &Jid, reset: bool) -> Self {
1086        Self {
1087            group_jid: group_jid.clone(),
1088            reset,
1089        }
1090    }
1091}
1092
1093impl IqSpec for GetGroupInviteLinkIq {
1094    type Response = String;
1095
1096    fn build_iq(&self) -> InfoQuery<'static> {
1097        let content = Some(NodeContent::Nodes(vec![NodeBuilder::new("invite").build()]));
1098        if self.reset {
1099            InfoQuery::set_ref(GROUP_IQ_NAMESPACE, &self.group_jid, content)
1100        } else {
1101            InfoQuery::get_ref(GROUP_IQ_NAMESPACE, &self.group_jid, content)
1102        }
1103    }
1104
1105    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1106        let invite_node = required_child(response, "invite")?;
1107        let code = required_attr(invite_node, "code")?;
1108        Ok(format!("https://chat.whatsapp.com/{code}"))
1109    }
1110}
1111
1112// ---------------------------------------------------------------------------
1113// Group property setters (SetProperty RPC)
1114// ---------------------------------------------------------------------------
1115
1116/// IQ specification for locking or unlocking a group (only admins can change group info).
1117///
1118/// Wire format:
1119///  - Lock group:
1120/// ```xml
1121/// <iq type="set" xmlns="w:g2" to="{group_jid}">
1122///   <locked/>
1123/// </iq>
1124/// ```
1125///  - Unlock group:
1126/// ```xml
1127/// <iq type="set" xmlns="w:g2" to="{group_jid}">
1128///   <unlocked/>
1129/// </iq>
1130/// ```
1131#[derive(Debug, Clone)]
1132pub struct SetGroupLockedIq {
1133    pub group_jid: Jid,
1134    pub locked: bool,
1135}
1136
1137impl SetGroupLockedIq {
1138    pub fn lock(group_jid: &Jid) -> Self {
1139        Self {
1140            group_jid: group_jid.clone(),
1141            locked: true,
1142        }
1143    }
1144
1145    pub fn unlock(group_jid: &Jid) -> Self {
1146        Self {
1147            group_jid: group_jid.clone(),
1148            locked: false,
1149        }
1150    }
1151}
1152
1153impl IqSpec for SetGroupLockedIq {
1154    type Response = ();
1155
1156    fn build_iq(&self) -> InfoQuery<'static> {
1157        let tag = if self.locked { "locked" } else { "unlocked" };
1158        InfoQuery::set_ref(
1159            GROUP_IQ_NAMESPACE,
1160            &self.group_jid,
1161            Some(NodeContent::Nodes(vec![NodeBuilder::new(tag).build()])),
1162        )
1163    }
1164
1165    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1166        Ok(())
1167    }
1168}
1169
1170/// IQ specification for setting announcement mode (only admins can send messages).
1171///
1172/// Wire format:
1173/// ```xml
1174/// <iq type="set" xmlns="w:g2" to="{group_jid}">
1175///   <announcement/>
1176///   <!-- or -->
1177///   <not_announcement/>
1178/// </iq>
1179/// ```
1180#[derive(Debug, Clone)]
1181pub struct SetGroupAnnouncementIq {
1182    pub group_jid: Jid,
1183    pub announce: bool,
1184}
1185
1186impl SetGroupAnnouncementIq {
1187    pub fn announce(group_jid: &Jid) -> Self {
1188        Self {
1189            group_jid: group_jid.clone(),
1190            announce: true,
1191        }
1192    }
1193
1194    pub fn unannounce(group_jid: &Jid) -> Self {
1195        Self {
1196            group_jid: group_jid.clone(),
1197            announce: false,
1198        }
1199    }
1200}
1201
1202impl IqSpec for SetGroupAnnouncementIq {
1203    type Response = ();
1204
1205    fn build_iq(&self) -> InfoQuery<'static> {
1206        let tag = if self.announce {
1207            "announcement"
1208        } else {
1209            "not_announcement"
1210        };
1211        InfoQuery::set_ref(
1212            GROUP_IQ_NAMESPACE,
1213            &self.group_jid,
1214            Some(NodeContent::Nodes(vec![NodeBuilder::new(tag).build()])),
1215        )
1216    }
1217
1218    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1219        Ok(())
1220    }
1221}
1222
1223/// IQ specification for setting ephemeral (disappearing) messages on a group.
1224///
1225/// Wire format:
1226/// ```xml
1227/// <iq type="set" xmlns="w:g2" to="{group_jid}">
1228///   <ephemeral expiration="86400"/>
1229///   <!-- or to disable: -->
1230///   <not_ephemeral/>
1231/// </iq>
1232/// ```
1233///
1234/// Common expiration values (seconds):
1235/// - 86400 (24 hours)
1236/// - 604800 (7 days)
1237/// - 7776000 (90 days)
1238/// - 0 or `not_ephemeral` to disable
1239#[derive(Debug, Clone)]
1240pub struct SetGroupEphemeralIq {
1241    pub group_jid: Jid,
1242    /// Expiration in seconds. `None` means disable.
1243    pub expiration: Option<NonZeroU32>,
1244}
1245
1246impl SetGroupEphemeralIq {
1247    /// Enable ephemeral messages with the given expiration in seconds.
1248    pub fn enable(group_jid: &Jid, expiration: NonZeroU32) -> Self {
1249        Self {
1250            group_jid: group_jid.clone(),
1251            expiration: Some(expiration),
1252        }
1253    }
1254
1255    /// Disable ephemeral messages.
1256    pub fn disable(group_jid: &Jid) -> Self {
1257        Self {
1258            group_jid: group_jid.clone(),
1259            expiration: None,
1260        }
1261    }
1262}
1263
1264impl IqSpec for SetGroupEphemeralIq {
1265    type Response = ();
1266
1267    fn build_iq(&self) -> InfoQuery<'static> {
1268        let node = match self.expiration {
1269            Some(exp) => NodeBuilder::new("ephemeral")
1270                .attr("expiration", exp.to_string())
1271                .build(),
1272            None => NodeBuilder::new("not_ephemeral").build(),
1273        };
1274        InfoQuery::set_ref(
1275            GROUP_IQ_NAMESPACE,
1276            &self.group_jid,
1277            Some(NodeContent::Nodes(vec![node])),
1278        )
1279    }
1280
1281    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1282        Ok(())
1283    }
1284}
1285
1286/// IQ specification for setting the membership approval mode on a group.
1287///
1288/// When enabled, new members must be approved by an admin before joining.
1289///
1290/// Wire format:
1291/// ```xml
1292/// <iq type="set" xmlns="w:g2" to="{group_jid}">
1293///   <membership_approval_mode>
1294///     <group_join state="on"/>
1295///   </membership_approval_mode>
1296/// </iq>
1297/// ```
1298#[derive(Debug, Clone)]
1299pub struct SetGroupMembershipApprovalIq {
1300    pub group_jid: Jid,
1301    pub mode: MembershipApprovalMode,
1302}
1303
1304impl SetGroupMembershipApprovalIq {
1305    pub fn new(group_jid: &Jid, mode: MembershipApprovalMode) -> Self {
1306        Self {
1307            group_jid: group_jid.clone(),
1308            mode,
1309        }
1310    }
1311}
1312
1313impl IqSpec for SetGroupMembershipApprovalIq {
1314    type Response = ();
1315
1316    fn build_iq(&self) -> InfoQuery<'static> {
1317        let node = NodeBuilder::new("membership_approval_mode")
1318            .children([NodeBuilder::new("group_join")
1319                .attr("state", self.mode.as_str())
1320                .build()])
1321            .build();
1322        InfoQuery::set_ref(
1323            GROUP_IQ_NAMESPACE,
1324            &self.group_jid,
1325            Some(NodeContent::Nodes(vec![node])),
1326        )
1327    }
1328
1329    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1330        Ok(())
1331    }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use super::*;
1337    use crate::request::InfoQueryType;
1338
1339    #[test]
1340    fn test_group_subject_validation() {
1341        let subject = GroupSubject::new("Test Group").unwrap();
1342        assert_eq!(subject.as_str(), "Test Group");
1343
1344        let at_limit = "a".repeat(GROUP_SUBJECT_MAX_LENGTH);
1345        assert!(GroupSubject::new(&at_limit).is_ok());
1346
1347        let over_limit = "a".repeat(GROUP_SUBJECT_MAX_LENGTH + 1);
1348        assert!(GroupSubject::new(&over_limit).is_err());
1349    }
1350
1351    #[test]
1352    fn test_group_description_validation() {
1353        let desc = GroupDescription::new("Test Description").unwrap();
1354        assert_eq!(desc.as_str(), "Test Description");
1355
1356        let at_limit = "a".repeat(GROUP_DESCRIPTION_MAX_LENGTH);
1357        assert!(GroupDescription::new(&at_limit).is_ok());
1358
1359        let over_limit = "a".repeat(GROUP_DESCRIPTION_MAX_LENGTH + 1);
1360        assert!(GroupDescription::new(&over_limit).is_err());
1361    }
1362
1363    #[test]
1364    fn test_string_enum_member_add_mode() {
1365        assert_eq!(MemberAddMode::AdminAdd.as_str(), "admin_add");
1366        assert_eq!(MemberAddMode::AllMemberAdd.as_str(), "all_member_add");
1367        assert_eq!(
1368            MemberAddMode::try_from("admin_add").unwrap(),
1369            MemberAddMode::AdminAdd
1370        );
1371        assert!(MemberAddMode::try_from("invalid").is_err());
1372    }
1373
1374    #[test]
1375    fn test_string_enum_member_link_mode() {
1376        assert_eq!(MemberLinkMode::AdminLink.as_str(), "admin_link");
1377        assert_eq!(MemberLinkMode::AllMemberLink.as_str(), "all_member_link");
1378        assert_eq!(
1379            MemberLinkMode::try_from("admin_link").unwrap(),
1380            MemberLinkMode::AdminLink
1381        );
1382    }
1383
1384    #[test]
1385    fn test_participant_type_is_admin() {
1386        assert!(!ParticipantType::Member.is_admin());
1387        assert!(ParticipantType::Admin.is_admin());
1388        assert!(ParticipantType::SuperAdmin.is_admin());
1389    }
1390
1391    #[test]
1392    fn test_normalize_participants_drops_phone_for_pn() {
1393        let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap();
1394        let lid_jid: Jid = "100000000000001@lid".parse().unwrap();
1395        let phone_jid: Jid = "15550000001@s.whatsapp.net".parse().unwrap();
1396
1397        let participants = vec![
1398            GroupParticipantOptions::new(pn_jid.clone()).with_phone_number(phone_jid.clone()),
1399            GroupParticipantOptions::new(lid_jid.clone()).with_phone_number(phone_jid.clone()),
1400        ];
1401
1402        let normalized = normalize_participants(&participants);
1403        assert!(normalized[0].phone_number.is_none());
1404        assert_eq!(normalized[0].jid, pn_jid);
1405        assert_eq!(normalized[1].phone_number.as_ref(), Some(&phone_jid));
1406    }
1407
1408    #[test]
1409    fn test_build_create_group_node() {
1410        let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap();
1411        let options = GroupCreateOptions::new("Test Subject")
1412            .with_participant(GroupParticipantOptions::from_phone(pn_jid))
1413            .with_member_link_mode(MemberLinkMode::AllMemberLink)
1414            .with_member_add_mode(MemberAddMode::AdminAdd);
1415
1416        let node = build_create_group_node(&options);
1417        assert_eq!(node.tag, "create");
1418        assert_eq!(
1419            node.attrs().optional_string("subject"),
1420            Some("Test Subject")
1421        );
1422
1423        let link_mode = node.get_children_by_tag("member_link_mode").next().unwrap();
1424        assert_eq!(
1425            link_mode.content.as_ref().and_then(|c| match c {
1426                NodeContent::String(s) => Some(s.as_str()),
1427                _ => None,
1428            }),
1429            Some("all_member_link")
1430        );
1431    }
1432
1433    #[test]
1434    fn test_typed_builder() {
1435        let options: GroupCreateOptions = GroupCreateOptions::builder()
1436            .subject("My Group")
1437            .member_add_mode(MemberAddMode::AdminAdd)
1438            .build();
1439
1440        assert_eq!(options.subject, "My Group");
1441        assert_eq!(options.member_add_mode, Some(MemberAddMode::AdminAdd));
1442    }
1443
1444    #[test]
1445    fn test_set_group_description_with_id_and_prev() {
1446        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
1447        let desc = GroupDescription::new("New description").unwrap();
1448        let spec = SetGroupDescriptionIq::new(&jid, Some(desc), Some("AABBCCDD".to_string()));
1449        let iq = spec.build_iq();
1450
1451        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1452            let desc_node = &nodes[0];
1453            assert_eq!(desc_node.tag, "description");
1454            // id is random hex, just check it exists and is 8 chars
1455            let id = desc_node.attrs().optional_string("id").unwrap();
1456            assert_eq!(id.len(), 8);
1457            assert_eq!(desc_node.attrs().optional_string("prev"), Some("AABBCCDD"));
1458            // Should have a <body> child
1459            assert!(desc_node.get_children_by_tag("body").next().is_some());
1460        } else {
1461            panic!("expected nodes content");
1462        }
1463    }
1464
1465    #[test]
1466    fn test_set_group_description_delete() {
1467        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
1468        let spec = SetGroupDescriptionIq::new(&jid, None, Some("PREV1234".to_string()));
1469        let iq = spec.build_iq();
1470
1471        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1472            let desc_node = &nodes[0];
1473            assert_eq!(desc_node.tag, "description");
1474            assert_eq!(desc_node.attrs().optional_string("delete"), Some("true"));
1475            assert_eq!(desc_node.attrs().optional_string("prev"), Some("PREV1234"));
1476            // id should still be present
1477            assert!(desc_node.attrs().optional_string("id").is_some());
1478        } else {
1479            panic!("expected nodes content");
1480        }
1481    }
1482
1483    #[test]
1484    fn test_leave_group_iq() {
1485        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
1486        let spec = LeaveGroupIq::new(&jid);
1487        let iq = spec.build_iq();
1488
1489        assert_eq!(iq.namespace, GROUP_IQ_NAMESPACE);
1490        assert_eq!(iq.query_type, InfoQueryType::Set);
1491        // Leave goes to g.us, not the group JID
1492        assert_eq!(iq.to.server, GROUP_SERVER);
1493    }
1494
1495    #[test]
1496    fn test_add_participants_iq() {
1497        let group: Jid = "120363000000000001@g.us".parse().unwrap();
1498        let p1: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
1499        let p2: Jid = "9876543210@s.whatsapp.net".parse().unwrap();
1500        let spec = AddParticipantsIq::new(&group, &[p1, p2]);
1501        let iq = spec.build_iq();
1502
1503        assert_eq!(iq.namespace, GROUP_IQ_NAMESPACE);
1504        assert_eq!(iq.to, group);
1505
1506        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1507            let add_node = &nodes[0];
1508            assert_eq!(add_node.tag, "add");
1509            let participants: Vec<_> = add_node.get_children_by_tag("participant").collect();
1510            assert_eq!(participants.len(), 2);
1511        } else {
1512            panic!("expected nodes content");
1513        }
1514    }
1515
1516    #[test]
1517    fn test_promote_demote_iq() {
1518        let group: Jid = "120363000000000001@g.us".parse().unwrap();
1519        let p1: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
1520
1521        let promote = PromoteParticipantsIq::new(&group, std::slice::from_ref(&p1));
1522        let iq = promote.build_iq();
1523        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1524            assert_eq!(nodes[0].tag, "promote");
1525        } else {
1526            panic!("expected nodes content");
1527        }
1528
1529        let demote = DemoteParticipantsIq::new(&group, &[p1]);
1530        let iq = demote.build_iq();
1531        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1532            assert_eq!(nodes[0].tag, "demote");
1533        } else {
1534            panic!("expected nodes content");
1535        }
1536    }
1537
1538    #[test]
1539    fn test_get_group_invite_link_iq() {
1540        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
1541        let spec = GetGroupInviteLinkIq::new(&jid, false);
1542        let iq = spec.build_iq();
1543
1544        assert_eq!(iq.query_type, InfoQueryType::Get);
1545        assert_eq!(iq.to, jid);
1546
1547        // With reset=true it should be a SET
1548        let reset_spec = GetGroupInviteLinkIq::new(&jid, true);
1549        assert_eq!(reset_spec.build_iq().query_type, InfoQueryType::Set);
1550    }
1551
1552    #[test]
1553    fn test_get_group_invite_link_parse_response() {
1554        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
1555        let spec = GetGroupInviteLinkIq::new(&jid, false);
1556
1557        let response = NodeBuilder::new("response")
1558            .children([NodeBuilder::new("invite")
1559                .attr("code", "AbCdEfGhIjKl")
1560                .build()])
1561            .build();
1562
1563        let result = spec.parse_response(&response).unwrap();
1564        assert_eq!(result, "https://chat.whatsapp.com/AbCdEfGhIjKl");
1565    }
1566
1567    #[test]
1568    fn test_participant_change_response_parse() {
1569        let node = NodeBuilder::new("participant")
1570            .attr("jid", "1234567890@s.whatsapp.net")
1571            .attr("type", "200")
1572            .build();
1573
1574        let result = ParticipantChangeResponse::try_from_node(&node).unwrap();
1575        assert_eq!(result.jid.user, "1234567890");
1576        assert_eq!(result.status, Some("200".to_string()));
1577    }
1578
1579    #[test]
1580    fn test_set_group_locked_iq() {
1581        let group: Jid = "120363000000000001@g.us".parse().unwrap();
1582
1583        let lock = SetGroupLockedIq::lock(&group);
1584        let iq = lock.build_iq();
1585        assert_eq!(iq.query_type, InfoQueryType::Set);
1586        assert_eq!(iq.to, group);
1587        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1588            assert_eq!(nodes[0].tag, "locked");
1589        } else {
1590            panic!("expected nodes content");
1591        }
1592
1593        let unlock = SetGroupLockedIq::unlock(&group);
1594        let iq = unlock.build_iq();
1595        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1596            assert_eq!(nodes[0].tag, "unlocked");
1597        } else {
1598            panic!("expected nodes content");
1599        }
1600    }
1601
1602    #[test]
1603    fn test_set_group_announcement_iq() {
1604        let group: Jid = "120363000000000001@g.us".parse().unwrap();
1605
1606        let announce = SetGroupAnnouncementIq::announce(&group);
1607        let iq = announce.build_iq();
1608        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1609            assert_eq!(nodes[0].tag, "announcement");
1610        } else {
1611            panic!("expected nodes content");
1612        }
1613
1614        let not_announce = SetGroupAnnouncementIq::unannounce(&group);
1615        let iq = not_announce.build_iq();
1616        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1617            assert_eq!(nodes[0].tag, "not_announcement");
1618        } else {
1619            panic!("expected nodes content");
1620        }
1621    }
1622
1623    #[test]
1624    fn test_set_group_ephemeral_iq() {
1625        let group: Jid = "120363000000000001@g.us".parse().unwrap();
1626
1627        let enable = SetGroupEphemeralIq::enable(&group, NonZeroU32::new(86400).unwrap());
1628        let iq = enable.build_iq();
1629        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1630            assert_eq!(nodes[0].tag, "ephemeral");
1631            assert_eq!(
1632                nodes[0].attrs().optional_string("expiration"),
1633                Some("86400")
1634            );
1635        } else {
1636            panic!("expected nodes content");
1637        }
1638
1639        let disable = SetGroupEphemeralIq::disable(&group);
1640        let iq = disable.build_iq();
1641        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1642            assert_eq!(nodes[0].tag, "not_ephemeral");
1643        } else {
1644            panic!("expected nodes content");
1645        }
1646    }
1647
1648    #[test]
1649    fn test_set_group_membership_approval_iq() {
1650        let group: Jid = "120363000000000001@g.us".parse().unwrap();
1651
1652        let spec = SetGroupMembershipApprovalIq::new(&group, MembershipApprovalMode::On);
1653        let iq = spec.build_iq();
1654        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1655            assert_eq!(nodes[0].tag, "membership_approval_mode");
1656            let join = nodes[0].get_children_by_tag("group_join").next().unwrap();
1657            assert_eq!(join.attrs().optional_string("state"), Some("on"));
1658        } else {
1659            panic!("expected nodes content");
1660        }
1661    }
1662}