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::community::mex_docs;
13use wacore::iq::groups::{
14    DeleteCommunityIq, GetLinkedGroupsParticipantsIq, GroupCreateIq, GroupCreateOptions,
15    JoinLinkedGroupIq, LinkSubgroupsIq, QueryLinkedGroupIq, UnlinkSubgroupsIq,
16};
17use wacore_binary::jid::Jid;
18
19// Types
20
21/// Classification of a group within the community hierarchy.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum GroupType {
24    /// Regular standalone group (not part of a community).
25    Default,
26    /// Community parent group.
27    Community,
28    /// A subgroup linked to a community.
29    LinkedSubgroup,
30    /// The default announcement subgroup of a community.
31    LinkedAnnouncementGroup,
32    /// The general chat subgroup of a community.
33    LinkedGeneralGroup,
34}
35
36/// Options for creating a new community.
37#[derive(Debug, Clone)]
38pub struct CreateCommunityOptions {
39    pub name: String,
40    pub description: Option<String>,
41    /// Whether the community is closed (requires approval to join).
42    pub closed: bool,
43    /// Allow non-admin members to create subgroups.
44    pub allow_non_admin_sub_group_creation: bool,
45    /// Create a general chat subgroup alongside the community.
46    pub create_general_chat: bool,
47}
48
49impl CreateCommunityOptions {
50    pub fn new(name: impl Into<String>) -> Self {
51        Self {
52            name: name.into(),
53            description: None,
54            closed: false,
55            allow_non_admin_sub_group_creation: false,
56            create_general_chat: true,
57        }
58    }
59}
60
61/// Result of creating a community.
62#[derive(Debug, Clone)]
63pub struct CreateCommunityResult {
64    /// JID of the created community parent group.
65    pub gid: Jid,
66}
67
68/// A subgroup within a community.
69#[derive(Debug, Clone)]
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)]
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)]
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 gid = self
138            .client
139            .execute(GroupCreateIq::new(create_options))
140            .await?;
141
142        // Set description via follow-up IQ if provided
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(&gid, Some(desc), None)
149                .await?;
150        }
151
152        Ok(CreateCommunityResult { gid })
153    }
154
155    /// Deactivate (delete) a community. Subgroups are unlinked but not deleted.
156    pub async fn deactivate(&self, community_jid: &Jid) -> Result<(), anyhow::Error> {
157        self.client
158            .execute(DeleteCommunityIq::new(community_jid))
159            .await?;
160        Ok(())
161    }
162
163    /// Link existing groups as subgroups of a community.
164    pub async fn link_subgroups(
165        &self,
166        community_jid: &Jid,
167        subgroup_jids: &[Jid],
168    ) -> Result<LinkSubgroupsResult, anyhow::Error> {
169        let response = self
170            .client
171            .execute(LinkSubgroupsIq::new(community_jid, subgroup_jids))
172            .await?;
173
174        let mut linked_jids = Vec::new();
175        let mut failed_groups = Vec::new();
176
177        for group in response.groups {
178            if let Some(error) = group.error {
179                failed_groups.push((group.jid, error));
180            } else {
181                linked_jids.push(group.jid);
182            }
183        }
184
185        Ok(LinkSubgroupsResult {
186            linked_jids,
187            failed_groups,
188        })
189    }
190
191    /// Unlink subgroups from a community.
192    pub async fn unlink_subgroups(
193        &self,
194        community_jid: &Jid,
195        subgroup_jids: &[Jid],
196        remove_orphan_members: bool,
197    ) -> Result<UnlinkSubgroupsResult, anyhow::Error> {
198        let response = self
199            .client
200            .execute(UnlinkSubgroupsIq::new(
201                community_jid,
202                subgroup_jids,
203                remove_orphan_members,
204            ))
205            .await?;
206
207        let mut unlinked_jids = Vec::new();
208        let mut failed_groups = Vec::new();
209
210        for group in response.groups {
211            if let Some(error) = group.error {
212                failed_groups.push((group.jid, error));
213            } else {
214                unlinked_jids.push(group.jid);
215            }
216        }
217
218        Ok(UnlinkSubgroupsResult {
219            unlinked_jids,
220            failed_groups,
221        })
222    }
223
224    /// Fetch all subgroups of a community via MEX (GraphQL).
225    pub async fn get_subgroups(
226        &self,
227        community_jid: &Jid,
228    ) -> Result<Vec<CommunitySubgroup>, MexError> {
229        let response = self
230            .client
231            .mex()
232            .query(MexRequest {
233                doc_id: mex_docs::FETCH_ALL_SUBGROUPS,
234                variables: json!({
235                    "group_id": community_jid.to_string()
236                }),
237            })
238            .await?;
239
240        let data = response
241            .data
242            .ok_or_else(|| MexError::PayloadParsing("missing data field".into()))?;
243
244        let group_query = &data["xwa2_group_query_by_id"];
245        let mut subgroups = Vec::new();
246
247        // Parse default subgroup
248        if let Some(default_sub) = group_query.get("default_sub_group")
249            && !default_sub.is_null()
250            && let Some(sg) = parse_subgroup_node(default_sub, true)
251        {
252            subgroups.push(sg);
253        }
254
255        // Parse regular subgroups
256        if let Some(sub_groups) = group_query.get("sub_groups")
257            && let Some(edges) = sub_groups.get("edges").and_then(|e| e.as_array())
258        {
259            for edge in edges {
260                if let Some(node) = edge.get("node")
261                    && let Some(sg) = parse_subgroup_node(node, false)
262                {
263                    subgroups.push(sg);
264                }
265            }
266        }
267
268        Ok(subgroups)
269    }
270
271    /// Fetch participant counts per subgroup via MEX (GraphQL).
272    pub async fn get_subgroup_participant_counts(
273        &self,
274        community_jid: &Jid,
275    ) -> Result<Vec<(Jid, u32)>, MexError> {
276        let response = self
277            .client
278            .mex()
279            .query(MexRequest {
280                doc_id: mex_docs::FETCH_SUBGROUP_PARTICIPANT_COUNT,
281                variables: json!({
282                    "input": {
283                        "group_jid": community_jid.to_string()
284                    }
285                }),
286            })
287            .await?;
288
289        let data = response
290            .data
291            .ok_or_else(|| MexError::PayloadParsing("missing data field".into()))?;
292
293        let group_query = &data["xwa2_group_query_by_id"];
294        let mut counts = Vec::new();
295
296        if let Some(sub_groups) = group_query.get("sub_groups")
297            && let Some(edges) = sub_groups.get("edges").and_then(|e| e.as_array())
298        {
299            for edge in edges {
300                if let Some(node) = edge.get("node") {
301                    let id_str = node["id"].as_str().unwrap_or_default();
302                    let count = node
303                        .get("total_participants_count")
304                        .or_else(|| node.get("participants_count"))
305                        .and_then(|c| c.as_u64())
306                        .unwrap_or(0) as u32;
307                    match id_str.parse::<Jid>() {
308                        Ok(jid) => counts.push((jid, count)),
309                        Err(_) => warn!(
310                            "community: skipping subgroup with unparseable id: {:?}",
311                            id_str
312                        ),
313                    }
314                }
315            }
316        }
317
318        Ok(counts)
319    }
320
321    /// Query a linked subgroup's metadata from the parent community.
322    pub async fn query_linked_group(
323        &self,
324        community_jid: &Jid,
325        subgroup_jid: &Jid,
326    ) -> Result<GroupMetadata, anyhow::Error> {
327        let response = self
328            .client
329            .execute(QueryLinkedGroupIq::new(community_jid, subgroup_jid))
330            .await?;
331        Ok(GroupMetadata::from(response))
332    }
333
334    /// Join a linked subgroup via the parent community.
335    pub async fn join_subgroup(
336        &self,
337        community_jid: &Jid,
338        subgroup_jid: &Jid,
339    ) -> Result<GroupMetadata, anyhow::Error> {
340        let response = self
341            .client
342            .execute(JoinLinkedGroupIq::new(community_jid, subgroup_jid))
343            .await?;
344        Ok(GroupMetadata::from(response))
345    }
346
347    /// Get all participants across all linked groups of a community.
348    pub async fn get_linked_groups_participants(
349        &self,
350        community_jid: &Jid,
351    ) -> Result<Vec<GroupParticipant>, anyhow::Error> {
352        let response = self
353            .client
354            .execute(GetLinkedGroupsParticipantsIq::new(community_jid))
355            .await?;
356        Ok(response.into_iter().map(Into::into).collect())
357    }
358}
359
360fn parse_subgroup_node(node: &serde_json::Value, is_default: bool) -> Option<CommunitySubgroup> {
361    let id_str = node.get("id")?.as_str()?;
362    let jid: Jid = id_str.parse().ok()?;
363
364    // Subject can be a plain string or an object {"value": "..."}
365    let subject = node
366        .get("subject")
367        .and_then(|s| {
368            s.as_str().map(|v| v.to_string()).or_else(|| {
369                s.get("value")
370                    .and_then(|v| v.as_str())
371                    .map(|v| v.to_string())
372            })
373        })
374        .unwrap_or_default();
375
376    let participant_count = node
377        .get("participants_count")
378        .or_else(|| node.get("total_participants_count"))
379        .and_then(|c| c.as_u64())
380        .map(|c| c as u32);
381
382    // Check if properties indicate general chat
383    let is_general_from_props = node
384        .get("properties")
385        .and_then(|p| p.get("general_chat"))
386        .and_then(|v| v.as_bool())
387        .unwrap_or(false);
388
389    Some(CommunitySubgroup {
390        id: jid,
391        subject,
392        participant_count,
393        is_default_sub_group: is_default,
394        is_general_chat: is_general_from_props,
395    })
396}
397
398impl Client {
399    pub fn community(&self) -> Community<'_> {
400        Community::new(self)
401    }
402}