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::GroupError;
8use crate::features::groups::GroupMetadata;
9use crate::features::groups::GroupParticipant;
10use crate::features::groups::GroupParticipantOptions;
11use crate::features::groups::ParticipantChangeResponse;
12use crate::features::groups::PreviousDescription;
13use crate::features::mex::{MexError, mex_request};
14use crate::request::IqError;
15use log::warn;
16use thiserror::Error;
17use wacore::iq::groups::{
18    CommunityParticipatingIq, DeleteCommunityIq, GetLinkedGroupsParticipantsIq, GroupCreateOptions,
19    JoinLinkedGroupIq, LinkSubgroupsIq, QueryLinkedGroupIq, UnlinkSubgroupsIq,
20};
21use wacore::iq::mex_operations::{fetch_all_subgroups, query_subgroup_participant_count};
22use wacore_binary::Jid;
23
24/// Error returned by community operations.
25#[derive(Debug, Error)]
26#[non_exhaustive]
27pub enum CommunityError {
28    /// A `w:g2` IQ to the server failed.
29    #[error("{0}")]
30    Iq(#[from] IqError),
31    /// A MEX (GraphQL) metadata query/mutation failed or returned bad data.
32    #[error("{0}")]
33    Mex(#[from] MexError),
34    /// A delegated group operation failed (e.g. setting the community description).
35    #[error("{0}")]
36    Group(#[from] GroupError),
37    /// The request was malformed or the server response was missing required data.
38    #[error("invalid community request: {0}")]
39    InvalidRequest(String),
40}
41
42// Types
43
44/// Classification of a group within the community hierarchy.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum GroupType {
48    /// Regular standalone group (not part of a community).
49    Default,
50    /// Community parent group.
51    Community,
52    /// A subgroup linked to a community.
53    LinkedSubgroup,
54    /// The default announcement subgroup of a community.
55    LinkedAnnouncementGroup,
56    /// The general chat subgroup of a community.
57    LinkedGeneralGroup,
58}
59
60/// Options for creating a new community.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CreateCommunityOptions {
63    pub name: String,
64    pub description: Option<String>,
65    /// Whether the community is closed (requires approval to join).
66    pub closed: bool,
67    /// Allow non-admin members to create subgroups.
68    pub allow_non_admin_sub_group_creation: bool,
69    /// Create a general chat subgroup alongside the community.
70    pub create_general_chat: bool,
71}
72
73impl CreateCommunityOptions {
74    pub fn new(name: impl Into<String>) -> Self {
75        Self {
76            name: name.into(),
77            description: None,
78            closed: false,
79            allow_non_admin_sub_group_creation: false,
80            create_general_chat: true,
81        }
82    }
83}
84
85/// Result of creating a community.
86#[derive(Debug, Clone)]
87#[non_exhaustive]
88pub struct CreateCommunityResult {
89    pub metadata: GroupMetadata,
90}
91
92/// A subgroup within a community.
93#[derive(Debug, Clone, PartialEq, Eq)]
94#[non_exhaustive]
95pub struct CommunitySubgroup {
96    pub id: Jid,
97    pub subject: String,
98    pub participant_count: Option<u32>,
99    /// Server-reported subgroup creation timestamp, when available.
100    pub creation: Option<u64>,
101    /// Server-reported subgroup creator, when available.
102    pub owner: Option<Jid>,
103    pub is_default_sub_group: bool,
104    pub is_general_chat: bool,
105}
106
107/// Result of linking subgroups to a community.
108#[derive(Debug, Clone, PartialEq, Eq)]
109#[non_exhaustive]
110pub struct LinkSubgroupsResult {
111    pub linked_jids: Vec<Jid>,
112    pub failed_groups: Vec<(Jid, u32)>,
113}
114
115/// Result of unlinking subgroups from a community.
116#[derive(Debug, Clone, PartialEq, Eq)]
117#[non_exhaustive]
118pub struct UnlinkSubgroupsResult {
119    pub unlinked_jids: Vec<Jid>,
120    pub failed_groups: Vec<(Jid, u32)>,
121}
122
123/// Determine the group type from metadata fields.
124pub fn group_type(metadata: &GroupMetadata) -> GroupType {
125    if metadata.is_default_sub_group {
126        GroupType::LinkedAnnouncementGroup
127    } else if metadata.is_general_chat {
128        GroupType::LinkedGeneralGroup
129    } else if metadata.parent_group_jid.is_some() {
130        GroupType::LinkedSubgroup
131    } else if metadata.is_parent_group {
132        GroupType::Community
133    } else {
134        GroupType::Default
135    }
136}
137
138// Feature handle
139
140pub struct Community<'a> {
141    client: &'a Client,
142}
143
144impl<'a> Community<'a> {
145    pub(crate) fn new(client: &'a Client) -> Self {
146        Self { client }
147    }
148
149    /// Create a new community.
150    ///
151    /// If a description is provided, it is set via a follow-up IQ after creation
152    /// (the group create stanza does not support inline descriptions for communities).
153    pub async fn create(
154        &self,
155        options: CreateCommunityOptions,
156    ) -> Result<CreateCommunityResult, CommunityError> {
157        let description = options.description.clone();
158
159        let create_options = GroupCreateOptions {
160            subject: options.name,
161            is_parent: true,
162            closed: options.closed,
163            allow_non_admin_sub_group_creation: options.allow_non_admin_sub_group_creation,
164            create_general_chat: options.create_general_chat,
165            ..Default::default()
166        };
167
168        let mut metadata = self
169            .client
170            .groups()
171            .create_group(create_options)
172            .await?
173            .metadata;
174
175        if let Some(desc_text) = description
176            && let Ok(desc) = wacore::iq::groups::GroupDescription::new(&desc_text)
177        {
178            self.client
179                .groups()
180                // The group was just created, so nothing can have set a
181                // description ahead of us.
182                .set_description(&metadata.id, Some(desc), PreviousDescription::Absent)
183                .await?;
184            metadata.description = Some(desc_text);
185        }
186
187        Ok(CreateCommunityResult { metadata })
188    }
189
190    /// Create a subgroup already linked to a parent group.
191    pub async fn create_subgroup(
192        &self,
193        name: impl Into<String>,
194        participants: &[Jid],
195        parent_jid: impl Into<Jid>,
196    ) -> Result<CreateCommunityResult, CommunityError> {
197        let options = GroupCreateOptions {
198            subject: name.into(),
199            participants: participants
200                .iter()
201                .cloned()
202                .map(GroupParticipantOptions::new)
203                .collect(),
204            linked_parent: Some(parent_jid.into()),
205            ..Default::default()
206        };
207        let metadata = self.client.groups().create_group(options).await?.metadata;
208        Ok(CreateCommunityResult { metadata })
209    }
210
211    /// Deactivate (delete) a community. Subgroups are unlinked but not deleted.
212    pub async fn deactivate(&self, community_jid: impl Into<Jid>) -> Result<(), CommunityError> {
213        let community_jid = &community_jid.into();
214        self.client
215            .execute(DeleteCommunityIq::new(community_jid))
216            .await?;
217        Ok(())
218    }
219
220    /// Remove participants from the parent and all linked groups.
221    pub async fn remove_participants(
222        &self,
223        community_jid: impl Into<Jid>,
224        participants: &[Jid],
225    ) -> Result<Vec<ParticipantChangeResponse>, CommunityError> {
226        Ok(self
227            .client
228            .groups()
229            .remove_participants_including_linked_groups(community_jid, participants)
230            .await?)
231    }
232
233    /// Link existing groups as subgroups of a community.
234    pub async fn link_subgroups(
235        &self,
236        community_jid: impl Into<Jid>,
237        subgroup_jids: &[Jid],
238    ) -> Result<LinkSubgroupsResult, CommunityError> {
239        let community_jid = &community_jid.into();
240        let response = self
241            .client
242            .execute(LinkSubgroupsIq::new(community_jid, subgroup_jids))
243            .await?;
244
245        let mut linked_jids = Vec::with_capacity(response.groups.len());
246        let mut failed_groups = Vec::with_capacity(response.groups.len());
247
248        for group in response.groups {
249            if let Some(error) = group.error {
250                failed_groups.push((group.jid, error));
251            } else {
252                linked_jids.push(group.jid);
253            }
254        }
255
256        Ok(LinkSubgroupsResult {
257            linked_jids,
258            failed_groups,
259        })
260    }
261
262    /// Unlink subgroups from a community.
263    pub async fn unlink_subgroups(
264        &self,
265        community_jid: impl Into<Jid>,
266        subgroup_jids: &[Jid],
267        remove_orphan_members: bool,
268    ) -> Result<UnlinkSubgroupsResult, CommunityError> {
269        let community_jid = &community_jid.into();
270        let response = self
271            .client
272            .execute(UnlinkSubgroupsIq::new(
273                community_jid,
274                subgroup_jids,
275                remove_orphan_members,
276            ))
277            .await?;
278
279        let mut unlinked_jids = Vec::with_capacity(response.groups.len());
280        let mut failed_groups = Vec::with_capacity(response.groups.len());
281
282        for group in response.groups {
283            if let Some(error) = group.error {
284                failed_groups.push((group.jid, error));
285            } else {
286                unlinked_jids.push(group.jid);
287            }
288        }
289
290        Ok(UnlinkSubgroupsResult {
291            unlinked_jids,
292            failed_groups,
293        })
294    }
295
296    /// Fetch all subgroups of a community via MEX (GraphQL).
297    pub async fn get_subgroups(
298        &self,
299        community_jid: &Jid,
300    ) -> Result<Vec<CommunitySubgroup>, CommunityError> {
301        let response = self
302            .client
303            .mex()
304            .query(mex_request!(fetch_all_subgroups {
305                group_id: Some(community_jid.to_string()),
306                ..Default::default()
307            }))
308            .await?;
309
310        let data = response.data.ok_or_else(|| {
311            CommunityError::InvalidRequest("MEX response missing data field".into())
312        })?;
313
314        let group_query = &data["xwa2_group_query_by_id"];
315        let mut subgroups = Vec::new();
316
317        // Parse default subgroup
318        if let Some(default_sub) = group_query.get("default_sub_group")
319            && !default_sub.is_null()
320            && let Some(sg) = parse_subgroup_node(default_sub, true)
321        {
322            subgroups.push(sg);
323        }
324
325        // Parse regular subgroups
326        if let Some(sub_groups) = group_query.get("sub_groups")
327            && let Some(edges) = sub_groups.get("edges").and_then(|e| e.as_array())
328        {
329            for edge in edges {
330                if let Some(node) = edge.get("node")
331                    && let Some(sg) = parse_subgroup_node(node, false)
332                {
333                    subgroups.push(sg);
334                }
335            }
336        }
337
338        Ok(subgroups)
339    }
340
341    /// Fetch all parent groups the account currently participates in.
342    pub async fn get_participating(
343        &self,
344    ) -> Result<std::collections::HashMap<Jid, GroupMetadata>, CommunityError> {
345        let response = self.client.execute(CommunityParticipatingIq::new()).await?;
346        let mut result: std::collections::HashMap<Jid, GroupMetadata> = response
347            .groups
348            .into_iter()
349            .map(|community| {
350                let id = community.id.clone();
351                (id, GroupMetadata::from(community))
352            })
353            .collect();
354
355        for metadata in result.values_mut() {
356            self.client.groups().fill_participant_pns(metadata).await;
357        }
358
359        Ok(result)
360    }
361
362    /// Fetch participant counts per subgroup via MEX (GraphQL).
363    pub async fn get_subgroup_participant_counts(
364        &self,
365        community_jid: &Jid,
366    ) -> Result<Vec<(Jid, u32)>, CommunityError> {
367        let response = self
368            .client
369            .mex()
370            .query(mex_request!(query_subgroup_participant_count {
371                input: Some(query_subgroup_participant_count::Input {
372                    group_jid: Some(community_jid.to_string()),
373                    ..Default::default()
374                }),
375            }))
376            .await?;
377
378        let data = response.data.ok_or_else(|| {
379            CommunityError::InvalidRequest("MEX response missing data field".into())
380        })?;
381
382        let group_query = &data["xwa2_group_query_by_id"];
383        let edges_ref = group_query
384            .get("sub_groups")
385            .and_then(|s| s.get("edges"))
386            .and_then(|e| e.as_array());
387        let mut counts = Vec::with_capacity(edges_ref.map_or(0, |e| e.len()));
388
389        if let Some(edges) = edges_ref {
390            for edge in edges {
391                if let Some(node) = edge.get("node") {
392                    let id_str = node["id"].as_str().unwrap_or_default();
393                    let count = node
394                        .get("total_participants_count")
395                        .or_else(|| node.get("participants_count"))
396                        .and_then(|c| c.as_u64())
397                        .unwrap_or(0) as u32;
398                    match id_str.parse::<Jid>() {
399                        Ok(jid) => counts.push((jid, count)),
400                        Err(_) => warn!(
401                            "community: skipping subgroup with unparseable id: {:?}",
402                            id_str
403                        ),
404                    }
405                }
406            }
407        }
408
409        Ok(counts)
410    }
411
412    /// Query a linked subgroup's metadata from the parent community.
413    pub async fn query_linked_group(
414        &self,
415        community_jid: impl Into<Jid>,
416        subgroup_jid: impl Into<Jid>,
417    ) -> Result<GroupMetadata, CommunityError> {
418        let community_jid = &community_jid.into();
419        let subgroup_jid = &subgroup_jid.into();
420        let response = self
421            .client
422            .execute(QueryLinkedGroupIq::new(community_jid, subgroup_jid))
423            .await?;
424        Ok(GroupMetadata::from(response))
425    }
426
427    /// Join a linked subgroup via the parent community.
428    pub async fn join_subgroup(
429        &self,
430        community_jid: impl Into<Jid>,
431        subgroup_jid: impl Into<Jid>,
432    ) -> Result<GroupMetadata, CommunityError> {
433        let community_jid = &community_jid.into();
434        let subgroup_jid = &subgroup_jid.into();
435        let response = self
436            .client
437            .execute(JoinLinkedGroupIq::new(community_jid, subgroup_jid))
438            .await?;
439        Ok(GroupMetadata::from(response))
440    }
441
442    /// Get all participants across all linked groups of a community.
443    pub async fn get_linked_groups_participants(
444        &self,
445        community_jid: impl Into<Jid>,
446    ) -> Result<Vec<GroupParticipant>, CommunityError> {
447        let community_jid = &community_jid.into();
448        let response = self
449            .client
450            .execute(GetLinkedGroupsParticipantsIq::new(community_jid))
451            .await?;
452        Ok(response.into_iter().map(Into::into).collect())
453    }
454}
455
456fn json_u64(value: &serde_json::Value) -> Option<u64> {
457    value
458        .as_u64()
459        .or_else(|| value.as_str()?.parse::<u64>().ok())
460}
461
462fn json_jid(value: &serde_json::Value) -> Option<Jid> {
463    if let Some(value) = value.as_str() {
464        return value.parse().ok();
465    }
466
467    let object = value.as_object()?;
468    ["id", "lid", "pn"]
469        .into_iter()
470        .filter_map(|field| object.get(field)?.as_str())
471        .find_map(|value| value.parse().ok())
472}
473
474fn json_bool(value: &serde_json::Value) -> Option<bool> {
475    value.as_bool().or_else(|| match value.as_str()? {
476        "1" | "true" => Some(true),
477        "0" | "false" => Some(false),
478        _ => None,
479    })
480}
481
482fn parse_subgroup_node(node: &serde_json::Value, is_default: bool) -> Option<CommunitySubgroup> {
483    let id_str = node.get("id")?.as_str()?;
484    let jid: Jid = id_str.parse().ok()?;
485
486    // Subject can be a plain string or an object {"value": "..."}
487    let subject = node
488        .get("subject")
489        .and_then(|s| {
490            s.as_str().map(|v| v.to_string()).or_else(|| {
491                s.get("value")
492                    .and_then(|v| v.as_str())
493                    .map(|v| v.to_string())
494            })
495        })
496        .unwrap_or_default();
497
498    let participant_count = node
499        .get("participants_count")
500        .or_else(|| node.get("total_participants_count"))
501        .and_then(json_u64)
502        .and_then(|count| u32::try_from(count).ok());
503
504    let creation = node
505        .get("creation")
506        .or_else(|| node.get("creation_time"))
507        .and_then(json_u64)
508        .or_else(|| node.get("subject")?.get("creation_time").and_then(json_u64));
509    let owner = node
510        .get("creator")
511        .or_else(|| node.get("owner"))
512        .and_then(json_jid)
513        .or_else(|| node.get("subject")?.get("creator").and_then(json_jid));
514
515    // Check if properties indicate general chat
516    let is_general_from_props = node
517        .get("properties")
518        .and_then(|p| p.get("general_chat"))
519        .and_then(json_bool)
520        .unwrap_or(false);
521
522    Some(CommunitySubgroup {
523        id: jid,
524        subject,
525        participant_count,
526        creation,
527        owner,
528        is_default_sub_group: is_default,
529        is_general_chat: is_general_from_props,
530    })
531}
532
533impl Client {
534    pub fn community(&self) -> Community<'_> {
535        Community::new(self)
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn subgroup_parser_preserves_typed_metadata() {
545        let node = serde_json::json!({
546            "id": "120363000000000002@g.us",
547            "subject": {
548                "value": "Fictitious subgroup",
549                "creation_time": "1700000012"
550            },
551            "creator": {
552                "id": "100000000000002@lid",
553                "pn": "15550000002@s.whatsapp.net"
554            },
555            "total_participants_count": 42,
556            "properties": { "general_chat": "1" }
557        });
558
559        let subgroup = parse_subgroup_node(&node, false).expect("valid subgroup");
560        assert_eq!(subgroup.subject, "Fictitious subgroup");
561        assert_eq!(subgroup.creation, Some(1_700_000_012));
562        assert_eq!(subgroup.participant_count, Some(42));
563        assert_eq!(subgroup.owner, Some("100000000000002@lid".parse().unwrap()));
564        assert!(subgroup.is_general_chat);
565        assert!(!subgroup.is_default_sub_group);
566    }
567
568    #[test]
569    fn subgroup_parser_accepts_legacy_scalar_metadata() {
570        let node = serde_json::json!({
571            "id": "120363000000000003@g.us",
572            "subject": "Legacy subgroup",
573            "creation": 1700000024,
574            "owner": "15550000003@s.whatsapp.net",
575            "properties": { "general_chat": false }
576        });
577
578        let subgroup = parse_subgroup_node(&node, true).expect("valid subgroup");
579        assert_eq!(subgroup.creation, Some(1_700_000_024));
580        assert_eq!(
581            subgroup.owner,
582            Some("15550000003@s.whatsapp.net".parse().unwrap())
583        );
584        assert!(!subgroup.is_general_chat);
585        assert!(subgroup.is_default_sub_group);
586    }
587}