Skip to main content

whatsapp_rust/features/
community.rs

1//! Community feature.
2//!
3//! Communities are parent groups that contain linked subgroups.
4//! Uses the `w:g2` IQ namespace for mutations and MEX (GraphQL) for metadata queries.
5
6use crate::client::Client;
7use crate::features::groups::GroupMetadata;
8use crate::features::groups::GroupParticipant;
9use crate::features::mex::{MexError, MexRequest};
10use log::warn;
11use serde_json::json;
12use wacore::iq::groups::{
13    DeleteCommunityIq, GetLinkedGroupsParticipantsIq, GroupCreateIq, GroupCreateOptions,
14    JoinLinkedGroupIq, LinkSubgroupsIq, QueryLinkedGroupIq, UnlinkSubgroupsIq,
15};
16use wacore::iq::mex_ids::community as community_docs;
17use wacore_binary::Jid;
18
19// Types
20
21/// Classification of a group within the community hierarchy.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum GroupType {
25    /// Regular standalone group (not part of a community).
26    Default,
27    /// Community parent group.
28    Community,
29    /// A subgroup linked to a community.
30    LinkedSubgroup,
31    /// The default announcement subgroup of a community.
32    LinkedAnnouncementGroup,
33    /// The general chat subgroup of a community.
34    LinkedGeneralGroup,
35}
36
37/// Options for creating a new community.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct CreateCommunityOptions {
40    pub name: String,
41    pub description: Option<String>,
42    /// Whether the community is closed (requires approval to join).
43    pub closed: bool,
44    /// Allow non-admin members to create subgroups.
45    pub allow_non_admin_sub_group_creation: bool,
46    /// Create a general chat subgroup alongside the community.
47    pub create_general_chat: bool,
48}
49
50impl CreateCommunityOptions {
51    pub fn new(name: impl Into<String>) -> Self {
52        Self {
53            name: name.into(),
54            description: None,
55            closed: false,
56            allow_non_admin_sub_group_creation: false,
57            create_general_chat: true,
58        }
59    }
60}
61
62/// Result of creating a community.
63#[derive(Debug, Clone)]
64pub struct CreateCommunityResult {
65    pub metadata: GroupMetadata,
66}
67
68/// A subgroup within a community.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct CommunitySubgroup {
71    pub id: Jid,
72    pub subject: String,
73    pub participant_count: Option<u32>,
74    pub is_default_sub_group: bool,
75    pub is_general_chat: bool,
76}
77
78/// Result of linking subgroups to a community.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct LinkSubgroupsResult {
81    pub linked_jids: Vec<Jid>,
82    pub failed_groups: Vec<(Jid, u32)>,
83}
84
85/// Result of unlinking subgroups from a community.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct UnlinkSubgroupsResult {
88    pub unlinked_jids: Vec<Jid>,
89    pub failed_groups: Vec<(Jid, u32)>,
90}
91
92/// Determine the group type from metadata fields.
93pub fn group_type(metadata: &GroupMetadata) -> GroupType {
94    if metadata.is_default_sub_group {
95        GroupType::LinkedAnnouncementGroup
96    } else if metadata.is_general_chat {
97        GroupType::LinkedGeneralGroup
98    } else if metadata.parent_group_jid.is_some() {
99        GroupType::LinkedSubgroup
100    } else if metadata.is_parent_group {
101        GroupType::Community
102    } else {
103        GroupType::Default
104    }
105}
106
107// Feature handle
108
109pub struct Community<'a> {
110    client: &'a Client,
111}
112
113impl<'a> Community<'a> {
114    pub(crate) fn new(client: &'a Client) -> Self {
115        Self { client }
116    }
117
118    /// Create a new community.
119    ///
120    /// If a description is provided, it is set via a follow-up IQ after creation
121    /// (the group create stanza does not support inline descriptions for communities).
122    pub async fn create(
123        &self,
124        options: CreateCommunityOptions,
125    ) -> Result<CreateCommunityResult, anyhow::Error> {
126        let description = options.description.clone();
127
128        let create_options = GroupCreateOptions {
129            subject: options.name,
130            is_parent: true,
131            closed: options.closed,
132            allow_non_admin_sub_group_creation: options.allow_non_admin_sub_group_creation,
133            create_general_chat: options.create_general_chat,
134            ..Default::default()
135        };
136
137        let group = self
138            .client
139            .execute(GroupCreateIq::new(create_options))
140            .await?;
141        let mut metadata = GroupMetadata::from(group);
142
143        if let Some(desc_text) = description
144            && let Ok(desc) = wacore::iq::groups::GroupDescription::new(&desc_text)
145        {
146            self.client
147                .groups()
148                .set_description(&metadata.id, Some(desc), None)
149                .await?;
150            metadata.description = Some(desc_text);
151        }
152
153        Ok(CreateCommunityResult { metadata })
154    }
155
156    /// Deactivate (delete) a community. Subgroups are unlinked but not deleted.
157    pub async fn deactivate(&self, community_jid: &Jid) -> Result<(), anyhow::Error> {
158        self.client
159            .execute(DeleteCommunityIq::new(community_jid))
160            .await?;
161        Ok(())
162    }
163
164    /// Link existing groups as subgroups of a community.
165    pub async fn link_subgroups(
166        &self,
167        community_jid: &Jid,
168        subgroup_jids: &[Jid],
169    ) -> Result<LinkSubgroupsResult, anyhow::Error> {
170        let response = self
171            .client
172            .execute(LinkSubgroupsIq::new(community_jid, subgroup_jids))
173            .await?;
174
175        let mut linked_jids = Vec::with_capacity(response.groups.len());
176        let mut failed_groups = Vec::with_capacity(response.groups.len());
177
178        for group in response.groups {
179            if let Some(error) = group.error {
180                failed_groups.push((group.jid, error));
181            } else {
182                linked_jids.push(group.jid);
183            }
184        }
185
186        Ok(LinkSubgroupsResult {
187            linked_jids,
188            failed_groups,
189        })
190    }
191
192    /// Unlink subgroups from a community.
193    pub async fn unlink_subgroups(
194        &self,
195        community_jid: &Jid,
196        subgroup_jids: &[Jid],
197        remove_orphan_members: bool,
198    ) -> Result<UnlinkSubgroupsResult, anyhow::Error> {
199        let response = self
200            .client
201            .execute(UnlinkSubgroupsIq::new(
202                community_jid,
203                subgroup_jids,
204                remove_orphan_members,
205            ))
206            .await?;
207
208        let mut unlinked_jids = Vec::with_capacity(response.groups.len());
209        let mut failed_groups = Vec::with_capacity(response.groups.len());
210
211        for group in response.groups {
212            if let Some(error) = group.error {
213                failed_groups.push((group.jid, error));
214            } else {
215                unlinked_jids.push(group.jid);
216            }
217        }
218
219        Ok(UnlinkSubgroupsResult {
220            unlinked_jids,
221            failed_groups,
222        })
223    }
224
225    /// Fetch all subgroups of a community via MEX (GraphQL).
226    pub async fn get_subgroups(
227        &self,
228        community_jid: &Jid,
229    ) -> Result<Vec<CommunitySubgroup>, MexError> {
230        let response = self
231            .client
232            .mex()
233            .query(MexRequest {
234                doc: community_docs::FETCH_ALL_SUBGROUPS,
235                variables: json!({
236                    "group_id": community_jid.to_string()
237                }),
238            })
239            .await?;
240
241        let data = response
242            .data
243            .ok_or_else(|| MexError::PayloadParsing("missing data field".into()))?;
244
245        let group_query = &data["xwa2_group_query_by_id"];
246        let mut subgroups = Vec::new();
247
248        // Parse default subgroup
249        if let Some(default_sub) = group_query.get("default_sub_group")
250            && !default_sub.is_null()
251            && let Some(sg) = parse_subgroup_node(default_sub, true)
252        {
253            subgroups.push(sg);
254        }
255
256        // Parse regular subgroups
257        if let Some(sub_groups) = group_query.get("sub_groups")
258            && let Some(edges) = sub_groups.get("edges").and_then(|e| e.as_array())
259        {
260            for edge in edges {
261                if let Some(node) = edge.get("node")
262                    && let Some(sg) = parse_subgroup_node(node, false)
263                {
264                    subgroups.push(sg);
265                }
266            }
267        }
268
269        Ok(subgroups)
270    }
271
272    /// Fetch participant counts per subgroup via MEX (GraphQL).
273    pub async fn get_subgroup_participant_counts(
274        &self,
275        community_jid: &Jid,
276    ) -> Result<Vec<(Jid, u32)>, MexError> {
277        let response = self
278            .client
279            .mex()
280            .query(MexRequest {
281                doc: community_docs::FETCH_SUBGROUP_PARTICIPANT_COUNT,
282                variables: json!({
283                    "input": {
284                        "group_jid": community_jid.to_string()
285                    }
286                }),
287            })
288            .await?;
289
290        let data = response
291            .data
292            .ok_or_else(|| MexError::PayloadParsing("missing data field".into()))?;
293
294        let group_query = &data["xwa2_group_query_by_id"];
295        let edges_ref = group_query
296            .get("sub_groups")
297            .and_then(|s| s.get("edges"))
298            .and_then(|e| e.as_array());
299        let mut counts = Vec::with_capacity(edges_ref.map_or(0, |e| e.len()));
300
301        if let Some(edges) = edges_ref {
302            for edge in edges {
303                if let Some(node) = edge.get("node") {
304                    let id_str = node["id"].as_str().unwrap_or_default();
305                    let count = node
306                        .get("total_participants_count")
307                        .or_else(|| node.get("participants_count"))
308                        .and_then(|c| c.as_u64())
309                        .unwrap_or(0) as u32;
310                    match id_str.parse::<Jid>() {
311                        Ok(jid) => counts.push((jid, count)),
312                        Err(_) => warn!(
313                            "community: skipping subgroup with unparseable id: {:?}",
314                            id_str
315                        ),
316                    }
317                }
318            }
319        }
320
321        Ok(counts)
322    }
323
324    /// Query a linked subgroup's metadata from the parent community.
325    pub async fn query_linked_group(
326        &self,
327        community_jid: &Jid,
328        subgroup_jid: &Jid,
329    ) -> Result<GroupMetadata, anyhow::Error> {
330        let response = self
331            .client
332            .execute(QueryLinkedGroupIq::new(community_jid, subgroup_jid))
333            .await?;
334        Ok(GroupMetadata::from(response))
335    }
336
337    /// Join a linked subgroup via the parent community.
338    pub async fn join_subgroup(
339        &self,
340        community_jid: &Jid,
341        subgroup_jid: &Jid,
342    ) -> Result<GroupMetadata, anyhow::Error> {
343        let response = self
344            .client
345            .execute(JoinLinkedGroupIq::new(community_jid, subgroup_jid))
346            .await?;
347        Ok(GroupMetadata::from(response))
348    }
349
350    /// Get all participants across all linked groups of a community.
351    pub async fn get_linked_groups_participants(
352        &self,
353        community_jid: &Jid,
354    ) -> Result<Vec<GroupParticipant>, anyhow::Error> {
355        let response = self
356            .client
357            .execute(GetLinkedGroupsParticipantsIq::new(community_jid))
358            .await?;
359        Ok(response.into_iter().map(Into::into).collect())
360    }
361}
362
363fn parse_subgroup_node(node: &serde_json::Value, is_default: bool) -> Option<CommunitySubgroup> {
364    let id_str = node.get("id")?.as_str()?;
365    let jid: Jid = id_str.parse().ok()?;
366
367    // Subject can be a plain string or an object {"value": "..."}
368    let subject = node
369        .get("subject")
370        .and_then(|s| {
371            s.as_str().map(|v| v.to_string()).or_else(|| {
372                s.get("value")
373                    .and_then(|v| v.as_str())
374                    .map(|v| v.to_string())
375            })
376        })
377        .unwrap_or_default();
378
379    let participant_count = node
380        .get("participants_count")
381        .or_else(|| node.get("total_participants_count"))
382        .and_then(|c| c.as_u64())
383        .map(|c| c as u32);
384
385    // Check if properties indicate general chat
386    let is_general_from_props = node
387        .get("properties")
388        .and_then(|p| p.get("general_chat"))
389        .and_then(|v| v.as_bool())
390        .unwrap_or(false);
391
392    Some(CommunitySubgroup {
393        id: jid,
394        subject,
395        participant_count,
396        is_default_sub_group: is_default,
397        is_general_chat: is_general_from_props,
398    })
399}
400
401impl Client {
402    pub fn community(&self) -> Community<'_> {
403        Community::new(self)
404    }
405}