Skip to main content

wa_rs_core/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 typed_builder::TypedBuilder;
8use wa_rs_binary::builder::NodeBuilder;
9use wa_rs_binary::jid::{GROUP_SERVER, Jid};
10use wa_rs_binary::node::{Node, NodeContent};
11
12// Re-export AddressingMode from types::message for convenience
13pub use crate::types::message::AddressingMode;
14/// IQ namespace for group operations.
15pub const GROUP_IQ_NAMESPACE: &str = "w:g2";
16
17/// Maximum length for a WhatsApp group subject (from `group_max_subject` A/B prop).
18pub const GROUP_SUBJECT_MAX_LENGTH: usize = 100;
19
20/// Maximum length for a WhatsApp group description (from `group_description_length` A/B prop).
21pub const GROUP_DESCRIPTION_MAX_LENGTH: usize = 2048;
22
23/// Maximum number of participants in a group (from `group_size_limit` A/B prop).
24pub const GROUP_SIZE_LIMIT: usize = 257;
25/// Member link mode for group invite links.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
27pub enum MemberLinkMode {
28    #[str = "admin_link"]
29    AdminLink,
30    #[str = "all_member_link"]
31    AllMemberLink,
32}
33
34/// Member add mode for who can add participants.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
36pub enum MemberAddMode {
37    #[str = "admin_add"]
38    AdminAdd,
39    #[str = "all_member_add"]
40    AllMemberAdd,
41}
42
43/// Membership approval mode for join requests.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
45pub enum MembershipApprovalMode {
46    #[string_default]
47    #[str = "off"]
48    Off,
49    #[str = "on"]
50    On,
51}
52
53/// Query request type.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
55pub enum GroupQueryRequestType {
56    #[string_default]
57    #[str = "interactive"]
58    Interactive,
59}
60
61/// Participant type (admin level).
62#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
63pub enum ParticipantType {
64    #[string_default]
65    #[str = "member"]
66    Member,
67    #[str = "admin"]
68    Admin,
69    #[str = "superadmin"]
70    SuperAdmin,
71}
72
73impl ParticipantType {
74    pub fn is_admin(&self) -> bool {
75        matches!(self, ParticipantType::Admin | ParticipantType::SuperAdmin)
76    }
77}
78
79impl TryFrom<Option<&str>> for ParticipantType {
80    type Error = anyhow::Error;
81
82    fn try_from(value: Option<&str>) -> Result<Self> {
83        match value {
84            Some("admin") => Ok(ParticipantType::Admin),
85            Some("superadmin") => Ok(ParticipantType::SuperAdmin),
86            Some("member") | None => Ok(ParticipantType::Member),
87            Some(other) => Err(anyhow!("unknown participant type: {other}")),
88        }
89    }
90}
91crate::define_validated_string! {
92    /// A validated group subject string.
93    ///
94    /// WhatsApp limits group subjects to [`GROUP_SUBJECT_MAX_LENGTH`] characters.
95    pub struct GroupSubject(max_len = GROUP_SUBJECT_MAX_LENGTH, name = "Group subject")
96}
97
98crate::define_validated_string! {
99    /// A validated group description string.
100    ///
101    /// WhatsApp limits group descriptions to [`GROUP_DESCRIPTION_MAX_LENGTH`] characters.
102    pub struct GroupDescription(max_len = GROUP_DESCRIPTION_MAX_LENGTH, name = "Group description")
103}
104/// Options for a participant when creating a group.
105#[derive(Debug, Clone, TypedBuilder)]
106#[builder(build_method(into))]
107pub struct GroupParticipantOptions {
108    pub jid: Jid,
109    #[builder(default, setter(strip_option))]
110    pub phone_number: Option<Jid>,
111    #[builder(default, setter(strip_option))]
112    pub privacy: Option<Vec<u8>>,
113}
114
115impl GroupParticipantOptions {
116    pub fn new(jid: Jid) -> Self {
117        Self {
118            jid,
119            phone_number: None,
120            privacy: None,
121        }
122    }
123
124    pub fn from_phone(phone_number: Jid) -> Self {
125        Self::new(phone_number)
126    }
127
128    pub fn from_lid_and_phone(lid: Jid, phone_number: Jid) -> Self {
129        Self::new(lid).with_phone_number(phone_number)
130    }
131
132    pub fn with_phone_number(mut self, phone_number: Jid) -> Self {
133        self.phone_number = Some(phone_number);
134        self
135    }
136
137    pub fn with_privacy(mut self, privacy: Vec<u8>) -> Self {
138        self.privacy = Some(privacy);
139        self
140    }
141}
142
143/// Options for creating a new group.
144#[derive(Debug, Clone, TypedBuilder)]
145#[builder(build_method(into))]
146pub struct GroupCreateOptions {
147    #[builder(setter(into))]
148    pub subject: String,
149    #[builder(default)]
150    pub participants: Vec<GroupParticipantOptions>,
151    #[builder(default = Some(MemberLinkMode::AdminLink), setter(strip_option))]
152    pub member_link_mode: Option<MemberLinkMode>,
153    #[builder(default = Some(MemberAddMode::AllMemberAdd), setter(strip_option))]
154    pub member_add_mode: Option<MemberAddMode>,
155    #[builder(default = Some(MembershipApprovalMode::Off), setter(strip_option))]
156    pub membership_approval_mode: Option<MembershipApprovalMode>,
157    #[builder(default = Some(0), setter(strip_option))]
158    pub ephemeral_expiration: Option<u32>,
159}
160
161impl GroupCreateOptions {
162    /// Create new options with just a subject (for backwards compatibility).
163    pub fn new(subject: impl Into<String>) -> Self {
164        Self {
165            subject: subject.into(),
166            ..Default::default()
167        }
168    }
169
170    pub fn with_participant(mut self, participant: GroupParticipantOptions) -> Self {
171        self.participants.push(participant);
172        self
173    }
174
175    pub fn with_participants(mut self, participants: Vec<GroupParticipantOptions>) -> Self {
176        self.participants = participants;
177        self
178    }
179
180    pub fn with_member_link_mode(mut self, mode: MemberLinkMode) -> Self {
181        self.member_link_mode = Some(mode);
182        self
183    }
184
185    pub fn with_member_add_mode(mut self, mode: MemberAddMode) -> Self {
186        self.member_add_mode = Some(mode);
187        self
188    }
189
190    pub fn with_membership_approval_mode(mut self, mode: MembershipApprovalMode) -> Self {
191        self.membership_approval_mode = Some(mode);
192        self
193    }
194
195    pub fn with_ephemeral_expiration(mut self, expiration: u32) -> Self {
196        self.ephemeral_expiration = Some(expiration);
197        self
198    }
199}
200
201impl Default for GroupCreateOptions {
202    fn default() -> Self {
203        Self {
204            subject: String::new(),
205            participants: Vec::new(),
206            member_link_mode: Some(MemberLinkMode::AdminLink),
207            member_add_mode: Some(MemberAddMode::AllMemberAdd),
208            membership_approval_mode: Some(MembershipApprovalMode::Off),
209            ephemeral_expiration: Some(0),
210        }
211    }
212}
213
214/// Normalize participants: drop phone_number for non-LID JIDs.
215pub fn normalize_participants(
216    participants: &[GroupParticipantOptions],
217) -> Vec<GroupParticipantOptions> {
218    participants
219        .iter()
220        .cloned()
221        .map(|p| {
222            if !p.jid.is_lid() && p.phone_number.is_some() {
223                GroupParticipantOptions {
224                    phone_number: None,
225                    ..p
226                }
227            } else {
228                p
229            }
230        })
231        .collect()
232}
233
234/// Build the `<create>` node for group creation.
235pub fn build_create_group_node(options: &GroupCreateOptions) -> Node {
236    let mut children = Vec::new();
237
238    if let Some(link_mode) = &options.member_link_mode {
239        children.push(
240            NodeBuilder::new("member_link_mode")
241                .string_content(link_mode.as_str())
242                .build(),
243        );
244    }
245
246    if let Some(add_mode) = &options.member_add_mode {
247        children.push(
248            NodeBuilder::new("member_add_mode")
249                .string_content(add_mode.as_str())
250                .build(),
251        );
252    }
253
254    // Normalize participants to avoid sending phone_number for non-LID JIDs
255    let participants = normalize_participants(&options.participants);
256
257    for participant in &participants {
258        let mut attrs = vec![("jid", participant.jid.to_string())];
259        if let Some(pn) = &participant.phone_number {
260            attrs.push(("phone_number", pn.to_string()));
261        }
262
263        let participant_node = if let Some(privacy_bytes) = &participant.privacy {
264            NodeBuilder::new("participant")
265                .attrs(attrs)
266                .children([NodeBuilder::new("privacy")
267                    .string_content(hex::encode(privacy_bytes))
268                    .build()])
269                .build()
270        } else {
271            NodeBuilder::new("participant").attrs(attrs).build()
272        };
273        children.push(participant_node);
274    }
275
276    if let Some(expiration) = &options.ephemeral_expiration {
277        children.push(
278            NodeBuilder::new("ephemeral")
279                .attr("expiration", expiration.to_string())
280                .build(),
281        );
282    }
283
284    if let Some(approval_mode) = &options.membership_approval_mode {
285        children.push(
286            NodeBuilder::new("membership_approval_mode")
287                .children([NodeBuilder::new("group_join")
288                    .attr("state", approval_mode.as_str())
289                    .build()])
290                .build(),
291        );
292    }
293
294    NodeBuilder::new("create")
295        .attr("subject", &options.subject)
296        .children(children)
297        .build()
298}
299/// Request to query group information.
300#[derive(Debug, Clone, Default)]
301pub struct GroupQueryRequest {
302    pub request: GroupQueryRequestType,
303}
304
305impl ProtocolNode for GroupQueryRequest {
306    fn tag(&self) -> &'static str {
307        "query"
308    }
309
310    fn into_node(self) -> Node {
311        NodeBuilder::new("query")
312            .attr("request", self.request.as_str())
313            .build()
314    }
315
316    fn try_from_node(node: &Node) -> Result<Self> {
317        if node.tag != "query" {
318            return Err(anyhow!("expected <query>, got <{}>", node.tag));
319        }
320        Ok(Self::default())
321    }
322}
323
324/// A participant in a group response.
325#[derive(Debug, Clone)]
326pub struct GroupParticipantResponse {
327    pub jid: Jid,
328    pub phone_number: Option<Jid>,
329    pub participant_type: ParticipantType,
330}
331
332impl ProtocolNode for GroupParticipantResponse {
333    fn tag(&self) -> &'static str {
334        "participant"
335    }
336
337    fn into_node(self) -> Node {
338        let mut builder = NodeBuilder::new("participant").attr("jid", self.jid.to_string());
339        if let Some(pn) = &self.phone_number {
340            builder = builder.attr("phone_number", pn.to_string());
341        }
342        if self.participant_type != ParticipantType::Member {
343            builder = builder.attr("type", self.participant_type.as_str());
344        }
345        builder.build()
346    }
347
348    fn try_from_node(node: &Node) -> Result<Self> {
349        if node.tag != "participant" {
350            return Err(anyhow!("expected <participant>, got <{}>", node.tag));
351        }
352        let jid = node
353            .attrs()
354            .optional_jid("jid")
355            .ok_or_else(|| anyhow!("participant missing required 'jid' attribute"))?;
356        let phone_number = node.attrs().optional_jid("phone_number");
357        // Default to Member for unknown participant types to avoid failing the whole group parse
358        let participant_type = ParticipantType::try_from(node.attrs().optional_string("type"))
359            .unwrap_or(ParticipantType::Member);
360
361        Ok(Self {
362            jid,
363            phone_number,
364            participant_type,
365        })
366    }
367}
368
369/// Response from a group info query.
370#[derive(Debug, Clone)]
371pub struct GroupInfoResponse {
372    pub id: Jid,
373    pub subject: GroupSubject,
374    pub addressing_mode: AddressingMode,
375    pub participants: Vec<GroupParticipantResponse>,
376}
377
378impl ProtocolNode for GroupInfoResponse {
379    fn tag(&self) -> &'static str {
380        "group"
381    }
382
383    fn into_node(self) -> Node {
384        let children: Vec<Node> = self
385            .participants
386            .into_iter()
387            .map(|p| p.into_node())
388            .collect();
389        NodeBuilder::new("group")
390            .attr("id", self.id.to_string())
391            .attr("subject", self.subject.as_str())
392            .attr("addressing_mode", self.addressing_mode.as_str())
393            .children(children)
394            .build()
395    }
396
397    fn try_from_node(node: &Node) -> Result<Self> {
398        if node.tag != "group" {
399            return Err(anyhow!("expected <group>, got <{}>", node.tag));
400        }
401
402        let id_str = required_attr(node, "id")?;
403        let id = if id_str.contains('@') {
404            id_str.parse()?
405        } else {
406            Jid::group(id_str)
407        };
408
409        let subject =
410            GroupSubject::new_unchecked(optional_attr(node, "subject").unwrap_or_default());
411
412        let addressing_mode =
413            AddressingMode::try_from(optional_attr(node, "addressing_mode").unwrap_or("pn"))?;
414
415        let participants = collect_children::<GroupParticipantResponse>(node, "participant")?;
416
417        Ok(Self {
418            id,
419            subject,
420            addressing_mode,
421            participants,
422        })
423    }
424}
425/// Request to get all groups the user is participating in.
426#[derive(Debug, Clone)]
427pub struct GroupParticipatingRequest {
428    pub include_participants: bool,
429    pub include_description: bool,
430}
431
432impl GroupParticipatingRequest {
433    pub fn new() -> Self {
434        Self {
435            include_participants: true,
436            include_description: true,
437        }
438    }
439}
440
441impl Default for GroupParticipatingRequest {
442    fn default() -> Self {
443        Self::new()
444    }
445}
446
447impl ProtocolNode for GroupParticipatingRequest {
448    fn tag(&self) -> &'static str {
449        "participating"
450    }
451
452    fn into_node(self) -> Node {
453        let mut children = Vec::new();
454        if self.include_participants {
455            children.push(NodeBuilder::new("participants").build());
456        }
457        if self.include_description {
458            children.push(NodeBuilder::new("description").build());
459        }
460        NodeBuilder::new("participating").children(children).build()
461    }
462
463    fn try_from_node(node: &Node) -> Result<Self> {
464        if node.tag != "participating" {
465            return Err(anyhow!("expected <participating>, got <{}>", node.tag));
466        }
467        Ok(Self::default())
468    }
469}
470
471/// Response containing all groups the user is participating in.
472#[derive(Debug, Clone, Default)]
473pub struct GroupParticipatingResponse {
474    pub groups: Vec<GroupInfoResponse>,
475}
476
477impl ProtocolNode for GroupParticipatingResponse {
478    fn tag(&self) -> &'static str {
479        "groups"
480    }
481
482    fn into_node(self) -> Node {
483        let children: Vec<Node> = self.groups.into_iter().map(|g| g.into_node()).collect();
484        NodeBuilder::new("groups").children(children).build()
485    }
486
487    fn try_from_node(node: &Node) -> Result<Self> {
488        if node.tag != "groups" {
489            return Err(anyhow!("expected <groups>, got <{}>", node.tag));
490        }
491
492        let groups = collect_children::<GroupInfoResponse>(node, "group")?;
493
494        Ok(Self { groups })
495    }
496}
497/// IQ specification for querying a specific group's info.
498#[derive(Debug, Clone)]
499pub struct GroupQueryIq {
500    pub group_jid: Jid,
501}
502
503impl GroupQueryIq {
504    pub fn new(group_jid: &Jid) -> Self {
505        Self {
506            group_jid: group_jid.clone(),
507        }
508    }
509}
510
511impl IqSpec for GroupQueryIq {
512    type Response = GroupInfoResponse;
513
514    fn build_iq(&self) -> InfoQuery<'static> {
515        InfoQuery::get_ref(
516            GROUP_IQ_NAMESPACE,
517            &self.group_jid,
518            Some(NodeContent::Nodes(vec![
519                GroupQueryRequest::default().into_node(),
520            ])),
521        )
522    }
523
524    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
525        let group_node = required_child(response, "group")?;
526        GroupInfoResponse::try_from_node(group_node)
527    }
528}
529
530/// IQ specification for getting all groups the user is participating in.
531#[derive(Debug, Clone, Default)]
532pub struct GroupParticipatingIq;
533
534impl GroupParticipatingIq {
535    pub fn new() -> Self {
536        Self
537    }
538}
539
540impl IqSpec for GroupParticipatingIq {
541    type Response = GroupParticipatingResponse;
542
543    fn build_iq(&self) -> InfoQuery<'static> {
544        InfoQuery::get(
545            GROUP_IQ_NAMESPACE,
546            Jid::new("", GROUP_SERVER),
547            Some(NodeContent::Nodes(vec![
548                GroupParticipatingRequest::new().into_node(),
549            ])),
550        )
551    }
552
553    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
554        let groups_node = required_child(response, "groups")?;
555        GroupParticipatingResponse::try_from_node(groups_node)
556    }
557}
558
559/// IQ specification for creating a new group.
560#[derive(Debug, Clone)]
561pub struct GroupCreateIq {
562    pub options: GroupCreateOptions,
563}
564
565impl GroupCreateIq {
566    pub fn new(options: GroupCreateOptions) -> Self {
567        Self { options }
568    }
569}
570
571impl IqSpec for GroupCreateIq {
572    type Response = Jid;
573
574    fn build_iq(&self) -> InfoQuery<'static> {
575        InfoQuery::set(
576            GROUP_IQ_NAMESPACE,
577            Jid::new("", GROUP_SERVER),
578            Some(NodeContent::Nodes(vec![build_create_group_node(
579                &self.options,
580            )])),
581        )
582    }
583
584    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
585        let group_node = required_child(response, "group")?;
586        let group_id_str = required_attr(group_node, "id")?;
587
588        if group_id_str.contains('@') {
589            group_id_str.parse().map_err(Into::into)
590        } else {
591            Ok(Jid::group(group_id_str))
592        }
593    }
594}
595
596// ---------------------------------------------------------------------------
597// Group Management IQ Specs
598// ---------------------------------------------------------------------------
599
600/// Response for participant change operations (add/remove/promote/demote).
601#[derive(Debug, Clone)]
602pub struct ParticipantChangeResponse {
603    pub jid: Jid,
604    /// HTTP-like status code (e.g. 200, 403, 409).
605    pub status: Option<String>,
606    pub error: Option<String>,
607}
608
609impl ProtocolNode for ParticipantChangeResponse {
610    fn tag(&self) -> &'static str {
611        "participant"
612    }
613
614    fn into_node(self) -> Node {
615        let mut builder = NodeBuilder::new("participant").attr("jid", self.jid.to_string());
616        if let Some(ref status) = self.status {
617            builder = builder.attr("type", status);
618        }
619        if let Some(ref error) = self.error {
620            builder = builder.attr("error", error);
621        }
622        builder.build()
623    }
624
625    fn try_from_node(node: &Node) -> Result<Self> {
626        if node.tag != "participant" {
627            return Err(anyhow!("expected <participant>, got <{}>", node.tag));
628        }
629        let jid = node
630            .attrs()
631            .optional_jid("jid")
632            .ok_or_else(|| anyhow!("participant missing required 'jid' attribute"))?;
633        let status = optional_attr(node, "type").map(String::from);
634        let error = optional_attr(node, "error").map(String::from);
635        Ok(Self { jid, status, error })
636    }
637}
638
639/// IQ specification for setting a group's subject.
640///
641/// Wire format:
642/// ```xml
643/// <iq type="set" xmlns="w:g2" to="{group_jid}">
644///   <subject>{text}</subject>
645/// </iq>
646/// ```
647#[derive(Debug, Clone)]
648pub struct SetGroupSubjectIq {
649    pub group_jid: Jid,
650    pub subject: GroupSubject,
651}
652
653impl SetGroupSubjectIq {
654    pub fn new(group_jid: &Jid, subject: GroupSubject) -> Self {
655        Self {
656            group_jid: group_jid.clone(),
657            subject,
658        }
659    }
660}
661
662impl IqSpec for SetGroupSubjectIq {
663    type Response = ();
664
665    fn build_iq(&self) -> InfoQuery<'static> {
666        InfoQuery::set_ref(
667            GROUP_IQ_NAMESPACE,
668            &self.group_jid,
669            Some(NodeContent::Nodes(vec![
670                NodeBuilder::new("subject")
671                    .string_content(self.subject.as_str())
672                    .build(),
673            ])),
674        )
675    }
676
677    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
678        Ok(())
679    }
680}
681
682/// IQ specification for setting a group's description.
683///
684/// Wire format:
685/// ```xml
686/// <iq type="set" xmlns="w:g2" to="{group_jid}">
687///   <description id="{new_id}" prev="{prev_id}"><body>{text}</body></description>
688/// </iq>
689/// ```
690///
691/// - `id`: random 8-char hex, generated automatically.
692/// - `prev`: the current description ID (from group metadata), used for conflict detection.
693/// - To delete the description, pass `None` as the description.
694#[derive(Debug, Clone)]
695pub struct SetGroupDescriptionIq {
696    pub group_jid: Jid,
697    pub description: Option<GroupDescription>,
698    /// New description ID (random 8-char hex).
699    pub id: String,
700    /// Previous description ID from group metadata, for conflict detection.
701    pub prev: Option<String>,
702}
703
704impl SetGroupDescriptionIq {
705    pub fn new(
706        group_jid: &Jid,
707        description: Option<GroupDescription>,
708        prev: Option<String>,
709    ) -> Self {
710        use rand::Rng;
711        let id = format!("{:08X}", rand::rng().random::<u32>());
712        Self {
713            group_jid: group_jid.clone(),
714            description,
715            id,
716            prev,
717        }
718    }
719}
720
721impl IqSpec for SetGroupDescriptionIq {
722    type Response = ();
723
724    fn build_iq(&self) -> InfoQuery<'static> {
725        let desc_node = if let Some(ref desc) = self.description {
726            let mut builder = NodeBuilder::new("description").attr("id", &self.id);
727            if let Some(ref prev) = self.prev {
728                builder = builder.attr("prev", prev);
729            }
730            builder
731                .children([NodeBuilder::new("body")
732                    .string_content(desc.as_str())
733                    .build()])
734                .build()
735        } else {
736            let mut builder = NodeBuilder::new("description")
737                .attr("id", &self.id)
738                .attr("delete", "true");
739            if let Some(ref prev) = self.prev {
740                builder = builder.attr("prev", prev);
741            }
742            builder.build()
743        };
744
745        InfoQuery::set_ref(
746            GROUP_IQ_NAMESPACE,
747            &self.group_jid,
748            Some(NodeContent::Nodes(vec![desc_node])),
749        )
750    }
751
752    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
753        Ok(())
754    }
755}
756
757/// IQ specification for leaving a group.
758///
759/// Wire format:
760/// ```xml
761/// <iq type="set" xmlns="w:g2" to="g.us">
762///   <leave><group id="{group_jid}"/></leave>
763/// </iq>
764/// ```
765#[derive(Debug, Clone)]
766pub struct LeaveGroupIq {
767    pub group_jid: Jid,
768}
769
770impl LeaveGroupIq {
771    pub fn new(group_jid: &Jid) -> Self {
772        Self {
773            group_jid: group_jid.clone(),
774        }
775    }
776}
777
778impl IqSpec for LeaveGroupIq {
779    type Response = ();
780
781    fn build_iq(&self) -> InfoQuery<'static> {
782        let group_node = NodeBuilder::new("group")
783            .attr("id", self.group_jid.to_string())
784            .build();
785        let leave_node = NodeBuilder::new("leave").children([group_node]).build();
786
787        InfoQuery::set(
788            GROUP_IQ_NAMESPACE,
789            Jid::new("", GROUP_SERVER),
790            Some(NodeContent::Nodes(vec![leave_node])),
791        )
792    }
793
794    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
795        Ok(())
796    }
797}
798
799/// IQ specification for adding participants to a group.
800///
801/// Wire format:
802/// ```xml
803/// <iq type="set" xmlns="w:g2" to="{group_jid}">
804///   <add>
805///     <participant jid="{user_jid}"/>
806///   </add>
807/// </iq>
808/// ```
809#[derive(Debug, Clone)]
810pub struct AddParticipantsIq {
811    pub group_jid: Jid,
812    pub participants: Vec<Jid>,
813}
814
815impl AddParticipantsIq {
816    pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
817        Self {
818            group_jid: group_jid.clone(),
819            participants: participants.to_vec(),
820        }
821    }
822}
823
824impl IqSpec for AddParticipantsIq {
825    type Response = Vec<ParticipantChangeResponse>;
826
827    fn build_iq(&self) -> InfoQuery<'static> {
828        let children: Vec<Node> = self
829            .participants
830            .iter()
831            .map(|jid| {
832                NodeBuilder::new("participant")
833                    .attr("jid", jid.to_string())
834                    .build()
835            })
836            .collect();
837
838        let add_node = NodeBuilder::new("add").children(children).build();
839
840        InfoQuery::set_ref(
841            GROUP_IQ_NAMESPACE,
842            &self.group_jid,
843            Some(NodeContent::Nodes(vec![add_node])),
844        )
845    }
846
847    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
848        let add_node = required_child(response, "add")?;
849        collect_children::<ParticipantChangeResponse>(add_node, "participant")
850    }
851}
852
853/// IQ specification for removing participants from a group.
854///
855/// Wire format:
856/// ```xml
857/// <iq type="set" xmlns="w:g2" to="{group_jid}">
858///   <remove>
859///     <participant jid="{user_jid}"/>
860///   </remove>
861/// </iq>
862/// ```
863#[derive(Debug, Clone)]
864pub struct RemoveParticipantsIq {
865    pub group_jid: Jid,
866    pub participants: Vec<Jid>,
867}
868
869impl RemoveParticipantsIq {
870    pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
871        Self {
872            group_jid: group_jid.clone(),
873            participants: participants.to_vec(),
874        }
875    }
876}
877
878impl IqSpec for RemoveParticipantsIq {
879    type Response = Vec<ParticipantChangeResponse>;
880
881    fn build_iq(&self) -> InfoQuery<'static> {
882        let children: Vec<Node> = self
883            .participants
884            .iter()
885            .map(|jid| {
886                NodeBuilder::new("participant")
887                    .attr("jid", jid.to_string())
888                    .build()
889            })
890            .collect();
891
892        let remove_node = NodeBuilder::new("remove").children(children).build();
893
894        InfoQuery::set_ref(
895            GROUP_IQ_NAMESPACE,
896            &self.group_jid,
897            Some(NodeContent::Nodes(vec![remove_node])),
898        )
899    }
900
901    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
902        let remove_node = required_child(response, "remove")?;
903        collect_children::<ParticipantChangeResponse>(remove_node, "participant")
904    }
905}
906
907/// IQ specification for promoting participants to admin.
908///
909/// Wire format:
910/// ```xml
911/// <iq type="set" xmlns="w:g2" to="{group_jid}">
912///   <promote>
913///     <participant jid="{user_jid}"/>
914///   </promote>
915/// </iq>
916/// ```
917#[derive(Debug, Clone)]
918pub struct PromoteParticipantsIq {
919    pub group_jid: Jid,
920    pub participants: Vec<Jid>,
921}
922
923impl PromoteParticipantsIq {
924    pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
925        Self {
926            group_jid: group_jid.clone(),
927            participants: participants.to_vec(),
928        }
929    }
930}
931
932impl IqSpec for PromoteParticipantsIq {
933    type Response = ();
934
935    fn build_iq(&self) -> InfoQuery<'static> {
936        let children: Vec<Node> = self
937            .participants
938            .iter()
939            .map(|jid| {
940                NodeBuilder::new("participant")
941                    .attr("jid", jid.to_string())
942                    .build()
943            })
944            .collect();
945
946        let promote_node = NodeBuilder::new("promote").children(children).build();
947
948        InfoQuery::set_ref(
949            GROUP_IQ_NAMESPACE,
950            &self.group_jid,
951            Some(NodeContent::Nodes(vec![promote_node])),
952        )
953    }
954
955    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
956        Ok(())
957    }
958}
959
960/// IQ specification for demoting participants from admin.
961///
962/// Wire format:
963/// ```xml
964/// <iq type="set" xmlns="w:g2" to="{group_jid}">
965///   <demote>
966///     <participant jid="{user_jid}"/>
967///   </demote>
968/// </iq>
969/// ```
970#[derive(Debug, Clone)]
971pub struct DemoteParticipantsIq {
972    pub group_jid: Jid,
973    pub participants: Vec<Jid>,
974}
975
976impl DemoteParticipantsIq {
977    pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
978        Self {
979            group_jid: group_jid.clone(),
980            participants: participants.to_vec(),
981        }
982    }
983}
984
985impl IqSpec for DemoteParticipantsIq {
986    type Response = ();
987
988    fn build_iq(&self) -> InfoQuery<'static> {
989        let children: Vec<Node> = self
990            .participants
991            .iter()
992            .map(|jid| {
993                NodeBuilder::new("participant")
994                    .attr("jid", jid.to_string())
995                    .build()
996            })
997            .collect();
998
999        let demote_node = NodeBuilder::new("demote").children(children).build();
1000
1001        InfoQuery::set_ref(
1002            GROUP_IQ_NAMESPACE,
1003            &self.group_jid,
1004            Some(NodeContent::Nodes(vec![demote_node])),
1005        )
1006    }
1007
1008    fn parse_response(&self, _response: &Node) -> Result<Self::Response> {
1009        Ok(())
1010    }
1011}
1012
1013/// IQ specification for getting (or resetting) a group's invite link.
1014///
1015/// - `reset: false` (GET) fetches the existing link.
1016/// - `reset: true` (SET) revokes the old link and generates a new one.
1017///
1018/// Response: `<invite code="XXXX"/>`
1019#[derive(Debug, Clone)]
1020pub struct GetGroupInviteLinkIq {
1021    pub group_jid: Jid,
1022    pub reset: bool,
1023}
1024
1025impl GetGroupInviteLinkIq {
1026    pub fn new(group_jid: &Jid, reset: bool) -> Self {
1027        Self {
1028            group_jid: group_jid.clone(),
1029            reset,
1030        }
1031    }
1032}
1033
1034impl IqSpec for GetGroupInviteLinkIq {
1035    type Response = String;
1036
1037    fn build_iq(&self) -> InfoQuery<'static> {
1038        let content = Some(NodeContent::Nodes(vec![NodeBuilder::new("invite").build()]));
1039        if self.reset {
1040            InfoQuery::set_ref(GROUP_IQ_NAMESPACE, &self.group_jid, content)
1041        } else {
1042            InfoQuery::get_ref(GROUP_IQ_NAMESPACE, &self.group_jid, content)
1043        }
1044    }
1045
1046    fn parse_response(&self, response: &Node) -> Result<Self::Response> {
1047        let invite_node = required_child(response, "invite")?;
1048        let code = required_attr(invite_node, "code")?;
1049        Ok(format!("https://chat.whatsapp.com/{code}"))
1050    }
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055    use super::*;
1056    use crate::request::InfoQueryType;
1057
1058    #[test]
1059    fn test_group_subject_validation() {
1060        let subject = GroupSubject::new("Test Group").unwrap();
1061        assert_eq!(subject.as_str(), "Test Group");
1062
1063        let at_limit = "a".repeat(GROUP_SUBJECT_MAX_LENGTH);
1064        assert!(GroupSubject::new(&at_limit).is_ok());
1065
1066        let over_limit = "a".repeat(GROUP_SUBJECT_MAX_LENGTH + 1);
1067        assert!(GroupSubject::new(&over_limit).is_err());
1068    }
1069
1070    #[test]
1071    fn test_group_description_validation() {
1072        let desc = GroupDescription::new("Test Description").unwrap();
1073        assert_eq!(desc.as_str(), "Test Description");
1074
1075        let at_limit = "a".repeat(GROUP_DESCRIPTION_MAX_LENGTH);
1076        assert!(GroupDescription::new(&at_limit).is_ok());
1077
1078        let over_limit = "a".repeat(GROUP_DESCRIPTION_MAX_LENGTH + 1);
1079        assert!(GroupDescription::new(&over_limit).is_err());
1080    }
1081
1082    #[test]
1083    fn test_string_enum_member_add_mode() {
1084        assert_eq!(MemberAddMode::AdminAdd.as_str(), "admin_add");
1085        assert_eq!(MemberAddMode::AllMemberAdd.as_str(), "all_member_add");
1086        assert_eq!(
1087            MemberAddMode::try_from("admin_add").unwrap(),
1088            MemberAddMode::AdminAdd
1089        );
1090        assert!(MemberAddMode::try_from("invalid").is_err());
1091    }
1092
1093    #[test]
1094    fn test_string_enum_member_link_mode() {
1095        assert_eq!(MemberLinkMode::AdminLink.as_str(), "admin_link");
1096        assert_eq!(MemberLinkMode::AllMemberLink.as_str(), "all_member_link");
1097        assert_eq!(
1098            MemberLinkMode::try_from("admin_link").unwrap(),
1099            MemberLinkMode::AdminLink
1100        );
1101    }
1102
1103    #[test]
1104    fn test_participant_type_is_admin() {
1105        assert!(!ParticipantType::Member.is_admin());
1106        assert!(ParticipantType::Admin.is_admin());
1107        assert!(ParticipantType::SuperAdmin.is_admin());
1108    }
1109
1110    #[test]
1111    fn test_normalize_participants_drops_phone_for_pn() {
1112        let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap();
1113        let lid_jid: Jid = "100000000000001@lid".parse().unwrap();
1114        let phone_jid: Jid = "15550000001@s.whatsapp.net".parse().unwrap();
1115
1116        let participants = vec![
1117            GroupParticipantOptions::new(pn_jid.clone()).with_phone_number(phone_jid.clone()),
1118            GroupParticipantOptions::new(lid_jid.clone()).with_phone_number(phone_jid.clone()),
1119        ];
1120
1121        let normalized = normalize_participants(&participants);
1122        assert!(normalized[0].phone_number.is_none());
1123        assert_eq!(normalized[0].jid, pn_jid);
1124        assert_eq!(normalized[1].phone_number.as_ref(), Some(&phone_jid));
1125    }
1126
1127    #[test]
1128    fn test_build_create_group_node() {
1129        let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap();
1130        let options = GroupCreateOptions::new("Test Subject")
1131            .with_participant(GroupParticipantOptions::from_phone(pn_jid))
1132            .with_member_link_mode(MemberLinkMode::AllMemberLink)
1133            .with_member_add_mode(MemberAddMode::AdminAdd);
1134
1135        let node = build_create_group_node(&options);
1136        assert_eq!(node.tag, "create");
1137        assert_eq!(
1138            node.attrs().optional_string("subject"),
1139            Some("Test Subject")
1140        );
1141
1142        let link_mode = node.get_children_by_tag("member_link_mode").next().unwrap();
1143        assert_eq!(
1144            link_mode.content.as_ref().and_then(|c| match c {
1145                NodeContent::String(s) => Some(s.as_str()),
1146                _ => None,
1147            }),
1148            Some("all_member_link")
1149        );
1150    }
1151
1152    #[test]
1153    fn test_typed_builder() {
1154        let options: GroupCreateOptions = GroupCreateOptions::builder()
1155            .subject("My Group")
1156            .member_add_mode(MemberAddMode::AdminAdd)
1157            .build();
1158
1159        assert_eq!(options.subject, "My Group");
1160        assert_eq!(options.member_add_mode, Some(MemberAddMode::AdminAdd));
1161    }
1162
1163    #[test]
1164    fn test_set_group_description_with_id_and_prev() {
1165        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
1166        let desc = GroupDescription::new("New description").unwrap();
1167        let spec = SetGroupDescriptionIq::new(&jid, Some(desc), Some("AABBCCDD".to_string()));
1168        let iq = spec.build_iq();
1169
1170        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1171            let desc_node = &nodes[0];
1172            assert_eq!(desc_node.tag, "description");
1173            // id is random hex, just check it exists and is 8 chars
1174            let id = desc_node.attrs().optional_string("id").unwrap();
1175            assert_eq!(id.len(), 8);
1176            assert_eq!(desc_node.attrs().optional_string("prev"), Some("AABBCCDD"));
1177            // Should have a <body> child
1178            assert!(desc_node.get_children_by_tag("body").next().is_some());
1179        } else {
1180            panic!("expected nodes content");
1181        }
1182    }
1183
1184    #[test]
1185    fn test_set_group_description_delete() {
1186        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
1187        let spec = SetGroupDescriptionIq::new(&jid, None, Some("PREV1234".to_string()));
1188        let iq = spec.build_iq();
1189
1190        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1191            let desc_node = &nodes[0];
1192            assert_eq!(desc_node.tag, "description");
1193            assert_eq!(desc_node.attrs().optional_string("delete"), Some("true"));
1194            assert_eq!(desc_node.attrs().optional_string("prev"), Some("PREV1234"));
1195            // id should still be present
1196            assert!(desc_node.attrs().optional_string("id").is_some());
1197        } else {
1198            panic!("expected nodes content");
1199        }
1200    }
1201
1202    #[test]
1203    fn test_leave_group_iq() {
1204        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
1205        let spec = LeaveGroupIq::new(&jid);
1206        let iq = spec.build_iq();
1207
1208        assert_eq!(iq.namespace, GROUP_IQ_NAMESPACE);
1209        assert_eq!(iq.query_type, InfoQueryType::Set);
1210        // Leave goes to g.us, not the group JID
1211        assert_eq!(iq.to.server, GROUP_SERVER);
1212    }
1213
1214    #[test]
1215    fn test_add_participants_iq() {
1216        let group: Jid = "120363000000000001@g.us".parse().unwrap();
1217        let p1: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
1218        let p2: Jid = "9876543210@s.whatsapp.net".parse().unwrap();
1219        let spec = AddParticipantsIq::new(&group, &[p1, p2]);
1220        let iq = spec.build_iq();
1221
1222        assert_eq!(iq.namespace, GROUP_IQ_NAMESPACE);
1223        assert_eq!(iq.to, group);
1224
1225        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1226            let add_node = &nodes[0];
1227            assert_eq!(add_node.tag, "add");
1228            let participants: Vec<_> = add_node.get_children_by_tag("participant").collect();
1229            assert_eq!(participants.len(), 2);
1230        } else {
1231            panic!("expected nodes content");
1232        }
1233    }
1234
1235    #[test]
1236    fn test_promote_demote_iq() {
1237        let group: Jid = "120363000000000001@g.us".parse().unwrap();
1238        let p1: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
1239
1240        let promote = PromoteParticipantsIq::new(&group, std::slice::from_ref(&p1));
1241        let iq = promote.build_iq();
1242        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1243            assert_eq!(nodes[0].tag, "promote");
1244        } else {
1245            panic!("expected nodes content");
1246        }
1247
1248        let demote = DemoteParticipantsIq::new(&group, &[p1]);
1249        let iq = demote.build_iq();
1250        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
1251            assert_eq!(nodes[0].tag, "demote");
1252        } else {
1253            panic!("expected nodes content");
1254        }
1255    }
1256
1257    #[test]
1258    fn test_get_group_invite_link_iq() {
1259        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
1260        let spec = GetGroupInviteLinkIq::new(&jid, false);
1261        let iq = spec.build_iq();
1262
1263        assert_eq!(iq.query_type, InfoQueryType::Get);
1264        assert_eq!(iq.to, jid);
1265
1266        // With reset=true it should be a SET
1267        let reset_spec = GetGroupInviteLinkIq::new(&jid, true);
1268        assert_eq!(reset_spec.build_iq().query_type, InfoQueryType::Set);
1269    }
1270
1271    #[test]
1272    fn test_get_group_invite_link_parse_response() {
1273        let jid: Jid = "120363000000000001@g.us".parse().unwrap();
1274        let spec = GetGroupInviteLinkIq::new(&jid, false);
1275
1276        let response = NodeBuilder::new("response")
1277            .children([NodeBuilder::new("invite")
1278                .attr("code", "AbCdEfGhIjKl")
1279                .build()])
1280            .build();
1281
1282        let result = spec.parse_response(&response).unwrap();
1283        assert_eq!(result, "https://chat.whatsapp.com/AbCdEfGhIjKl");
1284    }
1285
1286    #[test]
1287    fn test_participant_change_response_parse() {
1288        let node = NodeBuilder::new("participant")
1289            .attr("jid", "1234567890@s.whatsapp.net")
1290            .attr("type", "200")
1291            .build();
1292
1293        let result = ParticipantChangeResponse::try_from_node(&node).unwrap();
1294        assert_eq!(result.jid.user, "1234567890");
1295        assert_eq!(result.status, Some("200".to_string()));
1296    }
1297}