Skip to main content

wacore/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::builder::NodeBuilder;
10use wacore_binary::jid::{GROUP_SERVER, Jid};
11use wacore_binary::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    /// Create as a community (parent group). Emits `<parent/>` in the create stanza.
161    #[builder(default)]
162    pub is_parent: bool,
163    /// Whether the community is closed (requires approval to join).
164    /// Only used when `is_parent` is true.
165    #[builder(default)]
166    pub closed: bool,
167    /// Allow non-admin members to create subgroups.
168    /// Only used when `is_parent` is true.
169    #[builder(default)]
170    pub allow_non_admin_sub_group_creation: bool,
171    /// Create a general chat subgroup alongside the community.
172    /// Only used when `is_parent` is true.
173    #[builder(default)]
174    pub create_general_chat: bool,
175}
176
177impl GroupCreateOptions {
178    /// Create new options with just a subject (for backwards compatibility).
179    pub fn new(subject: impl Into<String>) -> Self {
180        Self {
181            subject: subject.into(),
182            ..Default::default()
183        }
184    }
185
186    pub fn with_participant(mut self, participant: GroupParticipantOptions) -> Self {
187        self.participants.push(participant);
188        self
189    }
190
191    pub fn with_participants(mut self, participants: Vec<GroupParticipantOptions>) -> Self {
192        self.participants = participants;
193        self
194    }
195
196    pub fn with_member_link_mode(mut self, mode: MemberLinkMode) -> Self {
197        self.member_link_mode = Some(mode);
198        self
199    }
200
201    pub fn with_member_add_mode(mut self, mode: MemberAddMode) -> Self {
202        self.member_add_mode = Some(mode);
203        self
204    }
205
206    pub fn with_membership_approval_mode(mut self, mode: MembershipApprovalMode) -> Self {
207        self.membership_approval_mode = Some(mode);
208        self
209    }
210
211    pub fn with_ephemeral_expiration(mut self, expiration: u32) -> Self {
212        self.ephemeral_expiration = Some(expiration);
213        self
214    }
215}
216
217impl Default for GroupCreateOptions {
218    fn default() -> Self {
219        Self {
220            subject: String::new(),
221            participants: Vec::new(),
222            member_link_mode: Some(MemberLinkMode::AdminLink),
223            member_add_mode: Some(MemberAddMode::AllMemberAdd),
224            membership_approval_mode: Some(MembershipApprovalMode::Off),
225            ephemeral_expiration: Some(0),
226            is_parent: false,
227            closed: false,
228            allow_non_admin_sub_group_creation: false,
229            create_general_chat: false,
230        }
231    }
232}
233
234/// Normalize participants: drop phone_number for non-LID JIDs.
235pub fn normalize_participants(
236    participants: &[GroupParticipantOptions],
237) -> Vec<GroupParticipantOptions> {
238    participants
239        .iter()
240        .cloned()
241        .map(|p| {
242            if !p.jid.is_lid() && p.phone_number.is_some() {
243                GroupParticipantOptions {
244                    phone_number: None,
245                    ..p
246                }
247            } else {
248                p
249            }
250        })
251        .collect()
252}
253
254/// Build the `<create>` node for group creation.
255pub fn build_create_group_node(options: &GroupCreateOptions) -> Node {
256    let mut children = Vec::new();
257
258    if let Some(link_mode) = &options.member_link_mode {
259        children.push(
260            NodeBuilder::new("member_link_mode")
261                .string_content(link_mode.as_str())
262                .build(),
263        );
264    }
265
266    if let Some(add_mode) = &options.member_add_mode {
267        children.push(
268            NodeBuilder::new("member_add_mode")
269                .string_content(add_mode.as_str())
270                .build(),
271        );
272    }
273
274    // Normalize participants to avoid sending phone_number for non-LID JIDs
275    let participants = normalize_participants(&options.participants);
276
277    for participant in &participants {
278        let mut attrs = vec![("jid", participant.jid.to_string())];
279        if let Some(pn) = &participant.phone_number {
280            attrs.push(("phone_number", pn.to_string()));
281        }
282
283        let participant_node = if let Some(privacy_bytes) = &participant.privacy {
284            NodeBuilder::new("participant")
285                .attrs(attrs)
286                .children([NodeBuilder::new("privacy")
287                    .string_content(hex::encode(privacy_bytes))
288                    .build()])
289                .build()
290        } else {
291            NodeBuilder::new("participant").attrs(attrs).build()
292        };
293        children.push(participant_node);
294    }
295
296    if let Some(expiration) = &options.ephemeral_expiration {
297        children.push(
298            NodeBuilder::new("ephemeral")
299                .attr("expiration", expiration.to_string())
300                .build(),
301        );
302    }
303
304    if let Some(approval_mode) = &options.membership_approval_mode {
305        children.push(
306            NodeBuilder::new("membership_approval_mode")
307                .children([NodeBuilder::new("group_join")
308                    .attr("state", approval_mode.as_str())
309                    .build()])
310                .build(),
311        );
312    }
313
314    // Community (parent group) fields
315    if options.is_parent {
316        let mut parent_builder = NodeBuilder::new("parent");
317        if options.closed {
318            parent_builder =
319                parent_builder.attr("default_membership_approval_mode", "request_required");
320        }
321        children.push(parent_builder.build());
322
323        if options.allow_non_admin_sub_group_creation {
324            children.push(NodeBuilder::new("allow_non_admin_sub_group_creation").build());
325        }
326        if options.create_general_chat {
327            children.push(NodeBuilder::new("create_general_chat").build());
328        }
329    }
330
331    NodeBuilder::new("create")
332        .attr("subject", &options.subject)
333        .children(children)
334        .build()
335}
336/// Request to query group information.
337///
338/// Wire format: `<query request="interactive"/>`
339#[derive(Debug, Clone, crate::ProtocolNode)]
340#[protocol(tag = "query")]
341pub struct GroupQueryRequest {
342    #[attr(name = "request", string_enum)]
343    pub request: GroupQueryRequestType,
344}
345
346/// A participant in a group response.
347#[derive(Debug, Clone)]
348pub struct GroupParticipantResponse {
349    pub jid: Jid,
350    pub phone_number: Option<Jid>,
351    pub participant_type: ParticipantType,
352}
353
354impl ProtocolNode for GroupParticipantResponse {
355    fn tag(&self) -> &'static str {
356        "participant"
357    }
358
359    fn into_node(self) -> Node {
360        let mut builder = NodeBuilder::new("participant").attr("jid", self.jid);
361        if let Some(pn) = self.phone_number {
362            builder = builder.attr("phone_number", pn);
363        }
364        if self.participant_type != ParticipantType::Member {
365            builder = builder.attr("type", self.participant_type.as_str());
366        }
367        builder.build()
368    }
369
370    fn try_from_node(node: &Node) -> Result<Self> {
371        if node.tag != "participant" {
372            return Err(anyhow!("expected <participant>, got <{}>", node.tag));
373        }
374        let jid = node
375            .attrs()
376            .optional_jid("jid")
377            .ok_or_else(|| anyhow!("participant missing required 'jid' attribute"))?;
378        let phone_number = node.attrs().optional_jid("phone_number");
379        // Default to Member for unknown participant types to avoid failing the whole group parse
380        let participant_type = node
381            .attrs()
382            .optional_string("type")
383            .and_then(|s| ParticipantType::try_from(s.as_ref()).ok())
384            .unwrap_or(ParticipantType::Member);
385
386        Ok(Self {
387            jid,
388            phone_number,
389            participant_type,
390        })
391    }
392}
393
394/// Response from a group info query.
395#[derive(Debug, Clone)]
396pub struct GroupInfoResponse {
397    pub id: Jid,
398    pub subject: GroupSubject,
399    pub addressing_mode: AddressingMode,
400    pub participants: Vec<GroupParticipantResponse>,
401    /// Group creator JID (from `creator` attribute).
402    pub creator: Option<Jid>,
403    /// Group creation timestamp (from `creation` attribute).
404    pub creation_time: Option<u64>,
405    /// Subject modification timestamp (from `s_t` attribute).
406    pub subject_time: Option<u64>,
407    /// Subject owner JID (from `s_o` attribute).
408    pub subject_owner: Option<Jid>,
409    /// Group description body text.
410    pub description: Option<String>,
411    /// Description ID (for conflict detection when updating).
412    pub description_id: Option<String>,
413    /// Whether the group is locked (only admins can edit group info).
414    pub is_locked: bool,
415    /// Whether announcement mode is enabled (only admins can send messages).
416    pub is_announcement: bool,
417    /// Ephemeral message expiration in seconds (0 = disabled).
418    pub ephemeral_expiration: u32,
419    /// Whether membership approval is required to join.
420    pub membership_approval: bool,
421    /// Who can add members to the group.
422    pub member_add_mode: Option<MemberAddMode>,
423    /// Who can use invite links.
424    pub member_link_mode: Option<MemberLinkMode>,
425    /// Total participant count (from `size` attribute, useful for large groups).
426    pub size: Option<u32>,
427    /// Whether this group is a community parent group (has `<parent>` child).
428    pub is_parent_group: bool,
429    /// JID of the parent community (for subgroups, from `<linked_parent jid="..."/>`).
430    pub parent_group_jid: Option<Jid>,
431    /// Whether this is the default announcement subgroup of a community.
432    pub is_default_sub_group: bool,
433    /// Whether this is the general chat subgroup of a community.
434    pub is_general_chat: bool,
435    /// Whether non-admin community members can create subgroups.
436    pub allow_non_admin_sub_group_creation: bool,
437}
438
439impl ProtocolNode for GroupInfoResponse {
440    fn tag(&self) -> &'static str {
441        "group"
442    }
443
444    fn into_node(self) -> Node {
445        let mut children: Vec<Node> = self
446            .participants
447            .into_iter()
448            .map(|p| p.into_node())
449            .collect();
450
451        if self.is_locked {
452            children.push(NodeBuilder::new("locked").build());
453        }
454        if self.is_announcement {
455            children.push(NodeBuilder::new("announcement").build());
456        }
457        if self.ephemeral_expiration > 0 {
458            children.push(
459                NodeBuilder::new("ephemeral")
460                    .attr("expiration", self.ephemeral_expiration.to_string())
461                    .build(),
462            );
463        }
464        if self.membership_approval {
465            children.push(
466                NodeBuilder::new("membership_approval_mode")
467                    .children(vec![
468                        NodeBuilder::new("group_join").attr("state", "on").build(),
469                    ])
470                    .build(),
471            );
472        }
473        if let Some(ref add_mode) = self.member_add_mode {
474            children.push(
475                NodeBuilder::new("member_add_mode")
476                    .string_content(add_mode.as_str())
477                    .build(),
478            );
479        }
480        if let Some(ref link_mode) = self.member_link_mode {
481            children.push(
482                NodeBuilder::new("member_link_mode")
483                    .string_content(link_mode.as_str())
484                    .build(),
485            );
486        }
487        if let Some(ref desc) = self.description {
488            let mut desc_builder = NodeBuilder::new("description");
489            if let Some(ref desc_id) = self.description_id {
490                desc_builder = desc_builder.attr("id", desc_id.as_str());
491            }
492            children.push(desc_builder.string_content(desc.as_str()).build());
493        }
494
495        // Community fields
496        if self.is_parent_group {
497            children.push(NodeBuilder::new("parent").build());
498        }
499        if let Some(ref parent_jid) = self.parent_group_jid {
500            children.push(
501                NodeBuilder::new("linked_parent")
502                    .attr("jid", parent_jid.clone())
503                    .build(),
504            );
505        }
506        if self.is_default_sub_group {
507            children.push(NodeBuilder::new("default_sub_group").build());
508        }
509        if self.is_general_chat {
510            children.push(NodeBuilder::new("general_chat").build());
511        }
512        if self.allow_non_admin_sub_group_creation {
513            children.push(NodeBuilder::new("allow_non_admin_sub_group_creation").build());
514        }
515
516        let mut builder = NodeBuilder::new("group")
517            .attr("id", self.id)
518            .attr("subject", self.subject.as_str())
519            .attr("addressing_mode", self.addressing_mode.as_str());
520
521        if let Some(creator) = self.creator {
522            builder = builder.attr("creator", creator);
523        }
524        if let Some(creation_time) = self.creation_time {
525            builder = builder.attr("creation", creation_time.to_string());
526        }
527        if let Some(subject_time) = self.subject_time {
528            builder = builder.attr("s_t", subject_time.to_string());
529        }
530        if let Some(subject_owner) = self.subject_owner {
531            builder = builder.attr("s_o", subject_owner);
532        }
533        if let Some(size) = self.size {
534            builder = builder.attr("size", size.to_string());
535        }
536
537        builder.children(children).build()
538    }
539
540    fn try_from_node(node: &Node) -> Result<Self> {
541        if node.tag != "group" {
542            return Err(anyhow!("expected <group>, got <{}>", node.tag));
543        }
544
545        let id_str = required_attr(node, "id")?;
546        let id = if id_str.contains('@') {
547            id_str.parse()?
548        } else {
549            Jid::group(id_str)
550        };
551
552        let subject = GroupSubject::new_unchecked(
553            optional_attr(node, "subject")
554                .as_deref()
555                .unwrap_or_default(),
556        );
557
558        let addressing_mode = AddressingMode::try_from(
559            optional_attr(node, "addressing_mode")
560                .as_deref()
561                .unwrap_or("pn"),
562        )?;
563
564        let participants = collect_children::<GroupParticipantResponse>(node, "participant")?;
565
566        // Parse attributes
567        let creator = node
568            .attrs()
569            .optional_string("creator")
570            .and_then(|s| s.parse::<Jid>().ok());
571        let creation_time = node
572            .attrs()
573            .optional_string("creation")
574            .and_then(|s| s.parse::<u64>().ok());
575        let subject_time = node
576            .attrs()
577            .optional_string("s_t")
578            .and_then(|s| s.parse::<u64>().ok());
579        let subject_owner = node
580            .attrs()
581            .optional_string("s_o")
582            .and_then(|s| s.parse::<Jid>().ok());
583        let size = node
584            .attrs()
585            .optional_string("size")
586            .and_then(|s| s.parse::<u32>().ok());
587
588        // Parse settings from child nodes
589        let is_locked = node.get_optional_child_by_tag(&["locked"]).is_some();
590        let is_announcement = node.get_optional_child_by_tag(&["announcement"]).is_some();
591
592        let ephemeral_expiration = node
593            .get_optional_child_by_tag(&["ephemeral"])
594            .and_then(|n| n.attrs().optional_string("expiration"))
595            .and_then(|s| s.parse::<u32>().ok())
596            .unwrap_or(0);
597
598        let membership_approval = node
599            .get_optional_child_by_tag(&["membership_approval_mode", "group_join"])
600            .and_then(|n| n.attrs().optional_string("state"))
601            .is_some_and(|s| s == "on");
602
603        let member_add_mode = node
604            .get_optional_child_by_tag(&["member_add_mode"])
605            .and_then(|n| match &n.content {
606                Some(NodeContent::String(s)) => MemberAddMode::try_from(s.as_str()).ok(),
607                _ => None,
608            });
609
610        let member_link_mode = node
611            .get_optional_child_by_tag(&["member_link_mode"])
612            .and_then(|n| match &n.content {
613                Some(NodeContent::String(s)) => MemberLinkMode::try_from(s.as_str()).ok(),
614                _ => None,
615            });
616
617        // Parse description
618        let description_node = node.get_optional_child_by_tag(&["description"]);
619        let description = description_node.and_then(|n| match &n.content {
620            Some(NodeContent::String(s)) => Some(s.clone()),
621            _ => None,
622        });
623        let description_id = description_node
624            .and_then(|n| n.attrs().optional_string("id"))
625            .map(|s| s.to_string());
626
627        // Parse community fields
628        let is_parent_group = node.get_optional_child_by_tag(&["parent"]).is_some();
629        let parent_group_jid = node
630            .get_optional_child_by_tag(&["linked_parent"])
631            .and_then(|n| n.attrs().optional_jid("jid"));
632        let is_default_sub_group = node
633            .get_optional_child_by_tag(&["default_sub_group"])
634            .is_some();
635        let is_general_chat = node.get_optional_child_by_tag(&["general_chat"]).is_some();
636        let allow_non_admin_sub_group_creation = node
637            .get_optional_child_by_tag(&["allow_non_admin_sub_group_creation"])
638            .is_some();
639
640        Ok(Self {
641            id,
642            subject,
643            addressing_mode,
644            participants,
645            creator,
646            creation_time,
647            subject_time,
648            subject_owner,
649            description,
650            description_id,
651            is_locked,
652            is_announcement,
653            ephemeral_expiration,
654            membership_approval,
655            member_add_mode,
656            member_link_mode,
657            size,
658            is_parent_group,
659            parent_group_jid,
660            is_default_sub_group,
661            is_general_chat,
662            allow_non_admin_sub_group_creation,
663        })
664    }
665}
666/// Request to get all groups the user is participating in.
667#[derive(Debug, Clone)]
668pub struct GroupParticipatingRequest {
669    pub include_participants: bool,
670    pub include_description: bool,
671}
672
673impl GroupParticipatingRequest {
674    pub fn new() -> Self {
675        Self {
676            include_participants: true,
677            include_description: true,
678        }
679    }
680}
681
682impl Default for GroupParticipatingRequest {
683    fn default() -> Self {
684        Self::new()
685    }
686}
687
688impl ProtocolNode for GroupParticipatingRequest {
689    fn tag(&self) -> &'static str {
690        "participating"
691    }
692
693    fn into_node(self) -> Node {
694        let mut children = Vec::new();
695        if self.include_participants {
696            children.push(NodeBuilder::new("participants").build());
697        }
698        if self.include_description {
699            children.push(NodeBuilder::new("description").build());
700        }
701        NodeBuilder::new("participating").children(children).build()
702    }
703
704    fn try_from_node(node: &Node) -> Result<Self> {
705        if node.tag != "participating" {
706            return Err(anyhow!("expected <participating>, got <{}>", node.tag));
707        }
708        Ok(Self::default())
709    }
710}
711
712/// Response containing all groups the user is participating in.
713#[derive(Debug, Clone, Default)]
714pub struct GroupParticipatingResponse {
715    pub groups: Vec<GroupInfoResponse>,
716}
717
718impl ProtocolNode for GroupParticipatingResponse {
719    fn tag(&self) -> &'static str {
720        "groups"
721    }
722
723    fn into_node(self) -> Node {
724        let children: Vec<Node> = self.groups.into_iter().map(|g| g.into_node()).collect();
725        NodeBuilder::new("groups").children(children).build()
726    }
727
728    fn try_from_node(node: &Node) -> Result<Self> {
729        if node.tag != "groups" {
730            return Err(anyhow!("expected <groups>, got <{}>", node.tag));
731        }
732
733        let groups = collect_children::<GroupInfoResponse>(node, "group")?;
734
735        Ok(Self { groups })
736    }
737}
738/// IQ specification for querying a specific group's info.
739#[derive(Debug, Clone)]
740pub struct GroupQueryIq {
741    pub group_jid: Jid,
742}
743
744impl GroupQueryIq {
745    pub fn new(group_jid: &Jid) -> Self {
746        Self {
747            group_jid: group_jid.clone(),
748        }
749    }
750}
751
752impl IqSpec for GroupQueryIq {
753    type Response = GroupInfoResponse;
754
755    fn build_iq(&self) -> InfoQuery<'static> {
756        InfoQuery::get_ref(
757            GROUP_IQ_NAMESPACE,
758            &self.group_jid,
759            Some(NodeContent::Nodes(vec![
760                GroupQueryRequest::default().into_node(),
761            ])),
762        )
763    }
764
765    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
766        let group_node = required_child(response, "group")?;
767        GroupInfoResponse::try_from_node(group_node)
768    }
769}
770
771/// IQ specification for getting all groups the user is participating in.
772#[derive(Debug, Clone, Default)]
773pub struct GroupParticipatingIq;
774
775impl GroupParticipatingIq {
776    pub fn new() -> Self {
777        Self
778    }
779}
780
781impl IqSpec for GroupParticipatingIq {
782    type Response = GroupParticipatingResponse;
783
784    fn build_iq(&self) -> InfoQuery<'static> {
785        InfoQuery::get(
786            GROUP_IQ_NAMESPACE,
787            Jid::new("", GROUP_SERVER),
788            Some(NodeContent::Nodes(vec![
789                GroupParticipatingRequest::new().into_node(),
790            ])),
791        )
792    }
793
794    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
795        let groups_node = required_child(response, "groups")?;
796        GroupParticipatingResponse::try_from_node(groups_node)
797    }
798}
799
800/// IQ specification for creating a new group.
801#[derive(Debug, Clone)]
802pub struct GroupCreateIq {
803    pub options: GroupCreateOptions,
804}
805
806impl GroupCreateIq {
807    pub fn new(options: GroupCreateOptions) -> Self {
808        Self { options }
809    }
810}
811
812impl IqSpec for GroupCreateIq {
813    type Response = Jid;
814
815    fn build_iq(&self) -> InfoQuery<'static> {
816        InfoQuery::set(
817            GROUP_IQ_NAMESPACE,
818            Jid::new("", GROUP_SERVER),
819            Some(NodeContent::Nodes(vec![build_create_group_node(
820                &self.options,
821            )])),
822        )
823    }
824
825    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
826        let group_node = required_child(response, "group")?;
827        let group_id_str = required_attr(group_node, "id")?;
828
829        if group_id_str.contains('@') {
830            group_id_str.parse().map_err(Into::into)
831        } else {
832            Ok(Jid::group(group_id_str))
833        }
834    }
835}
836
837// ---------------------------------------------------------------------------
838// Group Management IQ Specs
839// ---------------------------------------------------------------------------
840
841/// Response for participant change operations (add/remove/promote/demote).
842///
843/// Wire format: `<participant jid="..." type="200" error="..."/>`
844#[derive(Debug, Clone, crate::ProtocolNode)]
845#[protocol(tag = "participant")]
846pub struct ParticipantChangeResponse {
847    #[attr(name = "jid", jid)]
848    pub jid: Jid,
849    /// HTTP-like status code (e.g. 200, 403, 409).
850    #[attr(name = "type")]
851    pub status: Option<String>,
852    #[attr(name = "error")]
853    pub error: Option<String>,
854}
855
856/// IQ specification for setting a group's subject.
857///
858/// Wire format:
859/// ```xml
860/// <iq type="set" xmlns="w:g2" to="{group_jid}">
861///   <subject>{text}</subject>
862/// </iq>
863/// ```
864#[derive(Debug, Clone)]
865pub struct SetGroupSubjectIq {
866    pub group_jid: Jid,
867    pub subject: GroupSubject,
868}
869
870impl SetGroupSubjectIq {
871    pub fn new(group_jid: &Jid, subject: GroupSubject) -> Self {
872        Self {
873            group_jid: group_jid.clone(),
874            subject,
875        }
876    }
877}
878
879impl IqSpec for SetGroupSubjectIq {
880    type Response = ();
881
882    fn build_iq(&self) -> InfoQuery<'static> {
883        InfoQuery::set_ref(
884            GROUP_IQ_NAMESPACE,
885            &self.group_jid,
886            Some(NodeContent::Nodes(vec![
887                NodeBuilder::new("subject")
888                    .string_content(self.subject.as_str())
889                    .build(),
890            ])),
891        )
892    }
893
894    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
895        Ok(())
896    }
897}
898
899/// IQ specification for setting a group's description.
900///
901/// Wire format:
902/// ```xml
903/// <iq type="set" xmlns="w:g2" to="{group_jid}">
904///   <description id="{new_id}" prev="{prev_id}"><body>{text}</body></description>
905/// </iq>
906/// ```
907///
908/// - `id`: random 8-char hex, generated automatically.
909/// - `prev`: the current description ID (from group metadata), used for conflict detection.
910/// - To delete the description, pass `None` as the description.
911#[derive(Debug, Clone)]
912pub struct SetGroupDescriptionIq {
913    pub group_jid: Jid,
914    pub description: Option<GroupDescription>,
915    /// New description ID (random 8-char hex).
916    pub id: String,
917    /// Previous description ID from group metadata, for conflict detection.
918    pub prev: Option<String>,
919}
920
921impl SetGroupDescriptionIq {
922    pub fn new(
923        group_jid: &Jid,
924        description: Option<GroupDescription>,
925        prev: Option<String>,
926    ) -> Self {
927        use rand::RngExt;
928        let id = format!(
929            "{:08X}",
930            rand::make_rng::<rand::rngs::StdRng>().random::<u32>()
931        );
932        Self {
933            group_jid: group_jid.clone(),
934            description,
935            id,
936            prev,
937        }
938    }
939}
940
941impl IqSpec for SetGroupDescriptionIq {
942    type Response = ();
943
944    fn build_iq(&self) -> InfoQuery<'static> {
945        let desc_node = if let Some(ref desc) = self.description {
946            let mut builder = NodeBuilder::new("description").attr("id", &self.id);
947            if let Some(ref prev) = self.prev {
948                builder = builder.attr("prev", prev);
949            }
950            builder
951                .children([NodeBuilder::new("body")
952                    .string_content(desc.as_str())
953                    .build()])
954                .build()
955        } else {
956            let mut builder = NodeBuilder::new("description")
957                .attr("id", &self.id)
958                .attr("delete", "true");
959            if let Some(ref prev) = self.prev {
960                builder = builder.attr("prev", prev);
961            }
962            builder.build()
963        };
964
965        InfoQuery::set_ref(
966            GROUP_IQ_NAMESPACE,
967            &self.group_jid,
968            Some(NodeContent::Nodes(vec![desc_node])),
969        )
970    }
971
972    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
973        Ok(())
974    }
975}
976
977/// IQ specification for leaving a group.
978///
979/// Wire format:
980/// ```xml
981/// <iq type="set" xmlns="w:g2" to="g.us">
982///   <leave><group id="{group_jid}"/></leave>
983/// </iq>
984/// ```
985#[derive(Debug, Clone)]
986pub struct LeaveGroupIq {
987    pub group_jid: Jid,
988}
989
990impl LeaveGroupIq {
991    pub fn new(group_jid: &Jid) -> Self {
992        Self {
993            group_jid: group_jid.clone(),
994        }
995    }
996}
997
998impl IqSpec for LeaveGroupIq {
999    type Response = ();
1000
1001    fn build_iq(&self) -> InfoQuery<'static> {
1002        let group_node = NodeBuilder::new("group")
1003            .attr("id", self.group_jid.clone())
1004            .build();
1005        let leave_node = NodeBuilder::new("leave").children([group_node]).build();
1006
1007        InfoQuery::set(
1008            GROUP_IQ_NAMESPACE,
1009            Jid::new("", GROUP_SERVER),
1010            Some(NodeContent::Nodes(vec![leave_node])),
1011        )
1012    }
1013
1014    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1015        Ok(())
1016    }
1017}
1018
1019/// Macro to generate group participant IQ specs that share the same structure:
1020/// a `set` IQ to `{group_jid}` with `<{action}><participant jid="..."/>...</{action}>`.
1021macro_rules! define_group_participant_iq {
1022    (
1023        $(#[$meta:meta])*
1024        $name:ident, action = $action:literal, response = Vec<ParticipantChangeResponse>
1025    ) => {
1026        $(#[$meta])*
1027        #[derive(Debug, Clone)]
1028        pub struct $name {
1029            pub group_jid: Jid,
1030            pub participants: Vec<Jid>,
1031        }
1032
1033        impl $name {
1034            pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
1035                Self {
1036                    group_jid: group_jid.clone(),
1037                    participants: participants.to_vec(),
1038                }
1039            }
1040        }
1041
1042        impl IqSpec for $name {
1043            type Response = Vec<ParticipantChangeResponse>;
1044
1045            fn build_iq(&self) -> InfoQuery<'static> {
1046                let children: Vec<Node> = self
1047                    .participants
1048                    .iter()
1049                    .map(|jid| {
1050                        NodeBuilder::new("participant")
1051                            .attr("jid", jid.clone())
1052                            .build()
1053                    })
1054                    .collect();
1055
1056                let action_node = NodeBuilder::new($action).children(children).build();
1057
1058                InfoQuery::set_ref(
1059                    GROUP_IQ_NAMESPACE,
1060                    &self.group_jid,
1061                    Some(NodeContent::Nodes(vec![action_node])),
1062                )
1063            }
1064
1065            fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1066                let action_node = required_child(response, $action)?;
1067                collect_children::<ParticipantChangeResponse>(action_node, "participant")
1068            }
1069        }
1070    };
1071    (
1072        $(#[$meta:meta])*
1073        $name:ident, action = $action:literal, response = ()
1074    ) => {
1075        $(#[$meta])*
1076        #[derive(Debug, Clone)]
1077        pub struct $name {
1078            pub group_jid: Jid,
1079            pub participants: Vec<Jid>,
1080        }
1081
1082        impl $name {
1083            pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
1084                Self {
1085                    group_jid: group_jid.clone(),
1086                    participants: participants.to_vec(),
1087                }
1088            }
1089        }
1090
1091        impl IqSpec for $name {
1092            type Response = ();
1093
1094            fn build_iq(&self) -> InfoQuery<'static> {
1095                let children: Vec<Node> = self
1096                    .participants
1097                    .iter()
1098                    .map(|jid| {
1099                        NodeBuilder::new("participant")
1100                            .attr("jid", jid.clone())
1101                            .build()
1102                    })
1103                    .collect();
1104
1105                let action_node = NodeBuilder::new($action).children(children).build();
1106
1107                InfoQuery::set_ref(
1108                    GROUP_IQ_NAMESPACE,
1109                    &self.group_jid,
1110                    Some(NodeContent::Nodes(vec![action_node])),
1111                )
1112            }
1113
1114            fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1115                Ok(())
1116            }
1117        }
1118    };
1119}
1120
1121define_group_participant_iq!(
1122    /// IQ specification for adding participants to a group.
1123    ///
1124    /// Wire format:
1125    /// ```xml
1126    /// <iq type="set" xmlns="w:g2" to="{group_jid}">
1127    ///   <add><participant jid="{user_jid}"/></add>
1128    /// </iq>
1129    /// ```
1130    AddParticipantsIq, action = "add", response = Vec<ParticipantChangeResponse>
1131);
1132
1133define_group_participant_iq!(
1134    /// IQ specification for removing participants from a group.
1135    ///
1136    /// Wire format:
1137    /// ```xml
1138    /// <iq type="set" xmlns="w:g2" to="{group_jid}">
1139    ///   <remove><participant jid="{user_jid}"/></remove>
1140    /// </iq>
1141    /// ```
1142    RemoveParticipantsIq, action = "remove", response = Vec<ParticipantChangeResponse>
1143);
1144
1145define_group_participant_iq!(
1146    /// IQ specification for promoting participants to admin.
1147    ///
1148    /// Wire format:
1149    /// ```xml
1150    /// <iq type="set" xmlns="w:g2" to="{group_jid}">
1151    ///   <promote><participant jid="{user_jid}"/></promote>
1152    /// </iq>
1153    /// ```
1154    PromoteParticipantsIq, action = "promote", response = ()
1155);
1156
1157define_group_participant_iq!(
1158    /// IQ specification for demoting participants from admin.
1159    ///
1160    /// Wire format:
1161    /// ```xml
1162    /// <iq type="set" xmlns="w:g2" to="{group_jid}">
1163    ///   <demote><participant jid="{user_jid}"/></demote>
1164    /// </iq>
1165    /// ```
1166    DemoteParticipantsIq, action = "demote", response = ()
1167);
1168
1169/// IQ specification for getting (or resetting) a group's invite link.
1170///
1171/// - `reset: false` (GET) fetches the existing link.
1172/// - `reset: true` (SET) revokes the old link and generates a new one.
1173///
1174/// Response: `<invite code="XXXX"/>`
1175#[derive(Debug, Clone)]
1176pub struct GetGroupInviteLinkIq {
1177    pub group_jid: Jid,
1178    pub reset: bool,
1179}
1180
1181impl GetGroupInviteLinkIq {
1182    pub fn new(group_jid: &Jid, reset: bool) -> Self {
1183        Self {
1184            group_jid: group_jid.clone(),
1185            reset,
1186        }
1187    }
1188}
1189
1190impl IqSpec for GetGroupInviteLinkIq {
1191    type Response = String;
1192
1193    fn build_iq(&self) -> InfoQuery<'static> {
1194        let content = Some(NodeContent::Nodes(vec![NodeBuilder::new("invite").build()]));
1195        if self.reset {
1196            InfoQuery::set_ref(GROUP_IQ_NAMESPACE, &self.group_jid, content)
1197        } else {
1198            InfoQuery::get_ref(GROUP_IQ_NAMESPACE, &self.group_jid, content)
1199        }
1200    }
1201
1202    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1203        let invite_node = required_child(response, "invite")?;
1204        let code = required_attr(invite_node, "code")?;
1205        Ok(format!("https://chat.whatsapp.com/{code}"))
1206    }
1207}
1208
1209// ---------------------------------------------------------------------------
1210// Group property setters (SetProperty RPC)
1211// ---------------------------------------------------------------------------
1212
1213/// IQ specification for locking or unlocking a group (only admins can change group info).
1214///
1215/// Wire format:
1216///  - Lock group:
1217/// ```xml
1218/// <iq type="set" xmlns="w:g2" to="{group_jid}">
1219///   <locked/>
1220/// </iq>
1221/// ```
1222///  - Unlock group:
1223/// ```xml
1224/// <iq type="set" xmlns="w:g2" to="{group_jid}">
1225///   <unlocked/>
1226/// </iq>
1227/// ```
1228#[derive(Debug, Clone)]
1229pub struct SetGroupLockedIq {
1230    pub group_jid: Jid,
1231    pub locked: bool,
1232}
1233
1234impl SetGroupLockedIq {
1235    pub fn lock(group_jid: &Jid) -> Self {
1236        Self {
1237            group_jid: group_jid.clone(),
1238            locked: true,
1239        }
1240    }
1241
1242    pub fn unlock(group_jid: &Jid) -> Self {
1243        Self {
1244            group_jid: group_jid.clone(),
1245            locked: false,
1246        }
1247    }
1248}
1249
1250impl IqSpec for SetGroupLockedIq {
1251    type Response = ();
1252
1253    fn build_iq(&self) -> InfoQuery<'static> {
1254        let tag = if self.locked { "locked" } else { "unlocked" };
1255        InfoQuery::set_ref(
1256            GROUP_IQ_NAMESPACE,
1257            &self.group_jid,
1258            Some(NodeContent::Nodes(vec![NodeBuilder::new(tag).build()])),
1259        )
1260    }
1261
1262    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1263        Ok(())
1264    }
1265}
1266
1267/// IQ specification for setting announcement mode (only admins can send messages).
1268///
1269/// Wire format:
1270/// ```xml
1271/// <iq type="set" xmlns="w:g2" to="{group_jid}">
1272///   <announcement/>
1273///   <!-- or -->
1274///   <not_announcement/>
1275/// </iq>
1276/// ```
1277#[derive(Debug, Clone)]
1278pub struct SetGroupAnnouncementIq {
1279    pub group_jid: Jid,
1280    pub announce: bool,
1281}
1282
1283impl SetGroupAnnouncementIq {
1284    pub fn announce(group_jid: &Jid) -> Self {
1285        Self {
1286            group_jid: group_jid.clone(),
1287            announce: true,
1288        }
1289    }
1290
1291    pub fn unannounce(group_jid: &Jid) -> Self {
1292        Self {
1293            group_jid: group_jid.clone(),
1294            announce: false,
1295        }
1296    }
1297}
1298
1299impl IqSpec for SetGroupAnnouncementIq {
1300    type Response = ();
1301
1302    fn build_iq(&self) -> InfoQuery<'static> {
1303        let tag = if self.announce {
1304            "announcement"
1305        } else {
1306            "not_announcement"
1307        };
1308        InfoQuery::set_ref(
1309            GROUP_IQ_NAMESPACE,
1310            &self.group_jid,
1311            Some(NodeContent::Nodes(vec![NodeBuilder::new(tag).build()])),
1312        )
1313    }
1314
1315    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1316        Ok(())
1317    }
1318}
1319
1320/// IQ specification for setting ephemeral (disappearing) messages on a group.
1321///
1322/// Wire format:
1323/// ```xml
1324/// <iq type="set" xmlns="w:g2" to="{group_jid}">
1325///   <ephemeral expiration="86400"/>
1326///   <!-- or to disable: -->
1327///   <not_ephemeral/>
1328/// </iq>
1329/// ```
1330///
1331/// Common expiration values (seconds):
1332/// - 86400 (24 hours)
1333/// - 604800 (7 days)
1334/// - 7776000 (90 days)
1335/// - 0 or `not_ephemeral` to disable
1336#[derive(Debug, Clone)]
1337pub struct SetGroupEphemeralIq {
1338    pub group_jid: Jid,
1339    /// Expiration in seconds. `None` means disable.
1340    pub expiration: Option<NonZeroU32>,
1341}
1342
1343impl SetGroupEphemeralIq {
1344    /// Enable ephemeral messages with the given expiration in seconds.
1345    pub fn enable(group_jid: &Jid, expiration: NonZeroU32) -> Self {
1346        Self {
1347            group_jid: group_jid.clone(),
1348            expiration: Some(expiration),
1349        }
1350    }
1351
1352    /// Disable ephemeral messages.
1353    pub fn disable(group_jid: &Jid) -> Self {
1354        Self {
1355            group_jid: group_jid.clone(),
1356            expiration: None,
1357        }
1358    }
1359}
1360
1361impl IqSpec for SetGroupEphemeralIq {
1362    type Response = ();
1363
1364    fn build_iq(&self) -> InfoQuery<'static> {
1365        let node = match self.expiration {
1366            Some(exp) => NodeBuilder::new("ephemeral")
1367                .attr("expiration", exp.to_string())
1368                .build(),
1369            None => NodeBuilder::new("not_ephemeral").build(),
1370        };
1371        InfoQuery::set_ref(
1372            GROUP_IQ_NAMESPACE,
1373            &self.group_jid,
1374            Some(NodeContent::Nodes(vec![node])),
1375        )
1376    }
1377
1378    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1379        Ok(())
1380    }
1381}
1382
1383/// IQ specification for setting the membership approval mode on a group.
1384///
1385/// When enabled, new members must be approved by an admin before joining.
1386///
1387/// Wire format:
1388/// ```xml
1389/// <iq type="set" xmlns="w:g2" to="{group_jid}">
1390///   <membership_approval_mode>
1391///     <group_join state="on"/>
1392///   </membership_approval_mode>
1393/// </iq>
1394/// ```
1395#[derive(Debug, Clone)]
1396pub struct SetGroupMembershipApprovalIq {
1397    pub group_jid: Jid,
1398    pub mode: MembershipApprovalMode,
1399}
1400
1401impl SetGroupMembershipApprovalIq {
1402    pub fn new(group_jid: &Jid, mode: MembershipApprovalMode) -> Self {
1403        Self {
1404            group_jid: group_jid.clone(),
1405            mode,
1406        }
1407    }
1408}
1409
1410impl IqSpec for SetGroupMembershipApprovalIq {
1411    type Response = ();
1412
1413    fn build_iq(&self) -> InfoQuery<'static> {
1414        let node = NodeBuilder::new("membership_approval_mode")
1415            .children([NodeBuilder::new("group_join")
1416                .attr("state", self.mode.as_str())
1417                .build()])
1418            .build();
1419        InfoQuery::set_ref(
1420            GROUP_IQ_NAMESPACE,
1421            &self.group_jid,
1422            Some(NodeContent::Nodes(vec![node])),
1423        )
1424    }
1425
1426    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1427        Ok(())
1428    }
1429}
1430
1431// ---------------------------------------------------------------------------
1432// Community IQ Specs
1433// ---------------------------------------------------------------------------
1434
1435/// Response for a single group in a link/unlink operation.
1436#[derive(Debug, Clone)]
1437pub struct LinkedGroupResult {
1438    pub jid: Jid,
1439    /// Error code if the operation failed for this group (e.g. 406 = community full).
1440    pub error: Option<u32>,
1441}
1442
1443/// Response from linking subgroups to a community.
1444#[derive(Debug, Clone)]
1445pub struct LinkSubgroupsResponse {
1446    pub groups: Vec<LinkedGroupResult>,
1447}
1448
1449/// Response from unlinking subgroups from a community.
1450#[derive(Debug, Clone)]
1451pub struct UnlinkSubgroupsResponse {
1452    pub groups: Vec<LinkedGroupResult>,
1453}
1454
1455/// IQ specification for linking subgroups to a community parent group.
1456///
1457/// Wire format:
1458/// ```xml
1459/// <iq type="set" xmlns="w:g2" to="{parent_jid}">
1460///   <links>
1461///     <link link_type="sub_group">
1462///       <group jid="{subgroup_jid}"/>
1463///     </link>
1464///   </links>
1465/// </iq>
1466/// ```
1467#[derive(Debug, Clone)]
1468pub struct LinkSubgroupsIq {
1469    pub parent_jid: Jid,
1470    pub subgroup_jids: Vec<Jid>,
1471}
1472
1473impl LinkSubgroupsIq {
1474    pub fn new(parent_jid: &Jid, subgroup_jids: &[Jid]) -> Self {
1475        Self {
1476            parent_jid: parent_jid.clone(),
1477            subgroup_jids: subgroup_jids.to_vec(),
1478        }
1479    }
1480}
1481
1482impl IqSpec for LinkSubgroupsIq {
1483    type Response = LinkSubgroupsResponse;
1484
1485    fn build_iq(&self) -> InfoQuery<'static> {
1486        let group_nodes: Vec<Node> = self
1487            .subgroup_jids
1488            .iter()
1489            .map(|jid| NodeBuilder::new("group").attr("jid", jid.clone()).build())
1490            .collect();
1491
1492        let link_node = NodeBuilder::new("link")
1493            .attr("link_type", "sub_group")
1494            .children(group_nodes)
1495            .build();
1496
1497        let links_node = NodeBuilder::new("links").children([link_node]).build();
1498
1499        InfoQuery::set_ref(
1500            GROUP_IQ_NAMESPACE,
1501            &self.parent_jid,
1502            Some(NodeContent::Nodes(vec![links_node])),
1503        )
1504    }
1505
1506    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1507        let links_node = required_child(response, "links")?;
1508        let link_node = required_child(links_node, "link")?;
1509
1510        let mut groups = Vec::new();
1511        for child in link_node.get_children_by_tag("group") {
1512            let jid_str = required_attr(child, "jid")?;
1513            let jid: Jid = jid_str.parse()?;
1514            let error = child
1515                .attrs()
1516                .optional_string("error")
1517                .and_then(|s| s.parse::<u32>().ok());
1518            groups.push(LinkedGroupResult { jid, error });
1519        }
1520
1521        Ok(LinkSubgroupsResponse { groups })
1522    }
1523}
1524
1525/// IQ specification for unlinking subgroups from a community parent group.
1526///
1527/// Wire format:
1528/// ```xml
1529/// <iq type="set" xmlns="w:g2" to="{parent_jid}">
1530///   <unlink unlink_type="sub_group">
1531///     <group jid="{subgroup_jid}"/>
1532///   </unlink>
1533/// </iq>
1534/// ```
1535#[derive(Debug, Clone)]
1536pub struct UnlinkSubgroupsIq {
1537    pub parent_jid: Jid,
1538    pub subgroup_jids: Vec<Jid>,
1539    pub remove_orphan_members: bool,
1540}
1541
1542impl UnlinkSubgroupsIq {
1543    pub fn new(parent_jid: &Jid, subgroup_jids: &[Jid], remove_orphan_members: bool) -> Self {
1544        Self {
1545            parent_jid: parent_jid.clone(),
1546            subgroup_jids: subgroup_jids.to_vec(),
1547            remove_orphan_members,
1548        }
1549    }
1550}
1551
1552impl IqSpec for UnlinkSubgroupsIq {
1553    type Response = UnlinkSubgroupsResponse;
1554
1555    fn build_iq(&self) -> InfoQuery<'static> {
1556        let group_nodes: Vec<Node> = self
1557            .subgroup_jids
1558            .iter()
1559            .map(|jid| {
1560                let mut builder = NodeBuilder::new("group").attr("jid", jid.clone());
1561                if self.remove_orphan_members {
1562                    builder = builder.attr("remove_orphaned_members", "true");
1563                }
1564                builder.build()
1565            })
1566            .collect();
1567
1568        let unlink_node = NodeBuilder::new("unlink")
1569            .attr("unlink_type", "sub_group")
1570            .children(group_nodes)
1571            .build();
1572
1573        InfoQuery::set_ref(
1574            GROUP_IQ_NAMESPACE,
1575            &self.parent_jid,
1576            Some(NodeContent::Nodes(vec![unlink_node])),
1577        )
1578    }
1579
1580    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1581        let unlink_node = required_child(response, "unlink")?;
1582
1583        let mut groups = Vec::new();
1584        for child in unlink_node.get_children_by_tag("group") {
1585            let jid_str = required_attr(child, "jid")?;
1586            let jid: Jid = jid_str.parse()?;
1587            let error = child
1588                .attrs()
1589                .optional_string("error")
1590                .and_then(|s| s.parse::<u32>().ok());
1591            groups.push(LinkedGroupResult { jid, error });
1592        }
1593
1594        Ok(UnlinkSubgroupsResponse { groups })
1595    }
1596}
1597
1598/// IQ specification for deleting (deactivating) a community.
1599///
1600/// Wire format:
1601/// ```xml
1602/// <iq type="set" xmlns="w:g2" to="{parent_jid}">
1603///   <delete_parent/>
1604/// </iq>
1605/// ```
1606#[derive(Debug, Clone)]
1607pub struct DeleteCommunityIq {
1608    pub parent_jid: Jid,
1609}
1610
1611impl DeleteCommunityIq {
1612    pub fn new(parent_jid: &Jid) -> Self {
1613        Self {
1614            parent_jid: parent_jid.clone(),
1615        }
1616    }
1617}
1618
1619impl IqSpec for DeleteCommunityIq {
1620    type Response = ();
1621
1622    fn build_iq(&self) -> InfoQuery<'static> {
1623        InfoQuery::set_ref(
1624            GROUP_IQ_NAMESPACE,
1625            &self.parent_jid,
1626            Some(NodeContent::Nodes(vec![
1627                NodeBuilder::new("delete_parent").build(),
1628            ])),
1629        )
1630    }
1631
1632    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1633        Ok(())
1634    }
1635}
1636
1637/// IQ specification for querying a linked subgroup's info from the parent community.
1638///
1639/// Wire format:
1640/// ```xml
1641/// <iq type="get" xmlns="w:g2" to="{parent_jid}">
1642///   <query_linked type="sub_group" jid="{subgroup_jid}"/>
1643/// </iq>
1644/// ```
1645#[derive(Debug, Clone)]
1646pub struct QueryLinkedGroupIq {
1647    pub parent_jid: Jid,
1648    pub subgroup_jid: Jid,
1649}
1650
1651impl QueryLinkedGroupIq {
1652    pub fn new(parent_jid: &Jid, subgroup_jid: &Jid) -> Self {
1653        Self {
1654            parent_jid: parent_jid.clone(),
1655            subgroup_jid: subgroup_jid.clone(),
1656        }
1657    }
1658}
1659
1660impl IqSpec for QueryLinkedGroupIq {
1661    type Response = GroupInfoResponse;
1662
1663    fn build_iq(&self) -> InfoQuery<'static> {
1664        let query_node = NodeBuilder::new("query_linked")
1665            .attr("type", "sub_group")
1666            .attr("jid", self.subgroup_jid.clone())
1667            .build();
1668
1669        InfoQuery::get_ref(
1670            GROUP_IQ_NAMESPACE,
1671            &self.parent_jid,
1672            Some(NodeContent::Nodes(vec![query_node])),
1673        )
1674    }
1675
1676    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1677        let linked_node = required_child(response, "linked_group")?;
1678        let group_node = required_child(linked_node, "group")?;
1679        GroupInfoResponse::try_from_node(group_node)
1680    }
1681}
1682
1683/// IQ specification for joining a linked subgroup via the parent community.
1684///
1685/// Wire format:
1686/// ```xml
1687/// <iq type="set" xmlns="w:g2" to="{parent_jid}">
1688///   <join_linked_group jid="{subgroup_jid}"/>
1689/// </iq>
1690/// ```
1691#[derive(Debug, Clone)]
1692pub struct JoinLinkedGroupIq {
1693    pub parent_jid: Jid,
1694    pub subgroup_jid: Jid,
1695}
1696
1697impl JoinLinkedGroupIq {
1698    pub fn new(parent_jid: &Jid, subgroup_jid: &Jid) -> Self {
1699        Self {
1700            parent_jid: parent_jid.clone(),
1701            subgroup_jid: subgroup_jid.clone(),
1702        }
1703    }
1704}
1705
1706impl IqSpec for JoinLinkedGroupIq {
1707    type Response = GroupInfoResponse;
1708
1709    fn build_iq(&self) -> InfoQuery<'static> {
1710        let node = NodeBuilder::new("join_linked_group")
1711            .attr("jid", self.subgroup_jid.clone())
1712            .build();
1713
1714        InfoQuery::set_ref(
1715            GROUP_IQ_NAMESPACE,
1716            &self.parent_jid,
1717            Some(NodeContent::Nodes(vec![node])),
1718        )
1719    }
1720
1721    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1722        let linked_node = required_child(response, "linked_group")?;
1723        let group_node = required_child(linked_node, "group")?;
1724        GroupInfoResponse::try_from_node(group_node)
1725    }
1726}
1727
1728/// IQ specification for getting all participants across linked groups.
1729///
1730/// Wire format:
1731/// ```xml
1732/// <iq type="get" xmlns="w:g2" to="{parent_jid}">
1733///   <linked_groups_participants/>
1734/// </iq>
1735/// ```
1736#[derive(Debug, Clone)]
1737pub struct GetLinkedGroupsParticipantsIq {
1738    pub parent_jid: Jid,
1739}
1740
1741impl GetLinkedGroupsParticipantsIq {
1742    pub fn new(parent_jid: &Jid) -> Self {
1743        Self {
1744            parent_jid: parent_jid.clone(),
1745        }
1746    }
1747}
1748
1749impl IqSpec for GetLinkedGroupsParticipantsIq {
1750    type Response = Vec<GroupParticipantResponse>;
1751
1752    fn build_iq(&self) -> InfoQuery<'static> {
1753        InfoQuery::get_ref(
1754            GROUP_IQ_NAMESPACE,
1755            &self.parent_jid,
1756            Some(NodeContent::Nodes(vec![
1757                NodeBuilder::new("linked_groups_participants").build(),
1758            ])),
1759        )
1760    }
1761
1762    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1763        let container = required_child(response, "linked_groups_participants")?;
1764
1765        // Participants may be direct children or nested inside <group> nodes.
1766        let direct = collect_children::<GroupParticipantResponse>(container, "participant")?;
1767        if !direct.is_empty() {
1768            return Ok(direct);
1769        }
1770
1771        // Nested: <linked_groups_participants><group><participant/></group></linked_groups_participants>
1772        let mut all = Vec::new();
1773        for group_node in container.get_children_by_tag("group") {
1774            let participants =
1775                collect_children::<GroupParticipantResponse>(group_node, "participant")?;
1776            all.extend(participants);
1777        }
1778        Ok(all)
1779    }
1780}
1781
1782// ---------------------------------------------------------------------------
1783// Accept group invite (join via code)
1784// ---------------------------------------------------------------------------
1785
1786/// Result of joining a group via invite code.
1787#[derive(Debug, Clone, PartialEq)]
1788pub enum JoinGroupResult {
1789    Joined(Jid),
1790    PendingApproval(Jid),
1791}
1792
1793impl JoinGroupResult {
1794    pub fn group_jid(&self) -> &Jid {
1795        match self {
1796            JoinGroupResult::Joined(jid) | JoinGroupResult::PendingApproval(jid) => jid,
1797        }
1798    }
1799}
1800
1801/// Join a group using an invite code.
1802///
1803/// ```xml
1804/// <iq type="set" xmlns="w:g2" to="@g.us">
1805///   <invite code="{code}"/>
1806/// </iq>
1807/// ```
1808#[derive(Debug, Clone)]
1809pub struct AcceptGroupInviteIq {
1810    pub code: String,
1811}
1812
1813impl AcceptGroupInviteIq {
1814    pub fn new(code: impl Into<String>) -> Self {
1815        Self { code: code.into() }
1816    }
1817}
1818
1819impl IqSpec for AcceptGroupInviteIq {
1820    type Response = JoinGroupResult;
1821
1822    fn build_iq(&self) -> InfoQuery<'static> {
1823        let to = Jid::new("", GROUP_SERVER);
1824        InfoQuery::set_ref(
1825            GROUP_IQ_NAMESPACE,
1826            &to,
1827            Some(NodeContent::Nodes(vec![
1828                NodeBuilder::new("invite").attr("code", &self.code).build(),
1829            ])),
1830        )
1831    }
1832
1833    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1834        if let Some(group_node) = response.get_optional_child("group") {
1835            let jid_str = required_attr(group_node, "jid")?;
1836            let jid: Jid = jid_str
1837                .parse()
1838                .map_err(|e| anyhow!("invalid group jid: {e}"))?;
1839            return Ok(JoinGroupResult::Joined(jid));
1840        }
1841        if let Some(approval_node) = response.get_optional_child("membership_approval_request") {
1842            let jid_str = required_attr(approval_node, "jid")?;
1843            let jid: Jid = jid_str
1844                .parse()
1845                .map_err(|e| anyhow!("invalid group jid: {e}"))?;
1846            return Ok(JoinGroupResult::PendingApproval(jid));
1847        }
1848        Err(anyhow!(
1849            "expected <group> or <membership_approval_request> child in invite response"
1850        ))
1851    }
1852}
1853
1854// ---------------------------------------------------------------------------
1855// Get group info by invite code
1856// ---------------------------------------------------------------------------
1857
1858/// Get group metadata from an invite code without joining.
1859///
1860/// ```xml
1861/// <iq type="get" xmlns="w:g2" to="@g.us">
1862///   <invite code="{code}"/>
1863/// </iq>
1864/// ```
1865#[derive(Debug, Clone)]
1866pub struct GetGroupInviteInfoIq {
1867    pub code: String,
1868}
1869
1870impl GetGroupInviteInfoIq {
1871    pub fn new(code: impl Into<String>) -> Self {
1872        Self { code: code.into() }
1873    }
1874}
1875
1876impl IqSpec for GetGroupInviteInfoIq {
1877    type Response = GroupInfoResponse;
1878
1879    fn build_iq(&self) -> InfoQuery<'static> {
1880        let to = Jid::new("", GROUP_SERVER);
1881        InfoQuery::get_ref(
1882            GROUP_IQ_NAMESPACE,
1883            &to,
1884            Some(NodeContent::Nodes(vec![
1885                NodeBuilder::new("invite").attr("code", &self.code).build(),
1886            ])),
1887        )
1888    }
1889
1890    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1891        let group_node = required_child(response, "group")?;
1892        GroupInfoResponse::try_from_node(group_node)
1893    }
1894}
1895
1896// ---------------------------------------------------------------------------
1897// Membership approval requests
1898// ---------------------------------------------------------------------------
1899
1900/// Get pending membership approval requests for a group.
1901///
1902/// ```xml
1903/// <iq type="get" xmlns="w:g2" to="{group_jid}">
1904///   <membership_approval_requests/>
1905/// </iq>
1906/// ```
1907#[derive(Debug, Clone)]
1908pub struct GetMembershipRequestsIq {
1909    pub group_jid: Jid,
1910}
1911
1912impl GetMembershipRequestsIq {
1913    pub fn new(jid: &Jid) -> Self {
1914        Self {
1915            group_jid: jid.clone(),
1916        }
1917    }
1918}
1919
1920#[derive(Debug, Clone, serde::Serialize)]
1921pub struct MembershipRequest {
1922    pub jid: Jid,
1923    #[serde(skip_serializing_if = "Option::is_none")]
1924    pub request_time: Option<u64>,
1925}
1926
1927impl IqSpec for GetMembershipRequestsIq {
1928    type Response = Vec<MembershipRequest>;
1929
1930    fn build_iq(&self) -> InfoQuery<'static> {
1931        InfoQuery::get_ref(
1932            GROUP_IQ_NAMESPACE,
1933            &self.group_jid,
1934            Some(NodeContent::Nodes(vec![
1935                NodeBuilder::new("membership_approval_requests").build(),
1936            ])),
1937        )
1938    }
1939
1940    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1941        let requests_node = response
1942            .get_optional_child("membership_approval_requests")
1943            .ok_or_else(|| anyhow!("missing membership_approval_requests"))?;
1944
1945        let mut requests = Vec::new();
1946        for child in requests_node.get_children_by_tag("membership_approval_request") {
1947            let jid_str = required_attr(child, "jid")?;
1948            let jid: Jid = jid_str
1949                .parse()
1950                .map_err(|e| anyhow!("invalid jid in membership request: {e}"))?;
1951            let request_time = child
1952                .attrs()
1953                .optional_string("request_time")
1954                .and_then(|s| s.parse::<u64>().ok());
1955            requests.push(MembershipRequest { jid, request_time });
1956        }
1957        Ok(requests)
1958    }
1959}
1960
1961/// Approve or reject pending membership requests.
1962///
1963/// ```xml
1964/// <iq type="set" xmlns="w:g2" to="{group_jid}">
1965///   <membership_requests_action>
1966///     <approve> or <reject>
1967///       <participant jid="{jid}"/>
1968///     </approve>
1969///   </membership_requests_action>
1970/// </iq>
1971/// ```
1972#[derive(Debug, Clone)]
1973pub struct MembershipRequestActionIq {
1974    pub group_jid: Jid,
1975    pub participants: Vec<Jid>,
1976    pub approve: bool,
1977}
1978
1979impl MembershipRequestActionIq {
1980    pub fn approve(group_jid: &Jid, participants: &[Jid]) -> Self {
1981        Self {
1982            group_jid: group_jid.clone(),
1983            participants: participants.to_vec(),
1984            approve: true,
1985        }
1986    }
1987
1988    pub fn reject(group_jid: &Jid, participants: &[Jid]) -> Self {
1989        Self {
1990            group_jid: group_jid.clone(),
1991            participants: participants.to_vec(),
1992            approve: false,
1993        }
1994    }
1995}
1996
1997impl IqSpec for MembershipRequestActionIq {
1998    type Response = Vec<ParticipantChangeResponse>;
1999
2000    fn build_iq(&self) -> InfoQuery<'static> {
2001        let action_tag = if self.approve { "approve" } else { "reject" };
2002        let participant_nodes: Vec<Node> = self
2003            .participants
2004            .iter()
2005            .map(|jid| {
2006                NodeBuilder::new("participant")
2007                    .attr("jid", jid.clone())
2008                    .build()
2009            })
2010            .collect();
2011
2012        InfoQuery::set_ref(
2013            GROUP_IQ_NAMESPACE,
2014            &self.group_jid,
2015            Some(NodeContent::Nodes(vec![
2016                NodeBuilder::new("membership_requests_action")
2017                    .children(vec![
2018                        NodeBuilder::new(action_tag)
2019                            .children(participant_nodes)
2020                            .build(),
2021                    ])
2022                    .build(),
2023            ])),
2024        )
2025    }
2026
2027    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
2028        let action_node = required_child(response, "membership_requests_action")?;
2029        let action_tag = if self.approve { "approve" } else { "reject" };
2030        let inner = required_child(action_node, action_tag)?;
2031        collect_children::<ParticipantChangeResponse>(inner, "participant")
2032    }
2033}
2034
2035// ---------------------------------------------------------------------------
2036// Member add mode
2037// ---------------------------------------------------------------------------
2038
2039/// Set who can add members to the group.
2040///
2041/// ```xml
2042/// <iq type="set" xmlns="w:g2" to="{group_jid}">
2043///   <member_add_mode>admin_add|all_member_add</member_add_mode>
2044/// </iq>
2045/// ```
2046#[derive(Debug, Clone)]
2047pub struct SetMemberAddModeIq {
2048    pub group_jid: Jid,
2049    pub mode: MemberAddMode,
2050}
2051
2052impl SetMemberAddModeIq {
2053    pub fn new(jid: &Jid, mode: MemberAddMode) -> Self {
2054        Self {
2055            group_jid: jid.clone(),
2056            mode,
2057        }
2058    }
2059}
2060
2061impl IqSpec for SetMemberAddModeIq {
2062    type Response = ();
2063
2064    fn build_iq(&self) -> InfoQuery<'static> {
2065        InfoQuery::set_ref(
2066            GROUP_IQ_NAMESPACE,
2067            &self.group_jid,
2068            Some(NodeContent::Nodes(vec![
2069                NodeBuilder::new("member_add_mode")
2070                    .string_content(self.mode.as_str())
2071                    .build(),
2072            ])),
2073        )
2074    }
2075
2076    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
2077        Ok(())
2078    }
2079}
2080
2081#[cfg(test)]
2082mod tests {
2083    use super::*;
2084    use crate::request::InfoQueryType;
2085
2086    #[test]
2087    fn test_group_subject_validation() {
2088        let subject = GroupSubject::new("Test Group").unwrap();
2089        assert_eq!(subject.as_str(), "Test Group");
2090
2091        let at_limit = "a".repeat(GROUP_SUBJECT_MAX_LENGTH);
2092        assert!(GroupSubject::new(&at_limit).is_ok());
2093
2094        let over_limit = "a".repeat(GROUP_SUBJECT_MAX_LENGTH + 1);
2095        assert!(GroupSubject::new(&over_limit).is_err());
2096    }
2097
2098    #[test]
2099    fn test_group_description_validation() {
2100        let desc = GroupDescription::new("Test Description").unwrap();
2101        assert_eq!(desc.as_str(), "Test Description");
2102
2103        let at_limit = "a".repeat(GROUP_DESCRIPTION_MAX_LENGTH);
2104        assert!(GroupDescription::new(&at_limit).is_ok());
2105
2106        let over_limit = "a".repeat(GROUP_DESCRIPTION_MAX_LENGTH + 1);
2107        assert!(GroupDescription::new(&over_limit).is_err());
2108    }
2109
2110    #[test]
2111    fn test_string_enum_member_add_mode() {
2112        assert_eq!(MemberAddMode::AdminAdd.as_str(), "admin_add");
2113        assert_eq!(MemberAddMode::AllMemberAdd.as_str(), "all_member_add");
2114        assert_eq!(
2115            MemberAddMode::try_from("admin_add").unwrap(),
2116            MemberAddMode::AdminAdd
2117        );
2118        assert!(MemberAddMode::try_from("invalid").is_err());
2119    }
2120
2121    #[test]
2122    fn test_string_enum_member_link_mode() {
2123        assert_eq!(MemberLinkMode::AdminLink.as_str(), "admin_link");
2124        assert_eq!(MemberLinkMode::AllMemberLink.as_str(), "all_member_link");
2125        assert_eq!(
2126            MemberLinkMode::try_from("admin_link").unwrap(),
2127            MemberLinkMode::AdminLink
2128        );
2129    }
2130
2131    #[test]
2132    fn test_participant_type_is_admin() {
2133        assert!(!ParticipantType::Member.is_admin());
2134        assert!(ParticipantType::Admin.is_admin());
2135        assert!(ParticipantType::SuperAdmin.is_admin());
2136    }
2137
2138    #[test]
2139    fn test_normalize_participants_drops_phone_for_pn() {
2140        let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap();
2141        let lid_jid: Jid = "100000000000001@lid".parse().unwrap();
2142        let phone_jid: Jid = "15550000001@s.whatsapp.net".parse().unwrap();
2143
2144        let participants = vec![
2145            GroupParticipantOptions::new(pn_jid.clone()).with_phone_number(phone_jid.clone()),
2146            GroupParticipantOptions::new(lid_jid.clone()).with_phone_number(phone_jid.clone()),
2147        ];
2148
2149        let normalized = normalize_participants(&participants);
2150        assert!(normalized[0].phone_number.is_none());
2151        assert_eq!(normalized[0].jid, pn_jid);
2152        assert_eq!(normalized[1].phone_number.as_ref(), Some(&phone_jid));
2153    }
2154
2155    #[test]
2156    fn test_build_create_group_node() {
2157        let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap();
2158        let options = GroupCreateOptions::new("Test Subject")
2159            .with_participant(GroupParticipantOptions::from_phone(pn_jid))
2160            .with_member_link_mode(MemberLinkMode::AllMemberLink)
2161            .with_member_add_mode(MemberAddMode::AdminAdd);
2162
2163        let node = build_create_group_node(&options);
2164        assert_eq!(node.tag, "create");
2165        assert_eq!(
2166            node.attrs().optional_string("subject").as_deref(),
2167            Some("Test Subject")
2168        );
2169
2170        let link_mode = node.get_children_by_tag("member_link_mode").next().unwrap();
2171        assert_eq!(
2172            link_mode.content.as_ref().and_then(|c| match c {
2173                NodeContent::String(s) => Some(s.as_str()),
2174                _ => None,
2175            }),
2176            Some("all_member_link")
2177        );
2178    }
2179
2180    #[test]
2181    fn test_typed_builder() {
2182        let options: GroupCreateOptions = GroupCreateOptions::builder()
2183            .subject("My Group")
2184            .member_add_mode(MemberAddMode::AdminAdd)
2185            .build();
2186
2187        assert_eq!(options.subject, "My Group");
2188        assert_eq!(options.member_add_mode, Some(MemberAddMode::AdminAdd));
2189    }
2190
2191    #[test]
2192    fn test_set_group_description_with_id_and_prev() {
2193        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
2194        let desc = GroupDescription::new("New description").unwrap();
2195        let spec = SetGroupDescriptionIq::new(&jid, Some(desc), Some("AABBCCDD".to_string()));
2196        let iq = spec.build_iq();
2197
2198        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2199            let desc_node = &nodes[0];
2200            assert_eq!(desc_node.tag, "description");
2201            // id is random hex, just check it exists and is 8 chars
2202            let id = desc_node.attrs().optional_string("id").unwrap();
2203            assert_eq!(id.len(), 8);
2204            assert_eq!(
2205                desc_node.attrs().optional_string("prev").as_deref(),
2206                Some("AABBCCDD")
2207            );
2208            // Should have a <body> child
2209            assert!(desc_node.get_children_by_tag("body").next().is_some());
2210        } else {
2211            panic!("expected nodes content");
2212        }
2213    }
2214
2215    #[test]
2216    fn test_set_group_description_delete() {
2217        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
2218        let spec = SetGroupDescriptionIq::new(&jid, None, Some("PREV1234".to_string()));
2219        let iq = spec.build_iq();
2220
2221        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2222            let desc_node = &nodes[0];
2223            assert_eq!(desc_node.tag, "description");
2224            assert_eq!(
2225                desc_node.attrs().optional_string("delete").as_deref(),
2226                Some("true")
2227            );
2228            assert_eq!(
2229                desc_node.attrs().optional_string("prev").as_deref(),
2230                Some("PREV1234")
2231            );
2232            // id should still be present
2233            assert!(desc_node.attrs().optional_string("id").is_some());
2234        } else {
2235            panic!("expected nodes content");
2236        }
2237    }
2238
2239    #[test]
2240    fn test_leave_group_iq() {
2241        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
2242        let spec = LeaveGroupIq::new(&jid);
2243        let iq = spec.build_iq();
2244
2245        assert_eq!(iq.namespace, GROUP_IQ_NAMESPACE);
2246        assert_eq!(iq.query_type, InfoQueryType::Set);
2247        // Leave goes to g.us, not the group JID
2248        assert_eq!(iq.to.server, GROUP_SERVER);
2249    }
2250
2251    #[test]
2252    fn test_add_participants_iq() {
2253        let group: Jid = "120363000000000001@g.us".parse().unwrap();
2254        let p1: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
2255        let p2: Jid = "9876543210@s.whatsapp.net".parse().unwrap();
2256        let spec = AddParticipantsIq::new(&group, &[p1, p2]);
2257        let iq = spec.build_iq();
2258
2259        assert_eq!(iq.namespace, GROUP_IQ_NAMESPACE);
2260        assert_eq!(iq.to, group);
2261
2262        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2263            let add_node = &nodes[0];
2264            assert_eq!(add_node.tag, "add");
2265            let participants: Vec<_> = add_node.get_children_by_tag("participant").collect();
2266            assert_eq!(participants.len(), 2);
2267        } else {
2268            panic!("expected nodes content");
2269        }
2270    }
2271
2272    #[test]
2273    fn test_promote_demote_iq() {
2274        let group: Jid = "120363000000000001@g.us".parse().unwrap();
2275        let p1: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
2276
2277        let promote = PromoteParticipantsIq::new(&group, std::slice::from_ref(&p1));
2278        let iq = promote.build_iq();
2279        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2280            assert_eq!(nodes[0].tag, "promote");
2281        } else {
2282            panic!("expected nodes content");
2283        }
2284
2285        let demote = DemoteParticipantsIq::new(&group, &[p1]);
2286        let iq = demote.build_iq();
2287        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2288            assert_eq!(nodes[0].tag, "demote");
2289        } else {
2290            panic!("expected nodes content");
2291        }
2292    }
2293
2294    #[test]
2295    fn test_get_group_invite_link_iq() {
2296        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
2297        let spec = GetGroupInviteLinkIq::new(&jid, false);
2298        let iq = spec.build_iq();
2299
2300        assert_eq!(iq.query_type, InfoQueryType::Get);
2301        assert_eq!(iq.to, jid);
2302
2303        // With reset=true it should be a SET
2304        let reset_spec = GetGroupInviteLinkIq::new(&jid, true);
2305        assert_eq!(reset_spec.build_iq().query_type, InfoQueryType::Set);
2306    }
2307
2308    #[test]
2309    fn test_get_group_invite_link_parse_response() {
2310        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
2311        let spec = GetGroupInviteLinkIq::new(&jid, false);
2312
2313        let response = NodeBuilder::new("response")
2314            .children([NodeBuilder::new("invite")
2315                .attr("code", "AbCdEfGhIjKl")
2316                .build()])
2317            .build();
2318
2319        let result = spec.parse_response(&response).unwrap();
2320        assert_eq!(result, "https://chat.whatsapp.com/AbCdEfGhIjKl");
2321    }
2322
2323    #[test]
2324    fn test_participant_change_response_parse() {
2325        let node = NodeBuilder::new("participant")
2326            .attr("jid", "1234567890@s.whatsapp.net")
2327            .attr("type", "200")
2328            .build();
2329
2330        let result = ParticipantChangeResponse::try_from_node(&node).unwrap();
2331        assert_eq!(result.jid.user, "1234567890");
2332        assert_eq!(result.status, Some("200".to_string()));
2333    }
2334
2335    #[test]
2336    fn test_set_group_locked_iq() {
2337        let group: Jid = "120363000000000001@g.us".parse().unwrap();
2338
2339        let lock = SetGroupLockedIq::lock(&group);
2340        let iq = lock.build_iq();
2341        assert_eq!(iq.query_type, InfoQueryType::Set);
2342        assert_eq!(iq.to, group);
2343        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2344            assert_eq!(nodes[0].tag, "locked");
2345        } else {
2346            panic!("expected nodes content");
2347        }
2348
2349        let unlock = SetGroupLockedIq::unlock(&group);
2350        let iq = unlock.build_iq();
2351        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2352            assert_eq!(nodes[0].tag, "unlocked");
2353        } else {
2354            panic!("expected nodes content");
2355        }
2356    }
2357
2358    #[test]
2359    fn test_set_group_announcement_iq() {
2360        let group: Jid = "120363000000000001@g.us".parse().unwrap();
2361
2362        let announce = SetGroupAnnouncementIq::announce(&group);
2363        let iq = announce.build_iq();
2364        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2365            assert_eq!(nodes[0].tag, "announcement");
2366        } else {
2367            panic!("expected nodes content");
2368        }
2369
2370        let not_announce = SetGroupAnnouncementIq::unannounce(&group);
2371        let iq = not_announce.build_iq();
2372        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2373            assert_eq!(nodes[0].tag, "not_announcement");
2374        } else {
2375            panic!("expected nodes content");
2376        }
2377    }
2378
2379    #[test]
2380    fn test_set_group_ephemeral_iq() {
2381        let group: Jid = "120363000000000001@g.us".parse().unwrap();
2382
2383        let enable = SetGroupEphemeralIq::enable(&group, NonZeroU32::new(86400).unwrap());
2384        let iq = enable.build_iq();
2385        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2386            assert_eq!(nodes[0].tag, "ephemeral");
2387            assert_eq!(
2388                nodes[0].attrs().optional_string("expiration").as_deref(),
2389                Some("86400")
2390            );
2391        } else {
2392            panic!("expected nodes content");
2393        }
2394
2395        let disable = SetGroupEphemeralIq::disable(&group);
2396        let iq = disable.build_iq();
2397        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2398            assert_eq!(nodes[0].tag, "not_ephemeral");
2399        } else {
2400            panic!("expected nodes content");
2401        }
2402    }
2403
2404    #[test]
2405    fn test_set_group_membership_approval_iq() {
2406        let group: Jid = "120363000000000001@g.us".parse().unwrap();
2407
2408        let spec = SetGroupMembershipApprovalIq::new(&group, MembershipApprovalMode::On);
2409        let iq = spec.build_iq();
2410        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2411            assert_eq!(nodes[0].tag, "membership_approval_mode");
2412            let join = nodes[0].get_children_by_tag("group_join").next().unwrap();
2413            assert!(join.attrs.get("state").is_some_and(|v| v == "on"));
2414        } else {
2415            panic!("expected nodes content");
2416        }
2417    }
2418
2419    // -----------------------------------------------------------------------
2420    // Community IQ spec tests
2421    // -----------------------------------------------------------------------
2422
2423    #[test]
2424    fn test_build_create_community_node() {
2425        let options = GroupCreateOptions {
2426            subject: "My Community".to_string(),
2427            is_parent: true,
2428            closed: true,
2429            allow_non_admin_sub_group_creation: true,
2430            create_general_chat: true,
2431            ..Default::default()
2432        };
2433
2434        let node = build_create_group_node(&options);
2435        assert_eq!(node.tag, "create");
2436
2437        // Should have <parent default_membership_approval_mode="request_required"/>
2438        let parent = node.get_children_by_tag("parent").next().unwrap();
2439        assert_eq!(
2440            parent
2441                .attrs()
2442                .optional_string("default_membership_approval_mode")
2443                .as_deref(),
2444            Some("request_required")
2445        );
2446
2447        assert!(
2448            node.get_children_by_tag("allow_non_admin_sub_group_creation")
2449                .next()
2450                .is_some()
2451        );
2452        assert!(
2453            node.get_children_by_tag("create_general_chat")
2454                .next()
2455                .is_some()
2456        );
2457    }
2458
2459    #[test]
2460    fn test_build_create_non_community_omits_parent() {
2461        let options = GroupCreateOptions {
2462            subject: "Regular Group".to_string(),
2463            is_parent: false,
2464            ..Default::default()
2465        };
2466
2467        let node = build_create_group_node(&options);
2468        assert!(
2469            node.get_children_by_tag("parent").next().is_none(),
2470            "non-community group should not have <parent>"
2471        );
2472    }
2473
2474    #[test]
2475    fn test_link_subgroups_iq_build() {
2476        let parent: Jid = "120363000000000001@g.us".parse().unwrap();
2477        let sub: Jid = "120363000000000002@g.us".parse().unwrap();
2478
2479        let spec = LinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub));
2480        let iq = spec.build_iq();
2481
2482        assert_eq!(iq.to, parent);
2483        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2484            let links = &nodes[0];
2485            assert_eq!(links.tag, "links");
2486            let link = links.get_children_by_tag("link").next().unwrap();
2487            assert_eq!(
2488                link.attrs().optional_string("link_type").as_deref(),
2489                Some("sub_group")
2490            );
2491            let group = link.get_children_by_tag("group").next().unwrap();
2492            assert_eq!(group.attrs().optional_jid("jid"), Some(sub));
2493        } else {
2494            panic!("expected nodes content");
2495        }
2496    }
2497
2498    #[test]
2499    fn test_link_subgroups_iq_parse_response() {
2500        let parent: Jid = "120363000000000001@g.us".parse().unwrap();
2501        let sub: Jid = "120363000000000002@g.us".parse().unwrap();
2502
2503        let response = NodeBuilder::new("iq")
2504            .children([NodeBuilder::new("links")
2505                .children([NodeBuilder::new("link")
2506                    .attr("link_type", "sub_group")
2507                    .children([NodeBuilder::new("group")
2508                        .attr("jid", sub.to_string())
2509                        .build()])
2510                    .build()])
2511                .build()])
2512            .build();
2513
2514        let spec = LinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub));
2515        let result = spec.parse_response(&response).unwrap();
2516        assert_eq!(result.groups.len(), 1);
2517        assert_eq!(result.groups[0].jid, sub);
2518        assert!(result.groups[0].error.is_none());
2519    }
2520
2521    #[test]
2522    fn test_unlink_subgroups_iq_build() {
2523        let parent: Jid = "120363000000000001@g.us".parse().unwrap();
2524        let sub: Jid = "120363000000000002@g.us".parse().unwrap();
2525
2526        let spec = UnlinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub), true);
2527        let iq = spec.build_iq();
2528
2529        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2530            let unlink = &nodes[0];
2531            assert_eq!(unlink.tag, "unlink");
2532            assert_eq!(
2533                unlink.attrs().optional_string("unlink_type").as_deref(),
2534                Some("sub_group")
2535            );
2536            let group = unlink.get_children_by_tag("group").next().unwrap();
2537            assert_eq!(group.attrs().optional_jid("jid"), Some(sub));
2538            assert_eq!(
2539                group
2540                    .attrs()
2541                    .optional_string("remove_orphaned_members")
2542                    .as_deref(),
2543                Some("true")
2544            );
2545        } else {
2546            panic!("expected nodes content");
2547        }
2548    }
2549
2550    #[test]
2551    fn test_unlink_subgroups_iq_parse_response_with_error() {
2552        let parent: Jid = "120363000000000001@g.us".parse().unwrap();
2553        let sub: Jid = "120363000000000002@g.us".parse().unwrap();
2554
2555        let response = NodeBuilder::new("iq")
2556            .children([NodeBuilder::new("unlink")
2557                .attr("unlink_type", "sub_group")
2558                .children([NodeBuilder::new("group")
2559                    .attr("jid", sub.to_string())
2560                    .attr("error", "406")
2561                    .build()])
2562                .build()])
2563            .build();
2564
2565        let spec = UnlinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub), false);
2566        let result = spec.parse_response(&response).unwrap();
2567        assert_eq!(result.groups.len(), 1);
2568        assert_eq!(result.groups[0].jid, sub);
2569        assert_eq!(result.groups[0].error, Some(406));
2570    }
2571
2572    #[test]
2573    fn test_delete_community_iq_build() {
2574        let parent: Jid = "120363000000000001@g.us".parse().unwrap();
2575        let spec = DeleteCommunityIq::new(&parent);
2576        let iq = spec.build_iq();
2577
2578        assert_eq!(iq.to, parent);
2579        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2580            assert_eq!(nodes[0].tag, "delete_parent");
2581        } else {
2582            panic!("expected nodes content");
2583        }
2584    }
2585
2586    #[test]
2587    fn test_query_linked_group_iq_build() {
2588        let parent: Jid = "120363000000000001@g.us".parse().unwrap();
2589        let sub: Jid = "120363000000000002@g.us".parse().unwrap();
2590
2591        let spec = QueryLinkedGroupIq::new(&parent, &sub);
2592        let iq = spec.build_iq();
2593
2594        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2595            let query = &nodes[0];
2596            assert_eq!(query.tag, "query_linked");
2597            assert_eq!(
2598                query.attrs().optional_string("type").as_deref(),
2599                Some("sub_group")
2600            );
2601            assert_eq!(query.attrs().optional_jid("jid"), Some(sub));
2602        } else {
2603            panic!("expected nodes content");
2604        }
2605    }
2606
2607    #[test]
2608    fn test_join_linked_group_iq_build() {
2609        let parent: Jid = "120363000000000001@g.us".parse().unwrap();
2610        let sub: Jid = "120363000000000002@g.us".parse().unwrap();
2611
2612        let spec = JoinLinkedGroupIq::new(&parent, &sub);
2613        let iq = spec.build_iq();
2614
2615        assert_eq!(iq.to, parent);
2616        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2617            let join = &nodes[0];
2618            assert_eq!(join.tag, "join_linked_group");
2619            assert_eq!(join.attrs().optional_jid("jid"), Some(sub));
2620        } else {
2621            panic!("expected nodes content");
2622        }
2623    }
2624
2625    #[test]
2626    fn test_get_linked_groups_participants_iq_build() {
2627        let parent: Jid = "120363000000000001@g.us".parse().unwrap();
2628        let spec = GetLinkedGroupsParticipantsIq::new(&parent);
2629        let iq = spec.build_iq();
2630
2631        assert_eq!(iq.to, parent);
2632        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
2633            assert_eq!(nodes[0].tag, "linked_groups_participants");
2634        } else {
2635            panic!("expected nodes content");
2636        }
2637    }
2638
2639    #[test]
2640    fn test_group_info_response_parses_community_fields() {
2641        let node = NodeBuilder::new("group")
2642            .attr("id", "120363000000000001@g.us")
2643            .attr("subject", "My Community")
2644            .children([
2645                NodeBuilder::new("parent").build(),
2646                NodeBuilder::new("allow_non_admin_sub_group_creation").build(),
2647            ])
2648            .build();
2649
2650        let response = GroupInfoResponse::try_from_node(&node).unwrap();
2651        assert!(response.is_parent_group);
2652        assert!(response.allow_non_admin_sub_group_creation);
2653        assert!(response.parent_group_jid.is_none());
2654        assert!(!response.is_default_sub_group);
2655        assert!(!response.is_general_chat);
2656    }
2657
2658    #[test]
2659    fn test_group_info_response_parses_subgroup_fields() {
2660        let parent_jid = "120363000000000001@g.us";
2661        let node = NodeBuilder::new("group")
2662            .attr("id", "120363000000000002@g.us")
2663            .attr("subject", "Sub Group")
2664            .children([
2665                NodeBuilder::new("linked_parent")
2666                    .attr("jid", parent_jid)
2667                    .build(),
2668                NodeBuilder::new("default_sub_group").build(),
2669            ])
2670            .build();
2671
2672        let response = GroupInfoResponse::try_from_node(&node).unwrap();
2673        assert!(!response.is_parent_group);
2674        assert!(response.is_default_sub_group);
2675        assert_eq!(response.parent_group_jid, Some(parent_jid.parse().unwrap()));
2676    }
2677}